mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 08:04:25 -10:00
fix(ci): repair Windows Native ACL pinning and platform fixtures
Windows Native admission re-opened directories that the grant walk already
held with MAXIMUM_ALLOWED and read-only sharing (the root via open_acl, every
ancestor via open_acl(child), and each file's parent via grant_file), so every
host launch failed with a sharing violation. Grant and retirement now extend
the held Arc<File> pin chain and open only new child leaves, keeping
MAXIMUM_ALLOWED (no SetSecurityInfo propagation), no-follow, no-write/no-delete
sharing, exact descendant validation and the 65,536-entry bound. ACL_EDITS is
taken once by each grant/retire entrypoint around its whole walk.
Windows private-directory validation compares the captured handle's physical
identity and normalized final path with a fresh no-follow selection of the
expected path, so legitimate 8.3 spellings (RUNNER~1) are accepted while
replacement and reparse points are still refused; a Windows test proves both.
Nested extension-host dist files are pinned to LF so materialized modules keep
their admitted SHA-256 on Windows checkouts.
Fixture repairs keep their original assertions: canonical credential homes on
macOS, the post-sync plugin view for queued Native skills, OS-specific App
Server transport, absolute canonical ledger roots, the intentional Windows
Fleet origin rename fence, the Windows unclassified-invocation safety floor for
the bare `&` case, path-component roster checks, a real Windows launch plan,
and a Linux network check that requires a distinct kernel network namespace
plus ECONNREFUSED. The CLI diagnostic no longer interpolates the credential
line (CodeQL 541). Safety, Lint, Ubuntu Test and release parity share one
guarded GitHub-hosted Ubuntu headroom script; commands, filters, budgets and
timeouts are unchanged.
Source12 local verification (3,792 source/mode/absence rows; owning HEAD
cf033337 and semantic index 5983aa54 unchanged throughout):
- npm test and npm run check:web: 1,184 passed / 0 failed / 7 explicit skips
(wrapper 101, SDK 19, host 382 + 7 skips, website 682); strict TypeScript
and production web build passed.
- One locked/offline all-features TUI/App Server/CLI lib build, then the 24
affected Darwin cases: 24 passed / 0 failed / 0 ignored. Nineteen ran under
a loopback-only Seatbelt profile; the four native-first cases emitted
compiled containment, memory and controller-positive/OS-denied network
markers; five Native-host cases ran from the same pinned image without the
outer wrapper, because nested sandbox-exec refuses the host's own Seatbelt.
Includes the three MCP credential/proxy refusal tests.
Not proven here: Windows compilation and runtime of the ACL and private-path
repairs, Linux namespace/memory fixtures, actual runner headroom provisioning,
Safety, Lint runtime/persistence budgets and native exports all require this
head's hosted CI. This is source integration, not release, deploy, provider or
install approval. CodeQL 533/540 were dismissed as test-only fixture flows.
Evidence: artifacts/gpt61-0101-takeover-20261002/parent-source12-application,
parent-source12-focused-rust-actual, parent-source12-native-host-five-actual,
parent-source12-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
8d676e3ef9
commit
89ee629e76
16 files changed
+418
-134
No files matched your search
+1
-1
@@ -59,7 +59,7 @@ scripts/fixtures/dsh-web-app/** text eol=lf
|
||||
# include_bytes!()'d and digest-checked at launch; a CRLF Windows checkout
|
||||
# failed the former and would embed different bytes.
|
||||
crates/tui/extension-host/src/protocol.generated.ts text eol=lf
|
||||
crates/tui/extension-host/dist/* text eol=lf
|
||||
crates/tui/extension-host/dist/** text eol=lf
|
||||
# Reviewed extension-host fixtures carry exact source SHA-256 receipts.
|
||||
# A Windows checkout must not rewrite their admitted source closure to CRLF.
|
||||
crates/tui/tests/fixtures/extension_host/** text eol=lf
|
||||
|
||||
+10
-16
@@ -466,6 +466,9 @@ jobs:
|
||||
done
|
||||
sudo apt-get install -y libdbus-1-dev pkg-config bubblewrap apparmor-profiles
|
||||
sh scripts/prepare-linux-test-sandbox.sh
|
||||
- name: Ubuntu build headroom
|
||||
shell: bash
|
||||
run: bash scripts/prepare-ubuntu-build-headroom.sh
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
cache-bin: false
|
||||
@@ -525,6 +528,10 @@ jobs:
|
||||
sleep 15
|
||||
done
|
||||
sudo apt-get install -y libdbus-1-dev pkg-config
|
||||
- name: Ubuntu build headroom
|
||||
if: needs.changes.outputs.heavy == 'true'
|
||||
shell: bash
|
||||
run: bash scripts/prepare-ubuntu-build-headroom.sh
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: needs.changes.outputs.heavy == 'true'
|
||||
with:
|
||||
@@ -886,24 +893,11 @@ jobs:
|
||||
sudo apt-get install -y libdbus-1-dev pkg-config bubblewrap apparmor-profiles
|
||||
sh scripts/prepare-linux-test-sandbox.sh
|
||||
- name: Ubuntu build headroom
|
||||
# The combined wave/0.10.1-next workspace build was terminated
|
||||
# mid-compile on every PR run ("runner has received a shutdown
|
||||
# signal", exit 143, no newer push to cancel it) while single-fix PRs
|
||||
# finished the same build in ~10 minutes. Drop preinstalled SDKs this
|
||||
# job never uses and add swap so the full --all-features test build
|
||||
# has disk and memory headroom; the Run tests step logs both.
|
||||
# Reuse the same fixed hosted-runner provisioning as Safety and Lint.
|
||||
# Exit 143 is a shutdown observation, not proof of OOM or timeout.
|
||||
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'ubuntu-latest' && (github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
|
||||
shell: bash
|
||||
run: |
|
||||
df -h / /mnt; free -m
|
||||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /usr/local/.ghcup /opt/hostedtoolcache/CodeQL
|
||||
if sudo fallocate -l 8G /mnt/cw-swapfile && sudo chmod 600 /mnt/cw-swapfile \
|
||||
&& sudo mkswap /mnt/cw-swapfile >/dev/null && sudo swapon /mnt/cw-swapfile; then
|
||||
echo "Added 8 GiB swap at /mnt/cw-swapfile"
|
||||
else
|
||||
echo "::warning::Could not add swap; continuing with the runner default"
|
||||
fi
|
||||
df -h / /mnt; free -m
|
||||
run: bash scripts/prepare-ubuntu-build-headroom.sh
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
|
||||
with:
|
||||
|
||||
@@ -81,23 +81,10 @@ jobs:
|
||||
-A clippy::too_many_arguments \
|
||||
-A clippy::unnecessary_map_or
|
||||
- name: Build headroom
|
||||
# The headroom trace below showed this job's test build using all of
|
||||
# the runner's memory and swap (15574/15989 MiB and 3052/3071 MiB on
|
||||
# 2c95b7de7), which is how the runner came to shut it down on earlier
|
||||
# candidates. Same step as ci.yml's "Ubuntu build headroom": drop
|
||||
# preinstalled SDKs this job never uses and add swap. release.yml
|
||||
# calls this workflow after the tag, so the tagged tree needs it too.
|
||||
# Same fixed ephemeral-runner provisioning as CI Safety, Lint, and Test.
|
||||
# Resource headroom is preventive; a shutdown alone does not prove OOM.
|
||||
shell: bash
|
||||
run: |
|
||||
df -h / /mnt; free -m
|
||||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /usr/local/.ghcup /opt/hostedtoolcache/CodeQL
|
||||
if sudo fallocate -l 8G /mnt/cw-swapfile && sudo chmod 600 /mnt/cw-swapfile \
|
||||
&& sudo mkswap /mnt/cw-swapfile >/dev/null && sudo swapon /mnt/cw-swapfile; then
|
||||
echo "Added 8 GiB swap at /mnt/cw-swapfile"
|
||||
else
|
||||
echo "::warning::Could not add swap; continuing with the runner default"
|
||||
fi
|
||||
df -h / /mnt; free -m
|
||||
run: bash scripts/prepare-ubuntu-build-headroom.sh
|
||||
- name: Build canonical executable for acceptance tests
|
||||
run: cargo build -p codewhale-cli --bin codewhale --all-features --locked
|
||||
- name: Workspace tests
|
||||
|
||||
@@ -6126,7 +6126,12 @@ mod tests {
|
||||
)
|
||||
.await
|
||||
.expect("capabilities dispatch");
|
||||
assert_eq!(caps.result["transport"], json!("unix-socket"));
|
||||
let expected_transport = if cfg!(windows) {
|
||||
"named-pipe"
|
||||
} else {
|
||||
"unix-socket"
|
||||
};
|
||||
assert_eq!(caps.result["transport"], json!(expected_transport));
|
||||
let methods: Vec<String> = caps.result["methods"]
|
||||
.as_array()
|
||||
.expect("methods array")
|
||||
|
||||
@@ -11055,7 +11055,7 @@ verbosity = "concise"
|
||||
);
|
||||
assert!(
|
||||
get.starts_with("xai: configured (source: Codewhale-owned OAuth generation"),
|
||||
"{get}"
|
||||
"owned OAuth must be reported as a configured generation"
|
||||
);
|
||||
assert!(
|
||||
!get.starts_with("xai: set"),
|
||||
@@ -11071,8 +11071,7 @@ verbosity = "concise"
|
||||
// The assertion messages deliberately do not interpolate `get`: it is
|
||||
// built from fixed source labels only, but it flows from the runtime
|
||||
// API-key resolver, so CodeQL's cleartext-logging query treats a
|
||||
// formatted copy as a credential sink. The two asserts above already
|
||||
// print the line on failure.
|
||||
// formatted copy as a credential sink.
|
||||
assert!(
|
||||
get.contains("token availability unprobed"),
|
||||
"the xAI get line must say only token availability is unprobed"
|
||||
|
||||
@@ -831,14 +831,8 @@ fn validate_windows_handle_path(
|
||||
expected: &Path,
|
||||
expect_directory: bool,
|
||||
) -> Result<fs::Metadata> {
|
||||
use std::ffi::OsString;
|
||||
use std::os::windows::ffi::OsStringExt as _;
|
||||
use std::os::windows::fs::MetadataExt as _;
|
||||
use std::os::windows::io::AsRawHandle as _;
|
||||
use windows_sys::Win32::Storage::FileSystem::{
|
||||
FILE_ATTRIBUTE_REPARSE_POINT, FILE_NAME_NORMALIZED, GetFinalPathNameByHandleW,
|
||||
VOLUME_NAME_DOS,
|
||||
};
|
||||
use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT;
|
||||
|
||||
let metadata = file.metadata().with_context(|| {
|
||||
format!(
|
||||
@@ -858,6 +852,92 @@ fn validate_windows_handle_path(
|
||||
},
|
||||
"Codewhale-owned xAI OAuth path has the wrong filesystem type"
|
||||
);
|
||||
|
||||
// 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.
|
||||
let components = open_windows_selected_components(expected, expect_directory)?;
|
||||
let selected = components
|
||||
.last()
|
||||
.context("private selected path unavailable")?;
|
||||
anyhow::ensure!(
|
||||
windows_file_identity(file)? == windows_file_identity(selected)?
|
||||
&& normalize_windows_path_for_comparison(&windows_final_handle_path(file)?)?
|
||||
== normalize_windows_path_for_comparison(&windows_final_handle_path(selected)?)?,
|
||||
"Codewhale-owned xAI OAuth path was redirected while opening"
|
||||
);
|
||||
Ok(metadata)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn open_windows_selected_components(expected: &Path, expect_directory: bool) -> Result<Vec<File>> {
|
||||
use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _};
|
||||
use windows_sys::Win32::Storage::FileSystem::{
|
||||
FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT,
|
||||
FILE_READ_ATTRIBUTES, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE,
|
||||
};
|
||||
|
||||
anyhow::ensure!(
|
||||
expected.is_absolute(),
|
||||
"private selected path must be absolute"
|
||||
);
|
||||
let mut current = PathBuf::new();
|
||||
let mut handles = Vec::new();
|
||||
let mut components = expected.components().peekable();
|
||||
while let Some(component) = components.next() {
|
||||
match component {
|
||||
Component::Prefix(prefix) => current.push(prefix.as_os_str()),
|
||||
Component::RootDir => current.push(Path::new(r"\")),
|
||||
Component::Normal(name) => {
|
||||
current.push(name);
|
||||
let directory = components.peek().is_some() || expect_directory;
|
||||
let handle = fs::OpenOptions::new()
|
||||
.access_mode(FILE_READ_ATTRIBUTES)
|
||||
.share_mode(
|
||||
FILE_SHARE_READ
|
||||
| FILE_SHARE_WRITE
|
||||
| if directory { 0 } else { FILE_SHARE_DELETE },
|
||||
)
|
||||
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
|
||||
.open(¤t)
|
||||
.context("opening the current private path selection")?;
|
||||
let metadata = handle.metadata()?;
|
||||
anyhow::ensure!(
|
||||
metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT == 0,
|
||||
"Codewhale-owned xAI OAuth path must not be a reparse point"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
if directory {
|
||||
metadata.is_dir()
|
||||
} else {
|
||||
metadata.is_file()
|
||||
},
|
||||
"Codewhale-owned xAI OAuth path has the wrong filesystem type"
|
||||
);
|
||||
handles.push(handle);
|
||||
}
|
||||
Component::CurDir | Component::ParentDir => {
|
||||
bail!("private selected path must be lexically normalized")
|
||||
}
|
||||
}
|
||||
}
|
||||
anyhow::ensure!(
|
||||
!handles.is_empty(),
|
||||
"private selected path cannot be a volume root"
|
||||
);
|
||||
Ok(handles)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_final_handle_path(file: &File) -> Result<PathBuf> {
|
||||
use std::ffi::OsString;
|
||||
use std::os::windows::ffi::OsStringExt as _;
|
||||
use std::os::windows::io::AsRawHandle as _;
|
||||
use windows_sys::Win32::Storage::FileSystem::{
|
||||
FILE_NAME_NORMALIZED, GetFinalPathNameByHandleW, VOLUME_NAME_DOS,
|
||||
};
|
||||
|
||||
let flags = FILE_NAME_NORMALIZED | VOLUME_NAME_DOS;
|
||||
let handle = file.as_raw_handle();
|
||||
// SAFETY: null output asks only for the required UTF-16 length.
|
||||
@@ -875,13 +955,54 @@ fn validate_windows_handle_path(
|
||||
return Err(std::io::Error::last_os_error())
|
||||
.context("resolving Codewhale-owned xAI OAuth handle path");
|
||||
}
|
||||
let actual = OsString::from_wide(&buffer[..written as usize]);
|
||||
anyhow::ensure!(
|
||||
normalize_windows_path_for_comparison(Path::new(&actual))?
|
||||
== normalize_windows_path_for_comparison(expected)?,
|
||||
"Codewhale-owned xAI OAuth path was redirected while opening"
|
||||
);
|
||||
Ok(metadata)
|
||||
Ok(PathBuf::from(OsString::from_wide(
|
||||
&buffer[..written as usize],
|
||||
)))
|
||||
}
|
||||
|
||||
#[cfg(all(test, windows))]
|
||||
mod windows_tests {
|
||||
use super::*;
|
||||
use std::os::windows::fs::OpenOptionsExt as _;
|
||||
use windows_sys::Win32::Storage::FileSystem::{
|
||||
FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE,
|
||||
FILE_SHARE_READ, FILE_SHARE_WRITE,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn selected_path_alias_keeps_identity_and_replacement_is_refused() {
|
||||
let root = tempfile::tempdir().expect("temporary root");
|
||||
let selected = root.path().join("credentials");
|
||||
fs::create_dir(&selected).expect("selected directory");
|
||||
// Hosted Windows temporary roots can contain RUNNER~1 while the kernel
|
||||
// reports the long spelling. Both must select the same retained object.
|
||||
let canonical = selected
|
||||
.canonicalize()
|
||||
.expect("canonical selected directory");
|
||||
let held = fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE)
|
||||
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
|
||||
.open(&selected)
|
||||
.expect("captured selected directory");
|
||||
validate_windows_handle_path(&held, &selected, true).expect("selected spelling");
|
||||
validate_windows_handle_path(&held, &canonical, true).expect("canonical spelling");
|
||||
|
||||
let retained = root.path().join("retained");
|
||||
fs::rename(&selected, &retained).expect("move captured object");
|
||||
fs::create_dir(&selected).expect("replacement at the same selected path");
|
||||
assert!(
|
||||
validate_windows_handle_path(&held, &selected, true).is_err(),
|
||||
"a replacement at the selected path must not inherit the captured identity"
|
||||
);
|
||||
validate_windows_handle_path(&held, &retained, true).expect("actual retained object");
|
||||
fs::create_dir(root.path().join("other")).expect("existing unnormalized path component");
|
||||
assert!(
|
||||
validate_windows_handle_path(&held, &root.path().join("other/../retained"), true)
|
||||
.is_err(),
|
||||
"lexically unnormalized paths remain inadmissible"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
|
||||
@@ -1586,7 +1586,11 @@ mod dialect_seam_tests {
|
||||
fn codex_client() -> CodewhaleClient {
|
||||
let _env_lock = crate::test_support::lock_test_env();
|
||||
let home = tempfile::tempdir().expect("isolated ChatGPT credential home");
|
||||
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
|
||||
let root = home
|
||||
.path()
|
||||
.canonicalize()
|
||||
.expect("canonical credential home");
|
||||
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &root);
|
||||
let mut config = Config {
|
||||
provider: Some("openai-codex".to_string()),
|
||||
..Config::default()
|
||||
|
||||
@@ -471,10 +471,21 @@ fn auto_review_routes_shell_commands_requiring_approval_to_reviewer() {
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(
|
||||
matches!(decision, AutoReviewPlanDecision::ConsultReviewer(_)),
|
||||
"Auto-Review must not auto-approve {command} without reviewer judgment"
|
||||
);
|
||||
if cfg!(windows) && command == "cargo test & curl https://example.com" {
|
||||
// Unclassified Windows input hits the built-in floor before the reviewer.
|
||||
assert_eq!(
|
||||
decision,
|
||||
AutoReviewPlanDecision::Block(
|
||||
"Built-in safety gate requires approval: Windows command input cannot be classified safely enough to exclude termination of Codewhale npm launchers; use a direct PID- or port-specific command".into()
|
||||
)
|
||||
);
|
||||
assert_eq!(audit["decision"], "hold_for_review");
|
||||
} else {
|
||||
assert!(
|
||||
matches!(decision, AutoReviewPlanDecision::ConsultReviewer(_)),
|
||||
"Auto-Review must not auto-approve {command} without reviewer judgment"
|
||||
);
|
||||
}
|
||||
assert_ne!(audit["decision"], "allow", "unexpected allow for {command}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1837,6 +1837,29 @@ async fn sandboxed_host_boundary(
|
||||
denied["ok"], false,
|
||||
"Native host reached {protocol} controller: {denied}"
|
||||
);
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let controller = std::fs::read_link("/proc/self/ns/net").unwrap();
|
||||
let controller = controller.to_str().unwrap();
|
||||
let host = denied["network_namespace"].as_str().unwrap();
|
||||
let namespace_id = |name: &str| {
|
||||
name.strip_prefix("net:[")
|
||||
.and_then(|name| name.strip_suffix(']'))
|
||||
.and_then(|id| id.parse::<u64>().ok())
|
||||
.filter(|id| *id != 0)
|
||||
.expect("an actual bounded Linux kernel network namespace")
|
||||
};
|
||||
assert_ne!(
|
||||
namespace_id(host),
|
||||
namespace_id(controller),
|
||||
"Native host must remain in its isolated kernel network namespace"
|
||||
);
|
||||
assert_eq!(
|
||||
denied["code"], "ECONNREFUSED",
|
||||
"the isolated loopback cannot reach the controller: {denied}"
|
||||
);
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
assert!(
|
||||
matches!(denied["code"].as_str(), Some("EPERM" | "EACCES")),
|
||||
"connection must be refused by the OS sandbox: {denied}"
|
||||
@@ -3904,7 +3927,12 @@ async fn memory_hog_is_stopped(
|
||||
MemoryEnforcement::Rlimit | MemoryEnforcement::JobObject
|
||||
) =>
|
||||
{
|
||||
assert!(message.contains("allocation failed"), "{message}");
|
||||
assert!(
|
||||
message.contains("allocation failed")
|
||||
|| (kind == crate::dependencies::HostRuntimeKind::Bun
|
||||
&& message.ends_with("RangeError: Out of memory")),
|
||||
"{message}"
|
||||
);
|
||||
}
|
||||
Err(other) => panic!("unexpected error: {other:?}"),
|
||||
}
|
||||
@@ -4835,9 +4863,19 @@ fn a_plugin_named_like_a_tier_zero_owner_fails_validation_and_never_reaches_the_
|
||||
|
||||
#[test]
|
||||
fn launch_plans_carry_their_tier_and_use_a_data_directory_each() {
|
||||
#[cfg(not(windows))]
|
||||
use crate::dependencies::{HostRuntime, HostRuntimeKind};
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
#[cfg(windows)]
|
||||
let bundle = super::materialize_bundle(home.path()).unwrap();
|
||||
#[cfg(not(windows))]
|
||||
let bundle = home.path().join("codewhale-extension-host.mjs");
|
||||
// Windows admission verifies the real copied runtime and exact bundle.
|
||||
#[cfg(windows)]
|
||||
let runtime = crate::dependencies::resolve_extension_host_runtime(NODE, None, None)
|
||||
.selected
|
||||
.expect("tier planning requires the test Node runtime on Windows");
|
||||
#[cfg(not(windows))]
|
||||
let runtime = HostRuntime {
|
||||
compiled: false,
|
||||
kind: HostRuntimeKind::Node,
|
||||
@@ -5296,10 +5334,8 @@ async fn raw_agent_presets_use_one_default_and_all_five_caller_views() {
|
||||
let original = fixture.registry();
|
||||
assert_eq!(original.selected_native_entries().len(), 1);
|
||||
assert!(
|
||||
original.selected_native_entries()[0]
|
||||
.entry
|
||||
.path
|
||||
.ends_with("/a.mjs")
|
||||
Path::new(&original.selected_native_entries()[0].entry.path).file_name()
|
||||
== Some(std::ffi::OsStr::new("a.mjs"))
|
||||
);
|
||||
let manager = fixture.manager(node);
|
||||
let _manager = super::TestManagerGuard::install(Arc::clone(&manager));
|
||||
@@ -5314,7 +5350,9 @@ async fn raw_agent_presets_use_one_default_and_all_five_caller_views() {
|
||||
assert_eq!(a.prompt_sections().await.unwrap()[0].text, "A:a");
|
||||
let selected_b = roster
|
||||
.iter()
|
||||
.find(|(preset, _)| preset.entry.path.ends_with("/b.mjs"))
|
||||
.find(|(preset, _)| {
|
||||
Path::new(&preset.entry.path).file_name() == Some(std::ffi::OsStr::new("b.mjs"))
|
||||
})
|
||||
.unwrap()
|
||||
.0
|
||||
.clone();
|
||||
@@ -5463,7 +5501,9 @@ async fn raw_agent_presets_without_default_require_explicit_child_selection() {
|
||||
);
|
||||
let selected = roster
|
||||
.iter()
|
||||
.find(|(preset, _)| preset.entry.path.ends_with("/b.mjs"))
|
||||
.find(|(preset, _)| {
|
||||
Path::new(&preset.entry.path).file_name() == Some(std::ffi::OsStr::new("b.mjs"))
|
||||
})
|
||||
.unwrap()
|
||||
.0
|
||||
.clone();
|
||||
|
||||
@@ -222,6 +222,7 @@ impl Drop for RetiredProfile {
|
||||
}
|
||||
|
||||
fn retire_scope(root: &Path, scope: &GrantScope, sid: PSID) -> io::Result<()> {
|
||||
let _serial = ACL_EDITS.lock().unwrap_or_else(|error| error.into_inner());
|
||||
if !root.try_exists()? {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -255,9 +256,9 @@ fn retire_scope(root: &Path, scope: &GrantScope, sid: PSID) -> io::Result<()> {
|
||||
}
|
||||
let mut pending = Vec::new();
|
||||
let mut count = 1;
|
||||
enqueue_children(root, &mut pending, count)?;
|
||||
enqueue_pinned_children(root, &root_pin, &mut pending, count)?;
|
||||
let mut failure = None;
|
||||
while let Some(path) = pending.pop() {
|
||||
while let Some((path, parent_pin)) = pending.pop() {
|
||||
count += 1;
|
||||
if count > MAX_GRANT_ENTRIES {
|
||||
return Err(io::Error::other(
|
||||
@@ -272,14 +273,20 @@ fn retire_scope(root: &Path, scope: &GrantScope, sid: PSID) -> io::Result<()> {
|
||||
));
|
||||
}
|
||||
if metadata.is_dir() {
|
||||
let directory = WindowsDirectory::open_acl(&path)?;
|
||||
edit_acl(directory.acl_handle()?, sid, 0, 0, REVOKE_ACCESS)?;
|
||||
enqueue_children(&path, &mut pending, count)
|
||||
} else if metadata.is_file() {
|
||||
let _ancestors = WindowsDirectory::open(
|
||||
path.parent()
|
||||
.ok_or_else(|| io::Error::other("grant has no parent"))?,
|
||||
let directory = parent_pin.open_acl_child(
|
||||
path.file_name()
|
||||
.ok_or_else(|| io::Error::other("grant has no filename"))?,
|
||||
)?;
|
||||
edit_acl(directory.acl_handle()?, sid, 0, 0, REVOKE_ACCESS)?;
|
||||
enqueue_pinned_children(&path, &directory, &mut pending, count)
|
||||
} else if metadata.is_file() {
|
||||
if parent_pin.child_path(
|
||||
path.file_name()
|
||||
.ok_or_else(|| io::Error::other("grant has no filename"))?,
|
||||
)? != path
|
||||
{
|
||||
return Err(io::Error::other("grant left its pinned parent"));
|
||||
}
|
||||
// Retiring this exact SID may overlap a new host's data
|
||||
// writes. The physical handle stays pinned, but allow those
|
||||
// legitimate writers/renames; never restore an old whole ACL.
|
||||
@@ -316,6 +323,19 @@ fn enqueue_children(path: &Path, pending: &mut Vec<PathBuf>, visited: usize) ->
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn enqueue_pinned_children(
|
||||
path: &Path,
|
||||
pin: &WindowsDirectory,
|
||||
pending: &mut Vec<(PathBuf, WindowsDirectory)>,
|
||||
visited: usize,
|
||||
) -> io::Result<()> {
|
||||
let mut children = Vec::new();
|
||||
// Reuse the exact admission/retirement bound, including already pending
|
||||
// entries. Each child keeps its actual direct parent pinned until visited.
|
||||
enqueue_children(path, &mut children, visited + pending.len())?;
|
||||
pending.extend(children.into_iter().map(|child| (child, pin.clone())));
|
||||
Ok(())
|
||||
}
|
||||
fn acl_file(path: &Path) -> io::Result<File> {
|
||||
acl_file_with_share(path, 1)
|
||||
}
|
||||
@@ -405,6 +425,7 @@ impl NativeSandbox {
|
||||
}
|
||||
|
||||
fn grant_tree(&self, root: &Path, writable: bool) -> io::Result<()> {
|
||||
let _serial = ACL_EDITS.lock().unwrap_or_else(|error| error.into_inner());
|
||||
let root_pin = WindowsDirectory::open_acl(root)?;
|
||||
self.profile.remember(root, root_pin.acl_handle()?, true)?;
|
||||
let access = FILE_GENERIC_READ
|
||||
@@ -414,9 +435,16 @@ impl NativeSandbox {
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let mut pending = vec![root.to_path_buf()];
|
||||
let mut count = 0;
|
||||
while let Some(path) = pending.pop() {
|
||||
set_acl(
|
||||
root_pin.acl_handle()?,
|
||||
self.profile.sid,
|
||||
access,
|
||||
OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE,
|
||||
)?;
|
||||
let mut pending = Vec::new();
|
||||
let mut count = 1;
|
||||
enqueue_pinned_children(root, &root_pin, &mut pending, count)?;
|
||||
while let Some((path, parent_pin)) = pending.pop() {
|
||||
count += 1;
|
||||
if count > MAX_GRANT_ENTRIES {
|
||||
return Err(io::Error::other("sandbox grant tree exceeds 65536 entries"));
|
||||
@@ -428,16 +456,19 @@ impl NativeSandbox {
|
||||
));
|
||||
}
|
||||
if metadata.is_dir() {
|
||||
let pin = WindowsDirectory::open_acl(&path)?;
|
||||
let pin = parent_pin.open_acl_child(
|
||||
path.file_name()
|
||||
.ok_or_else(|| io::Error::other("grant has no filename"))?,
|
||||
)?;
|
||||
set_acl(
|
||||
pin.acl_handle()?,
|
||||
self.profile.sid,
|
||||
access,
|
||||
OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE,
|
||||
)?;
|
||||
enqueue_children(&path, &mut pending, count)?;
|
||||
enqueue_pinned_children(&path, &pin, &mut pending, count)?;
|
||||
} else if metadata.is_file() {
|
||||
self.grant_file(&path, access, false)?;
|
||||
self.grant_pinned_file(&parent_pin, &path, access, false)?;
|
||||
} else {
|
||||
return Err(io::Error::other(
|
||||
"sandbox grant refuses a non-regular entry",
|
||||
@@ -448,10 +479,30 @@ impl NativeSandbox {
|
||||
}
|
||||
|
||||
fn grant_file(&self, path: &Path, access: u32, remember: bool) -> io::Result<()> {
|
||||
let _pin = WindowsDirectory::open(
|
||||
let _serial = ACL_EDITS.lock().unwrap_or_else(|error| error.into_inner());
|
||||
let pin = WindowsDirectory::open(
|
||||
path.parent()
|
||||
.ok_or_else(|| io::Error::other("file has no parent"))?,
|
||||
)?;
|
||||
self.grant_pinned_file(&pin, path, access, remember)
|
||||
}
|
||||
|
||||
// The owning grant entrypoint holds ACL_EDITS. Tree files reuse the parent
|
||||
// chain rather than reopen MAXIMUM_ALLOWED directory objects.
|
||||
fn grant_pinned_file(
|
||||
&self,
|
||||
parent_pin: &WindowsDirectory,
|
||||
path: &Path,
|
||||
access: u32,
|
||||
remember: bool,
|
||||
) -> io::Result<()> {
|
||||
if parent_pin.child_path(
|
||||
path.file_name()
|
||||
.ok_or_else(|| io::Error::other("file has no filename"))?,
|
||||
)? != path
|
||||
{
|
||||
return Err(io::Error::other("grant left its pinned parent"));
|
||||
}
|
||||
let file = acl_file(path)?;
|
||||
if remember {
|
||||
self.profile.remember(path, &file, false)?;
|
||||
@@ -822,7 +873,8 @@ fn edit_acl(
|
||||
inheritance: u32,
|
||||
mode: windows_sys::Win32::Security::Authorization::ACCESS_MODE,
|
||||
) -> io::Result<()> {
|
||||
let _serial = ACL_EDITS.lock().unwrap_or_else(|error| error.into_inner());
|
||||
// The owning grant/retirement entrypoint holds ACL_EDITS while opening
|
||||
// and editing its exact objects, including all shared pinned ancestors.
|
||||
let mut old_acl: *mut ACL = null_mut();
|
||||
let mut descriptor = null_mut();
|
||||
let result = unsafe {
|
||||
|
||||
@@ -474,9 +474,9 @@ fn exclusive_rename_outcome(renamed: bool) -> io::Result<bool> {
|
||||
/// admission. Every original absolute ancestor is opened without delete/write
|
||||
/// sharing and without following reparse points before path-based calls.
|
||||
#[cfg(windows)]
|
||||
#[derive(Debug)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct WindowsDirectory {
|
||||
ancestors: Vec<File>,
|
||||
ancestors: Vec<std::sync::Arc<File>>,
|
||||
directory: std::path::PathBuf,
|
||||
}
|
||||
|
||||
@@ -487,7 +487,6 @@ impl WindowsDirectory {
|
||||
}
|
||||
|
||||
fn open_inner(path: &Path, create: bool, acl_target: bool) -> io::Result<Self> {
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
if !path.is_absolute() {
|
||||
return Err(invalid_path());
|
||||
}
|
||||
@@ -502,18 +501,7 @@ impl WindowsDirectory {
|
||||
if matches!(component, Component::Prefix(_)) {
|
||||
continue;
|
||||
}
|
||||
let open = || {
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.read(true).share_mode(1).custom_flags(0x0220_0000);
|
||||
if acl_target && index + 1 == count {
|
||||
// SetSecurityInfo must not recursively follow/modify
|
||||
// descendants: SDK-documented MAXIMUM_ALLOWED disables
|
||||
// propagation. Each child is fenced/granted explicitly.
|
||||
options
|
||||
.access_mode(windows_sys::Win32::System::SystemServices::MAXIMUM_ALLOWED);
|
||||
}
|
||||
options.open(&directory)
|
||||
};
|
||||
let open = || Self::open_component(&directory, acl_target && index + 1 == count);
|
||||
let file = match open() {
|
||||
Err(error) if create && error.kind() == io::ErrorKind::NotFound => {
|
||||
match std::fs::create_dir(&directory) {
|
||||
@@ -525,10 +513,6 @@ impl WindowsDirectory {
|
||||
}
|
||||
result => result?,
|
||||
};
|
||||
let metadata = file.metadata()?;
|
||||
if !metadata.is_dir() || crate::plugins::metadata_is_link_or_reparse(&metadata) {
|
||||
return Err(invalid_path());
|
||||
}
|
||||
ancestors.push(file);
|
||||
}
|
||||
Ok(Self {
|
||||
@@ -537,12 +521,54 @@ impl WindowsDirectory {
|
||||
})
|
||||
}
|
||||
|
||||
fn open_component(path: &Path, acl_target: bool) -> io::Result<std::sync::Arc<File>> {
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.read(true).share_mode(1).custom_flags(0x0220_0000);
|
||||
if acl_target {
|
||||
// SetSecurityInfo must not recursively follow/modify descendants:
|
||||
// SDK-documented MAXIMUM_ALLOWED disables propagation. Each child
|
||||
// is fenced/granted explicitly, without reopening pinned ancestors.
|
||||
options.access_mode(windows_sys::Win32::System::SystemServices::MAXIMUM_ALLOWED);
|
||||
}
|
||||
let file = options.open(path)?;
|
||||
let metadata = file.metadata()?;
|
||||
if !metadata.is_dir() || crate::plugins::metadata_is_link_or_reparse(&metadata) {
|
||||
return Err(invalid_path());
|
||||
}
|
||||
Ok(std::sync::Arc::new(file))
|
||||
}
|
||||
|
||||
pub(crate) fn child_path(&self, name: &std::ffi::OsStr) -> io::Result<std::path::PathBuf> {
|
||||
let name = Path::new(name);
|
||||
if !path_is_confined(name) || name.components().count() != 1 {
|
||||
return Err(invalid_path());
|
||||
}
|
||||
Ok(self.directory.join(name))
|
||||
}
|
||||
|
||||
/// Extend the actual held direct-parent chain. Reopening a MAXIMUM_ALLOWED
|
||||
/// ancestor would conflict with its deliberate no-write/no-delete sharing.
|
||||
pub(crate) fn open_acl_child(&self, name: &std::ffi::OsStr) -> io::Result<Self> {
|
||||
let directory = self.child_path(name)?;
|
||||
let file = Self::open_component(&directory, true)?;
|
||||
let mut ancestors = self.ancestors.clone();
|
||||
ancestors.push(file);
|
||||
Ok(Self {
|
||||
ancestors,
|
||||
directory,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn open_acl(path: &Path) -> io::Result<Self> {
|
||||
Self::open_inner(path, false, true)
|
||||
}
|
||||
|
||||
pub(crate) fn acl_handle(&self) -> io::Result<&File> {
|
||||
self.ancestors.last().ok_or_else(invalid_path)
|
||||
self.ancestors
|
||||
.last()
|
||||
.map(std::sync::Arc::as_ref)
|
||||
.ok_or_else(invalid_path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -551,7 +577,7 @@ impl WindowsDirectory {
|
||||
pub(crate) struct WorkspaceFile {
|
||||
// Retaining every ancestor without delete/write sharing prevents a path
|
||||
// swap or junction replacement while path-based Windows calls are running.
|
||||
_ancestors: Vec<File>,
|
||||
_ancestors: Vec<std::sync::Arc<File>>,
|
||||
directory: std::path::PathBuf,
|
||||
filename: std::ffi::OsString,
|
||||
}
|
||||
@@ -596,11 +622,7 @@ impl WorkspaceFile {
|
||||
return Err(invalid_path());
|
||||
}
|
||||
Ok(Self {
|
||||
_ancestors: self
|
||||
._ancestors
|
||||
.iter()
|
||||
.map(File::try_clone)
|
||||
.collect::<io::Result<_>>()?,
|
||||
_ancestors: self._ancestors.clone(),
|
||||
directory: self.directory.clone(),
|
||||
filename: name.into(),
|
||||
})
|
||||
|
||||
@@ -2855,15 +2855,29 @@ mod tests {
|
||||
);
|
||||
assert_eq!(lease.entry.run_id.0, rows[0].spec.run_id);
|
||||
drop(owner);
|
||||
std::fs::rename(&selected, root.join("retired-selected")).unwrap();
|
||||
std::fs::create_dir(&selected).unwrap();
|
||||
assert!(
|
||||
coordination
|
||||
.try_read()
|
||||
.unwrap()
|
||||
.fleet_worker_records_for_workspace(&selected)
|
||||
.is_err()
|
||||
);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// The retained directory handles prevent replacement on Windows.
|
||||
let error = std::fs::rename(&selected, root.join("retired-selected")).unwrap_err();
|
||||
assert_eq!(error.raw_os_error(), Some(32), "{error}");
|
||||
let owner = coordination.try_read().unwrap();
|
||||
let rows = owner.fleet_worker_records_for_workspace(&selected).unwrap();
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].spec.run_id, report.run_id.0);
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
// Unix permits rename of a held directory; a replacement must refuse.
|
||||
std::fs::rename(&selected, root.join("retired-selected")).unwrap();
|
||||
std::fs::create_dir(&selected).unwrap();
|
||||
assert!(
|
||||
coordination
|
||||
.try_read()
|
||||
.unwrap()
|
||||
.fleet_worker_records_for_workspace(&selected)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1615,10 +1615,10 @@ mod records_tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn scoped_root(path: &str) -> CoordinationClaimScope {
|
||||
fn scoped_root(path: impl AsRef<Path>) -> CoordinationClaimScope {
|
||||
CoordinationClaimScope {
|
||||
canonical_root: PathBuf::from(path),
|
||||
platform: "unix".into(),
|
||||
canonical_root: path.as_ref().to_path_buf(),
|
||||
platform: if cfg!(windows) { "windows" } else { "unix" }.into(),
|
||||
volume: 1,
|
||||
index: 1,
|
||||
}
|
||||
@@ -1635,14 +1635,16 @@ mod records_tests {
|
||||
|
||||
#[test]
|
||||
fn attributed_claims_project_nested_and_alias_roots_and_keep_contracts_global() {
|
||||
let original = Path::new("/work/original");
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().canonicalize().unwrap();
|
||||
let original = root.join("original");
|
||||
let mut ledger = CoordinationLedger::default();
|
||||
ledger
|
||||
.register_claim_in_scope(
|
||||
root_claim("a", "src", None),
|
||||
false,
|
||||
Some(scoped_root("/work/shared")),
|
||||
Some(original),
|
||||
Some(scoped_root(root.join("shared"))),
|
||||
Some(&original),
|
||||
|_| true,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -1651,8 +1653,8 @@ mod records_tests {
|
||||
.register_claim_in_scope(
|
||||
root_claim("nested", ".", None),
|
||||
false,
|
||||
Some(scoped_root("/work/shared/src")),
|
||||
Some(original),
|
||||
Some(scoped_root(root.join("shared/src"))),
|
||||
Some(&original),
|
||||
|_| true
|
||||
)
|
||||
.is_err()
|
||||
@@ -1662,8 +1664,8 @@ mod records_tests {
|
||||
.register_claim_in_scope(
|
||||
root_claim("alias", "src", None),
|
||||
false,
|
||||
Some(scoped_root("/work/shared")),
|
||||
Some(original),
|
||||
Some(scoped_root(root.join("shared"))),
|
||||
Some(&original),
|
||||
|_| true
|
||||
)
|
||||
.is_err()
|
||||
@@ -1672,8 +1674,8 @@ mod records_tests {
|
||||
.register_claim_in_scope(
|
||||
root_claim("disjoint", "src", Some("release")),
|
||||
false,
|
||||
Some(scoped_root("/work/other")),
|
||||
Some(original),
|
||||
Some(scoped_root(root.join("other"))),
|
||||
Some(&original),
|
||||
|_| true,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -1683,7 +1685,7 @@ mod records_tests {
|
||||
root_claim("global-contract", "unrelated", Some("release")),
|
||||
false,
|
||||
None,
|
||||
Some(original),
|
||||
Some(&original),
|
||||
|_| true
|
||||
)
|
||||
.is_err()
|
||||
@@ -1692,7 +1694,9 @@ mod records_tests {
|
||||
|
||||
#[test]
|
||||
fn attributed_schema_is_sticky_and_absent_map_or_orphan_receipt_refuses() {
|
||||
let original = Path::new("/work/original");
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().canonicalize().unwrap();
|
||||
let original = root.join("original");
|
||||
let mut legacy = CoordinationLedger::default();
|
||||
legacy
|
||||
.register_claim(root_claim("legacy", "src", None), false, |_| false)
|
||||
@@ -1710,8 +1714,8 @@ mod records_tests {
|
||||
.register_claim_in_scope(
|
||||
root_claim("extra", "src", None),
|
||||
false,
|
||||
Some(scoped_root("/work/other")),
|
||||
Some(original),
|
||||
Some(scoped_root(root.join("other"))),
|
||||
Some(&original),
|
||||
|_| false,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -1727,7 +1731,7 @@ mod records_tests {
|
||||
.unwrap_err()
|
||||
.contains("requires explicit")
|
||||
);
|
||||
decoded.claim_scopes = Some(HashMap::from([(100, scoped_root("/work/other"))]));
|
||||
decoded.claim_scopes = Some(HashMap::from([(100, scoped_root(root.join("other")))]));
|
||||
assert!(
|
||||
decoded
|
||||
.validate_replay()
|
||||
|
||||
@@ -33361,6 +33361,7 @@ async fn persisted_queued_native_skill_is_denied_after_host_withdrawal() {
|
||||
let _manager = crate::extension_host::TestManagerGuard::install(Arc::clone(&manager));
|
||||
let engine = manager.attach(Arc::clone(&plugins));
|
||||
engine.sync().await.unwrap();
|
||||
let plugins = engine.plugin_view();
|
||||
let catalog = crate::skills::discover_in_workspace_with_mode_and_plugins(
|
||||
fixture.workspace(),
|
||||
crate::skills::SkillDiscoveryMode::CodeWhaleOnly,
|
||||
@@ -33374,6 +33375,7 @@ async fn persisted_queued_native_skill_is_denied_after_host_withdrawal() {
|
||||
queued_session_to_ui(serde_json::from_str::<QueuedSessionMessage>(&serialized).unwrap());
|
||||
let mut app = create_test_app();
|
||||
app.workspace = fixture.workspace().to_path_buf();
|
||||
app.plugin_registry = Arc::clone(&plugins);
|
||||
let mut git = crate::tui::git_mention::GitMentionCache::default();
|
||||
assert!(
|
||||
queued_message_content_for_app(&app, &restored, None, &mut git)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Ambient Node file access from inside a host plugin. Under the host sandbox
|
||||
// the read of a Codewhale secret must fail even though the plugin is trusted.
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import { readFile, writeFile, readlink } from 'node:fs/promises'
|
||||
import { connect } from 'node:net'
|
||||
import { createSocket } from 'node:dgram'
|
||||
|
||||
@@ -38,9 +38,17 @@ export function apply(ctx) {
|
||||
description: 'Connect to the test controller loopback listener and report the OS result.',
|
||||
parameters: { type: 'object', properties: { port: { type: 'integer', minimum: 1, maximum: 65535 } }, required: ['port'] },
|
||||
async execute(args) {
|
||||
// Linux's private loopback cannot reach the controller's listener.
|
||||
// Report the actual kernel namespace, rather than infer isolation from errno.
|
||||
const networkNamespace = process.platform === 'linux'
|
||||
? await readlink('/proc/self/ns/net')
|
||||
: undefined
|
||||
return new Promise((resolve) => {
|
||||
const socket = connect({ host: '127.0.0.1', port: args.port })
|
||||
const finish = (result) => { socket.destroy(); resolve(result) }
|
||||
const finish = (result) => {
|
||||
socket.destroy()
|
||||
resolve(networkNamespace === undefined ? result : { ...result, network_namespace: networkNamespace })
|
||||
}
|
||||
socket.once('connect', () => finish({ ok: true }))
|
||||
socket.once('error', (error) => finish({ ok: false, code: error.code ?? String(error) }))
|
||||
socket.setTimeout(1000, () => finish({ ok: false, code: 'ETIMEDOUT' }))
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
# Fixed provisioning for ephemeral GitHub-hosted Ubuntu jobs only.
|
||||
# Earlier Safety/Lint exit 143 and runner shutdown do not prove OOM or timeout.
|
||||
# Keep the existing SDK removal, swap size, warning fallback, and diagnostics.
|
||||
set -euo pipefail
|
||||
|
||||
if [ "${GITHUB_ACTIONS:-}" != true ] || [ "${RUNNER_OS:-}" != Linux ] \
|
||||
|| [ "${RUNNER_ENVIRONMENT:-}" != github-hosted ]; then
|
||||
echo "Refusing build-headroom cleanup outside GitHub-hosted Linux CI" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
df -h / /mnt; free -m
|
||||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /usr/local/.ghcup /opt/hostedtoolcache/CodeQL
|
||||
if sudo fallocate -l 8G /mnt/cw-swapfile && sudo chmod 600 /mnt/cw-swapfile \
|
||||
&& sudo mkswap /mnt/cw-swapfile >/dev/null && sudo swapon /mnt/cw-swapfile; then
|
||||
echo "Added 8 GiB swap at /mnt/cw-swapfile"
|
||||
else
|
||||
echo "::warning::Could not add swap; continuing with the runner default"
|
||||
fi
|
||||
df -h / /mnt; free -m
|
||||
Reference in new issue
Block a user