mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 15:03:37 +00:00
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:
parent
47312b2a4e
commit
2687df1e06
6 changed files with 333 additions and 2 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
---
|
||||
|
|
|
|||
224
server/packages/sandbox-agent/src/browser_context.rs
Normal file
224
server/packages/sandbox-agent/src/browser_context.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
mod acp_proxy_runtime;
|
||||
mod browser_cdp;
|
||||
mod browser_context;
|
||||
mod browser_errors;
|
||||
mod browser_install;
|
||||
mod browser_runtime;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue