mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-17 06:04:56 +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"
|
"Typecheck passes"
|
||||||
],
|
],
|
||||||
"priority": 16,
|
"priority": 16,
|
||||||
"passes": false,
|
"passes": true,
|
||||||
"notes": ""
|
"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",
|
"id": "US-017",
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,8 @@
|
||||||
- CDP `Page.handleJavaScriptDialog` takes `{accept, promptText?}` for alert/confirm/prompt handling; no DOM setup needed
|
- 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
|
- 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
|
- 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
|
# Ralph Progress Log
|
||||||
Started: Tue Mar 17 04:32:06 AM PDT 2026
|
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)
|
- 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
|
- 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
|
&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.
|
/// Push a console message into the ring buffer.
|
||||||
pub async fn push_console_message(&self, message: BrowserConsoleMessage) {
|
pub async fn push_console_message(&self, message: BrowserConsoleMessage) {
|
||||||
let mut state = self.inner.lock().await;
|
let mut state = self.inner.lock().await;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
mod acp_proxy_runtime;
|
mod acp_proxy_runtime;
|
||||||
mod browser_cdp;
|
mod browser_cdp;
|
||||||
|
mod browser_context;
|
||||||
mod browser_errors;
|
mod browser_errors;
|
||||||
mod browser_install;
|
mod browser_install;
|
||||||
mod browser_runtime;
|
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/dialog", post(post_v1_browser_dialog))
|
||||||
.route("/browser/console", get(get_v1_browser_console))
|
.route("/browser/console", get(get_v1_browser_console))
|
||||||
.route("/browser/network", get(get_v1_browser_network))
|
.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", get(get_v1_agents))
|
||||||
.route("/agents/:agent", get(get_v1_agent))
|
.route("/agents/:agent", get(get_v1_agent))
|
||||||
.route("/agents/:agent/install", post(post_v1_agent_install))
|
.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,
|
post_v1_browser_dialog,
|
||||||
get_v1_browser_console,
|
get_v1_browser_console,
|
||||||
get_v1_browser_network,
|
get_v1_browser_network,
|
||||||
|
get_v1_browser_contexts,
|
||||||
|
post_v1_browser_contexts,
|
||||||
|
delete_v1_browser_context,
|
||||||
get_v1_agents,
|
get_v1_agents,
|
||||||
get_v1_agent,
|
get_v1_agent,
|
||||||
post_v1_agent_install,
|
post_v1_agent_install,
|
||||||
|
|
@ -635,6 +646,9 @@ pub async fn shutdown_servers(state: &Arc<AppState>) {
|
||||||
BrowserNetworkQuery,
|
BrowserNetworkQuery,
|
||||||
BrowserNetworkRequest,
|
BrowserNetworkRequest,
|
||||||
BrowserNetworkResponse,
|
BrowserNetworkResponse,
|
||||||
|
BrowserContextInfo,
|
||||||
|
BrowserContextListResponse,
|
||||||
|
BrowserContextCreateRequest,
|
||||||
DesktopClipboardResponse,
|
DesktopClipboardResponse,
|
||||||
DesktopClipboardQuery,
|
DesktopClipboardQuery,
|
||||||
DesktopClipboardWriteRequest,
|
DesktopClipboardWriteRequest,
|
||||||
|
|
@ -2684,6 +2698,73 @@ async fn get_v1_browser_network(
|
||||||
Ok(Json(BrowserNetworkResponse { requests }))
|
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.
|
/// Helper: get the current page URL and title via CDP Runtime.evaluate.
|
||||||
async fn get_page_info_via_cdp(
|
async fn get_page_info_via_cdp(
|
||||||
cdp: &crate::browser_cdp::CdpClient,
|
cdp: &crate::browser_cdp::CdpClient,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue