Skip to content
Merged
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
7 changes: 6 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ reach is an AI-drivable containerized desktop sandbox. It provides three things:
2. A Rust CLI (`reach`) that manages sandbox containers from the host
3. An MCP server (`reach serve`) that exposes sandbox tools to AI agents via SSE

Current phase: **Phase 1 -- container desktop (complete), entering Phase 2**. All 8 CLI commands are implemented, the type system is designed (5 layers, 1197 lines), all 8 MCP tools are implemented, and e2e tests pass (88 tests: 52 unit + 36 e2e).
Current phase: **Phase 1 -- container desktop (complete), entering Phase 2**. All 8 CLI commands are implemented, the type system is designed (5 layers, 1197 lines), 10 MCP tools are implemented (including `page_text` and `auth_handoff` for JS-heavy SPAs and login handoffs), and e2e tests pass (Phase 1: 88 tests, +3 e2e tests for the new tools).

## Tech Stack

Expand Down Expand Up @@ -106,3 +106,8 @@ GitHub Actions workflows in `.github/workflows/`:
6. Configuration loading is in `crates/reach-cli/src/config.rs`.
7. When adding a new CLI command, add the variant to `commands/mod.rs` and create the corresponding module.
8. When adding a new supervised process, add it to `processes.rs` in reach-supervisor.
9. Python helpers that run inside the container should be embedded as `pub const` strings in `docker.rs` (see `PAGE_TEXT_SCRIPT` / `AUTH_HANDOFF_SCRIPT`) so the binary stays self-contained.

## Persistent Chrome Profiles

`reach create --persist-profile <name>` mounts `~/.local/share/reach/profiles/<name>` (host) into the container at `/home/sandbox/.config/google-chrome-profiles/<name>`. The host root is overridable via `sandbox.profile_dir` in `~/.config/reach/config.toml`. Pass the same profile name to `page_text` / `auth_handoff` via `use_profile` so a one-time login carries across sandbox restarts.
113 changes: 112 additions & 1 deletion crates/reach-cli/src/commands/connect.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
use clap::Args;
use reach_cli::docker::DockerClient;
use reach_cli::docker::{
AuthHandoffOptions, DockerClient, PageTextOptions, ProfileMount, novnc_url,
};
use reach_cli::mcp::{
JsonRpcRequest, JsonRpcResponse, McpInitializeResult, RequestId, ToolResponse, tool_definitions,
};
Expand Down Expand Up @@ -176,6 +178,115 @@ async fn dispatch_tool(
.unwrap_or("echo");
exec_cmd(docker, target, cmd).await
}
"page_text" => {
let url = match args.get("url").and_then(|v| v.as_str()) {
Some(u) if !u.is_empty() => u.to_string(),
_ => {
return JsonRpcResponse::success(
req.id.clone(),
serde_json::to_value(ToolResponse::error(
"page_text: missing required `url`",
))
.unwrap(),
);
}
};
let opts = PageTextOptions {
url,
wait_for: args
.get("wait_for")
.and_then(|v| v.as_str())
.map(str::to_string),
selector: args
.get("selector")
.and_then(|v| v.as_str())
.map(str::to_string),
timeout_ms: args
.get("timeout_ms")
.and_then(|v| v.as_u64())
.unwrap_or(30_000),
user_data_dir: args
.get("use_profile")
.and_then(|v| v.as_str())
.map(ProfileMount::container_path_for),
};
match docker.page_text(target, &opts).await {
Ok(out) => match serde_json::to_string_pretty(&out) {
Ok(s) => ToolResponse::text(s),
Err(e) => ToolResponse::error(e.to_string()),
},
Err(e) => ToolResponse::error(e.to_string()),
}
}
"auth_handoff" => {
let url = match args.get("url").and_then(|v| v.as_str()) {
Some(u) if !u.is_empty() => u.to_string(),
_ => {
return JsonRpcResponse::success(
req.id.clone(),
serde_json::to_value(ToolResponse::error(
"auth_handoff: missing required `url`",
))
.unwrap(),
);
}
};
let opts = AuthHandoffOptions {
url,
wait_for_selector: args
.get("wait_for_selector")
.and_then(|v| v.as_str())
.map(str::to_string),
wait_for_url_contains: args
.get("wait_for_url_contains")
.and_then(|v| v.as_str())
.map(str::to_string),
timeout_seconds: args
.get("timeout_seconds")
.and_then(|v| v.as_u64())
.unwrap_or(300),
user_data_dir: args
.get("use_profile")
.and_then(|v| v.as_str())
.map(ProfileMount::container_path_for),
};

let vnc = match docker.find(target).await {
Ok(sandbox) => sandbox
.ports
.novnc
.map(|p| novnc_url("localhost", p))
.unwrap_or_else(|| novnc_url("localhost", 6080)),
Err(_) => novnc_url("localhost", 6080),
};

match docker.auth_handoff(target, &opts).await {
Ok(out) => {
let body = serde_json::json!({
"status": out.status,
"vnc_url": vnc,
"url": out.url,
"message": out.message,
"instructions": "Open the vnc_url in your browser to log in. Re-call \
`auth_handoff` (with wait_for_*) or `page_text` once done.",
});
match serde_json::to_string_pretty(&body) {
Ok(s) => ToolResponse::text(s),
Err(e) => ToolResponse::error(e.to_string()),
}
}
Err(e) => {
let body = serde_json::json!({
"status": "error",
"vnc_url": vnc,
"message": e.to_string(),
});
ToolResponse::error(
serde_json::to_string_pretty(&body).unwrap_or_else(|_| e.to_string()),
)
}
}
}
_ => ToolResponse::error(format!("unknown tool: {tool}")),
};

