diff --git a/scripts/ralph/prd.json b/scripts/ralph/prd.json index 7f42829..cca1564 100644 --- a/scripts/ralph/prd.json +++ b/scripts/ralph/prd.json @@ -263,8 +263,8 @@ "Typecheck passes" ], "priority": 16, - "passes": false, - "notes": "" + "passes": true, + "notes": "Context CRUD is pure filesystem operations (no browser needed to be active). IDs are hex-encoded random bytes (no uuid dependency). context.json metadata file stores name+createdAt per context directory. contextId integration in browser start was already implemented in US-005." }, { "id": "US-017", diff --git a/scripts/ralph/progress.txt b/scripts/ralph/progress.txt index cd8590d..dd0f1c1 100644 --- a/scripts/ralph/progress.txt +++ b/scripts/ralph/progress.txt @@ -41,6 +41,8 @@ - CDP `Page.handleJavaScriptDialog` takes `{accept, promptText?}` for alert/confirm/prompt handling; no DOM setup needed - CDP event monitoring pattern: `Runtime.enable` + `Network.enable` in start(), subscribe via `cdp.subscribe(event)`, spawn tokio tasks to populate ring buffers; tasks auto-terminate when CDP connection closes - For internal-only fields in API types, use `#[serde(default, skip_serializing)]` to keep them out of JSON responses +- Browser context management is pure filesystem CRUD; each context is a directory under `{state_dir}/browser-contexts/{id}/` with a `context.json` metadata file +- Use hex-encoded /dev/urandom bytes for generating IDs (same pattern as telemetry.rs) to avoid adding new crate deps # Ralph Progress Log Started: Tue Mar 17 04:32:06 AM PDT 2026 @@ -288,3 +290,21 @@ Started: Tue Mar 17 04:32:06 AM PDT 2026 - Background tokio::spawn tasks for event processing don't need explicit cleanup; they terminate when CDP subscription channel closes (on browser stop) - Added internal `request_id` field with `#[serde(skip_serializing)]` to keep it out of API responses while enabling request/response correlation --- + +## 2026-03-17 - US-016 +- Created `browser_context.rs` with context management functions: list_contexts, create_context, delete_context +- Each context stored as a directory under `{state_dir}/browser-contexts/{id}/` with a `context.json` metadata file +- Added `state_dir()` accessor to `BrowserRuntime` to expose the state directory path +- Added 3 HTTP endpoints in router.rs: GET /v1/browser/contexts, POST /v1/browser/contexts (201), DELETE /v1/browser/contexts/:context_id +- Registered `mod browser_context` in lib.rs +- OpenAPI paths and schemas registered for all 3 endpoints and context types +- contextId integration in POST /v1/browser/start was already implemented in US-005 (sets --user-data-dir) +- 3 unit tests pass (create+list, delete, delete-nonexistent) +- Files changed: browser_context.rs (new), browser_runtime.rs, router.rs, lib.rs, prd.json +- **Learnings for future iterations:** + - Context management is pure filesystem operations; no browser needs to be running + - Use hex-encoded random bytes from /dev/urandom for IDs to avoid adding uuid dependency (same pattern as telemetry.rs) + - `BrowserRuntime.config.state_dir` was private; added `state_dir()` pub accessor for context module access + - Context types (BrowserContextInfo, BrowserContextListResponse, BrowserContextCreateRequest) were already defined in browser_types.rs from US-003 + - `tempfile` crate is a workspace dev-dependency available via `test-utils` feature flag +--- diff --git a/server/packages/sandbox-agent/src/browser_context.rs b/server/packages/sandbox-agent/src/browser_context.rs new file mode 100644 index 0000000..b20a2f0 --- /dev/null +++ b/server/packages/sandbox-agent/src/browser_context.rs @@ -0,0 +1,224 @@ +use std::fs; +use std::io::Read as _; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::browser_errors::BrowserProblem; +use crate::browser_types::{BrowserContextCreateRequest, BrowserContextInfo}; + +/// Metadata stored alongside each browser context directory. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ContextMeta { + id: String, + name: String, + created_at: String, +} + +const META_FILE: &str = "context.json"; + +/// Return the base directory that holds all browser context directories. +pub fn contexts_base_dir(state_dir: &Path) -> PathBuf { + state_dir.join("browser-contexts") +} + +/// List all browser contexts stored on disk. +pub fn list_contexts(state_dir: &Path) -> Result, BrowserProblem> { + let base = contexts_base_dir(state_dir); + if !base.exists() { + return Ok(Vec::new()); + } + + let mut contexts = Vec::new(); + let entries = fs::read_dir(&base).map_err(|e| { + BrowserProblem::start_failed(format!("failed to read contexts directory: {e}")) + })?; + + for entry in entries { + let entry = match entry { + Ok(e) => e, + Err(_) => continue, + }; + let path = entry.path(); + if !path.is_dir() { + continue; + } + let meta_path = path.join(META_FILE); + if !meta_path.exists() { + continue; + } + let meta_bytes = match fs::read(&meta_path) { + Ok(b) => b, + Err(_) => continue, + }; + let meta: ContextMeta = match serde_json::from_slice(&meta_bytes) { + Ok(m) => m, + Err(_) => continue, + }; + let size_bytes = dir_size(&path); + contexts.push(BrowserContextInfo { + id: meta.id, + name: meta.name, + created_at: meta.created_at, + size_bytes: Some(size_bytes), + }); + } + + contexts.sort_by(|a, b| a.created_at.cmp(&b.created_at)); + Ok(contexts) +} + +/// Create a new browser context directory with metadata. +pub fn create_context( + state_dir: &Path, + request: BrowserContextCreateRequest, +) -> Result { + let id = generate_context_id(); + let base = contexts_base_dir(state_dir); + let context_dir = base.join(&id); + + fs::create_dir_all(&context_dir).map_err(|e| { + BrowserProblem::start_failed(format!("failed to create context directory: {e}")) + })?; + + let now = chrono::Utc::now().to_rfc3339(); + let meta = ContextMeta { + id: id.clone(), + name: request.name.clone(), + created_at: now.clone(), + }; + + let meta_bytes = serde_json::to_vec_pretty(&meta).map_err(|e| { + BrowserProblem::start_failed(format!("failed to serialize context metadata: {e}")) + })?; + + fs::write(context_dir.join(META_FILE), meta_bytes).map_err(|e| { + BrowserProblem::start_failed(format!("failed to write context metadata: {e}")) + })?; + + Ok(BrowserContextInfo { + id, + name: request.name, + created_at: now, + size_bytes: Some(0), + }) +} + +/// Delete a browser context directory. +pub fn delete_context(state_dir: &Path, context_id: &str) -> Result<(), BrowserProblem> { + let base = contexts_base_dir(state_dir); + let context_dir = base.join(context_id); + + if !context_dir.exists() { + return Err(BrowserProblem::not_found(format!( + "browser context '{context_id}' not found" + ))); + } + + fs::remove_dir_all(&context_dir).map_err(|e| { + BrowserProblem::start_failed(format!("failed to delete context directory: {e}")) + })?; + + Ok(()) +} + +/// Generate a hex-encoded random ID for a browser context. +fn generate_context_id() -> String { + let mut bytes = [0u8; 16]; + if let Ok(mut file) = fs::File::open("/dev/urandom") { + if file.read_exact(&mut bytes).is_ok() { + return hex_encode(&bytes); + } + } + // Fallback: timestamp + pid + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let pid = std::process::id() as u128; + bytes = (now ^ (pid << 64)).to_le_bytes(); + hex_encode(&bytes) +} + +fn hex_encode(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + out.push_str(&format!("{byte:02x}")); + } + out +} + +/// Recursively compute the size of a directory in bytes. +fn dir_size(path: &Path) -> u64 { + let mut total = 0u64; + if let Ok(entries) = fs::read_dir(path) { + for entry in entries.flatten() { + let p = entry.path(); + if p.is_dir() { + total += dir_size(&p); + } else if let Ok(meta) = p.metadata() { + total += meta.len(); + } + } + } + total +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_create_and_list_contexts() { + let tmp = TempDir::new().unwrap(); + let state_dir = tmp.path(); + + // Initially empty + let contexts = list_contexts(state_dir).unwrap(); + assert!(contexts.is_empty()); + + // Create a context + let info = create_context( + state_dir, + BrowserContextCreateRequest { + name: "test-profile".to_string(), + }, + ) + .unwrap(); + assert_eq!(info.name, "test-profile"); + assert!(!info.id.is_empty()); + + // List shows it + let contexts = list_contexts(state_dir).unwrap(); + assert_eq!(contexts.len(), 1); + assert_eq!(contexts[0].name, "test-profile"); + assert_eq!(contexts[0].id, info.id); + } + + #[test] + fn test_delete_context() { + let tmp = TempDir::new().unwrap(); + let state_dir = tmp.path(); + + let info = create_context( + state_dir, + BrowserContextCreateRequest { + name: "to-delete".to_string(), + }, + ) + .unwrap(); + + delete_context(state_dir, &info.id).unwrap(); + + let contexts = list_contexts(state_dir).unwrap(); + assert!(contexts.is_empty()); + } + + #[test] + fn test_delete_nonexistent_context() { + let tmp = TempDir::new().unwrap(); + let result = delete_context(tmp.path(), "nonexistent"); + assert!(result.is_err()); + } +} diff --git a/server/packages/sandbox-agent/src/browser_runtime.rs b/server/packages/sandbox-agent/src/browser_runtime.rs index d1bda73..9490019 100644 --- a/server/packages/sandbox-agent/src/browser_runtime.rs +++ b/server/packages/sandbox-agent/src/browser_runtime.rs @@ -637,6 +637,11 @@ impl BrowserRuntime { &self.streaming_manager } + /// Return the state directory used for browser data (contexts, logs, etc.). + pub fn state_dir(&self) -> &Path { + &self.config.state_dir + } + /// Push a console message into the ring buffer. pub async fn push_console_message(&self, message: BrowserConsoleMessage) { let mut state = self.inner.lock().await; diff --git a/server/packages/sandbox-agent/src/lib.rs b/server/packages/sandbox-agent/src/lib.rs index d095c5c..4f2182c 100644 --- a/server/packages/sandbox-agent/src/lib.rs +++ b/server/packages/sandbox-agent/src/lib.rs @@ -2,6 +2,7 @@ mod acp_proxy_runtime; mod browser_cdp; +mod browser_context; mod browser_errors; mod browser_install; mod browser_runtime; diff --git a/server/packages/sandbox-agent/src/router.rs b/server/packages/sandbox-agent/src/router.rs index e43dacb..8ac7b37 100644 --- a/server/packages/sandbox-agent/src/router.rs +++ b/server/packages/sandbox-agent/src/router.rs @@ -307,6 +307,14 @@ pub fn build_router_with_state(shared: Arc) -> (Router, Arc) .route("/browser/dialog", post(post_v1_browser_dialog)) .route("/browser/console", get(get_v1_browser_console)) .route("/browser/network", get(get_v1_browser_network)) + .route( + "/browser/contexts", + get(get_v1_browser_contexts).post(post_v1_browser_contexts), + ) + .route( + "/browser/contexts/:context_id", + delete(delete_v1_browser_context), + ) .route("/agents", get(get_v1_agents)) .route("/agents/:agent", get(get_v1_agent)) .route("/agents/:agent/install", post(post_v1_agent_install)) @@ -524,6 +532,9 @@ pub async fn shutdown_servers(state: &Arc) { post_v1_browser_dialog, get_v1_browser_console, get_v1_browser_network, + get_v1_browser_contexts, + post_v1_browser_contexts, + delete_v1_browser_context, get_v1_agents, get_v1_agent, post_v1_agent_install, @@ -635,6 +646,9 @@ pub async fn shutdown_servers(state: &Arc) { BrowserNetworkQuery, BrowserNetworkRequest, BrowserNetworkResponse, + BrowserContextInfo, + BrowserContextListResponse, + BrowserContextCreateRequest, DesktopClipboardResponse, DesktopClipboardQuery, DesktopClipboardWriteRequest, @@ -2684,6 +2698,73 @@ async fn get_v1_browser_network( Ok(Json(BrowserNetworkResponse { requests })) } +/// List browser contexts (persistent profiles). +/// +/// Returns all browser context directories with their name, creation date, +/// and on-disk size. +#[utoipa::path( + get, + path = "/v1/browser/contexts", + tag = "v1", + responses( + (status = 200, description = "Browser contexts listed", body = BrowserContextListResponse), + (status = 500, description = "Internal error", body = ProblemDetails) + ) +)] +async fn get_v1_browser_contexts( + State(state): State>, +) -> Result, ApiError> { + let contexts = crate::browser_context::list_contexts(state.browser_runtime().state_dir())?; + Ok(Json(BrowserContextListResponse { contexts })) +} + +/// Create a browser context (persistent profile). +/// +/// Creates a new browser context directory that can be passed as contextId +/// to the browser start endpoint for persistent cookies and storage. +#[utoipa::path( + post, + path = "/v1/browser/contexts", + tag = "v1", + request_body = BrowserContextCreateRequest, + responses( + (status = 201, description = "Browser context created", body = BrowserContextInfo), + (status = 500, description = "Internal error", body = ProblemDetails) + ) +)] +async fn post_v1_browser_contexts( + State(state): State>, + Json(body): Json, +) -> Result<(StatusCode, Json), ApiError> { + let info = crate::browser_context::create_context(state.browser_runtime().state_dir(), body)?; + Ok((StatusCode::CREATED, Json(info))) +} + +/// Delete a browser context (persistent profile). +/// +/// Removes the browser context directory and all stored data (cookies, +/// local storage, cache, etc.). +#[utoipa::path( + delete, + path = "/v1/browser/contexts/{context_id}", + tag = "v1", + params( + ("context_id" = String, Path, description = "Browser context ID") + ), + responses( + (status = 200, description = "Browser context deleted", body = BrowserActionResponse), + (status = 404, description = "Browser context not found", body = ProblemDetails), + (status = 500, description = "Internal error", body = ProblemDetails) + ) +)] +async fn delete_v1_browser_context( + State(state): State>, + Path(context_id): Path, +) -> Result, ApiError> { + crate::browser_context::delete_context(state.browser_runtime().state_dir(), &context_id)?; + Ok(Json(BrowserActionResponse { ok: true })) +} + /// Helper: get the current page URL and title via CDP Runtime.evaluate. async fn get_page_info_via_cdp( cdp: &crate::browser_cdp::CdpClient,