mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 08:04:25 -10:00
fix(extension-host): keep Node 24 hosts under the Linux memory cap
Hosted Safety on89ee629ereached 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: on89ee629eclippy 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 HEADcf033337and 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 for89ee629e: 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:
1 parent
89ee629e76
commit
e0a758ffed
8 files changed
+130
-43
No files matched your search
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user