Skip to content
Open
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
63 changes: 63 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,66 @@ Use the `/browse` skill from gstack for **all web browsing**. Never use `mcp__cl
Available skills: `/plan-ceo-review`, `/plan-eng-review`, `/review`, `/ship`, `/browse`, `/qa`, `/setup-browser-cookies`, `/retro`

If gstack skills aren't working, run `cd .claude/skills/gstack && ./setup` to build the binary and register skills.

## BYOC Agent Architecture (for tervezo integration)

### What was built

BYOC = Bring Your Own Compute. Customers run `warpd agent` on their infra, connecting to WarpGrid cloud control plane on Fly.io. No self-hosted control plane needed.

### Data flow

```
Customer infra WarpGrid Cloud (Fly.io)
───────────────── ───────────────────────
warpd agent warpd cloud
--auth-token wg_agent_* ──gRPC──► ClusterServer.Join()
--control-plane host:port validates token via TokenValidator trait
--region iad injects namespace label into node
returns node_id + namespace binding
heartbeat loop (5s) ──gRPC──► MembershipManager.heartbeat()
DeploymentWatcher ◄─Turso── cloud_deployments (read replica)
RuntimeSync (30s) ──Turso──► cloud_instances, cloud_nodes, cloud_metrics
```

### Key files and what they do

**Agent token system (issue/validate/revoke):**
- `crates/warpd/src/cloud/agent_tokens.rs` — `AgentTokenStore` with memory + libSQL backends. Token format: `wg_agent_<32hex>`. SHA-256 hashed before storage. Methods: `issue()`, `validate()`, `list()`, `revoke()`.
- `crates/warpd/src/cloud/db.rs` — `cloud_agent_tokens` table (id, namespace, token_hash, name, revoked, created_at, last_used_at).

**gRPC auth on cluster Join:**
- `crates/warpgrid-cluster/proto/cluster.proto` — `JoinRequest.auth_token` (field 6), `JoinResponse.namespace` (field 4).
- `crates/warpgrid-cluster/src/server.rs` — `TokenValidator` trait with `validate_agent_token(&str) -> Option<(token_id, namespace)>`. `ClusterServer::with_auth()` constructor for cloud mode (requires token). `ClusterServer::new()` for self-hosted (NoopTokenValidator, no auth). On valid token: namespace injected into node labels as `labels["namespace"] = ns`.
- `crates/warpgrid-cluster/src/agent.rs` — `AgentConfig.auth_token: Option<String>`. `NodeAgent.namespace: Option<String>` set after join.

**REST API for token management:**
- `crates/warpd/src/cloud/routes.rs` — Three endpoints added to `cloud_router()`:
- `POST /api/v1/cloud/agent-tokens` — `create_agent_token()`, requires Bearer auth, returns raw token (shown once).
- `GET /api/v1/cloud/agent-tokens` — `list_agent_tokens()`, namespace-scoped.
- `POST /api/v1/cloud/agent-tokens/{id}/revoke` — `revoke_agent_token()`, namespace-scoped.
- `CloudState.agent_tokens: AgentTokenStore` field added.

**CLI:**
- `crates/warpd/src/main.rs` — `Command::Agent` has `--auth-token` / `WARPGRID_AGENT_TOKEN` env var.
- `crates/warpd/src/agent_mode.rs` — `run_agent()` accepts `auth_token: Option<String>`, passes to `AgentConfig`.

**Wiring in cloud mode:**
- `crates/warpd/src/cloud_mode.rs` — Creates `AgentTokenStore::with_libsql()`, adds to `CloudState`.

**Deploy artifacts:**
- `deploy/agent/install.sh` — curl|bash installer. Validates KVM, downloads warpd + cloud-hypervisor + kernel + golden image, creates systemd unit.
- `deploy/helm/warpgrid-agent/` — Helm chart. DaemonSet on nodes labeled `warpgrid.dev/kvm=true`. Secret for agent token. Mounts `/dev/kvm`.

### What's NOT wired yet (needed for tervezo)

1. **Cloud mode does not run a gRPC ClusterServer** — `cloud_mode.rs` only starts HTTP. Need to add a gRPC listener (e.g. port 50051) with `ClusterServer::with_auth(membership, agent_token_store_as_validator)`. The `AgentTokenStore` needs to implement `TokenValidator` trait (trivial adapter).
2. **Placement engine doesn't filter by namespace** — `warpgrid-placement` picks nodes by resource availability only. Need to add `namespace` label filter so sprites for tenant X only land on tenant X's nodes.
3. **Sprite API doesn't exist yet on cloud** — Cloud has Wasm deployment API but no sprite-specific endpoints. Tervezo needs: `POST /api/v1/sprites` (create sprite VM on customer's BYOC node), `GET /api/v1/sprites/{id}` (status), `DELETE /api/v1/sprites/{id}` (terminate).
4. **No TokenValidator impl for AgentTokenStore** — Need `impl TokenValidator for AgentTokenStore` in warpd (can't be in warpgrid-cluster crate due to dependency direction). Add a newtype wrapper or implement directly since both are in warpd's scope.

### Test coverage

- 8 unit tests in `cloud::agent_tokens::tests` (memory + libSQL backends, revoke, namespace isolation).
- 15 unit tests in `warpgrid-cluster` (membership, heartbeat, labels, dead node detection).
- 133 total tests in warpd binary pass.
41 changes: 41 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ members = [
"crates/warpgrid-rollout",
"crates/warpgrid-bun",
"crates/warpgrid-async",
"crates/warpgrid-sprite",
"crates/warpgrid-sprite-storage",
"crates/sprite-init",
]

[workspace.package]
Expand Down
19 changes: 19 additions & 0 deletions crates/sprite-init/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
[package]
name = "sprite-init"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "WarpGrid sprite-init — guest PID 1 supervisor with namespace setup and vsock communication"

[[bin]]
name = "sprite-init"
path = "src/main.rs"

[dependencies]
tokio.workspace = true
anyhow.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
serde.workspace = true
serde_json.workspace = true
libc = "0.2"
108 changes: 108 additions & 0 deletions crates/sprite-init/src/container.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
//! Inner container (user namespace) management.
//!
//! The user's workload (Claude Code) runs in an inner Linux namespace with
//! its own PID, mount, and optionally network namespace.

use std::collections::HashMap;

use tracing::info;

/// Configuration for the inner container.
pub struct ContainerConfig {
/// Entrypoint command (default: claude code session).
pub entrypoint: Vec<String>,
/// Environment variables for the inner namespace.
pub env: HashMap<String, String>,
/// Working directory inside the container.
pub workdir: String,
}

impl ContainerConfig {
/// Build config from environment variables set by the host.
pub fn from_env() -> Self {
let entrypoint = std::env::var("SPRITE_ENTRYPOINT")
.unwrap_or_else(|_| "claude --dangerously-skip-permissions".to_string());

let workdir = std::env::var("SPRITE_WORKDIR").unwrap_or_else(|_| "/workspace".to_string());

let mut env = HashMap::new();
// Pass through common env vars.
for key in &[
"ANTHROPIC_API_KEY",
"PATH",
"HOME",
"USER",
"TERM",
"LANG",
] {
if let Ok(val) = std::env::var(key) {
env.insert(key.to_string(), val);
}
}

// Default PATH if not set.
env.entry("PATH".to_string()).or_insert_with(|| {
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".to_string()
});
env.entry("HOME".to_string())
.or_insert_with(|| "/root".to_string());

Self {
entrypoint: entrypoint.split_whitespace().map(String::from).collect(),
env,
workdir,
}
}
}

/// Spawn the inner container process with namespace isolation.
pub async fn spawn_inner(
config: &ContainerConfig,
) -> anyhow::Result<tokio::process::Child> {
info!(
entrypoint = ?config.entrypoint,
workdir = %config.workdir,
"spawning inner container"
);

let program = config
.entrypoint
.first()
.ok_or_else(|| anyhow::anyhow!("empty entrypoint"))?;

let args = &config.entrypoint[1..];

let mut cmd = tokio::process::Command::new(program);
cmd.args(args);
cmd.current_dir(&config.workdir);
cmd.envs(&config.env);

// In a real implementation, we'd use clone(2) with CLONE_NEWPID | CLONE_NEWNS
// to create a new PID and mount namespace. For now, spawn as a regular child.
let child = cmd.spawn()?;

info!(pid = ?child.id(), "inner container spawned");
Ok(child)
}

/// Execute a one-off command inside the inner namespace.
pub async fn exec_command(
command: &str,
env: &[(String, String)],
) -> anyhow::Result<(i32, String, String)> {
let mut cmd = tokio::process::Command::new("sh");
cmd.arg("-c").arg(command);
cmd.current_dir("/workspace");

for (key, value) in env {
cmd.env(key, value);
}

let output = cmd.output().await?;

let exit_code = output.status.code().unwrap_or(-1);
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();

Ok((exit_code, stdout, stderr))
}
132 changes: 132 additions & 0 deletions crates/sprite-init/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
//! sprite-init — PID 1 supervisor for WarpGrid sprite VMs.
//!
//! Runs as the init process inside a sprite VM's root namespace.
//! Responsibilities:
//! 1. Mount filesystems (proc, sys, dev, workspace via virtio-fs)
//! 2. Set up inner namespace for user workload (Claude Code)
//! 3. Communicate with host via vsock control channel
//! 4. Monitor activity for auto-sleep decisions
//! 5. Forward logs to host
//! 6. Detect bound ports for service proxy registration
//! 7. Handle checkpoint signals

mod container;
mod mounts;
mod vsock_guest;

use std::time::{Duration, Instant};

use tracing::{error, info, warn};

/// Default vsock port for the control channel.
const CONTROL_PORT: u32 = 5000;

/// Default inactivity timeout before signaling the host.
const IDLE_TIMEOUT: Duration = Duration::from_secs(600);

#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter("info,sprite_init=debug")
.init();

info!("sprite-init starting as PID 1");

// Mount essential filesystems.
if let Err(e) = mounts::mount_essential() {
warn!(error = %e, "failed to mount some filesystems (may be running outside VM)");
}

// Mount workspace via virtio-fs if available.
if let Err(e) = mounts::mount_workspace() {
warn!(error = %e, "workspace mount not available");
}

// Set up vsock listener for host communication.
let vsock = vsock_guest::VsockListener::new(CONTROL_PORT);
info!(port = CONTROL_PORT, "vsock control channel ready");

// Notify host we're ready.
vsock.send_ready().await;

// Start the inner container with user workload.
let container_config = container::ContainerConfig::from_env();
let _child = container::spawn_inner(&container_config).await?;

// Main event loop: handle vsock messages and track activity.
let mut last_activity = Instant::now();

loop {
tokio::select! {
msg = vsock.recv() => {
match msg {
Ok(Some(message)) => {
last_activity = Instant::now();
handle_message(&vsock, message).await;
}
Ok(None) => {
// Connection closed, reconnect.
tokio::time::sleep(Duration::from_secs(1)).await;
}
Err(e) => {
warn!(error = %e, "vsock recv error");
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
}
_ = tokio::time::sleep(Duration::from_secs(30)) => {
// Periodic activity check.
let idle_duration = last_activity.elapsed();
if idle_duration >= IDLE_TIMEOUT {
info!(idle_secs = idle_duration.as_secs(), "idle timeout reached, notifying host");
vsock.send_activity_timeout().await;
} else {
// Send periodic activity ping.
vsock.send_activity_ping().await;
}
}
}
}
}

/// Handle an incoming message from the host.
async fn handle_message(vsock: &vsock_guest::VsockListener, message: vsock_guest::HostMessage) {
match message {
vsock_guest::HostMessage::Checkpoint => {
info!("checkpoint requested, flushing buffers");
// Sync filesystems.
unsafe { libc::sync(); }
vsock.send_checkpoint_ready().await;
}
vsock_guest::HostMessage::Sleep => {
info!("sleep requested, preparing for suspension");
// Sync and prepare for pause.
unsafe { libc::sync(); }
}
vsock_guest::HostMessage::Wake => {
info!("woken from sleep");
}
vsock_guest::HostMessage::Exec { command, env } => {
info!(command, "exec requested");
let result = container::exec_command(&command, &env).await;
match result {
Ok((exit_code, stdout, stderr)) => {
vsock.send_exec_result(exit_code, stdout, stderr).await;
}
Err(e) => {
error!(error = %e, "exec failed");
vsock
.send_exec_result(-1, String::new(), e.to_string())
.await;
}
}
}
vsock_guest::HostMessage::InjectEnv { env } => {
info!(count = env.len(), "injecting environment variables");
for (key, value) in env {
// SAFETY: sprite-init is single-threaded at this point during env setup.
unsafe { std::env::set_var(&key, &value); }
}
}
}
}
Loading
Loading