ollie/doc/REMOTE_EXECUTION.md

12 KiB

Remote Execution (Split-Brain)

Local agent loop + remote tool execution over SSH.

Motivation

The default ollie deployment runs everything on one machine: the agent loop (LLM calls, prompt assembly, event bus), tool execution (sandbox, bash, file I/O), and the interaction layer (D-Bus signals, GUI). This works for local development but breaks down when:

  • The codebase lives on a remote server (SSH host, cloud VM, container)
  • The build environment requires specific hardware or OS
  • You want local GUI responsiveness while operating on remote filesystems
  • Network latency makes full-remote agents sluggish (per-token streaming over WAN)

The split-brain model keeps the brain local (agent loop, LLM, D-Bus, GUI) and sends the hands remote (tool execution, sandbox, file access). This is the inverse of 9P remote mount: instead of bringing remote state to your local filesystem, you send local decisions to a remote executor.

Architecture

Local machine                              Remote host (via SSH)
┌──────────────────────────────┐         ┌──────────────────────────────┐
│ olliesrv / ollied             │         │ ollie-remote                  │
│                              │         │                              │
│  ┌────────────────────────┐  │  SSH    │  ┌────────────────────────┐  │
│  │ agent.Core             │  │────────▶│  │ execute.Server         │  │
│  │  - LLM streaming       │  │  JSON   │  │  - landrun sandbox     │  │
│  │  - prompt assembly     │  │  RPC    │  │  - bash/python/perl    │  │
│  │  - event bus           │  │◀────────│  │  - tool scripts        │  │
│  │  - compaction          │  │         │  │  - file I/O            │  │
│  └────────────────────────┘  │         │  └────────────────────────┘  │
│                              │         │                              │
│  ┌────────────────────────┐  │         │  Working directory:          │
│  │ D-Bus adapter          │  │         │    /home/user/project/       │
│  │  - SessionCreated      │  │         │                              │
│  │  - StateChanged        │  │         │  Sandbox config:             │
│  │  - ChatUpdated         │  │         │    remote host's landrun     │
│  └────────────────────────┘  │         │                              │
│         ▲                    │         └──────────────────────────────┘
│         │ D-Bus signals      │
│         ▼                    │
│  ┌────────────────────────┐  │
│  │ KDE GUI / Web UI       │  │
│  └────────────────────────┘  │
└──────────────────────────────┘

Split Point

The natural split is at tools.Server. Today, agent.Core calls execute.Server in-process. The remote mode replaces this with a RemoteServer that forwards calls over an SSH-backed RPC channel:

// Local side
type RemoteServer struct {
    conn *rpc.Conn  // JSON-RPC over SSH stdin/stdout
}

func (r *RemoteServer) Call(ctx context.Context, req tools.Request) (tools.Response, error) {
    return r.conn.Call(ctx, "execute", req)
}

The agent loop doesn't know or care whether execution is local or remote. The NewDispatcherFunc in session creation just returns a RemoteServer instead of a local execute.Server when the session is configured for remote execution.

SSH Bootstrap

Inspired by goq's approach (toolserv/ssh.go):

  1. Open SSH connection to target host
  2. Send a bootstrap shell script over stdin
  3. Bootstrap checks for cached ollie-remote binary (by SHA-256 hash)
  4. If not cached: receive binary over stdin (gzipped + base64), install to ~/.cache/ollie/bin/
  5. Launch ollie-remote with the working directory as argument
  6. Signal readiness over stderr
  7. Switch to JSON-RPC over stdin/stdout
# Bootstrap (simplified)
HASH="@@HASH@@"
BIN="$HOME/.cache/ollie/bin/ollie-remote-$HASH"
if [ -x "$BIN" ]; then
    echo "GOQ_LOADER_START {\"need_download\":false}" >&2
else
    echo "GOQ_LOADER_START {\"need_download\":true}" >&2
    # ... receive and install binary ...
    echo "GOQ_LOADER_READY" >&2
