Skip to content

Commit 575cfd3

Browse files
feat(sparsekernel): add strict runtime acceptance
1 parent 8968bba commit 575cfd3

18 files changed

Lines changed: 1017 additions & 61 deletions

File tree

crates/sparsekernel-cli/src/lib.rs

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ use sparsekernel_core::{
88
AuditInput, BrowserBroker, CapabilityCheck, CompleteToolCallInput, CreateToolCallInput,
99
EnqueueTaskInput, GrantCapabilityInput, LedgerToolBroker, ListBrowserObservationsInput,
1010
ListBrowserTargetsInput, LocalSandboxBroker, MockBrowserBroker, RecordBrowserObservationInput,
11-
RecordBrowserTargetInput, SandboxBroker, SparseKernelDb, SparseKernelPaths, ToolBroker,
12-
UpsertSessionInput, SPARSEKERNEL_PROTOCOL_VERSION,
11+
RecordBrowserTargetInput, ResourceBudgetUpdateInput, SandboxBroker, SparseKernelDb,
12+
SparseKernelPaths, ToolBroker, UpsertSessionInput, SPARSEKERNEL_PROTOCOL_VERSION,
1313
};
1414
use std::collections::HashMap;
1515
use std::error::Error;
@@ -1313,10 +1313,22 @@ pub fn handle_api_request_with_daemon_state(
13131313
"capabilities.v1",
13141314
"browser-broker.v1",
13151315
"sandbox-broker.v1",
1316-
"sandbox-backends.probe.v1"
1316+
"sandbox-backends.probe.v1",
1317+
"resource-budgets.v1"
13171318
],
13181319
}),
13191320
},
1321+
("GET", "/runtime/budgets") => ApiReply {
1322+
status_code: 200,
1323+
body: serde_json::to_value(db.resource_budgets()?)?,
1324+
},
1325+
("POST", "/runtime/budgets/update") => {
1326+
let input: ResourceBudgetUpdateInput = parse_body(body)?;
1327+
ApiReply {
1328+
status_code: 200,
1329+
body: serde_json::to_value(db.update_resource_budgets(input)?)?,
1330+
}
1331+
}
13201332
("GET", "/status") => ApiReply {
13211333
status_code: 200,
13221334
body: serde_json::to_value(db.inspect()?)?,
@@ -1975,6 +1987,30 @@ mod tests {
19751987
.as_array()
19761988
.unwrap()
19771989
.contains(&json!("tasks.v1")));
1990+
assert!(health["features"]
1991+
.as_array()
1992+
.unwrap()
1993+
.contains(&json!("resource-budgets.v1")));
1994+
}
1995+
1996+
#[test]
1997+
fn runtime_budget_api_reads_and_updates_budgets() {
1998+
let mut db = SparseKernelDb::open(":memory:").unwrap();
1999+
let initial = json_call(&mut db, "GET", "/runtime/budgets", json!({}));
2000+
assert_eq!(initial["browser_contexts_max"], 2);
2001+
let updated = json_call(
2002+
&mut db,
2003+
"POST",
2004+
"/runtime/budgets/update",
2005+
json!({
2006+
"active_agent_steps_max": 12,
2007+
"browser_contexts_max": 3,
2008+
"heavy_sandboxes_max": 2,
2009+
}),
2010+
);
2011+
assert_eq!(updated["active_agent_steps_max"], 12);
2012+
assert_eq!(updated["browser_contexts_max"], 3);
2013+
assert_eq!(updated["heavy_sandboxes_max"], 2);
19782014
}
19792015

19802016
#[test]

crates/sparsekernel-core/src/lib.rs

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,28 @@ fn task_budget_default(kind: TaskBudgetKind) -> i64 {
146146
}
147147
}
148148

149+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
150+
pub struct ResourceBudgetSnapshot {
151+
pub logical_agents_max: i64,
152+
pub active_agent_steps_max: i64,
153+
pub model_calls_in_flight_max: i64,
154+
pub file_patch_jobs_max: i64,
155+
pub test_jobs_max: i64,
156+
pub browser_contexts_max: i64,
157+
pub heavy_sandboxes_max: i64,
158+
}
159+
160+
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
161+
pub struct ResourceBudgetUpdateInput {
162+
pub logical_agents_max: Option<i64>,
163+
pub active_agent_steps_max: Option<i64>,
164+
pub model_calls_in_flight_max: Option<i64>,
165+
pub file_patch_jobs_max: Option<i64>,
166+
pub test_jobs_max: Option<i64>,
167+
pub browser_contexts_max: Option<i64>,
168+
pub heavy_sandboxes_max: Option<i64>,
169+
}
170+
149171
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
150172
pub struct SparseKernelPaths {
151173
pub home_dir: PathBuf,
@@ -1033,6 +1055,88 @@ impl SparseKernelDb {
10331055
.unwrap_or(fallback))
10341056
}
10351057

1058+
fn runtime_info_integer(&self, key: &str, fallback: i64) -> Result<i64> {
1059+
let value: Option<String> = self
1060+
.conn
1061+
.query_row(
1062+
"SELECT value FROM runtime_info WHERE key = ?",
1063+
params![key],
1064+
|row| row.get(0),
1065+
)
1066+
.optional()?;
1067+
Ok(value
1068+
.as_deref()
1069+
.and_then(|entry| entry.trim().parse::<i64>().ok())
1070+
.map(|entry| entry.max(0))
1071+
.unwrap_or(fallback))
1072+
}
1073+
1074+
pub fn resource_budgets(&self) -> Result<ResourceBudgetSnapshot> {
1075+
Ok(ResourceBudgetSnapshot {
1076+
logical_agents_max: self
1077+
.runtime_info_integer("resource_budget.logical_agents_max", 500)?,
1078+
active_agent_steps_max: self
1079+
.runtime_info_integer("resource_budget.active_agent_steps_max", 100)?,
1080+
model_calls_in_flight_max: self
1081+
.runtime_info_integer("resource_budget.model_calls_in_flight_max", 50)?,
1082+
file_patch_jobs_max: self
1083+
.runtime_info_integer("resource_budget.file_patch_jobs_max", 16)?,
1084+
test_jobs_max: self.runtime_info_integer("resource_budget.test_jobs_max", 4)?,
1085+
browser_contexts_max: self
1086+
.runtime_info_integer("resource_budget.browser_contexts_max", 2)?,
1087+
heavy_sandboxes_max: self
1088+
.runtime_info_integer("resource_budget.heavy_sandboxes_max", 1)?,
1089+
})
1090+
}
1091+
1092+
pub fn update_resource_budgets(
1093+
&mut self,
1094+
input: ResourceBudgetUpdateInput,
1095+
) -> Result<ResourceBudgetSnapshot> {
1096+
let tx = self.conn.transaction()?;
1097+
let now = now_iso();
1098+
let set_value = |key: &str, value: Option<i64>| -> Result<()> {
1099+
if let Some(value) = value {
1100+
tx.execute(
1101+
"INSERT INTO runtime_info(key, value, updated_at)
1102+
VALUES(?, ?, ?)
1103+
ON CONFLICT(key) DO UPDATE SET
1104+
value=excluded.value,
1105+
updated_at=excluded.updated_at",
1106+
params![key, value.max(0).to_string(), now],
1107+
)?;
1108+
}
1109+
Ok(())
1110+
};
1111+
set_value(
1112+
"resource_budget.logical_agents_max",
1113+
input.logical_agents_max,
1114+
)?;
1115+
set_value(
1116+
"resource_budget.active_agent_steps_max",
1117+
input.active_agent_steps_max,
1118+
)?;
1119+
set_value(
1120+
"resource_budget.model_calls_in_flight_max",
1121+
input.model_calls_in_flight_max,
1122+
)?;
1123+
set_value(
1124+
"resource_budget.file_patch_jobs_max",
1125+
input.file_patch_jobs_max,
1126+
)?;
1127+
set_value("resource_budget.test_jobs_max", input.test_jobs_max)?;
1128+
set_value(
1129+
"resource_budget.browser_contexts_max",
1130+
input.browser_contexts_max,
1131+
)?;
1132+
set_value(
1133+
"resource_budget.heavy_sandboxes_max",
1134+
input.heavy_sandboxes_max,
1135+
)?;
1136+
tx.commit()?;
1137+
self.resource_budgets()
1138+
}
1139+
10361140
fn running_task_count_for_budget_tx(
10371141
tx: &rusqlite::Transaction<'_>,
10381142
kind: TaskBudgetKind,
@@ -2680,6 +2784,30 @@ impl BrowserBroker for MockBrowserBroker<'_> {
26802784
"no browser contexts available".to_string(),
26812785
));
26822786
}
2787+
let budget = self.db.resource_budgets()?;
2788+
let active_budget: i64 = self.db.conn.query_row(
2789+
"SELECT COUNT(*) FROM resource_leases WHERE resource_type = 'browser_context' AND status = 'active'",
2790+
[],
2791+
|row| row.get(0),
2792+
)?;
2793+
if active_budget >= budget.browser_contexts_max {
2794+
self.db.record_audit(AuditInput {
2795+
actor_type: agent_id.map(|_| "agent".to_string()),
2796+
actor_id: agent_id.map(str::to_string),
2797+
action: "resource_lease.denied_budget_exhausted".to_string(),
2798+
object_type: Some("browser_context".to_string()),
2799+
object_id: None,
2800+
payload: Some(json!({
2801+
"resourceType": "browser_context",
2802+
"active": active_budget,
2803+
"limit": budget.browser_contexts_max,
2804+
})),
2805+
})?;
2806+
return Err(SparseKernelError::Denied(format!(
2807+
"browser context budget exhausted: {}/{} active",
2808+
active_budget, budget.browser_contexts_max
2809+
)));
2810+
}
26832811
let context_id = format!("browser_ctx_{}", Uuid::new_v4());
26842812
self.db.conn.execute(
26852813
"INSERT INTO browser_contexts(id, pool_id, agent_id, session_id, task_id, profile_mode, status, created_at)
@@ -3010,6 +3138,32 @@ impl SandboxBroker for LocalSandboxBroker<'_> {
30103138
}
30113139
}
30123140
}
3141+
let budget = self.db.resource_budgets()?;
3142+
let active_sandboxes: i64 = self.db.conn.query_row(
3143+
"SELECT COUNT(*) FROM resource_leases WHERE resource_type = 'sandbox' AND status = 'active'",
3144+
[],
3145+
|row| row.get(0),
3146+
)?;
3147+
if active_sandboxes >= budget.heavy_sandboxes_max {
3148+
self.db.record_audit(AuditInput {
3149+
actor_type: agent_id.map(|_| "agent".to_string()),
3150+
actor_id: agent_id.map(str::to_string),
3151+
action: "resource_lease.denied_budget_exhausted".to_string(),
3152+
object_type: Some("trust_zone".to_string()),
3153+
object_id: Some(trust_zone_id.to_string()),
3154+
payload: Some(json!({
3155+
"trustZoneId": trust_zone_id,
3156+
"resourceType": "sandbox",
3157+
"budget": "heavy_sandboxes",
3158+
"active": active_sandboxes,
3159+
"limit": budget.heavy_sandboxes_max,
3160+
})),
3161+
})?;
3162+
return Err(SparseKernelError::Denied(format!(
3163+
"heavy sandbox budget exhausted: {}/{} active",
3164+
active_sandboxes, budget.heavy_sandboxes_max
3165+
)));
3166+
}
30133167
self.db.conn.execute(
30143168
"INSERT INTO resource_leases(id, resource_type, resource_id, owner_task_id, owner_agent_id, trust_zone_id, status, metadata_json, created_at, updated_at)
30153169
VALUES(?, 'sandbox', ?, ?, ?, ?, 'active', ?, ?, ?)",
@@ -3333,6 +3487,68 @@ mod tests {
33333487
}));
33343488
}
33353489

