341 lines
12 KiB
Markdown
341 lines
12 KiB
Markdown
# 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:
|
|
|
|
```go
|
|
// 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
|
|
|
|
```bash
|
|
# 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
|
|
|
|
```go
|
|
// 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):
|
|
|
|
```json
|
|
// 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:
|
|
|
|
```bash
|
|
# 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
|
|
|
|
```yaml
|
|
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:
|
|
|
|
```yaml
|
|
# .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.
|