fi
exec "$BIN" serve --cwd "@@CWD@@"

ollie-remote Binary

A minimal binary containing:

  • execute.Server (the same one used locally)
  • Sandbox enforcement (landrun)
  • Tool script discovery from a bundled or remote-local OLLIE_TOOLS_PATH
  • JSON-RPC server over stdin/stdout

It does NOT contain:

  • Agent loop
  • LLM backends
  • D-Bus anything
  • Session management
  • Prompt resolution
// cmd/ollie-remote/main.go
func main() {
    cwd := flag.Arg(0)
    srv := execute.NewServer(cwd, toolsPath, sandboxConfig)
    rpc.ServeStdio(srv)
}

RPC Protocol

JSON-RPC 2.0 over stdin/stdout (newline-delimited):

// Request (local → remote)
{"jsonrpc":"2.0","id":1,"method":"execute","params":{
  "steps":[{"code":"go build ./..."}],
  "timeout":60,
  "sandbox":"default"
}}

// Response (remote → local)
{"jsonrpc":"2.0","id":1,"result":{
  "output":"...",
  "exit_code":0
}}

// Streaming output (remote → local, notification)
{"jsonrpc":"2.0","method":"output","params":{
  "step":0,"data":"compiling..."
}}

Streaming output notifications allow the local agent to see partial results in real-time (for the event bus / ChatUpdated signals).

Session Configuration

A session targeting a remote host:

# Via 9P
printf 'cwd=/home/user/project\nremote=devbox\n' > $OLLIE/s/new

# Via D-Bus
dbus-send ... org.ollie.SessionManager.CreateSession \
  string:"/home/user/project" string:"" string:"" string:"default" \
  string:"devbox"  # additional arg or config key

Or as a session config key:

remote=user@devbox.internal

The remote field is an SSH target. When set, the session manager:

  1. Bootstraps ollie-remote on the target
  2. Creates a RemoteServer connected over the SSH channel
  3. Passes it to agent.Core as the tool dispatcher

Tool Scripts

Two strategies for making tool scripts available remotely:

  1. Embedded: ollie-remote bundles the standard tool scripts (file_read, file_grep, etc.) at compile time. Custom tools would need to be synced.

  2. Sync-on-connect: During bootstrap, rsync OLLIE_TOOLS_PATH to the remote ~/.cache/ollie/tools/. This handles custom tools transparently.

  3. Hybrid: Standard tools embedded, custom tools synced on first use (detected by hash mismatch).

Option 2 is simplest and matches goq's philosophy of "the remote is a mirror of local capabilities."

What Stays Local

Component Location Why
Agent loop Local Latency: LLM streaming is per-token
LLM backends Local API keys stay local, streaming stays low-latency
D-Bus signals Local GUI responsiveness
Memory Local Personal knowledge, not project-specific
Skills Local Read during prompt assembly, not execution
Prompts Local Assembled before the loop starts
Session persistence Local State management is a local concern

What Goes Remote

Component Location Why
execute_code Remote Needs remote filesystem, build tools
call_tool (file_*) Remote File I/O targets remote tree
call_tool (lsp_*) Remote Language server needs remote source
Sandbox Remote Landrun enforces on the execution host
Working directory Remote The project lives there

Comparison with 9P Remote Mount

9P Mount Split-Brain
Agent runs on Remote Local
Tools execute on Remote Remote
GUI connects to Remote (via 9P) Local (D-Bus)
LLM calls from Remote Local
Latency profile All remote Only tool calls remote
Network requirement Persistent 9P mount SSH session per tool call batch
API keys On remote host On local machine
Use case Observe/control remote agents Develop on remote, interact locally

They complement each other. You might use split-brain for active development (local GUI, remote execution) and 9P mount for monitoring a fleet of headless agents.

Implementation Plan

