From 8876c63caae2e76d00cacdc918c3a683373c7419 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sun, 9 Aug 2026 23:10:00 -0400 Subject: [PATCH] feat(server): ship browserless npm runtime --- .github/workflows/pr.yml | 31 ++ .gitignore | 1 + README.md | 2 +- config/docker/node-server/Dockerfile | 32 ++ .../scripts/build-node-server-web-client.mjs | 32 ++ config/scripts/build-node-server.mjs | 75 +++ .../scripts/node-server-runtime-verifier.ts | 304 +++++++++++ .../scripts/pr-workflow-parallelism.test.mjs | 25 + .../run-node-server-runtime-verifier.mjs | 39 ++ config/scripts/verify-node-server-docker.mjs | 106 ++++ config/scripts/verify-node-server-package.mjs | 153 ++++++ config/tsconfig.node.json | 1 + docs/reference/headless-linux-server.md | 55 +- docs/reference/npm-server-design.md | 292 ++++++++++ package.json | 6 + packages/orca-server/LICENSE | 21 + packages/orca-server/README.md | 16 + packages/orca-server/package.json | 32 ++ pnpm-lock.yaml | 502 +++++++++++++++++- pnpm-workspace.yaml | 3 +- src/main/runtime/runtime-rpc.test.ts | 23 + src/main/runtime/runtime-rpc.ts | 9 + src/node-server/index.ts | 155 ++++++ src/node-server/node-host-electron-facade.ts | 256 +++++++++ .../server-address-discovery.test.ts | 26 + src/node-server/server-address-discovery.ts | 34 ++ src/node-server/server-cli-arguments.test.ts | 64 +++ src/node-server/server-cli-arguments.ts | 75 +++ src/node-server/server-paths.test.ts | 42 ++ src/node-server/server-paths.ts | 26 + src/node-server/server-window-graph.test.ts | 51 ++ src/node-server/server-window-graph.ts | 36 ++ .../settings/NodeServerSetupCallout.tsx | 75 +++ .../settings/RuntimeHostAccessForm.test.tsx | 9 + .../settings/RuntimeHostAccessForm.tsx | 27 +- .../sidebar/AddRemoteHostFields.test.tsx | 22 + .../sidebar/AddRemoteHostFields.tsx | 2 + src/renderer/src/i18n/locales/en.json | 11 +- 38 files changed, 2627 insertions(+), 44 deletions(-) create mode 100644 config/docker/node-server/Dockerfile create mode 100644 config/scripts/build-node-server-web-client.mjs create mode 100644 config/scripts/build-node-server.mjs create mode 100644 config/scripts/node-server-runtime-verifier.ts create mode 100644 config/scripts/run-node-server-runtime-verifier.mjs create mode 100644 config/scripts/verify-node-server-docker.mjs create mode 100644 config/scripts/verify-node-server-package.mjs create mode 100644 docs/reference/npm-server-design.md create mode 100644 packages/orca-server/LICENSE create mode 100644 packages/orca-server/README.md create mode 100644 packages/orca-server/package.json create mode 100644 src/node-server/index.ts create mode 100644 src/node-server/node-host-electron-facade.ts create mode 100644 src/node-server/server-address-discovery.test.ts create mode 100644 src/node-server/server-address-discovery.ts create mode 100644 src/node-server/server-cli-arguments.test.ts create mode 100644 src/node-server/server-cli-arguments.ts create mode 100644 src/node-server/server-paths.test.ts create mode 100644 src/node-server/server-paths.ts create mode 100644 src/node-server/server-window-graph.test.ts create mode 100644 src/node-server/server-window-graph.ts create mode 100644 src/renderer/src/components/settings/NodeServerSetupCallout.tsx diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 05a73229d69..68edfe6d43f 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -285,6 +285,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 @@ -450,6 +478,7 @@ jobs: - shell_contracts - test - managed_hook_node18 + - node_server_package - package - package_windows runs-on: ubuntu-latest @@ -472,6 +501,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: | @@ -483,6 +513,7 @@ jobs: "$SHELL_CONTRACTS" \ "$TEST" \ "$MANAGED_HOOK_NODE18" \ + "$NODE_SERVER_PACKAGE" \ "$PACKAGE" \ "$PACKAGE_WINDOWS"; do if [ "$result" != "success" ]; then diff --git a/.gitignore b/.gitignore index fc40e495e7a..4a959c65a7a 100644 --- a/.gitignore +++ b/.gitignore @@ -99,6 +99,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/windows-setup-shell.md diff --git a/README.md b/README.md index 670b54e2835..4935b9be00f 100644 --- a/README.md +++ b/README.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:_ diff --git a/config/docker/node-server/Dockerfile b/config/docker/node-server/Dockerfile new file mode 100644 index 00000000000..4dd04ff8a72 --- /dev/null +++ b/config/docker/node-server/Dockerfile @@ -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 diff --git a/config/scripts/build-node-server-web-client.mjs b/config/scripts/build-node-server-web-client.mjs new file mode 100644 index 00000000000..e35509102dc --- /dev/null +++ b/config/scripts/build-node-server-web-client.mjs @@ -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() + } + }) + }) +} diff --git a/config/scripts/build-node-server.mjs b/config/scripts/build-node-server.mjs new file mode 100644 index 00000000000..df5ba50a6ed --- /dev/null +++ b/config/scripts/build-node-server.mjs @@ -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}`) + } + } +} diff --git a/config/scripts/node-server-runtime-verifier.ts b/config/scripts/node-server-runtime-verifier.ts new file mode 100644 index 00000000000..556a608114d --- /dev/null +++ b/config/scripts/node-server-runtime-verifier.ts @@ -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 { + 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 { + 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 { + 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((_, 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 { + const response = await fetch(url) + assert(response.ok, `web client returned ${response.status}`) + assert((await response.text()).includes(''), 'web client HTML was not served') +} + +async function verifyStatus(pairing: PairingOffer, expectedRuntimeId: string): Promise { + const status = await call(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 { + 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 { + 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 { + 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( + pairing: PairingOffer, + method: string, + params?: unknown +): Promise { + const response = await sendRemoteRuntimeRequest(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) + } +} diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index 5b3b66dea3f..d4ceffb9fa1 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -132,6 +132,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') @@ -214,6 +236,7 @@ describe('PR workflow parallelism', () => { 'shell_contracts', 'test', 'managed_hook_node18', + 'node_server_package', 'package', 'package_windows' ]) @@ -222,5 +245,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"') }) }) diff --git a/config/scripts/run-node-server-runtime-verifier.mjs b/config/scripts/run-node-server-runtime-verifier.mjs new file mode 100644 index 00000000000..a704d67955e --- /dev/null +++ b/config/scripts/run-node-server-runtime-verifier.mjs @@ -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() + } + }) + }) +} diff --git a/config/scripts/verify-node-server-docker.mjs b/config/scripts/verify-node-server-docker.mjs new file mode 100644 index 00000000000..3a16e6d4ec0 --- /dev/null +++ b/config/scripts/verify-node-server-docker.mjs @@ -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) + } + }) + }) +} diff --git a/config/scripts/verify-node-server-package.mjs b/config/scripts/verify-node-server-package.mjs new file mode 100644 index 00000000000..7d8d9496cca --- /dev/null +++ b/config/scripts/verify-node-server-package.mjs @@ -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) + } + }) + }) +} diff --git a/config/tsconfig.node.json b/config/tsconfig.node.json index 948a9e88a1f..229c124cc1c 100644 --- a/config/tsconfig.node.json +++ b/config/tsconfig.node.json @@ -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/**/*", diff --git a/docs/reference/headless-linux-server.md b/docs/reference/headless-linux-server.md index 2284bcdaa23..89a130bd991 100644 --- a/docs/reference/headless-linux-server.md +++ b/docs/reference/headless-linux-server.md @@ -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: diff --git a/docs/reference/npm-server-design.md b/docs/reference/npm-server-design.md new file mode 100644 index 00000000000..ffc6cd88c2f --- /dev/null +++ b/docs/reference/npm-server-design.md @@ -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. diff --git a/package.json b/package.json index 06407e81144..f058949dc05 100644 --- a/package.json +++ b/package.json @@ -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", @@ -203,6 +207,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", @@ -291,6 +296,7 @@ ] }, "onlyBuiltDependencies": [ + "@homebridge/node-pty-prebuilt-multiarch", "@parcel/watcher", "cpu-features", "esbuild", diff --git a/packages/orca-server/LICENSE b/packages/orca-server/LICENSE new file mode 100644 index 00000000000..fbf46fc17da --- /dev/null +++ b/packages/orca-server/LICENSE @@ -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. diff --git a/packages/orca-server/README.md b/packages/orca-server/README.md new file mode 100644 index 00000000000..1c850c643fa --- /dev/null +++ b/packages/orca-server/README.md @@ -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. diff --git a/packages/orca-server/package.json b/packages/orca-server/package.json new file mode 100644 index 00000000000..a7946abba5f --- /dev/null +++ b/packages/orca-server/package.json @@ -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" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1872f9860e8..61f4096dd87 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -124,7 +124,7 @@ importers: version: 2.1.14(@playwright/test@1.59.1)(zod@4.4.3) '@tailwindcss/vite': specifier: ^4.2.4 - version: 4.2.4(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4)) + version: 4.2.4(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.8.4)) '@tanstack/react-virtual': specifier: ^3.13.24 version: 3.13.24(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -205,7 +205,7 @@ importers: version: 8.18.1 '@vitejs/plugin-react': specifier: ^5.2.0 - version: 5.2.0(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4)) + version: 5.2.0(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.8.4)) '@xterm/addon-fit': specifier: 0.12.0-beta.287 version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)) @@ -250,13 +250,16 @@ importers: version: 26.15.3(dmg-builder@26.15.3) electron-vite: specifier: ^5.0.0 - version: 5.0.0(@swc/core@1.15.46)(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4)) + version: 5.0.0(@swc/core@1.15.46)(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.8.4)) emoji-picker-react: specifier: ^4.19.1 version: 4.19.1(react@19.2.7) emojibase-data: specifier: 17.0.0 version: 17.0.0(emojibase@17.0.0) + esbuild: + specifier: 0.27.7 + version: 0.27.7 happy-dom: specifier: ^20.9.0 version: 20.9.0 @@ -385,10 +388,10 @@ importers: version: 11.0.5 vite: specifier: npm:rolldown-vite@7.3.1 - version: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4) + version: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.8.4) vitest: specifier: ^4.1.5 - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.9.5)(happy-dom@20.9.0)(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.9.5)(happy-dom@20.9.0)(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.8.4)) vscode-oniguruma: specifier: ^2.0.1 version: 2.0.1 @@ -418,6 +421,24 @@ importers: specifier: 3.2.2 version: 3.2.2 + packages/orca-server: + dependencies: + '@parcel/watcher': + specifier: 2.5.6 + version: 2.5.6 + node-pty: + specifier: npm:@homebridge/node-pty-prebuilt-multiarch@0.14.1 + version: '@homebridge/node-pty-prebuilt-multiarch@0.14.1' + ssh2: + specifier: 1.17.0 + version: 1.17.0 + tweetnacl: + specifier: 1.0.3 + version: 1.0.3 + ws: + specifier: 8.21.0 + version: 8.21.0 + packages: '@adobe/css-tools@4.5.0': @@ -695,156 +716,312 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.25.12': resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.25.12': resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.25.12': resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.25.12': resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.25.12': resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.25.12': resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.25.12': resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.25.12': resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.25.12': resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.25.12': resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.25.12': resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.25.12': resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.25.12': resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.25.12': resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.25.12': resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.25.12': resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.25.12': resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.25.12': resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.25.12': resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.25.12': resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} @@ -865,6 +1042,10 @@ packages: peerDependencies: graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + '@homebridge/node-pty-prebuilt-multiarch@0.14.1': + resolution: {integrity: sha512-JaaTsATWPkOhm/cM2t+IjA2lztDFkoWQYU5VS8ThTsa9VbKhHwp83Cww4t8X/BH0k4Q8cWqvOKmYoGrlwLVhbA==} + engines: {node: '>=20.0.0 <27.0.0'} + '@hono/node-server@2.1.0': resolution: {integrity: sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg==} engines: {node: '>=20'} @@ -3552,6 +3733,9 @@ packages: peerDependencies: react: '>=17.0.1' + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + bluebird@3.7.2: resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} @@ -3585,6 +3769,9 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + buildcheck@0.0.7: resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==} engines: {node: '>=10.0.0'} @@ -3674,6 +3861,9 @@ packages: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} @@ -4044,6 +4234,10 @@ packages: babel-plugin-macros: optional: true + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} @@ -4278,6 +4472,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -4343,6 +4542,10 @@ packages: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -4439,6 +4642,9 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fs-extra@10.1.0: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} @@ -4522,6 +4728,9 @@ packages: resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} engines: {node: '>=18'} + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + github-slugger@2.0.0: resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} @@ -4715,6 +4924,9 @@ packages: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -4738,6 +4950,9 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -5377,6 +5592,9 @@ packages: resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} engines: {node: '>= 18'} + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + mkdirp@0.5.6: resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} hasBin: true @@ -5412,10 +5630,17 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + node-abi@3.94.0: + resolution: {integrity: sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==} + engines: {node: '>=10'} + node-abi@4.33.0: resolution: {integrity: sha512-vLBWCKb+7LWsX+TbfzWOkw0W81m377tyx3hOweBTjO43CXZnRGS1/JPWs20fr0PgZyDXk6ROYrylsEycK8raDA==} engines: {node: '>=22.12.0'} @@ -5426,6 +5651,10 @@ packages: node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + node-addon-api@8.9.1: + resolution: {integrity: sha512-4eUQWVPCUUUiBjLnHS3cXWeC6ryoPUc0U3rP7IuzapoGbzMqd/r6KKO0clr0b+snQhsrueFEhCZDdK+LK7hxKg==} + engines: {node: ^18 || ^20 || >= 21} + node-api-version@0.2.1: resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==} @@ -5722,6 +5951,12 @@ packages: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + pretty-format@27.5.1: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} @@ -5849,6 +6084,10 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + react-colorful@5.7.0: resolution: {integrity: sha512-fuesYIemttah97XmsIHmz4OORDHiSFzyc9HMAIrCHJou2jaRQmL8cFJ76K4zQhhj8jzwOBlOi4BaGTjjOZCfTg==} peerDependencies: @@ -5939,6 +6178,10 @@ packages: readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + readdirp@5.0.0: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} @@ -6246,6 +6489,12 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + simple-update-notifier@2.0.0: resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} engines: {node: '>=10'} @@ -6370,6 +6619,10 @@ packages: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -6401,6 +6654,13 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} + tar-fs@2.1.5: + resolution: {integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + tar@7.5.22: resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==} engines: {node: '>=18'} @@ -6489,6 +6749,9 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} @@ -7251,81 +7514,159 @@ snapshots: '@esbuild/aix-ppc64@0.25.12': optional: true + '@esbuild/aix-ppc64@0.27.7': + optional: true + '@esbuild/android-arm64@0.25.12': optional: true + '@esbuild/android-arm64@0.27.7': + optional: true + '@esbuild/android-arm@0.25.12': optional: true + '@esbuild/android-arm@0.27.7': + optional: true + '@esbuild/android-x64@0.25.12': optional: true + '@esbuild/android-x64@0.27.7': + optional: true + '@esbuild/darwin-arm64@0.25.12': optional: true + '@esbuild/darwin-arm64@0.27.7': + optional: true + '@esbuild/darwin-x64@0.25.12': optional: true + '@esbuild/darwin-x64@0.27.7': + optional: true + '@esbuild/freebsd-arm64@0.25.12': optional: true + '@esbuild/freebsd-arm64@0.27.7': + optional: true + '@esbuild/freebsd-x64@0.25.12': optional: true + '@esbuild/freebsd-x64@0.27.7': + optional: true + '@esbuild/linux-arm64@0.25.12': optional: true + '@esbuild/linux-arm64@0.27.7': + optional: true + '@esbuild/linux-arm@0.25.12': optional: true + '@esbuild/linux-arm@0.27.7': + optional: true + '@esbuild/linux-ia32@0.25.12': optional: true + '@esbuild/linux-ia32@0.27.7': + optional: true + '@esbuild/linux-loong64@0.25.12': optional: true + '@esbuild/linux-loong64@0.27.7': + optional: true + '@esbuild/linux-mips64el@0.25.12': optional: true + '@esbuild/linux-mips64el@0.27.7': + optional: true + '@esbuild/linux-ppc64@0.25.12': optional: true + '@esbuild/linux-ppc64@0.27.7': + optional: true + '@esbuild/linux-riscv64@0.25.12': optional: true + '@esbuild/linux-riscv64@0.27.7': + optional: true + '@esbuild/linux-s390x@0.25.12': optional: true + '@esbuild/linux-s390x@0.27.7': + optional: true + '@esbuild/linux-x64@0.25.12': optional: true + '@esbuild/linux-x64@0.27.7': + optional: true + '@esbuild/netbsd-arm64@0.25.12': optional: true + '@esbuild/netbsd-arm64@0.27.7': + optional: true + '@esbuild/netbsd-x64@0.25.12': optional: true + '@esbuild/netbsd-x64@0.27.7': + optional: true + '@esbuild/openbsd-arm64@0.25.12': optional: true + '@esbuild/openbsd-arm64@0.27.7': + optional: true + '@esbuild/openbsd-x64@0.25.12': optional: true + '@esbuild/openbsd-x64@0.27.7': + optional: true + '@esbuild/openharmony-arm64@0.25.12': optional: true + '@esbuild/openharmony-arm64@0.27.7': + optional: true + '@esbuild/sunos-x64@0.25.12': optional: true + '@esbuild/sunos-x64@0.27.7': + optional: true + '@esbuild/win32-arm64@0.25.12': optional: true + '@esbuild/win32-arm64@0.27.7': + optional: true + '@esbuild/win32-ia32@0.25.12': optional: true + '@esbuild/win32-ia32@0.27.7': + optional: true + '@esbuild/win32-x64@0.25.12': optional: true + '@esbuild/win32-x64@0.27.7': + optional: true + '@floating-ui/core@1.7.5': dependencies: '@floating-ui/utils': 0.2.11 @@ -7347,6 +7688,11 @@ snapshots: dependencies: graphql: 16.14.2 + '@homebridge/node-pty-prebuilt-multiarch@0.14.1': + dependencies: + node-addon-api: 8.9.1 + prebuild-install: 7.1.3 + '@hono/node-server@2.1.0(hono@4.13.0)': dependencies: hono: 4.13.0 @@ -8923,12 +9269,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.2.4 '@tailwindcss/oxide-win32-x64-msvc': 4.2.4 - '@tailwindcss/vite@4.2.4(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4))': + '@tailwindcss/vite@4.2.4(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.8.4))': dependencies: '@tailwindcss/node': 4.2.4 '@tailwindcss/oxide': 4.2.4 tailwindcss: 4.2.4 - vite: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4) + vite: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.8.4) '@tanstack/react-virtual@3.13.24(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: @@ -9535,7 +9881,7 @@ snapshots: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) - '@vitejs/plugin-react@5.2.0(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4))': + '@vitejs/plugin-react@5.2.0(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.8.4))': dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.7) @@ -9543,7 +9889,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4) + vite: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.8.4) transitivePeerDependencies: - supports-color @@ -9556,14 +9902,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.5(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4))': + '@vitest/mocker@4.1.5(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.8.4))': dependencies: '@vitest/spy': 4.1.5 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.14.3(@types/node@25.9.5)(typescript@7.0.2) - vite: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4) + vite: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.8.4) '@vitest/pretty-format@4.1.5': dependencies: @@ -9767,6 +10113,12 @@ snapshots: dependencies: react: 19.2.7 + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + bluebird@3.7.2: {} body-parser@2.3.0: @@ -9813,6 +10165,11 @@ snapshots: buffer-from@1.1.2: {} + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + buildcheck@0.0.7: optional: true @@ -9905,6 +10262,8 @@ snapshots: dependencies: readdirp: 5.0.0 + chownr@1.1.4: {} + chownr@3.0.0: {} chromium-pickle-js@0.2.0: {} @@ -10259,6 +10618,8 @@ snapshots: dedent@1.7.2: {} + deep-extend@0.6.0: {} + deepmerge@4.3.1: {} default-browser-id@5.0.1: {} @@ -10419,7 +10780,7 @@ snapshots: transitivePeerDependencies: - supports-color - electron-vite@5.0.0(@swc/core@1.15.46)(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4)): + electron-vite@5.0.0(@swc/core@1.15.46)(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.8.4)): dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.7) @@ -10427,7 +10788,7 @@ snapshots: esbuild: 0.25.12 magic-string: 0.30.21 picocolors: 1.1.1 - vite: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4) + vite: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.8.4) optionalDependencies: '@swc/core': 1.15.46 transitivePeerDependencies: @@ -10546,6 +10907,35 @@ snapshots: '@esbuild/win32-ia32': 0.25.12 '@esbuild/win32-x64': 0.25.12 + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + escalade@3.2.0: {} escape-html@1.0.3: {} @@ -10615,6 +11005,8 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.2 + expand-template@2.0.3: {} + expect-type@1.3.0: {} exponential-backoff@3.1.3: {} @@ -10739,6 +11131,8 @@ snapshots: fresh@2.0.0: {} + fs-constants@1.0.0: {} + fs-extra@10.1.0: dependencies: graceful-fs: 4.2.11 @@ -10827,6 +11221,8 @@ snapshots: '@sec-ant/readable-stream': 0.4.1 is-stream: 4.0.1 + github-from-package@0.0.0: {} + github-slugger@2.0.0: {} glob-parent@5.1.2: @@ -11141,6 +11537,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + ieee754@1.2.1: {} + ignore@5.3.2: {} ignore@7.0.5: {} @@ -11159,6 +11557,8 @@ snapshots: inherits@2.0.4: {} + ini@1.3.8: {} + inline-style-parser@0.2.7: {} inquirer@14.0.2(@types/node@25.9.5): @@ -11941,6 +12341,8 @@ snapshots: dependencies: minipass: 7.1.3 + mkdirp-classic@0.5.3: {} + mkdirp@0.5.6: dependencies: minimist: 1.2.8 @@ -11992,8 +12394,14 @@ snapshots: nanoid@3.3.17: {} + napi-build-utils@2.0.0: {} + negotiator@1.0.0: {} + node-abi@3.94.0: + dependencies: + semver: 7.8.1 + node-abi@4.33.0: dependencies: semver: 7.8.1 @@ -12003,6 +12411,8 @@ snapshots: node-addon-api@7.1.1: {} + node-addon-api@8.9.1: {} + node-api-version@0.2.1: dependencies: semver: 7.8.1 @@ -12349,6 +12759,21 @@ snapshots: powershell-utils@0.1.0: {} + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.94.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.5 + tunnel-agent: 0.6.0 + pretty-format@27.5.1: dependencies: ansi-regex: 5.0.1 @@ -12560,6 +12985,13 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + react-colorful@5.7.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: react: 19.2.7 @@ -12655,6 +13087,12 @@ snapshots: string_decoder: 1.1.1 util-deprecate: 1.0.2 + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + readdirp@5.0.0: {} recast@0.23.11: @@ -12815,7 +13253,7 @@ snapshots: robust-predicates@3.0.3: {} - rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4): + rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.8.4): dependencies: '@oxc-project/runtime': 0.101.0 fdir: 6.5.0(picomatch@4.0.4) @@ -12826,6 +13264,7 @@ snapshots: tinyglobby: 0.2.16 optionalDependencies: '@types/node': 25.9.5 + esbuild: 0.27.7 fsevents: 2.3.3 jiti: 2.7.0 yaml: 2.8.4 @@ -13047,6 +13486,14 @@ snapshots: signal-exit@4.1.0: {} + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + simple-update-notifier@2.0.0: dependencies: semver: 7.8.1 @@ -13161,6 +13608,8 @@ snapshots: dependencies: min-indent: 1.0.1 + strip-json-comments@2.0.1: {} + style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 @@ -13190,6 +13639,21 @@ snapshots: tapable@2.3.3: {} + tar-fs@2.1.5: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + tar@7.5.22: dependencies: '@isaacs/fs-minipass': 4.0.1 @@ -13277,6 +13741,10 @@ snapshots: tslib@2.8.1: {} + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.1.2 + tw-animate-css@1.4.0: {} tweetnacl@0.14.5: {} @@ -13446,10 +13914,10 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.9.5)(happy-dom@20.9.0)(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4)): + vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.9.5)(happy-dom@20.9.0)(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.8.4)): dependencies: '@vitest/expect': 4.1.5 - '@vitest/mocker': 4.1.5(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4)) + '@vitest/mocker': 4.1.5(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.8.4)) '@vitest/pretty-format': 4.1.5 '@vitest/runner': 4.1.5 '@vitest/snapshot': 4.1.5 @@ -13466,7 +13934,7 @@ snapshots: tinyexec: 1.1.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4) + vite: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.8.4) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 52304c4dedb..b5e11f6ddf1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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 diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 2df636da4c0..fb999a48afa 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -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 diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index f4d5006303a..d2f61f49326 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -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 @@ -485,6 +487,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 @@ -543,6 +546,7 @@ export class OrcaRuntimeRpcServer { wsPort = DEFAULT_WS_PORT, preferPinnedWsPort = false, exposeNetworkByDefault = false, + wsBindHost, webClientRoot, keepaliveIntervalMs = KEEPALIVE_INTERVAL_MS, longPollCap = LONG_POLL_CAP, @@ -557,6 +561,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 @@ -1239,6 +1244,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 } @@ -1360,6 +1368,7 @@ export class OrcaRuntimeRpcServer { if ( !this.enableWebSocket || this.stopping || + this.wsBindHost !== undefined || this.wsBoundHost === WS_BIND_HOST_ALL_INTERFACES ) { return diff --git a/src/node-server/index.ts b/src/node-server/index.ts new file mode 100644 index 00000000000..81ea64146e7 --- /dev/null +++ b/src/node-server/index.ts @@ -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 { + 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 => { + 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'}@\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, + pairingAddress: string +): Promise { + 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 +}) diff --git a/src/node-server/node-host-electron-facade.ts b/src/node-server/node-host-electron-facade.ts new file mode 100644 index 00000000000..61ec5808365 --- /dev/null +++ b/src/node-server/node-host-electron-facade.ts @@ -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 { + 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 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) => 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() diff --git a/src/node-server/server-address-discovery.test.ts b/src/node-server/server-address-discovery.test.ts new file mode 100644 index 00000000000..1f1590e38ef --- /dev/null +++ b/src/node-server/server-address-discovery.test.ts @@ -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>() + 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' + }) + }) +}) diff --git a/src/node-server/server-address-discovery.ts b/src/node-server/server-address-discovery.ts new file mode 100644 index 00000000000..0171ba5a6d9 --- /dev/null +++ b/src/node-server/server-address-discovery.ts @@ -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 = 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 { + 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) + } + ) + }) +} diff --git a/src/node-server/server-cli-arguments.test.ts b/src/node-server/server-cli-arguments.test.ts new file mode 100644 index 00000000000..aa29fb1bc29 --- /dev/null +++ b/src/node-server/server-cli-arguments.test.ts @@ -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) + } + }) +}) diff --git a/src/node-server/server-cli-arguments.ts b/src/node-server/server-cli-arguments.ts new file mode 100644 index 00000000000..7ea8c952c62 --- /dev/null +++ b/src/node-server/server-cli-arguments.ts @@ -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 WebSocket/HTTP port (default: 6768; 0 = random) + --listen Listener interface (default: Tailscale or loopback) + --pairing-address
Reachable host or ws(s) URL embedded in access links + --data-dir 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` +} diff --git a/src/node-server/server-paths.test.ts b/src/node-server/server-paths.test.ts new file mode 100644 index 00000000000..5dcd6e0ae4b --- /dev/null +++ b/src/node-server/server-paths.test.ts @@ -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) + } + }) +}) diff --git a/src/node-server/server-paths.ts b/src/node-server/server-paths.ts new file mode 100644 index 00000000000..534c01db2b1 --- /dev/null +++ b/src/node-server/server-paths.ts @@ -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) + } +} diff --git a/src/node-server/server-window-graph.test.ts b/src/node-server/server-window-graph.test.ts new file mode 100644 index 00000000000..61f1652bf81 --- /dev/null +++ b/src/node-server/server-window-graph.test.ts @@ -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' + } + ] + }) + }) +}) diff --git a/src/node-server/server-window-graph.ts b/src/node-server/server-window-graph.ts new file mode 100644 index 00000000000..9e8cf8a4f01 --- /dev/null +++ b/src/node-server/server-window-graph.ts @@ -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 } +} diff --git a/src/renderer/src/components/settings/NodeServerSetupCallout.tsx b/src/renderer/src/components/settings/NodeServerSetupCallout.tsx new file mode 100644 index 00000000000..0605b4aa72a --- /dev/null +++ b/src/renderer/src/components/settings/NodeServerSetupCallout.tsx @@ -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 => { + 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 ( +
+
+ +
+
+ {translate( + 'auto.components.settings.NodeServerSetupCallout.title', + 'Run Orca on a remote computer' + )} +
+