3490+
#[test]
3491+
fn resource_budgets_update_and_gate_browser_contexts() {
3492+
let (_dir, mut db) = temp_db();
3493+
let budgets = db
3494+
.update_resource_budgets(ResourceBudgetUpdateInput {
3495+
browser_contexts_max: Some(1),
3496+
heavy_sandboxes_max: Some(2),
3497+
..ResourceBudgetUpdateInput::default()
3498+
})
3499+
.unwrap();
3500+
assert_eq!(budgets.browser_contexts_max, 1);
3501+
assert_eq!(budgets.heavy_sandboxes_max, 2);
3502+
db.grant_capability(GrantCapabilityInput {
3503+
subject_type: "agent".to_string(),
3504+
subject_id: "agent-a".to_string(),
3505+
resource_type: "browser_context".to_string(),
3506+
resource_id: Some("public_web".to_string()),
3507+
action: "allocate".to_string(),
3508+
constraints: None,
3509+
expires_at: None,
3510+
})
3511+
.unwrap();
3512+
db.grant_capability(GrantCapabilityInput {
3513+
subject_type: "agent".to_string(),
3514+
subject_id: "agent-a".to_string(),
3515+
resource_type: "browser_context".to_string(),
3516+
resource_id: Some("authenticated_web".to_string()),
3517+
action: "allocate".to_string(),
3518+
constraints: None,
3519+
expires_at: None,
3520+
})
3521+
.unwrap();
3522+
let broker = MockBrowserBroker { db: &db };
3523+
assert!(broker
3524+
.acquire_context(Some("agent-a"), None, None, "public_web", 10, None)
3525+
.is_ok());
3526+
let denied = broker
3527+
.acquire_context(Some("agent-a"), None, None, "authenticated_web", 10, None)
3528+
.unwrap_err()
3529+
.to_string();
3530+
assert!(denied.contains("browser context budget exhausted"));
3531+
}
3532+
3533+
#[test]
3534+
fn resource_budgets_gate_heavy_sandboxes() {
3535+
let (_dir, mut db) = temp_db();
3536+
db.update_resource_budgets(ResourceBudgetUpdateInput {
3537+
heavy_sandboxes_max: Some(1),
3538+
..ResourceBudgetUpdateInput::default()
3539+
})
3540+
.unwrap();
3541+
let broker = LocalSandboxBroker { db: &db };
3542+
assert!(broker
3543+
.allocate_sandbox(None, None, "code_execution", Some("local/no_isolation"))
3544+
.is_ok());
3545+
let denied = broker
3546+
.allocate_sandbox(None, None, "plugin_untrusted", Some("local/no_isolation"))
3547+
.unwrap_err()
3548+
.to_string();
3549+
assert!(denied.contains("heavy sandbox budget exhausted"));
3550+
}
3551+
33363552
#[test]
33373553
fn artifact_store_dedupes_and_enforces_access() {
33383554
let (dir, db) = temp_db();

docs/architecture/browser-broker.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ The `@openclaw/sparsekernel-browser-broker` adapter materializes the ledger leas
1717

1818
When `OPENCLAW_RUNTIME_BROWSER_BROKER=cdp` and `OPENCLAW_SPARSEKERNEL_BROWSER_CDP_ENDPOINT=<loopback endpoint>` are set, the embedded OpenClaw browser tool receives an internal SparseKernel proxy instead of raw CDP access. Set `OPENCLAW_RUNTIME_BROWSER_BROKER=managed` to let SparseKernel ask the existing OpenClaw browser control service to start the managed browser and return its loopback CDP endpoint; `OPENCLAW_SPARSEKERNEL_BROWSER_CONTROL_URL` overrides the default `http://127.0.0.1:18791` control URL.
1919

20-
Set `OPENCLAW_RUNTIME_BROWSER_BROKER=native` to let SparseKernel launch and supervise a local Chromium-compatible process pool by trust zone and profile. The native pool uses a loopback-only remote debugging endpoint, a runtime-owned browser profile directory, and pooled process refcounts; the leased CDP context is released first, then the browser process is stopped after the pool idle timeout. `OPENCLAW_SPARSEKERNEL_BROWSER_MAX_CONTEXTS` caps simultaneous leased contexts per native pool and defaults to `8`. Use `OPENCLAW_SPARSEKERNEL_BROWSER_EXECUTABLE` when Chrome/Chromium is not discoverable on `PATH` or a common platform path. Headless mode is on by default. `OPENCLAW_SPARSEKERNEL_BROWSER_NO_SANDBOX=1` is an explicit opt-out and should only be used when the host environment cannot run Chromium's sandbox.
20+
Set `OPENCLAW_RUNTIME_BROWSER_BROKER=native` to let SparseKernel launch and supervise a local Chromium-compatible process pool by trust zone and profile. The native pool uses a loopback-only remote debugging endpoint, a runtime-owned browser profile directory, and pooled process refcounts; the leased CDP context is released first, then the browser process is stopped after the pool idle timeout. `OPENCLAW_SPARSEKERNEL_BROWSER_MAX_CONTEXTS` caps simultaneous contexts per native process pool and defaults to `8`; the SparseKernel ledger also enforces the global `resource_budget.browser_contexts_max` lease cap, which defaults to `2` for small machines. Use `OPENCLAW_SPARSEKERNEL_BROWSER_EXECUTABLE` when Chrome/Chromium is not discoverable on `PATH` or a common platform path. Headless mode is on by default. `OPENCLAW_SPARSEKERNEL_BROWSER_NO_SANDBOX=1` is an explicit opt-out and should only be used when the host environment cannot run Chromium's sandbox.
2121

2222
Use `openclaw runtime network-proxy set --trust-zone <id> --proxy-ref <loopback-url>` to attach an existing proxy to a trust-zone network policy, or `openclaw runtime egress-proxy --trust-zone <id> --attach` to start the built-in policy-checking HTTP/CONNECT proxy and attach it. The Rust daemon exposes matching local API endpoints for trust-zone proxy attachment and supervised egress proxy lifecycle; it starts the built-in proxy by default and only launches an operator command when explicitly configured. Set `OPENCLAW_RUNTIME_BROWSER_REQUIRE_PROXY=1` when a trust zone must use a proxy-backed browser egress path. The trust zone's network policy must contain a loopback `proxy_ref`, and native browser pools launch Chromium with `--proxy-server=<proxy_ref>`. Static or externally managed CDP endpoints are rejected in this mode unless `OPENCLAW_RUNTIME_BROWSER_EXTERNAL_PROXY_OK=1` asserts that the external browser process is already proxy-controlled. This protects the SparseKernel-owned browser process path; it is not host-level egress enforcement for arbitrary host processes.
2323

docs/architecture/four-gb-vm-design.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,6 @@ Five hundred logical agents are feasible because most are parked in SQLite as co
2323

2424
Book-writing and file-writing agents can run at higher active counts than coding agents because they do not all need browsers, sandboxes, test runners, or heavy model contexts. Expensive work should be scarce, leased, and scheduled.
2525

26-
Resource leases let SparseKernel answer which task owned which expensive resource and when it was released or expired. The ledger seeds small-VM resource budgets in `runtime_info` and enforces active task budgets during atomic task claiming: active steps, model calls, file patch jobs, and test jobs can be capped before a worker materializes a harness. Trust-zone budgets are enforced at lease creation for sandbox work: `max_processes` caps active sandbox leases, and `max_runtime_seconds` clamps lease runtime and expiry. This keeps a 4 GB machine from materializing more heavy execution work than its configured task and trust-zone budgets allow.
26+
Resource leases let SparseKernel answer which task owned which expensive resource and when it was released or expired. The ledger seeds small-VM resource budgets in `runtime_info` and enforces active task budgets during atomic task claiming: active steps, model calls, file patch jobs, and test jobs can be capped before a worker materializes a harness. Browser context and heavy sandbox budgets are enforced at resource-lease creation, so a second pool or trust zone cannot bypass the global small-VM cap. Trust-zone budgets are also enforced at lease creation for sandbox work: `max_processes` caps active sandbox leases inside the zone, and `max_runtime_seconds` clamps lease runtime and expiry. Operators can tune these values with `openclaw runtime budget set --active-agent-steps-max <n> --browser-contexts-max <n> --heavy-sandboxes-max <n>`. This keeps a 4 GB machine from materializing more heavy execution work than its configured task and trust-zone budgets allow.
2727

2828
Browser targets and observations are compact ledger rows, not retained screenshots or traces. This lets small machines keep enough browser provenance to answer which target made a request, emitted console output, or produced an artifact while still pruning old observations with `openclaw runtime prune`.

0 commit comments

Comments
 (0)