Expand Down
36 changes: 34 additions & 2 deletions crates/reach-cli/src/commands/create.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use clap::Args;
use colored::Colorize;
use reach_cli::config::ReachConfig;
use reach_cli::docker::{DockerClient, Resolution, SandboxConfig, SandboxPorts};
use reach_cli::docker::{DockerClient, ProfileMount, Resolution, SandboxConfig, SandboxPorts};
use std::time::Duration;

#[derive(Args)]
Expand Down Expand Up @@ -33,22 +33,43 @@ pub struct CreateArgs {
/// Skip waiting for health check
#[arg(long)]
pub no_wait: bool,

/// Persist a Chrome profile across sandbox restarts.
///
/// The named profile is stored on the host under
/// `~/.local/share/reach/profiles/<name>` (overridable via the
/// `sandbox.profile_dir` config key) and bind-mounted into the
/// container at `/home/sandbox/.config/google-chrome-profiles/<name>`.
/// Pass the same name to `page_text` / `auth_handoff` via
/// `use_profile` to reuse the session.
#[arg(long, value_name = "NAME")]
pub persist_profile: Option<String>,
}

pub async fn run(args: CreateArgs) -> anyhow::Result<()> {
let cfg = ReachConfig::load();
let resolution = Resolution::parse(&args.resolution)?;

let profile = args.persist_profile.as_ref().map(|name| {
let host_path = ProfileMount::host_path_for(&cfg.sandbox.resolved_profile_dir(), name);
ProfileMount {
name: name.clone(),
host_path,
container_path: ProfileMount::container_path_for(name),
}
});

let config = SandboxConfig {
name: args.name.clone(),
image: args.image.unwrap_or(cfg.sandbox.image),
image: args.image.unwrap_or(cfg.sandbox.image.clone()),
resolution,
shm_size: cfg.sandbox.shm_size,
ports: SandboxPorts {
vnc: args.vnc_port.unwrap_or(cfg.sandbox.vnc_port),
novnc: args.novnc_port.unwrap_or(cfg.sandbox.novnc_port),
health: args.health_port.unwrap_or(cfg.sandbox.health_port),
},
profile,
};

let docker = DockerClient::new()?;
Expand Down Expand Up @@ -76,6 +97,17 @@ pub async fn run(args: CreateArgs) -> anyhow::Result<()> {
args.resolution
);

if let Some(name) = &args.persist_profile {
let host = ProfileMount::host_path_for(&cfg.sandbox.resolved_profile_dir(), name);
println!(
" {} {} {} {}",
"\u{2713}".green(),
"Profile ".dimmed(),
name,
format!("({})", host.display()).dimmed()
);
}

if !args.no_wait {
print!(" \u{2819} {}", "Waiting for health...".dimmed());
docker
Expand Down
99 changes: 98 additions & 1 deletion crates/reach-cli/src/commands/serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ use axum::response::sse::{Event, Sse};
use axum::routing::{get, post};
use axum::{Json, Router};
use clap::Args;
use reach_cli::docker::DockerClient;
use reach_cli::docker::{
AuthHandoffOptions, DockerClient, PageTextOptions, ProfileMount, novnc_url,
};
use reach_cli::mcp::{
JsonRpcRequest, JsonRpcResponse, McpInitializeResult, ToolResponse, tool_definitions,
};
Expand Down Expand Up @@ -221,6 +223,101 @@ async fn dispatch(
.unwrap_or("echo");
sh(state, target, cmd).await
}
"page_text" => {
let url = match args.get("url").and_then(|v| v.as_str()) {
Some(u) if !u.is_empty() => u.to_string(),
_ => return ToolResponse::error("page_text: missing required `url`"),
};
let opts = PageTextOptions {
url,
wait_for: args
.get("wait_for")
.and_then(|v| v.as_str())
.map(str::to_string),
selector: args
.get("selector")
.and_then(|v| v.as_str())
.map(str::to_string),
timeout_ms: args
.get("timeout_ms")
.and_then(|v| v.as_u64())
.unwrap_or(30_000),
user_data_dir: args
.get("use_profile")
.and_then(|v| v.as_str())
.map(ProfileMount::container_path_for),
};
match state.docker.page_text(target, &opts).await {
Ok(out) => match serde_json::to_string_pretty(&out) {
Ok(s) => ToolResponse::text(s),
Err(e) => ToolResponse::error(e.to_string()),
},
Err(e) => ToolResponse::error(e.to_string()),
}
}
"auth_handoff" => {
let url = match args.get("url").and_then(|v| v.as_str()) {
Some(u) if !u.is_empty() => u.to_string(),
_ => return ToolResponse::error("auth_handoff: missing required `url`"),
};
let opts = AuthHandoffOptions {
url,
wait_for_selector: args
.get("wait_for_selector")
.and_then(|v| v.as_str())
.map(str::to_string),
wait_for_url_contains: args
.get("wait_for_url_contains")
.and_then(|v| v.as_str())
.map(str::to_string),
timeout_seconds: args
.get("timeout_seconds")
.and_then(|v| v.as_u64())
.unwrap_or(300),
user_data_dir: args
.get("use_profile")
.and_then(|v| v.as_str())
.map(ProfileMount::container_path_for),
};

// Resolve the noVNC URL up-front so we can include it in the
// response no matter which branch the helper takes.
let vnc = match state.docker.find(target).await {
Ok(sandbox) => sandbox
.ports
.novnc
.map(|p| novnc_url("localhost", p))
.unwrap_or_else(|| novnc_url("localhost", 6080)),
Err(_) => novnc_url("localhost", 6080),
};

match state.docker.auth_handoff(target, &opts).await {
Ok(out) => {
let body = serde_json::json!({
"status": out.status,
"vnc_url": vnc,
"url": out.url,
"message": out.message,
"instructions": "Open the vnc_url in your browser to log in. Re-call \
`auth_handoff` (with wait_for_*) or `page_text` once done.",
});
match serde_json::to_string_pretty(&body) {
Ok(s) => ToolResponse::text(s),
Err(e) => ToolResponse::error(e.to_string()),
}
}
Err(e) => {
let body = serde_json::json!({
"status": "error",
"vnc_url": vnc,
"message": e.to_string(),
});
ToolResponse::error(
serde_json::to_string_pretty(&body).unwrap_or_else(|_| e.to_string()),
)
}
}
}
_ => ToolResponse::error(format!("unknown tool: {tool}")),
}
}
Expand Down
Loading
Loading