Phase 1: ollie-remote binary

  • New submodule remote/ or cmd/ollie-remote/
  • Wraps execute.Server with JSON-RPC stdio interface
  • Includes sandbox, tool script discovery
  • Cross-compile for linux/amd64 and linux/arm64

Phase 2: SSH bootstrap

  • core/pkg/tools/execute/ssh.go or new package
  • Bootstrap script generation (hash-based caching)
  • Binary transfer over stdin
  • Connection lifecycle management

Phase 3: RemoteServer adapter

  • Implements tools.Server interface
  • Forwards calls over SSH-backed RPC connection
  • Handles streaming output notifications
  • Reconnection on SSH drop

Phase 4: Session integration

  • remote= config key in session creation
  • cfg file supports reading/writing remote target
  • D-Bus method: SetConfig(id, "remote", "user@host")

Phase 5: Tool sync

  • Rsync OLLIE_TOOLS_PATH to remote on connect
  • Hash-based skip for unchanged tools
  • Hot-reload: detect local tool changes, re-sync

Sandbox on Remote

The remote sandbox is simpler than local — ollie-remote only does execution, not session management, prompts, or D-Bus.

Configuration layering

  1. Embedded default — ships with ollie-remote, covers 90% of Linux hosts
  2. .ollie-sandbox.yaml in project root — per-project overrides, version-controlled
  3. Auto-detection — bootstrap detects NixOS, adjusts paths accordingly

Convention over configuration: the common case requires zero setup.

Embedded default config

filesystem:
  ro:
    - "/etc"
    - "/proc"
    - "/sys"
    - "/dev"
    - "{HOME}/.ssh/known_hosts"
    - "{HOME}/.gitconfig"
    - "{HOME}/.netrc"
  rox:
    - "/usr"
    - "/lib"
    - "/lib64"
    - "/bin"
    - "/sbin"
    - "{HOME}/go/bin"
    - "{HOME}/.cargo/bin"
    - "{HOME}/.local/bin"
    - "{HOME}/.nvm"
    - "{HOME}/.pyenv"
    - "{OLLIE_TOOLS_PATH}"
  rwx:
    - "{CWD}"
    - "{TMPDIR}"
    - "{HOME}/.cache"
    - "{HOME}/go"
    - "{HOME}/.cargo"
    - "{HOME}/.npm"
    - "{HOME}/.cache/go-build"
env:
  - PATH
  - HOME
  - TMPDIR
  - GOPATH
  - GOBIN
  - SSH_AUTH_SOCK
network:
  unrestricted: true
general:
  best_effort: true

Per-project override (.ollie-sandbox.yaml)

Dropped in the project root, merged on top of the default:

# .ollie-sandbox.yaml — this project needs Docker
filesystem:
  rw:
    - "/var/run/docker.sock"
    - "{HOME}/.docker"
env:
    - DOCKER_HOST

Why it's simpler than local

Local sandbox needs Remote sandbox needs
OLLIE_* paths (10+) Just OLLIE_TOOLS_PATH
D-Bus socket No
Elevation socket No
9P mount paths No
Session/transcript dirs No
Editor integration No

The remote config is ~20 lines vs ~170 locally.

Kernel compatibility

  • Linux 5.13+: Full Landlock enforcement
  • Older kernels: best_effort: true degrades gracefully (unenforced rules skipped)
  • No Landlock at all: --yolo flag skips sandboxing (explicit opt-in, logged as warning)

ollie-remote ships landrun alongside itself in ~/.cache/ollie/bin/. No system-wide install required.

Open Questions

  • Elevation on remote: elevated: true escapes the sandbox. On remote, this means escaping the remote sandbox. The elevation socket doesn't exist remotely. Options: skip elevation, or run a remote elevation adapter.
  • Detached processes: Currently tracked locally. Remote detached processes need their own lifecycle.
  • Multiple remotes per session: Useful? Or one remote per session is sufficient?
  • Fallback: If SSH drops mid-execution, retry? Fail the tool call? The agent can handle tool errors gracefully already.