feat(server): ship browserless npm runtime

This commit is contained in:
Jinwoo-H
2026-08-12 22:43:30 -04:00
committed by Jinwoo-H
parent 150cc6f50c
commit f4cf8422f9
38 changed files with 2627 additions and 44 deletions
+31
View File
@@ -333,6 +333,34 @@ jobs:
- name: Smoke managed-hook companions
run: node config/scripts/smoke-managed-hook-runtime-node18.mjs
node_server_package:
name: node server package (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-2022]
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
- name: Build Node server package
run: pnpm run build:node-server
- name: Verify installed package and runtime
run: pnpm run verify:node-server-package
- name: Verify Ubuntu 20.04 and glibc 2.31 floor
if: runner.os == 'Linux'
run: pnpm run verify:node-server-docker -- --ubuntu 20.04
package:
name: package
runs-on: ubuntu-latest
@@ -501,6 +529,7 @@ jobs:
- shell_contracts
- test
- managed_hook_node18
- node_server_package
- package
- package_windows
runs-on: ubuntu-latest
@@ -523,6 +552,7 @@ jobs:
SHELL_CONTRACTS: ${{ needs.shell_contracts.result }}
TEST: ${{ needs.test.result }}
MANAGED_HOOK_NODE18: ${{ needs.managed_hook_node18.result }}
NODE_SERVER_PACKAGE: ${{ needs.node_server_package.result }}
PACKAGE: ${{ needs.package.result }}
PACKAGE_WINDOWS: ${{ needs.package_windows.result }}
run: |
@@ -534,6 +564,7 @@ jobs:
"$SHELL_CONTRACTS" \
"$TEST" \
"$MANAGED_HOOK_NODE18" \
"$NODE_SERVER_PACKAGE" \
"$PACKAGE" \
"$PACKAGE_WINDOWS"; do
if [ "$result" != "success" ]; then
+1
View File
@@ -100,6 +100,7 @@ docs/**
!docs/reference/git-compatibility.md
!docs/reference/headless-linux-server.md
!docs/reference/linux-glibc-compatibility.md
!docs/reference/npm-server-design.md
!docs/reference/relay-grace-time-reconfiguration.md
!docs/reference/remote-wire-compatibility.md
!docs/reference/renderer-agent-status-performance.md
+1 -1
View File
@@ -213,7 +213,7 @@ Works with **any CLI agent** — if it runs in a terminal, it runs in Orca.
- **[Download from onOrca.dev](https://onorca.dev/download)**
- Or grab a build directly: [macOS Apple Silicon](https://github.com/stablyai/orca/releases/latest/download/orca-macos-arm64.dmg) · [macOS Intel](https://github.com/stablyai/orca/releases/latest/download/orca-macos-x64.dmg) · [Windows (.exe)](https://github.com/stablyai/orca/releases/latest/download/orca-windows-setup.exe) · [Linux AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · [All builds](https://github.com/stablyai/orca/releases/latest)
- Running `orca serve` on a headless Linux server? See the [headless Linux server guide](docs/reference/headless-linux-server.md).
- Running Orca on a headless Linux server? Start with `npx @stablyai/orca@latest` and see the [headless Linux server guide](docs/reference/headless-linux-server.md).
_Or via a package manager:_
+32
View File
@@ -0,0 +1,32 @@
ARG UBUNTU_VERSION=20.04
FROM ubuntu:${UBUNTU_VERSION}
ARG NODE_VERSION=24.18.0
ARG TARGETARCH
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends binutils ca-certificates curl git xz-utils \
&& rm -rf /var/lib/apt/lists/*
RUN case "$TARGETARCH" in \
amd64) node_arch=x64 ;; \
arm64) node_arch=arm64 ;; \
*) echo "Unsupported architecture: $TARGETARCH" >&2; exit 1 ;; \
esac \
&& curl -fsSLO "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${node_arch}.tar.xz" \
&& tar -xJf "node-v${NODE_VERSION}-linux-${node_arch}.tar.xz" -C /usr/local --strip-components=1 \
&& rm "node-v${NODE_VERSION}-linux-${node_arch}.tar.xz" \
&& node --version \
&& npm --version
WORKDIR /opt/orca-test
COPY orca-server.tgz /opt/orca-test/orca-server.tgz
RUN npm init -y >/dev/null \
&& npm install ./orca-server.tgz --no-audit --no-fund \
&& ! command -v gcc \
&& ! command -v make
COPY verify-linux-glibc-floor.cjs runtime-verifier.cjs /opt/orca-test/
RUN node -e "const fs=require('node:fs'); const {ptyPath}=require('node-pty/lib/prebuild-file-path'); if(!ptyPath) throw new Error('active node-pty prebuild missing'); fs.mkdirSync('./active-native'); fs.copyFileSync(ptyPath,'./active-native/pty.node')" \
&& node -e "require('./verify-linux-glibc-floor.cjs').verifyLinuxGlibcFloor('./active-native')" \
&& node ./runtime-verifier.cjs --cli ./node_modules/@stablyai/orca/dist/cli.js
@@ -0,0 +1,32 @@
import { spawn } from 'node:child_process'
import { fileURLToPath } from 'node:url'
const buildScript = fileURLToPath(new URL('./run-electron-vite-build.mjs', import.meta.url))
const targetConfig = fileURLToPath(new URL('../electron-vite-target.config.ts', import.meta.url))
await run(process.execPath, [buildScript, '--config', targetConfig, '--ignoreConfigWarning'], {
ORCA_ELECTRON_VITE_TARGET: 'renderer'
})
await run(process.execPath, [
fileURLToPath(new URL('./project-renderer-web-client.mjs', import.meta.url))
])
await run(process.execPath, [fileURLToPath(new URL('./verify-web-build.mjs', import.meta.url))])
function run(command, args, env = {}) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
stdio: 'inherit',
env: { ...process.env, ...env }
})
child.on('error', reject)
child.on('exit', (code, signal) => {
if (signal) {
reject(new Error(`${command} exited with signal ${signal}`))
} else if (code !== 0) {
reject(new Error(`${command} exited with code ${code}`))
} else {
resolve()
}
})
})
}
+75
View File
@@ -0,0 +1,75 @@
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { build } from 'esbuild'
const repoRoot = resolve(import.meta.dirname, '..', '..')
const packageRoot = join(repoRoot, 'packages', 'orca-server')
const distRoot = join(packageRoot, 'dist')
const facadePath = join(repoRoot, 'src', 'node-server', 'node-host-electron-facade.ts')
const external = ['@parcel/watcher', 'node-pty', 'ssh2', 'tweetnacl', 'ws']
const common = {
absWorkingDir: repoRoot,
alias: { electron: facadePath, 'electron-updater': facadePath },
bundle: true,
define: {
ORCA_BUILD_IDENTITY: 'null',
ORCA_DIAGNOSTICS_TOKEN_URL: 'null',
ORCA_POSTHOG_WRITE_KEY: 'null'
},
external,
format: 'cjs',
logLevel: 'info',
mainFields: ['module', 'main'],
metafile: true,
platform: 'node',
sourcemap: false,
target: 'node24'
}
rmSync(distRoot, { force: true, recursive: true })
mkdirSync(distRoot, { recursive: true })
const serverResult = await build({
...common,
entryPoints: [join(repoRoot, 'src', 'node-server', 'index.ts')],
outfile: join(distRoot, 'cli.js')
})
const daemonResult = await build({
...common,
entryPoints: [join(repoRoot, 'src', 'main', 'daemon', 'daemon-entry.ts')],
outfile: join(distRoot, 'daemon-entry.js')
})
const webRoot = join(repoRoot, 'out', 'web')
if (!existsSync(join(webRoot, 'web-index.html'))) {
throw new Error('Missing out/web/web-index.html; run build:web-from-renderer first')
}
cpSync(webRoot, join(distRoot, 'web'), { recursive: true })
cpSync(join(repoRoot, 'LICENSE'), join(distRoot, 'LICENSE'))
verifyPlainNodeBundle(serverResult.metafile, 'server')
verifyPlainNodeBundle(daemonResult.metafile, 'daemon')
const packageJson = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8'))
const rootPackageJson = JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf8'))
if (packageJson.version !== rootPackageJson.version) {
throw new Error(
`Server package version ${packageJson.version} does not match root ${rootPackageJson.version}`
)
}
function verifyPlainNodeBundle(metafile, label) {
const forbiddenExternal = new Set(['electron', 'electron-updater'])
const forbiddenPackages = ['agent-browser', 'serve-sim', 'sherpa-onnx']
for (const [output, metadata] of Object.entries(metafile.outputs)) {
for (const imported of metadata.imports) {
if (forbiddenExternal.has(imported.path)) {
throw new Error(`${label} bundle ${output} imports forbidden runtime ${imported.path}`)
}
}
}
for (const input of Object.keys(metafile.inputs)) {
if (forbiddenPackages.some((name) => input.includes(`/node_modules/${name}/`))) {
throw new Error(`${label} bundle includes desktop-only package ${input}`)
}
}
}
@@ -0,0 +1,304 @@
import { execFileSync, spawn, type ChildProcess } from 'node:child_process'
import { existsSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { decodePairingOffer, type PairingOffer } from '../../src/shared/pairing'
import { sendRemoteRuntimeRequest } from '../../src/shared/remote-runtime-client'
import type { RuntimeStatus, RuntimeTerminalRead } from '../../src/shared/runtime-types'
type ReadyPayload = {
type: 'orca_server_ready'
schemaVersion: 1
runtimeId: string
boundEndpoint: string
pairing: {
available: true
url: string
webClientUrl: string
}
}
type RunningServer = { child: ChildProcess; ready: ReadyPayload; stderr: string[] }
const cliPath = resolve(readArgument('--cli') ?? 'packages/orca-server/dist/cli.js')
const shortTemporaryRoot = process.platform === 'win32' ? tmpdir() : '/tmp'
const ownedRoot = realpathSync(mkdtempSync(join(shortTemporaryRoot, 'orca-nsv-')))
const dataPath = join(ownedRoot, 'state')
const gitPath = join(ownedRoot, 'git-workspace')
const folderPath = join(ownedRoot, 'folder-workspace')
async function main(): Promise<void> {
let activeServer: RunningServer | null = null
try {
execFileSync('git', ['init', gitPath], { stdio: 'ignore' })
execFileSync(process.execPath, [
'-e',
`require('node:fs').mkdirSync(${JSON.stringify(folderPath)})`
])
const first = await startServer()
activeServer = first
const pairing = decodePairingOffer(first.ready.pairing.url)
await verifyWebClient(first.ready.pairing.webClientUrl)
await verifyStatus(pairing, first.ready.runtimeId)
await verifyBrowserUnavailable(pairing)
await verifyGitWorkspace(pairing)
const terminal = await verifyFolderWorkspaceAndTerminal(pairing)
await stopServer(first)
activeServer = null
const second = await startServer()
activeServer = second
assert(
second.ready.runtimeId !== first.ready.runtimeId,
'runtime process identity did not rotate'
)
const reconnectedPairing = { ...pairing, endpoint: second.ready.boundEndpoint }
await verifyStatus(reconnectedPairing, second.ready.runtimeId)
const restored = await call<{ terminals: { handle: string; ptyId?: string }[] }>(
reconnectedPairing,
'terminal.list',
{ worktree: terminal.workspaceSelector }
)
assert(terminal.ptyId, 'created terminal did not expose its daemon identity')
const reattached = restored.terminals.find((candidate) => candidate.ptyId === terminal.ptyId)
assert(reattached, 'terminal daemon session was not adopted')
await waitForTerminalMarker(reconnectedPairing, reattached.handle, terminal.marker)
await stopServer(second)
activeServer = null
process.stdout.write('Node server runtime verification passed\n')
} finally {
if (activeServer?.child.exitCode === null) {
await stopServer(activeServer).catch(() => activeServer?.child.kill('SIGKILL'))
}
stopOwnedDaemon()
rmSync(ownedRoot, { force: true, recursive: true })
}
}
void main().catch((error) => {
process.stderr.write(
`${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`
)
process.exitCode = 1
})
function readArgument(flag: string): string | undefined {
const index = process.argv.indexOf(flag)
return index === -1 ? undefined : process.argv[index + 1]
}
function startServer(): Promise<RunningServer> {
return new Promise((resolveStart, rejectStart) => {
const stderr: string[] = []
const child = spawn(
process.execPath,
[
cliPath,
'serve',
'--port',
'0',
'--listen',
'127.0.0.1',
'--pairing-address',
'127.0.0.1',
'--json'
],
{ env: { ...process.env, ORCA_SERVER_DATA_DIR: dataPath }, stdio: ['ignore', 'pipe', 'pipe'] }
)
let stdout = ''
const timeout = setTimeout(() => fail(new Error('server readiness timed out')), 30_000)
child.stderr?.setEncoding('utf8')
child.stderr?.on('data', (chunk: string) => stderr.push(chunk))
child.stdout?.setEncoding('utf8')
child.stdout?.on('data', (chunk: string) => {
stdout += chunk
const newline = stdout.indexOf('\n')
if (newline === -1) {
return
}
try {
const ready = JSON.parse(stdout.slice(0, newline)) as ReadyPayload
assert(ready.type === 'orca_server_ready', 'unexpected readiness type')
assert(ready.schemaVersion === 1, 'unexpected readiness schema')
assert(ready.pairing.available, 'pairing unavailable')
assert(new URL(ready.boundEndpoint).hostname === '127.0.0.1', 'listener is not loopback')
clearTimeout(timeout)
resolveStart({ child, ready, stderr })
} catch (error) {
fail(error)
}
})
child.once('error', fail)
child.once('exit', (code) =>
fail(new Error(`server exited before readiness (${code}): ${stderr.join('')}`))
)
function fail(error: unknown): void {
clearTimeout(timeout)
if (child.exitCode === null) {
child.kill('SIGTERM')
}
rejectStart(error)
}
})
}
async function stopServer(server: RunningServer): Promise<void> {
const { child } = server
if (child.exitCode !== null) {
throw new Error(`server exited unexpectedly (${child.exitCode})`)
}
child.kill('SIGTERM')
const result = await Promise.race([
new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolveExit) => {
child.once('exit', (code, signal) => resolveExit({ code, signal }))
}),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('server did not exit after SIGTERM')), 10_000)
)
])
if (result.code !== 0) {
throw new Error(
`server shutdown failed (${result.code ?? result.signal}): ${server.stderr.join('')}`
)
}
}
async function verifyWebClient(url: string): Promise<void> {
const response = await fetch(url)
assert(response.ok, `web client returned ${response.status}`)
assert((await response.text()).includes('<!doctype html>'), 'web client HTML was not served')
}
async function verifyStatus(pairing: PairingOffer, expectedRuntimeId: string): Promise<void> {
const status = await call<RuntimeStatus>(pairing, 'status.get')
assert(status.runtimeId === expectedRuntimeId, 'status returned the wrong runtime')
assert(status.desktopWindowStatus === 'blocked', 'desktop status is not blocked')
const capabilities = status.capabilities ?? []
for (const capability of [
'browser.screencast.v1',
'browser.headless.v1',
'browser.certificate-trust.v1'
]) {
assert(!capabilities.includes(capability as never), `browser capability leaked: ${capability}`)
}
}
async function verifyBrowserUnavailable(pairing: PairingOffer): Promise<void> {
try {
await call(pairing, 'browser.tabCreate', { url: 'about:blank' })
} catch (error) {
assert(
error instanceof Error && error.message.includes('does not support browser panes'),
'browser request did not fail with an unsupported-host error'
)
return
}
throw new Error('browser request unexpectedly succeeded')
}
async function verifyGitWorkspace(pairing: PairingOffer): Promise<void> {
const added = await call<{ repo: { id: string } }>(pairing, 'repo.add', {
path: gitPath,
kind: 'git'
})
const listed = await call<{ worktrees: { id: string; path: string }[] }>(
pairing,
'worktree.list',
{ repo: `id:${added.repo.id}` }
)
assert(
listed.worktrees.some((worktree) => worktree.path === gitPath),
'Git worktree unavailable'
)
}
async function verifyFolderWorkspaceAndTerminal(pairing: PairingOffer): Promise<{
handle: string
marker: string
ptyId: string | undefined
workspaceSelector: string
}> {
const group = await call<{ group: { id: string } }>(pairing, 'projectGroup.create', {
name: 'Package verification',
parentPath: folderPath,
createdFrom: 'manual'
})
const workspace = await call<{ folderWorkspace: { id: string } }>(
pairing,
'folderWorkspace.create',
{ projectGroupId: group.group.id, name: 'Folder workspace', folderPath }
)
const marker = `orca-node-server-${Date.now()}`
const workspaceSelector = `id:folder:${workspace.folderWorkspace.id}`
const created = await call<{ terminal: { handle: string; ptyId?: string } }>(
pairing,
'terminal.create',
{ worktree: workspaceSelector, command: `printf '${marker}\\n'` }
)
await waitForTerminalMarker(pairing, created.terminal.handle, marker)
return {
handle: created.terminal.handle,
marker,
ptyId: created.terminal.ptyId,
workspaceSelector
}
}
async function waitForTerminalMarker(
pairing: PairingOffer,
handle: string,
marker: string
): Promise<void> {
const deadline = Date.now() + 20_000
while (Date.now() < deadline) {
const result = await call<{ terminal: RuntimeTerminalRead }>(pairing, 'terminal.read', {
terminal: handle,
limit: 200
})
if (result.terminal.tail.join('\n').includes(marker)) {
return
}
await new Promise((resolveWait) => setTimeout(resolveWait, 100))
}
throw new Error('terminal marker was not observed')
}
async function call<TResult>(
pairing: PairingOffer,
method: string,
params?: unknown
): Promise<TResult> {
const response = await sendRemoteRuntimeRequest<TResult>(pairing, method, params, 15_000)
if (!response.ok) {
throw new Error(`${method} failed: ${response.error.message}`)
}
return response.result
}
function stopOwnedDaemon(): void {
const daemonPath = join(dataPath, 'daemon')
if (!existsSync(daemonPath)) {
return
}
for (const entry of readdirSync(daemonPath)) {
if (!/^daemon-v\d+\.pid$/.test(entry)) {
continue
}
try {
const record = JSON.parse(readFileSync(join(daemonPath, entry), 'utf8')) as { pid?: unknown }
if (typeof record.pid === 'number' && Number.isInteger(record.pid)) {
process.kill(record.pid, 'SIGTERM')
}
} catch {
// The isolated verifier directory can contain a daemon that already retired.
}
}
}
function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message)
}
}
@@ -134,6 +134,28 @@ describe('PR workflow parallelism', () => {
expect(node18Index).toBeLessThan(smokeIndex)
})
it('gates the Node server package on Linux and Windows', () => {
const job = workflow.jobs.node_server_package
expect(job.strategy.matrix.os).toEqual(['ubuntu-latest', 'windows-2022'])
expect(
job.steps.find((step) => step.uses === './.github/actions/install-node-dependencies').with[
'native-runtime'
]
).toBe('node')
expect(job.steps.find((step) => step.name === 'Build Node server package').run).toBe(
'pnpm run build:node-server'
)
expect(job.steps.find((step) => step.name === 'Verify installed package and runtime').run).toBe(
'pnpm run verify:node-server-package'
)
const dockerStep = job.steps.find(
(step) => step.name === 'Verify Ubuntu 20.04 and glibc 2.31 floor'
)
expect(dockerStep.if).toBe("runner.os == 'Linux'")
expect(dockerStep.run).toBe('pnpm run verify:node-server-docker -- --ubuntu 20.04')
})
it('restores the pnpm store before dependency installation', () => {
const steps = dependencyAction.runs.steps
const pnpmIndex = steps.findIndex((step) => step.name === 'Setup pnpm')
@@ -216,6 +238,7 @@ describe('PR workflow parallelism', () => {
'shell_contracts',
'test',
'managed_hook_node18',
'node_server_package',
'package',
'package_windows'
])
@@ -224,5 +247,7 @@ describe('PR workflow parallelism', () => {
)
expect(verifyStep.env.MANAGED_HOOK_NODE18).toBe('${{ needs.managed_hook_node18.result }}')
expect(verifyStep.run).toContain('"$MANAGED_HOOK_NODE18"')
expect(verifyStep.env.NODE_SERVER_PACKAGE).toBe('${{ needs.node_server_package.result }}')
expect(verifyStep.run).toContain('"$NODE_SERVER_PACKAGE"')
})
})
@@ -0,0 +1,39 @@
import { spawn } from 'node:child_process'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { build } from 'esbuild'
const temporaryRoot = mkdtempSync(join(tmpdir(), 'orca-runtime-verifier-build-'))
const verifierPath = join(temporaryRoot, 'verifier.cjs')
try {
await build({
absWorkingDir: resolve('.'),
bundle: true,
entryPoints: ['config/scripts/node-server-runtime-verifier.ts'],
format: 'cjs',
outfile: verifierPath,
platform: 'node',
target: 'node24'
})
await run(process.execPath, [verifierPath, ...process.argv.slice(2)])
} finally {
rmSync(temporaryRoot, { force: true, recursive: true })
}
function run(command, args) {
return new Promise((resolveRun, rejectRun) => {
const child = spawn(command, args, { stdio: 'inherit' })
child.once('error', rejectRun)
child.once('exit', (code, signal) => {
if (signal) {
rejectRun(new Error(`verifier exited with signal ${signal}`))
} else if (code !== 0) {
rejectRun(new Error(`verifier exited with code ${code}`))
} else {
resolveRun()
}
})
})
}
@@ -0,0 +1,106 @@
import { spawn } from 'node:child_process'
import { copyFileSync, mkdtempSync, rmSync, renameSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { build } from 'esbuild'
const repoRoot = resolve(import.meta.dirname, '..', '..')
const contextRoot = mkdtempSync(join(tmpdir(), 'orca-server-docker-'))
const versions = readVersions()
try {
const packOutput = await runCapture('npm', [
'pack',
join(repoRoot, 'packages', 'orca-server'),
'--json',
'--pack-destination',
contextRoot
])
const [packed] = JSON.parse(packOutput)
if (!packed?.filename) {
throw new Error('npm pack returned no tarball')
}
renameSync(join(contextRoot, packed.filename), join(contextRoot, 'orca-server.tgz'))
copyFileSync(
join(repoRoot, 'config', 'scripts', 'verify-linux-glibc-floor.cjs'),
join(contextRoot, 'verify-linux-glibc-floor.cjs')
)
await build({
absWorkingDir: repoRoot,
bundle: true,
entryPoints: ['config/scripts/node-server-runtime-verifier.ts'],
format: 'cjs',
outfile: join(contextRoot, 'runtime-verifier.cjs'),
platform: 'node',
target: 'node24'
})
for (const version of versions) {
process.stdout.write(`Verifying Orca server on Ubuntu ${version}\n`)
await run('docker', [
'build',
'--platform',
process.env.ORCA_SERVER_DOCKER_PLATFORM ?? 'linux/amd64',
'--build-arg',
`UBUNTU_VERSION=${version}`,
'--file',
join(repoRoot, 'config', 'docker', 'node-server', 'Dockerfile'),
'--progress',
'plain',
contextRoot
])
}
process.stdout.write(`Node server Docker verification passed: Ubuntu ${versions.join(', ')}\n`)
} finally {
rmSync(contextRoot, { force: true, recursive: true })
}
function readVersions() {
const index = process.argv.indexOf('--ubuntu')
if (index === -1) {
return ['20.04', '22.04', '24.04']
}
const value = process.argv[index + 1]
if (!value || !/^\d{2}\.\d{2}(?:,\d{2}\.\d{2})*$/.test(value)) {
throw new Error('--ubuntu requires a comma-separated version list')
}
return value.split(',')
}
function run(command, args) {
return new Promise((resolveRun, rejectRun) => {
const child = spawn(command, args, { stdio: 'inherit' })
child.once('error', rejectRun)
child.once('exit', (code, signal) => {
if (signal) {
rejectRun(new Error(`${command} exited with signal ${signal}`))
} else if (code !== 0) {
rejectRun(new Error(`${command} exited with code ${code}`))
} else {
resolveRun()
}
})
})
}
function runCapture(command, args) {
return new Promise((resolveRun, rejectRun) => {
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] })
let stdout = ''
let stderr = ''
child.stdout.setEncoding('utf8')
child.stderr.setEncoding('utf8')
child.stdout.on('data', (chunk) => (stdout += chunk))
child.stderr.on('data', (chunk) => (stderr += chunk))
child.once('error', rejectRun)
child.once('exit', (code, signal) => {
if (signal) {
rejectRun(new Error(`${command} exited with signal ${signal}`))
} else if (code !== 0) {
rejectRun(new Error(`${command} exited with code ${code}: ${stderr}`))
} else {
resolveRun(stdout)
}
})
})
}
@@ -0,0 +1,153 @@
import { spawn } from 'node:child_process'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
const repoRoot = resolve(import.meta.dirname, '..', '..')
const packageRoot = join(repoRoot, 'packages', 'orca-server')
const temporaryRoot = mkdtempSync(join(tmpdir(), 'orca-server-package-'))
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'
try {
const packOutput = await runCapture(npmCommand, [
'pack',
packageRoot,
'--json',
'--pack-destination',
temporaryRoot
])
const [packed] = JSON.parse(packOutput)
if (!packed || typeof packed.filename !== 'string') {
throw new Error('npm pack returned no tarball')
}
verifyInventory(packed)
const installRoot = join(temporaryRoot, 'install')
mkdirSync(installRoot)
writeFileSync(
join(installRoot, 'package.json'),
JSON.stringify({ private: true, name: 'orca-server-package-verifier', version: '0.0.0' })
)
await run(npmCommand, [
'install',
join(temporaryRoot, packed.filename),
'--no-audit',
'--no-fund',
'--prefix',
installRoot
])
const installedRoot = join(installRoot, 'node_modules', '@stablyai', 'orca')
const installedManifest = JSON.parse(readFileSync(join(installedRoot, 'package.json'), 'utf8'))
verifyManifest(installedManifest)
const cliPath = join(installedRoot, 'dist', 'cli.js')
const help = await runCapture(npmCommand, [
'--prefix',
installRoot,
'exec',
'--',
'orca',
'--help'
])
if (!help.includes('Usage: orca-ide')) {
throw new Error('installed CLI help is unavailable')
}
const version = (
await runCapture(npmCommand, ['--prefix', installRoot, 'exec', '--', 'orca-ide', '--version'])
).trim()
if (version !== installedManifest.version) {
throw new Error('installed CLI version is inconsistent')
}
await run(process.execPath, [
join(repoRoot, 'config', 'scripts', 'run-node-server-runtime-verifier.mjs'),
'--cli',
cliPath
])
process.stdout.write(
`Node server package verification passed (${packed.size} bytes packed, ${packed.unpackedSize} bytes unpacked)\n`
)
} finally {
rmSync(temporaryRoot, { force: true, recursive: true })
}
function verifyInventory(packed) {
const files = new Map(packed.files.map((file) => [file.path, file]))
for (const required of [
'LICENSE',
'README.md',
'package.json',
'dist/cli.js',
'dist/daemon-entry.js',
'dist/LICENSE',
'dist/web/web-index.html'
]) {
if (!files.has(required)) {
throw new Error(`tarball is missing ${required}`)
}
}
if ((files.get('dist/cli.js')?.mode & 0o111) === 0) {
throw new Error('CLI is not executable')
}
if (packed.unpackedSize > 100 * 1024 * 1024) {
throw new Error('tarball exceeds 100 MiB')
}
for (const path of files.keys()) {
if (/electron|agent-browser|serve-sim|sherpa-onnx|\.map$/i.test(path)) {
throw new Error(`tarball contains forbidden file: ${path}`)
}
}
}
function verifyManifest(manifest) {
if (manifest.name !== '@stablyai/orca') {
throw new Error('installed package name is inconsistent')
}
if (manifest.license !== 'MIT') {
throw new Error('installed package license is inconsistent')
}
if (manifest.bin?.orca !== 'dist/cli.js' || manifest.bin?.['orca-ide'] !== 'dist/cli.js') {
throw new Error('installed package bins are inconsistent')
}
const dependencies = Object.keys(manifest.dependencies ?? {})
if (dependencies.some((name) => name === 'electron' || name === 'electron-updater')) {
throw new Error('installed package depends on Electron')
}
}
function run(command, args) {
return new Promise((resolveRun, rejectRun) => {
const child = spawn(command, args, { stdio: 'inherit' })
child.once('error', rejectRun)
child.once('exit', (code, signal) => {
if (signal) {
rejectRun(new Error(`${command} exited with signal ${signal}`))
} else if (code !== 0) {
rejectRun(new Error(`${command} exited with code ${code}`))
} else {
resolveRun()
}
})
})
}
function runCapture(command, args) {
return new Promise((resolveRun, rejectRun) => {
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] })
let stdout = ''
let stderr = ''
child.stdout.setEncoding('utf8')
child.stderr.setEncoding('utf8')
child.stdout.on('data', (chunk) => (stdout += chunk))
child.stderr.on('data', (chunk) => (stderr += chunk))
child.once('error', rejectRun)
child.once('exit', (code, signal) => {
if (signal) {
rejectRun(new Error(`${command} exited with signal ${signal}`))
} else if (code !== 0) {
rejectRun(new Error(`${command} exited with code ${code}: ${stderr}`))
} else {
resolveRun(stdout)
}
})
})
}
+1
View File
@@ -4,6 +4,7 @@
"../electron.vite.config.*",
"./build-plugins/**/*",
"../src/main/**/*",
"../src/node-server/**/*",
"../src/renderer/src/lib/skill-freshness-display-status.ts",
"../src/preload/**/*",
"../src/shared/**/*",
+46 -9
View File
@@ -1,21 +1,58 @@
# Headless Linux Server
Use this guide when you want to run `orca serve` on a Linux machine without a
desktop session, such as an Ubuntu VPS or a remote build box.
Use this guide to run Orca on an Ubuntu VPS or remote build computer.
`orca serve` starts the Orca runtime without opening the desktop window. On
Linux, the packaged AppImage still needs the libraries that Electron expects at
startup. Current Orca builds start Xvfb automatically for `orca serve` when no
`DISPLAY` is set, but Xvfb must be installed first. A separate D-Bus session is
not required. When `DISPLAY` is set, Orca uses that display instead of starting
a competing Xvfb process.
## Browserless Node server (recommended)
Install Node.js 24, then run:
```bash
npx @stablyai/orca@latest
```
This package contains the Orca runtime and web client, but not Electron or Chromium. It needs no
desktop session, Xvfb, FUSE, or AppImage extraction. Browser panes, computer-use, emulators, and
speech are unavailable on this host; workspaces, files, Git, terminals, agents, orchestration,
pairing, and the web client remain available.
At startup Orca uses a Tailscale IPv4 address when one is available. Otherwise it binds to
localhost and, when started over SSH, prints a tunnel command using the actual selected port. It
never changes Tailscale or firewall configuration.
For automation, provide the listener and advertised address explicitly:
```bash
npx @stablyai/orca@latest serve \
--listen 100.64.1.20 \
--pairing-address 100.64.1.20 \
--port 6768 \
--json
```
`--listen` selects the local interface. `--pairing-address` selects the address embedded in access
links. Supplying only `--pairing-address` is an explicit network-exposure choice and listens on all
interfaces; use both flags when the server should be reachable only through one interface.
The package stores state in `$XDG_STATE_HOME/orca` or `~/.local/state/orca`. Override it with
`--data-dir` or `ORCA_SERVER_DATA_DIR`. Pairing identity and daemon terminals survive server
restarts. Stop the foreground server with `Ctrl+C`.
For the package architecture, security policy, capability behavior, and validation topology, see
the [npm server design](./npm-server-design.md).
## Desktop/AppImage server (browser-capable)
Use the AppImage path only when the remote host must provide Orca browser panes. `orca serve`
starts the desktop runtime without opening its window, but Electron still needs Linux desktop
libraries. Current builds start Xvfb automatically when no `DISPLAY` is set, but Xvfb must be
installed first. A separate D-Bus session is not required.
The supported deployment matrix covers Ubuntu 20.04, 22.04, and 24.04 and
current Debian stable — anything with glibc 2.31 or newer (see
[Linux glibc compatibility](./linux-glibc-compatibility.md)). Package names can
differ on other Debian-derived releases.
## Ubuntu and Debian prerequisites
### Ubuntu and Debian prerequisites
Install the AppImage runtime dependency and Xvfb:
+292
View File
@@ -0,0 +1,292 @@
# npm server design
## Summary
Orca should run as a remote host on a machine with Node.js without installing the Electron
desktop application. The npm distribution reuses the desktop host runtime, pairing protocol,
terminal daemon, and web client while omitting Electron-only capabilities such as browser panes.
The first-run contract is:
```bash
npx @stablyai/orca@latest
```
On a headless machine this starts a foreground server, chooses a safe reachable address, and
prints a web-client URL and pairing URL. A non-interactive `serve` command retains explicit
listener, advertised-address, port, and JSON controls.
## Problem
`orca serve` currently launches the packaged Electron executable. Linux hosts therefore need the
full AppImage, Electron libraries, FUSE or AppImage extraction, Chromium sandbox support, and
Xvfb. Pairing also requires operators to understand the difference between the bound listener and
the address embedded in an access link.
The remote runtime already owns the product behavior needed by a server: E2EE pairing, device
credentials, WebSocket transport, the web client, terminal persistence, workspaces, files, Git,
agents, and orchestration. The missing boundary is the process environment around that runtime.
## Goals
- Install and run the host through npm/npx without Electron, Chromium, Xvfb, or FUSE.
- Preserve the existing RPC, E2EE pairing, readiness, and persisted-state contracts.
- Keep one implementation of workspace, terminal, Git, file, agent, and orchestration behavior.
- Make zero-argument startup useful over SSH without exposing a public interface by accident.
- Keep desktop `orca serve` working during migration.
- Support folder workspaces and Git worktrees.
- Preserve macOS, Linux, Windows, SSH, WSL, Git 2.25, and glibc 2.31 compatibility.
## Non-goals
- Hosting browser, emulator, computer-use, speech, or renderer-only features in the initial npm
package.
- Replacing the remote wire protocol.
- Making a client-local fallback browser part of the remote host or giving it remote ownership.
- Automatically configuring a VPN, firewall, reverse proxy, or public network listener.
- Shipping an Electron binary inside an npm wrapper.
## User experience
### Guided foreground start
```bash
npx @stablyai/orca@latest
```
Startup chooses an advertised address in this order:
1. an explicit operator value;
2. a detected Tailscale IPv4 address;
3. loopback with a copyable SSH-tunnel command.
LAN exposure remains explicit. Public addresses require an explicit advertised address. Discovery
does not modify Tailscale or firewall configuration.
Human output distinguishes the two network roles:
```text
Orca server ready
Bound endpoint: ws://127.0.0.1:6768
Advertised endpoint: ws://127.0.0.1:6768
Web client URL: http://127.0.0.1:6768/web-index.html#pairing=...
Pairing URL: orca://pair?code=...
```
### Automation
```bash
orca-ide serve --port 6768 --pairing-address 100.64.1.20 --json
```
Existing serve flags and readiness schema version 1 remain valid. New fields must be optional.
### Package and executable names
The public package is `@stablyai/orca`. The durable executable is `orca-ide`, avoiding conflicts
with the Linux screen reader. The package also exposes `orca` so npm can select the expected bin
for `npx @stablyai/orca`.
## Architecture
```text
Electron entry ----+
+--> shared host runtime --> RPC / pairing / files / Git / PTY
Node server entry -+
| |
+--> Node process environment +--> optional capabilities
paths, version, lifecycle Electron browser backend
networking, notifications browserless Node backend
```
The Node distribution has three build outputs:
- the server CLI and composition root;
- the terminal daemon entry;
- the static web client.
Native packages remain npm dependencies so they target the host Node ABI. The npm package must not
depend on or contain Electron, Electron Updater, browser tooling, emulator tooling, or speech
models.
### Transitional process boundary
Some shared modules still import Electron-owned process primitives. The Node build resolves those
imports to a narrow process-environment facade. Every exposed member has either a Node
implementation or a fail-closed unsupported result. Browser and renderer behavior is never
emulated. The facade is a migration boundary; new host-runtime code must depend on concrete host
interfaces rather than adding facade surface.
CI inspects the built package, dependency manifest, and bundle metadata so an Electron runtime
dependency cannot enter transitively.
## Capability behavior
The Node host publishes the existing runtime capabilities except those requiring a browser
backend. In particular it omits:
- `browser.screencast.v1`;
- `browser.headless.v1`;
- `browser.certificate-trust.v1`.
Clients must treat the missing capability as unsupported on that host. A remote browser request
must not fall back to the viewing computer.
No protocol version bump is required. The server uses existing message shapes and optional
capabilities. Mixed-version testing covers an old client with the Node host and a new client with
the Electron host.
## Storage and security
The server stores state beneath an OS-standard Orca data directory. The directory is created with
owner-only permissions where the platform supports POSIX modes. Pairing identity and device files
reuse the current secure-file handling and survive package updates.
Foreground startup prints a pairing credential unless `--no-pairing` is set. Automation and
service workflows that retain logs must either use that flag or treat readiness output as a secret.
Pairing material stays in URL fragments for the web client so reverse proxies and referrer headers
do not receive it.
The default listener is loopback unless the operator selects a private reachable interface. A
detected Tailscale address is private but is not proof of authorization; E2EE pairing remains
mandatory.
## Native dependency policy
The supported package must install on Linux x64 and arm64 without a compiler. `node-pty` and file
watcher binaries are checked in clean containers. Linux binaries must retain the Ubuntu 20.04 /
glibc 2.31 floor. macOS and Windows receive package smoke tests before release support is claimed.
The initial Node engine remains aligned with the repository. Supporting an older active LTS can be
considered only after the server build and native dependency matrix pass there.
## Shutdown and persistence
SIGINT and SIGTERM stop listeners, flush the store, and detach from the terminal daemon. A normal
server shutdown does not kill daemon-owned terminals. Startup reconnects to the same daemon and
device identity.
Every listener, socket, timer, and signal handler installed by the Node composition root has a
matching cleanup path. Startup failure runs the same cleanup before exiting nonzero.
## Packaging gates
The publishable tarball must:
- contain the server, daemon, web client, license, README, and package manifest;
- exclude Electron, desktop assets, source maps with local paths, test fixtures, and secrets;
- have no runtime dependency on `electron` or `electron-updater`;
- expose executable `orca` and `orca-ide` bins;
- report the exact package version in runtime status;
- pass `npm pack --dry-run` inventory and size assertions.
## Validation topology
The deterministic integration oracle is:
1. start the built Node server with isolated state and port `0`;
2. parse readiness schema version 1;
3. prove the web client is served;
4. decode the pairing offer and authenticate over the real WebSocket transport;
5. call `status.get` and assert browser capabilities are absent;
6. add and open a folder workspace;
7. spawn a terminal, send a unique marker, and observe it through the paired stream;
8. restart the server and prove device identity and terminal continuity;
9. stop the server and prove no listener or child-process leak.
Docker runs the same package on Ubuntu 20.04, 22.04, and 24.04 where images and Node availability
permit. The test records host runtime identity, daemon PID, connection identity, PTY identity, and
the terminal marker.
## Rollout
1. Publish the package under a release-candidate tag.
2. Keep desktop `orca serve` as the documented stable path while collecting package telemetry and
install failures.
3. Promote npm/npx to the primary headless documentation after the Linux matrix is green.
4. Add service installation and readiness-based immutable updates.
5. Add desktop-managed SSH bootstrap using the same package.
6. Consider an opt-in browser sidecar only after the browserless server is stable.
## Implementation checklist
### Design and boundaries
- [x] Record goals, non-goals, security defaults, wire policy, and validation topology.
- [x] Add the Node server composition root.
- [x] Add the Node process-environment facade.
- [x] Keep the desktop composition root behavior unchanged.
- [x] Prohibit Electron runtime dependencies in the server build.
### Runtime
- [x] Initialize persistent state in an isolated server data directory.
- [x] Initialize or adopt the durable terminal daemon.
- [x] Register the headless PTY controller.
- [x] Start local and WebSocket RPC transports.
- [x] Serve the static web client.
- [x] Publish readiness schema version 1.
- [x] Generate runtime-scoped E2EE pairing offers.
- [x] Omit browser capabilities and fail remote browser requests explicitly.
- [x] Flush state and detach cleanly on signals and startup errors.
### CLI and networking
- [x] Support zero-argument guided startup.
- [x] Retain `serve`, `--port`, `--pairing-address`, `--no-pairing`, and `--json`.
- [x] Detect Tailscale without changing its configuration.
- [x] Default to loopback and print an SSH-tunnel path when no private address is selected.
- [x] Validate ports and advertised addresses before starting.
- [x] Support credential-free automation with `--no-pairing`.
### npm package
- [x] Add `@stablyai/orca` package metadata and both executable names.
- [x] Build the server and daemon as plain Node outputs.
- [x] Include the built web client.
- [x] Install native dependencies for the Node ABI.
- [x] Add tarball inventory, size, executable, license, and dependency gates.
- [x] Verify the tarball contains no Electron runtime.
### Product onboarding
- [x] Add the npm command to the remote-host connection UI.
- [x] Explain Tailscale, SSH-tunnel, and explicit LAN choices.
- [x] Link the shorter npm-server documentation from the existing Linux guide.
- [x] Capture visual proof for the UI change.
### Tests
- [x] Unit-test CLI parsing, address discovery, paths, readiness, and shutdown.
- [x] Test the built package with the real WebSocket/E2EE transport.
- [x] Test folder workspaces and Git worktrees.
- [x] Test terminal marker delivery and daemon continuity across restart.
- [x] Run the existing cross-version wire regression and verify no wire shape or opcode changed.
- [x] Test missing browser capabilities, explicit rejection, and no local fallback in remote RPCs.
- [x] Run clean Docker tests on Ubuntu 20.04, 22.04, and 24.04 amd64 and 20.04 arm64.
- [x] Verify the glibc 2.31 floor and Node native ABI.
- [ ] Run a Windows package smoke test; macOS package verification passed locally.
### Release readiness
- [x] Run typecheck, lint, focused tests, and the full relevant test suite.
- [x] Audit network failure, retry, cleanup, and bounded resource behavior.
- [x] Audit security, package contents, scripts, and dependency changes.
- [x] Audit persisted-state and mobile/old-client compatibility.
- [x] Audit macOS, Linux, Windows, WSL, SSH, and path behavior; retain runtime gaps below.
- [ ] Attach package/Docker evidence and UI visual proof to the PR.
- [ ] Complete the PR description with validation commands and remaining platform gaps.
## Validation record and residual gaps
The publishable tarball passed its inventory, executable, license, dependency, web-client, real
E2EE, workspace, Git, PTY, shutdown, and restart-continuity oracle on macOS. Docker passed the same
installed tarball and runtime oracle on Ubuntu 20.04, 22.04, and 24.04 amd64 plus Ubuntu 20.04
arm64. The Ubuntu 20.04 runs used stock Git 2.25.1 and verified the active native PTY against glibc
2.31 without a compiler, Electron, Chromium, Xvfb, or FUSE installed.
Windows and WSL package smoke tests remain release-CI gaps. The code uses Node path/process APIs and
the PTY dependency contains Windows x64 and arm64 prebuilds, but support should not be promoted
without a real Windows install and PTY run. The PTY package also retains a deprecated transitive
`prebuild-install` fallback; clean installs select the bundled prebuild before that downloader, but
the dependency should be replaced when an equally portable maintained package is available.
+6
View File
@@ -53,6 +53,10 @@
"dev-stable-name": "pnpm run ensure:electron-runtime && node config/scripts/run-electron-vite-dev.mjs --stable-name",
"dev:web": "vite --config vite.web.config.ts --host 127.0.0.1",
"build:relay": "node config/scripts/build-relay.mjs",
"build:node-server": "node config/scripts/build-node-server-web-client.mjs && node config/scripts/build-node-server.mjs",
"verify:node-server-runtime": "node config/scripts/run-node-server-runtime-verifier.mjs",
"verify:node-server-package": "node config/scripts/verify-node-server-package.mjs",
"verify:node-server-docker": "node config/scripts/verify-node-server-docker.mjs",
"build:computer-macos": "node config/scripts/build-computer-macos.mjs",
"build:notification-status-macos": "node config/scripts/build-notification-status-macos.mjs",
"build:native": "node config/scripts/build-native-for-platform.mjs",
@@ -205,6 +209,7 @@
"electron-vite": "^5.0.0",
"emoji-picker-react": "^4.19.1",
"emojibase-data": "17.0.0",
"esbuild": "0.27.7",
"happy-dom": "^20.9.0",
"html-to-image": "^1.11.13",
"husky": "^9.1.7",
@@ -293,6 +298,7 @@
]
},
"onlyBuiltDependencies": [
"@homebridge/node-pty-prebuilt-multiarch",
"@parcel/watcher",
"cpu-features",
"esbuild",
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Lovecast Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+16
View File
@@ -0,0 +1,16 @@
# Orca Server
Run Orca on a development server without installing the Electron desktop application:
```bash
npx @stablyai/orca@latest
```
The command starts a foreground browserless host and prints access links for Orca desktop and web
clients. It defaults to Tailscale when available and otherwise stays on loopback for an SSH tunnel.
```bash
npx @stablyai/orca@latest serve --help
```
Browser panes, computer-use, emulators, and speech are not available in the browserless package.
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@stablyai/orca",
"version": "1.4.178-rc.2",
"description": "Browserless Orca host for remote desktop and web clients",
"homepage": "https://github.com/stablyai/orca",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/stablyai/orca.git"
},
"bin": {
"orca": "dist/cli.js",
"orca-ide": "dist/cli.js"
},
"files": [
"dist/",
"README.md"
],
"publishConfig": {
"access": "public"
},
"dependencies": {
"@parcel/watcher": "2.5.6",
"node-pty": "npm:@homebridge/node-pty-prebuilt-multiarch@0.14.1",
"ssh2": "1.17.0",
"tweetnacl": "1.0.3",
"ws": "8.21.0"
},
"engines": {
"node": "24"
}
}
+485 -17
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -2,4 +2,5 @@
# with its own pnpm-workspace.yaml and lockfile, so keep it out of the root
# package graph. Without this file `pnpm -r` auto-discovers mobile/ and its
# nested package, and the root's patchedDependencies fail as ERR_PNPM_UNUSED_PATCH.
packages: []
packages:
- packages/orca-server
+23
View File
@@ -5638,6 +5638,29 @@ describe('OrcaRuntimeRpcServer WebSocket bind host (STA-2370)', () => {
}
})
it('binds only the standalone host interface when wsBindHost is set', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const server = new OrcaRuntimeRpcServer({
runtime: new OrcaRuntimeService(),
userDataPath,
enableWebSocket: true,
wsPort: 0,
wsBindHost: '127.0.0.1'
})
await server.start()
try {
expect(wsTransportOf(server)?.resolvedHost).toBe('127.0.0.1')
await server.createMobilePairingOffer({
address: '100.64.1.20',
connectionMode: 'local-only'
})
expect(wsTransportOf(server)?.resolvedHost).toBe('127.0.0.1')
} finally {
await server.stop()
}
})
it('binds all interfaces at startup when a previously-connected device can reconnect', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
// Why: a device that has actually connected (lastSeenAt > 0) may reconnect, so the listener must be
+9
View File
@@ -72,6 +72,8 @@ type OrcaRuntimeRpcServerOptions = {
// Why: STA-2370 — bind the WS listener to all interfaces at startup instead of loopback-until-paired.
// Only `orca serve` (explicit remote opt-in) and E2E set this; the desktop app widens lazily on pairing.
exposeNetworkByDefault?: boolean
// Why: standalone hosts can bind only their VPN interface instead of widening every interface.
wsBindHost?: string
webClientRoot?: string
// Why: test-only overrides for the two constants below; production must not pass these (defaults set by §3.1).
keepaliveIntervalMs?: number
@@ -486,6 +488,7 @@ export class OrcaRuntimeRpcServer {
private readonly wsPort: number
private readonly preferPinnedWsPort: boolean
private readonly exposeNetworkByDefault: boolean
private readonly wsBindHost: string | undefined
private readonly webClientRoot: string | undefined
// Why: STA-2370 — the host the WS listener is currently bound to, so pairing can widen loopback→all-interfaces once.
private wsBoundHost: string | null = null
@@ -544,6 +547,7 @@ export class OrcaRuntimeRpcServer {
wsPort = DEFAULT_WS_PORT,
preferPinnedWsPort = false,
exposeNetworkByDefault = false,
wsBindHost,
webClientRoot,
keepaliveIntervalMs = KEEPALIVE_INTERVAL_MS,
longPollCap = LONG_POLL_CAP,
@@ -558,6 +562,7 @@ export class OrcaRuntimeRpcServer {
this.wsPort = wsPort
this.preferPinnedWsPort = preferPinnedWsPort
this.exposeNetworkByDefault = exposeNetworkByDefault
this.wsBindHost = wsBindHost
this.webClientRoot = webClientRoot
this.keepaliveIntervalMs = keepaliveIntervalMs
this.longPollCap = longPollCap
@@ -1242,6 +1247,9 @@ export class OrcaRuntimeRpcServer {
// A grant minted for "This computer only" is excluded: its client is a browser on this machine, so
// counting it would republish the runtime on every interface one restart after the user declined that.
private resolveInitialWebSocketBindHost(): string {
if (this.wsBindHost) {
return this.wsBindHost
}
if (this.exposeNetworkByDefault) {
return WS_BIND_HOST_ALL_INTERFACES
}
@@ -1363,6 +1371,7 @@ export class OrcaRuntimeRpcServer {
if (
!this.enableWebSocket ||
this.stopping ||
this.wsBindHost !== undefined ||
this.wsBoundHost === WS_BIND_HOST_ALL_INTERFACES
) {
return
+155
View File
@@ -0,0 +1,155 @@
#!/usr/bin/env node
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import packageJson from '../../package.json' with { type: 'json' }
import {
disconnectDaemon,
getDaemonProvider,
initDaemonPtyProvider
} from '../main/daemon/daemon-init'
import { getLocalPtyProvider, registerHeadlessPtyRuntime } from '../main/ipc/pty'
import { initDataPath, Store } from '../main/persistence'
import { OrcaRuntimeService } from '../main/runtime/orca-runtime'
import { resolveAdvertisedPairingEndpoint } from '../main/runtime/pairing-endpoint'
import { OrcaRuntimeRpcServer } from '../main/runtime/runtime-rpc'
import { ServeReadinessPublisher } from '../main/server/serve-readiness'
import { HEADLESS_RUNTIME_WINDOW_ID } from '../shared/runtime-types'
import { configureNodeHostEnvironment } from './node-host-electron-facade'
import { discoverServerPairingAddress } from './server-address-discovery'
import { parseServerCliArguments, renderServerHelp } from './server-cli-arguments'
import { ensureServerDataPath, resolveServerDataPath } from './server-paths'
import { createServerWindowGraph } from './server-window-graph'
async function main(): Promise<void> {
const args = parseServerCliArguments(process.argv.slice(2))
if (args.command === 'help') {
process.stdout.write(`${renderServerHelp()}\n`)
return
}
if (args.command === 'version') {
process.stdout.write(`${packageJson.version}\n`)
return
}
const appPath = __dirname
const dataPath = resolveServerDataPath(args.dataPath)
ensureServerDataPath(dataPath)
configureNodeHostEnvironment({ appPath, dataPath, version: packageJson.version })
initDataPath()
const store = new Store({ dataFile: join(dataPath, 'orca-data.json') })
const runtime = new OrcaRuntimeService(store, undefined, {
canRecoverPersistentLocalPtys: () => getDaemonProvider() !== null,
getDesktopWindowStatus: () => 'blocked',
getLocalProvider: () => getLocalPtyProvider()
})
let rpc: OrcaRuntimeRpcServer | null = null
let shuttingDown = false
const shutdown = async (exitCode: number): Promise<void> => {
if (shuttingDown) {
return
}
shuttingDown = true
await Promise.allSettled([
rpc?.stop() ?? Promise.resolve(),
disconnectDaemon(),
store.flushAsync()
])
process.exitCode = exitCode
}
try {
await initDaemonPtyProvider(undefined, { macosLoginSessionWatch: false })
registerHeadlessPtyRuntime(runtime, undefined, () => store.getSettings(), undefined, store)
runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, createServerWindowGraph(store))
const pairingAddress = await discoverServerPairingAddress(args.pairingAddress)
validatePairingAddress(pairingAddress.address)
rpc = new OrcaRuntimeRpcServer({
runtime,
userDataPath: dataPath,
enableWebSocket: true,
preferPinnedWsPort: args.port !== 0,
wsBindHost:
args.listenHost ??
(pairingAddress.source === 'tailscale'
? pairingAddress.address
: pairingAddress.source === 'explicit'
? '0.0.0.0'
: '127.0.0.1'),
wsPort: args.port,
webClientRoot: resolveWebClientRoot(appPath)
})
await rpc.start()
await publishReadiness(runtime, rpc, args, pairingAddress.address)
if (!args.json && pairingAddress.source === 'loopback' && process.env.SSH_CONNECTION) {
const resolvedPort = new URL(rpc.getWebSocketEndpoint()!).port
process.stdout.write(
`SSH tunnel: ssh -L ${resolvedPort}:127.0.0.1:${resolvedPort} ${process.env.USER ?? 'user'}@<server>\n`
)
}
process.once('SIGINT', () => void shutdown(0).then(() => process.exit(0)))
process.once('SIGTERM', () => void shutdown(0).then(() => process.exit(0)))
} catch (error) {
await shutdown(1)
throw error
}
}
function validatePairingAddress(address: string): void {
const result = resolveAdvertisedPairingEndpoint('ws://127.0.0.1:6768', address)
if (!result.ok) {
throw new Error(result.guidance)
}
}
function resolveWebClientRoot(appPath: string): string | undefined {
const root = join(appPath, 'web')
return existsSync(join(root, 'web-index.html')) ? root : undefined
}
async function publishReadiness(
runtime: OrcaRuntimeService,
rpc: OrcaRuntimeRpcServer,
args: ReturnType<typeof parseServerCliArguments>,
pairingAddress: string
): Promise<void> {
const boundEndpoint = rpc.getWebSocketEndpoint()
const advertised = boundEndpoint
? resolveAdvertisedPairingEndpoint(boundEndpoint, pairingAddress)
: null
const pairing = args.noPairing
? {
available: false as const,
reason: 'disabled_by_operator' as const,
guidance: 'Restart without --no-pairing to create a client pairing offer.'
}
: rpc.createPairingOffer({ address: pairingAddress, name: 'Orca npm server', scope: 'runtime' })
await new ServeReadinessPublisher().publish(
{
runtimeId: runtime.getRuntimeId(),
boundEndpoint,
advertisedEndpoint: advertised?.ok ? advertised.endpoint : null,
managedWslCliReconciliation: 'settled',
pairing: pairing.available
? {
available: true,
url: pairing.pairingUrl,
endpoint: pairing.endpoint,
deviceId: pairing.deviceId,
webClientUrl: pairing.webClientUrl,
scope: 'runtime',
qr: null
}
: pairing
},
{ mode: args.json ? 'json' : 'human' }
)
}
void main().catch((error) => {
process.stderr.write(
`Orca server failed: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`
)
process.exitCode = 1
})
@@ -0,0 +1,256 @@
import { EventEmitter } from 'node:events'
import { homedir, tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
type NodeHostEnvironment = {
appPath: string
dataPath: string
version: string
}
let environment: NodeHostEnvironment = {
appPath: dirname(process.argv[1] ?? process.cwd()),
dataPath: join(homedir(), '.orca-server'),
version: '0.0.0'
}
export function configureNodeHostEnvironment(next: NodeHostEnvironment): void {
environment = next
}
class NodeHostApp extends EventEmitter {
readonly isPackaged = false
readonly commandLine = {
appendSwitch: () => {},
hasSwitch: () => false,
getSwitchValue: () => ''
}
readonly dock = {
badge: '',
setBadge: () => {},
setIcon: () => {},
show: async () => {},
hide: async () => {}
}
getAppPath(): string {
return environment.appPath
}
getName(): string {
return 'Orca Server'
}
getPath(name: string): string {
if (name === 'home') {
return homedir()
}
if (name === 'temp') {
return tmpdir()
}
if (name === 'logs') {
return join(environment.dataPath, 'logs')
}
return environment.dataPath
}
getVersion(): string {
return environment.version
}
setName(): void {}
setPath(): void {}
requestSingleInstanceLock(): boolean {
return true
}
releaseSingleInstanceLock(): void {}
disableHardwareAcceleration(): void {}
setAppUserModelId(): void {}
setAsDefaultProtocolClient(): boolean {
return false
}
removeAsDefaultProtocolClient(): boolean {
return false
}
isDefaultProtocolClient(): boolean {
return false
}
whenReady(): Promise<void> {
return Promise.resolve()
}
quit(): void {
this.emit('before-quit', { preventDefault: () => {} })
this.emit('will-quit', { preventDefault: () => {} })
}
exit(code = 0): never {
process.exit(code)
}
}
class NodeHostIpcMain extends EventEmitter {
private readonly handlers = new Map<string, (...args: unknown[]) => unknown>()
handle(channel: string, handler: (...args: unknown[]) => unknown): void {
this.handlers.set(channel, handler)
}
handleOnce(channel: string, handler: (...args: unknown[]) => unknown): void {
this.handlers.set(channel, handler)
}
removeHandler(channel: string): void {
this.handlers.delete(channel)
}
}
export const app = new NodeHostApp()
export const ipcMain = new NodeHostIpcMain()
export const powerMonitor = new EventEmitter()
export const nativeTheme = { themeSource: 'system', shouldUseDarkColors: false }
export const net = { fetch: (...args: Parameters<typeof fetch>) => fetch(...args) }
export class BrowserWindow extends EventEmitter {
static fromId(): null {
return null
}
static fromWebContents(): null {
return null
}
static getAllWindows(): BrowserWindow[] {
return []
}
static getFocusedWindow(): null {
return null
}
readonly webContents = nodeHostWebContents
constructor() {
super()
throw new Error('Browser windows are unavailable in the Orca npm server')
}
isDestroyed(): boolean {
return true
}
}
export class BaseWindow extends BrowserWindow {}
export class WebContentsView {
constructor() {
throw new Error('Web contents views are unavailable in the Orca npm server')
}
}
export class Notification extends EventEmitter {
static isSupported(): boolean {
return false
}
show(): void {}
close(): void {}
}
const nodeHostWebContents = Object.assign(new EventEmitter(), {
id: -1,
send: () => {},
isDestroyed: () => true,
getURL: () => '',
openDevTools: () => {},
closeDevTools: () => {},
setWindowOpenHandler: () => {},
session: null
})
export const webContents = {
fromId: () => undefined,
getAllWebContents: () => []
}
export const safeStorage = {
isEncryptionAvailable: () => false,
encryptString: () => {
throw new Error('OS secret encryption is unavailable in the Orca npm server')
},
decryptString: () => {
throw new Error('OS secret encryption is unavailable in the Orca npm server')
}
}
export const shell = {
openExternal: async () => {},
openPath: async () => 'Desktop shell access is unavailable in the Orca npm server',
showItemInFolder: () => {},
trashItem: async () => {
throw new Error('Desktop trash is unavailable in the Orca npm server')
}
}
export const clipboard = {
readText: () => '',
writeText: () => {},
readImage: () => nativeImage.createEmpty(),
writeImage: () => {}
}
export const nativeImage = {
createEmpty: () => ({
isEmpty: () => true,
toPNG: () => Buffer.alloc(0),
resize: () => nativeImage.createEmpty(),
setTemplateImage: () => {}
}),
createFromPath: () => nativeImage.createEmpty(),
createFromBuffer: () => nativeImage.createEmpty(),
createFromDataURL: () => nativeImage.createEmpty()
}
export const dialog = {
showMessageBox: async () => ({ response: 0, checkboxChecked: false }),
showErrorBox: () => {},
showOpenDialog: async () => ({ canceled: true, filePaths: [] }),
showSaveDialog: async () => ({ canceled: true, filePath: undefined })
}
export const Menu = {
buildFromTemplate: () => ({ popup: () => {}, items: [] }),
setApplicationMenu: () => {},
getApplicationMenu: () => null
}
export class Tray extends EventEmitter {
setToolTip(): void {}
setContextMenu(): void {}
setImage(): void {}
destroy(): void {}
}
export const screen = {
getPrimaryDisplay: () => ({ workArea: { x: 0, y: 0, width: 1280, height: 720 } }),
getDisplayNearestPoint: () => ({ workArea: { x: 0, y: 0, width: 1280, height: 720 } }),
getAllDisplays: () => []
}
export const session = {
defaultSession: null,
fromPartition: () => null
}
export const protocol = { registerSchemesAsPrivileged: () => {} }
export const desktopCapturer = { getSources: async () => [] }
export const systemPreferences = new EventEmitter()
export const utilityProcess = {
fork: () => {
throw new Error('Electron utility processes are unavailable in the Orca npm server')
}
}
export const MessageChannelMain = class {
readonly port1 = new EventEmitter()
readonly port2 = new EventEmitter()
}
export const globalShortcut = { register: () => false, unregisterAll: () => {} }
export const autoUpdater = new EventEmitter()
@@ -0,0 +1,26 @@
import { describe, expect, it, vi } from 'vitest'
import { discoverServerPairingAddress } from './server-address-discovery'
describe('discoverServerPairingAddress', () => {
it('uses the explicit address without probing Tailscale', async () => {
const probe = vi.fn<() => Promise<string | null>>()
await expect(discoverServerPairingAddress('host.example.test', probe)).resolves.toEqual({
address: 'host.example.test',
source: 'explicit'
})
expect(probe).not.toHaveBeenCalled()
})
it('advertises a detected Tailscale address', async () => {
await expect(
discoverServerPairingAddress(undefined, async () => '100.64.1.20')
).resolves.toEqual({ address: '100.64.1.20', source: 'tailscale' })
})
it('stays on loopback when Tailscale is unavailable', async () => {
await expect(discoverServerPairingAddress(undefined, async () => null)).resolves.toEqual({
address: '127.0.0.1',
source: 'loopback'
})
})
})
@@ -0,0 +1,34 @@
import { execFile } from 'node:child_process'
const TAILSCALE_ADDRESS_TIMEOUT_MS = 1_500
export async function discoverServerPairingAddress(
explicitAddress?: string,
readTailscaleAddress: () => Promise<string | null> = readTailscaleIpv4
): Promise<{ address: string; source: 'explicit' | 'tailscale' | 'loopback' }> {
if (explicitAddress) {
return { address: explicitAddress, source: 'explicit' }
}
const tailscaleAddress = await readTailscaleAddress()
return tailscaleAddress
? { address: tailscaleAddress, source: 'tailscale' }
: { address: '127.0.0.1', source: 'loopback' }
}
function readTailscaleIpv4(): Promise<string | null> {
return new Promise((resolve) => {
execFile(
'tailscale',
['ip', '-4'],
{ timeout: TAILSCALE_ADDRESS_TIMEOUT_MS },
(error, stdout) => {
if (error) {
resolve(null)
return
}
const address = stdout.trim().split(/\s+/)[0]
resolve(/^\d{1,3}(?:\.\d{1,3}){3}$/.test(address ?? '') ? address : null)
}
)
})
}
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest'
import { parseServerCliArguments, renderServerHelp } from './server-cli-arguments'
describe('parseServerCliArguments', () => {
it('starts the server with safe defaults when no command is supplied', () => {
expect(parseServerCliArguments([])).toEqual({
command: 'serve',
json: false,
noPairing: false,
port: 6768
})
})
it('parses the automation controls after an optional serve command', () => {
expect(
parseServerCliArguments([
'serve',
'--port',
'0',
'--listen',
'100.64.1.20',
'--pairing-address',
'vpn.example.test:7443',
'--data-dir',
'./state',
'--no-pairing',
'--json'
])
).toEqual({
command: 'serve',
dataPath: './state',
json: true,
listenHost: '100.64.1.20',
noPairing: true,
pairingAddress: 'vpn.example.test:7443',
port: 0
})
})
it.each([
[['--port'], '--port requires a value'],
[['--port', '-1'], 'Invalid --port value: -1'],
[['--port', '65536'], 'Invalid --port value: 65536'],
[['--listen'], '--listen requires a value'],
[['--unknown'], 'Unknown argument: --unknown']
])('rejects invalid input %j', (args, message) => {
expect(() => parseServerCliArguments(args)).toThrow(message)
})
it('documents the durable executable and all supported controls', () => {
const help = renderServerHelp()
expect(help).toContain('Usage: orca-ide [serve] [options]')
for (const flag of [
'--port',
'--listen',
'--pairing-address',
'--data-dir',
'--no-pairing',
'--json'
]) {
expect(help).toContain(flag)
}
})
})
+75
View File
@@ -0,0 +1,75 @@
export type ServerCliArguments = {
command: 'serve' | 'help' | 'version'
dataPath?: string
json: boolean
listenHost?: string
noPairing: boolean
pairingAddress?: string
port: number
}
export function parseServerCliArguments(argv: string[]): ServerCliArguments {
const args = argv[0] === 'serve' ? argv.slice(1) : argv
if (args.includes('--help') || args.includes('-h') || argv[0] === 'help') {
return defaults('help')
}
if (args.includes('--version') || args.includes('-v') || argv[0] === 'version') {
return defaults('version')
}
const parsed = defaults('serve')
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]
if (arg === '--json') {
parsed.json = true
} else if (arg === '--no-pairing') {
parsed.noPairing = true
} else if (arg === '--listen') {
parsed.listenHost = readValue(args, ++index, arg)
} else if (arg === '--port') {
parsed.port = parsePort(readValue(args, ++index, arg))
} else if (arg === '--pairing-address') {
parsed.pairingAddress = readValue(args, ++index, arg)
} else if (arg === '--data-dir') {
parsed.dataPath = readValue(args, ++index, arg)
} else {
throw new Error(`Unknown argument: ${arg}`)
}
}
return parsed
}
function defaults(command: ServerCliArguments['command']): ServerCliArguments {
return { command, json: false, noPairing: false, port: 6768 }
}
function readValue(args: string[], index: number, flag: string): string {
const value = args[index]
if (!value || value.startsWith('--')) {
throw new Error(`${flag} requires a value`)
}
return value
}
function parsePort(value: string): number {
const port = Number(value)
if (!Number.isInteger(port) || port < 0 || port > 65_535) {
throw new Error(`Invalid --port value: ${value}`)
}
return port
}
export function renderServerHelp(): string {
return `Usage: orca-ide [serve] [options]
Start a browserless Orca host for remote desktop and web clients.
Options:
--port <port> WebSocket/HTTP port (default: 6768; 0 = random)
--listen <host> Listener interface (default: Tailscale or loopback)
--pairing-address <address> Reachable host or ws(s) URL embedded in access links
--data-dir <path> Persistent server state directory
--no-pairing Start without printing a new access link
--json Print versioned machine-readable readiness
-h, --help Show help
-v, --version Show version`
}
+42
View File
@@ -0,0 +1,42 @@
import { mkdtempSync, rmSync, statSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { ensureServerDataPath, resolveServerDataPath } from './server-paths'
const originalDataDirectory = process.env.ORCA_SERVER_DATA_DIR
const createdPaths: string[] = []
afterEach(() => {
if (originalDataDirectory === undefined) {
delete process.env.ORCA_SERVER_DATA_DIR
} else {
process.env.ORCA_SERVER_DATA_DIR = originalDataDirectory
}
for (const path of createdPaths.splice(0)) {
rmSync(path, { force: true, recursive: true })
}
})
describe('server data paths', () => {
it('prefers an explicit path over the environment', () => {
process.env.ORCA_SERVER_DATA_DIR = './environment-state'
expect(resolveServerDataPath('./explicit-state')).toBe(resolve('./explicit-state'))
})
it('uses the server environment path when no flag is supplied', () => {
process.env.ORCA_SERVER_DATA_DIR = './environment-state'
expect(resolveServerDataPath()).toBe(resolve('./environment-state'))
})
it('creates an owner-only state directory on POSIX', () => {
const parent = mkdtempSync(join(tmpdir(), 'orca-server-paths-'))
const dataPath = join(parent, 'state')
createdPaths.push(parent)
ensureServerDataPath(dataPath)
expect(statSync(dataPath).isDirectory()).toBe(true)
if (process.platform !== 'win32') {
expect(statSync(dataPath).mode & 0o777).toBe(0o700)
}
})
})
+26
View File
@@ -0,0 +1,26 @@
import { chmodSync, mkdirSync } from 'node:fs'
import { homedir } from 'node:os'
import { join, resolve } from 'node:path'
export function resolveServerDataPath(explicitPath?: string): string {
if (explicitPath) {
return resolve(explicitPath)
}
if (process.env.ORCA_SERVER_DATA_DIR) {
return resolve(process.env.ORCA_SERVER_DATA_DIR)
}
if (process.platform === 'win32') {
return join(process.env.LOCALAPPDATA ?? homedir(), 'Orca Server')
}
if (process.platform === 'darwin') {
return join(homedir(), 'Library', 'Application Support', 'Orca Server')
}
return join(process.env.XDG_STATE_HOME ?? join(homedir(), '.local', 'state'), 'orca')
}
export function ensureServerDataPath(path: string): void {
mkdirSync(path, { recursive: true, mode: 0o700 })
if (process.platform !== 'win32') {
chmodSync(path, 0o700)
}
}
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest'
import type { Store } from '../main/persistence'
import { createServerWindowGraph } from './server-window-graph'
describe('createServerWindowGraph', () => {
it('restores persisted terminal pane identities for daemon adoption', () => {
const store = {
getWorkspaceSession: () => ({
tabsByWorktree: {
'folder:workspace-1': [
{
id: 'tab-1',
worktreeId: 'folder:workspace-1',
title: 'Server terminal'
}
]
},
terminalLayoutsByTabId: {
'tab-1': {
root: { type: 'leaf', leafId: 'leaf-1' },
activeLeafId: 'leaf-1',
ptyIdsByLeafId: { 'leaf-1': 'pty-1' },
titlesByLeafId: { 'leaf-1': 'Agent' }
}
}
})
} as unknown as Store
expect(createServerWindowGraph(store)).toEqual({
tabs: [
{
tabId: 'tab-1',
worktreeId: 'folder:workspace-1',
title: 'Server terminal',
activeLeafId: 'leaf-1',
layout: { type: 'leaf', leafId: 'leaf-1' }
}
],
leaves: [
{
tabId: 'tab-1',
worktreeId: 'folder:workspace-1',
leafId: 'leaf-1',
paneRuntimeId: 1,
ptyId: 'pty-1',
paneTitle: 'Agent'
}
]
})
})
})
+36
View File
@@ -0,0 +1,36 @@
import type { Store } from '../main/persistence'
import type { RuntimeSyncWindowGraph } from '../shared/runtime-types'
export function createServerWindowGraph(store: Store): RuntimeSyncWindowGraph {
const session = store.getWorkspaceSession()
const tabs: RuntimeSyncWindowGraph['tabs'] = []
const leaves: RuntimeSyncWindowGraph['leaves'] = []
let paneRuntimeId = 1
for (const [worktreeId, persistedTabs] of Object.entries(session.tabsByWorktree ?? {})) {
for (const tab of persistedTabs) {
const layout = session.terminalLayoutsByTabId?.[tab.id]
if (!layout) {
continue
}
tabs.push({
tabId: tab.id,
worktreeId,
title: tab.title,
activeLeafId: layout.activeLeafId,
layout: layout.root
})
for (const [leafId, ptyId] of Object.entries(layout.ptyIdsByLeafId ?? {})) {
leaves.push({
tabId: tab.id,
worktreeId,
leafId,
paneRuntimeId: paneRuntimeId++,
ptyId,
paneTitle: layout.titlesByLeafId?.[leafId] ?? null
})
}
}
}
return { tabs, leaves }
}
@@ -0,0 +1,75 @@
import { Copy, Server } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '../ui/button'
import { translate } from '@/i18n/i18n'
const NODE_SERVER_COMMAND = 'npx @stablyai/orca@latest'
export function NodeServerSetupCallout(): React.JSX.Element {
const copyCommand = async (): Promise<void> => {
try {
await window.api.ui.writeClipboardText(NODE_SERVER_COMMAND)
toast.success(
translate(
'auto.components.settings.NodeServerSetupCallout.copied',
'Copied server command.'
)
)
} catch (error) {
toast.error(
error instanceof Error
? error.message
: translate(
'auto.components.settings.NodeServerSetupCallout.copyFailed',
'Failed to copy server command.'
)
)
}
}
return (
<div className="space-y-3 rounded-md border border-border/70 bg-background/60 p-3 shadow-xs">
<div className="flex items-start gap-2.5">
<Server className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 space-y-1">
<div className="text-sm font-medium">
{translate(
'auto.components.settings.NodeServerSetupCallout.title',
'Run Orca on a remote computer'
)}
</div>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.NodeServerSetupCallout.description',
'With Node.js 24 installed, run this command there—no desktop app, display, or Electron install required.'
)}
</p>
</div>
</div>
<div className="group relative rounded-md border border-border/70 bg-editor-surface">
<code className="block px-3 py-2.5 pr-10 font-mono text-xs text-foreground">
{NODE_SERVER_COMMAND}
</code>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="absolute top-2 right-2"
aria-label={translate(
'auto.components.settings.NodeServerSetupCallout.copy',
'Copy server command'
)}
onClick={() => void copyCommand()}
>
<Copy className="size-3.5" />
</Button>
</div>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.NodeServerSetupCallout.networking',
'Orca uses Tailscale when available. Otherwise it stays on localhost and prints an SSH tunnel command.'
)}
</p>
</div>
)
}
@@ -29,6 +29,15 @@ function renderForm(endpoint: string): string {
}
describe('RuntimeHostAccessForm', () => {
it('leads with the browserless server command and networking path', () => {
const markup = renderForm('ws://100.76.32.125:6768')
expect(markup).toContain('npx @stablyai/orca@latest')
expect(markup).toContain('no desktop app, display, or Electron install required')
expect(markup).toContain('Tailscale')
expect(markup).toContain('SSH tunnel')
expect(markup).toContain('Already have the Orca desktop app')
})
it('shows the sanitized destination without credential material', () => {
const markup = renderForm('ws://100.76.32.125:6768')
expect(markup).toContain('100.76.32.125:6768')
@@ -8,6 +8,7 @@ import { Checkbox } from '../ui/checkbox'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { translate } from '@/i18n/i18n'
import { NodeServerSetupCallout } from './NodeServerSetupCallout'
import {
translateHostAccessLinkError,
translateRemotePairingEndpointKind,
@@ -66,14 +67,17 @@ export function RuntimeHostAccessForm({
}
}}
>
<div className="space-y-2 rounded-md border border-border/60 bg-background/60 p-3">
<div className="text-sm font-medium">
<NodeServerSetupCallout />
<details className="group text-xs">
<summary className="flex cursor-pointer list-none items-center gap-1 font-medium text-muted-foreground">
{translate(
'auto.components.settings.RuntimeHostAccessForm.getLink',
'Get an access link from the other host'
'auto.components.settings.RuntimeHostAccessForm.desktopInstructions',
'Already have the Orca desktop app on that computer?'
)}
</div>
<ol className="ml-4 list-decimal space-y-1 text-xs text-muted-foreground">
<ChevronDown className="size-3.5 transition-transform group-open:rotate-180" />
</summary>
<ol className="mt-2 ml-4 list-decimal space-y-1 text-muted-foreground">
<li>
{translate(
'auto.components.settings.RuntimeHostAccessForm.stepOpenShare',
@@ -93,7 +97,7 @@ export function RuntimeHostAccessForm({
)}
</li>
</ol>
</div>
</details>
<div className="grid gap-3 sm:grid-cols-[minmax(0,180px)_minmax(0,1fr)]">
<div className="space-y-2">
@@ -312,14 +316,7 @@ export function RuntimeHostAccessForm({
</span>
</span>
</label>
) : (
<p className="mt-2 text-muted-foreground">
{translate(
'auto.components.settings.RuntimeHostAccessForm.headlessHelp',
'Using headless orca serve? Run orca serve --pairing-address <reachable-host> on the other computer.'
)}
</p>
)}
) : null}
</details>
<div className="flex justify-end gap-2">
@@ -15,6 +15,28 @@ function loopbackAccessLink(): string {
}
describe('RemoteServerFields', () => {
it('shows the npm server setup before the access-link fields', () => {
const pairingCode = loopbackAccessLink()
const markup = renderToStaticMarkup(
<RemoteServerFields
name="Remote workstation"
pairingCode={pairingCode}
parsedLink={parseHostAccessLink(pairingCode)}
disabled={false}
onNameChange={vi.fn()}
onPairingCodeChange={vi.fn()}
allowLoopback={false}
onAllowLoopbackChange={vi.fn()}
onSubmit={vi.fn()}
/>
)
expect(markup.indexOf('npx @stablyai/orca@latest')).toBeLessThan(
markup.indexOf('add-server-name')
)
expect(markup).toContain('Copy server command')
})
it('associates blocked loopback guidance with the access-link input', () => {
const pairingCode = loopbackAccessLink()
const markup = renderToStaticMarkup(
@@ -11,6 +11,7 @@ import {
import type { ParseHostAccessLinkResult } from '../../../../shared/remote-pairing-address'
import { applyParsedSshHostInput, type EditingTarget } from '../settings/ssh-target-draft'
import { SshHostAdvancedFields } from '../settings/SshHostAdvancedFields'
import { NodeServerSetupCallout } from '../settings/NodeServerSetupCallout'
export function SshHostFields({
form,
@@ -176,6 +177,7 @@ export function RemoteServerFields({
onSubmit()
}}
>
<NodeServerSetupCallout />
<div className="space-y-1.5">
<Label htmlFor="add-server-name">
{translate('auto.components.sidebar.AddRemoteHostDialog.serverName', 'Name in Orca')}
+10 -1
View File
@@ -10500,7 +10500,8 @@
"networkConnection": "Network connection",
"notAttempted": "Not attempted",
"saveFailed": "Could not save the host",
"saveFailedHelp": "The host was verified, but Orca could not save it. Check the name and local settings storage, then try again."
"saveFailedHelp": "The host was verified, but Orca could not save it. Check the name and local settings storage, then try again.",
"desktopInstructions": "Already have the Orca desktop app on that computer?"
},
"CloudVmSetupGuide": {
"title": "Create a Cloud VM",
@@ -10661,6 +10662,14 @@
"disconnectFailed": "Could not remove the saved Bitbucket credential."
}
}
},
"NodeServerSetupCallout": {
"copied": "Copied server command.",
"copyFailed": "Failed to copy server command.",
"title": "Run Orca on a remote computer",
"description": "With Node.js 24 installed, run this command there—no desktop app, display, or Electron install required.",
"copy": "Copy server command",
"networking": "Orca uses Tailscale when available. Otherwise it stays on localhost and prints an SSH tunnel command."
}
},
"right": {