diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml new file mode 100644 index 00000000..bfc4cdfd --- /dev/null +++ b/.github/workflows/macos.yml @@ -0,0 +1,38 @@ +name: macOS +# The macOS port (docs/MACOS_PORT_ESTIMATE.md): the whole workspace must keep +# compiling on macOS with clippy warnings as errors, and the tests that pass +# there must keep passing. Nothing here is packaged or signed. +on: + push: + branches: [main] + paths: ['native/**', 'src-tauri/**', 'ui/**', 'Cargo.toml', 'Cargo.lock', '.github/workflows/macos.yml'] + pull_request: + paths: ['native/**', 'src-tauri/**', 'ui/**', 'Cargo.toml', 'Cargo.lock', '.github/workflows/macos.yml'] +permissions: + contents: read +concurrency: + group: macos-${{ github.ref }} + cancel-in-progress: true +jobs: + build: + runs-on: macos-15 + timeout-minutes: 60 + env: + RUSTUP_TOOLCHAIN: '1.95.0' + CARGO_PROFILE_DEV_DEBUG: line-tables-only + CARGO_PROFILE_TEST_DEBUG: line-tables-only + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: ui/package-lock.json + - name: Install pinned Rust toolchain + run: rustup toolchain install 1.95.0 --profile minimal --component clippy + # The desktop crate embeds the built interface (tauri.conf.json frontendDist). + - run: npm --prefix ui ci + - run: npm --prefix ui run build + - run: cargo +1.95.0 clippy --workspace --all-targets --locked -- -D warnings + # Phase 2: engine unit tests pass on macOS; integration tests join later. + - run: cargo +1.95.0 test -p shadowcode-core --lib --locked diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml new file mode 100644 index 00000000..2403e554 --- /dev/null +++ b/.github/workflows/release-macos.yml @@ -0,0 +1,122 @@ +name: macOS release +# ShadowCode.app as a .dmg for Apple silicon and Intel Macs, attached to a +# published GitHub release with a SHA-256 checksum file +# (ShadowCode__macos.sha256) that scripts/install-macos.sh checks. +# The images are ad-hoc signed, not notarized: GitHub is the distribution +# channel. On pull requests that touch macOS packaging this only builds and +# smoke-tests the app (nothing is uploaded). +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: 'Release tag to build and attach (for example v2.2.0)' + required: true + pull_request: + paths: + - '.github/workflows/release-macos.yml' + - 'scripts/build-macos-app.sh' + - 'scripts/build-llama.cpp.sh' + - 'scripts/install-macos.sh' + - 'packaging/macos/**' + - 'src-tauri/tauri.macos.conf.json' + - 'src-tauri/Info.plist' + - 'tools/llama.cpp.pin' +permissions: + contents: read +concurrency: + group: release-macos-${{ github.event.release.tag_name || inputs.tag || github.ref }} + cancel-in-progress: false +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - runner: macos-15 + arch: aarch64 + - runner: macos-15-intel + arch: x64 + runs-on: ${{ matrix.runner }} + timeout-minutes: 120 + env: + RUSTUP_TOOLCHAIN: '1.95.0' + TAG: ${{ github.event.release.tag_name || inputs.tag || '' }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ env.TAG || github.sha }} + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: ui/package-lock.json + - name: Install the pinned Rust toolchain + run: rustup toolchain install 1.95.0 --profile minimal + - name: Require the tag to match the app version + if: env.TAG != '' + run: | + version="$(node -p "require('./src-tauri/tauri.conf.json').version")" + test "v$version" = "$TAG" || { echo "Tag $TAG does not match app version $version" >&2; exit 1; } + - name: Build the Metal runtime, ShadowCode.app and the .dmg + env: + SHADOWCODE_COMMIT: ${{ github.sha }} + CARGO_PROFILE_RELEASE_DEBUG: '0' + run: bash scripts/build-macos-app.sh + - name: Smoke-test the bundled app and runtime + run: | + app=target/release/bundle/macos/ShadowCode.app + version="$(node -p "require('./src-tauri/tauri.conf.json').version")" + "$app/Contents/MacOS/shadowcode" --version | grep -Fx "ShadowCode $version" + profile="$(mktemp -d)" + "$app/Contents/MacOS/shadowcode" --profile "$profile" doctor --json > "$profile/doctor.json" + node -e "const d=require(process.argv[1]); const c=(d.checks||[]).find(x=>x.id==='seatbelt'); if(!c||c.status!=='pass') throw new Error('macOS sandbox check: '+JSON.stringify(c))" "$profile/doctor.json" + "$app/Contents/Resources/llama.cpp/llama-server" --version + test -f "$app/Contents/Resources/llama.cpp/default.metallib" + dmg="target/release/bundle/dmg/ShadowCode_${version}_${{ matrix.arch }}.dmg" + test -f "$dmg" + hdiutil verify "$dmg" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: macos-dmg-${{ matrix.arch }} + path: target/release/bundle/dmg/*.dmg + if-no-files-found: error + compression-level: 0 + retention-days: 7 + attach: + needs: build + if: github.event_name != 'pull_request' + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: write + env: + TAG: ${{ github.event.release.tag_name || inputs.tag }} + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + pattern: macos-dmg-* + path: dmg + merge-multiple: true + - name: Attach the images and their checksums without replacing anything + run: | + version="${TAG#v}" + cd dmg + ls + test -f "ShadowCode_${version}_aarch64.dmg" + test -f "ShadowCode_${version}_x64.dmg" + sha256sum "ShadowCode_${version}_aarch64.dmg" "ShadowCode_${version}_x64.dmg" > "ShadowCode_${version}_macos.sha256" + cat "ShadowCode_${version}_macos.sha256" + existing="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets -q '.assets[].name')" + for file in "ShadowCode_${version}_aarch64.dmg" "ShadowCode_${version}_x64.dmg" "ShadowCode_${version}_macos.sha256"; do + if grep -Fxq "$file" <<<"$existing"; then + # Never overwrite a published file: an existing one must match. + gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern "$file" --output "existing-$file" + cmp -s "$file" "existing-$file" || { echo "$file on the release differs from this build; not replacing it" >&2; exit 1; } + else + gh release upload "$TAG" "$file" --repo "$GITHUB_REPOSITORY" + fi + done diff --git a/Cargo.lock b/Cargo.lock index 03eac00b..b2a74d80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -512,6 +512,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "cap-fs-ext" +version = "3.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "476f0d0003a760918ed4b1e039a59e11769030416f79c8222551d22785f7f70d" +dependencies = [ + "cap-primitives", + "cap-std", + "io-lifetimes", + "windows-sys 0.59.0", +] + [[package]] name = "cap-primitives" version = "3.4.6" @@ -4239,6 +4251,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.36.1" @@ -4482,6 +4517,7 @@ dependencies = [ "anyhow", "base64 0.22.1", "bytes", + "cap-fs-ext", "cap-std", "chrono", "clap", @@ -4505,6 +4541,7 @@ dependencies = [ "reqwest 0.12.28", "rmcp", "rusqlite", + "security-framework", "serde", "serde_json", "serde_yaml_ng", diff --git a/README.md b/README.md index d6979566..17475324 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,33 @@ for published versions. The 2.1.0 x86_64 packages and checksums are: `RELEASE-AUTH.sig` (download these beside the package; the installer checks the publisher's signature before running anything) +### macOS + +From 2.2.0, releases also carry `ShadowCode_VERSION_aarch64.dmg` (Apple +silicon) and `ShadowCode_VERSION_x64.dmg` (Intel), for macOS 12 or later, +with their SHA-256 checksums in `ShadowCode_VERSION_macos.sha256`. The app +includes llama.cpp with Metal for local models. + +Install or update with one command; it picks your Mac's image, checks it +against the release's checksums, puts ShadowCode in Applications and links +the `shadowcode` command into `~/.local/bin`: + +```bash +curl -fsSL https://raw.githubusercontent.com/ShadowfetchStudio/ShadowCode/main/scripts/install-macos.sh | bash +``` + +Or download the `.dmg` from the release page and drag ShadowCode into +Applications. The app is distributed on GitHub only and is not notarized by +Apple, so the first time you open a copy downloaded in a browser macOS +refuses it: open **System Settings › Privacy & Security** and choose **Open +Anyway** next to ShadowCode (the installer above does not need this). + +On macOS your settings and history are in `~/Library/Application +Support/ShadowCode`, shell commands run in the macOS sandbox (Seatbelt) and +API keys can be kept in the Keychain. To build the app yourself, run +`scripts/build-macos-app.sh` on a Mac with Xcode, Rust 1.95, Node 22 and +cmake. + ### AppImage (recommended) The installer requires signed release packages. Published 0.32.0 @@ -246,7 +273,8 @@ no version or identifier, and nothing is downloaded or installed for you. When a newer version exists, **Update available** appears in the status bar; **Settings › About** links the release notes and shows how to update this installation (the authenticated installer for the AppImage, `apt upgrade` from the -ShadowCode APT repository or your package manager for the deb). Turn it off there with **Check for updates once a +ShadowCode APT repository or your package manager for the deb, the macOS +installer command for the Mac app). Turn it off there with **Check for updates once a day**; Offline mode pauses it. Distributions can turn it off for everyone. ## First run @@ -658,6 +686,12 @@ The editor's project must be trusted in ShadowCode (or start the agent with | Your rules, skills, commands and agents for every project, Git imports | `~/.config/shadowcode/profile/` | | Rules & skills switches, imports and export links | `~/.config/shadow-agent/rulebook.json` (mode 600) | +On macOS, settings, data and state are the `config`, `data` and `state` +folders in `~/Library/Application Support/ShadowCode`, the cache is +`~/Library/Caches/ShadowCode`, and a llama.cpp runtime you build yourself goes +to `~/Library/Application Support/ShadowCode/llama.cpp` (the app carries its +own). + The directories are named `shadow-agent`, and keep that name in every 1.x version so every upgrade finds your data. `--profile DIR` keeps a separate set, for example for development. The Antigravity directories follow diff --git a/docs/MACOS_PORT_ESTIMATE.md b/docs/MACOS_PORT_ESTIMATE.md index 794211ab..9c65ae70 100644 --- a/docs/MACOS_PORT_ESTIMATE.md +++ b/docs/MACOS_PORT_ESTIMATE.md @@ -1,6 +1,6 @@ # macOS port: build estimate -Status: estimate only, nothing built. Figures are judgement from a source survey +Status: ShadowCode builds, installs and runs as a macOS app and is released on GitHub (see Progress). The estimate below is the original one. Figures are judgement from a source survey (~135k lines of Rust in `native/core` and `src-tauri`), not measurements. ## Summary @@ -11,6 +11,110 @@ Status: estimate only, nothing built. Figures are judgement from a source survey - First blocker: the ungated `gdk` dependency in `src-tauri/Cargo.toml`, plus `/proc`, D-Bus and namespace code that needs `cfg(target_os = "linux")`. +## Progress + +**Phase 1 (compile on macOS): done.** On macOS 15 (Intel iMac, Xcode 26.3) +the whole workspace compiles and `cargo clippy --workspace --all-targets -- -D +warnings` is clean. It took two fixes: voice capture's `cpal` is now a macOS +dependency too (CoreAudio), and the control socket probe sets non-blocking and +close-on-exec with `fcntl` where `SOCK_NONBLOCK`/`SOCK_CLOEXEC` don't exist. +The `gdk` dependency was already Linux-only. `.github/workflows/macos.yml` +keeps it compiling on GitHub's macOS runner. + +**Phase 2 (engine on macOS): done.** All 640 engine unit tests that build on macOS pass +there (Linux: 664). Fixed on the way, each a real macOS gap: + +- Workspace opens through cap-std now also set `FollowSymlinks::No`: on macOS + cap-std resolves paths itself and followed a final-component symlink before + `O_NOFOLLOW` reached the last `openat`. +- Absolute paths naming the workspace through a symlinked folder (`/var` is + `/private/var`, as is every temporary folder) were refused as outside it. +- Remote access's "own settings folder" refusal missed a folder that does not + exist yet under a symlinked profile path (both systems). +- Backups found their open folder through `/proc/self/fd`; on macOS they now + ask the kernel with `fcntl(F_GETPATH)`, so a symlink swapped in at the + folder's name is never written through. +- Rules exported to a vendor CLI were recognised as linked only when the + paths matched as written; they are compared by real location now. + +Integration tests (`native/core/tests`): all 1,400-odd pass on macOS too +(one language-server test can report "server too slow" under full-suite +load on a 4-core Intel iMac; it passes alone), after these product fixes: + +- Sessions and managed checkouts record the folder's real path, so a + session opened through `/var/…` still matches its workspace. +- SQLite inspection opened databases through `/proc/self/fd`. On macOS a + name under a reserved prefix stands for the held directory, and SQLite's + own `open`/`stat` calls for it become `openat`/`fstatat` on that + directory, never following a final link. +- Terminals: `openpty`, with the session's members found through + `proc_listallpids` and `getsid` so hang-up reaches job-control groups. +- Installed memory for local models comes from `hw.memsize` (was 0 GB). +- Worktree cleanup, which keeps a checkout deleted outside ShadowCode + recoverable, runs on macOS (`F_GETPATH`, `renameatx_np(RENAME_EXCL)`); + the looser fallback is gone. +- A profile rule's id stays relative when discovery names it by real path. + +The tests themselves no longer read `/proc` to decide whether a process is +alive (it passed vacuously on macOS; `proc_pidinfo` there) and no longer +need GNU `touch -d` or `setsid(1)`. Three paused-clock tests (two streams, +one download) are Linux-only: the paused clock races real loopback I/O on macOS, and the +timing logic they cover is the same everywhere. To run the suite on a Mac, +build the app first (`cargo build -p shadowcode-desktop`; the real-engine +tests start it), then `cargo test -p shadowcode-core --tests`. + +**Phases 2–3 (runs as a Mac app): done, unsigned.** `scripts/build-macos-app.sh` +builds `ShadowCode.app` and `ShadowCode__.dmg` (ad-hoc signed, +macOS 12 or later) with the Metal llama.cpp runtime inside. On the Intel +iMac the app opens from Finder, doctor passes, and the local engine reports +`metal` on its Radeon Pro 555X. What the Mac now does like Linux: + +- **Folders:** settings and history in `~/Library/Application Support/ShadowCode`, + cache in `~/Library/Caches/ShadowCode`, the user-installed runtime in + `…/ShadowCode/llama.cpp`, the bundled one in `Contents/Resources/llama.cpp`. +- **`PATH`:** opened from Finder or the Dock, the app asks the login shell + for its `PATH` once, so Homebrew Git, Node and the vendor CLIs are found. +- **Shell sandbox:** Seatbelt (`sandbox-exec`) mirroring bubblewrap: reads + only in system, developer and package-manager folders, the project and + the toolchain folders (the rest of the home folder and other volumes are + hidden, even from `stat`); an empty `HOME` linking to read-only + toolchains; writes only in the project and scratch; network off (no IP + at all, localhost included), on, or only the allow-list proxy's port. + `DEVELOPER_DIR` keeps Apple's git/clang shims fast and quiet inside it. +- **Keychain:** "Move to keyring" keeps API keys in the login Keychain. +- **Processes:** stopping work reaches every descendant + (`proc_listchildpids`); helpers die with ShadowCode through a kqueue + watcher where Linux uses `PR_SET_PDEATHSIG`. +- **Terminals** (`openpty`), the **dev-server preview** (`lsof` + libproc), + **worktree cleanup** (`F_GETPATH`, `renameatx_np`), **SQLite inspection** + (SQLite's open/stat routed through the held directory) and **installed + memory** (`hw.memsize`). +- **Local models:** llama.cpp with Metal and the same loadable CPU + variants. Its shaders are compiled at build time: compiling them at run + time took 55 s on first use and hung outside a desktop session. + +Distribution is GitHub only (no App Store, no notarization): +`.github/workflows/release-macos.yml` builds the `.dmg` for Apple silicon +(`macos-15`) and Intel (`macos-15-intel`) when a release is published, +smoke-tests the app, and attaches both with `ShadowCode__macos.sha256` +(on pull requests touching macOS packaging it only builds). +`scripts/install-macos.sh` installs or updates from a release after checking +that checksum; files fetched with curl are not quarantined, so Gatekeeper +does not block the un-notarized app. The update notice names that command. + +Still open: + +- **Publisher signatures for the macOS images.** They are checked against + the release's SHA-256 file over HTTPS, not signed with the release key: + that needs macOS targets in `release/trust/policy` and the signing tools. +- **Apple silicon** builds (`aarch64-apple-darwin`) are untested: there is + no arm64 Mac here. The runtime build already has the M1–M4 CPU variants. +- **Real model runs** on the Mac: the runtime loads and sees the GPU, but no + GGUF was available to generate with. +- Redaction's high-entropy rule masks macOS's per-user temporary folder + (`/var/folders//T/…`) as a secret. Safe but noisy; a path-aware + rule must not let base64 secrets with slashes through. + ## Work items | Area | Main files | macOS approach | Weeks | Risk | diff --git a/native/core/Cargo.toml b/native/core/Cargo.toml index d1855d7f..6260988f 100644 --- a/native/core/Cargo.toml +++ b/native/core/Cargo.toml @@ -25,6 +25,9 @@ fs2 = "0.4" libc = "0.2" tracing = "0.1" cap-std = "3" +# cap-std's own no-follow option: where it resolves paths itself (macOS has +# no openat2) O_NOFOLLOW alone does not stop a final-component symlink. +cap-fs-ext = "3" clap = { version = "4.6.7", features = ["derive"] } rmcp = { version = "=3.4.0", default-features = false, features = ["client", "server", "transport-streamable-http-client", "transport-streamable-http-server"] } sse-stream = "=0.2.6" @@ -85,6 +88,12 @@ landlock = "=0.4.7" # provide the default ALSA device on desktop Linux). Apache-2.0. cpal = { version = "=0.18.2", default-features = false } +[target.'cfg(target_os = "macos")'.dependencies] +# Voice input on macOS: the same cpal, through CoreAudio. +cpal = { version = "=0.18.2", default-features = false } +# API keys in the macOS Keychain (keyring.rs). MIT OR Apache-2.0. +security-framework = { version = "3", default-features = false } + [dev-dependencies] # The same tokio, with its paused test clock (no new crate). tokio = { version = "1", features = ["full", "test-util"] } diff --git a/native/core/src/cli/mod.rs b/native/core/src/cli/mod.rs index 52f77a87..aa69246c 100644 --- a/native/core/src/cli/mod.rs +++ b/native/core/src/cli/mod.rs @@ -51,7 +51,12 @@ impl Outcome { } impl Options { pub fn parse_args() -> Self { - Self::parse() + let args = std::env::args_os(); + // Finder may add a process serial number (`-psn_0_12345`) when it + // opens the app; it is not an option of ours. + #[cfg(target_os = "macos")] + let args = args.filter(|arg| !arg.to_string_lossy().starts_with("-psn_")); + Self::parse_from(args) } pub fn desktop(&self) -> bool { matches!(self.command, None | Some(Command::Ui)) diff --git a/native/core/src/cli_agent/auth.rs b/native/core/src/cli_agent/auth.rs index e087deb2..d31165a5 100644 --- a/native/core/src/cli_agent/auth.rs +++ b/native/core/src/cli_agent/auth.rs @@ -336,14 +336,13 @@ fn login_command(binary: &Path, args: &[&str]) -> tokio::process::Command { // No separate process group: a browser the CLI launches must survive // when the login child is stopped. super::scrub_api_keys(&mut command); - #[cfg(target_os = "linux")] - unsafe { - command.pre_exec(|| { - if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) != 0 { - return Err(std::io::Error::last_os_error()); - } - Ok(()) - }); + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + let parent = std::process::id(); + // SAFETY: only system calls between fork and exec. + unsafe { + command.pre_exec(move || crate::process::die_with_parent(parent)); + } } command } @@ -540,14 +539,13 @@ async fn connect_antigravity( return Err(error); } }; - #[cfg(target_os = "linux")] - unsafe { - command.pre_exec(|| { - if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) != 0 { - return Err(std::io::Error::last_os_error()); - } - Ok(()) - }); + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + let parent = std::process::id(); + // SAFETY: only system calls between fork and exec. + unsafe { + command.pre_exec(move || crate::process::die_with_parent(parent)); + } } let mut child = match command.spawn() { Ok(child) => child, diff --git a/native/core/src/cli_agent/catalog.rs b/native/core/src/cli_agent/catalog.rs index 7a021041..06383c04 100644 --- a/native/core/src/cli_agent/catalog.rs +++ b/native/core/src/cli_agent/catalog.rs @@ -1372,7 +1372,6 @@ mod tests { } } - #[cfg(target_os = "linux")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn cancelled_login_waiting_to_publish_cannot_save_ready_or_usage() { for expires_while_waiting in [false, true] { diff --git a/native/core/src/cli_agent/doctor.rs b/native/core/src/cli_agent/doctor.rs index 749cc936..c65b5bfb 100644 --- a/native/core/src/cli_agent/doctor.rs +++ b/native/core/src/cli_agent/doctor.rs @@ -438,7 +438,7 @@ pub async fn status(config: &CliAgentsConfig) -> Value { Value::Object(map) } -#[cfg(all(test, target_os = "linux"))] +#[cfg(all(test, any(target_os = "linux", target_os = "macos")))] mod login_lifecycle_tests { use super::*; use std::os::unix::fs::PermissionsExt; @@ -470,10 +470,8 @@ mod login_lifecycle_tests { loop { if let Ok(text) = std::fs::read_to_string(&pid_file) { if let Ok(pid) = text.parse::() { - let identity = - std::fs::read_to_string(format!("/proc/{pid}/stat")).ok(); - if let Some(identity) = identity { - break (pid, identity); + if crate::process::exists(pid) { + break pid; } } } @@ -487,15 +485,14 @@ mod login_lifecycle_tests { entered }; let (result, entered) = tokio::join!(command, interrupt); - let (pid, _identity) = - entered.expect("private version probe reached its live-child barrier"); + let pid = entered.expect("private version probe reached its live-child barrier"); assert!( result.is_none(), "cancelled read cannot publish version output" ); // No grace period or sleep after return: a zombie also fails this. assert!( - !Path::new(&format!("/proc/{pid}")).exists(), + !crate::process::exists(pid), "owned short-command child was not reaped at return" ); } diff --git a/native/core/src/cli_agent/runner.rs b/native/core/src/cli_agent/runner.rs index bd4517cf..56663e8b 100644 --- a/native/core/src/cli_agent/runner.rs +++ b/native/core/src/cli_agent/runner.rs @@ -181,30 +181,14 @@ fn append_assistant_text(collected: &mut String, text: &str) -> Result<()> { struct ProcessGroup(u32); impl ProcessGroup { - #[cfg(target_os = "linux")] - fn descendants(pid: u32) -> Vec { - let path = format!("/proc/{pid}/task/{pid}/children"); - std::fs::read_to_string(path) - .unwrap_or_default() - .split_whitespace() - .filter_map(|value| value.parse().ok()) - .flat_map(|child| { - let mut all = vec![child]; - all.extend(Self::descendants(child)); - all - }) - .collect() - } fn terminate(&self) { if self.0 == 0 { return; } - #[cfg(target_os = "linux")] - let descendants = Self::descendants(self.0); + let descendants = crate::process::descendants(self.0); unsafe { libc::kill(-(self.0 as i32), libc::SIGTERM); libc::kill(self.0 as i32, libc::SIGTERM); - #[cfg(target_os = "linux")] for child in descendants { libc::kill(child as i32, libc::SIGTERM); } @@ -214,12 +198,10 @@ impl ProcessGroup { if self.0 == 0 { return; } - #[cfg(target_os = "linux")] - let descendants = Self::descendants(self.0); + let descendants = crate::process::descendants(self.0); unsafe { libc::kill(-(self.0 as i32), libc::SIGKILL); libc::kill(self.0 as i32, libc::SIGKILL); - #[cfg(target_os = "linux")] for child in descendants { libc::kill(child as i32, libc::SIGKILL); } @@ -933,14 +915,13 @@ fn spawn_vendor( }; #[cfg(unix)] command.process_group(0); - #[cfg(target_os = "linux")] - unsafe { - command.pre_exec(|| { - if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) != 0 { - return Err(std::io::Error::last_os_error()); - } - Ok(()) - }); + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + let parent = std::process::id(); + // SAFETY: only system calls between fork and exec. + unsafe { + command.pre_exec(move || crate::process::die_with_parent(parent)); + } } let child = command .spawn() diff --git a/native/core/src/code_intel/embeddings.rs b/native/core/src/code_intel/embeddings.rs index bd60b927..b977cbef 100644 --- a/native/core/src/code_intel/embeddings.rs +++ b/native/core/src/code_intel/embeddings.rs @@ -507,14 +507,13 @@ async fn launch(binary: &Path, entry: &ModelEntry, model: &Path, gpu: bool) -> R .kill_on_drop(true); #[cfg(unix)] command.process_group(0); - #[cfg(target_os = "linux")] - unsafe { - command.pre_exec(|| { - if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) != 0 { - return Err(std::io::Error::last_os_error()); - } - Ok(()) - }); + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + let parent = std::process::id(); + // SAFETY: only system calls between fork and exec. + unsafe { + command.pre_exec(move || crate::process::die_with_parent(parent)); + } } let mut child = command .spawn() @@ -858,6 +857,13 @@ mod tests { /// A download whose server goes silent fails with a clear message /// instead of showing "downloading" forever. Paused clock: no real wait. #[tokio::test(start_paused = true)] + // The paused clock jumps ahead whenever the runtime looks idle; on macOS a + // loopback write is not always readable by then (the request then looks + // unanswered). The timeout logic is the same on every platform. + #[cfg_attr( + not(target_os = "linux"), + ignore = "paused clock races real loopback I/O outside Linux" + )] async fn a_silent_download_fails_instead_of_hanging() { use tokio::io::{AsyncReadExt, AsyncWriteExt}; let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); diff --git a/native/core/src/control.rs b/native/core/src/control.rs index ca4a5089..2ce41421 100644 --- a/native/core/src/control.rs +++ b/native/core/src/control.rs @@ -261,6 +261,7 @@ fn socket_refuses(path: &Path) -> bool { *dst = *src as libc::c_char; } // SAFETY: plain socket(2) call; the descriptor is closed below. + #[cfg(target_os = "linux")] let fd = unsafe { libc::socket( libc::AF_UNIX, @@ -268,6 +269,21 @@ fn socket_refuses(path: &Path) -> bool { 0, ) }; + // macOS has no SOCK_NONBLOCK/SOCK_CLOEXEC: set both with fcntl(2) before + // the descriptor is used. + #[cfg(not(target_os = "linux"))] + let fd = unsafe { + let fd = libc::socket(libc::AF_UNIX, libc::SOCK_STREAM, 0); + if fd >= 0 + && (libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC) != 0 + || libc::fcntl(fd, libc::F_SETFL, libc::O_NONBLOCK) != 0) + { + libc::close(fd); + -1 + } else { + fd + } + }; if fd < 0 { return false; } diff --git a/native/core/src/data.rs b/native/core/src/data.rs index fd026c98..417ec18d 100644 --- a/native/core/src/data.rs +++ b/native/core/src/data.rs @@ -397,7 +397,7 @@ impl NewFolder { /// Where to write: the open folder, whatever its name leads to now. fn inside(&self) -> PathBuf { - #[cfg(unix)] + #[cfg(target_os = "linux")] { use std::os::fd::AsRawFd; let held = PathBuf::from(format!("/proc/self/fd/{}", self.handle.as_raw_fd())); @@ -405,6 +405,30 @@ impl NewFolder { return held; } } + // macOS has no /proc: ask the kernel where the open folder is now + // (after a rename too), so a symlink swapped in at its old name is + // never written through. + #[cfg(target_os = "macos")] + { + use std::os::{fd::AsRawFd, unix::ffi::OsStrExt}; + let mut buffer = vec![0u8; libc::PATH_MAX as usize]; + // SAFETY: F_GETPATH writes at most PATH_MAX bytes, NUL-terminated, + // into the buffer; the descriptor is owned by self.handle. + let rc = unsafe { + libc::fcntl( + self.handle.as_raw_fd(), + libc::F_GETPATH, + buffer.as_mut_ptr(), + ) + }; + if rc != -1 { + let end = buffer.iter().position(|b| *b == 0).unwrap_or(buffer.len()); + let held = PathBuf::from(std::ffi::OsStr::from_bytes(&buffer[..end])); + if held.is_dir() { + return held; + } + } + } self.path.clone() } diff --git a/native/core/src/keyring.rs b/native/core/src/keyring.rs index d30ec00e..cc806b5b 100644 --- a/native/core/src/keyring.rs +++ b/native/core/src/keyring.rs @@ -1,7 +1,7 @@ //! API keys in the desktop keyring, with `secrets.env` as the fallback. //! //! The keyring is the freedesktop Secret Service (GNOME Keyring, KWallet, -//! KeePassXC …) on the session D-Bus. A key moves there only when the user +//! KeePassXC …) on the session D-Bus, or the login Keychain on macOS. A key moves there only when the user //! asks (Settings › Accounts), and moves back the same way; the move writes //! and reads back the new place before removing the old one, so a key is //! never lost. `config/keyring.json` (mode 600) lists which keys live in the @@ -95,6 +95,9 @@ fn vault() -> Result> { } return Ok(Box::new(fake::FileVault(file))); } + #[cfg(target_os = "macos")] + return Ok(Box::new(keychain::Keychain)); + #[cfg(not(target_os = "macos"))] Ok(Box::new(dbus::SecretService::open()?)) } @@ -135,7 +138,8 @@ pub fn availability() -> std::result::Result<(), String> { if std::env::var_os("SHADOWCODE_TEST_KEYRING").is_some() { return with_vault(|_| Ok(())).map_err(|error| format!("{error:#}")); } - if std::env::var_os("DBUS_SESSION_BUS_ADDRESS").is_none() + if cfg!(not(target_os = "macos")) + && std::env::var_os("DBUS_SESSION_BUS_ADDRESS").is_none() && std::env::var_os("XDG_RUNTIME_DIR").is_none() { return Err("This session has no desktop keyring (no D-Bus session).".into()); @@ -282,6 +286,58 @@ pub fn move_out(paths: &AppPaths, name: &str) -> Result<()> { Ok(()) } +#[cfg(target_os = "macos")] +mod keychain { + //! Generic passwords in the login Keychain: service `ShadowCode`, + //! account `/` (the same attributes as the Secret + //! Service). A locked Keychain is reported, never unlocked here. + use super::Vault; + use anyhow::{anyhow, Context, Result}; + use security_framework::passwords; + + const SERVICE: &str = "ShadowCode"; + /// errSecItemNotFound + const NOT_FOUND: i32 = -25300; + + pub struct Keychain; + + fn account(attributes: &[(&str, &str)]) -> Result { + let get = |key: &str| { + attributes + .iter() + .find(|(k, _)| *k == key) + .map(|(_, value)| *value) + }; + let profile = get("profile").context("Keychain item has no profile")?; + let name = get("name").context("Keychain item has no name")?; + Ok(format!("{profile}/{name}")) + } + + impl Vault for Keychain { + fn get(&self, attributes: &[(&str, &str)]) -> Result> { + match passwords::get_generic_password(SERVICE, &account(attributes)?) { + Ok(bytes) => Ok(Some( + String::from_utf8(bytes).context("Keychain item is not text")?, + )), + Err(error) if error.code() == NOT_FOUND => Ok(None), + Err(error) => Err(anyhow!("The Keychain refused: {error}")), + } + } + fn put(&self, attributes: &[(&str, &str)], _label: &str, value: &str) -> Result<()> { + passwords::set_generic_password(SERVICE, &account(attributes)?, value.as_bytes()) + .map_err(|error| anyhow!("The Keychain refused: {error}")) + } + fn delete(&self, attributes: &[(&str, &str)]) -> Result<()> { + match passwords::delete_generic_password(SERVICE, &account(attributes)?) { + Ok(()) => Ok(()), + Err(error) if error.code() == NOT_FOUND => Ok(()), + Err(error) => Err(anyhow!("The Keychain refused: {error}")), + } + } + } +} + +#[cfg(not(target_os = "macos"))] mod dbus { //! The freedesktop Secret Service, spoken directly over D-Bus with the //! `plain` session algorithm (the session bus is private to the user). @@ -515,6 +571,7 @@ pub fn overview(paths: &AppPaths) -> Result { "keyring": { "available": available.is_ok(), "detail": available.err(), + "kind": if cfg!(target_os = "macos") { "keychain" } else { "secret_service" }, }, "file": paths.secrets_file(), "keys": keys, @@ -675,4 +732,31 @@ mod tests { cache().lock().unwrap().clear(); assert_eq!(get(&paths, "SHADOWCODE_LIVE_TEST").unwrap(), None); } + + /// Writes a throwaway item to the real login Keychain and removes it. + /// Ignored by default: run with `--ignored` on a Mac with an unlocked + /// login Keychain. + #[cfg(target_os = "macos")] + #[test] + #[ignore] + fn the_macos_keychain_stores_reads_and_deletes_an_item() { + let vault = super::keychain::Keychain; + let profile = format!("test-{}", crate::id()); + let attributes = [ + ("application", "shadowcode"), + ("profile", profile.as_str()), + ("name", "SHADOWCODE_TEST_KEY"), + ]; + assert_eq!(vault.get(&attributes).unwrap(), None); + vault.put(&attributes, "test", "not-a-real-key").unwrap(); + assert_eq!( + vault.get(&attributes).unwrap().as_deref(), + Some("not-a-real-key") + ); + vault.put(&attributes, "test", "replaced").unwrap(); + assert_eq!(vault.get(&attributes).unwrap().as_deref(), Some("replaced")); + vault.delete(&attributes).unwrap(); + assert_eq!(vault.get(&attributes).unwrap(), None); + vault.delete(&attributes).unwrap(); + } } diff --git a/native/core/src/lib.rs b/native/core/src/lib.rs index af330c52..87c557a9 100644 --- a/native/core/src/lib.rs +++ b/native/core/src/lib.rs @@ -41,6 +41,8 @@ pub mod local_downloads; pub mod local_engine; pub mod local_runtime; pub mod local_templates; +#[cfg(target_os = "macos")] +pub mod login_path; pub mod lsp; #[cfg(unix)] pub mod mcp; @@ -59,7 +61,7 @@ pub mod paths; pub mod permissions; #[cfg(unix)] pub mod plugins; -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "macos"))] pub mod preview; pub mod process; pub mod project; diff --git a/native/core/src/local_engine.rs b/native/core/src/local_engine.rs index 14cee94b..06c81286 100644 --- a/native/core/src/local_engine.rs +++ b/native/core/src/local_engine.rs @@ -20,7 +20,11 @@ use std::{ }; const MAX_SCAN_FILES: usize = 256; +#[cfg(not(target_os = "macos"))] pub const MANAGED_RELATIVE: &str = ".local/lib/shadowcode"; +/// macOS keeps the user-installed runtime with the app's other data. +#[cfg(target_os = "macos")] +pub const MANAGED_RELATIVE: &str = "Library/Application Support/ShadowCode/llama.cpp"; pub const MANAGED_SERVER: &str = "llama-server"; /// Default upper bound for the server context window (tokens). pub const DEFAULT_CONTEXT_CAP: u64 = 16384; @@ -317,9 +321,13 @@ pub fn managed_dir() -> Option { } /// `usr/lib/shadowcode` next to the executable (AppImage `usr/bin/..`, deb -/// `/usr/bin/..`). +/// `/usr/bin/..`); on macOS the app bundle's `Contents/Resources/llama.cpp` +/// (the executable is in `Contents/MacOS`). pub fn bundled_dir() -> Option { let exe = std::env::current_exe().ok()?; + #[cfg(target_os = "macos")] + return Some(exe.parent()?.join("../Resources/llama.cpp")); + #[cfg(not(target_os = "macos"))] Some(exe.parent()?.join("../lib/shadowcode")) } @@ -464,8 +472,15 @@ fn backend_of(devices: &[Device]) -> String { devices .first() .map(|d| { - d.id.trim_end_matches(|c: char| c.is_ascii_digit()) - .to_ascii_lowercase() + let kind = + d.id.trim_end_matches(|c: char| c.is_ascii_digit()) + .to_ascii_lowercase(); + // llama.cpp names Metal devices MTL0, MTL1, ... + if kind == "mtl" { + "metal".into() + } else { + kind + } }) .unwrap_or_else(|| "cpu".into()) } @@ -801,6 +816,78 @@ pub fn runtime_from(candidates: Vec<(PathBuf, &'static str)>) -> Runtime { first_failure.expect("at least one candidate") } +/// Memory available to a new process now, in bytes (0 when unknown): +/// Linux's `MemAvailable`; on macOS free, inactive, speculative and +/// purgeable pages (what Activity Monitor does not count as used). +#[cfg(target_os = "macos")] +pub fn available_memory() -> u64 { + extern "C" { + // libSystem's own; libc's binding is deprecated in favour of a crate. + fn mach_host_self() -> libc::mach_port_t; + } + // One send right to the host port for the life of the process. + static HOST: std::sync::OnceLock = std::sync::OnceLock::new(); + // SAFETY: mach_host_self has no preconditions. + let host = *HOST.get_or_init(|| unsafe { mach_host_self() }); + let mut info: libc::vm_statistics64 = unsafe { std::mem::zeroed() }; + let mut count = libc::HOST_VM_INFO64_COUNT; + // SAFETY: host_statistics64 writes at most `count` integers into `info`. + let result = unsafe { + libc::host_statistics64( + host, + libc::HOST_VM_INFO64, + (&mut info as *mut libc::vm_statistics64).cast(), + &mut count, + ) + }; + // SAFETY: sysconf has no preconditions. + let page = unsafe { libc::sysconf(libc::_SC_PAGESIZE) }; + if result != 0 || page <= 0 { + return 0; + } + let pages = u64::from(info.free_count) + + u64::from(info.inactive_count) + + u64::from(info.speculative_count) + + u64::from(info.purgeable_count); + pages.saturating_mul(page as u64) +} + +/// See the macOS version. +#[cfg(not(target_os = "macos"))] +pub fn available_memory() -> u64 { + let Ok(text) = fs::read_to_string("/proc/meminfo") else { + return 0; + }; + text.lines() + .find_map(|line| line.strip_prefix("MemAvailable:")) + .and_then(|rest| rest.split_whitespace().next()?.parse::().ok()) + .map_or(0, |kb| kb.saturating_mul(1024)) +} + +/// Installed memory in bytes (0 when unknown). +#[cfg(target_os = "macos")] +pub fn read_meminfo() -> u64 { + let mut bytes: u64 = 0; + let mut size = std::mem::size_of::(); + // SAFETY: hw.memsize is a 64-bit integer; `size` is the buffer's length. + let result = unsafe { + libc::sysctlbyname( + c"hw.memsize".as_ptr(), + (&mut bytes as *mut u64).cast(), + &mut size, + std::ptr::null_mut(), + 0, + ) + }; + if result == 0 && size == std::mem::size_of::() { + bytes + } else { + 0 + } +} + +/// Installed memory in bytes (0 when unknown). +#[cfg(not(target_os = "macos"))] pub fn read_meminfo() -> u64 { let Ok(text) = fs::read_to_string("/proc/meminfo") else { return 0; @@ -838,7 +925,10 @@ pub fn hardware_json(runtime: &Runtime) -> Value { "{} with {:.1} GB VRAM via {}; {:.1} GB RAM", g.name, gb(g.total_bytes), - g.id.trim_end_matches(|c: char| c.is_ascii_digit()), + match g.id.trim_end_matches(|c: char| c.is_ascii_digit()) { + "MTL" => "Metal", + api => api, + }, gb(ram) ), (None, "cpu") => format!("CPU only; {:.1} GB RAM", gb(ram)), @@ -850,6 +940,7 @@ pub fn hardware_json(runtime: &Runtime) -> Value { json!({ "cpu_cores": std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1), "ram_bytes": ram, + "available_bytes": available_memory(), "gpu": gpu.map(|g| g.name.clone()), "vram_bytes": gpu.map(|g| g.total_bytes), "backend": backend, @@ -872,6 +963,9 @@ pub struct Budget { pub runtime_ready: bool, pub gpu_bytes: Option, pub ram_bytes: u64, + /// Memory free for a new process now (0 when unknown): installed memory + /// says whether a model can ever fit, this whether it fits today. + pub available_bytes: u64, pub context_cap: u64, pub architectures: Option>>, } @@ -882,6 +976,7 @@ impl Budget { runtime_ready: runtime.ready(), gpu_bytes: runtime.gpu().map(|g| g.total_bytes), ram_bytes: read_meminfo(), + available_bytes: available_memory(), context_cap: config.context_cap(), architectures: runtime.probe.as_ref().and_then(|p| p.architectures.clone()), } @@ -1096,6 +1191,14 @@ pub fn inspect(candidate: &Candidate, budget: &Budget) -> Result { reason = "The llama.cpp runtime is not ready; see Settings › Local models.".into(); "setup_required" } else { + // In RAM, or in GPU memory that is the same RAM (Apple silicon). + let shared = fits == "cpu" || cfg!(target_os = "macos"); + if shared && budget.available_bytes > 0 && memory.total_bytes > budget.available_bytes { + notes.push(format!( + "Only {:.1} GB of memory is free right now; close other apps first or it will run very slowly.", + gb(budget.available_bytes) + )); + } reason = format!( "{} · {}-token context · ≈{:.1} GB {}", architecture.as_deref().unwrap_or("gguf"), @@ -1847,6 +1950,7 @@ mod tests { runtime_ready: true, gpu_bytes: Some(16 * GIB), ram_bytes: 64 * GIB, + available_bytes: 0, context_cap: DEFAULT_CONTEXT_CAP, architectures: Some(Arc::new(archs.iter().map(|s| s.to_string()).collect())), } @@ -1914,6 +2018,45 @@ mod tests { ); } + #[test] + fn free_memory_is_read_and_never_exceeds_installed() { + let available = available_memory(); + let installed = read_meminfo(); + eprintln!("available={available} installed={installed}"); + assert!(available > 0 && available <= installed); + } + + #[test] + fn a_model_bigger_than_free_memory_says_so() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("small.gguf"); + model(&path, "qwen3", Some("{% if tools %}{% endif %}")); + let config = LocalEngineConfig { + files: vec![path.display().to_string()], + ..Default::default() + }; + // Plenty installed, but the GPU is too small and almost nothing is free. + let mut short = budget(&["qwen3"]); + short.gpu_bytes = None; + short.available_bytes = 1024 * 1024; + let entries = scan_with(&config, &short); + assert_eq!(entries[0].availability, "ready"); + assert!( + entries[0].reason.contains("of memory is free right now"), + "{}", + entries[0].reason + ); + // Unknown (0) or enough free memory: no note. + short.available_bytes = 0; + assert!(!scan_with(&config, &short)[0] + .reason + .contains("free right now")); + short.available_bytes = 64 * GIB; + assert!(!scan_with(&config, &short)[0] + .reason + .contains("free right now")); + } + #[test] fn metadata_decides_models_projectors_tools_and_compatibility() { let dir = tempfile::tempdir().unwrap(); @@ -2034,6 +2177,12 @@ mod tests { assert_eq!(backend_of(&devices), "vulkan"); assert!(parse_devices("Available devices:\n").is_empty()); assert_eq!(backend_of(&[]), "cpu"); + // macOS (the real output on an Intel iMac). + let metal = parse_devices( + "Available devices:\n MTL0: AMD Radeon Pro 555X (2048 MiB, 2047 MiB free)\n", + ); + assert_eq!(metal[0].name, "AMD Radeon Pro 555X"); + assert_eq!(backend_of(&metal), "metal"); let (v, c) = parse_version("x\nversion: 0.4.1-dev (build 1, commit 18f9f7bef)\nbuilt with GNU") .unwrap(); diff --git a/native/core/src/local_runtime.rs b/native/core/src/local_runtime.rs index 4dfe7436..b5cb46d1 100644 --- a/native/core/src/local_runtime.rs +++ b/native/core/src/local_runtime.rs @@ -905,14 +905,13 @@ async fn launch( .kill_on_drop(true); #[cfg(unix)] command.process_group(0); - #[cfg(target_os = "linux")] - unsafe { - command.pre_exec(|| { - if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) != 0 { - return Err(std::io::Error::last_os_error()); - } - Ok(()) - }); + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + let parent = std::process::id(); + // SAFETY: only system calls between fork and exec. + unsafe { + command.pre_exec(move || crate::process::die_with_parent(parent)); + } } if cancel.is_cancelled() || abort.is_cancelled() { return Err(other(anyhow!("Model load cancelled"))); diff --git a/native/core/src/login_path.rs b/native/core/src/login_path.rs new file mode 100644 index 00000000..32f52858 --- /dev/null +++ b/native/core/src/login_path.rs @@ -0,0 +1,102 @@ +//! macOS apps opened from Finder or the Dock inherit launchd's minimal +//! `PATH` (`/usr/bin:/bin:/usr/sbin:/sbin`), so Homebrew's Git, Node and the +//! vendor command-line tools would be missing. When not started from a +//! terminal, ask the user's login shell for its `PATH` once at startup and +//! put those entries first; the inherited entries stay after them. +use std::{ + io::Read, + path::Path, + process::{Command, Stdio}, + time::{Duration, Instant}, +}; + +const MARK: &str = "__SHADOWCODE_PATH__"; +const WAIT: Duration = Duration::from_secs(5); + +/// Adopt the login shell's `PATH`. Call before any other thread starts. +pub fn adopt() { + if std::env::var_os("TERM").is_some() { + // Started from a terminal: its PATH is already the user's. + return; + } + let inherited = std::env::var("PATH").unwrap_or_default(); + let mut entries: Vec = login_path().map(|p| split(&p)).unwrap_or_default(); + if entries.is_empty() { + // The shell did not answer in time: the usual install locations. + let home = std::env::var("HOME").unwrap_or_default(); + entries = [ + "/opt/homebrew/bin", + "/opt/homebrew/sbin", + "/usr/local/bin", + &format!("{home}/.local/bin"), + &format!("{home}/.cargo/bin"), + ] + .iter() + .filter(|dir| Path::new(dir).is_dir()) + .map(|dir| dir.to_string()) + .collect(); + } + for entry in split(&inherited) { + if !entries.contains(&entry) { + entries.push(entry); + } + } + // Called at startup, before the program starts other threads. + std::env::set_var("PATH", entries.join(":")); +} + +fn split(path: &str) -> Vec { + path.split(':') + .filter(|entry| entry.starts_with('/')) + .map(str::to_owned) + .collect() +} + +/// `PATH` as an interactive login shell sets it (.zprofile and .zshrc). +fn login_path() -> Option { + let shell = std::env::var("SHELL") + .ok() + .filter(|shell| shell.starts_with('/') && Path::new(shell).is_file()) + .unwrap_or_else(|| "/bin/zsh".into()); + let mut child = Command::new(shell) + .args(["-l", "-i", "-c"]) + .arg(format!("printf '{MARK}%s{MARK}' \"$PATH\"")) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + let mut stdout = child.stdout.take()?; + let reader = std::thread::spawn(move || { + let mut text = String::new(); + let _ = stdout.read_to_string(&mut text); + text + }); + let started = Instant::now(); + loop { + match child.try_wait() { + Ok(Some(_)) => break, + Ok(None) if started.elapsed() < WAIT => std::thread::sleep(Duration::from_millis(20)), + _ => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + } + } + let text = reader.join().ok()?; + let start = text.find(MARK)? + MARK.len(); + let end = start + text[start..].find(MARK)?; + Some(text[start..end].to_owned()) +} + +#[cfg(test)] +mod tests { + #[test] + fn only_absolute_entries_are_kept() { + assert_eq!( + super::split("/a::relative:/b"), + vec!["/a".to_string(), "/b".to_string()] + ); + } +} diff --git a/native/core/src/lsp/client.rs b/native/core/src/lsp/client.rs index f10a1804..a798791c 100644 --- a/native/core/src/lsp/client.rs +++ b/native/core/src/lsp/client.rs @@ -348,14 +348,13 @@ impl Client { .kill_on_drop(true); #[cfg(unix)] command.process_group(0); - #[cfg(target_os = "linux")] - unsafe { - command.pre_exec(|| { - if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) != 0 { - return Err(std::io::Error::last_os_error()); - } - Ok(()) - }); + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + let parent = std::process::id(); + // SAFETY: only system calls between fork and exec. + unsafe { + command.pre_exec(move || crate::process::die_with_parent(parent)); + } } let mut child = command .spawn() diff --git a/native/core/src/paths.rs b/native/core/src/paths.rs index 5b0b1809..d17c1f04 100644 --- a/native/core/src/paths.rs +++ b/native/core/src/paths.rs @@ -35,12 +35,23 @@ impl AppPaths { pub fn discover() -> Result { let home = env::var_os("HOME").context("HOME is not set")?; let home = PathBuf::from(home); - let xdg = |key: &str, fallback: &str| { + // macOS keeps app settings and history under ~/Library; an XDG + // variable someone set on purpose still wins there too. + #[cfg(target_os = "macos")] + let fallback = |base: &str| match base { + ".cache" => home.join("Library/Caches/ShadowCode"), + ".config" => home.join("Library/Application Support/ShadowCode/config"), + ".local/share" => home.join("Library/Application Support/ShadowCode/data"), + _ => home.join("Library/Application Support/ShadowCode/state"), + }; + #[cfg(not(target_os = "macos"))] + let fallback = |base: &str| home.join(base).join("shadow-agent"); + let xdg = |key: &str, base: &str| { env::var_os(key) .map(PathBuf::from) .filter(|p| p.is_absolute()) - .unwrap_or_else(|| home.join(fallback)) - .join("shadow-agent") + .map(|p| p.join("shadow-agent")) + .unwrap_or_else(|| fallback(base)) }; let result = Self { config: xdg("XDG_CONFIG_HOME", ".config"), @@ -130,6 +141,30 @@ impl AppPaths { }) } } +/// A path naming the directory held open as `fd`. Linux names the +/// descriptor itself; macOS has no /proc and asks the kernel for the +/// directory's current path, so callers that later act through the held +/// descriptor fail closed if the directory moved in between. +pub(crate) fn held_directory(fd: std::os::fd::RawFd) -> Result { + #[cfg(target_os = "linux")] + { + Ok(PathBuf::from(format!("/proc/self/fd/{fd}"))) + } + #[cfg(target_os = "macos")] + { + use std::os::unix::ffi::OsStrExt; + let mut buffer = vec![0u8; libc::PATH_MAX as usize]; + // SAFETY: F_GETPATH writes at most PATH_MAX bytes, NUL-terminated. + let rc = unsafe { libc::fcntl(fd, libc::F_GETPATH, buffer.as_mut_ptr()) }; + anyhow::ensure!( + rc != -1, + "Could not name a held directory: {}", + std::io::Error::last_os_error() + ); + let end = buffer.iter().position(|b| *b == 0).unwrap_or(buffer.len()); + Ok(PathBuf::from(std::ffi::OsStr::from_bytes(&buffer[..end]))) + } +} /// Restrict only application-owned leaves, not the user's XDG base or an /// existing --profile parent. Use the opened directory for validation/chmod so diff --git a/native/core/src/preview/ports.rs b/native/core/src/preview/ports.rs index b540c663..3068074f 100644 --- a/native/core/src/preview/ports.rs +++ b/native/core/src/preview/ports.rs @@ -7,8 +7,12 @@ //! folder inside the project (`/proc//cwd`). //! //! Only sockets reachable over loopback count: bound to a loopback address or -//! to the wildcard address. Everything takes a `/proc` root so tests can use +//! to the wildcard address. The `/proc` readers take a root so tests can use //! fixture trees. +//! +//! macOS has no `/proc`: [`mac`] lists this user's listening sockets with the +//! system's `lsof` and reads each process's working folder through libproc. +//! [`servers`], [`own_ports_now`] and [`listening_now`] pick the right one. use regex::Regex; use serde::Serialize; use std::{ @@ -208,6 +212,246 @@ pub fn own_ports(proc_root: &Path, pid: u32) -> HashSet { .collect() } +/// The project's servers on this computer (see [`project_servers`]). +pub fn servers(project: &Path, exclude_pid: u32) -> Vec { + #[cfg(target_os = "macos")] + { + mac::project_servers(project, exclude_pid) + } + #[cfg(not(target_os = "macos"))] + { + project_servers(Path::new("/proc"), project, exclude_pid) + } +} + +/// Ports this process listens on now (see [`own_ports`]). +pub fn own_ports_now(pid: u32) -> HashSet { + #[cfg(target_os = "macos")] + { + mac::listeners() + .into_iter() + .filter(|l| l.pid == pid) + .map(|l| l.port) + .collect() + } + #[cfg(not(target_os = "macos"))] + { + own_ports(Path::new("/proc"), pid) + } +} + +/// Every port something listens on now (on macOS: this user's processes). +pub fn listening_now() -> HashSet { + #[cfg(target_os = "macos")] + { + mac::listeners().into_iter().map(|l| l.port).collect() + } + #[cfg(not(target_os = "macos"))] + { + listening(Path::new("/proc")) + .iter() + .map(|l| l.port) + .collect() + } +} + +#[cfg(target_os = "macos")] +pub mod mac { + use super::{loopback_reachable, Found}; + use std::{ + net::IpAddr, + path::{Path, PathBuf}, + process::{Command, Stdio}, + }; + + /// One listening TCP socket of one process. + #[derive(Clone, Debug, PartialEq, Eq)] + pub struct Socket { + pub pid: u32, + pub command: String, + pub ip: IpAddr, + pub port: u16, + } + + /// Parse `lsof -F pcn` output: `p`, `c`, then one + /// `n
:` line per socket (`*`, `127.0.0.1`, `[::1]`). + pub fn parse_lsof(text: &str) -> Vec { + let mut sockets = Vec::new(); + let (mut pid, mut command) = (None, String::new()); + for line in text.lines() { + let (tag, value) = line.split_at(line.len().min(1)); + match tag { + "p" => { + pid = value.parse().ok(); + command.clear(); + } + "c" => command = value.chars().take(64).collect(), + "n" => { + let Some(pid) = pid else { continue }; + let Some((host, port)) = value.rsplit_once(':') else { + continue; + }; + let Ok(port) = port.parse::() else { + continue; + }; + let host = host.trim_start_matches('[').trim_end_matches(']'); + let ip = match host { + "*" => IpAddr::from([0, 0, 0, 0]), + host => match host.parse() { + Ok(ip) => ip, + Err(_) => continue, + }, + }; + sockets.push(Socket { + pid, + command: command.clone(), + ip, + port, + }); + } + _ => {} + } + } + sockets + } + + /// This user's listening TCP sockets that loopback can reach. + pub fn listeners() -> Vec { + // SAFETY: getuid cannot fail. + let uid = unsafe { libc::getuid() }; + let Ok(output) = Command::new("/usr/sbin/lsof") + .args(["-nP", "-a", "-iTCP", "-sTCP:LISTEN", "-u"]) + .arg(uid.to_string()) + .arg("-Fpcn") + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .output() + else { + return Vec::new(); + }; + parse_lsof(&String::from_utf8_lossy(&output.stdout)) + .into_iter() + .filter(|s| loopback_reachable(s.ip)) + .collect() + } + + /// A process's current working folder. + pub fn cwd(pid: u32) -> Option { + let mut info: libc::proc_vnodepathinfo = unsafe { std::mem::zeroed() }; + let size = std::mem::size_of::() as libc::c_int; + // SAFETY: proc_pidinfo writes at most `size` bytes into `info`. + let written = unsafe { + libc::proc_pidinfo( + i32::try_from(pid).ok()?, + libc::PROC_PIDVNODEPATHINFO, + 0, + (&mut info as *mut libc::proc_vnodepathinfo).cast(), + size, + ) + }; + if written != size { + return None; + } + // SAFETY: vip_path is a MAXPATHLEN byte buffer, NUL-terminated. + let bytes: &[u8] = unsafe { + std::slice::from_raw_parts( + info.pvi_cdir.vip_path.as_ptr().cast(), + std::mem::size_of_val(&info.pvi_cdir.vip_path), + ) + }; + let end = bytes.iter().position(|b| *b == 0)?; + use std::os::unix::ffi::OsStrExt; + Some(PathBuf::from(std::ffi::OsStr::from_bytes(&bytes[..end]))) + } + + /// A process's executable path, bounded. + fn executable(pid: u32) -> String { + let mut buffer = vec![0u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize]; + // SAFETY: proc_pidpath writes at most the buffer's size. + let len = unsafe { + libc::proc_pidpath( + pid as libc::c_int, + buffer.as_mut_ptr().cast(), + buffer.len() as u32, + ) + }; + buffer.truncate(len.max(0) as usize); + String::from_utf8_lossy(&buffer).chars().take(400).collect() + } + + /// Processes of this user whose working folder is inside `project` + /// and that listen on a loopback-reachable port. + pub fn project_servers(project: &Path, exclude_pid: u32) -> Vec { + let project = project + .canonicalize() + .unwrap_or_else(|_| project.to_path_buf()); + let mut found = Vec::new(); + for socket in listeners() { + if socket.pid == exclude_pid { + continue; + } + let Some(cwd) = cwd(socket.pid) else { + continue; + }; + if !cwd.starts_with(&project) { + continue; + } + found.push(Found { + port: socket.port, + ip: socket.ip, + pid: socket.pid, + process: socket.command.clone(), + command: executable(socket.pid), + cwd, + }); + } + found.sort_by_key(|f| (f.port, f.pid)); + found.dedup_by_key(|f| f.port); + found + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn parses_lsof_fields() { + let text = + "p501\ncnode\nn*:5173\nn127.0.0.1:9229\np502\ncpython3.12\nn[::1]:8000\nnbad\n"; + let sockets = parse_lsof(text); + assert_eq!(sockets.len(), 3); + assert_eq!((sockets[0].pid, sockets[0].port), (501, 5173)); + assert_eq!(sockets[0].command, "node"); + assert_eq!(sockets[2].ip, "::1".parse::().unwrap()); + } + + #[test] + fn finds_a_server_running_in_the_project() { + let project = tempfile::tempdir().unwrap(); + let mut child = Command::new("/usr/bin/python3") + .args(["-c", "import socket,time; s=socket.socket(); s.bind(('127.0.0.1',0)); s.listen(); print(s.getsockname()[1], flush=True); time.sleep(30)"]) + .current_dir(project.path()) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + let mut line = String::new(); + std::io::BufRead::read_line( + &mut std::io::BufReader::new(child.stdout.take().unwrap()), + &mut line, + ) + .unwrap(); + let port: u16 = line.trim().parse().unwrap(); + let found = project_servers(project.path(), std::process::id()); + let _ = child.kill(); + let _ = child.wait(); + assert!( + found.iter().any(|f| f.port == port && f.pid == child.id()), + "{found:?}" + ); + } + } +} + /// A local URL printed in a process's output. #[derive(Clone, Debug, PartialEq, Eq)] pub struct PrintedUrl { @@ -343,9 +587,11 @@ mod tests { #[test] fn finds_project_servers_from_proc_fixture() { let dir = tempfile::tempdir().unwrap(); - let root = dir.path().join("proc"); - let project = dir.path().join("app"); - let other = dir.path().join("elsewhere"); + // A real path, as the kernel reports working folders (macOS: /private/var). + let base = dir.path().canonicalize().unwrap(); + let root = base.join("proc"); + let project = base.join("app"); + let other = base.join("elsewhere"); fs::create_dir_all(root.join("net")).unwrap(); fs::create_dir_all(project.join("web")).unwrap(); fs::create_dir_all(&other).unwrap(); diff --git a/native/core/src/process.rs b/native/core/src/process.rs index dc40655c..72927109 100644 --- a/native/core/src/process.rs +++ b/native/core/src/process.rs @@ -80,34 +80,162 @@ pub struct ProcessResult { pub pid: u32, } +/// Every live descendant of `pid`, children first. A shell's background +/// job may leave the shell's process group; signalling these as well keeps +/// an owned task from outliving its cancellation. +pub(crate) fn descendants(pid: u32) -> Vec { + let mut all = Vec::new(); + let mut pending = vec![pid]; + while let Some(parent) = pending.pop() { + if all.len() >= 4096 { + break; + } + for child in children(parent) { + if child != pid && !all.contains(&child) { + all.push(child); + pending.push(child); + } + } + } + all +} + +#[cfg(target_os = "linux")] +fn children(pid: u32) -> Vec { + std::fs::read_to_string(format!("/proc/{pid}/task/{pid}/children")) + .unwrap_or_default() + .split_whitespace() + .filter_map(|value| value.parse().ok()) + .collect() +} + +#[cfg(target_os = "macos")] +fn children(pid: u32) -> Vec { + let Ok(pid) = libc::pid_t::try_from(pid) else { + return Vec::new(); + }; + let mut buffer = vec![0 as libc::pid_t; 1024]; + // SAFETY: the buffer holds `buffer.len()` pids; its size is in bytes. + let bytes = unsafe { + libc::proc_listchildpids( + pid, + buffer.as_mut_ptr().cast(), + (buffer.len() * std::mem::size_of::()) as libc::c_int, + ) + }; + // proc_listchildpids returns a count of pids, not of bytes. + buffer.truncate(bytes.max(0) as usize); + buffer + .into_iter() + .filter_map(|child| u32::try_from(child).ok()) + .filter(|child| *child > 0) + .collect() +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +fn children(_pid: u32) -> Vec { + Vec::new() +} + +/// Make the child about to `exec` die with ShadowCode (`parent`), so a +/// crash never leaves a model server or language server behind. +/// +/// Linux: `PR_SET_PDEATHSIG`. macOS has no equivalent: the child forks a +/// watcher that keeps none of its descriptors (pipes still reach EOF) and +/// waits on kqueue for either process to exit; if ShadowCode goes first it +/// kills the child. The watcher shares the child's process group, so +/// stopping the group stops it too. +/// +/// # Safety +/// Call only between fork and exec: it only makes system calls. +#[cfg(target_os = "linux")] +pub(crate) unsafe fn die_with_parent(_parent: u32) -> std::io::Result<()> { + if unsafe { libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) } != 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) +} + +/// See the Linux version. +/// +/// # Safety +/// Call only between fork and exec: it only makes system calls. +#[cfg(target_os = "macos")] +pub(crate) unsafe fn die_with_parent(parent: u32) -> std::io::Result<()> { + let parent = parent as libc::pid_t; + let child = unsafe { libc::getpid() }; + match unsafe { libc::fork() } { + -1 => return Err(std::io::Error::last_os_error()), + 0 => {} + _ => return Ok(()), + } + // The watcher. Nothing here allocates or takes a lock. + unsafe { + let limit = libc::getdtablesize().clamp(0, 65536); + for fd in 0..limit { + libc::close(fd); + } + let queue = libc::kqueue(); + if queue < 0 { + libc::_exit(0); + } + let watch = |pid: libc::pid_t| libc::kevent { + ident: pid as usize, + filter: libc::EVFILT_PROC, + flags: libc::EV_ADD | libc::EV_ONESHOT, + fflags: libc::NOTE_EXIT, + data: 0, + udata: std::ptr::null_mut(), + }; + let none = std::ptr::null_mut(); + // The child already gone (its exec failed): nothing to watch. + if libc::kevent(queue, &watch(child), 1, none, 0, std::ptr::null()) != 0 { + libc::_exit(0); + } + // ShadowCode already gone: the child must not outlive it. + if libc::kevent(queue, &watch(parent), 1, none, 0, std::ptr::null()) != 0 { + libc::kill(child, libc::SIGKILL); + libc::_exit(0); + } + let mut event: libc::kevent = std::mem::zeroed(); + loop { + match libc::kevent(queue, std::ptr::null(), 0, &mut event, 1, std::ptr::null()) { + 1 => { + if event.ident == parent as usize { + libc::kill(child, libc::SIGKILL); + } + libc::_exit(0); + } + -1 if *libc::__error() == libc::EINTR => continue, + _ => libc::_exit(0), + } + } + } +} + +/// Whether a process id is still taken, zombies included (not yet reaped). +#[cfg(test)] +pub(crate) fn exists(pid: u32) -> bool { + let Ok(pid) = libc::pid_t::try_from(pid) else { + return false; + }; + // SAFETY: signal 0 only checks that the process exists. + let alive = unsafe { libc::kill(pid, 0) } == 0; + alive || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) +} + struct ProcessGroup(u32); impl ProcessGroup { - #[cfg(target_os = "linux")] - fn descendants(pid: u32) -> Vec { - let path = format!("/proc/{pid}/task/{pid}/children"); - std::fs::read_to_string(path) - .unwrap_or_default() - .split_whitespace() - .filter_map(|value| value.parse().ok()) - .flat_map(|child| { - let mut all = vec![child]; - all.extend(Self::descendants(child)); - all - }) - .collect() - } fn terminate(&self) { #[cfg(unix)] if self.0 != 0 { - #[cfg(target_os = "linux")] - let descendants = Self::descendants(self.0); + let descendants = crate::process::descendants(self.0); unsafe { libc::kill(-(self.0 as i32), libc::SIGTERM); // Some shells alter their process group while spawning a // background job. Signal the leader directly as well so an // owned task cannot outlive its cancellation scope. libc::kill(self.0 as i32, libc::SIGTERM); - #[cfg(target_os = "linux")] for child in descendants { libc::kill(child as i32, libc::SIGTERM); } @@ -117,12 +245,10 @@ impl ProcessGroup { fn kill(&mut self) { #[cfg(unix)] if self.0 != 0 { - #[cfg(target_os = "linux")] - let descendants = Self::descendants(self.0); + let descendants = crate::process::descendants(self.0); unsafe { libc::kill(-(self.0 as i32), libc::SIGKILL); libc::kill(self.0 as i32, libc::SIGKILL); - #[cfg(target_os = "linux")] for child in descendants { libc::kill(child as i32, libc::SIGKILL); } @@ -259,21 +385,22 @@ async fn run_internal( .envs(&spec.env); #[cfg(unix)] command.process_group(0); - #[cfg(target_os = "linux")] - let setup = spec.child.clone(); - #[cfg(target_os = "linux")] - unsafe { - command.pre_exec(move || { - // Propagate cancellation to shell-created descendants when their - // owning process exits, preventing orphaned local-model tasks. - if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) != 0 { - return Err(std::io::Error::last_os_error()); - } - if let Some(setup) = &setup { - setup.enter()?; - } - Ok(()) - }); + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + let setup = spec.child.clone(); + let parent = std::process::id(); + // SAFETY: only system calls between fork and exec. + unsafe { + command.pre_exec(move || { + // Propagate cancellation to shell-created descendants when + // their owning process exits, preventing orphaned tasks. + die_with_parent(parent)?; + if let Some(setup) = &setup { + setup.enter()?; + } + Ok(()) + }); + } } let mut child = command .spawn() @@ -440,3 +567,65 @@ async fn drain( } Ok(()) } + +#[cfg(all(test, any(target_os = "linux", target_os = "macos")))] +mod die_with_parent_tests { + use std::{ + io::BufRead, + os::unix::process::CommandExt, + process::{Command, Stdio}, + time::{Duration, Instant}, + }; + + const HELPER: &str = "SHADOWCODE_DIE_WITH_PARENT_HELPER"; + + /// A crashed ShadowCode (SIGKILL, no cleanup) takes its child along. + #[test] + // The helper never waits for its child on purpose: it is killed first. + #[allow(clippy::zombie_processes)] + fn a_child_dies_when_shadowcode_is_killed() { + if std::env::var_os(HELPER).is_some() { + // The helper stands in for ShadowCode: start a child, report it, + // then wait to be killed. + let parent = std::process::id(); + let mut command = Command::new("/bin/sleep"); + command.arg("30"); + // SAFETY: only system calls between fork and exec. + unsafe { + command.pre_exec(move || super::die_with_parent(parent)); + } + let child = command.spawn().unwrap(); + println!("CHILD {}", child.id()); + std::thread::sleep(Duration::from_secs(30)); + return; + } + let mut helper = Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "process::die_with_parent_tests::a_child_dies_when_shadowcode_is_killed", + "--nocapture", + "--test-threads=1", + ]) + .env(HELPER, "1") + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + let mut child = None; + for line in std::io::BufReader::new(helper.stdout.take().unwrap()).lines() { + // libtest prints the test's name first on the same line. + if let Some(pid) = line.unwrap().split("CHILD ").nth(1) { + child = pid.trim().parse::().ok(); + break; + } + } + let child = child.expect("the helper started its child"); + assert!(super::exists(child)); + helper.kill().unwrap(); + helper.wait().unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + while super::exists(child) && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(20)); + } + assert!(!super::exists(child), "child {child} outlived its parent"); + } +} diff --git a/native/core/src/redaction.rs b/native/core/src/redaction.rs index 7b3e2691..3c32468c 100644 --- a/native/core/src/redaction.rs +++ b/native/core/src/redaction.rs @@ -90,29 +90,86 @@ fn patterns() -> &'static [Regex] { } /// High-entropy token heuristic: long base64/hex-like runs outside common words. +/// +/// An absolute path under a well-known filesystem root (`/Users/…`, +/// `/Volumes/…`, `/private/var/folders/…`) is judged one segment at a time: +/// as a whole it is long and mixes cases, digits and `/` like base64, which +/// hid the folder of a command on its approval card. A secret that is itself +/// a path segment is still found; a run that does not start at such a root +/// (base64 may contain `/`) is judged whole, as before. fn high_entropy_tokens(text: &str) -> Vec<(usize, usize)> { static ENTROPY: OnceLock = OnceLock::new(); let re = ENTROPY.get_or_init(|| Regex::new(r"[A-Za-z0-9+/_=-]{32,}").expect("entropy regex")); - re.find_iter(text) - .filter(|m| { - let s = m.as_str(); - if s.chars().all(|c| c.is_ascii_digit()) { - return false; - } - let classes = [ - s.bytes().any(|b| b.is_ascii_lowercase()), - s.bytes().any(|b| b.is_ascii_uppercase()), - s.bytes().any(|b| b.is_ascii_digit()), - s.bytes() - .any(|b| matches!(b, b'+' | b'/' | b'_' | b'=' | b'-')), - ] - .into_iter() - .filter(|v| *v) - .count(); - classes >= 3 && shannon(s) >= 3.5 - }) - .map(|m| (m.start(), m.end())) - .collect() + let mut spans = Vec::new(); + for found in re.find_iter(text) { + let run = found.as_str(); + if let Some(segments) = path_segments(run) { + spans.extend(segments.filter(|(_, segment)| looks_random(segment)).map( + |(offset, segment)| { + let start = found.start() + offset; + (start, start + segment.len()) + }, + )); + } else if looks_random(run) { + spans.push((found.start(), found.end())); + } + } + spans +} + +/// The segments (with their offsets) of an absolute path under a well-known +/// root, or `None` for anything else. +fn path_segments(run: &str) -> Option> { + const ROOTS: &[&str] = &[ + "Users", + "Volumes", + "Applications", + "Library", + "System", + "private", + "home", + "root", + "tmp", + "var", + "opt", + "usr", + "srv", + "mnt", + "media", + "run", + "etc", + "nix", + "proc", + ]; + // `file:///Users/…` leaves `///Users/…` in the run. + let rest = run.trim_start_matches('/'); + let lead = run.len() - rest.len(); + let first = rest.split('/').next()?; + if lead == 0 || !ROOTS.contains(&first) || rest.contains("//") || run.contains(['+', '=']) { + return None; + } + Some(rest.split('/').scan(lead, |offset, segment| { + let at = *offset; + *offset += segment.len() + 1; + Some((at, segment)) + })) +} + +fn looks_random(s: &str) -> bool { + if s.len() < 32 || s.chars().all(|c| c.is_ascii_digit()) { + return false; + } + let classes = [ + s.bytes().any(|b| b.is_ascii_lowercase()), + s.bytes().any(|b| b.is_ascii_uppercase()), + s.bytes().any(|b| b.is_ascii_digit()), + s.bytes() + .any(|b| matches!(b, b'+' | b'/' | b'_' | b'=' | b'-')), + ] + .into_iter() + .filter(|v| *v) + .count(); + classes >= 3 && shannon(s) >= 3.5 } fn shannon(s: &str) -> f64 { @@ -584,4 +641,35 @@ mod tests { assert!(!result.redacted); assert_eq!(result.text, code); } + + #[test] + fn paths_are_judged_by_segment_so_folders_stay_visible() { + for path in [ + "/Volumes/NVME1TB/shadowcode-macos/task-demo", + "/private/var/folders/qq/twswrb793xqgdr954yy4t1dc0000gn/T/.tmpAb3dEf/project", + "/Users/Robert/Projects/ShadowCode2026/native/core/src/redaction.rs", + "/home/dev/work/Project-2026_v2/src/main.rs", + "file:///private/var/folders/qq/twswrb793xqgdr954yy4t1dc0000gn/T/.tmpX1/team.git", + ] { + let result = redact_text(&format!("cwd: {path}")); + assert!(!result.redacted, "{path} -> {}", result.text); + } + // A secret that is a whole path segment is still hidden, the rest kept. + let secret = "Zx9Kq2Lm7Pw4Rt6Yv8Nb3Hc5Jd1Fg0Se7Ua2Qi"; + let result = redact_text(&format!("/Users/dev/{secret}/notes.txt")); + assert!( + result.redacted && !result.text.contains(secret), + "{}", + result.text + ); + assert!(result.text.starts_with("/Users/dev/"), "{}", result.text); + // Base64 that merely contains or starts with '/' is judged whole. + for token in [ + "/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODw0QFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj", + "dGhpcyBpcyBub3QgYSByZWFsIHNlY3JldC9idXQvaXQ+bG9va3M9bGlrZQ", + "/Users/abc+Zx9Kq2Lm7Pw4Rt6Yv8Nb3Hc5Jd1Fg0Se7Ua2Qi=", + ] { + assert!(redact_text(token).redacted, "{token}"); + } + } } diff --git a/native/core/src/remote/policy.rs b/native/core/src/remote/policy.rs index f628d948..b84286cc 100644 --- a/native/core/src/remote/policy.rs +++ b/native/core/src/remote/policy.rs @@ -131,7 +131,28 @@ pub fn inside_profile(candidate: &str, paths: &AppPaths) -> bool { if candidate.is_empty() { return false; } - let resolve = |p: &Path| p.canonicalize().unwrap_or_else(|_| p.to_path_buf()); + // Resolve the deepest existing ancestor and keep the rest as written: a + // folder that does not exist yet still resolves through a symlinked + // profile path (on macOS /var is /private/var). + let resolve = |p: &Path| { + let mut rest = Vec::new(); + let mut current = p; + loop { + if let Ok(real) = current.canonicalize() { + return rest + .iter() + .rev() + .fold(real, |acc: PathBuf, part| acc.join(part)); + } + match (current.file_name(), current.parent()) { + (Some(name), Some(parent)) => { + rest.push(name.to_owned()); + current = parent; + } + _ => return p.to_path_buf(), + } + } + }; let candidate = resolve(&expand(candidate)); [&paths.config, &paths.data, &paths.state] .into_iter() @@ -840,6 +861,34 @@ mod tests { } } + #[cfg(unix)] + #[test] + fn a_profile_reached_through_a_symlink_is_still_refused() { + // Like macOS, where the temporary folder is under /var -> /private/var. + let root = tempfile::tempdir().unwrap(); + let real = root.path().join("real"); + std::fs::create_dir(&real).unwrap(); + std::os::unix::fs::symlink(&real, root.path().join("alias")).unwrap(); + let paths = AppPaths::isolated(&root.path().join("alias/profile")).unwrap(); + for folder in [&paths.config, &paths.data, &paths.state] { + assert!(inside_profile(&folder.to_string_lossy(), &paths)); + // A folder inside it that does not exist yet. + assert!(inside_profile( + &folder.join("not-yet/x").to_string_lossy(), + &paths + )); + let real_form = real.join(folder.strip_prefix(root.path().join("alias")).unwrap()); + assert!(inside_profile( + &real_form.join("new").to_string_lossy(), + &paths + )); + } + assert!(!inside_profile( + &real.join("elsewhere").to_string_lossy(), + &paths + )); + } + #[test] fn secret_files_profile_folders_and_placeholders() { let (_dir, paths) = paths(); diff --git a/native/core/src/rulebook/export.rs b/native/core/src/rulebook/export.rs index 17109525..43d25fb0 100644 --- a/native/core/src/rulebook/export.rs +++ b/native/core/src/rulebook/export.rs @@ -92,9 +92,32 @@ fn planned(book: &Book, target: &str, vendor: &Path) -> Vec<(PathBuf, PathBuf)> links } +/// Two paths name the same place, also through a symlinked folder (on macOS +/// /var is /private/var); paths that do not resolve must match as written. +fn same_place(a: &Path, b: &Path) -> bool { + match (a.canonicalize(), b.canonicalize()) { + (Ok(a), Ok(b)) => a == b, + _ => a == b, + } +} + fn points_to(link: &Path, target: &Path) -> bool { std::fs::symlink_metadata(link).is_ok_and(|m| m.file_type().is_symlink()) - && std::fs::read_link(link).is_ok_and(|t| t == target) + && std::fs::read_link(link).is_ok_and(|t| { + let t = if t.is_relative() { + link.parent().map_or(t.clone(), |dir| dir.join(&t)) + } else { + t + }; + same_place(&t, target) + }) +} + +/// `link` is inside `home`, compared by where its folder really is. +fn inside(link: &Path, home: &Path) -> bool { + let real = |p: &Path| p.canonicalize().unwrap_or_else(|_| p.to_path_buf()); + link.parent() + .is_some_and(|dir| real(dir).starts_with(real(home))) } /// Profile files and skill folders `vendor` reads by itself through an @@ -117,7 +140,7 @@ pub(crate) fn linked( recorded .iter() .map(|l| (PathBuf::from(&l.link), PathBuf::from(&l.target))) - .filter(|(link, to)| link.starts_with(&home) && points_to(link, to)) + .filter(|(link, to)| inside(link, &home) && points_to(link, to)) .map(|(_, to)| to) .collect() } diff --git a/native/core/src/rulebook/mod.rs b/native/core/src/rulebook/mod.rs index bd0b62ab..77529629 100644 --- a/native/core/src/rulebook/mod.rs +++ b/native/core/src/rulebook/mod.rs @@ -365,10 +365,20 @@ impl Book { } /// Relative path inside the profile of an absolute path it reported. pub fn relative(&self, path: &str) -> Option { - Path::new(path) + let path = Path::new(path); + let inside = path .strip_prefix(&self.dir) - .ok() - .map(|p| p.to_string_lossy().into_owned()) + .map(Path::to_path_buf) + .or_else(|_| { + // Discovery may name a file by its real path while the profile + // folder is reached through a link (macOS: /var is /private/var). + let dir = self.dir.canonicalize()?; + let path = path.canonicalize()?; + path.strip_prefix(&dir) + .map(Path::to_path_buf) + .map_err(std::io::Error::other) + }); + inside.ok().map(|p| p.to_string_lossy().into_owned()) } pub fn path_of(&self, rel: &str) -> String { self.dir.join(rel).display().to_string() diff --git a/native/core/src/rulebook/tests.rs b/native/core/src/rulebook/tests.rs index 758a6e34..9a5bd1ce 100644 --- a/native/core/src/rulebook/tests.rs +++ b/native/core/src/rulebook/tests.rs @@ -12,9 +12,12 @@ struct Fixture { fn fixture() -> Fixture { let root = tempfile::tempdir().unwrap(); - let paths = AppPaths::isolated(&root.path().join("app")).unwrap(); + // Canonical, as product paths are (on macOS the temporary folder is under + // /var, a symlink to /private/var). + let base = root.path().canonicalize().unwrap(); + let paths = AppPaths::isolated(&base.join("app")).unwrap(); let profile = profile_dir(&paths); - let project = root.path().join("project"); + let project = base.join("project"); fs::create_dir_all(&project).unwrap(); Fixture { profile, diff --git a/native/core/src/sandbox.rs b/native/core/src/sandbox.rs index c77c781d..dbe38279 100644 --- a/native/core/src/sandbox.rs +++ b/native/core/src/sandbox.rs @@ -30,6 +30,8 @@ mod lsm; #[cfg(target_os = "linux")] mod netns; pub mod proxy; +#[cfg(target_os = "macos")] +mod seatbelt; /// Home entries that toolchains commonly need, mounted read-only. pub const DEFAULT_HOME_BINDS: &[&str] = &[ @@ -478,6 +480,10 @@ pub struct ChildSetup { /// Flatpak host commands: the proxy's Unix socket, reached through the /// relay inside the host bubblewrap network namespace. relay: Option>, + /// macOS: the proxy's loopback listener, the only address the + /// command's Seatbelt profile may connect to. + #[cfg(target_os = "macos")] + listener: Option>>>, } /// The proxy's listening Unix socket and the private folder holding it, /// removed when the command's setup is dropped. @@ -604,6 +610,15 @@ impl ChildSetup { let listener = netns.receive()?; return Ok(Some(ProxyGuard(proxy::spawn(listener, gate.clone())?))); } + #[cfg(target_os = "macos")] + if let (Some(listener), Some(gate)) = (&self.listener, &self.gate) { + let listener = listener + .lock() + .map_err(|_| anyhow::anyhow!("Proxy listener lock poisoned"))? + .take() + .context("The allow-list proxy listener was already used")?; + return Ok(Some(ProxyGuard(proxy::spawn(listener, gate.clone())?))); + } if let (Some(relay), Some(gate)) = (&self.relay, &self.gate) { let listener = relay .listener @@ -669,7 +684,14 @@ pub fn prepare_shell( policy: &ShellPolicy, ) -> Result { let layout = HomeLayout::current(&policy.home_binds, workspace); - match working_bwrap(workspace) { + #[cfg(target_os = "macos")] + let result = { + // The process runs in `cwd` already; the profile is per project. + let _ = cwd; + prepare_seatbelt(workspace, command, policy, &layout) + }; + #[cfg(not(target_os = "macos"))] + let result = match working_bwrap(workspace) { Ok(program) => { let net = match policy.network { ShellNetwork::Off => Net::Off, @@ -705,6 +727,8 @@ pub fn prepare_shell( landlock: None, gate: Some(shared.clone()), relay: Some(socket.clone()), + #[cfg(target_os = "macos")] + listener: None, }); gate = Some(shared); relay = Some((program, socket)); @@ -849,7 +873,139 @@ pub fn prepare_shell( warning: Some(warning), }) } + }; + result +} + +/// macOS: the command under Seatbelt, or (when `sandbox-exec` cannot run) +/// unsandboxed with a warning unless the sandbox is required or the +/// allow-list is on. See [`seatbelt`]. +#[cfg(target_os = "macos")] +fn prepare_seatbelt( + workspace: &Path, + command: &str, + policy: &ShellPolicy, + layout: &HomeLayout, +) -> Result { + if let Err(error) = seatbelt::probe() { + if policy.require { + bail!("The command did not run: 'Require sandbox' is on and the macOS sandbox is unavailable ({error:#}). Turn off 'Require sandbox' in Settings > Permissions & network to run commands without it."); + } + if policy.network == ShellNetwork::Allowlist { + bail!("The command did not run: the network allow-list needs the macOS sandbox, which is unavailable ({error:#}). Change 'Shell commands and the network' in Settings > Permissions & network."); + } + return Ok(PreparedShell { + program: "/bin/sh".into(), + args: vec!["-c".into(), command.into()], + child: None, + scratch: None, + gate: None, + note: json!({ + "mode": "none", + "reason": format!("{error:#}"), + "network": network_label(policy.network), + "network_requested": network_label(policy.network), + "network_enforcement": "none", + "note": "No macOS sandbox: the command runs as your user without isolation; only the approval policy applies." + }), + warning: Some(format!("Shell commands are running without a sandbox ({error:#}): they can read and change anything your user can. Turn on 'Require sandbox' in Settings > Permissions & network to refuse commands instead.")), + }); + } + let net = match policy.network { + ShellNetwork::Off => Net::Off, + ShellNetwork::On => Net::On, + ShellNetwork::Allowlist => Net::Proxy, + }; + let mut child = None; + let mut gate = None; + let mut proxy_port = None; + if net == Net::Proxy { + let listener = std::net::TcpListener::bind(("127.0.0.1", 0)) + .context("Could not start the network allow-list proxy")?; + proxy_port = Some(listener.local_addr()?.port()); + let shared = proxy::Gate::new(policy.allow.clone()); + child = Some(ChildSetup { + gate: Some(shared.clone()), + relay: None, + listener: Some(Arc::new(Mutex::new(Some(listener)))), + }); + gate = Some(shared); + } + let scratch = create_scratch(&scratch_base())?; + let home = seatbelt::make_home(&scratch.path, layout)?; + let developer = seatbelt::developer_dir(); + let mut read = seatbelt::path_roots(layout.home.as_deref()); + if let Some(dir) = &developer { + read.push(real(dir)); + } + let workspace = real(workspace); + let profile = seatbelt::Profile { + workspace: &workspace, + scratch: &scratch.path, + layout, + net, + proxy_port, + read, + user_temp: seatbelt::user_temp(), + }; + let s = |p: &Path| p.to_string_lossy().into_owned(); + let mut args = vec![ + "-p".to_owned(), + profile.text(), + "/usr/bin/env".into(), + "-u".into(), + "XDG_CONFIG_HOME".into(), + "-u".into(), + "XDG_DATA_HOME".into(), + "-u".into(), + "XDG_STATE_HOME".into(), + format!("HOME={}", s(&home)), + format!("TMPDIR={}/", s(&scratch.path.join("tmp"))), + format!("SHADOWCODE_SCRATCH={}", s(&scratch.path)), + ]; + if let Some(dir) = &developer { + args.push(format!("DEVELOPER_DIR={}", s(dir))); + } + if let Some(port) = proxy_port { + let url = format!("http://127.0.0.1:{port}"); + for name in [ + "HTTP_PROXY", + "HTTPS_PROXY", + "http_proxy", + "https_proxy", + "ALL_PROXY", + "all_proxy", + ] { + args.push(format!("{name}={url}")); + } } + args.extend(["/bin/sh".into(), "-c".into(), command.into()]); + let note = json!({ + "mode": "seatbelt", + "network": network_label(policy.network), + "network_requested": network_label(policy.network), + "network_enforcement": match net { + Net::Off => "no_network", + Net::On => "none", + Net::Proxy => "host_allowlist_proxy", + }, + "allow": if net == Net::Proxy { json!(policy.allow_text) } else { Value::Null }, + "home": "empty temporary folder", + "home_read_only": layout.binds.iter().map(|(_, d)| d.to_string_lossy()).collect::>(), + "home_skipped": layout.skipped, + "scratch": scratch.path.to_string_lossy(), + "workspace_cow": false, + "note": "macOS sandbox: read-only system and developer folders, empty home with read-only toolchains, writable project, ephemeral scratch at SHADOWCODE_SCRATCH (also TMPDIR). Not a complete OS sandbox." + }); + Ok(PreparedShell { + program: seatbelt::PROGRAM.into(), + args, + child, + scratch: Some(scratch.path), + gate, + note, + warning: None, + }) } #[cfg(target_os = "linux")] @@ -872,6 +1028,37 @@ fn landlock_paths(layout: &HomeLayout, network: ShellNetwork) -> (Vec, } /// What shell isolation this computer and configuration provide. +#[cfg(target_os = "macos")] +pub fn status(config: &Config) -> Value { + let works = seatbelt::probe(); + let network = config.shell_network(); + let effective = match (&works, config.sandbox.require, network) { + (Ok(()), _, _) => "seatbelt", + (Err(_), true, _) | (Err(_), _, ShellNetwork::Allowlist) => "blocked", + _ => "none", + }; + let layout = HomeLayout::current(&config.sandbox.home_binds, Path::new("/nonexistent")); + json!({ + "platform": "macos", + "effective": effective, + "seatbelt": { + "works": works.is_ok(), + "detail": match &works { Ok(()) => "The macOS sandbox works".to_owned(), Err(e) => format!("{e:#}") }, + }, + "bubblewrap": {"installed": false, "works": false, "detail": "bubblewrap is Linux-only"}, + "landlock_abi": 0, + "network_namespace": {"available": false, "detail": "macOS commands share the computer's network; the allow-list proxy's port is the only address they may reach"}, + "require": config.sandbox.require, + "shell_network": network_label(network), + "allow": config.network.allow, + "home_read_only": layout.binds.iter().map(|(_, d)| d.to_string_lossy()).collect::>(), + "home_skipped": layout.skipped, + "never_mounted": DENIED_HOME, + }) +} + +/// What shell isolation this computer and configuration provide. +#[cfg(not(target_os = "macos"))] pub fn status(config: &Config) -> Value { let installed = which("bwrap").is_some(); let bwrap = (|| -> Result<()> { @@ -892,6 +1079,7 @@ pub fn status(config: &Config) -> Value { }; let layout = HomeLayout::current(&config.sandbox.home_binds, Path::new("/nonexistent")); json!({ + "platform": "linux", "effective": effective, "bubblewrap": { "installed": installed, @@ -912,6 +1100,23 @@ pub fn status(config: &Config) -> Value { }) } +#[cfg(target_os = "macos")] +pub fn doctor_checks() -> Vec { + let works = seatbelt::probe(); + vec![ + json!({"id":"seatbelt","status":if works.is_ok() {"pass"} else {"warn"}, + "title":"Shell sandbox (macOS)","detail":match &works { + Ok(()) => "The macOS sandbox works: shell commands see an empty home folder with read-only toolchains, read only system, developer and project folders, and write only to the project.".to_owned(), + Err(e) => format!("{e:#}. Shell commands run unsandboxed with a warning."), + }, + "fix":"Turn on 'Require sandbox' to refuse commands when the sandbox cannot run. A command is never replayed after a sandbox failure."}), + json!({"id":"workspace-cow","status":"info","title":"Live workspace writes", + "detail":"Shell writes affect the current workspace. Scratch storage is temporary, not copy-on-write. A checkpoint taken before each command lets rewind restore project files.", + "fix":"Use an isolated Git worktree for changes you want to review before integrating."}), + ] +} + +#[cfg(not(target_os = "macos"))] pub fn doctor_checks() -> Vec { let report = probe_workspace_cow(); #[cfg(target_os = "linux")] @@ -1114,6 +1319,8 @@ mod tests { .port() } + // The relay is built from packaging/flatpak/proxy-relay.c (Linux). + #[cfg(target_os = "linux")] #[tokio::test] async fn flatpak_relay_forwards_only_through_the_allow_list_proxy() { use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -1124,6 +1331,9 @@ mod tests { }; let socket = Arc::new(RelaySocket::bind(temp.path()).unwrap()); let gate = proxy::Gate::new(proxy::parse_list(&["allowed.invalid".into()]).unwrap()); + // The Linux-only fields (network namespace, Landlock) take defaults; + // elsewhere these two are all of them. + #[allow(clippy::needless_update)] let setup = ChildSetup { gate: Some(gate.clone()), relay: Some(socket.clone()), @@ -1272,6 +1482,8 @@ mod tests { assert_eq!(report["kernel_proof"], false); } + // macOS always uses its own sandbox (see the seatbelt tests below). + #[cfg(not(target_os = "macos"))] #[test] fn missing_bubblewrap_reports_honest_fallback_and_fails_closed_when_required() { // PATH is process-global and tests share one process: run the mutation @@ -1368,4 +1580,183 @@ mod tests { assert!(!first_warning(&session)); assert!(first_warning(&format!("{session}-other"))); } + + #[cfg(target_os = "macos")] + fn seatbelt_policy(network: ShellNetwork, allow: &[&str]) -> ShellPolicy { + let allow_text: Vec = allow.iter().map(|s| (*s).to_owned()).collect(); + ShellPolicy { + network, + allow: proxy::parse_list(&allow_text).unwrap(), + allow_text, + home_binds: DEFAULT_HOME_BINDS.iter().map(|s| (*s).to_owned()).collect(), + require: true, + landlock: false, + } + } + + /// Run a prepared command to completion: (exit code, stdout + stderr). + #[cfg(target_os = "macos")] + fn run_prepared(prepared: &PreparedShell, cwd: &Path) -> (i32, String) { + let output = Command::new(&prepared.program) + .args(&prepared.args) + .current_dir(cwd) + .env_clear() + .env("PATH", "/usr/bin:/bin:/usr/sbin:/sbin") + .stdin(Stdio::null()) + .output() + .unwrap(); + let mut text = String::from_utf8_lossy(&output.stdout).into_owned(); + text.push_str(&String::from_utf8_lossy(&output.stderr)); + (output.status.code().unwrap_or(-1), text) + } + + #[cfg(target_os = "macos")] + #[test] + fn seatbelt_confines_files_to_the_project_and_hides_home_and_credentials() { + let (root, home, ws) = fake_home(); + let root = root.path().canonicalize().unwrap(); + fs::write(root.join("outside.txt"), "outside").unwrap(); + // A project that contains a credential folder of its own home. + let layout = HomeLayout::build( + Some(home.clone()), + &DEFAULT_HOME_BINDS + .iter() + .map(|s| (*s).to_owned()) + .collect::>(), + &ws, + ); + let policy = seatbelt_policy(ShellNetwork::Off, &[]); + let run = |command: &str| { + let prepared = prepare_seatbelt(&ws, command, &policy, &layout).unwrap(); + assert_eq!(prepared.note["mode"], "seatbelt"); + let result = run_prepared(&prepared, &ws); + if let Some(scratch) = &prepared.scratch { + discard_scratch(scratch).unwrap(); + } + result + }; + let (code, text) = run("echo hi > made.txt && cat made.txt"); + assert_eq!((code, text.trim()), (0, "hi")); + assert!(ws.join("made.txt").is_file()); + let (code, _) = run(&format!("cat {}", home.join(".ssh/id_ed25519").display())); + assert_ne!(code, 0, "the real home folder is hidden"); + let (code, _) = run(&format!("cat {}", root.join("outside.txt").display())); + assert_ne!(code, 0, "folders outside the project are hidden"); + let (code, _) = run(&format!("echo x > {}", root.join("written.txt").display())); + assert_ne!(code, 0); + assert!( + !root.join("written.txt").exists(), + "no writes outside the project" + ); + // HOME is empty but for links to read-only toolchains. + let (code, text) = + run("test -L \"$HOME/.cargo\" && ls \"$HOME/.cargo\" && ls -a \"$HOME\""); + assert_eq!(code, 0, "{text}"); + assert!(text.contains("bin") && !text.contains(".ssh"), "{text}"); + let (code, _) = run("touch \"$HOME/.cargo/new\""); + assert_ne!(code, 0); + assert!( + !home.join(".cargo/new").exists(), + "toolchains stay read-only" + ); + let (code, _) = run("cat \"$HOME/.cargo/credentials.toml\""); + assert_ne!(code, 0, "credential files inside toolchains stay hidden"); + let (code, text) = + run("echo t > \"$TMPDIR/x\" && cat \"$TMPDIR/x\" && echo $SHADOWCODE_SCRATCH"); + assert_eq!(code, 0, "{text}"); + } + + #[cfg(target_os = "macos")] + #[test] + fn seatbelt_hides_credential_folders_inside_the_project() { + let root = tempfile::tempdir().unwrap(); + let home = root.path().canonicalize().unwrap().join("me"); + fs::create_dir_all(home.join(".ssh")).unwrap(); + fs::write(home.join(".ssh/id_ed25519"), "not a real key").unwrap(); + // The project is the home folder itself. + let layout = HomeLayout::build(Some(home.clone()), &[], &home); + let policy = seatbelt_policy(ShellNetwork::Off, &[]); + for command in [ + "cat .ssh/id_ed25519", + "ls .ssh", + "mv .ssh moved", + "echo x > .ssh/new", + ] { + let prepared = prepare_seatbelt(&home, command, &policy, &layout).unwrap(); + let (code, text) = run_prepared(&prepared, &home); + assert_ne!(code, 0, "{command}: {text}"); + } + assert!(home.join(".ssh/id_ed25519").is_file()); + assert!(!home.join(".ssh/new").exists() && !home.join("moved").exists()); + } + + #[cfg(target_os = "macos")] + #[tokio::test] + async fn seatbelt_network_modes_are_enforced() { + let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + let ws = tempfile::tempdir().unwrap(); + let ws = ws.path().canonicalize().unwrap(); + let layout = HomeLayout::build(None, &[], &ws); + let probe = format!("/usr/bin/nc -z -w 2 127.0.0.1 {port}"); + let run = |network: ShellNetwork, command: &str| { + let policy = seatbelt_policy(network, &["allowed.invalid"]); + let prepared = prepare_seatbelt(&ws, command, &policy, &layout).unwrap(); + (prepared, ws.clone()) + }; + // Off: not even this computer's own services. + let (prepared, cwd) = run(ShellNetwork::Off, &probe); + assert_ne!(run_prepared(&prepared, &cwd).0, 0); + // On: anything. + let (prepared, cwd) = run(ShellNetwork::On, &probe); + assert_eq!(run_prepared(&prepared, &cwd).0, 0); + // Allow-list: only the proxy, which refuses hosts not on the list. + let (prepared, cwd) = run(ShellNetwork::Allowlist, &probe); + assert_ne!( + run_prepared(&prepared, &cwd).0, + 0, + "other local ports are closed" + ); + let (prepared, cwd) = run( + ShellNetwork::Allowlist, + "/usr/bin/curl -sS -m 5 -o /dev/null -w '%{http_code}' -p https://example.com/; echo", + ); + let _guard = prepared + .child + .as_ref() + .unwrap() + .after_spawn() + .unwrap() + .unwrap(); + let output = tokio::task::spawn_blocking(move || run_prepared(&prepared, &cwd)) + .await + .unwrap(); + assert!(output.1.contains("403"), "{output:?}"); + drop(listener); + } + + #[cfg(target_os = "macos")] + #[test] + fn apple_developer_tools_run_cleanly_in_the_seatbelt() { + let ws = tempfile::tempdir().unwrap(); + let ws = ws.path().canonicalize().unwrap(); + let layout = HomeLayout::current( + &DEFAULT_HOME_BINDS + .iter() + .map(|s| (*s).to_owned()) + .collect::>(), + &ws, + ); + let policy = seatbelt_policy(ShellNetwork::Off, &[]); + let command = "git init -q . && git status --short && cc --version && make --version && python3 -c 'print(6*7)'"; + let prepared = prepare_seatbelt(&ws, command, &policy, &layout).unwrap(); + let (code, text) = run_prepared(&prepared, &ws); + assert_eq!(code, 0, "{text}"); + assert!(text.contains("42"), "{text}"); + // Apple's shims must not fall back to xcodebuild inside the sandbox. + assert!( + !text.contains("xcodebuild") && !text.contains("xcrun"), + "{text}" + ); + } } diff --git a/native/core/src/sandbox/seatbelt.rs b/native/core/src/sandbox/seatbelt.rs new file mode 100644 index 00000000..9514261c --- /dev/null +++ b/native/core/src/sandbox/seatbelt.rs @@ -0,0 +1,314 @@ +//! macOS shell isolation through Seatbelt (`sandbox-exec`), the kernel +//! sandbox behind the App Sandbox. It mirrors the bubblewrap profile: +//! +//! * Reading is allowed only in system and developer-tool folders, the +//! folders holding the programs on `PATH` (outside the home folder), the +//! project, the configured toolchain folders and the command's scratch. +//! The rest of the home folder, other users and other volumes stay hidden: +//! even their existence (`stat`) is refused. Only the folders leading to +//! an allowed one can be looked up, so paths still resolve. +//! * `HOME` is an empty folder in the command's scratch whose toolchain +//! entries (`sandbox.home_binds`) link to the real, read-only folders, and +//! `TMPDIR` is the scratch too. Credential folders are never linked. +//! * Writing is allowed in the project and the scratch only (plus /dev/null +//! and the terminal, and xcrun's own cache files). +//! * Network off allows no IP networking at all, not even localhost (macOS +//! cannot give a command its own loopback); on allows it; the allow-list +//! allows only the allow-list proxy's loopback port. +//! * Credential folders inside the project are unreadable and unwritable. +//! +//! Signals reach only processes in the same sandbox; system services are +//! reachable only for user lookup, logging and (with network) DNS and +//! certificate checks. +use super::{HomeLayout, Net}; +use anyhow::{ensure, Context, Result}; +use std::{ + path::{Path, PathBuf}, + process::{Command, Stdio}, + sync::LazyLock, + time::{Duration, Instant}, +}; + +pub const PROGRAM: &str = "/usr/bin/sandbox-exec"; + +/// Folders every command may read: the system, developer tools and +/// package managers' prefixes. +const READ_ROOTS: &[&str] = &[ + "/System", + "/usr", + "/bin", + "/sbin", + "/Library", + "/private/etc", + "/private/var/db/timezone", + "/private/var/select", + "/opt", + "/nix", + "/dev", +]; + +const BASE: &str = r#"(version 1) +(deny default) +(allow process-exec process-fork) +(allow signal (target same-sandbox)) +(allow process-info* (target same-sandbox)) +(allow sysctl-read) +(allow pseudo-tty) +(allow ipc-posix-sem ipc-posix-shm-read* ipc-posix-shm-write-data ipc-posix-shm-write-create) +(allow file-read* (literal "/") (regex #"^/Applications/Xcode[^/]*\.app(/|$)")) +(allow file-read-metadata (literal "/etc") (literal "/var") (literal "/tmp")) +(allow file-write-data file-read* (literal "/dev/null") (literal "/dev/zero") + (literal "/dev/random") (literal "/dev/urandom") (literal "/dev/dtracehelper") + (regex #"^/dev/tty") (regex #"^/dev/fd/")) +(allow file-ioctl (literal "/dev/dtracehelper") (regex #"^/dev/tty")) +(allow mach-lookup + (global-name "com.apple.system.opendirectoryd.libinfo") + (global-name "com.apple.system.opendirectoryd.membership") + (global-name "com.apple.system.notification_center") + (global-name "com.apple.system.logger") + (global-name "com.apple.logd") + (global-name "com.apple.FSEvents")) +"#; + +/// Network rules for a command. +fn network(net: Net, proxy_port: Option) -> String { + match net { + Net::Off => String::new(), + Net::On => r#"(allow network*) +(allow mach-lookup + (global-name "com.apple.dnssd.service") + (global-name "com.apple.trustd") + (global-name "com.apple.trustd.agent") + (global-name "com.apple.SystemConfiguration.configd")) +"# + .into(), + // Only the allow-list proxy; it resolves names itself. + Net::Proxy => match proxy_port { + Some(port) => format!("(allow network-outbound (remote ip \"localhost:{port}\"))\n"), + None => String::new(), + }, + } +} + +/// A quoted SBPL string. Paths come from this computer, but quote them +/// properly anyway: a `"` or `\` must not end the string early. +fn quoted(path: &Path) -> String { + let text = path.to_string_lossy(); + let mut out = String::with_capacity(text.len() + 2); + out.push('"'); + for c in text.chars() { + if c == '"' || c == '\\' { + out.push('\\'); + } + out.push(c); + } + out.push('"'); + out +} + +/// Everything one command's profile needs. +pub struct Profile<'a> { + pub workspace: &'a Path, + pub scratch: &'a Path, + pub layout: &'a HomeLayout, + pub net: Net, + pub proxy_port: Option, + /// Extra read-only folders (programs on `PATH`, the developer folder). + pub read: Vec, + /// The per-user temporary folder, where xcrun keeps its cache. + pub user_temp: Option, +} + +impl Profile<'_> { + pub fn text(&self) -> String { + let mut profile = String::from(BASE); + let read: Vec = READ_ROOTS + .iter() + .map(PathBuf::from) + .chain(self.read.iter().cloned()) + .chain(self.layout.binds.iter().map(|(source, _)| source.clone())) + .collect(); + profile.push_str("(allow file-read*"); + for path in &read { + profile.push_str(&format!(" (subpath {})", quoted(path))); + } + profile.push_str(")\n"); + // The folders leading to each allowed one may be looked up (stat), + // nothing else in them. + let mut ancestors: Vec = Vec::new(); + let reachable = read + .iter() + .map(PathBuf::as_path) + .chain([self.workspace, self.scratch]) + .chain(self.user_temp.as_deref()); + for path in reachable { + for ancestor in path.ancestors().skip(1) { + if !ancestors.iter().any(|known| known == ancestor) { + ancestors.push(ancestor.to_path_buf()); + } + } + } + if !ancestors.is_empty() { + profile.push_str("(allow file-read-metadata"); + for path in &ancestors { + profile.push_str(&format!(" (literal {})", quoted(path))); + } + profile.push_str(")\n"); + } + profile.push_str(&format!( + "(allow file-read* file-write* (subpath {}) (subpath {}))\n", + quoted(self.workspace), + quoted(self.scratch) + )); + if let Some(temp) = &self.user_temp { + profile.push_str(&format!( + "(allow file-read* file-write* (prefix {}))\n", + quoted(&temp.join("xcrun_db")) + )); + } + profile.push_str(&network(self.net, self.proxy_port)); + // Last, so they win over the allowances above. + let hidden: Vec = self + .layout + .masked_dirs + .iter() + .map(|dir| format!(" (subpath {})", quoted(dir))) + .chain( + self.layout + .masked_files + .iter() + .map(|file| format!(" (literal {})", quoted(file))), + ) + .collect(); + if !hidden.is_empty() { + profile.push_str(&format!( + "(deny file-read* file-write*{})\n", + hidden.concat() + )); + } + profile + } +} + +/// The empty home folder: toolchain entries link to the real folders. +pub fn make_home(scratch: &Path, layout: &HomeLayout) -> Result { + let home = scratch.join("home"); + std::fs::create_dir(&home)?; + std::fs::create_dir(scratch.join("tmp"))?; + let Some(real_home) = &layout.home else { + return Ok(home); + }; + for (source, dest) in &layout.binds { + let Ok(relative) = dest.strip_prefix(real_home) else { + continue; + }; + let link = home.join(relative); + if let Some(parent) = link.parent() { + std::fs::create_dir_all(parent)?; + } + std::os::unix::fs::symlink(source, &link) + .with_context(|| format!("Could not link {} into the sandbox", relative.display()))?; + } + Ok(home) +} + +/// Folders holding the programs on `PATH`, outside the home folder: a +/// `bin` folder's prefix (for example Homebrew's), or the folder itself. +pub fn path_roots(home: Option<&Path>) -> Vec { + let home = home.map(super::real); + let path = std::env::var_os("PATH").unwrap_or_default(); + let mut roots: Vec = Vec::new(); + for dir in std::env::split_paths(&path) { + let Ok(dir) = dir.canonicalize() else { + continue; + }; + if home + .as_ref() + .is_some_and(|home| dir.starts_with(home) || home.starts_with(&dir)) + || READ_ROOTS.iter().any(|root| dir.starts_with(root)) + { + continue; + } + let prefix = match (dir.file_name(), dir.parent()) { + (Some(name), Some(parent)) + if (name == "bin" || name == "sbin") && parent.components().count() >= 4 => + { + parent.to_path_buf() + } + _ => dir, + }; + // Never a volume or a top-level folder as a whole. + if prefix.components().count() >= 3 && !roots.contains(&prefix) { + roots.push(prefix); + } + } + roots +} + +/// The active developer folder (`xcode-select -p`), asked once. Passing it +/// as `DEVELOPER_DIR` keeps Apple's `git`/`clang` shims from running +/// `xcodebuild` (slow, and noisy inside the sandbox) on every call. +pub fn developer_dir() -> Option { + static DIR: LazyLock> = LazyLock::new(|| { + if let Some(dir) = std::env::var_os("DEVELOPER_DIR") { + return Some(PathBuf::from(dir)); + } + let output = Command::new("/usr/bin/xcode-select") + .arg("-p") + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .output() + .ok()?; + let dir = PathBuf::from(String::from_utf8(output.stdout).ok()?.trim()); + (output.status.success() && dir.is_dir()).then_some(dir) + }); + DIR.clone() +} + +/// The per-user temporary folder (`DARWIN_USER_TEMP_DIR`), real path. +pub fn user_temp() -> Option { + let mut buffer = vec![0u8; libc::PATH_MAX as usize]; + // SAFETY: confstr writes at most `len` bytes, NUL-terminated. + let len = unsafe { + libc::confstr( + libc::_CS_DARWIN_USER_TEMP_DIR, + buffer.as_mut_ptr().cast(), + buffer.len(), + ) + }; + if len == 0 || len > buffer.len() { + return None; + } + buffer.truncate(len - 1); + PathBuf::from(String::from_utf8(buffer).ok()?) + .canonicalize() + .ok() +} + +/// `sandbox-exec` exists and can start a trivial sandboxed program. +pub fn probe() -> Result<()> { + ensure!( + Path::new(PROGRAM).is_file(), + "sandbox-exec is missing from this Mac" + ); + let mut child = Command::new(PROGRAM) + .args(["-p", "(version 1)(allow default)", "/usr/bin/true"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .context("Could not start sandbox-exec")?; + let deadline = Instant::now() + Duration::from_secs(3); + loop { + if let Some(status) = child.try_wait()? { + ensure!(status.success(), "sandbox-exec could not start ({status})"); + return Ok(()); + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + anyhow::bail!("sandbox-exec availability probe timed out"); + } + std::thread::sleep(Duration::from_millis(10)); + } +} diff --git a/native/core/src/service.rs b/native/core/src/service.rs index 3a87ef65..1c7e1158 100644 --- a/native/core/src/service.rs +++ b/native/core/src/service.rs @@ -73,7 +73,7 @@ mod local_downloads; mod logs; mod memory; mod model_catalog; -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "macos"))] mod preview; #[cfg(unix)] mod remote; @@ -122,7 +122,7 @@ pub struct Service { /// This view's interactive terminals (a forked view starts with none). terminals: Arc, /// Loopback proxies for the in-app preview, shared by every view. - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "macos"))] previews: Arc, /// Remote access and phone notifications (one per engine). #[cfg(unix)] @@ -178,7 +178,7 @@ impl Service { remember_selection: true, job_owner: None, terminals, - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "macos"))] previews: Arc::default(), #[cfg(unix)] remote, @@ -216,7 +216,7 @@ impl Service { job_owner: None, // An attached window's terminals live as long as its view. terminals: Arc::new(crate::terminal::Terminals::new(self.engine.notifier())), - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "macos"))] previews: self.previews.clone(), #[cfg(unix)] remote: self.remote.clone(), @@ -395,7 +395,7 @@ impl Service { "remote" => self.remote_routes(&call).await, "git" => self.forge_routes(&call).await, "background" => self.background_routes(&call).await, - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "macos"))] "preview" => self.preview_routes(&call).await, "code-intel" => self.code_intel_routes(&call).await, "voice" => self.voice_routes(&call).await, diff --git a/native/core/src/service/preview.rs b/native/core/src/service/preview.rs index 2dfb7fb8..092e394b 100644 --- a/native/core/src/service/preview.rs +++ b/native/core/src/service/preview.rs @@ -19,10 +19,9 @@ impl Service { ("POST", "/api/preview/open") => { let body: OpenBody = call.body()?; let (target, path) = Target::parse(body.url.as_str())?; - let mut forbidden = tokio::task::spawn_blocking(|| { - ports::own_ports(Path::new("/proc"), std::process::id()) - }) - .await?; + let mut forbidden = + tokio::task::spawn_blocking(|| ports::own_ports_now(std::process::id())) + .await?; forbidden.extend(self.previews.ports().await); let opened = self .previews @@ -42,12 +41,11 @@ impl Service { /// merged by port. ShadowCode's own ports are never listed. fn preview_servers(&self, _call: &Call) -> Result { let workspace = self.workspace()?; - let proc_root = Path::new("/proc"); let own = std::process::id(); - let own_ports: HashSet = ports::own_ports(proc_root, own); - let listening: HashSet = ports::listening(proc_root).iter().map(|l| l.port).collect(); + let own_ports: HashSet = ports::own_ports_now(own); + let listening: HashSet = ports::listening_now(); let mut servers: BTreeMap = BTreeMap::new(); - for found in ports::project_servers(proc_root, &workspace, own) { + for found in ports::servers(&workspace, own) { if own_ports.contains(&found.port) { continue; } diff --git a/native/core/src/sqlite.rs b/native/core/src/sqlite.rs index 6e22505c..87cb61c8 100644 --- a/native/core/src/sqlite.rs +++ b/native/core/src/sqlite.rs @@ -223,7 +223,10 @@ fn query_inner( } } vfs::register()?; - let path = format!("/proc/self/fd/{}/{}", parent.as_raw_fd(), name); + // Declared before the connection so the directory stays held until the + // connection has closed. + let _held = vfs::hold(parent.as_raw_fd()); + let path = vfs::filename(parent.as_raw_fd(), &name); let mut uri = reqwest::Url::from_file_path(&path) .map_err(|_| anyhow::anyhow!("Invalid database path"))?; uri.query_pairs_mut().append_pair("mode", "ro"); diff --git a/native/core/src/sqlite/vfs.rs b/native/core/src/sqlite/vfs.rs index d6c5ead5..c1331790 100644 --- a/native/core/src/sqlite/vfs.rs +++ b/native/core/src/sqlite/vfs.rs @@ -2,29 +2,90 @@ //! VFS otherwise canonicalizes /proc/self/fd back to an ambient path, losing //! the held parent when a project directory is renamed. All locking, WAL and //! I/O methods remain SQLite's own; this is not a second locking implementation. +//! +//! macOS has no /proc: a name under a reserved prefix stands for the held +//! directory, and SQLite's own `open` and `stat` calls for such a name are +//! answered relative to that directory (`openat`), see [`calls`]. use anyhow::{ensure, Context, Result}; use rusqlite::ffi; use std::{ ffi::{c_char, c_int, CStr}, + os::fd::RawFd, sync::OnceLock, }; pub(super) const NAME: &str = "shadowcode-confined-readonly"; -fn valid(name: &CStr) -> bool { - let Some(rest) = name.to_bytes().strip_prefix(b"/proc/self/fd/") else { - return false; - }; - let Some(split) = rest.iter().position(|b| *b == b'/') else { - return false; - }; +#[cfg(target_os = "linux")] +const PREFIX: &str = "/proc/self/fd/"; +#[cfg(not(target_os = "linux"))] +const PREFIX: &str = "/.shadowcode-confined-fd/"; + +/// SQLite's filename for `leaf` inside the held directory `dir`. +pub(super) fn filename(dir: RawFd, leaf: &str) -> String { + format!("{PREFIX}{dir}/{leaf}") +} + +/// The held directory and the single leaf a confined name stands for. +fn split(name: &CStr) -> Option<(RawFd, &CStr)> { + let bytes = name.to_bytes_with_nul(); + let rest = bytes.strip_prefix(PREFIX.as_bytes())?; + let split = rest.iter().position(|b| *b == b'/')?; let (fd, leaf) = rest.split_at(split); - !fd.is_empty() + let leaf = &leaf[1..]; + let valid = !fd.is_empty() + && fd.len() <= 9 && fd.iter().all(u8::is_ascii_digit) && leaf.len() > 1 - && !leaf[1..].contains(&b'/') - && leaf != b"/." - && leaf != b"/.." + && !leaf.contains(&b'/') + && leaf != b".\0" + && leaf != b"..\0"; + if !valid { + return None; + } + let fd = std::str::from_utf8(fd).ok()?.parse().ok()?; + Some((fd, CStr::from_bytes_with_nul(leaf).ok()?)) +} + +fn valid(name: &CStr) -> bool { + split(name).is_some() +} + +/// Whether a confined name is a regular file, never following a link. +fn regular_file(name: &CStr) -> std::io::Result { + let (fd, leaf) = split(name).ok_or(std::io::ErrorKind::InvalidInput)?; + let mut stat = std::mem::MaybeUninit::::uninit(); + // `fd` is the caller's held directory and `leaf` one NUL-terminated name. + let code = unsafe { + libc::fstatat( + fd, + leaf.as_ptr(), + stat.as_mut_ptr(), + libc::AT_SYMLINK_NOFOLLOW, + ) + }; + if code != 0 { + return Err(std::io::Error::last_os_error()); + } + let stat = unsafe { stat.assume_init() }; + Ok(stat.st_mode & libc::S_IFMT == libc::S_IFREG) +} + +/// Keeps a held directory reachable through [`filename`] while a connection +/// is open. Linux names it through /proc and needs no registration. +pub(super) struct Held(#[allow(dead_code)] RawFd); + +pub(super) fn hold(dir: RawFd) -> Held { + #[cfg(target_os = "macos")] + calls::hold(dir); + Held(dir) +} + +#[cfg(target_os = "macos")] +impl Drop for Held { + fn drop(&mut self) { + calls::release(self.0); + } } unsafe extern "C" fn full_path( @@ -68,10 +129,8 @@ unsafe extern "C" fn open( if !valid(name_str) { return ffi::SQLITE_CANTOPEN; } - use std::os::unix::ffi::OsStrExt; - let path = std::path::Path::new(std::ffi::OsStr::from_bytes(name_str.to_bytes())); - match std::fs::symlink_metadata(path) { - Ok(meta) if meta.is_file() => (), + match regular_file(name_str) { + Ok(true) => (), Err(error) if error.kind() == std::io::ErrorKind::NotFound && flags & ffi::SQLITE_OPEN_WAL != 0 @@ -118,10 +177,8 @@ unsafe extern "C" fn access( if !valid(name) { return ffi::SQLITE_CANTOPEN; } - use std::os::unix::ffi::OsStrExt; - let path = std::path::Path::new(std::ffi::OsStr::from_bytes(name.to_bytes())); - match std::fs::symlink_metadata(path) { - Ok(meta) if meta.is_file() => { + match regular_file(name) { + Ok(true) => { unsafe { *result = i32::from(flags != ffi::SQLITE_ACCESS_READWRITE); } @@ -137,14 +194,16 @@ pub(super) fn register() -> Result<()> { let result = REGISTERED.get_or_init(|| { let register = || -> Result<()> { ensure!( - cfg!(target_os = "linux"), - "Native SQLite inspection requires Linux /proc" + cfg!(any(target_os = "linux", target_os = "macos")), + "Native SQLite inspection requires Linux or macOS" ); // SQLite's immutable callback table and pAppData have process lifetime. // This private copy is published once, never made default or mutated // after registration, and retained until process exit as required. let base = unsafe { ffi::sqlite3_vfs_find(c"unix".as_ptr()) }; ensure!(!base.is_null(), "SQLite Unix VFS unavailable"); + #[cfg(target_os = "macos")] + calls::install(base)?; let mut vfs = Box::new(unsafe { std::ptr::read(base) }); vfs.zName = c"shadowcode-confined-readonly".as_ptr(); vfs.pNext = std::ptr::null_mut(); @@ -171,3 +230,130 @@ pub(super) fn register() -> Result<()> { .map_err(|e| anyhow::anyhow!(e.clone())) .context("Native SQLite initialization failed") } + +/// macOS: SQLite's Unix VFS opens and stats files by name through its +/// replaceable system-call table. Names under [`PREFIX`] for a registered +/// directory are answered with `openat`/`fstatat` on that directory, never +/// following a final link; every other name goes to SQLite's original call +/// unchanged. The table is process-wide, so the replacements are installed +/// once and only pass other names through. +#[cfg(target_os = "macos")] +mod calls { + use super::{split, CStr}; + use anyhow::{ensure, Result}; + use rusqlite::ffi; + use std::{ + ffi::{c_char, c_int}, + os::fd::RawFd, + sync::{Mutex, OnceLock}, + }; + type Open = unsafe extern "C" fn(*const c_char, c_int, c_int) -> c_int; + type Stat = unsafe extern "C" fn(*const c_char, *mut libc::stat) -> c_int; + static OPEN: OnceLock = OnceLock::new(); + static STAT: OnceLock = OnceLock::new(); + static LSTAT: OnceLock = OnceLock::new(); + /// Directories currently held by an open inspection, one entry per hold. + static HELD: Mutex> = Mutex::new(Vec::new()); + + pub(super) fn hold(dir: RawFd) { + HELD.lock().unwrap_or_else(|e| e.into_inner()).push(dir); + } + pub(super) fn release(dir: RawFd) { + let mut held = HELD.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(index) = held.iter().position(|fd| *fd == dir) { + held.swap_remove(index); + } + } + /// The held directory and leaf for a confined name; `Err` for a confined + /// name whose directory is not held; `Ok(None)` for any other name. + fn confined<'a>(path: *const c_char) -> Result, ()> { + if path.is_null() { + return Ok(None); + } + // SQLite passes a NUL-terminated name that outlives the call. + let name: &'a CStr = unsafe { CStr::from_ptr(path) }; + if !name.to_bytes().starts_with(super::PREFIX.as_bytes()) { + return Ok(None); + } + let (fd, leaf) = split(name).ok_or(())?; + let held = HELD.lock().unwrap_or_else(|e| e.into_inner()); + if held.contains(&fd) { + Ok(Some((fd, leaf))) + } else { + Err(()) + } + } + fn refuse() -> c_int { + unsafe { *libc::__error() = libc::ENOENT }; + -1 + } + unsafe extern "C" fn open(path: *const c_char, flags: c_int, mode: c_int) -> c_int { + match confined(path) { + Ok(Some((fd, leaf))) => unsafe { + libc::openat( + fd, + leaf.as_ptr(), + flags | libc::O_NOFOLLOW | libc::O_CLOEXEC, + mode as libc::c_uint, + ) + }, + Ok(None) => unsafe { OPEN.get().expect("installed before use")(path, flags, mode) }, + Err(()) => refuse(), + } + } + fn stat_with(original: &OnceLock, path: *const c_char, out: *mut libc::stat) -> c_int { + match confined(path) { + Ok(Some((fd, leaf))) => unsafe { + libc::fstatat(fd, leaf.as_ptr(), out, libc::AT_SYMLINK_NOFOLLOW) + }, + Ok(None) => unsafe { original.get().expect("installed before use")(path, out) }, + Err(()) => refuse(), + } + } + unsafe extern "C" fn stat(path: *const c_char, out: *mut libc::stat) -> c_int { + stat_with(&STAT, path, out) + } + unsafe extern "C" fn lstat(path: *const c_char, out: *mut libc::stat) -> c_int { + stat_with(&LSTAT, path, out) + } + pub(super) fn install(base: *mut ffi::sqlite3_vfs) -> Result<()> { + // SAFETY: `base` is SQLite's registered Unix VFS. Each original call is + // recorded before its replacement is installed, and the function + // pointer types match SQLite's osOpen/osStat/osLstat declarations. + unsafe { + let get = (*base).xGetSystemCall.expect("Unix VFS system calls"); + let set = (*base).xSetSystemCall.expect("Unix VFS system calls"); + let (Some(o), Some(s), Some(l)) = ( + get(base, c"open".as_ptr()), + get(base, c"stat".as_ptr()), + get(base, c"lstat".as_ptr()), + ) else { + anyhow::bail!("SQLite Unix VFS lacks replaceable open/stat calls"); + }; + OPEN.get_or_init(|| std::mem::transmute::(o)); + STAT.get_or_init(|| std::mem::transmute::(s)); + LSTAT.get_or_init(|| std::mem::transmute::(l)); + for (name, call) in [ + ( + c"open", + std::mem::transmute::(open as Open), + ), + ( + c"stat", + std::mem::transmute::(stat as Stat), + ), + ( + c"lstat", + std::mem::transmute::(lstat as Stat), + ), + ] { + let code = set(base, name.as_ptr(), Some(call)); + ensure!( + code == ffi::SQLITE_OK, + "Could not route SQLite {name:?} through the held directory ({code})" + ); + } + } + Ok(()) + } +} diff --git a/native/core/src/store.rs b/native/core/src/store.rs index f918b195..035da5b7 100644 --- a/native/core/src/store.rs +++ b/native/core/src/store.rs @@ -552,6 +552,11 @@ impl Store { pub fn create_session(&self, workspace: &Path, model: &str, title: &str) -> Result { let sid = id(); let now = now(); + // Sessions are matched against `Workspace::open`, which names the + // folder by its real path (on macOS /var is /private/var). + let workspace = workspace + .canonicalize() + .unwrap_or_else(|_| workspace.to_path_buf()); self.execute("INSERT INTO sessions(id,workspace,created_at,updated_at,model_id,status,title) VALUES(?,?,?,?,?,'active',?)", params![sid,workspace.to_string_lossy(),now,now,model,title])?; self.session(&sid)?.context("Created session disappeared") diff --git a/native/core/src/terminal.rs b/native/core/src/terminal.rs index ddb446cb..f490dddc 100644 --- a/native/core/src/terminal.rs +++ b/native/core/src/terminal.rs @@ -490,9 +490,9 @@ fn set_size(master: &std::fs::File, cols: u16, rows: u16) -> Result<()> { } #[cfg(target_os = "linux")] -fn spawn(shell: &Path, login: bool, workspace: &Path, cols: u16, rows: u16) -> Result { +fn open_pty() -> Result<(std::fs::File, std::fs::File)> { use std::os::fd::FromRawFd; - use std::os::unix::{fs::OpenOptionsExt, process::CommandExt}; + use std::os::unix::fs::OpenOptionsExt; // SAFETY: posix_openpt returns a new descriptor we own, or -1. let fd = unsafe { libc::posix_openpt(libc::O_RDWR | libc::O_NOCTTY | libc::O_CLOEXEC) }; ensure!( @@ -519,13 +519,59 @@ fn spawn(shell: &Path, login: bool, workspace: &Path, cols: u16, rows: u16) -> R let path = unsafe { std::ffi::CStr::from_ptr(name.as_ptr()) } .to_string_lossy() .into_owned(); - set_size(&master, cols, rows)?; let slave = std::fs::OpenOptions::new() .read(true) .write(true) .custom_flags(libc::O_NOCTTY) .open(&path) .context("Could not open the terminal device")?; + Ok((master, slave)) +} + +#[cfg(target_os = "macos")] +fn open_pty() -> Result<(std::fs::File, std::fs::File)> { + use std::os::fd::{AsRawFd, FromRawFd}; + let (mut master, mut slave) = (-1, -1); + // SAFETY: openpty writes two new descriptors we own; the name, termios + // and window size arguments may be null. + let result = unsafe { + libc::openpty( + &mut master, + &mut slave, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }; + ensure!( + result == 0, + "Could not open a terminal: {}", + std::io::Error::last_os_error() + ); + // SAFETY: both descriptors are fresh and owned by nothing else. + let (master, slave) = unsafe { + ( + std::fs::File::from_raw_fd(master), + std::fs::File::from_raw_fd(slave), + ) + }; + // macOS has no atomic close-on-exec for openpty. + for fd in [master.as_raw_fd(), slave.as_raw_fd()] { + // SAFETY: F_SETFD on a descriptor owned above. + ensure!( + unsafe { libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC) } == 0, + "Could not prepare the terminal: {}", + std::io::Error::last_os_error() + ); + } + Ok((master, slave)) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn spawn(shell: &Path, login: bool, workspace: &Path, cols: u16, rows: u16) -> Result { + use std::os::unix::process::CommandExt; + let (master, slave) = open_pty()?; + set_size(&master, cols, rows)?; let name = shell .file_name() .map(|n| n.to_string_lossy().into_owned()) @@ -560,7 +606,7 @@ fn spawn(shell: &Path, login: bool, workspace: &Path, cols: u16, rows: u16) -> R } // The host monitor gives the host shell its own session and // controlling terminal. Claiming it here would prevent that. - if !host_terminal && libc::ioctl(0, libc::TIOCSCTTY, 0) < 0 { + if !host_terminal && libc::ioctl(0, libc::TIOCSCTTY as _, 0) < 0 { return Err(std::io::Error::last_os_error()); } Ok(()) @@ -573,14 +619,14 @@ fn spawn(shell: &Path, login: bool, workspace: &Path, cols: u16, rows: u16) -> R Ok(Pty { master, child, pid }) } -#[cfg(not(target_os = "linux"))] +#[cfg(not(any(target_os = "linux", target_os = "macos")))] fn spawn(_shell: &Path, _login: bool, _workspace: &Path, _cols: u16, _rows: u16) -> Result { - bail!("Terminals are available on Linux") + bail!("Terminals are available on Linux and macOS") } /// Signal a shell's whole session: its own process group, the leader, and -/// (on Linux) every process whose session is the shell's, which includes the -/// job-control groups the shell created. +/// every process whose session is the shell's, which includes the job-control +/// groups the shell created. fn signal_session(pid: i32, signal: libc::c_int, leader: bool) { if pid <= 0 { return; @@ -593,9 +639,9 @@ fn signal_session(pid: i32, signal: libc::c_int, leader: bool) { libc::kill(pid, signal); } } - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "macos"))] for member in session_members(pid) { - // SAFETY: as above; members were read from /proc just now. + // SAFETY: as above; members were listed just now. unsafe { libc::kill(member, signal); } @@ -623,6 +669,23 @@ fn session_members(sid: i32) -> Vec { .collect() } +#[cfg(target_os = "macos")] +fn session_members(sid: i32) -> Vec { + let mut pids = vec![0 as libc::pid_t; 8192]; + // SAFETY: the buffer holds `pids.len()` pids and its size is passed in bytes. + let count = unsafe { + libc::proc_listallpids( + pids.as_mut_ptr().cast(), + (pids.len() * std::mem::size_of::()) as libc::c_int, + ) + }; + pids.truncate(count.max(0) as usize); + pids.into_iter() + // SAFETY: getsid only reads the session id of a process id. + .filter(|pid| *pid > 0 && unsafe { libc::getsid(*pid) } == sid) + .collect() +} + /// Terminal-safe check used by routes: a terminal id is 32 hex digits. pub fn valid_id(id: &str) -> Result<&str> { if id.len() == 32 && id.bytes().all(|b| b.is_ascii_hexdigit()) { diff --git a/native/core/src/updates.rs b/native/core/src/updates.rs index 648da6c3..f8044ae9 100644 --- a/native/core/src/updates.rs +++ b/native/core/src/updates.rs @@ -420,6 +420,8 @@ pub enum InstallKind { Appimage, Deb, Flatpak, + /// ShadowCode.app on macOS (the .dmg or the macOS installer). + MacApp, /// Under /usr or /opt without the bundler's package marker: a /// distribution's own build or another package format. System, @@ -434,6 +436,7 @@ impl InstallKind { Self::Appimage => "AppImage", Self::Deb => "Debian package", Self::Flatpak => "Flatpak", + Self::MacApp => "macOS app", Self::System => "System package", Self::Source => "Built from source", Self::Unknown => "Unknown", @@ -478,12 +481,21 @@ pub fn detect_install(probe: &InstallProbe) -> InstallKind { return InstallKind::Appimage; } } - if matches!(probe.bundle, Some(InstallKind::Appimage | InstallKind::Deb)) { + if matches!( + probe.bundle, + Some(InstallKind::Appimage | InstallKind::Deb | InstallKind::MacApp) + ) { return probe.bundle.unwrap_or(InstallKind::Unknown); } let Some(exe) = exe else { return InstallKind::Unknown; }; + if exe + .to_string_lossy() + .ends_with(".app/Contents/MacOS/shadowcode") + { + return InstallKind::MacApp; + } let names: Vec<_> = exe .components() .map(|part| part.as_os_str().to_string_lossy()) @@ -738,6 +750,11 @@ fn next_step_for( "command": null, "link": readme("debian-package"), }), + InstallKind::MacApp => json!({ + "text": format!("Run the macOS installer again: it downloads ShadowCode_{version}_{}.dmg, checks it against the release's checksums and replaces the app, keeping your settings. Or download the .dmg from the release page and drag ShadowCode into Applications.", dmg_arch(arch)), + "command": format!("curl -fsSL https://raw.githubusercontent.com/ShadowfetchStudio/ShadowCode/{tag}/scripts/install-macos.sh | bash"), + "link": readme("macos"), + }), InstallKind::Flatpak => json!({ "text": "Install the new Flatpak bundle from the official publisher, or update through the Flatpak remote you configured. Your settings and projects are preserved.", "command": null, @@ -761,6 +778,14 @@ fn next_step_for( } } +/// The architecture in a macOS .dmg's name (Tauri's naming). +fn dmg_arch(arch: &str) -> &str { + match arch { + "x86_64" => "x64", + other => other, + } +} + /// `GET /api/updates`: the notice and its settings, without network access. pub fn status(paths: &AppPaths, config: &Config, policy: &Policy, install: InstallKind) -> Value { let state = load_state(paths); @@ -1181,6 +1206,21 @@ mod tests { assert!(flatpak["text"].as_str().unwrap().contains("Flatpak")); assert!(flatpak["command"].is_null()); assert_eq!(InstallKind::Flatpak.label(), "Flatpak"); + let mac = next_step_for(InstallKind::MacApp, &open, Some(&release), "x86_64"); + assert!(mac["text"].as_str().unwrap().contains("_x64.dmg"), "{mac}"); + assert!(mac["command"] + .as_str() + .unwrap() + .contains("scripts/install-macos.sh")); + assert_eq!( + detect_install(&InstallProbe { + bundle: None, + appimage: None, + appdir: None, + exe: Some("/Applications/ShadowCode.app/Contents/MacOS/shadowcode".into()), + }), + InstallKind::MacApp + ); let deb = next_step(InstallKind::Deb, &open, Some(&release)); assert!(deb["text"] .as_str() diff --git a/native/core/src/voice/capture.rs b/native/core/src/voice/capture.rs index e708bede..8e7abe52 100644 --- a/native/core/src/voice/capture.rs +++ b/native/core/src/voice/capture.rs @@ -1,6 +1,7 @@ //! Microphone capture in the engine process (cpal over ALSA; on PipeWire and //! PulseAudio desktops the default ALSA device is the desktop's default -//! microphone). The window never touches the microphone: WebKitGTK's +//! microphone. On macOS cpal uses CoreAudio, and the app needs the microphone +//! usage description in its Info.plist). The window never touches the microphone: WebKitGTK's //! getUserMedia is off by default, the Tauri webview denies its permission //! requests, and it would need GStreamer PipeWire plugins inside the AppImage. //! diff --git a/native/core/src/workspace.rs b/native/core/src/workspace.rs index 138c0519..b70ad733 100644 --- a/native/core/src/workspace.rs +++ b/native/core/src/workspace.rs @@ -10,6 +10,15 @@ use std::{ path::{Component, Path, PathBuf}, }; +/// Never follow a symlink at the path's last component. On Linux cap-std +/// opens with openat2 and O_NOFOLLOW is enough; on macOS it resolves the path +/// itself and follows a final symlink unless told not to, before the flag +/// reaches the last openat. +fn no_follow(options: &mut OpenOptions) -> &mut OpenOptions { + use cap_fs_ext::{FollowSymlinks, OpenOptionsFollowExt}; + options.follow(FollowSymlinks::No) +} + pub const MAX_FILE_BYTES: usize = 4_000_000; /// Directory capabilities make file operations relative to an already-open @@ -84,9 +93,21 @@ impl Workspace { pub fn relative(&self, path: &str) -> Result { ensure!(!path.contains('\0'), "Path contains a NUL byte"); let path = Path::new(path); + let resolved; let path = if path.is_absolute() { - path.strip_prefix(&self.path) - .context("Path is outside the workspace")? + match path.strip_prefix(&self.path) { + Ok(inside) => inside, + // The root is canonical; the path may name it through a + // symlink (on macOS /var and every temporary folder are under + // /private). Resolve the deepest existing ancestor and try + // again; a path that really resolves outside stays refused. + Err(_) => { + resolved = canonical_ancestor(path).context("Path is outside the workspace")?; + resolved + .strip_prefix(&self.path) + .context("Path is outside the workspace")? + } + } } else { path }; @@ -278,6 +299,7 @@ impl Workspace { { use cap_std::fs::OpenOptionsExt; options.custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW); + no_follow(&mut options); } let relative = self.relative(path)?; #[cfg(unix)] @@ -288,6 +310,7 @@ impl Workspace { directory .read(true) .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_NONBLOCK); + no_follow(&mut directory); for part in relative.parent().unwrap_or(Path::new(".")).components() { if let Component::Normal(name) = part { parent = Dir::from_std_file(parent.open_with(name, &directory)?.into_std()); @@ -357,6 +380,7 @@ impl Workspace { options .read(true) .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_NONBLOCK); + no_follow(&mut options); for part in relative.parent().unwrap_or(Path::new(".")).components() { if let Component::Normal(name) = part { parent = Dir::from_std_file( @@ -653,6 +677,20 @@ impl Workspace { } } +/// `path` with its deepest existing ancestor canonicalized and the rest +/// appended unchanged (it may not exist yet). `None` when nothing resolves. +fn canonical_ancestor(path: &Path) -> Option { + let mut rest = Vec::new(); + let mut current = path; + loop { + if let Ok(real) = current.canonicalize() { + return Some(rest.iter().rev().fold(real, |acc, part| acc.join(part))); + } + rest.push(current.file_name()?.to_owned()); + current = current.parent()?; + } +} + fn open_mutation_directory(parent: &Dir, name: &std::ffi::OsStr) -> std::io::Result { #[cfg(unix)] { @@ -661,6 +699,7 @@ fn open_mutation_directory(parent: &Dir, name: &std::ffi::OsStr) -> std::io::Res options .read(true) .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_NONBLOCK); + no_follow(&mut options); Ok(Dir::from_std_file( parent.open_with(name, &options)?.into_std(), )) @@ -676,6 +715,7 @@ fn open_mutation_file(parent: &Dir, name: &std::ffi::OsStr) -> std::io::Result usize { locks::record_references(store, id) @@ -995,12 +996,30 @@ async fn close(engine: &Engine, record: &mut Record, state: &str) { #[cfg(test)] mod tests { + /// A temporary folder named by its canonical path. + struct Canonical { + _dir: tempfile::TempDir, + path: std::path::PathBuf, + } + impl Canonical { + fn new(dir: tempfile::TempDir) -> Self { + let path = dir.path().canonicalize().unwrap(); + Self { _dir: dir, path } + } + fn path(&self) -> &std::path::Path { + &self.path + } + } + use super::*; use std::time::Duration; #[test] fn attachments_named_in_the_task_are_copied() { let root = tempfile::tempdir().unwrap(); + // Canonical, as product paths are (on macOS the temporary folder is + // under /var, a symlink to /private/var). + let root = Canonical::new(root); let (source, worktree) = (root.path().join("s"), root.path().join("w")); fs::create_dir_all(source.join(".shadow/attachments")).unwrap(); fs::create_dir_all(&worktree).unwrap(); @@ -1022,6 +1041,9 @@ mod tests { #[test] fn attachments_never_follow_a_symlink_out_of_the_project() { let root = tempfile::tempdir().unwrap(); + // Canonical, as product paths are (on macOS the temporary folder is + // under /var, a symlink to /private/var). + let root = Canonical::new(root); let (source, worktree) = (root.path().join("s"), root.path().join("w")); let outside = root.path().join("outside"); fs::create_dir_all(&outside).unwrap(); @@ -1086,12 +1108,15 @@ mod tests { } struct TaskFixture { engine: Engine, - root: tempfile::TempDir, + root: Canonical, source: PathBuf, record: Record, } async fn fixture() -> TaskFixture { let root = tempfile::tempdir().unwrap(); + // Canonical, as product paths are (on macOS the temporary folder is + // under /var, a symlink to /private/var). + let root = Canonical::new(root); let source = root.path().join("source"); fs::create_dir(&source).unwrap(); command(&source, &["init", "-q"]); diff --git a/native/core/src/worktrees.rs b/native/core/src/worktrees.rs index a77e2928..bb301a31 100644 --- a/native/core/src/worktrees.rs +++ b/native/core/src/worktrees.rs @@ -1,6 +1,5 @@ //! Managed Git worktrees. The source checkout is never reset or stashed. pub mod changes; -#[cfg(target_os = "linux")] mod cleanup; mod locks; pub mod repair; @@ -37,7 +36,9 @@ fn roots(paths: &AppPaths) -> Result<(PathBuf, PathBuf)> { paths::private_directory(&records)?; let checkouts = root.join("checkouts"); paths::private_directory(&checkouts)?; - Ok((records, checkouts)) + // Checkouts are recorded and later checked by their real path (on macOS + // the data folder may be reached through /var, a link to /private/var). + Ok((records, checkouts.canonicalize()?)) } fn save(root: &Path, record: &Record) -> Result<()> { let bytes = serde_json::to_vec_pretty(record)?; @@ -645,10 +646,7 @@ async fn dispose_checkout( cancel: CancellationToken, ) -> Result> { let index = index_root(paths, id); - #[cfg(target_os = "linux")] let result = cleanup::dispose(paths, source, id, delete_branch, cancel).await; - #[cfg(not(target_os = "linux"))] - let result = dispose_checkout_legacy(paths, source, id, delete_branch, cancel).await; if result.is_ok() { forget_index(index); } @@ -669,127 +667,6 @@ fn forget_index(root: Option) { } } -#[cfg(not(target_os = "linux"))] -async fn dispose_checkout_legacy( - paths: &AppPaths, - source: &Path, - id: &str, - delete_branch: bool, - cancel: CancellationToken, -) -> Result> { - let _ownership = locks::mutation(source, &cancel).await?; - let mut record = read_record(paths, source, id)?; - let (records, _) = roots(paths)?; - let location = record - .path - .to_str() - .context("Worktree path must be UTF-8")? - .to_owned(); - let missing = matches!( - fs::symlink_metadata(&record.path), - Err(ref error) if error.kind() == std::io::ErrorKind::NotFound - ); - let mut note = None; - if missing { - let common = PathBuf::from( - git( - &record.source, - &["rev-parse", "--path-format=absolute", "--git-common-dir"], - cancel.clone(), - ) - .await?, - ) - .canonicalize()?; - ensure!( - common == record.common_directory, - "Source repository identity changed" - ); - let registered = git( - &record.source, - &["worktree", "list", "--porcelain", "-z"], - cancel.clone(), - ) - .await?; - let expected = format!("worktree {location}"); - if let Some(block) = registered - .split("\0\0") - .find(|block| block.split('\0').any(|field| field == expected)) - { - ensure!( - !block - .split('\0') - .any(|field| field == "locked" || field.starts_with("locked ")), - "Worktree is locked; it may be on an unavailable device" - ); - git( - &record.source, - &["worktree", "remove", "--force", "--", &location], - cancel.clone(), - ) - .await?; - } - note = Some(format!( - "Checkout {} was already deleted outside ShadowCode; its Git registration and branch were cleaned up", - record.path.display() - )); - } else { - let inspection = inspect(paths, source, id, cancel.clone()).await?; - ensure!( - !inspection.locked, - "Git worktree is locked; unlock it deliberately before removal" - ); - ensure!( - inspection.current_branch == record.branch, - "Checkout {} is no longer on its managed branch {}; preserve it before removal", - record.path.display(), - record.branch - ); - record.state = "removing".into(); - record.detail = "Removing disposable checkout and its managed branch".into(); - save(&records, &record)?; - git( - &record.source, - &["worktree", "remove", "--force", "--", &location], - cancel.clone(), - ) - .await?; - } - // Only this record's own branch, whose name `read_record` verified. - let reference = format!("refs/heads/{}", record.branch); - if delete_branch - && git( - &record.source, - &["show-ref", "--verify", "--quiet", &reference], - cancel.clone(), - ) - .await - .is_ok() - { - if let Err(error) = git(&record.source, &["branch", "-D", &record.branch], cancel).await { - note = Some(format!("Branch {} was kept: {error:#}", record.branch)); - } - } - record.state = "removed".into(); - record.detail = if delete_branch { - format!( - "Disposable checkout removed; branch {} deleted", - record.branch - ) - } else { - format!("Checkout removed; branch {} kept", record.branch) - }; - save(&records, &record)?; - let archive = records.join("archive"); - paths::private_directory(&archive)?; - fs::rename( - records.join(format!("{}.json", record.id)), - archive.join(format!("{}.json", record.id)), - )?; - fs::File::open(&records)?.sync_all()?; - fs::File::open(&archive)?.sync_all()?; - Ok(note) -} - /// A rescue creates a new checkout; it never prunes the missing checkout's /// registration or index, which may still contain recoverable staged changes. #[derive(Clone, Debug, Serialize)] diff --git a/native/core/src/worktrees/cleanup.rs b/native/core/src/worktrees/cleanup.rs index a3ab444d..0baae669 100644 --- a/native/core/src/worktrees/cleanup.rs +++ b/native/core/src/worktrees/cleanup.rs @@ -758,7 +758,7 @@ fn restore_admin( ); let staged = tempfile::Builder::new() .prefix(".shadowcode-cleanup-") - .tempdir_in(format!("/proc/self/fd/{}", source_parent.as_raw_fd()))?; + .tempdir_in(crate::paths::held_directory(source_parent.as_raw_fd())?)?; for (relative, entry) in &saved.entries { ensure!( relative @@ -824,6 +824,7 @@ fn rename_noreplace( use std::os::fd::AsRawFd; let from_name = std::ffi::CString::new(name.as_bytes())?; let to_name = std::ffi::CString::new(destination.as_bytes())?; + #[cfg(target_os = "linux")] let result = unsafe { libc::renameat2( from.as_raw_fd(), @@ -833,6 +834,16 @@ fn rename_noreplace( libc::RENAME_NOREPLACE, ) }; + #[cfg(target_os = "macos")] + let result = unsafe { + libc::renameatx_np( + from.as_raw_fd(), + from_name.as_ptr(), + to.as_raw_fd(), + to_name.as_ptr(), + libc::RENAME_EXCL, + ) + }; ensure!( result == 0, "Exclusive cleanup rename refused: {}", diff --git a/native/core/src/worktrees/locks.rs b/native/core/src/worktrees/locks.rs index d9d34dac..e0c6237a 100644 --- a/native/core/src/worktrees/locks.rs +++ b/native/core/src/worktrees/locks.rs @@ -224,12 +224,30 @@ pub(super) async fn reserve_record( #[cfg(all(test, unix))] mod tests { + /// A temporary folder named by its canonical path. + struct Canonical { + _dir: tempfile::TempDir, + path: std::path::PathBuf, + } + impl Canonical { + fn new(dir: tempfile::TempDir) -> Self { + let path = dir.path().canonicalize().unwrap(); + Self { _dir: dir, path } + } + fn path(&self) -> &std::path::Path { + &self.path + } + } + use super::*; use std::os::unix::fs::symlink; #[test] fn advisory_refuses_symlinks_hardlinks_and_replaced_ownership() { let root = tempfile::tempdir().unwrap(); + // Canonical, as product paths are (on macOS the temporary folder is + // under /var, a symlink to /private/var). + let root = Canonical::new(root); let directory = root.path().join("repository"); fs::create_dir(&directory).unwrap(); let target = root.path().join("foreign"); @@ -289,6 +307,9 @@ mod tests { ) { use std::time::{Duration, Instant}; let root = tempfile::tempdir().unwrap(); + // Canonical, as product paths are (on macOS the temporary folder is + // under /var, a symlink to /private/var). + let root = Canonical::new(root); let source = root.path().join("source"); let second = root.path().join("second"); repository(&source); diff --git a/native/core/tests/antigravity_acp.rs b/native/core/tests/antigravity_acp.rs index 888308a8..ccace306 100644 --- a/native/core/tests/antigravity_acp.rs +++ b/native/core/tests/antigravity_acp.rs @@ -111,12 +111,10 @@ async fn install_sign_in_models_run_and_sign_out_through_the_acp_server() { let recent = home.join("runs/still-running"); fs::create_dir_all(stale.join("tmp")).unwrap(); fs::create_dir_all(&recent).unwrap(); - let aged = std::process::Command::new("touch") - .args(["-d", "3 days ago"]) - .arg(&stale) - .status() + fs::File::open(&stale) + .unwrap() + .set_modified(std::time::SystemTime::now() - Duration::from_secs(3 * 24 * 3600)) .unwrap(); - assert!(aged.success()); // Installed, not signed in: the status probe sees the printed link and // stops; no browser is opened. diff --git a/native/core/tests/background_processes.rs b/native/core/tests/background_processes.rs index 25069c89..55973ae0 100644 --- a/native/core/tests/background_processes.rs +++ b/native/core/tests/background_processes.rs @@ -67,12 +67,7 @@ async fn until( .expect("background state timed out") } fn dead(pid: u32) -> bool { - match fs::read_to_string(format!("/proc/{pid}/stat")) { - Ok(stat) => stat - .rsplit_once(") ") - .is_some_and(|(_, rest)| rest.starts_with('Z') || rest.starts_with('X')), - Err(_) => true, - } + !support::running(pid) } #[tokio::test] diff --git a/native/core/tests/background_tools.rs b/native/core/tests/background_tools.rs index ed9e5266..73f04b8b 100644 --- a/native/core/tests/background_tools.rs +++ b/native/core/tests/background_tools.rs @@ -92,12 +92,7 @@ async fn until( .expect("background process did not reach expected state") } fn dead(pid: u32) -> bool { - match fs::read_to_string(format!("/proc/{pid}/stat")) { - Ok(stat) => stat - .rsplit_once(") ") - .is_some_and(|(_, state)| state.starts_with('Z') || state.starts_with('X')), - Err(_) => true, - } + !support::running(pid) } #[tokio::test] diff --git a/native/core/tests/config_concurrency.rs b/native/core/tests/config_concurrency.rs index de2a3c27..99945e82 100644 --- a/native/core/tests/config_concurrency.rs +++ b/native/core/tests/config_concurrency.rs @@ -123,6 +123,7 @@ async fn simultaneous_projects_keep_trust_settings_and_integration_grants() { for worker in 0..8 { let project = root.path().join(format!("project-{worker}")); std::fs::create_dir(&project).unwrap(); + let project = project.canonicalize().unwrap(); let client = service.fork_selection(project.clone(), None).unwrap(); let barrier = barrier.clone(); handles.push(tokio::spawn(async move { diff --git a/native/core/tests/control.rs b/native/core/tests/control.rs index 3c4a8094..4a0dfe9c 100644 --- a/native/core/tests/control.rs +++ b/native/core/tests/control.rs @@ -1,3 +1,4 @@ +mod support; use serde_json::{json, Value}; use shadowcode_core::{ config::Config, @@ -146,8 +147,7 @@ async fn client_disconnect_cancels_manual_command_and_releases_workspace() { let _ = operation.await; tokio::time::timeout(Duration::from_secs(5), async { loop { - let alive = fs::read_to_string(format!("/proc/{pid}/stat")) - .is_ok_and(|stat| !stat.contains(") Z ")); + let alive = support::running(pid); if !alive { break; } @@ -273,6 +273,7 @@ async fn attached_views_keep_navigation_independent_and_detach_without_stopping_ let primary = service.workspace().unwrap(); let other = root.path().join("view-project"); fs::create_dir(&other).unwrap(); + let other = other.canonicalize().unwrap(); let server = Server::start_with_mode(service.clone(), "server").unwrap(); let client = server.endpoint().client(primary.clone(), None); let first = client.open_view().await.unwrap(); @@ -658,9 +659,13 @@ async fn closing_view_interrupts_pending_owner_discovery() { } fn descriptor_count() -> usize { - std::fs::read_dir("/proc/self/fd") - .map(|entries| entries.count()) - .unwrap_or(0) + std::fs::read_dir(if cfg!(target_os = "linux") { + "/proc/self/fd" + } else { + "/dev/fd" + }) + .map(|entries| entries.count()) + .unwrap_or(0) } #[tokio::test] diff --git a/native/core/tests/hooks.rs b/native/core/tests/hooks.rs index 100f0f5d..6580fa25 100644 --- a/native/core/tests/hooks.rs +++ b/native/core/tests/hooks.rs @@ -594,8 +594,7 @@ async fn hook_timeout_output_flood_and_cancellation_stop_owned_processes() { struct PathCheck; impl PathCheck { fn alive(pid: &str) -> bool { - fs::read_to_string(format!("/proc/{}/stat", pid.trim())) - .is_ok_and(|s| !s.contains(") Z ") && !s.contains(") X ")) + pid.trim().parse().is_ok_and(support::running) } } diff --git a/native/core/tests/local_engine.rs b/native/core/tests/local_engine.rs index 571e8edf..65a4aaea 100644 --- a/native/core/tests/local_engine.rs +++ b/native/core/tests/local_engine.rs @@ -436,10 +436,7 @@ async fn call(service: &Service, method: &str, path: &str, body: Value) -> anyho } fn pid_alive(pid: u64) -> bool { - // A zombie still has a /proc entry; check its state. - fs::read_to_string(format!("/proc/{pid}/stat")) - .map(|s| !s.split(") ").nth(1).unwrap_or("").starts_with('Z')) - .unwrap_or(false) + u32::try_from(pid).is_ok_and(support::running) } async fn wait_job(service: &Service, id: &str) -> Value { @@ -1494,7 +1491,7 @@ async fn load_switch_lease_unload_and_shutdown_stop_the_server() { assert_eq!(provenance["identity_kind"], "filesystem_metadata"); assert_eq!( provenance["files"]["model"]["path"], - b.display().to_string() + b.canonicalize().unwrap().display().to_string() ); assert_eq!(provenance["model"]["architecture"], "gemma4"); assert_eq!( diff --git a/native/core/tests/mcp_application.rs b/native/core/tests/mcp_application.rs index 927a3b5a..1728fa2e 100644 --- a/native/core/tests/mcp_application.rs +++ b/native/core/tests/mcp_application.rs @@ -14,12 +14,7 @@ use shadowcode_core::{ tools::{ToolExecutor, ToolResult}, workspace::Workspace, }; -use std::{ - fs, - path::{Path, PathBuf}, - sync::Arc, - time::Duration, -}; +use std::{fs, path::PathBuf, sync::Arc, time::Duration}; use tokio_util::sync::CancellationToken; struct Fixture { @@ -129,16 +124,11 @@ impl Fixture { async fn stopped(&self) { let pids = self.pids(); // Engine completion must already have awaited leader reaping. - assert!( - !Path::new(&format!("/proc/{}", pids[0])).exists(), - "Leader outlived task completion" - ); + assert!(!support::exists(pids[0]), "Leader outlived task completion"); for pid in pids { tokio::time::timeout(Duration::from_secs(1), async { loop { - let active = fs::read_to_string(format!("/proc/{pid}/stat")) - .ok() - .is_some_and(|s| !s.rsplit_once(") ").unwrap().1.starts_with('Z')); + let active = support::running(pid); if !active { break; } diff --git a/native/core/tests/mcp_http_server.rs b/native/core/tests/mcp_http_server.rs index bfd63e43..002a7326 100644 --- a/native/core/tests/mcp_http_server.rs +++ b/native/core/tests/mcp_http_server.rs @@ -38,6 +38,11 @@ impl Fixture { let other = root.path().join("other"); fs::create_dir(&project).unwrap(); fs::create_dir(&other).unwrap(); + // Real paths, as the service names them (macOS: /var is /private/var). + let (project, other) = ( + project.canonicalize().unwrap(), + other.canonicalize().unwrap(), + ); let paths = AppPaths::isolated(&root.path().join("profile")).unwrap(); Config::patch(&paths,json!({"model":{"provider":"local","name":"fixture","default":"fixture","endpoint":endpoint,"context_limit":16384},"trusted_workspaces":[project,other],"agent":{"max_steps":5,"model_retries":0}})).unwrap(); let service = Service::open(paths.clone(), Some(other.clone())).unwrap(); @@ -140,6 +145,10 @@ fn rpc(method: &str, params: Value) -> Value { // A sandbox child reports a namespace-local PID. Resolve its host identity // while it is alive, then assert that exact process is reaped on shutdown. fn host_pid(project: &std::path::Path, namespace_pid: u32) -> u32 { + // macOS has no PID namespaces: the reported PID is the host's. + if !cfg!(target_os = "linux") { + return namespace_pid; + } let project = project.canonicalize().unwrap(); let matches: Vec<_> = fs::read_dir("/proc") .unwrap() @@ -164,10 +173,7 @@ fn host_pid(project: &std::path::Path, namespace_pid: u32) -> u32 { matches[0] } fn dead(pid: u32) -> bool { - fs::read_to_string(format!("/proc/{pid}/stat")).map_or(true, |stat| { - stat.rsplit_once(") ") - .is_some_and(|(_, state)| state.starts_with('Z') || state.starts_with('X')) - }) + !support::running(pid) } #[tokio::test] diff --git a/native/core/tests/mcp_native_server.rs b/native/core/tests/mcp_native_server.rs index 3ab2e5fd..49f6cc7a 100644 --- a/native/core/tests/mcp_native_server.rs +++ b/native/core/tests/mcp_native_server.rs @@ -35,6 +35,11 @@ impl Fixture { let other = root.path().join("other"); fs::create_dir(&project).unwrap(); fs::create_dir(&other).unwrap(); + // Real paths, as the service names them (macOS: /var is /private/var). + let (project, other) = ( + project.canonicalize().unwrap(), + other.canonicalize().unwrap(), + ); let paths = AppPaths::isolated(&root.path().join("profile")).unwrap(); Config::patch(&paths,json!({"model":{"provider":"local","endpoint":endpoint,"name":"fixture","default":"fixture","context_limit":16384},"trusted_workspaces":[project,other],"agent":{"max_steps":4,"model_retries":0}})).unwrap(); let service = Service::open(paths.clone(), Some(other.clone())).unwrap(); diff --git a/native/core/tests/mcp_stdio.rs b/native/core/tests/mcp_stdio.rs index 33c64fc3..05e42c46 100644 --- a/native/core/tests/mcp_stdio.rs +++ b/native/core/tests/mcp_stdio.rs @@ -1,4 +1,5 @@ #![cfg(unix)] +mod support; use serde_json::{json, Value}; use shadowcode_core::mcp::{Client, StdioSpec}; use std::{collections::BTreeMap, path::PathBuf, time::Duration}; @@ -46,23 +47,18 @@ impl Fixture { let pids = self.pids(); for pid in &pids { for _ in 0..100 { - let running = std::fs::read_to_string(format!("/proc/{pid}/stat")) - .ok() - .is_some_and(|s| !s.rsplit_once(") ").unwrap().1.starts_with('Z')); - if !running { + if !support::running(*pid) { break; } tokio::time::sleep(Duration::from_millis(10)).await; } - if let Ok(s) = std::fs::read_to_string(format!("/proc/{pid}/stat")) { - assert!( - s.rsplit_once(") ").unwrap().1.starts_with('Z'), - "MCP process {pid} survived cleanup" - ); - } + assert!( + !support::running(*pid), + "MCP process {pid} survived cleanup" + ); } for _ in 0..100 { - if !std::path::Path::new(&format!("/proc/{}", pids[0])).exists() { + if !support::exists(pids[0]) { return; } tokio::time::sleep(Duration::from_millis(10)).await; @@ -102,7 +98,13 @@ async fn native_mcp_handshake_paginated_catalog_literal_arguments_and_error_resu assert!(!environment.contains(&json!("CARGO_MANIFEST_DIR"))); assert_eq!( result["structuredContent"]["cwd"], - fixture.root.path().to_string_lossy().as_ref() + fixture + .root + .path() + .canonicalize() + .unwrap() + .to_string_lossy() + .as_ref() ); assert_eq!( client.call("failure", json!({})).await.unwrap()["isError"], diff --git a/native/core/tests/memory.rs b/native/core/tests/memory.rs index f3098904..6c687596 100644 --- a/native/core/tests/memory.rs +++ b/native/core/tests/memory.rs @@ -291,6 +291,7 @@ fn concurrent_task_appends_do_not_lose_notes_and_migration_keeps_a_backup() { let paths = AppPaths::isolated(root.path()).unwrap(); let project = root.path().join("project"); fs::create_dir(&project).unwrap(); + let project = project.canonicalize().unwrap(); let store = Store::open(&paths.database()).unwrap(); let sid = store.create_session(&project, "fixture", "").unwrap(); let tid = store diff --git a/native/core/tests/model_streams.rs b/native/core/tests/model_streams.rs index d532d744..5b87dfa7 100644 --- a/native/core/tests/model_streams.rs +++ b/native/core/tests/model_streams.rs @@ -973,6 +973,13 @@ fn provider_error_details_are_the_providers_own_redacted_message() { /// deadline; only a stream with no bytes for 120 seconds stalls. Runs on /// tokio's paused clock, so it takes no real time. #[tokio::test(start_paused = true)] +// The paused clock jumps ahead whenever the runtime looks idle; on macOS a +// loopback write is not always readable by then. The timing logic is the +// same on every platform. +#[cfg_attr( + not(target_os = "linux"), + ignore = "paused clock races real loopback I/O outside Linux" +)] async fn long_steady_streams_are_not_cut_off_by_a_total_deadline() { let root = tempfile::tempdir().unwrap(); let paths = AppPaths::isolated(root.path()).unwrap(); @@ -1040,6 +1047,13 @@ async fn long_steady_streams_are_not_cut_off_by_a_total_deadline() { /// the first byte must not stall the request; silence after the answer has /// started still does after 120 seconds. Paused clock: no real waiting. #[tokio::test(start_paused = true)] +// The paused clock jumps ahead whenever the runtime looks idle; on macOS a +// loopback write is not always readable by then. The timing logic is the +// same on every platform. +#[cfg_attr( + not(target_os = "linux"), + ignore = "paused clock races real loopback I/O outside Linux" +)] async fn slow_local_prompt_processing_is_not_a_stall_but_a_silent_answer_is() { // `seen` is notified once the client has handed the first words to its // caller, so the silence after it starts only when the client is waiting. diff --git a/native/core/tests/preview.rs b/native/core/tests/preview.rs index 61a45329..820d9313 100644 --- a/native/core/tests/preview.rs +++ b/native/core/tests/preview.rs @@ -2,7 +2,7 @@ //! process output and from project processes' listening sockets, the //! loopback proxy (refusals, HTML injection) and preview context appended //! to a task for the model. -#![cfg(target_os = "linux")] +#![cfg(any(target_os = "linux", target_os = "macos"))] mod support; use serde_json::{json, Value}; use shadowcode_core::{ @@ -135,7 +135,11 @@ async fn finds_background_urls_and_project_listeners_but_not_others() { assert_eq!(inside["source"], "process"); assert_eq!(inside["listening"], true); assert_eq!(inside["url"], format!("http://localhost:{inside_port}/")); - assert_eq!(inside["process"], "python3"); + // The executable's own name: python3 on Linux, python3.13 (say) on macOS. + assert!( + inside["process"].as_str().unwrap().starts_with("python3"), + "{inside}" + ); let printed = by_port(printed_port).unwrap(); assert_eq!(printed["source"], "background"); assert_eq!( diff --git a/native/core/tests/process_stress.rs b/native/core/tests/process_stress.rs index 0761217f..7494075d 100644 --- a/native/core/tests/process_stress.rs +++ b/native/core/tests/process_stress.rs @@ -115,13 +115,21 @@ async fn dropping_execution_does_not_leave_the_process_group_alive() { #[tokio::test] async fn a_detached_pipe_cannot_hang_completion() { let root = tempfile::tempdir().unwrap(); + // macOS has no setsid(1); its Perl calls setsid(2) the same way. + let setsid = if cfg!(target_os = "macos") { + "perl -MPOSIX -e 'POSIX::setsid(); exec @ARGV'" + } else { + "setsid" + }; let result = run( ProcessSpec::shell( // Exit only once the child has left the shell's process group // (it writes its pid after setsid); a fixed pause could end the // shell first on a slow runner, and the group kill took it too. - "setsid sh -c 'echo $$ > escaped.pid; sleep 8' & \ - while [ ! -s escaped.pid ]; do sleep .02; done; printf done", + &format!( + "{setsid} sh -c 'echo $$ > escaped.pid; sleep 8' & \ + while [ ! -s escaped.pid ]; do sleep .02; done; printf done" + ), root.path().into(), Duration::from_secs(3), ), diff --git a/native/core/tests/project_inspection.rs b/native/core/tests/project_inspection.rs index 0116755f..6feff71a 100644 --- a/native/core/tests/project_inspection.rs +++ b/native/core/tests/project_inspection.rs @@ -495,6 +495,10 @@ async fn command_completion_hooks_run_once_and_cannot_hide_test_output_or_failur // A sandbox child reports a namespace-local PID. Resolve its host identity // while it is alive, then assert that exact process is reaped on shutdown. fn host_pid(project: &std::path::Path, namespace_pid: u32) -> u32 { + // macOS has no PID namespaces: the reported PID is the host's. + if !cfg!(target_os = "linux") { + return namespace_pid; + } let project = project.canonicalize().unwrap(); let matches: Vec<_> = fs::read_dir("/proc") .unwrap() @@ -553,7 +557,7 @@ async fn test_timeouts_stop_command_children_and_keep_the_workspace_reusable() { let done = wait(&service, &job).await; assert_eq!(done["status"], "failed"); assert_eq!(done["result"]["command"]["timed_out"], true); - assert!(!fs::read_to_string(format!("/proc/{pid}/stat")).is_ok_and(|s| !s.contains(") Z "))); + assert!(!support::running(pid)); let next = api( &service, "POST", diff --git a/native/core/tests/sandbox_isolation.rs b/native/core/tests/sandbox_isolation.rs index b3108813..e096e1ba 100644 --- a/native/core/tests/sandbox_isolation.rs +++ b/native/core/tests/sandbox_isolation.rs @@ -1,5 +1,6 @@ -//! Live checks of the shell sandbox: the real bubblewrap profile, the -//! network allow-list proxy and the Landlock fallback. A test that needs a +//! Live checks of the shell sandbox: the real bubblewrap (Linux) or +//! Seatbelt (macOS) profile, the network allow-list proxy and the Landlock +//! fallback. A test that needs a //! layer this machine cannot provide (no bubblewrap, no unprivileged user //! namespaces, no Landlock, no curl) prints why and returns. use serde_json::json; @@ -90,7 +91,12 @@ printf ok > written.txt || exit 16 return; } }; - assert_eq!(prepared.note["mode"], "bubblewrap"); + let expected = if cfg!(target_os = "macos") { + "seatbelt" + } else { + "bubblewrap" + }; + assert_eq!(prepared.note["mode"], expected); assert_eq!(prepared.note["network"], "off"); let result = run(prepared, ws.path()).await.unwrap(); assert!( diff --git a/native/core/tests/service_commands.rs b/native/core/tests/service_commands.rs index a618ae24..47523777 100644 --- a/native/core/tests/service_commands.rs +++ b/native/core/tests/service_commands.rs @@ -151,6 +151,7 @@ async fn editor_drafts_survive_restart_without_overwriting_workspace_files() { assert_eq!(listed["drafts"][0]["draft"], "let answer = 2;\n"); let other = root.path().join("other"); fs::create_dir(&other).unwrap(); + let other = other.canonicalize().unwrap(); let second_project = reopened.fork_selection(other.clone(), None).unwrap(); assert_eq!( call( @@ -837,12 +838,7 @@ async fn shutdown_cancels_manual_terminal_and_audits_its_original_session() { ); tokio::time::timeout(Duration::from_secs(2), async { loop { - let stat = fs::read_to_string(format!("/proc/{pid}/stat")).unwrap_or_default(); - if stat.is_empty() - || stat - .rsplit_once(") ") - .is_some_and(|(_, tail)| tail.starts_with(['Z', 'X'])) - { + if !support::running(pid) { break; } tokio::time::sleep(Duration::from_millis(10)).await; diff --git a/native/core/tests/support/mod.rs b/native/core/tests/support/mod.rs index 796fbbe8..56551b0a 100644 --- a/native/core/tests/support/mod.rs +++ b/native/core/tests/support/mod.rs @@ -1,3 +1,5 @@ +//! Shared by the integration tests; each uses only some of it. +#![allow(dead_code)] use serde_json::Value; use std::{ sync::{Arc, Mutex}, @@ -70,3 +72,51 @@ pub async fn server( worker, } } + +/// Whether a process exists and has not exited (a zombie has). +pub fn running(pid: u32) -> bool { + #[cfg(target_os = "linux")] + { + std::fs::read_to_string(format!("/proc/{pid}/stat")).is_ok_and(|stat| { + stat.rsplit_once(") ") + .is_some_and(|(_, state)| !state.starts_with('Z') && !state.starts_with('X')) + }) + } + #[cfg(target_os = "macos")] + { + let Ok(pid) = i32::try_from(pid) else { + return false; + }; + // SAFETY: proc_bsdinfo is plain data; proc_pidinfo writes at most + // `size` bytes into it. + let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() }; + let size = std::mem::size_of::() as i32; + let written = unsafe { + libc::proc_pidinfo( + pid, + libc::PROC_PIDTBSDINFO, + 0, + (&mut info as *mut libc::proc_bsdinfo).cast(), + size, + ) + }; + written == size && info.pbi_status != libc::SZOMB + } +} + +/// Whether a process id is still taken, zombies included (not yet reaped). +pub fn exists(pid: u32) -> bool { + let Ok(pid) = i32::try_from(pid) else { + return false; + }; + // SAFETY: signal 0 only checks that the process exists. + unsafe { libc::kill(pid, 0) == 0 || *errno() == libc::EPERM } +} +#[cfg(target_os = "linux")] +unsafe fn errno() -> *mut i32 { + unsafe { libc::__errno_location() } +} +#[cfg(target_os = "macos")] +unsafe fn errno() -> *mut i32 { + unsafe { libc::__error() } +} diff --git a/native/core/tests/terminals.rs b/native/core/tests/terminals.rs index 41a57b12..9ddecad4 100644 --- a/native/core/tests/terminals.rs +++ b/native/core/tests/terminals.rs @@ -1,6 +1,7 @@ //! The drawer's interactive terminals: a real `sh` on a pseudo-terminal, //! typed into, read by cursor, resized and hung up, and the routes the //! window uses. +mod support; use base64::Engine as _; use serde_json::{json, Value}; use shadowcode_core::{ @@ -44,14 +45,8 @@ fn read_until(hub: &Terminals, id: &str, cursor: &mut u64, needle: &str) -> Stri } fn alive(pid: i32) -> bool { - // A reaped or never-started process has no /proc entry; a zombie is - // gone for our purposes. - std::fs::read_to_string(format!("/proc/{pid}/stat")) - .map(|stat| { - let state = stat[stat.rfind(')').unwrap() + 2..].chars().next(); - state != Some('Z') - }) - .unwrap_or(false) + // A zombie is gone for our purposes. + u32::try_from(pid).is_ok_and(support::running) } fn wait_gone(pid: i32) { diff --git a/native/core/tests/tester_route_budget.rs b/native/core/tests/tester_route_budget.rs index 60063d44..4b341e42 100644 --- a/native/core/tests/tester_route_budget.rs +++ b/native/core/tests/tester_route_budget.rs @@ -44,9 +44,10 @@ fn workspace_at(path: &std::path::Path) -> Workspace { fn long_project(root: &std::path::Path) -> std::path::PathBuf { let workspace = workspace_at(root); let base = needed(&tester_messages(&workspace), &tools::schemas()); + // The temporary folder is itself part of the path: macOS's is longer. assert!( - base < 4080, - "Short tester request must leave room for path pressure" + base < 4088, + "Short tester request must leave room for path pressure ({base} tokens)" ); let padding = (4088 - base) * 3; root.join("p".repeat(padding.min(200))) diff --git a/native/core/tests/vendor_auth.rs b/native/core/tests/vendor_auth.rs index 2db74c8c..64d7369f 100644 --- a/native/core/tests/vendor_auth.rs +++ b/native/core/tests/vendor_auth.rs @@ -1,5 +1,6 @@ //! Official-login supervision using an isolated fake CLI, never an account. -#![cfg(target_os = "linux")] +#![cfg(any(target_os = "linux", target_os = "macos"))] +mod support; use serde_json::Value; use shadowcode_core::{ @@ -135,15 +136,10 @@ async fn exercise(case: Case) { .unwrap() .parse() .unwrap(); - // /proc disappearance proves the supervised parent was reaped. Its helper - // still owns both inherited output pipes while the drain is in progress. - assert!( - wait_for( - || !Path::new(&format!("/proc/{pid}")).exists(), - Duration::from_millis(150) - ) - .await - ); + // The pid's disappearance proves the supervised parent was reaped. Its + // helper still owns both inherited output pipes while the drain is in + // progress. + assert!(wait_for(|| !support::exists(pid), Duration::from_millis(150)).await); if matches!(case, Case::Tail) { // Start the tail's delay only after reaping is observed, so scheduler // delay before that observation cannot make the tail finish early. diff --git a/native/core/tests/vendor_login_lifecycle.rs b/native/core/tests/vendor_login_lifecycle.rs index 9ecbd4ef..41ecdf88 100644 --- a/native/core/tests/vendor_login_lifecycle.rs +++ b/native/core/tests/vendor_login_lifecycle.rs @@ -1,5 +1,5 @@ //! Private fake login/account-check lifecycle barriers; no real authentication. -#![cfg(target_os = "linux")] +#![cfg(any(target_os = "linux", target_os = "macos"))] mod vendor_support; use serde_json::{json, Value}; @@ -46,6 +46,40 @@ async fn until(mut ready: impl FnMut() -> bool, timeout: Duration) -> bool { fn pid_from(file: &Path) -> Option { fs::read_to_string(file).ok()?.trim().parse().ok() } +/// The process's identity: present, state (Z for a zombie), start time and +/// executable. A reaped process is absent. +#[cfg(target_os = "macos")] +fn process_snapshot(pid: u32) -> Value { + let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() }; + let size = std::mem::size_of::() as i32; + // SAFETY: proc_pidinfo writes at most `size` bytes into `info`. + let written = unsafe { + libc::proc_pidinfo( + pid as i32, + libc::PROC_PIDTBSDINFO, + 0, + (&mut info as *mut libc::proc_bsdinfo).cast(), + size, + ) + }; + if written != size { + return json!({"pid":pid,"present":false}); + } + let mut path = vec![0u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize]; + // SAFETY: proc_pidpath writes at most the buffer's size. + let len = + unsafe { libc::proc_pidpath(pid as i32, path.as_mut_ptr().cast(), path.len() as u32) }; + path.truncate(len.max(0) as usize); + let state = if info.pbi_status == libc::SZOMB { + "Z" + } else { + "S" + }; + json!({"pid":pid,"present":true,"state":state, + "start_ticks":format!("{}.{:06}", info.pbi_start_tvsec, info.pbi_start_tvusec), + "exe":(len > 0).then(|| String::from_utf8_lossy(&path).into_owned())}) +} +#[cfg(target_os = "linux")] fn process_snapshot(pid: u32) -> Value { let proc = PathBuf::from(format!("/proc/{pid}")); match fs::read_to_string(proc.join("stat")) { diff --git a/native/core/tests/worktrees.rs b/native/core/tests/worktrees.rs index c85016ca..f86f78aa 100644 --- a/native/core/tests/worktrees.rs +++ b/native/core/tests/worktrees.rs @@ -55,6 +55,7 @@ async fn isolated_checkout_preserves_dirty_source_and_disables_checkout_hooks() let root = tempfile::tempdir().unwrap(); let project = root.path().join("project"); repository(&project); + let project = project.canonicalize().unwrap(); let paths = AppPaths::isolated(&root.path().join("profile")).unwrap(); fs::write(project.join("tracked.txt"), "staged change\n").unwrap(); git(&project, &["add", "tracked.txt"]); @@ -1790,13 +1791,16 @@ mod profile_admission { .await .unwrap(); let records = paths.data.join("managed-worktrees/records"); + // Records name checkouts by their real path (macOS: /private/var). + let checkouts = paths + .data + .join("managed-worktrees/checkouts") + .canonicalize() + .unwrap(); for _ in 0..62 { let mut pending = first.clone(); pending.id = shadowcode_core::id(); - pending.path = paths - .data - .join("managed-worktrees/checkouts") - .join(&pending.id); + pending.path = checkouts.join(&pending.id); pending.branch = format!("shadowcode/{}", pending.id); pending.state = "creating".into(); pending.detail = diff --git a/packaging/macos/bundle-runtime.conf.json b/packaging/macos/bundle-runtime.conf.json new file mode 100644 index 00000000..9dbf09d0 --- /dev/null +++ b/packaging/macos/bundle-runtime.conf.json @@ -0,0 +1,7 @@ +{ + "bundle": { + "resources": { + "../packaging/llama.cpp/bin/": "llama.cpp/" + } + } +} diff --git a/scripts/build-llama.cpp.sh b/scripts/build-llama.cpp.sh index b8a9754c..c0a2e9e0 100755 --- a/scripts/build-llama.cpp.sh +++ b/scripts/build-llama.cpp.sh @@ -20,8 +20,16 @@ # vendored cpp-httplib, nlohmann/json, stb_image, miniaudio and # subprocess.h, and SPIRV-Headers when the Vulkan module is built. # -# Outputs: packaging/llama.cpp/bin (bundled into the AppImage and the deb) and, -# unless --no-user-install is given, ~/.local/lib/shadowcode. +# * macOS: the same loadable CPU variants plus a Metal module (no Vulkan); +# libraries find each other through @loader_path. The Metal shaders are +# compiled at build time into default.metallib next to llama-server: +# compiling them at run time takes about a minute on first use and hangs +# outside a logged-in desktop session. Needs Xcode (its metal compiler) +# and cmake. +# +# Outputs: packaging/llama.cpp/bin (bundled into the AppImage, the deb and the +# macOS app) and, unless --no-user-install is given, ~/.local/lib/shadowcode +# (macOS: ~/Library/Application Support/ShadowCode/llama.cpp). # The tracked source pin and reference manifest are inputs, never build outputs. # Build timestamps and selected backends belong only in the generated runtime. # @@ -36,7 +44,13 @@ SRC="${SHADOWCODE_LLAMA_SRC:-$ROOT/tools/llama.cpp}" BUILD_DIR="${SHADOWCODE_LLAMA_BUILD_DIR:-$SRC/build}" PIN="$ROOT/tools/llama.cpp.pin" OUT_REPO="$ROOT/packaging/llama.cpp" -OUT_USER="${SHADOWCODE_LLAMA_PREFIX:-$HOME/.local/lib/shadowcode}" +DARWIN=0 +[[ "$(uname -s)" == Darwin ]] && DARWIN=1 +if [[ "$DARWIN" == "1" ]]; then + OUT_USER="${SHADOWCODE_LLAMA_PREFIX:-$HOME/Library/Application Support/ShadowCode/llama.cpp}" +else + OUT_USER="${SHADOWCODE_LLAMA_PREFIX:-$HOME/.local/lib/shadowcode}" +fi URL="${SHADOWCODE_LLAMA_URL:-https://github.com/ggml-org/llama.cpp.git}" SPIRV_URL="${SHADOWCODE_SPIRV_HEADERS_URL:-https://github.com/KhronosGroup/SPIRV-Headers.git}" SPIRV_SRC="$ROOT/tools/spirv-headers" @@ -73,7 +87,8 @@ GENERATOR=() if command -v ninja >/dev/null 2>&1; then GENERATOR=(-G Ninja) fi -JOBS="${SHADOWCODE_BUILD_JOBS:-$(( $(nproc) > 2 ? $(nproc) - 2 : 1 ))}" +CPUS="$(nproc 2>/dev/null || sysctl -n hw.ncpu)" +JOBS="${SHADOWCODE_BUILD_JOBS:-$(( CPUS > 2 ? CPUS - 2 : 1 ))}" mkdir -p "$(dirname "$SRC")" "$OUT_REPO/bin" if [[ ! -d "$SRC/.git" ]]; then @@ -182,6 +197,14 @@ fi VULKAN_FLAGS=(-DGGML_VULKAN=OFF) BACKEND="cpu" +RPATH='$ORIGIN' +if [[ "$DARWIN" == "1" ]]; then + # Metal instead of Vulkan; its shader library is compiled into the module. + WANT_VULKAN=0 + VULKAN_FLAGS=(-DGGML_VULKAN=OFF -DGGML_METAL=ON -DGGML_METAL_EMBED_LIBRARY=OFF -DGGML_BLAS=OFF) + BACKEND="metal+cpu" + RPATH='@loader_path' +fi if [[ "$WANT_VULKAN" == "1" ]]; then GLSLC="$ROOT/tools/glslc-flatpak.sh" if ! "$GLSLC" --version >/dev/null 2>&1; then @@ -216,7 +239,7 @@ fi # compiler is GCC 13. Use gcc-14/g++-14 when the default cannot target it. # SHADOWCODE_CC and SHADOWCODE_CXX choose the compilers explicitly. COMPILER_FLAGS=() -if [[ "$(uname -m)" == aarch64 ]]; then +if [[ "$(uname -m)" == aarch64 && "$DARWIN" == "0" ]]; then targets_sme() { printf 'int shadowcode_probe;\n' | "$1" -x "$2" -march=armv9.2-a+sme -c -o /dev/null - >/dev/null 2>&1; } LLAMA_CC="${SHADOWCODE_CC:-${CC:-cc}}" LLAMA_CXX="${SHADOWCODE_CXX:-${CXX:-c++}}" @@ -232,7 +255,8 @@ if [[ "$(uname -m)" == aarch64 ]]; then COMPILER_FLAGS=(-DCMAKE_C_COMPILER="$LLAMA_CC" -DCMAKE_CXX_COMPILER="$LLAMA_CXX") fi -"$CMAKE" -S "$SRC" -B "$BUILD_DIR" "${GENERATOR[@]}" "${COMPILER_FLAGS[@]}" \ +# ${array[@]+...}: empty arrays are "unbound" under set -u in bash 3.2 (macOS). +"$CMAKE" -S "$SRC" -B "$BUILD_DIR" ${GENERATOR[@]+"${GENERATOR[@]}"} ${COMPILER_FLAGS[@]+"${COMPILER_FLAGS[@]}"} \ -DCMAKE_BUILD_TYPE=Release \ -DBUILD_SHARED_LIBS=ON \ -DGGML_BACKEND_DL=ON \ @@ -245,7 +269,7 @@ fi -DLLAMA_BUILD_TOOLS=ON \ -DLLAMA_BUILD_SERVER=ON \ -DLLAMA_CURL=OFF \ - -DCMAKE_INSTALL_RPATH='$ORIGIN' \ + -DCMAKE_INSTALL_RPATH="$RPATH" \ -DCMAKE_BUILD_WITH_INSTALL_RPATH=ON "$CMAKE" --build "$BUILD_DIR" --parallel "$JOBS" BIN_DIR="$BUILD_DIR/bin" @@ -264,7 +288,11 @@ install_runtime() { # cp -a keeps the SONAME symlinks relative. find "$BIN_DIR" -maxdepth 1 \( -name 'libllama.so*' -o -name 'libllama-common.so*' \ -o -name 'libllama-server-impl.so' -o -name 'libllama-cli-impl.so' \ - -o -name 'libggml*.so*' -o -name 'libmtmd*.so*' \) -exec cp -a {} "$dest.new/" \; + -o -name 'libggml*.so*' -o -name 'libmtmd*.so*' \ + -o -name 'libllama.*dylib' -o -name 'libllama-common.*dylib' \ + -o -name 'libllama-server-impl.dylib' -o -name 'libllama-cli-impl.dylib' \ + -o -name 'libggml*.dylib' -o -name 'libmtmd*.dylib' -o -name '*.metallib' \) \ + -exec cp -a {} "$dest.new/" \; # Model architectures the pinned runtime can load, from its own source, so # the catalog can say "unsupported architecture" instead of guessing. sed -n '/LLM_ARCH_NAMES/,/^};/p' "$SRC/src/llama-arch.cpp" \ diff --git a/scripts/build-macos-app.sh b/scripts/build-macos-app.sh new file mode 100755 index 00000000..0d76b3db --- /dev/null +++ b/scripts/build-macos-app.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Build ShadowCode.app and ShadowCode__.dmg on macOS. +# +# * The pinned llama.cpp runtime (Metal + CPU) is built first when +# packaging/llama.cpp/bin does not already hold a macOS build of the pinned +# commit (scripts/build-llama.cpp.sh --no-user-install), then bundled into +# ShadowCode.app/Contents/Resources/llama.cpp. +# * The app is signed ad hoc ("-"): it runs on this Mac and on Apple +# silicon, but Gatekeeper asks before the first launch of a downloaded +# copy until the app is signed with a Developer ID and notarized. +# * Needs Xcode (or the Command Line Tools plus Xcode's metal compiler for +# the runtime), Rust 1.95.0, Node 22 and cmake. +# +# Outputs (in $CARGO_TARGET_DIR or target/): release/bundle/macos/ShadowCode.app +# and release/bundle/dmg/ShadowCode__.dmg. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +[[ "$(uname -s)" == Darwin ]] || { echo "Build the macOS app on macOS." >&2; exit 1; } +RUNTIME="$ROOT/packaging/llama.cpp/bin" +field() { [[ -f "$1" ]] && awk -F= -v key="$2" '$1 == key { print substr($0, length(key) + 2); exit }' "$1"; } +PINNED="$(field "$ROOT/tools/llama.cpp.pin" commit)" +if [[ ! -x "$RUNTIME/llama-server" || ! -f "$RUNTIME/default.metallib" \ + || "$(field "$RUNTIME/COMMIT" commit || true)" != "$PINNED" ]]; then + bash "$ROOT/scripts/build-llama.cpp.sh" --no-user-install +fi +if [[ ! -x "$ROOT/ui/node_modules/.bin/tauri" ]]; then + npm --prefix "$ROOT/ui" ci +fi +# CI=true keeps the DMG step from scripting Finder (which needs a desktop +# session and Automation permission); the image is the same. +cd "$ROOT" +CI=true "$ROOT/ui/node_modules/.bin/tauri" build --bundles app,dmg \ + --config "$ROOT/packaging/macos/bundle-runtime.conf.json" +TARGET="${CARGO_TARGET_DIR:-$ROOT/target}" +APP="$TARGET/release/bundle/macos/ShadowCode.app" +[[ -x "$APP/Contents/MacOS/shadowcode" && -x "$APP/Contents/Resources/llama.cpp/llama-server" ]] \ + || { echo "The bundle is missing the app or its runtime: $APP" >&2; exit 1; } +codesign --verify --deep --strict "$APP" +printf 'Built %s\n' "$APP" +ls "$TARGET"/release/bundle/dmg/*.dmg diff --git a/scripts/install-macos.sh b/scripts/install-macos.sh new file mode 100755 index 00000000..1858177b --- /dev/null +++ b/scripts/install-macos.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Install or update ShadowCode on macOS from its GitHub release: +# +# curl -fsSL https://raw.githubusercontent.com/ShadowfetchStudio/ShadowCode/main/scripts/install-macos.sh | bash +# +# * Downloads ShadowCode__.dmg (x64 for Intel, aarch64 for +# Apple silicon) and the release's ShadowCode__macos.sha256, and +# refuses the image unless its SHA-256 matches. +# * Copies ShadowCode.app into /Applications (or ~/Applications when +# /Applications is not writable), replacing an earlier copy only after the +# new one is complete. Settings and history live elsewhere +# (~/Library/Application Support/ShadowCode) and are kept. +# * Links the command-line tool as ~/.local/bin/shadowcode. +# * The app is not notarized by Apple. Files fetched with curl are not +# marked as downloaded, so macOS opens it without the "unidentified +# developer" block; a .dmg downloaded in a browser needs System Settings › +# Privacy & Security › Open Anyway once instead. +# +# Environment: SHADOWCODE_VERSION (default: the latest release), +# SHADOWCODE_APP_DIR (default: /Applications). +set -euo pipefail +REPO="ShadowfetchStudio/ShadowCode" +fail() { printf 'ShadowCode installer: %s\n' "$*" >&2; exit 1; } +[[ "$(uname -s)" == Darwin ]] || fail "this installer is for macOS." + +case "$(uname -m)" in + x86_64) ARCH=x64 ;; + arm64) ARCH=aarch64 ;; + *) fail "unsupported processor $(uname -m)." ;; +esac +# A shell running under Rosetta reports x86_64 on Apple silicon. +if [[ "$ARCH" == x64 && "$(sysctl -n hw.optional.arm64 2>/dev/null || true)" == 1 ]]; then + ARCH=aarch64 +fi + +fetch() { curl -fsSL --proto '=https' --tlsv1.2 --retry 3 "$@"; } +VERSION="${SHADOWCODE_VERSION:-}" +if [[ -z "$VERSION" ]]; then + # The latest release's page redirects to its tag; no API token needed. + LATEST="$(curl -fsSI --proto '=https' --tlsv1.2 "https://github.com/$REPO/releases/latest" \ + | awk 'tolower($1) == "location:" { print $2 }' | tr -d '\r')" + VERSION="${LATEST##*/tag/v}" +fi +[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "could not tell which version to install (got '$VERSION')." + +DMG="ShadowCode_${VERSION}_${ARCH}.dmg" +SUMS="ShadowCode_${VERSION}_macos.sha256" +BASE="https://github.com/$REPO/releases/download/v$VERSION" +WORK="$(mktemp -d)" +MOUNT="" +cleanup() { + if [[ -n "$MOUNT" ]]; then hdiutil detach -quiet "$MOUNT" 2>/dev/null || true; fi + rm -rf "$WORK" +} +trap cleanup EXIT + +printf 'Downloading ShadowCode %s for %s...\n' "$VERSION" "$([[ $ARCH == x64 ]] && echo Intel || echo 'Apple silicon')" +fetch -o "$WORK/$SUMS" "$BASE/$SUMS" || fail "release v$VERSION has no macOS checksums ($SUMS)." +fetch -o "$WORK/$DMG" "$BASE/$DMG" || fail "release v$VERSION has no $DMG." +EXPECTED="$(awk -v name="$DMG" '$2 == name || $2 == "*" name { print $1 }' "$WORK/$SUMS")" +ACTUAL="$(shasum -a 256 "$WORK/$DMG" | awk '{ print $1 }')" +[[ "$EXPECTED" =~ ^[0-9a-f]{64}$ && "$EXPECTED" == "$ACTUAL" ]] || fail "$DMG does not match the release's checksum; nothing was installed." + +MOUNT="$WORK/mount" +mkdir "$MOUNT" +hdiutil attach -quiet -nobrowse -readonly -noautoopen -mountpoint "$MOUNT" "$WORK/$DMG" || fail "could not open $DMG." +[[ -x "$MOUNT/ShadowCode.app/Contents/MacOS/shadowcode" ]] || fail "$DMG does not contain ShadowCode.app." + +DEST="${SHADOWCODE_APP_DIR:-/Applications}" +if [[ ! -w "$DEST" ]]; then + DEST="$HOME/Applications" + mkdir -p "$DEST" +fi +APP="$DEST/ShadowCode.app" +if pgrep -f "$APP/Contents/MacOS/shadowcode" >/dev/null 2>&1; then + fail "ShadowCode is running; quit it (ShadowCode › Quit) and run the installer again." +fi +rm -rf "$APP.new" "$APP.previous" +ditto "$MOUNT/ShadowCode.app" "$APP.new" +if [[ -e "$APP" ]]; then mv "$APP" "$APP.previous"; fi +mv "$APP.new" "$APP" +rm -rf "$APP.previous" +xattr -dr com.apple.quarantine "$APP" 2>/dev/null || true + +mkdir -p "$HOME/.local/bin" +ln -sf "$APP/Contents/MacOS/shadowcode" "$HOME/.local/bin/shadowcode" +printf 'Installed ShadowCode %s in %s.\n' "$VERSION" "$APP" +case ":$PATH:" in + *":$HOME/.local/bin:"*) printf 'The shadowcode command is ready in this terminal.\n' ;; + *) printf 'For the shadowcode command, add ~/.local/bin to your PATH:\n echo '\''export PATH="$HOME/.local/bin:$PATH"'\'' >> ~/.zprofile\n' ;; +esac +printf 'Open it from Applications, or run: open -a ShadowCode\n' diff --git a/scripts/publish-native-release.mjs b/scripts/publish-native-release.mjs index 4a0a6e2b..3d6c48b0 100644 --- a/scripts/publish-native-release.mjs +++ b/scripts/publish-native-release.mjs @@ -11,6 +11,10 @@ import { IDENTITY, TARGETS } from './native-release-auth.mjs'; import { withReleaseSnapshot, validateContext, validateStagedRelease, readReceipts, requireTransportLayout } from './native-release-assets.mjs'; export { digest } from './native-release-verification.mjs'; +// After publication, .github/workflows/release-macos.yml attaches the macOS +// disk images and their checksums. They are outside this signed asset set: +// a published-release retry leaves them alone; a draft never has them. +const MACOS_ASSET = /^ShadowCode_\d+\.\d+\.\d+_(x64|aarch64)\.dmg$|^ShadowCode_\d+\.\d+\.\d+_macos\.sha256$/; const command = (args) => execFileSync('gh', args, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] }); // One release carries every target's signed bundle. Each bundle is snapshotted @@ -86,6 +90,7 @@ async function publishVerified({ repo, tag, commit, expected, gh, sleep }) { const scratch = await mkdtemp(path.join(tmpdir(), 'shadowcode-release-')); try { for (const asset of record.assets) { + if (!record.draft && MACOS_ASSET.test(asset.name)) continue; assert(expected.has(asset.name), `Unexpected remote asset ${asset.name}; review the draft`); assert(!names.has(asset.name), `Duplicate remote asset ${asset.name}`); names.add(asset.name); diff --git a/scripts/test-native-release.mjs b/scripts/test-native-release.mjs index a997f616..fd427eb2 100644 --- a/scripts/test-native-release.mjs +++ b/scripts/test-native-release.mjs @@ -69,6 +69,15 @@ test('published retry verifies bytes without mutations', () => fixture(async ({ assert.equal((await execute()).status, 'already-published'); assert(state.calls.every(args => args[0] === 'api' || args[1] === 'download')); })); +test('a published retry leaves the macOS images alone but a draft refuses them', () => fixture(async ({ state, execute }) => { + await execute(); state.calls = []; + state.assets.set('ShadowCode_1.0.0_x64.dmg', Buffer.from('macOS image')); + state.assets.set('ShadowCode_1.0.0_macos.sha256', Buffer.from('checksums')); + assert.equal((await execute()).status, 'already-published'); + assert(state.calls.every(args => args[0] === 'api' || args[1] === 'download')); + state.release.draft = true; + await assert.rejects(execute, /Unexpected remote asset ShadowCode_1.0.0_/); +})); test('same version with changed local content is refused', () => fixture(async ({ state, execute, files, rebuild }) => { await execute(); state.calls = []; await writeFile(files[0], 'different binary'); diff --git a/src-tauri/Info.plist b/src-tauri/Info.plist new file mode 100644 index 00000000..90135495 --- /dev/null +++ b/src-tauri/Info.plist @@ -0,0 +1,8 @@ + + + + + NSMicrophoneUsageDescription + ShadowCode uses the microphone only while you dictate a message; speech is transcribed on this Mac. + + diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index bd1ffaa5..e8dee551 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -341,6 +341,8 @@ fn main() { } fn run() -> Result<()> { + #[cfg(target_os = "macos")] + shadowcode_core::login_path::adopt(); // The bundler records the package format in the executable; Settings › // About and the update notice use it to name the right update steps. { @@ -349,6 +351,7 @@ fn run() -> Result<()> { match bundle_type() { Some(BundleType::AppImage) => set_bundle(InstallKind::Appimage), Some(BundleType::Deb) => set_bundle(InstallKind::Deb), + Some(BundleType::App | BundleType::Dmg) => set_bundle(InstallKind::MacApp), _ => {} } } diff --git a/src-tauri/src/window_size.rs b/src-tauri/src/window_size.rs index f66bfd91..aadc86b4 100644 --- a/src-tauri/src/window_size.rs +++ b/src-tauri/src/window_size.rs @@ -40,6 +40,8 @@ const X11_TITLE_BAR: f64 = 48.0; pub enum Desktop { /// The window draws its own title bar (a GTK header bar) inside the size /// it asks for, and the compositor chooses where the window opens. + /// Only GTK reports it; macOS sizes like X11 (title bar outside). + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] Wayland, /// The window manager adds its title bar outside the window's size, and /// the window opens where Tauri or the window-state plugin puts it. diff --git a/src-tauri/tauri.macos.conf.json b/src-tauri/tauri.macos.conf.json new file mode 100644 index 00000000..3edf2633 --- /dev/null +++ b/src-tauri/tauri.macos.conf.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "bundle": { + "targets": ["app", "dmg"], + "macOS": { + "minimumSystemVersion": "12.0", + "signingIdentity": "-" + } + } +} diff --git a/ui/src/api.ts b/ui/src/api.ts index bc5c78f9..88168659 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -280,7 +280,7 @@ export type LocalCatalog = { ram_bytes?: number; gpu?: string | null; vram_bytes?: number | null; - backend?: "vulkan" | "cpu" | "unknown" | string; + backend?: "vulkan" | "metal" | "cpu" | "unknown" | string; devices?: string[]; detail?: string; }; @@ -908,7 +908,12 @@ export type GitHooksState = { fingerprint: string; }; export type SecretStorage = { - keyring: { available: boolean; detail: string | null }; + keyring: { + available: boolean; + detail: string | null; + /** The macOS Keychain or the freedesktop Secret Service. */ + kind?: "keychain" | "secret_service"; + }; file: string; keys: { name: string; place: "file" | "keyring" }[]; }; @@ -1374,8 +1379,11 @@ export type StartCompareRequest = { /** GET /api/sandbox/status: shell isolation this computer provides. */ export type SandboxStatus = { - effective: "bubblewrap" | "landlock" | "none" | "blocked"; + platform?: "linux" | "macos"; + effective: "bubblewrap" | "seatbelt" | "landlock" | "none" | "blocked"; bubblewrap: { installed: boolean; works: boolean; detail: string }; + /** macOS only. */ + seatbelt?: { works: boolean; detail: string }; landlock_abi: number; network_namespace: { available: boolean; detail: string }; require: boolean; @@ -1388,7 +1396,14 @@ export type SandboxStatus = { /** How this copy was installed (`/api/about`, `/api/updates`). */ export type InstallInfo = { - kind: "appimage" | "deb" | "flatpak" | "system" | "source" | "unknown"; + kind: + | "appimage" + | "deb" + | "flatpak" + | "mac_app" + | "system" + | "source" + | "unknown"; label: string; }; diff --git a/ui/src/components/settings/KeyStorage.tsx b/ui/src/components/settings/KeyStorage.tsx index f1104499..62f9b51d 100644 --- a/ui/src/components/settings/KeyStorage.tsx +++ b/ui/src/components/settings/KeyStorage.tsx @@ -22,6 +22,7 @@ export function KeyStorage() { }; }, []); if (!storage || storage.keys.length === 0) return null; + const keychain = storage.keyring.kind === "keychain"; async function move(name: string, to: "keyring" | "file") { setWorking(name); setError(""); @@ -37,9 +38,9 @@ export function KeyStorage() {

Where your keys are kept

- Keys are saved in a private file on this computer. On a desktop with a - keyring (GNOME Keyring, KWallet, KeePassXC), you can keep them there - instead. + {keychain + ? "Keys are saved in a private file on this computer. You can keep them in your macOS Keychain instead." + : "Keys are saved in a private file on this computer. On a desktop with a keyring (GNOME Keyring, KWallet, KeePassXC), you can keep them there instead."} {!storage.keyring.available && storage.keyring.detail ? ` ${storage.keyring.detail}` : ""} @@ -51,7 +52,12 @@ export function KeyStorage() { {LABELS[key.name] || {key.name}} {" "} - · {key.place === "keyring" ? "in the keyring" : "in the file"} + ·{" "} + {key.place === "keyring" + ? keychain + ? "in the Keychain" + : "in the keyring" + : "in the file"} {key.place === "file" ? ( @@ -61,7 +67,7 @@ export function KeyStorage() { disabled={!storage.keyring.available || working === key.name} onClick={() => void move(key.name, "keyring")} > - Move to keyring + {keychain ? "Move to the Keychain" : "Move to keyring"} ) : (