feat: [US-016] - Add browser context (persistent profile) management

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nathan Flurry 2026-03-17 05:58:50 -07:00
parent 47312b2a4e
commit 2687df1e06
6 changed files with 333 additions and 2 deletions

View file

@ -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",

View file

@ -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
---

View file

@ -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<Vec<BrowserContextInfo>, 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<BrowserContextInfo, BrowserProblem> {
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());
}
}

View file

@ -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;

View file

@ -2,6 +2,7 @@
mod acp_proxy_runtime;
mod browser_cdp;
mod browser_context;
mod browser_errors;
mod browser_install;
mod browser_runtime;

View file

@ -307,6 +307,14 @@ pub fn build_router_with_state(shared: Arc<AppState>) -> (Router, Arc<AppState>)
.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<AppState>) {
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<AppState>) {
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<Arc<AppState>>,
) -> Result<Json<BrowserContextListResponse>, 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<Arc<AppState>>,
Json(body): Json<BrowserContextCreateRequest>,
) -> Result<(StatusCode, Json<BrowserContextInfo>), 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<Arc<AppState>>,
Path(context_id): Path<String>,
) -> Result<Json<BrowserActionResponse>, 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,