Consolidate remote architecture documentation

This commit is contained in:
Ollie Agent 2026-08-16 19:41:19 +02:00
parent 3b587431a4
commit 670fa717c7
4 changed files with 138 additions and 507 deletions

136
doc/architecture-remote.md Normal file
View File

@ -0,0 +1,136 @@
# Remote Toolsrv Architecture
Ollie keeps the agent loop local and moves tool execution to a remote host when a session has a `remote` target. The remote boundary is the same authenticated 9P `toolsrv` protocol used for local execution. There is no separate `ollie-remote` binary, JSON-RPC protocol, or in-process `Runner` adapter.
## Architecture
```mermaid
flowchart LR
A[olliesrv agent loop] --> C[ToolsrvConn]
C -->|local Unix socket or SSH-forwarded Unix socket| T[toolsrv]
T --> R[remote registry]
T --> E[remote tool execution]
E --> S[remote sandbox]
E --> W[remote working directory]
```
The agent uses `ToolsrvConn` for both local and remote sessions. It lists tools, loads and unloads tools, calls tools, reads background-process output, and handles cancellation through the same 9P client methods. Only the process and socket setup differ.
## Session configuration
A session’s `remote` value is an SSH target. It may be supplied during session creation or as a session configuration value:
```text
remote=user@devbox
```
The target may include a port in `host:port` form. `session.SetupToolServer` selects `SpawnRemote` when the target is non-empty. The session stores the target and reuses the resulting `ProcessKeeper` and connection when agents are created or reloaded.
Remote execution therefore moves:
- the toolsrv process;
- tool metadata and installed tool commands;
- the working directory used by tool calls; and
- sandbox enforcement.
The following remain local:
- the agent loop and conversation history;
- LLM backend calls and credentials;
- prompt assembly and agent configuration;
- session persistence and the local 9P control namespace; and
- GUI or other local interaction layers.
## Startup and deployment
`toolclient.SpawnRemote` performs the following sequence:
1. Validate that the local Ollie configuration directory exists.
2. Create a local forwarded socket path and a temporary remote socket path.
3. Start `ssh` with local Unix-socket forwarding (`-L`).
4. Stream a gzipped tar archive of the local Ollie configuration directory to the remote shell.
5. Run a remote bootstrap script through `bash -s`.
6. Extract the archive into `${XDG_CACHE_HOME:-$HOME/.cache}/ollie`.
7. Set remote `XDG_CONFIG_HOME` and prepend the cached `bin` directory to `PATH`.
8. Start the cached `toolsrv` with the requested working directory, remote socket, session ID, and optional `--yolo` flag.
9. Wait for the toolsrv listening message and the local forwarded socket.
10. Return a managed `Process` whose socket path is local but whose server runs remotely.
The transferred configuration supplies the remote tools directory and metadata. Remote discovery and metadata variants therefore describe the remote host’s available commands, files, operating system, architecture, and environment.
The remote service is cached under `$XDG_CACHE_HOME/ollie` and is not installed system-wide. The bootstrap replaces the cache contents from the transferred archive for the session startup.
## Authentication and transport
The client connects to the forwarded local socket with `DialToolsrv`. The toolsrv server still performs the normal 9P `Tauth` exchange. The connection receives a secret and session token, and the client sends the session and agent identities used to scope registry state and process requests.
SSH protects the forwarded socket transport. 9P authentication remains enabled on the toolsrv protocol. The remote path does not introduce a second application protocol.
## Tool calls and results
A remote tool call follows the normal toolsrv path:
```text
agent
└─ ToolsrvConn.CallTool(ctx, name, args)
└─ authenticated 9P proc/new
└─ remote toolsrv registry lookup
└─ remote metadata command resolution
└─ remote sandboxed process
└─ structured ToolResult
```
Tool metadata is loaded from the remote configuration. The agent receives the remote catalog and renders the same tool definitions and documentation into its runtime.
Background calls use `proc/new.bg`. Output and exit state remain available through the remote toolsrv process namespace. The client reads them through the forwarded connection.
## Cancellation and recovery
Cancellation closes the active 9P request fid. The remote toolsrv cancels its request context, and `exec.CommandContext` terminates the remote child process. Closing the SSH-backed connection or cancelling the session context also terminates the managed SSH process and remote toolsrv process.
`ProcessKeeper` owns the current process and can respawn it when a new connection is needed. For a remote session, respawning repeats the SSH bootstrap, configuration transfer, remote toolsrv startup, and socket forwarding sequence. Agent reloads dial a fresh connection through the keeper while preserving the session-level infrastructure.
A dropped SSH connection does not silently replay a tool call. The failed connection or tool request returns an error; the keeper can establish a replacement toolsrv process for subsequent operations.
## Environment and prompt context
The agent prompt is assembled locally, but environment facts describing the tool host must come from the remote process setup. `SetupToolServer` uses the remote process information when constructing the environment section. The current implementation reports the remote platform as Linux and does not yet detect the remote Git state in `SpawnRemote`; callers should not treat those values as full host discovery.
The working directory passed to `SpawnRemote` is sent to the remote toolsrv `--cwd` argument. Tool commands execute relative to that remote directory, not the local caller’s filesystem.
## Sandbox
Sandbox policy is enforced where the command runs. A remote tool call uses the remote toolsrv sandbox and the remote host’s filesystem, network, environment, and Landlock capabilities. The `--yolo` session option passes through to the remote toolsrv and disables enforcement for explicit development use.
The local bypass broker is not a remote execution engine. Bypass requests and policy handling remain part of the toolsrv/olliesrv integration described in [`architecture-toolsrv.md`](architecture-toolsrv.md); the command itself executes on the remote host.
## Comparison with local execution
| Concern | Local session | Remote session |
|---|---|---|
| Agent loop | Local | Local |
| Toolsrv process | Local child process | Remote child process under SSH |
| Client socket | Local Unix socket | Local forwarded Unix socket |
| 9P protocol | Authenticated | Authenticated |
| Tool registry | Local toolsrv | Remote toolsrv |
| Tool commands | Local host | Remote host |
| Working directory | Local path | Remote path |
| Sandbox | Local toolsrv policy | Remote toolsrv policy |
| Prompt assembly | Local | Local |
| LLM calls | Local | Local |
Remote execution is useful when the project, compilers, dependencies, or hardware live on another host while the user interface and model connection remain local.
## Implementation map
| Responsibility | Implementation |
|---|---|
| Session selection and lifecycle | `cmd/olliesrv/internal/session/setup.go` |
| Local and remote process spawning | `cmd/olliesrv/internal/toolclient/spawn.go` |
| SSH bootstrap and archive transfer | `cmd/olliesrv/internal/toolclient/spawn.go` |
| Authenticated 9P client | `cmd/olliesrv/internal/toolclient/toolsrv.go` |
| Remote toolsrv service | `cmd/toolsrv/` |
| Registry and metadata | `cmd/toolsrv/internal/registry/`, `toolsrv/metadata/` |
| Execution and sandbox | `cmd/toolsrv/internal/exec/`, `cmd/toolsrv/internal/sandbox/` |
The complete toolsrv service architecture is documented in [`architecture-toolsrv.md`](architecture-toolsrv.md). The agent-side integration is documented in [`architecture-core.md`](architecture-core.md).

