Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
"project": ["src/**/*.ts"],
"ignoreDependencies": ["@types/vscode"]
},
"packages/{build,ipc,types}": {
"packages/{build,ipc,types,remote-bridge}": {
"project": ["src/**/*.ts"]
}
},
Expand Down
93 changes: 93 additions & 0 deletions packages/remote-bridge/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Remote Bridge

A forked Node.js process that connects to the Zoo Code extension's IPC **API surface** over a Unix socket. This is **Phase 1** of the Remote Control & Approval feature ([issue #650](https://github.com/Zoo-Code-Org/Zoo-Code/issues/650)).

## What Phase 1 does

- Spawns as a standalone Node process, **forked from the extension** when `zoo-code.remoteControl.enabled` is on.
- Connects to the extension's [`IpcServer`](../ipc/src/ipc-server.ts) using the same [`IpcClient`](../ipc/src/ipc-client.ts) the CLI uses.
- Can issue any [`TaskCommand`](../../packages/types/src/ipc.ts) (e.g. `GetModes`, `GetCommands`, `SendMessage`) and receive the resulting [`TaskEvent`](../../packages/types/src/events.ts) responses.
- In `--serve` mode (the mode the extension forks), stays connected and streams every `TaskEvent` to stdout as newline-delimited JSON. Phase 2 will replace this stdout line with a WebRTC data channel write.
- Proves the round-trip works via an integration test that stands up a real `IpcServer` and runs an API call through the `Bridge`.

What it does **not** do yet (later phases): WebRTC data channel, signaling, remote UI, push notifications.

## Architecture

```
┌──────────────────────┐ Unix socket ┌──────────────────────┐
│ Zoo Code Extension │ ◄──────────────► │ Bridge Process │
│ (IpcServer) │ /tmp/...sock │ (IpcClient + Bridge)│
│ - Task engine │ │ - send commands │
│ - Approval flow │ │ - receive events │
└──────────────────────┘ └──────────────────────┘
```

The socket path is the same one the extension already reads: the `ROO_CODE_IPC_SOCKET_PATH` environment variable. The extension starts its IPC server when either the **Remote Control** setting is on (persisted via the ContextProxy / `GlobalSettings` as `remoteControlEnabled` / `remoteControlSocketPath`, surfaced in the Zoo Code SettingsView) **or** `ROO_CODE_IPC_SOCKET_PATH` is set, so the bridge is opt-in. The bridge process itself is only auto-forked when the setting is on (the env var alone is for headless/CLI use and does not auto-fork).

## Usage

### As a library

```typescript
import { Bridge } from "@zoo-code/remote-bridge"

const bridge = new Bridge("/tmp/zoo-code.sock")
await bridge.connect()

// Issue an API call and await the response event.
const modes = await bridge.getModes()
console.log(modes.payload[0])

// Subscribe to live task events.
bridge.onEvent("taskStarted", (event) => console.log("started", event.payload[0]))

// Send a message to the active task.
bridge.sendMessage("hello from the bridge")

bridge.disconnect()
```

### As a CLI (demo / smoke test)

```bash
# 1. Start the extension with the IPC server enabled.
ROO_CODE_IPC_SOCKET_PATH=/tmp/zoo-code.sock code .

# 2. From the repo, run the bridge against that socket.
pnpm --filter @zoo-code/remote-bridge start -- --socket /tmp/zoo-code.sock --command get-modes
```

The response event is pretty-printed to stdout; diagnostic logs go to stderr.

## Enabling from Zoo Code preferences

Toggle **Settings → Remote Control → Enable Remote Control** in the Zoo Code SettingsView (persisted as `remoteControlEnabled` / `remoteControlSocketPath` through the ContextProxy / `GlobalSettings`). When on, the extension:

1. Starts its `IpcServer` on the configured socket (`remoteControlSocketPath`, or a per-user default under the system temp dir; `ROO_CODE_IPC_SOCKET_PATH` overrides if set).
2. Forks this bridge in `--serve` mode against that socket via [`RemoteBridgeHost`](../../src/services/remote-bridge/RemoteBridgeHost.ts), with crash-restart backoff.
3. Hot-toggles without a restart: the webview `updateSettings` handler persists the new values to the ContextProxy and fires [`ClineProvider#onRemoteControlChange`](../../src/core/webview/ClineProvider.ts), which [`src/extension.ts`](../../src/extension.ts) subscribes to.

The bridge is bundled into the extension VSIX at `dist/remote-bridge/main.js` by [`src/esbuild.mjs`](../../src/esbuild.mjs).

