fix(extension-host): keep Node 24 hosts under the Linux memory cap

Hosted Safety on 89ee629e reached its tests for the first time and found a
real Linux defect: with Node 24.21 on x64, every Native host aborted while
creating its watchdog Worker ("Failed to reserve virtual memory for
CodeRange"), because each V8 isolate's executable code range is charged in
full to the host's 1 GiB RLIMIT_DATA cap. Node 22 was unaffected, which is why
Ubuntu Test stayed green. The watchdog now asks for a 16 MiB code range; it is
the host's only Worker (plugins cannot start Workers). The cap, memory
protection keys and every sandbox property are unchanged. The committed host
bundle was rebuilt with the pinned esbuild 0.28.1; its only diff is this line,
and every builtin module is byte-identical.

Windows hosted failures fell from 142 to 92 on 89ee629e; the ACL sharing
violations are gone and admission now stops at the LPAC token check, code that
never ran on hosted Windows before. Windows admission errors now name the
failing step, and a token refusal reports each queried flag, value or OS
error. Both stay fail-closed.

Test-only repairs from adversarial review: the Runtime API harness now shuts
the owner daemon down the way the product does (trigger, then await while the
runtime is alive), so the Windows owner receipt is retired; the scoped MCP
test reports the server's error body instead of a bare 500. Two comments no
longer overstate protection (a lexical child-path invariant; attribute-only
handles that do not pin). Lint gets 75 minutes: on 89ee629e clippy took
10 min, the runtime-contract budget passed in 16 min, and the
persistence-backlog budget was still running at the old 45-minute limit.

Source13 local verification (3,792 rows; owning HEAD cf033337 and index
5983aa54 unchanged):
- npm test and npm run check:web: 1,184 passed / 0 failed / 7 explicit skips;
  strict TypeScript and production web build passed.
- One locked/offline all-features TUI lib build on macOS, then
  runtime_api::tests::runtime_store_convergence: 14 passed / 0 failed; and 8
  host cases (the four that failed in hosted Safety, Node memory cap, launch
  plan, raw-agent presets, Native skill catalog): 8 passed / 0 failed. All ran
  under a private credential-free HOME.

Hosted evidence for 89ee629e: Test (ubuntu-latest) passed; Windows compiled
the ACL and private-path repairs; Lint's runtime-contract budget passed.
Not proven here: Windows LPAC admission, Node 24 on hosted Linux, the
persistence budget, macOS hosted tests and native exports. Open: one Windows
retirement test shows inherited file ACEs duplicated as explicit after a
grant/retire cycle; it is under investigation, not hidden. This is source
integration, not release, deploy, provider or install approval.

Evidence: artifacts/gpt61-0101-takeover-20261002/parent-source13-application,
parent-source13-local, parent-source13-npm-web-actual.

Signed-off-by: CodeWhale Bot <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-10-03 17:03:55 -07:00
1 parent 89ee629e76
commit e0a758ffed
8 files changed
+130 -43

No files matched your search

+4 -1
View File
@@ -494,7 +494,10 @@ jobs:
lint:
name: Lint
needs: changes
timeout-minutes: 45
# Measured on 89ee629e with runner headroom: clippy 10 min, runtime-contract
# budget 16 min, and persistence-backlog still running at the old 45-minute
# cancellation. Commands, filters and budgets are unchanged.
timeout-minutes: 75
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
+4 -2
View File
@@ -855,8 +855,10 @@ fn validate_windows_handle_path(
// Compare the current selection to the captured object, not its lexical
// spelling: Windows can select the same object through an 8.3 short name.
// Keep every selected parent open without delete sharing until comparison
// finishes, and refuse reparse points at every component.
// Reparse points are refused at every component. These attribute-only
// handles do not pin the path (they bypass share checks); a component
// swapped mid-walk either fails to open or selects a different identity,
// which this comparison refuses.
let components = open_windows_selected_components(expected, expect_directory)?;
let selected = components
.last()
@@ -17050,7 +17050,12 @@ var watchdog = new Worker(
if (process.ppid === parent) return
try { process.kill(workerData.group ? -workerData.pid : workerData.pid, 'SIGKILL') } catch {}
}, 500)`,
{ eval: true, workerData: { pid: process.pid, group: OWN_GROUP }, resourceLimits: { maxOldGenerationSizeMb: 8 } }
// A Worker isolate reserves its own executable code range. On Linux x64
// with Node 24 that reservation is charged in full to the host's 1 GiB
// RLIMIT_DATA cap, so a default-sized range aborted the host at startup
// ("Failed to reserve virtual memory for CodeRange"). This loop needs
// almost no generated code.
{ eval: true, workerData: { pid: process.pid, group: OWN_GROUP }, resourceLimits: { maxOldGenerationSizeMb: 8, codeRangeSizeMb: 16 } }
);
watchdog.unref();
await denyNativeCode();
+6 -1
View File
@@ -134,7 +134,12 @@ const watchdog = new Worker(
if (process.ppid === parent) return
try { process.kill(workerData.group ? -workerData.pid : workerData.pid, 'SIGKILL') } catch {}
}, 500)`,
{ eval: true, workerData: { pid: process.pid, group: OWN_GROUP }, resourceLimits: { maxOldGenerationSizeMb: 8 } },
// A Worker isolate reserves its own executable code range. On Linux x64
// with Node 24 that reservation is charged in full to the host's 1 GiB
// RLIMIT_DATA cap, so a default-sized range aborted the host at startup
// ("Failed to reserve virtual memory for CodeRange"). This loop needs
// almost no generated code.
{ eval: true, workerData: { pid: process.pid, group: OWN_GROUP }, resourceLimits: { maxOldGenerationSizeMb: 8, codeRangeSizeMb: 16 } },
)
watchdog.unref()
+5 -1
View File
@@ -297,7 +297,11 @@ impl MemoryEnforcement {
/// not in CI: under `RLIMIT_DATA` Node 24 aborts when it creates the host's
/// watchdog Worker at 512 MiB, and Bun 1.4 aborts at startup at 256 MiB.
/// Both started and ran at 1 GiB and failed an allocation past it. An idle
/// host used 34–67 MB resident.
/// host used 34–67 MB resident. On hosted x64 Linux (Node 24.21) each V8
/// isolate's executable code range is charged to `RLIMIT_DATA` in full, and
/// a default-sized second isolate aborted the host at this cap; the
/// watchdog Worker therefore asks for a 16 MiB code range (`src/main.ts`).
/// Plugins cannot start Workers (`denyNativeCode`), so it is the only one.
pub const HOST_MEMORY_CAP: u64 = 1 << 30;
/// Runtime flags, before the bundle path. Keep in sync with
+57 -17
View File
@@ -336,6 +336,10 @@ fn enqueue_pinned_children(
pending.extend(children.into_iter().map(|child| (child, pin.clone())));
Ok(())
}
/// Name the admission step in an error, so a refusal says where it stopped.
fn in_step<T>(step: &str, result: io::Result<T>) -> io::Result<T> {
result.map_err(|error| io::Error::new(error.kind(), format!("{step}: {error}")))
}
fn acl_file(path: &Path) -> io::Result<File> {
acl_file_with_share(path, 1)
}
@@ -370,7 +374,7 @@ impl NativeSandbox {
let profile = Arc::new(Profile::create()?);
let parent = home.join("extension-host").join("native-launch");
fs::create_dir_all(&parent)?;
let _parent = WindowsDirectory::open(&parent)?;
let _parent = in_step("pin launch parent", WindowsDirectory::open(&parent))?;
let assets = Arc::new(
tempfile::Builder::new()
.prefix("host-")
@@ -380,12 +384,18 @@ impl NativeSandbox {
// The selected runtime is copied, never granted access in an
// installation/user directory. Pin its opened bytes while copying.
let original = runtime.path.canonicalize()?;
let source_pin = WindowsDirectory::open(
original
.parent()
.ok_or_else(|| io::Error::other("runtime has no parent"))?,
let source_pin = in_step(
"pin runtime directory",
WindowsDirectory::open(
original
.parent()
.ok_or_else(|| io::Error::other("runtime has no parent"))?,
),
)?;
let mut source = in_step(
"open runtime",
crate::plugins::manifest::open_bundle_file(&original),
)?;
let mut source = crate::plugins::manifest::open_bundle_file(&original)?;
if source.metadata()?.len() > 512 * 1024 * 1024 {
return Err(io::Error::other(
"selected runtime exceeds 512 MiB launch limit",
@@ -405,13 +415,19 @@ impl NativeSandbox {
program,
data: data.to_path_buf(),
};
sandbox.grant_tree(sandbox._assets.path(), false)?;
in_step(
"grant runtime copy",
sandbox.grant_tree(sandbox._assets.path(), false),
)?;
// Exact canonical bundle file only: never recursively grant its
// parent, which also contains the Builtin's private data.
sandbox.grant_file(bundle, FILE_GENERIC_READ | FILE_GENERIC_EXECUTE, true)?;
in_step(
"grant host bundle",
sandbox.grant_file(bundle, FILE_GENERIC_READ | FILE_GENERIC_EXECUTE, true),
)?;
fs::create_dir_all(data.join("tmp"))?;
sandbox.grant_tree(data, true)?;
sandbox.probe(runtime, memory_cap)?;
in_step("grant data directory", sandbox.grant_tree(data, true))?;
in_step("isolation probe", sandbox.probe(runtime, memory_cap))?;
Ok(sandbox)
};
work().map_err(|error| format!("Windows Native isolation could not be verified: {error}"))
@@ -488,7 +504,10 @@ impl NativeSandbox {
}
// The owning grant entrypoint holds ACL_EDITS. Tree files reuse the parent
// chain rather than reopen MAXIMUM_ALLOWED directory objects.
// chain rather than reopen MAXIMUM_ALLOWED directory objects. The
// child_path comparison is only a lexical invariant; the protection is the
// held no-write/no-delete parent chain plus acl_file's no-follow,
// regular, single-link checks.
fn grant_pinned_file(
&self,
parent_pin: &WindowsDirectory,
@@ -807,10 +826,20 @@ fn verify_token(process: &OwnedHandle, sid: PSID) -> io::Result<()> {
return Err(io::Error::last_os_error());
}
let token = unsafe { OwnedHandle::from_raw_handle(token) };
for class in [TokenIsAppContainer, TokenIsLessPrivilegedAppContainer] {
// Both flags must be exactly 1. Report each observation, so a refusal
// says which property the kernel did not confirm.
let mut observed = Vec::new();
let mut confirmed = true;
for (name, class) in [
("TokenIsAppContainer", TokenIsAppContainer),
(
"TokenIsLessPrivilegedAppContainer",
TokenIsLessPrivilegedAppContainer,
),
] {
let mut value = 0_u32;
let mut read = 0;
if unsafe {
let queried = unsafe {
GetTokenInformation(
token.as_raw_handle(),
class,
@@ -818,11 +847,22 @@ fn verify_token(process: &OwnedHandle, sid: PSID) -> io::Result<()> {
size_of::<u32>() as u32,
&mut read,
)
} == 0
|| value != 1
{
return Err(io::Error::other("Windows refused the required LPAC token"));
} != 0;
if queried {
observed.push(format!("{name}={value}"));
} else {
observed.push(format!(
"{name} query failed: {}",
io::Error::last_os_error()
));
}
confirmed &= queried && value == 1;
}
if !confirmed {
return Err(io::Error::other(format!(
"Windows refused the required LPAC token ({})",
observed.join(", ")
)));
}
// Variable-size token buffers are aligned for their SDK structs.
for class in [TokenAppContainerSid, TokenCapabilities] {
+31 -7
View File
@@ -1162,15 +1162,30 @@ fn spawn_product_stack_server(
.expect("nonblocking test listener");
let listener =
TcpListener::from_std(listener).expect("register test listener");
// Stop the owner the way the product does: trigger its
// shutdown and await it while this runtime is alive, so
// the owner receipt is retired before the thread exits.
let owner_handle =
owner_frontend.as_ref().map(|owner| owner.shutdown_handle());
let mut owner_task =
owner_frontend.map(|owner| tokio::spawn(owner.serve()));
tokio::select! {
_ = serve_runtime_api(listener, app, shutdown) => {}
_ = shutdown_rx => {}
_ = async {
match owner_frontend {
Some(owner) => { let _ = owner.serve().await; }
match owner_task.as_mut() {
Some(task) => { let _ = task.await; }
None => std::future::pending::<()>().await,
}
} => {}
} => {
owner_task = None;
}
}
if let Some(handle) = owner_handle {
handle.trigger();
}
if let Some(task) = owner_task {
let _ = task.await;
}
}
Ok(None) => {
@@ -23006,12 +23021,21 @@ async fn mcp_server_management_blocks_credential_retargeting() -> Result<()> {
Ok(())
}
/// Like `error_for_status`, but keeps the server's `ApiError` body so a hosted
/// failure names the refusing handler instead of only its status.
async fn mcp_test_success(response: reqwest::Response) -> Result<reqwest::Response> {
let status = response.status();
if status.is_success() {
return Ok(response);
}
let url = response.url().clone();
let body = response.text().await.unwrap_or_default();
bail!("{status} for {url}: {body}")
}
async fn mcp_test_revision(client: &reqwest::Client, base: &str) -> Result<String> {
let listing: Value = client
.get(base)
.send()
let listing: Value = mcp_test_success(client.get(base).send().await?)
.await?
.error_for_status()?
.json()
.await?;
Ok(format!(
@@ -1120,13 +1120,15 @@ async fn real_owner_two_workspace_services_share_scope_caches_and_settle_global_
// persisted global mutation settles. No pool replacement or replay occurs.
let pending = selected_pool.lock().await;
let base = format!("http://{original}/v1/apps/mcp/servers");
client
.post(&base)
.header(header::IF_MATCH, mcp_test_revision(&client, &base).await?)
.json(&json!({"name":"scope-proof","command":"scope-proof-never-executed"}))
.send()
.await?
.error_for_status()?;
mcp_test_success(
client
.post(&base)
.header(header::IF_MATCH, mcp_test_revision(&client, &base).await?)
.json(&json!({"name":"scope-proof","command":"scope-proof-never-executed"}))
.send()
.await?,
)
.await?;
assert!(!pending.server_names().contains(&"scope-proof".to_string()));
drop(pending);
for (method, suffix, body) in [
@@ -1147,14 +1149,16 @@ async fn real_owner_two_workspace_services_share_scope_caches_and_settle_global_
if let Some(body) = body {
request = request.json(&body);
}
request.send().await?.error_for_status()?;
mcp_test_success(request.send().await?).await?;
}
for address in [original, first_ready.endpoint, second_ready.endpoint] {
client
.get(format!("http://{address}/v1/apps/mcp/servers"))
.send()
.await?
.error_for_status()?;
mcp_test_success(
client
.get(format!("http://{address}/v1/apps/mcp/servers"))
.send()
.await?,
)
.await?;
}
let generation = scopes
.mcp_generation