Architecture, protocol, deployment, and security reference for the Berth agent system.
The Berth Remote Agent is a persistent Rust binary that runs on Linux servers as a systemd service. It receives code deployments from the Berth macOS app (or CLI) over gRPC or NATS, manages execution history in a local SQLite database, runs scheduled jobs independently, publishes services via cloudflared tunnels, and supports remote self-upgrade.
Key principle: Zero runtime dependencies + full persistence. The agent is a single static binary with embedded SQLite. All execution history, logs, events, and schedules survive restarts. Communication flows through NATS (primary) or gRPC (fallback) — neither desktop nor agent needs to expose inbound ports.
| Property | Value |
|---|---|
| Binary | berth-agent |
| Language | Rust (shared berth-core crate with app) |
| Protocol | gRPC over HTTP/2 (tonic 0.12) + NATS command channel, 16 RPCs |
| Default port | 50051 |
| Protobuf schema | proto/berth.proto |
| Local database | ~/.berth/agent.db (SQLite, 5 tables) |
| Deployments dir | ~/.berth/deploys/{project_id}/v{n}/ |
| Supported platforms | Linux x86_64, Linux aarch64 |
| Scheduler | Built-in, tick every 30s, runs independently of app |
| Tunnel providers | cloudflared (pluggable) |
| NATS transport | User-provided via Synadia Cloud (BYON) |
Defined in proto/berth.proto. The agent implements a single gRPC service with 16 RPCs:
AgentService| RPC | Request | Response | Type | Description |
|---|---|---|---|---|
Health | HealthRequest | HealthResponse | Unary | Agent version, status, uptime, os, arch, tunnel providers |
Status | StatusRequest | StatusResponse | Unary | CPU, memory, running projects list |
Deploy | DeployRequest | DeployResponse | Unary | Extract tarball, install deps, persist deployment |
Execute | ExecuteRequest | stream ExecuteResponse | Server streaming | Run code, stream logs, persist to SQLite |
Stop | StopRequest | StopResponse | Unary | Kill process, emit stop event |
StreamLogs | LogStreamRequest | stream LogStreamResponse | Server streaming | Live log streaming |
GetExecutions | GetExecutionsRequest | GetExecutionsResponse | Unary | Query persistent execution history |
GetExecutionLogs | GetExecutionLogsRequest | stream LogStreamResponse | Server streaming | Replay stored log lines (supports since_seq) |
GetEvents | GetEventsRequest | GetEventsResponse | Unary | Poll store-and-forward events (since_id) |
AckEvents | AckEventsRequest | AckEventsResponse | Unary | Acknowledge + prune old events |
AddSchedule | AddScheduleRequest | AddScheduleResponse | Unary | Create agent-side cron schedule |
RemoveSchedule | RemoveScheduleRequest | RemoveScheduleResponse | Unary | Delete a schedule |
ListSchedules | ListSchedulesRequest | ListSchedulesResponse | Unary | List schedules for a project |
Upgrade | stream UpgradeChunk | UpgradeResponse | Client streaming | Upload new binary, verify, swap, restart |
Publish | PublishRequest | PublishResponse | Unary | Start a cloudflared tunnel for a project |
Unpublish | UnpublishRequest | UnpublishResponse | Unary | Stop the tunnel for a project |
message ExecuteRequest {
string project_id = 1; // Unique project identifier
string runtime = 2; // "python", "node", "go", "shell", "rust"
string entrypoint = 3; // Filename: "main.py", "index.js", "run.sh"
bytes code = 4; // Inline code content (the file bytes)
string working_dir = 5; // Fallback working directory (if no inline code)
}
message ExecuteResponse {
string stream = 1; // "stdout" or "stderr"
string text = 2; // One line of output
string timestamp = 3; // RFC 3339 timestamp
}
message HealthResponse {
string agent_version = 1; // e.g. "0.1.9"
string status = 2; // "healthy"
uint64 uptime_seconds = 3; // Seconds since agent started
string os = 6; // e.g. "linux" (from std::env::consts::OS)
string arch = 7; // e.g. "x86_64" (from std::env::consts::ARCH)
repeated string tunnel_providers = 9; // e.g. ["cloudflared"] — installed tunnel binaries
}
message StatusResponse {
string agent_id = 1; // Hostname of the server
string status = 2; // "running"
double cpu_usage = 3; // Global CPU % (via sysinfo)
uint64 memory_bytes = 4; // Used memory in bytes
repeated ProjectStatus projects = 5;
}
The primary transport for remote agent communication. Both the desktop app and the agent connect outbound to Synadia Cloud — zero inbound ports required on either side. Works behind NAT, firewalls, and across different networks.
Zero inbound ports: Both desktop and agent connect outbound to your Synadia Cloud NATS account. No port forwarding, no firewall rules, no direct network connectivity between desktop and agent.
Defined in crates/berth-core/src/agent_transport.rs. A unified async trait that abstracts the transport layer:
// Both AgentClient (gRPC) and NatsAgentClient implement this trait.
// Transport is selected per target based on the nats_enabled flag.
//
// If target has nats_enabled=true + nats_agent_id → NatsAgentClient
// Otherwise → AgentClient (gRPC fallback)
The NatsCommandKind enum defines 15 command variants sent over NATS:
| Variant | Description |
|---|---|
Health | Agent health check |
Status | CPU, memory, running projects |
Stop | Kill a running project |
Execute | Run code (streaming response) |
Deploy | Deploy code to agent |
GetExecutions | Query execution history |
GetExecutionLogs | Replay stored log lines |
AddSchedule | Create agent-side cron schedule |
RemoveSchedule | Delete a schedule |
ListSchedules | List schedules for a project |
UpgradeDownload | Agent downloads binary from URL + checksum |
DeployChunked | Chunked code deployment over NATS |
Rollback | Rollback to previous agent binary |
Publish | Start a cloudflared tunnel |
Unpublish | Stop a cloudflared tunnel |
berth.<agent_id>.cmd.<type> # Commands from desktop → agent
berth.<agent_id>.resp.<request_id> # Streaming responses from agent → desktop
berth.<agent_id>.events # Store-and-forward events (JetStream)
berth.<agent_id>.logs # Log streaming (JetStream)
berth.<agent_id>.heartbeat # Periodic heartbeat (JetStream)
Defined in crates/berth-agent/src/nats_cmd_handler.rs. Subscribes to berth.<agent_id>.cmd.> and dispatches incoming commands to the corresponding PersistentAgentService::do_*() methods. Uses request-reply for simple RPCs and publish+subscribe for streaming operations (Execute, Deploy, Logs).
Defined in crates/berth-core/src/nats_cmd_client.rs. Implements the AgentTransport trait over NATS. Uses request-reply for unary RPCs and publish+subscribe for streaming responses.
The get_agent_client() function in agent_transport.rs returns a Box<dyn AgentTransport>. If the target has nats_enabled=true and a nats_agent_id, communication routes through NATS. Otherwise, it falls back to direct gRPC.
Target UI: The Add Target form includes an optional "NATS Agent ID" field. Targets with NATS enabled show a green "NATS" badge. The update_target_nats Tauri command toggles NATS on/off per target.
Running projects can be published to a public URL via cloudflared tunnels. The architecture is pluggable — adding a new tunnel provider (ngrok, bore, custom) requires changes to one file only (tunnel.rs).
Defined in crates/berth-core/src/tunnel.rs. Manages the lifecycle of tunnel processes:
cloudflared implemented initially.cloudflared tunnel --url http://127.0.0.1:{port}, parses the public URL from stderr (30s timeout).Important: cloudflared must be installed separately on the agent machine — it is NOT bundled with the Berth agent binary. Install from GitHub releases or your package manager.
The HealthResponse includes a tunnel_providers field (repeated string) that reports which tunnel binaries are installed on the agent. The available_providers() function checks for installed binaries at health-check time.
| Layer | Publish | Unpublish |
|---|---|---|
| Proto RPCs | Publish(PublishRequest) | Unpublish(UnpublishRequest) |
| AgentTransport trait | publish() | unpublish() |
| gRPC client | AgentClient::publish() | AgentClient::unpublish() |
| NATS client | NatsAgentClient::publish() | NatsAgentClient::unpublish() |
| NATS command | NatsCommandKind::Publish | NatsCommandKind::Unpublish |
| Tauri commands | publish_project | unpublish_project |
| React UI | PublishPanel (port input + button) | PublishPanel (green URL bar + Unpublish) |
| MCP tools | berth_publish(project_id, port, provider?) | berth_unpublish(project_id) |
| CLI | berth publish <project> --port 8080 | berth unpublish <project> |
Two columns on the projects table: tunnel_url and tunnel_provider. Updated via set_tunnel_url() and clear_tunnel_url() store methods.
The agent supports remote self-upgrade using a cloudflared-inspired model: download binary, verify, atomic swap, exit with code 42, systemd restarts with the new binary. Tested end-to-end (v0.1.7 → v0.1.8).
UpgradeDownload command or gRPC Upgrade RPC).berth-agent.old, new binary moved into place via atomic rename.SuccessExitStatus=42) and restarts with the new binary..probation-passed marker file created. Fail → exit(1) → rollback.SuccessExitStatus=42 # Prevents rate-limiting on intentional restart
ExecStopPost=+/usr/local/lib/berth/rollback.sh # Runs as root — restores old binary on failure
rollback.sh script (runs as root via + prefix) restores berth-agent.old to berth-agent.berth-agent.old.# CLI self-serve upgrade (on the agent machine)
berth-agent update [--version X.Y.Z] [--yes]
# Remote upgrade via NATS (desktop sends URL + checksum, agent downloads and swaps)
NatsCommandKind::UpgradeDownload { url, checksum }
Per-project environment variables are stored on the desktop side only and passed to the agent at runtime. Values are never persisted on the remote agent.
project_env_vars table in desktop SQLite. Methods: set_env_var(), get_env_vars(), delete_env_var().ExecuteParams.env_vars HashMap at execute time.*** in all log output, using longest-first matching to prevent partial leaks. Implemented in crates/berth-core/src/env.rs.parse_dotenv() in env.rs handles comments, quoted values, and export prefix. Import merges via upsert.| Interface | Commands / Tools |
|---|---|
| UI | EnvVarsPanel — key/value editor with eye-toggle reveal, delete button, add form, .env import textarea |
| MCP | berth_env_set(project_id, key, value), berth_env_get(project_id), berth_env_delete(project_id, key), berth_env_import(project_id, content) |
| CLI | berth env set <project> <KEY> <VALUE>, berth env list <project>, berth env remove <project> <KEY>, berth env import <project> <.env file> |
Projects can run in oneshot mode (run once, exit) or service mode (keep running, auto-restart on crash).
run_mode field on the Project model: oneshot (default) or service.service_port field for web services — used by tunnel publishing to know which port to expose.When run_mode = service, the agent's supervisor loop monitors the spawned process:
When a user clicks Run with a remote target selected in the UI:
~/Library/Application Support/com.berth.app/projects/{name}/{entrypoint} on macOS.nats_enabled flag.project_env_vars table and included in ExecuteParams.get_agent_client() returns the appropriate transport: NatsAgentClient (if NATS enabled) or AgentClient (gRPC fallback).~/.berth/deploys/{project_id}/v{n}/. A versioned directory is created per deployment, persisted in the deployments table.executions row, injects env vars into the process environment, then runs the command. Every log line is persisted to execution_logs AND streamed over the transport simultaneously. In service mode, the supervisor loop monitors for crashes.ExecuteResponse messages. Each line is also written to SQLite for later replay via GetExecutionLogs. Env var values are masked before storage and transmission.project-log Tauri event. The Terminal component renders them identically to local logs.project-status-change event is emitted (idle or failed), and the run is recorded in SQLite. On the agent side, the executions row is updated with exit code + finished_at, and an execution_completed event is inserted into the events queue.For development and testing. Requires Rust toolchain and protoc on the target server.
# On the Linux server:
sudo apt install -y protobuf-compiler build-essential
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
source ~/.cargo/env
# Copy source and build
rsync -avz --exclude target --exclude node_modules your-mac:~/berth/ ~/berth/
cd ~/berth
cargo build -p berth-agent --release
# Run (bind to all interfaces for network access)
./target/release/berth-agent --listen-all --port 50051
For production servers. Downloads a pre-built binary and installs as a systemd service.
# Install
curl -sSL https://get.berth.dev | sudo bash
# Uninstall
curl -sSL https://get.berth.dev | sudo bash -s -- --uninstall
The install script (scripts/install-agent.sh) performs:
| Step | Action |
|---|---|
| 1. OS Detection | Linux only (rejects macOS) |
| 2. Arch Detection | x86_64 or aarch64 |
| 3. Download | Binary from release URL to /usr/local/bin/berth-agent |
| 4. User Creation | Creates berth system user (no home, nologin shell) |
| 5. systemd Service | Creates unit file, enables, starts |
[Unit]
Description=Berth Agent
After=network.target
[Service]
Type=simple
User=berth
ExecStart=/usr/local/bin/berth-agent --port 50051
Restart=always
RestartSec=5
SuccessExitStatus=42
ExecStopPost=+/usr/local/lib/berth/rollback.sh
Environment=RUST_LOG=info
[Install]
WantedBy=multi-user.target
# Check status
systemctl status berth-agent
# View logs
journalctl -u berth-agent -f
# Restart
sudo systemctl restart berth-agent
After the agent is running, register it as a target:
# Via CLI
berth targets add my-server --host 192.168.1.100 --port 50051
berth targets ping my-server
# Via UI
# Targets page → + Add Target → fill name/host/port → Add → Ping
# Optional: enter NATS Agent ID for zero-port communication
| Flag | Default | Description |
|---|---|---|
--port <PORT> | 50051 | gRPC server port |
--listen-all | false | Bind to 0.0.0.0 (required for remote access) |
--show-version | false | Print version and exit |
Important: Without --listen-all, the agent binds to 127.0.0.1 only, rejecting remote gRPC connections. Always use --listen-all for remote targets. When using NATS transport, the gRPC port is only needed for local health checks.
Warning: The gRPC transport uses plaintext (no TLS). This is acceptable for trusted LANs and development, but must not be used over the internet without additional protection (VPN, SSH tunnel, or enabling mTLS). The NATS transport uses TLS to Synadia Cloud and is safe for use over the internet.
| Layer | Current | Planned |
|---|---|---|
| Transport encryption | gRPC: Plaintext / NATS: TLS | mTLS for gRPC |
| Authentication | None | Client certificates |
| Authorization | None | Per-project permissions |
| Code isolation | OS user separation | Container sandboxing |
| Credential storage | macOS Keychain | Keychain + encrypted file (Linux) |
The TLS module (crates/berth-core/src/tls.rs) provides certificate generation and tonic TLS configuration, ready to be activated:
| Function | Location | Purpose |
|---|---|---|
generate_ca() | tls.rs | Create self-signed CA with CertifiedIssuer |
generate_server_cert(ca, hostname) | tls.rs | Sign a server cert for an agent |
generate_client_cert(ca, name) | tls.rs | Sign a client cert for the app |
ensure_ca() | tls.rs | Load or create CA, persist to disk |
server_tls_config(bundle, ca_pem) | tls.rs | Build tonic::ServerTlsConfig with mTLS |
client_tls_config(bundle, ca_pem) | tls.rs | Build tonic::ClientTlsConfig with mTLS |
// Agent server startup (main.rs):
let ca_pem = fs::read_to_string("ca.crt")?;
let server_bundle = tls::load_bundle("server")?;
let tls_config = tls::server_tls_config(&server_bundle, &ca_pem)?;
Server::builder()
.tls_config(tls_config)?
.add_service(AgentServiceServer::new(service))
.serve(addr).await?;
// App client connection (agent_client.rs):
let ca_pem = fs::read_to_string("ca.crt")?;
let client_bundle = tls::load_bundle("client")?;
let tls_config = tls::client_tls_config(&client_bundle, &ca_pem)?;
Channel::from_shared(endpoint)?
.tls_config(tls_config)?
.connect().await?;
The credentials module (crates/berth-core/src/credentials.rs) stores secrets in the macOS Keychain:
| Function | Keychain Key Pattern | Purpose |
|---|---|---|
store_ssh_key(target, key) | target:{name}:ssh-key | SSH private key for remote access |
store_aws_credentials(profile, ak, sk) | aws:{profile}:access-key | AWS credentials for Lambda targets |
store_credential(key, value) | Custom key | Generic secret storage |
All credentials use the macOS security-framework crate, which stores secrets in the system Keychain — encrypted at rest, protected by the user's login password.
ssh -L 50051:localhost:50051 user@server and connect to localhost:50051.ufw or security groups.berth system user with no shell — processes run without root privileges.crates/berth-agent/
├── Cargo.toml # Deps: berth-core, tonic, clap, sysinfo, rusqlite, chrono, uuid
├── build.rs # Compiles berth.proto via tonic-build
└── src/
├── main.rs # CLI, SQLite init, gRPC server, NATS handler, scheduler loop spawn
├── service.rs # Legacy re-export (AgentServiceImpl from berth-core)
├── persistent_service.rs # PersistentAgentService (16 RPCs with SQLite persistence)
├── agent_store.rs # AgentStore: SQLite at ~/.berth/agent.db (5 tables)
├── agent_scheduler.rs # Independent scheduler (tick every 30s, runs cron jobs)
├── nats_cmd_handler.rs # NATS command subscriber and dispatcher
├── nats_publisher.rs # NATS event/log/heartbeat publisher
└── update.rs # CLI `berth-agent update` self-upgrade command
The agent uses a HashMap<String, RunningChild> protected by a tokio::sync::Mutex to track running processes:
struct RunningChild {
abort_handle: tokio::task::AbortHandle,
started_at: chrono::DateTime<chrono::Utc>,
}
// On Execute: insert(project_id, child)
// On Stop: remove(project_id) → abort_handle.abort()
// On exit: remove(project_id) automatically
// Service mode: supervisor loop re-inserts on crash (with backoff)
| Runtime | Command | Entrypoint |
|---|---|---|
| Python | python3 {entrypoint} | main.py |
| Node.js | node {entrypoint} | index.js |
| Go | go run {entrypoint} | main.go |
| Shell | bash {entrypoint} | run.sh |
| Rust | cargo run | main.rs |
The Status RPC reports real system metrics via the sysinfo crate:
agent_id)The gRPC client (crates/berth-core/src/agent_client.rs) is used by both the Tauri app and the CLI:
use berth_core::agent_client::AgentClient;
// Connect
let mut client = AgentClient::connect("http://192.168.1.100:50051").await?;
// Health check
let health = client.health().await?;
println!("v{}, uptime {}s", health.version, health.uptime_seconds);
// Execute code remotely
let code = std::fs::read("main.py")?;
let logs = client.execute(
"my-project", // project_id
"python", // runtime
"main.py", // entrypoint
"/tmp", // working_dir
Some(&code), // inline code bytes
).await?;
for line in &logs {
println!("[{}] {}", line.stream, line.text);
}
// Stop
client.stop("my-project").await?;
// System status
let status = client.status().await?;
println!("CPU: {:.1}%, Memory: {}MB", status.cpu_usage, status.memory_bytes / 1024 / 1024);
The project detail view shows pill-style target buttons when remote targets are configured:
| Command | Parameters | Action |
|---|---|---|
run_project | id, target: Option<String> | Read code, load env vars, send to agent (local UDS or remote TCP/NATS), stream logs via events |
stop_project | id, target: Option<String> | Connect to agent, call Stop RPC |
ping_target | id | Health check, update target status in DB |
list_targets | — | List all configured targets from SQLite |
add_target | name, host, port | Save new target to SQLite |
remove_target | id | Delete target from SQLite |
Remote execution reuses the same Tauri events as local execution — the Terminal component needs no changes:
| Event | Payload | Source |
|---|---|---|
project-log | { project_id, stream, text, timestamp } | Local: process stdout/stderr Remote: gRPC/NATS stream |
project-status-change | { project_id, status, exit_code } | Local: process exit Remote: stream completion |
| Limitation | Impact | Workaround |
|---|---|---|
| No TLS for gRPC transport | Code and logs are transmitted in plaintext over gRPC | Use NATS transport (TLS to Synadia Cloud) or SSH tunnel. mTLS infra built but not wired. |
| No authentication | Anyone who can reach port 50051 can execute code | Firewall rules, bind to localhost + SSH tunnel, or use NATS transport |
| No execution timeout | Processes can run indefinitely on the agent | Use Stop command or process supervisor |
| No container sandboxing | Code runs as the agent's OS user, no isolation | Run agent as a dedicated low-privilege user |
| cloudflared optional | Public URL publishing requires cloudflared installed on agent | Install manually: curl from GitHub releases or package manager |
Resolved in persistent agent redesign:
pip install, npm install, go mod download automatically~/.berth/deploys/, old versions auto-pruned (keep last 5)| Feature | Status | Notes |
|---|---|---|
| gRPC agent server (16 RPCs) | Done | Health, Status, Deploy, Execute, Stop, StreamLogs + 8 persistent RPCs + Publish/Unpublish |
| SQLite persistence | Done | ~/.berth/agent.db — 5 tables, survives restarts |
| Execution history + logs | Done | Persistent, replayable via GetExecutions/GetExecutionLogs |
| Store-and-forward events | Done | Agent queues events, app polls via GetEvents/AckEvents |
| Agent-side scheduler | Done | Tick every 30s, runs cron jobs independently of app |
| Dependency install on deploy | Done | pip install, npm install, go mod download during Deploy RPC |
| Remote self-upgrade | Done | Client-streaming Upgrade RPC, verify + swap + systemd restart |
| Multi-file deploy | Done | Deploy RPC accepts tarballs, extracts to persistent dir |
| Deployment versioning | Done | Auto-version, keep last 5, prune old |
| gRPC client library | Done | AgentClient with 8 new methods for persistent RPCs |
| CLI target management | Done | add, list, remove, ping |
| UI target management | Done | Targets page with add/remove/ping/stats (os, arch, CPU, memory) |
| UI remote execution | Done | Target selector + Run/Stop on remote |
| Install script + systemd | Done | systemd service with auto-restart on Linux |
| mDNS LAN discovery | Done | _berth._tcp.local. via mdns-sd |
| TLS cert generation | Done | CA + server/client certs via rcgen |
| NATS command channel | Done | Zero inbound ports, works behind NAT. AgentTransport trait abstracts gRPC vs NATS |
| Self-upgrade (cloudflared model) | Done | exit(42), 30s probation, auto-rollback. Tested v0.1.7 → v0.1.8 |
| Public URL publishing | Done | cloudflared tunnels, pluggable TunnelProvider enum. Tested end-to-end |
| Tunnel capability detection | Done | Health response reports available tunnel providers (installed binaries) |
| Environment variables | Done | Desktop-side storage, passed at runtime, log masking (values ≥ 3 chars → ***) |
| Service mode | Done | Auto-restart with exponential backoff (1s → 60s cap), uptime tracking, restart count |
| Activate mTLS | Planned | Wire tls.rs into agent + client |
| Container sandboxing | Planned | OCI/Docker isolation per project |
Berth Remote Agent Documentation — Updated March 2026