## Scripts

| Script | Description |
| ------------------ | ------------------------------------------------------------------------------------ |
| `pnpm test` | Run the vitest integration tests (stands up a real `IpcServer`). |
| `pnpm check-types` | `tsc --noEmit`. |
| `pnpm lint` | ESLint. |
| `pnpm start` | Run the one-shot CLI entry point via `tsx`. |
| `pnpm demo` | Fork the one-shot bridge against a mock server (live API call). |
| `pnpm demo:serve` | Fork the bundled bridge in `--serve` mode against a mock server and stream an event. |

## Tests

The integration test in [`src/__tests__/bridge.test.ts`](src/__tests__/bridge.test.ts) creates a real `IpcServer` on a temporary socket, wires a command handler that mimics the extension's API (see [`src/extension/api.ts`](../../src/extension/api.ts)), and verifies that a `Bridge` can:

1. Connect and receive its `Ack`.
2. Issue `GetModes` and receive the `ModesResponse` event.
3. Forward broadcast `TaskEvent`s to `onEvent` subscribers (and unsubscribe correctly).
4. Reject `request()` on timeout when no response arrives.

This is the proof that the forked process can talk to the API surface.
4 changes: 4 additions & 0 deletions packages/remote-bridge/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { config } from "@roo-code/config-eslint/base"