+ {translate( + 'auto.components.settings.NodeServerSetupCallout.description', + 'With Node.js 24 installed, run this command there—no desktop app, display, or Electron install required.' + )} +

+
+
+
+ + {NODE_SERVER_COMMAND} + + +
+

+ {translate( + 'auto.components.settings.NodeServerSetupCallout.networking', + 'Orca uses Tailscale when available. Otherwise it stays on localhost and prints an SSH tunnel command.' + )} +

+
+ ) +} diff --git a/src/renderer/src/components/settings/RuntimeHostAccessForm.test.tsx b/src/renderer/src/components/settings/RuntimeHostAccessForm.test.tsx index 2d1646683c7..9e695ec5dbc 100644 --- a/src/renderer/src/components/settings/RuntimeHostAccessForm.test.tsx +++ b/src/renderer/src/components/settings/RuntimeHostAccessForm.test.tsx @@ -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') diff --git a/src/renderer/src/components/settings/RuntimeHostAccessForm.tsx b/src/renderer/src/components/settings/RuntimeHostAccessForm.tsx index 54e31767f8c..d26427c4440 100644 --- a/src/renderer/src/components/settings/RuntimeHostAccessForm.tsx +++ b/src/renderer/src/components/settings/RuntimeHostAccessForm.tsx @@ -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({ } }} > -
-
+ + +
+ {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?' )} -
-
    + + +
    1. {translate( 'auto.components.settings.RuntimeHostAccessForm.stepOpenShare', @@ -93,7 +97,7 @@ export function RuntimeHostAccessForm({ )}
    -
+
@@ -312,14 +316,7 @@ export function RuntimeHostAccessForm({ - ) : ( -

- {translate( - 'auto.components.settings.RuntimeHostAccessForm.headlessHelp', - 'Using headless orca serve? Run orca serve --pairing-address on the other computer.' - )} -

- )} + ) : null}
diff --git a/src/renderer/src/components/sidebar/AddRemoteHostFields.test.tsx b/src/renderer/src/components/sidebar/AddRemoteHostFields.test.tsx index 631eb7b3025..7e20ff22b68 100644 --- a/src/renderer/src/components/sidebar/AddRemoteHostFields.test.tsx +++ b/src/renderer/src/components/sidebar/AddRemoteHostFields.test.tsx @@ -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( + + ) + + 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( diff --git a/src/renderer/src/components/sidebar/AddRemoteHostFields.tsx b/src/renderer/src/components/sidebar/AddRemoteHostFields.tsx index cdab890d459..68979d99b76 100644 --- a/src/renderer/src/components/sidebar/AddRemoteHostFields.tsx +++ b/src/renderer/src/components/sidebar/AddRemoteHostFields.tsx @@ -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() }} > +