View File

@ -2,6 +2,8 @@
`toolsrv` is Ollie's separate tool-execution service. It exposes a 9P filesystem over a Unix socket. `olliesrv` connects through `ToolsrvConn`; remote deployments use the same client protocol over an SSH-forwarded Unix socket.
This document is authoritative for the registry, metadata, protocol, execution, and service architecture.
The service owns tool discovery, metadata resolution, per-agent loaded-tool state, process execution, output streaming, sandbox enforcement, bypass requests, and process lifecycle.
## Position in the runtime

View File

@ -1,391 +0,0 @@
# 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
```mermaid
flowchart TB
subgraph Local["Local Machine"]
SRV["olliesrv"]
AG["agent.Agent\n- LLM streaming\n- prompt assembly\n- event bus\n- compaction"]
DBUS["D-Bus adapter\n- SessionCreated\n- StateChanged\n- ChatUpdated"]
GUI["KDE GUI"]
end
subgraph Remote["Remote Host (via SSH)"]
REMOTE_BIN["ollie-remote"]
EXEC["toolsrv.Server\n- landrun sandbox\n- shell execution\n- tool definitions\n- file I/O"]
CWD["Working directory:\n$HOME/project/"]
SANDCFG["Sandbox config:\nremote host's landrun"]
end
AG -- "SSH / JSON-RPC" --> REMOTE_BIN
REMOTE_BIN --> EXEC
EXEC --> CWD
EXEC --> SANDCFG
DBUS -.->|"D-Bus signals"| GUI
```
## Split Point
The natural split is at `toolsrv.Runner`. Today, `agent.Agent` calls `toolsrv.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) CallTool(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) {
return r.conn.Call(ctx, "execute", tool, args)
}
```
The agent loop doesn't know or care whether execution is local or remote. The `NewToolServer` factory in session creation just returns a `RemoteServer` instead of a local `toolsrv.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:
- `toolsrv.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 := toolsrv.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=%s\nremote=devbox\n' "$PWD" > $OLLIE/session/new
# Via D-Bus
dbus-send ... org.ollie.SessionManager.CreateSession \
string:"$HOME/project" string:"" string:"" string:"default" \
string:"" string:"devbox" # remote host
```
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.Agent` as the tool runner
## Tool Definitions
Two strategies for making tool definitions available remotely:
1. **Embedded**: `ollie-remote` bundles the standard tools (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 |
|---|---|---|
| shell | Remote | Needs remote filesystem, build tools |
| Tool definitions | Remote | File I/O targets remote tree |
| 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 Status
All five phases are implemented:
### Phase 1: ollie-remote binary ✓
- `cmd/ollie-remote/` with `main.go`
- Wraps `toolsrv.Server` with JSON-RPC stdio interface
- Embeds `sandbox-remote.yaml` and tool definitions via `go:embed`
- RPC methods: `shell`, `list_tools`, `host_info`, `ping`
### Phase 2: SSH bootstrap ✓
- `toolsrv/bootstrap.sh` embedded template
- SHA-256 hash-based caching on the remote
- Binary transfer: gzip + base64 over stdin
- `Dial()` handles full lifecycle: bootstrap → transfer → exec → JSON-RPC
### Phase 3: RemoteServer adapter ✓
- `toolsrv/remote.go` implements `toolsrv.Runner`
- `ListTools()` calls remote `list_tools` RPC
- `CallTool()` passes tool name as RPC method (routes shell)
- `HostInfo` fetched after connect (platform, arch, is_git_repo)
### Phase 4: Session integration ✓
- `remote=user@host:port` config key in session creation
- Eager dial during session creation (gets HostInfo for prompts)
- `PromptEnvExtra` stored on agent for /agent reloads
- FUSE mount started directly for remote sessions (prompt resolution)
### Phase 5: Tool definitions ✓
- Embedded in ollie-remote binary at compile time
- `justfile` copies from `tools/` submodule before building
- Extracted to tmpdir on startup, cleaned up on exit
- `file_grep`/`file_glob` fall back to GNU grep/find when rg unavailable
- 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 |
| Bypass 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
- **Bypass on remote**: `bypass: true` escapes the sandbox. On remote, this means escaping the remote sandbox. The bypass socket doesn't exist remotely. Options: skip bypass, or run a remote bypass 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.
- **Session restore**: `remote=` is not persisted in PersistedSession. Restored sessions always come back as local.
- **Eager dial**: Session creation blocks on SSH connect. Should be async with lazy tool list.
- **Streaming output**: ✓ Resolved. See Streaming section below.
## Lessons Learned
1. **Prompt resolution is always local.** The prime script runs on the local machine. It must be a pure template renderer — no local detection of platform/git/cwd.
2. **PRIME_* env vars are the contract.** Callers populate PRIME_CWD, PRIME_PLATFORM, PRIME_IS_GIT_REPO and store them in `PromptEnvExtra` for /agent reloads.
3. **Sandbox config must overwrite on deploy.** Skip-if-exists means binary updates don't propagate config changes. Always write.
4. **Tool scripts must be self-contained.** They need fallbacks when rg/ripgrep isn't available (GNU grep, find).
5. **Embed over sync.** Embedding tools in the binary (go:embed) is simpler than rsync — single artifact, atomic deploy, no state management.
6. **Build from source, not installed copies.** The justfile must copy from the repo's tools/ submodule, not ~/.config/ollie/tools/.
7. **No new event roles.** Streaming uses the same `"tool"` role as final results. Empty content signals stream start; frontends need no changes.
## Streaming
Tool output streams in real-time over the SSH channel, visible to the user as it arrives.
### Protocol
Remote → local streaming uses JSON-RPC notifications (no `id` field):
```json
{"jsonrpc":"2.0","result":{"data":"compiling...\n"}}
```
`remote.Server.CallTool` reads these in a loop, calling `toolsrv.StreamOutput(ctx, data)` for each, until the actual response (with matching `id`) arrives.
### Agent Loop Integration
The agent loop wraps the execution context with a streaming callback:
```go
streamCtx := toolsrv.WithOutputStream(ctx, func(data string) {
emit(cfg, Event{Role: "tool", Name: tc.Name, Content: data})
})
```
On first chunk, an empty-content tool event is emitted to signal stream start. If streaming occurred, the final result emit is suppressed (the content was already delivered chunk-by-chunk).
### Execute Server
`limitedWriter` has a `stream func(string)` field, wired to `toolsrv.StreamFunc(ctx)`. Every `Write()` call flushes the chunk both to the output buffer (for the full result) and to the stream callback (for real-time display).
### Chat Log
`startEventLog` treats tool streaming like assistant streaming:
- Empty-content tool event → writes `[tool:name]\n` header, enters streaming mode
- Subsequent tool events → content appended directly (no repeated header)
- Non-tool event → closes the stream with a newline
For non-streamed tools (fast results), the single tool event renders as before.

View File

@ -1,116 +0,0 @@
# Tool Registry
`toolsrv` owns Ollie's tool registry. No tool registry is compiled into `olliesrv`. The registry is scoped per agent, so each agent can load and unload tools independently while sharing the session's `toolsrv` process.
## Discovery
The registry scans the configured tools directory for `*.meta` files:
```text
$XDG_CONFIG_HOME/ollie/tools/ # default: ~/.config/ollie/tools/
├── file_read.meta
├── file_read
└── custom_tool.meta
```
A valid metadata file is enough for a tool to appear in the catalog. The executable is resolved when the tool is invoked. Invalid metadata is skipped. A tool with variants is unavailable when no variant matches the host.
The registry reads metadata, resolves the selected command, and passes tool-call arguments as JSON on standard input.
## Agent operations
```sh
# List tools currently loaded for an agent
ollie-9p read session/{session}/agent/{agent}/tools
# Load a tool
echo tool_name | ollie-9p write session/{session}/agent/{agent}/tools
# Unload a tool
echo tool_unload tool_name | ollie-9p write session/{session}/agent/{agent}/ctl
# List every discoverable tool
echo tools_all | ollie-9p write session/{session}/agent/{agent}/ctl
```
`tool_load` and `tool_unload` are also callable agent operations. Loading updates the agent's tool preamble and increments its per-agent registry revision. `toolsrv.loaded` and `toolsrv.rev` expose the loaded set and revision through 9P.
Agent profiles may specify startup loading with `autoLoad`. Discovery and loading are separate: a discovered tool is not callable until it is loaded.
## Metadata
A `.meta` file uses this structure:
```json
{
"description": "Find files by glob pattern.",
"prompt": "Full usage documentation injected when loaded.",
"args": {
"type": "object",
"required": ["pattern"],
"properties": {"pattern": {"type": "string"}}
},
"tier": "cold",
"scope": "read",
"outputFormat": "text",
"cmd": "file_glob",
"resetsCounter": false
}
```
| Field | Meaning |
|---|---|
| `description` | Short catalog description. |
| `prompt` | Documentation added when the tool is loaded. |
| `args` | JSON Schema for tool arguments. Defaults to an empty object schema. |
| `tier` | Context retention tier. Defaults to `hot`. |
| `scope` | Tool scope metadata. |
| `outputFormat` | Output format metadata. |
| `cmd` | Command used to execute the tool. |
| `resetsCounter` | Marks tools that reset the relevant counter. |
| `variants` | Host-conditional metadata definitions. |
`readOnly` and `bypass` are not current metadata fields. Sandbox and privilege behavior is configured by the execution service and its policies.
## Command resolution
For a tool named `name`, toolsrv resolves the command in this order:
1. `$XDG_CONFIG_HOME/ollie/tools/name` if the local file exists.
2. The resolved `.meta` `cmd` field, returned as a shell command string.
3. `name` found through `$PATH`.
A `.meta` file can describe a co-located script, a system executable, or a shell command. Metadata-only tools are valid when their `cmd` resolves to an executable command.
## Variants
The first variant whose conditions all match is merged over the top-level metadata. Variant values override non-empty top-level values. Supported match keys are:
- `binary`: an absolute path or executable available through `$PATH`.
- `file`: an existing file or directory.
- `os`: the current `runtime.GOOS`.
- `arch`: the current `runtime.GOARCH`.
- `env`: a non-empty environment variable.
- `nenv`: an empty or unset environment variable.
A condition value can be a string or an array; arrays use OR semantics. Multiple condition keys use AND semantics. If no variant matches, the tool is hidden.
```json
{
"description": "Read system logs.",
"variants": [
{"match": {"binary": "journalctl"}, "cmd": "system_logs_journald", "scope": "read"},
{"match": {"file": "/var/log/syslog"}, "cmd": "system_logs_syslog", "scope": "read"}
]
}
```
Remote toolsrv deployments receive the metadata and resolve variants on the remote host. The catalog therefore reflects that host's capabilities.
## Execution contract
The selected command receives one JSON object on standard input. A successful tool writes its result to standard output and exits with status 0. A failed tool exits non-zero. Structured content may be returned as JSON when required by the tool's output format.
Normal execution is sandboxed by toolsrv. The `--yolo` server option disables sandbox enforcement for explicit debugging or development. Bypass behavior is controlled by the separate bypass broker and sandbox policy.
See [`doc/writing-tools.md`](writing-tools.md) for authoring metadata and [`doc/tool-definitions.md`](tool-definitions.md) for metadata-only command definitions.