/** @type {import("eslint").Linter.Config} */
export default [...config]
30 changes: 30 additions & 0 deletions packages/remote-bridge/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"name": "@zoo-code/remote-bridge",
"description": "Forked Node bridge process that connects Zoo Code's IPC API surface to remote clients (WebRTC in later phases).",
"version": "0.0.1",
"type": "module",
"exports": "./src/index.ts",
"bin": "./src/main.ts",
"scripts": {
"lint": "eslint src scripts --ext=ts --max-warnings=0",
"check-types": "tsc --noEmit",
"test": "vitest run",
"start": "tsx src/main.ts",
"demo": "tsx scripts/demo.ts",
"demo:serve": "tsx scripts/serve-demo.ts",
"clean": "rimraf .turbo"
},
"dependencies": {
"@roo-code/ipc": "workspace:^",
"@roo-code/types": "workspace:^",
"node-ipc": "^12.0.0"
},
"devDependencies": {
"@roo-code/config-eslint": "workspace:^",
"@roo-code/config-typescript": "workspace:^",
"@types/node": "20.19.43",
"@types/node-ipc": "9.2.3",
"tsx": "4.19.4",
"vitest": "4.1.0"
}
}
88 changes: 88 additions & 0 deletions packages/remote-bridge/scripts/demo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* Live end-to-end demo for Phase 1.
*
* Spins up a mock IPC server (mimicking the extension's API surface) and then
* forks the bridge process (`src/main.ts`) as a real child Node process
* pointed at that socket. The bridge issues a `GetModes` API call and prints
* the response. This proves the forked node process can talk to the API
* surface over a real Unix socket.
*
* Run with: pnpm --filter @zoo-code/remote-bridge exec tsx scripts/demo.ts
*/
import os from "node:os"
import path from "node:path"
import fs from "node:fs"
import { spawn } from "node:child_process"
import { fileURLToPath } from "node:url"

import { IpcServer } from "@roo-code/ipc"
import { IpcMessageType, IpcOrigin, RooCodeEventName, TaskCommandName, type TaskEvent } from "@roo-code/types"

const __dirname = path.dirname(fileURLToPath(import.meta.url))

function uniqueSocketPath(): string {
return path.join(os.tmpdir(), `zoo-code-bridge-demo-${Date.now()}-${Math.random().toString(36).slice(2)}.sock`)
}

async function main() {
const socketPath = uniqueSocketPath()
try {
fs.unlinkSync(socketPath)
} catch {
// ignore
}

const server = new IpcServer(socketPath, (...args) => process.stderr.write(`[mock-server] ${args.join(" ")}\n`))

server.on(IpcMessageType.TaskCommand, (clientId, command) => {
process.stderr.write(`[mock-server] received command: ${command.commandName}\n`)

if (command.commandName === TaskCommandName.GetModes) {
server.send(clientId, {
type: IpcMessageType.TaskEvent,
origin: IpcOrigin.Server,
data: {
eventName: RooCodeEventName.ModesResponse,
payload: [
[
{ slug: "code", name: "Code" },
{ slug: "architect", name: "Architect" },
{ slug: "ask", name: "Ask" },
{ slug: "debug", name: "Debug" },
],
],
} as TaskEvent,
})
}
})

server.listen()
process.stderr.write(`[demo] mock IPC server listening on ${socketPath}\n`)

// Fork the bridge entry point as a real child process.
const mainPath = path.join(__dirname, "..", "src", "main.ts")
const child = spawn("tsx", [mainPath, "--socket", socketPath, "--command", "get-modes"], {
stdio: "inherit",
})

await new Promise<void>((resolve) => {
child.on("close", (code) => {
process.stderr.write(`\n[demo] bridge process exited with code ${code}\n`)
resolve()
})
})

cleanupSocket(socketPath)
// node-ipc keeps the event loop alive in the mock server; exit explicitly.
process.exit(0)
}

function cleanupSocket(socketPath: string) {
try {
fs.unlinkSync(socketPath)
} catch {
// ignore
}
}

void main()
106 changes: 106 additions & 0 deletions packages/remote-bridge/scripts/serve-demo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* Live end-to-end demo for the bridge --serve mode (the mode the extension
* forks when `zoo-code.remoteControl.enabled` is on).
*
* Stands up a mock IPC server, forks the bundled bridge
* (`src/dist/remote-bridge/main.js --serve`) as a real child process against
* that socket, broadcasts a TaskEvent, and prints the ndjson line the bridge
* streams to stdout. This proves the extension's fork path works end-to-end.
*
* Run from repo root:
* pnpm --filter @zoo-code/remote-bridge exec tsx scripts/serve-demo.ts
*/
import os from "node:os"
import path from "node:path"
import fs from "node:fs"
import { spawn } from "node:child_process"
import { fileURLToPath } from "node:url"

import { IpcServer } from "@roo-code/ipc"
import { IpcMessageType, IpcOrigin, RooCodeEventName, type TaskEvent } from "@roo-code/types"

const __dirname = path.dirname(fileURLToPath(import.meta.url))

function uniqueSocketPath(): string {
return path.join(os.tmpdir(), `zoo-code-serve-demo-${Date.now()}-${Math.random().toString(36).slice(2)}.sock`)
}

async function main() {
const socketPath = uniqueSocketPath()
try {
fs.unlinkSync(socketPath)
} catch {
// ignore
}

const server = new IpcServer(socketPath, (...args) => process.stderr.write(`[mock-server] ${args.join(" ")}\n`))

// Acknowledge connects; the bridge will log "connected".
server.listen()
process.stderr.write(`[serve-demo] mock IPC server listening on ${socketPath}\n`)

// Fork the BUNDLED bridge (the same artifact the extension forks).
const bundledMain = path.join(__dirname, "..", "..", "..", "src", "dist", "remote-bridge", "main.js")

if (!fs.existsSync(bundledMain)) {
process.stderr.write(
`[serve-demo] bundled bridge not found at ${bundledMain}\n` +
" Run `pnpm --filter zoo-code bundle` (or `node src/esbuild.mjs`) first.\n",
)
process.exit(1)
}

const child = spawn("node", [bundledMain, "--socket", socketPath, "--serve"], {
stdio: ["ignore", "pipe", "pipe"],
})

let gotEvent = false

child.stdout.on("data", (chunk: Buffer) => {
const line = chunk.toString().trim()

// The bridge streams TaskEvents as ndjson; pretty-print each line.
if (line.startsWith("{")) {
process.stdout.write(`[serve-demo] bridge streamed: ${line}\n`)
gotEvent = true
}
})

child.stderr.on("data", (chunk: Buffer) => process.stderr.write(`[bridge] ${chunk.toString()}`))

// Give the bridge a moment to connect, then broadcast a TaskEvent.
await new Promise((resolve) => setTimeout(resolve, 500))

process.stderr.write("[serve-demo] broadcasting taskStarted event\n")

server.broadcast({
type: IpcMessageType.TaskEvent,
origin: IpcOrigin.Server,
data: {
eventName: RooCodeEventName.TaskStarted,
payload: ["task-serve-demo-123"],
} as TaskEvent,
})

// Wait for the bridge to stream it back.
await new Promise((resolve) => setTimeout(resolve, 500))

child.kill("SIGTERM")

await new Promise<void>((resolve) => {
child.on("close", (code) => {
process.stderr.write(`\n[serve-demo] bridge exited with code ${code}\n`)
resolve()
})
})

try {
fs.unlinkSync(socketPath)
} catch {
// ignore
}

process.exit(gotEvent ? 0 : 1)
}

void main()
Loading
Loading