mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 16:03:26 +00:00
feat(server): ship browserless npm runtime
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:_
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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/**/*",
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+485
-17
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user