diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..6edd8950 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Bundled templates, scripts and sources are hashed, embedded and run as +# written: keep LF line endings in every checkout, Windows included. +* text=auto eol=lf diff --git a/.github/workflows/release-flatpak.yml b/.github/workflows/release-flatpak.yml index 22ef5dfb..f502a0d7 100644 --- a/.github/workflows/release-flatpak.yml +++ b/.github/workflows/release-flatpak.yml @@ -14,7 +14,7 @@ on: workflow_dispatch: inputs: tag: - description: 'Release tag to build and attach (for example v2.2.0)' + description: 'Release tag to build and attach (for example v2.3.0)' required: true permissions: contents: read diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml index ccad755d..ca74a6dd 100644 --- a/.github/workflows/release-macos.yml +++ b/.github/workflows/release-macos.yml @@ -14,7 +14,7 @@ on: workflow_dispatch: inputs: tag: - description: 'Release tag to build and attach (for example v2.2.0)' + description: 'Release tag to build and attach (for example v2.3.0)' required: true pull_request: paths: diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml new file mode 100644 index 00000000..221a4fff --- /dev/null +++ b/.github/workflows/release-windows.yml @@ -0,0 +1,177 @@ +name: Windows release +# ShadowCode for 64-bit Windows as a per-user NSIS installer +# (ShadowCode__x64-setup.exe), attached to a published GitHub +# release with the complete source of the bundled busybox-w32 (GPL-2.0, the +# sandbox's shell) and a SHA-256 checksum file +# (ShadowCode__windows.sha256) that scripts/install-windows.ps1 +# checks. The installer is not code-signed: GitHub is the distribution +# channel. On pull requests that touch Windows packaging this only builds, +# installs and smoke-tests it (nothing is uploaded). +# release.yml dispatches this after it publishes (its GITHUB_TOKEN-made +# release does not trigger `release: published`); a release published by +# hand still does. +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: 'Release tag to build and attach (for example v2.3.0)' + required: true + pull_request: + paths: + - '.github/workflows/release-windows.yml' + - 'scripts/build-busybox-w32.sh' + - 'scripts/build-llama.cpp.sh' + - 'scripts/install-windows.ps1' + - 'packaging/windows/**' + - 'src-tauri/tauri.windows.conf.json' + - 'tools/busybox-w32.pin' + - 'tools/busybox-w32-patches/**' + - 'tools/llama.cpp.pin' +permissions: + contents: read +concurrency: + group: release-windows-${{ github.event.release.tag_name || inputs.tag || github.ref }} + cancel-in-progress: false +env: + TAG: ${{ github.event.release.tag_name || inputs.tag || '' }} +jobs: + busybox: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ env.TAG || github.sha }} + persist-credentials: false + - name: Cross compiler + run: sudo apt-get update && sudo apt-get install -y gcc-mingw-w64-x86-64 bzip2 + - name: Build busybox-w32 and its complete source archive + run: | + version="$(node -p "require('./src-tauri/tauri.conf.json').version")" + bash scripts/build-busybox-w32.sh --source-archive "source/ShadowCode_${version}_busybox-w32-source.tar.gz" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: busybox-w32 + path: packaging/busybox-w32/bin/ + if-no-files-found: error + retention-days: 7 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: windows-release-source + path: source/*.tar.gz + if-no-files-found: error + compression-level: 0 + retention-days: 7 + build: + needs: busybox + runs-on: windows-latest + timeout-minutes: 150 + env: + RUSTUP_TOOLCHAIN: '1.95.0' + CARGO_TERM_COLOR: never + defaults: + run: + shell: bash + 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; } + - run: npm --prefix ui ci + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: busybox-w32 + path: packaging/busybox-w32/bin + - name: Build the local model runtime (llama.cpp, CPU) + run: bash scripts/build-llama.cpp.sh --no-user-install --cpu-only + - name: Build the installer (NSIS, current user) + env: + SHADOWCODE_COMMIT: ${{ github.sha }} + CARGO_PROFILE_RELEASE_DEBUG: '0' + run: CI=true ui/node_modules/.bin/tauri build --bundles nsis --config packaging/windows/bundle-runtime.conf.json + - name: Install it silently and smoke-test the installed app + run: | + version="$(node -p "require('./src-tauri/tauri.conf.json').version")" + setup="target/release/bundle/nsis/ShadowCode_${version}_x64-setup.exe" + test -f "$setup" + # Through the published installer script (Windows PowerShell 5.1), + # from this build's files instead of a download. + setup_dir="$(mktemp -d)" + cp "$setup" "$setup_dir/" + (cd "$setup_dir" && sha256sum "ShadowCode_${version}_x64-setup.exe" > "ShadowCode_${version}_windows.sha256") + SHADOWCODE_SETUP_DIR="$(cygpath -w "$setup_dir")" powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/install-windows.ps1 + powershell.exe -NoProfile -Command "[Environment]::GetEnvironmentVariable('Path','User')" | tr -d '\r' | tr ';' '\n' | grep -Fix "$(cygpath -w "$LOCALAPPDATA")\ShadowCode" + app="$(cygpath -u "$LOCALAPPDATA")/ShadowCode" + ls "$app" + test -f "$app/shadowcode.exe" + test -f "$app/busybox/busybox.exe" + test -f "$app/busybox/LICENSE" + test -f "$app/llama.cpp/llama-server.exe" + "$app/llama.cpp/llama-server.exe" --version + out="$(timeout 60 "$app/shadowcode.exe" --version 2>&1 || true)" + echo "$out" + grep -Fx "ShadowCode $version" <<<"$out" + profile="$(mktemp -d)" + timeout 120 "$app/shadowcode.exe" --profile "$(cygpath -w "$profile")" doctor --json > "$profile/doctor.json" || true + node -e "const d=require(process.argv[1]); const c=(d.checks||[]).find(x=>x.id==='appcontainer'); if(!c||c.status!=='pass') throw new Error('Windows sandbox check: '+JSON.stringify(c))" "$(cygpath -w "$profile/doctor.json")" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: windows-installer + path: target/release/bundle/nsis/*-setup.exe + 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: + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: windows-installer + path: windows + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: windows-release-source + path: windows + - name: Attach the installer, the busybox source and their checksums without replacing anything + run: | + version="${TAG#v}" + cd windows + ls + setup="ShadowCode_${version}_x64-setup.exe" + source="ShadowCode_${version}_busybox-w32-source.tar.gz" + sums="ShadowCode_${version}_windows.sha256" + test -f "$setup" + test -f "$source" + sha256sum "$setup" "$source" > "$sums" + cat "$sums" + existing="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets -q '.assets[].name')" + for file in "$setup" "$source" "$sums"; 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/.github/workflows/release.yml b/.github/workflows/release.yml index a17969cf..0e8e76bd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -283,13 +283,14 @@ jobs: run: node scripts/publish-native-release.mjs "$RUNNER_TEMP/signed-download/x86_64" release/trust "$RUNNER_TEMP/signed-download/aarch64" # A release this job publishes with GITHUB_TOKEN does not trigger other # workflows' `release: published`; a dispatch does. They build from the - # tag and attach the macOS images and Flatpak bundles. - - name: Start the macOS and Flatpak builds for this release + # tag and attach the macOS images, Flatpak bundles and Windows installer. + - name: Start the macOS, Flatpak and Windows builds for this release env: GH_TOKEN: ${{ github.token }} run: | gh workflow run release-macos.yml --repo "$GITHUB_REPOSITORY" --ref "$GITHUB_REF_NAME" -f tag="$GITHUB_REF_NAME" gh workflow run release-flatpak.yml --repo "$GITHUB_REPOSITORY" --ref "$GITHUB_REF_NAME" -f tag="$GITHUB_REF_NAME" + gh workflow run release-windows.yml --repo "$GITHUB_REPOSITORY" --ref "$GITHUB_REF_NAME" -f tag="$GITHUB_REF_NAME" # Signed APT repository on GitHub Pages (gh-pages branch, path apt/). Only # the .deb that passed publisher authentication is indexed; the repository # signing key is a separate OpenPGP key held in the protected environment. diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml new file mode 100644 index 00000000..b9a6f73b --- /dev/null +++ b/.github/workflows/windows.yml @@ -0,0 +1,171 @@ +name: Windows +# The Windows port (docs/WINDOWS_PORT.md). The whole workspace must compile +# on Windows without warnings (every error is reported at once with +# --keep-going), and the engine's unit and integration tests must pass. The +# logs are kept as artifacts. The installer job +# builds an unsigned preview installer (NSIS, per-user) as an artifact; it is +# not attached to any release. +on: + push: + branches: [main] + paths: ['native/**', 'src-tauri/**', 'ui/**', 'Cargo.toml', 'Cargo.lock', '.github/workflows/windows.yml', 'scripts/build-llama.cpp.sh', 'tools/llama.cpp.pin', 'scripts/build-busybox-w32.sh', 'tools/busybox-w32.pin', 'packaging/windows/**'] + pull_request: + paths: ['native/**', 'src-tauri/**', 'ui/**', 'Cargo.toml', 'Cargo.lock', '.github/workflows/windows.yml', 'scripts/build-llama.cpp.sh', 'tools/llama.cpp.pin', 'scripts/build-busybox-w32.sh', 'tools/busybox-w32.pin', 'packaging/windows/**'] +permissions: + contents: read +concurrency: + group: windows-${{ github.ref }} + cancel-in-progress: true +jobs: + # The sandbox's shell, cross-compiled from pinned source (see the script). + busybox: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - name: Cross compiler + run: sudo apt-get update && sudo apt-get install -y gcc-mingw-w64-x86-64 bzip2 + - run: bash scripts/build-busybox-w32.sh + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: busybox-w32 + path: packaging/busybox-w32/bin/ + if-no-files-found: error + retention-days: 7 + build: + needs: busybox + runs-on: windows-latest + timeout-minutes: 150 + env: + RUSTUP_TOOLCHAIN: '1.95.0' + CARGO_PROFILE_DEV_DEBUG: line-tables-only + CARGO_PROFILE_TEST_DEBUG: line-tables-only + CARGO_TERM_COLOR: never + # Job-wide so the check and the test build share one compilation. + RUSTFLAGS: -D warnings + defaults: + run: + shell: bash + 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 + - name: Compile everything for Windows (all errors at once) + run: | + set -o pipefail + cargo +1.95.0 check --workspace --all-targets --locked --keep-going --message-format=short 2>&1 | tee windows-check.log + - name: Summarize the errors + if: always() + run: | + echo "errors: $(grep -c ': error' windows-check.log || true)" + { grep ': error' windows-check.log || true; } | sed -E 's/:[0-9]+:[0-9]+:/:/' | sort | uniq -c | sort -rn | head -80 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: busybox-w32 + path: packaging/busybox-w32/bin + # The runner's Git converts line endings on checkout (core.autocrlf); + # the tests' repositories expect the bytes they committed. + - name: Neutral Git settings for the tests + run: git config --global core.autocrlf false + - name: Engine unit tests on Windows + id: unit + env: + # An error's backtrace says which call failed, not only its message. + RUST_LIB_BACKTRACE: '1' + run: | + set -o pipefail + cargo +1.95.0 test -p shadowcode-core --lib --locked --no-fail-fast 2>&1 | tee windows-test.log + - name: Engine integration tests on Windows + id: integration + env: + RUST_LIB_BACKTRACE: '1' + run: | + export SHADOWCODE_BUSYBOX="$(cygpath -w "$PWD/packaging/busybox-w32/bin/busybox.exe")" + set -o pipefail + cargo +1.95.0 test -p shadowcode-core --test '*' --locked --no-fail-fast 2>&1 | tee windows-integration.log + - name: Summarize the tests + if: always() && steps.unit.outcome != 'skipped' + run: | + for log in windows-test.log windows-integration.log; do + [ -f "$log" ] || continue + echo "== $log" + grep -E '^test result:' "$log" | sort | uniq -c || true + echo "failing tests:" + { grep -E '^test .* \.\.\. FAILED$' "$log" || true; } | sed -E 's/^test //; s/ \.\.\. FAILED$//' | sort + grep -E '^error: (test failed|could not compile)' "$log" || true + done + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + if: always() + with: + name: windows-check-log + path: | + windows-check.log + windows-test.log + windows-integration.log + if-no-files-found: ignore + retention-days: 14 + installer: + needs: busybox + runs-on: windows-latest + timeout-minutes: 150 + env: + RUSTUP_TOOLCHAIN: '1.95.0' + CARGO_TERM_COLOR: never + defaults: + run: + shell: bash + 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 + - run: npm --prefix ui ci + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: busybox-w32 + path: packaging/busybox-w32/bin + # The pinned llama.cpp (CPU variants, clang-cl), bundled as llama.cpp\. + - name: Build the local model runtime + run: bash scripts/build-llama.cpp.sh --no-user-install --cpu-only + - name: The runtime starts and reports its version + run: | + ls packaging/llama.cpp/bin + packaging/llama.cpp/bin/llama-server.exe --version + packaging/llama.cpp/bin/llama-server.exe --list-devices + - name: Build the preview installer (NSIS, current user) + run: CI=true ui/node_modules/.bin/tauri build --bundles nsis --config packaging/windows/bundle-runtime.conf.json + - name: The app starts and reports its version + run: | + version="$(node -p "require('./src-tauri/tauri.conf.json').version")" + ls target/release/bundle/nsis/ + out="$(timeout 60 target/release/shadowcode.exe --version 2>&1 || true)" + echo "$out" + grep -Fx "ShadowCode $version" <<<"$out" + # Started from Explorer, Windows programs have no HOME (Git Bash sets + # one): the app must still find its folders. + - name: The app finds its folders without HOME + run: | + out="$(env -u HOME timeout 60 target/release/shadowcode.exe status 2>&1 || true)" + echo "$out" + ! grep -q "HOME is not set" <<<"$out" + test -d "$(cygpath -u "$LOCALAPPDATA")/ShadowCode/state" + test -d "$(cygpath -u "$APPDATA")/ShadowCode/config" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: windows-preview-installer + path: target/release/bundle/nsis/*.exe + if-no-files-found: error + compression-level: 0 + retention-days: 14 diff --git a/.gitignore b/.gitignore index d02a995d..bc7ce666 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ src/shadow_agent/_assets/ /tools/spirv-headers/ /tools/spirv-headers-prefix/ /.claude/ +/tools/busybox-w32/ +/packaging/busybox-w32/bin/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 382cf369..b6e6948b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,39 @@ # Changelog +## 2.3.0: Windows + +- **Windows.** ShadowCode builds and runs on 64-bit Windows 10 (1809 or + later) and 11: `ShadowCode_VERSION_x64-setup.exe`, a per-user NSIS + installer (no administrator rights), not code-signed (GitHub is the + channel; `scripts/install-windows.ps1` installs from a release after + checking `ShadowCode_VERSION_windows.sha256`). The app lives in + `%LOCALAPPDATA%\ShadowCode`, settings in `%APPDATA%\ShadowCode\config`. + The engine connection is a named pipe that only this user may open; + terminals are pseudoconsoles (ConPTY) running PowerShell; subscription CLIs + and agent commands run in job objects, so stopping or closing ShadowCode + ends everything they started; llama.cpp is built with MSVC (CPU) and + installed beside the app. +- **Windows sandbox.** Agent shell commands run in an AppContainer: the + project and a scratch folder are writable, Windows, Program Files and + read-only toolchains readable, the rest of the home folder hidden; the + network is off (no capabilities, this computer's own services included) + or on. Commands run in a bundled busybox-w32 `sh`, built from a pinned + commit with one patch (the container may not open the null device, so + `/dev/null` uses a handle the launcher opens for it); its complete source + is attached to each release as `ShadowCode_VERSION_busybox-w32-source.tar.gz`. + The network allow-list is refused on Windows. `shadowcode doctor` and + **Settings › Permissions & network** report the Windows sandbox. +- **Windows release workflow.** `.github/workflows/release-windows.yml` + builds the installer from the release tag, installs it through the + install script, requires the doctor's sandbox check to pass and attaches + the installer, the busybox source and their checksums; `release.yml` + dispatches it with the macOS and Flatpak builds. +- **Contested writes.** Settings files replaced by two writers at the same + moment are retried (Windows refuses the second replacement), and writers + in one process queue for a database's write lock instead of polling it. +- **Native runtime pins** follow Alpine 3.21's libcurl and libexpat security + updates. + ## 2.2.0: macOS, Flatpak downloads and your own model servers - **macOS.** ShadowCode builds and runs as a Mac app: `ShadowCode.app` in diff --git a/Cargo.lock b/Cargo.lock index 4fbbb110..5acc0c1d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4512,7 +4512,7 @@ dependencies = [ [[package]] name = "shadowcode-core" -version = "2.2.0" +version = "2.3.0" dependencies = [ "anyhow", "base64 0.22.1", @@ -4525,9 +4525,11 @@ dependencies = [ "clap_mangen", "cpal", "crossterm", + "dunce", "flate2", "fs2", "futures-util", + "getrandom 0.4.3", "http-body", "http-body-util", "hyper", @@ -4567,12 +4569,13 @@ dependencies = [ "unicode-width", "uuid", "whisper-rs", + "windows-sys 0.61.2", "zbus", ] [[package]] name = "shadowcode-desktop" -version = "2.2.0" +version = "2.3.0" dependencies = [ "anyhow", "gdk", diff --git a/Cargo.toml b/Cargo.toml index 46c225ce..1b65c8b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ exclude = ["vendor/glib-0.18.5"] resolver = "2" [workspace.package] -version = "2.2.0" +version = "2.3.0" edition = "2021" rust-version = "1.95" license = "Apache-2.0" diff --git a/README.md b/README.md index a7656d9b..71935ab8 100644 --- a/README.md +++ b/README.md @@ -70,8 +70,8 @@ The code is open source; the Shadowfetch and ShadowCode product identity is cove own terminal, go from commit to pull request, preview the app you're building, dictate with your voice, and follow along from your phone. -ShadowCode **2.2.0** is available in [GitHub releases](https://github.com/ShadowfetchStudio/ShadowCode/releases/tag/v2.2.0) for x86_64 and 64-bit ARM Linux, from the signed APT repository below and, for the first time, for macOS (Apple silicon and Intel). -See the [2.2.0 release notes](docs/RELEASE_NOTES.md) for changes and qualification +ShadowCode **2.3.0** is available in [GitHub releases](https://github.com/ShadowfetchStudio/ShadowCode/releases/tag/v2.3.0) for x86_64 and 64-bit ARM Linux, from the signed APT repository below, for macOS (Apple silicon and Intel) and, for the first time, for Windows. +See the [2.3.0 release notes](docs/RELEASE_NOTES.md) for changes and qualification limits, and the [release history](CHANGELOG.md). ## Install @@ -89,12 +89,13 @@ file name. See From 2.2.0 each release also carries Flatpak bundles for both architectures (`ShadowCode_VERSION_x86_64.flatpak`, `ShadowCode_VERSION_aarch64.flatpak`, checked with `ShadowCode_VERSION_flatpak.sha256`; see [Flatpak](docs/FLATPAK.md)) -and macOS disk images (see [macOS](#macos)). +and macOS disk images (see [macOS](#macos)); from 2.3.0 a Windows installer +too (see [Windows](#windows)). Check [GitHub releases](https://github.com/ShadowfetchStudio/ShadowCode/releases) -for published versions. The 2.2.0 x86_64 packages and checksums are: +for published versions. The 2.3.0 x86_64 packages and checksums are: -- `ShadowCode_2.2.0_amd64.AppImage` -- `ShadowCode_2.2.0_amd64.deb` +- `ShadowCode_2.3.0_amd64.AppImage` +- `ShadowCode_2.3.0_amd64.deb` - `SHA256SUMS`, `RELEASE-MANIFEST.json`, `RELEASE-AUTH` and `RELEASE-AUTH.sig` (download these beside the package; the installer checks the publisher's signature before running anything) @@ -126,6 +127,38 @@ 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. +### Windows + +From 2.3.0, releases also carry `ShadowCode_VERSION_x64-setup.exe` for +64-bit Windows 10 (version 1809 or later) and 11, with its SHA-256 checksum in +`ShadowCode_VERSION_windows.sha256`. It installs for your user only (no +administrator rights) and includes llama.cpp for local models (CPU) and the +shell used by the Windows sandbox (busybox-w32, GPL-2.0; its complete source +is published beside the installer as +`ShadowCode_VERSION_busybox-w32-source.tar.gz`). + +Install or update with one command in PowerShell; it checks the installer +against the release's checksums, installs it for the current user and adds +the `shadowcode` command to your PATH: + +```powershell +irm https://raw.githubusercontent.com/ShadowfetchStudio/ShadowCode/main/scripts/install-windows.ps1 | iex +``` + +Or download the installer from the release page and run it. It is +distributed on GitHub only and is not code-signed, so Windows SmartScreen +shows **Windows protected your PC** for a copy downloaded in a browser: +choose **More info**, then **Run anyway** (the command above does not need +this). + +On Windows the app is in `%LOCALAPPDATA%\ShadowCode`, settings in +`%APPDATA%\ShadowCode\config` and history in `%LOCALAPPDATA%\ShadowCode`. +Shell commands run in an AppContainer sandbox with busybox `sh`, so they +keep their POSIX syntax; the network allow-list is not available on +Windows. The Windows version is new: it is tested on GitHub's Windows +runners, not yet on a wide range of PCs. See +[the Windows port notes](docs/WINDOWS_PORT.md). + ### AppImage (recommended) The installer requires signed release packages. Published 0.32.0 @@ -133,7 +166,7 @@ assets are checksum-only and cannot be installed by this new entry point; use the instructions shipped with that released version for those existing assets. The 0.33.0 release attempt failed before signing and publication. Its tag stays unchanged. Version 0.33.1 was the first signed release; the public trust policy -in `release/trust` now authorizes 0.33.0 through 2.2.0. +in `release/trust` now authorizes 0.33.0 through 2.3.0. For development, use [Build from source](#build-from-source). Obtain an independently authenticated installer bundle as described below, @@ -147,7 +180,7 @@ bundle and bootstrap requirements. From that independently authenticated installer bundle, run: ```bash -bash /path/to/trusted-bundle/scripts/install-appimage.sh /path/to/downloads/ShadowCode_2.2.0_amd64.AppImage +bash /path/to/trusted-bundle/scripts/install-appimage.sh /path/to/downloads/ShadowCode_2.3.0_amd64.AppImage ``` The installer takes the AppImage path (or `--recover`), resolves its own trusted @@ -193,7 +226,7 @@ as `--trust-dir` are not installer options. cannot corrupt it. After obtaining and verifying the intended package, run the AppImage without installing it: -`./ShadowCode_2.2.0_amd64.AppImage --appimage-extract-and-run`. FUSE is not +`./ShadowCode_2.3.0_amd64.AppImage --appimage-extract-and-run`. FUSE is not required. ### Debian package @@ -241,16 +274,16 @@ installed. The repository is published by the release workflow only; nothing in ShadowCode adds or contacts it by itself. To install one release by hand instead, verify the Debian artifact using the independently trusted verifier bundle. -`verified-deb-2.2.0` must not already exist; install the verified snapshot: +`verified-deb-2.3.0` must not already exist; install the verified snapshot: ```bash bash /path/to/trusted-bundle/scripts/verify-native-release.sh \ --bundle-dir /path/to/downloads \ --trust-dir /path/to/trusted-bundle/release/trust \ - --artifact ShadowCode_2.2.0_amd64.deb \ - --stage-dir ./verified-deb-2.2.0 \ - --expect-version 2.2.0 -sudo apt install ./verified-deb-2.2.0/ShadowCode_2.2.0_amd64.deb + --artifact ShadowCode_2.3.0_amd64.deb \ + --stage-dir ./verified-deb-2.3.0 \ + --expect-version 2.3.0 +sudo apt install ./verified-deb-2.3.0/ShadowCode_2.3.0_amd64.deb ``` A `.deb` installed by hand this way gets no updates and none of the APT diff --git a/assets/icons/shadow-agent.ico b/assets/icons/shadow-agent.ico new file mode 100644 index 00000000..d09776bd Binary files /dev/null and b/assets/icons/shadow-agent.ico differ diff --git a/docs/DISTRIBUTING.md b/docs/DISTRIBUTING.md index 60ec7f69..271edb20 100644 --- a/docs/DISTRIBUTING.md +++ b/docs/DISTRIBUTING.md @@ -48,7 +48,9 @@ bash /path/to/trusted-bundle/scripts/verify-native-release.sh \ The Flatpak bundles are attached after publication by `.github/workflows/release-flatpak.yml` (from 2.2.0), with SHA-256 checksums in `ShadowCode_VERSION_flatpak.sha256` rather than publisher signatures; the -macOS disk images likewise (`release-macos.yml`, `ShadowCode_VERSION_macos.sha256`). +macOS disk images likewise (`release-macos.yml`, `ShadowCode_VERSION_macos.sha256`), +and from 2.3.0 the Windows installer with the complete source of its bundled +busybox-w32 (`release-windows.yml`, `ShadowCode_VERSION_windows.sha256`). Releases before 2.1.0 have no aarch64 packages; build them from source (below) on an aarch64 machine. Cross-building is not supported: each architecture is built diff --git a/docs/FLATPAK.md b/docs/FLATPAK.md index 7d4da92b..7e214b85 100644 --- a/docs/FLATPAK.md +++ b/docs/FLATPAK.md @@ -16,12 +16,12 @@ Download the bundle for your computer and the checksum file from the release, then: ```sh -sha256sum --check --ignore-missing ShadowCode_2.2.0_flatpak.sha256 -flatpak install --user ./ShadowCode_2.2.0_x86_64.flatpak +sha256sum --check --ignore-missing ShadowCode_2.3.0_flatpak.sha256 +flatpak install --user ./ShadowCode_2.3.0_x86_64.flatpak flatpak run com.shadowfetch.shadowcode ``` -On 64-bit ARM, install `ShadowCode_2.2.0_aarch64.flatpak` instead. +On 64-bit ARM, install `ShadowCode_2.3.0_aarch64.flatpak` instead. The bundle points to Flathub for the GNOME runtime. Installation exports the application launcher and icons into the desktop's Applications menu. diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md index 6fda0c88..e8691838 100644 --- a/docs/RELEASE_NOTES.md +++ b/docs/RELEASE_NOTES.md @@ -1,3 +1,51 @@ +# ShadowCode 2.3.0 + +ShadowCode 2.3.0 runs on Windows. The 2.2.0 notes follow below. + +## New + +- **ShadowCode for Windows.** `ShadowCode_2.3.0_x64-setup.exe` for 64-bit + Windows 10 (version 1809 or later) and 11 installs for your user, without + administrator rights. Local models run on llama.cpp (CPU), terminals open + PowerShell, and subscription tools such as Codex and Claude Code run + through their Windows command-line programs. Install or update with one PowerShell command (see + the [README](../README.md#windows)); a copy downloaded in a browser shows + **Windows protected your PC** the first time: choose **More info**, then + **Run anyway**. +- **A sandbox for agent commands on Windows.** Commands run in a contained + shell that can change only the project, sees Windows, Program Files and + your toolchains read-only, cannot see the rest of your home folder, and + has no network unless you turn it on. Commands keep their usual `sh` + syntax. **Only allowed hosts** is not available on Windows. + +## Fixed + +- Two parts of ShadowCode saving settings at the same moment no longer fail + on Windows with "Access is denied", and busy databases no longer time out + with "database is locked". + +## Known limits + +- The Windows version is new and has been tested on GitHub's Windows + machines, not yet on a wide range of PCs. Subscription tools were tested + there with stand-in programs, not yet with each vendor's own Windows CLI. + Local models use the CPU only on Windows for now. + +## Download + +Linux x86_64: `ShadowCode_2.3.0_amd64.AppImage`, `ShadowCode_2.3.0_amd64.deb` +and `ShadowCode_2.3.0_appimage-runtime-sources.tar.gz`, with `SHA256SUMS`, +`RELEASE-MANIFEST.json`, `RELEASE-AUTH` and `RELEASE-AUTH.sig`. For ARM64 the +packages end in `_aarch64` (AppImage) or `_arm64` (`.deb`), with signature +files ending in `-aarch64`. Install the AppImage with the authenticated +installer described in the [README](../README.md#install); the signature is +checked before anything runs. The Flatpak bundles, the macOS disk images and +the Windows installer are attached shortly after publication, with their +SHA-256 checksums in `ShadowCode_2.3.0_flatpak.sha256`, +`ShadowCode_2.3.0_macos.sha256` and `ShadowCode_2.3.0_windows.sha256`; the +complete source of the busybox-w32 shell bundled for Windows is +`ShadowCode_2.3.0_busybox-w32-source.tar.gz`. + # ShadowCode 2.2.0 ShadowCode 2.2.0 runs on Macs, both Apple silicon and Intel, and its Linux diff --git a/docs/RELEASING.md b/docs/RELEASING.md index e38683ab..44651b98 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -7,7 +7,7 @@ and authentication receipts to the exact candidate before publication. Pushing a `v*` tag starts `.github/workflows/release.yml`. Publication follows build/qualification, protected signing review and verification of all seven public assets. The signing environment permits only the tag being released -(`v2.2.0` for 2.2.0); every later version needs reviewed trust-policy, +(`v2.3.0` for 2.3.0); every later version needs reviewed trust-policy, deployment-rule and tooling-pin updates. The workflow uses `docs/RELEASE_NOTES.md` as the release text. @@ -185,15 +185,15 @@ verifies and indexes both `.deb` files. The pinned tooling commit must include this multi-target signer and publisher; an older pin refuses the aarch64 signing call, so the release fails rather than shipping x86_64 alone. -Public trust in `release/trust` currently allows the x86_64 and aarch64 targets, epoch 1 and versions 0.33.0–2.2.0 +Public trust in `release/trust` currently allows the x86_64 and aarch64 targets, epoch 1 and versions 0.33.0–2.3.0 (raise the key's maximum version in the release commit, as for earlier releases); `release/install-policy` fixes the first authenticated version at 0.33.0. The `release-signing` environment has the signing secret and requires review by `Shadowfetchapps` (User ID `209457103`). Self-review is allowed and GitHub's -default administrator override remains unchanged. For 2.2.0 its sole -deployment rule is the tag `v2.2.0` (it was `v2.1.0` for 2.1.0, `v1.0.1` for +default administrator override remains unchanged. For 2.3.0 its sole +deployment rule is the tag `v2.3.0` (it was `v2.2.0` for 2.2.0, `v2.1.0` for 2.1.0, `v1.0.1` for 1.0.1, `v1.0.0` for 1.0.0, `v0.34.2` for 0.34.2 and `v0.33.1` for 0.33.1); -`NATIVE_RELEASE_SIGNING_ENVIRONMENT` points to that environment. For 2.2.0, +`NATIVE_RELEASE_SIGNING_ENVIRONMENT` points to that environment. For 2.3.0, `NATIVE_RELEASE_TOOLING_COMMIT` pins the tagged release commit itself, as it did for 0.33.1 (`e15c4480e65db5650af012bb2a9773dbe89acf84`). diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 272fc3d8..6b6c3419 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -1,6 +1,6 @@ # ShadowCode user guide -This guide describes the 2.2.0 workflow: open a +This guide describes the 2.3.0 workflow: open a project, pick a model, describe the task, watch the agent work, then review what changed. Installation and a feature overview are in the [README](../README.md). diff --git a/docs/WINDOWS_PORT.md b/docs/WINDOWS_PORT.md new file mode 100644 index 00000000..621ebd7f --- /dev/null +++ b/docs/WINDOWS_PORT.md @@ -0,0 +1,104 @@ +# Windows port + +Status: phase 1 (compile on Windows) done October 17, 2026; phase 2 +(engine tests) started. Linux and +macOS ship today; Windows follows the path the macOS port took +([MACOS_PORT_ESTIMATE.md](MACOS_PORT_ESTIMATE.md)): compile, then make the +engine's tests pass, then the app, then packaging. Work happens on GitHub's +Windows runners (`.github/workflows/windows.yml`); no Windows computer is +needed for phases 1 and 2. + +## Phases + +1. **Compile.** The workspace builds on Windows (x86_64, MSVC). Unix-only + code is gated `cfg(unix)` or given a Windows path. +2. **Engine tests.** Unit and integration tests run on the Windows runner; + Unix-only tests are ported or gated with a reason. +3. **App.** The window, terminals, local models and the agent's tools work on + Windows; checked on a Windows virtual machine. +4. **Packaging.** An installer (`.msi` or `.exe`) built by a release workflow + and attached to each release like the macOS images and the Flatpak. + +## Design decisions + +| Area | Linux and macOS | Windows | +|---|---|---| +| Agent shell commands | `/bin/sh -c` | Git Bash (`bash -c`) when Git for Windows is installed, so the same commands work; otherwise Windows PowerShell. Never WSL's `bash.exe` in System32 | +| Shell sandbox | bubblewrap, Landlock, Seatbelt | An AppContainer: the project and a scratch folder writable, Windows and Program Files readable, read-only toolchain and PATH folders from the home folder, the rest of the home hidden; network off (no capabilities, loopback included) or on (internet and private-network capabilities). The allow-list is refused: the container cannot reach its proxy on this computer. Started through ShadowCode's own executable as a launcher (`--shadowcode-sandbox`). Inside it commands run in busybox-w32's `sh` (POSIX syntax), built from pinned source and bundled: Git for Windows' bash (MSYS2) cannot start in an AppContainer, and PowerShell cannot enter a project whose parent folders the container may not list. Without the bundled shell the sandbox is unavailable (refused under Require sandbox, else unsandboxed with a warning) | +| Process trees | process groups, signals, `/proc`, kqueue | Job objects: every command and helper runs in a job that is killed as a whole, and closes with ShadowCode | +| Terminals | PTY | ConPTY | +| Engine connection | Unix socket | Named pipe restricted to the user | +| API keys | Secret Service, Keychain | Windows Credential Manager | +| Settings and history | XDG folders, `~/Library` | Settings in `%APPDATA%\ShadowCode\config` (they roam with the account); history, models and caches in `%LOCALAPPDATA%\ShadowCode` | +| Local models | Vulkan, Metal | Vulkan and CPU builds of llama.cpp | +| Installer and updates | AppImage, deb, Flatpak, dmg | `.msi`/`.exe` from Tauri; the update notice names it | + +## Progress + +- Phase 1 (done): the whole workspace — engine, desktop app and every + test target — compiles on Windows with warnings as errors. The Windows CI + job reports every compile error at once (`--keep-going`) with the full log + as an artifact. + - The engine connection is a named pipe (`control/transport.rs`) that + refuses remote clients, allows only this user's SID, and is checked by + the client to be served by this user before anything is sent. + - Folder syncs after a rename (`paths::sync_directory`) are skipped on + Windows, which cannot open a folder as a file. + - File opens never follow a final symlink on Windows either. + - Not yet available on Windows at the time, each with a clear error: + subscription CLI backends (the vendor runner), terminals, removing + isolated checkouts, and linking exported rules. + - Integration tests built on Unix sockets, FIFOs, symlinks, file modes or + the fake vendor CLIs (shell scripts) are Unix-only for now. +- Phase 2 (done October 18): the engine's unit tests (596; 554 of 605 on + the first run) and integration tests (90 suites) pass on Windows and are + required. Fixed along the way: + - Randomness for remote-access tokens and the local server key comes + from the system (getrandom), not `/dev/urandom`. + - Canonical paths have no `\\?\` prefix (`paths::Canonical`, dunce), and + project paths shown to the agent and in approvals use `/` + (`paths::slash`); with `\`, deleting `.git\objects` was not recognized + as unrecoverable. + - Files are flushed and re-timed through a handle that may write, and + folders are opened with backup semantics: Windows refuses otherwise + ("Access is denied"). + - Commands and worktree setup scripts run in a job object, so cancelling, + a timeout or closing ShadowCode ends everything they started. + - Installed and free memory come from `GlobalMemoryStatusEx`; model file + identity uses the volume serial, file index and change time. + - Claude skill files are copied, not linked, beside a staged `SKILL.md`. + - Managed worktree checkouts are removed with the same careful cleanup: + identities are the volume serial and file index read from open handles, + folders are never opened through links or junctions, and the exclusive + rename is `SetFileInformationByHandle` without replacement. Git writes + and lists its own paths with `/`, which the checks expect. + - Background processes use the Windows shell, not `/bin/sh`. + - Project paths in checkpoints, scope checks ("Only change these"), Git + requests and folder listings use `/`; with `\`, an edit inside the + chosen folder counted as outside it, and "Ignore this file" would have + written an escape into `.gitignore`. +- Phase 3 (done October 18, CI run 39163828940): the rest of the engine + runs on Windows, and all 607 unit tests and 92 integration suites pass. + - The vendor runner (subscription CLIs) watches its pipes with + `PeekNamedPipe` and keeps each CLI and its children in a job object. + - Terminals are pseudoconsoles (ConPTY) running PowerShell. + - Local models: llama.cpp is built for Windows (MSVC, CPU; Vulkan when + its SDK is present) and installed beside the app. + - The SQLite inspector opens project databases without following links. + - The shell sandbox is an AppContainer running busybox-w32's `sh` (see + the table above). The container may not open the null device, so the + launcher opens it and busybox's `/dev/null` uses that handle (patched + in `tools/busybox-w32-patches`). + - Settings files replaced at the same moment by two writers are retried + (Windows refuses the second replacement), and this process's writers to + one database queue for its write lock instead of polling it: on Windows + a polling writer could time out with "database is locked". +- Still not on Windows: the network allow-list (refused, see above) and + linking exported rules. Tests that need shell or Python fixtures, Unix + file modes, signals or file names stay Unix-only (each says why). +- Preview installer: the Windows job also builds an unsigned NSIS + installer (per-user) with the llama.cpp runtime and the sandbox shell, + checks that the app reports its version, and checks that it keeps its + data in `%LOCALAPPDATA%` and `%APPDATA%`. It is a 14-day CI artifact, not + a release. +- Not yet tried: the app on a real Windows PC (only CI runners so far). diff --git a/integrations/github-action/README.md b/integrations/github-action/README.md index 60767b47..94964961 100644 --- a/integrations/github-action/README.md +++ b/integrations/github-action/README.md @@ -50,7 +50,7 @@ command line runs directly. | `github-token` | `github.token` | Used only for the comment or pull request, after ShadowCode finished. | | `issue-number` | the event's issue or pull request | Where to comment. | | `base` | the checked-out branch | Target branch of the pull request. | -| `version` | `2.2.0` | ShadowCode release to run. `approval: approve` needs the release that ships this action or later. | +| `version` | `2.3.0` | ShadowCode release to run. `approval: approve` needs the release that ships this action or later. | | `appimage-sha256` | empty | Optional extra pin for the AppImage's SHA-256. | | `working-directory` | the workspace | Project folder. | diff --git a/integrations/github-action/action.yml b/integrations/github-action/action.yml index 91277b90..61d9a782 100644 --- a/integrations/github-action/action.yml +++ b/integrations/github-action/action.yml @@ -81,7 +81,7 @@ inputs: version: description: ShadowCode release to download (without the leading v). required: false - default: 2.2.0 + default: 2.3.0 appimage-sha256: description: >- Optional SHA-256 of the AppImage. When set it must match as well as the diff --git a/integrations/github-action/examples/issue-comment.yml b/integrations/github-action/examples/issue-comment.yml index 898659bd..f0a686de 100644 --- a/integrations/github-action/examples/issue-comment.yml +++ b/integrations/github-action/examples/issue-comment.yml @@ -39,7 +39,7 @@ jobs: persist-credentials: false # Pin to a release tag or, better, a full commit SHA. - - uses: ShadowfetchStudio/ShadowCode/integrations/github-action@v2.2.0 + - uses: ShadowfetchStudio/ShadowCode/integrations/github-action@v2.3.0 with: task: ${{ github.event.comment.body }} api-key: ${{ secrets.OPENROUTER_API_KEY }} diff --git a/integrations/github-action/examples/pr-label.yml b/integrations/github-action/examples/pr-label.yml index 77c02996..bd131db9 100644 --- a/integrations/github-action/examples/pr-label.yml +++ b/integrations/github-action/examples/pr-label.yml @@ -35,7 +35,7 @@ jobs: persist-credentials: false fetch-depth: 0 - - uses: ShadowfetchStudio/ShadowCode/integrations/github-action@v2.2.0 + - uses: ShadowfetchStudio/ShadowCode/integrations/github-action@v2.3.0 with: task: >- Review the changes on this branch against diff --git a/native/core/Cargo.toml b/native/core/Cargo.toml index 6260988f..33e6f37d 100644 --- a/native/core/Cargo.toml +++ b/native/core/Cargo.toml @@ -5,6 +5,14 @@ edition.workspace = true rust-version.workspace = true license.workspace = true +# The Windows sandbox launcher for the engine's own tests (the app is its +# own launcher); elsewhere it only says so. +[[bin]] +name = "shadowcode-sandbox" +path = "src/bin/shadowcode-sandbox.rs" +test = false +doc = false + [dependencies] anyhow = "1" serde = { version = "1", features = ["derive"] } @@ -12,6 +20,10 @@ serde_json = "1" serde_yaml_ng = "0.10" rusqlite = { version = "0.40.2", default-features = false, features = ["bundled", "backup", "cache", "hooks", "limits", "fallible_uint"] } uuid = { version = "1", features = ["v4", "serde"] } +# Kernel randomness on every system (already used through uuid). +getrandom = "0.4" +# Canonical paths without Windows' verbatim prefix (see paths::Canonical). +dunce = "1" tokio = { version = "1", features = ["full"] } tokio-util = { version = "0.7", features = ["rt"] } reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] } @@ -94,6 +106,13 @@ 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 } +[target.'cfg(windows)'.dependencies] +# Voice input on Windows: the same cpal, through WASAPI. +cpal = { version = "=0.18.2", default-features = false } +# The control channel's named pipe (its access rules and peer identity), the +# memory figures and file identities for local models. +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Security_Isolation", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_System_JobObjects", "Win32_System_Pipes", "Win32_System_StationsAndDesktops", "Win32_System_SystemInformation", "Win32_System_Threading"] } + [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/acp_server/mod.rs b/native/core/src/acp_server/mod.rs index f4f1d9c0..5cf267c1 100644 --- a/native/core/src/acp_server/mod.rs +++ b/native/core/src/acp_server/mod.rs @@ -861,8 +861,7 @@ impl Agent { .and_then(|u| u.to_file_path().ok()); let label = local .as_ref() - .and_then(|p| workspace.relative(&p.to_string_lossy()).ok()) - .map(|p| p.display().to_string()); + .and_then(|p| workspace.relative_text(&p.to_string_lossy()).ok()); // An editor that reads files can hand over an unsaved buffer. let buffer = match &local { Some(path) if self.client_reads_files() => self @@ -897,8 +896,7 @@ impl Agent { .ok() .filter(|u| u.scheme() == "file") .and_then(|u| u.to_file_path().ok()) - .and_then(|p| workspace.relative(&p.to_string_lossy()).ok()) - .map(|p| p.display().to_string()) + .and_then(|p| workspace.relative_text(&p.to_string_lossy()).ok()) .unwrap_or_else(|| uri.to_owned()); if let Some(body) = resource["text"].as_str() { embed(&mut context, &label, body); diff --git a/native/core/src/acp_server/translate.rs b/native/core/src/acp_server/translate.rs index 600c85eb..8c39f8c1 100644 --- a/native/core/src/acp_server/translate.rs +++ b/native/core/src/acp_server/translate.rs @@ -95,12 +95,19 @@ pub(crate) fn tool_title(tool: &str, args: &Value) -> String { } } +/// The editor wants an absolute path in the system's own form; collecting +/// the parts turns `src/a.rs` under `C:\w` into `C:\w\src\a.rs` on Windows. fn absolute(workspace: &Path, path: &str) -> String { let path = Path::new(path); if path.is_absolute() { path.display().to_string() } else { - workspace.join(path).display().to_string() + workspace + .join(path) + .components() + .collect::() + .display() + .to_string() } } @@ -400,6 +407,8 @@ impl Translator { } #[cfg(test)] +// Helpers of Unix-only tests are unused on Windows. +#[cfg_attr(not(unix), allow(unused))] mod tests { use super::*; @@ -442,7 +451,10 @@ mod tests { )); assert_eq!(started[0]["sessionUpdate"], "tool_call"); assert_eq!(started[0]["kind"], "edit"); + #[cfg(unix)] assert_eq!(started[0]["locations"][0]["path"], "/w/src/a.rs"); + #[cfg(windows)] + assert_eq!(started[0]["locations"][0]["path"], "\\w\\src\\a.rs"); assert_eq!(started[0]["content"][0]["type"], "diff"); assert_eq!(started[0]["content"][0]["oldText"], "a"); assert_eq!(t.open_call("edit_file").unwrap().id, "c1"); diff --git a/native/core/src/applog.rs b/native/core/src/applog.rs index 95c139e0..12ed59d3 100644 --- a/native/core/src/applog.rs +++ b/native/core/src/applog.rs @@ -57,7 +57,9 @@ pub fn init(paths: &AppPaths, level: &str) -> std::io::Result { dir: dir.clone(), file: None, size: 0, - home: std::env::var("HOME").ok().filter(|h| h.len() > 1), + home: crate::paths::home_dir() + .and_then(|h| h.to_str().map(str::to_owned)) + .filter(|h| h.len() > 1), }); } } diff --git a/native/core/src/approvals/assess.rs b/native/core/src/approvals/assess.rs index 8f0fc46d..821171fc 100644 --- a/native/core/src/approvals/assess.rs +++ b/native/core/src/approvals/assess.rs @@ -201,7 +201,7 @@ impl Ctx<'_> { let normal = normalize(&absolute); match normal.strip_prefix(self.root) { Ok(rel) => { - let mut rel = rel.to_string_lossy().into_owned(); + let mut rel = crate::paths::slash(rel); if text.ends_with('/') && !rel.is_empty() { rel.push('/'); } @@ -5402,6 +5402,8 @@ fn vendor_files(args: &Value) -> Vec { } #[cfg(test)] +// Helpers of Unix-only tests are unused on Windows. +#[cfg_attr(not(unix), allow(unused))] mod tests { use super::*; @@ -6471,6 +6473,7 @@ mod tests { ); } + #[cfg(unix)] #[test] fn vendor_rules_cover_only_commands_in_the_project() { let root = Path::new(ROOT); diff --git a/native/core/src/approvals/preview.rs b/native/core/src/approvals/preview.rs index f4fc96d7..4bd4d5d5 100644 --- a/native/core/src/approvals/preview.rs +++ b/native/core/src/approvals/preview.rs @@ -246,8 +246,7 @@ fn vendor_changes(root: &Path, kind: &str, args: &Value) -> Option { }; let shown = |path: &str| { workspace - .relative(path) - .map(|p| p.to_string_lossy().into_owned()) + .relative_text(path) .unwrap_or_else(|_| path.to_owned()) }; // Claude Code: Write / Edit / MultiEdit inputs. @@ -351,6 +350,8 @@ fn vendor_changes(root: &Path, kind: &str, args: &Value) -> Option { } #[cfg(test)] +// Helpers of Unix-only tests are unused on Windows. +#[cfg_attr(not(unix), allow(unused))] mod tests { use super::*; @@ -428,6 +429,7 @@ mod tests { .is_null()); } + #[cfg(unix)] #[test] fn commands_show_the_full_command_and_folder() { let (_dir, ws) = project(); diff --git a/native/core/src/background.rs b/native/core/src/background.rs index a2178972..54d133c6 100644 --- a/native/core/src/background.rs +++ b/native/core/src/background.rs @@ -514,13 +514,18 @@ mod removal_tests { "printf \"$PORT $SHADOWCODE_PORT\" > port.txt", ) .unwrap(); - tokio::time::timeout(Duration::from_secs(10), async { - while manager.get(&process.id).unwrap().status != "COMPLETED" { + let ended = tokio::time::timeout(Duration::from_secs(10), async { + loop { + let task = manager.get(&process.id).unwrap(); + if task.status != "RUNNING" && task.status != "STARTING" { + return task; + } tokio::time::sleep(Duration::from_millis(20)).await; } }) .await .expect("the process did not finish"); + assert_eq!(ended.status, "COMPLETED", "{ended:?}"); assert_eq!( std::fs::read_to_string(project.join("port.txt")).unwrap(), "4555 4555" diff --git a/native/core/src/bin/shadowcode-sandbox.rs b/native/core/src/bin/shadowcode-sandbox.rs new file mode 100644 index 00000000..e359a265 --- /dev/null +++ b/native/core/src/bin/shadowcode-sandbox.rs @@ -0,0 +1,23 @@ +//! The Windows sandbox launcher on its own, for the engine's tests (the app +//! runs its own executable with the launch flag instead). +fn main() { + #[cfg(windows)] + { + let mut args = std::env::args_os().skip(1).peekable(); + if args.peek().map(|a| a.as_os_str()) + == Some(std::ffi::OsStr::new( + shadowcode_core::sandbox::appcontainer::LAUNCH_FLAG, + )) + { + args.next(); + } + std::process::exit(shadowcode_core::sandbox::appcontainer::launch_main( + args.collect(), + )); + } + #[cfg(not(windows))] + { + eprintln!("The ShadowCode sandbox launcher is for Windows."); + std::process::exit(2); + } +} diff --git a/native/core/src/checkpoint.rs b/native/core/src/checkpoint.rs index 546c83e1..3a23a4a5 100644 --- a/native/core/src/checkpoint.rs +++ b/native/core/src/checkpoint.rs @@ -68,7 +68,7 @@ pub fn record_external( before_mode: Option, current_hash: Option<&str>, ) -> Result<()> { - let path = workspace.relative(path)?.to_string_lossy().into_owned(); + let path = workspace.relative_text(path)?; let before_hash = before.map(hash).unwrap_or_else(|| "missing".into()); store.execute("INSERT INTO file_changes(task_id,workspace,path,before_bytes,before_mode,after_hash,observed_hash) VALUES(?,?,?,?,?,?,?) ON CONFLICT(task_id,workspace,path) DO UPDATE SET after_hash=excluded.after_hash,observed_hash=excluded.observed_hash,restored=0", @@ -86,7 +86,7 @@ pub fn record( before: &Snapshot, after: Option<&[u8]>, ) -> Result<()> { - let path = workspace.relative(path)?.to_string_lossy().into_owned(); + let path = workspace.relative_text(path)?; let after_hash = after.map(hash).unwrap_or_else(|| "missing".into()); store.execute("INSERT INTO file_changes(task_id,workspace,path,before_bytes,before_mode,after_hash,observed_hash) VALUES(?,?,?,?,?,?,?) ON CONFLICT(task_id,workspace,path) DO UPDATE SET after_hash=excluded.after_hash,observed_hash=excluded.observed_hash,restored=0", diff --git a/native/core/src/checkpoint/turn_edits.rs b/native/core/src/checkpoint/turn_edits.rs index 0aeecc43..ee21ed21 100644 --- a/native/core/src/checkpoint/turn_edits.rs +++ b/native/core/src/checkpoint/turn_edits.rs @@ -50,10 +50,7 @@ fn vendor_reported(store: &Store, ws: &Workspace, task: &str) -> Result { writeln!(std::io::stdout(), $($arg)*).context("Could not write terminal output")? }; } @@ -96,9 +97,11 @@ impl Options { fn expand(path: &Path) -> Result { let text = path.to_string_lossy(); Ok(if text == "~" { - PathBuf::from(std::env::var_os("HOME").context("HOME is not set")?) + crate::paths::home_dir().context("HOME is not set")? } else if let Some(rest) = text.strip_prefix("~/") { - PathBuf::from(std::env::var_os("HOME").context("HOME is not set")?).join(rest) + crate::paths::home_dir() + .context("HOME is not set")? + .join(rest) } else { path.into() }) @@ -359,7 +362,7 @@ async fn acp( if let Some(profile) = &options.profile { // The profile may not exist yet; the agent creates it on start. let profile = expand(profile)?; - let profile = match profile.canonicalize() { + let profile = match profile.canonical() { Ok(path) => path, Err(_) => std::path::absolute(&profile)?, }; @@ -385,7 +388,7 @@ async fn acp( options.workspace.as_deref().map(expand).transpose()?, std::env::current_dir().ok(), paths.remembered_workspace(), - std::env::var_os("HOME").map(PathBuf::from), + crate::paths::home_dir(), ] .into_iter() .flatten() @@ -521,7 +524,7 @@ pub async fn run(options: Options) -> Result { args.extend([ "--profile".into(), expand(profile)? - .canonicalize()? + .canonical()? .to_str() .context("MCP profile path must be UTF-8")? .to_owned(), @@ -1330,8 +1333,12 @@ async fn execute(backend: &Backend, workspace: &Path, options: &Options) -> Resu let mut body = json!({"workspace":workspace,"name":name}); if let Some(path) = file { use std::io::Read; - use std::os::unix::fs::OpenOptionsExt; - let input = std::fs::OpenOptions::new().read(true).custom_flags(libc::O_NONBLOCK).open(path).context("Cannot open plugin bundle")?; + let mut options = std::fs::OpenOptions::new(); + options.read(true); + // A FIFO must not hang the open; the file check follows. + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::custom_flags(&mut options, libc::O_NONBLOCK); + let input = options.open(path).context("Cannot open plugin bundle")?; ensure!(input.metadata()?.is_file(), "Plugin bundle must be a regular file"); let mut text = String::new(); input.take((crate::plugins::MAX_BYTES + 1) as u64).read_to_string(&mut text)?; diff --git a/native/core/src/cli/watch.rs b/native/core/src/cli/watch.rs index 3489d87d..9dd5e70f 100644 --- a/native/core/src/cli/watch.rs +++ b/native/core/src/cli/watch.rs @@ -19,6 +19,7 @@ pub fn plain(text: &str) -> String { .collect() } pub use crate::lifecycle::interrupted; +#[cfg(unix)] async fn line() -> Result { ensure!( std::io::stdin().is_terminal(), @@ -68,6 +69,23 @@ async fn line() -> Result { } } } +/// Windows consoles have no nonblocking read: a detached thread reads the +/// line, so dropping this future (Ctrl-C) never holds up runtime shutdown. +#[cfg(not(unix))] +async fn line() -> Result { + ensure!( + std::io::stdin().is_terminal(), + "Interactive approval requires a terminal on stdin" + ); + let (sender, receiver) = tokio::sync::oneshot::channel(); + std::thread::spawn(move || { + let mut text = String::new(); + let _ = sender.send(std::io::stdin().read_line(&mut text).map(|_| text)); + }); + let text = receiver.await.context("Terminal input closed")??; + ensure!(text.len() < 8000, "Approval response is too long"); + Ok(text.trim().into()) +} async fn approve(backend: &Backend, approval: &Value) -> Result<()> { errln!( "\nApproval required: {}\n{}\n{}", diff --git a/native/core/src/cli_agent/antigravity_server.rs b/native/core/src/cli_agent/antigravity_server.rs index 5ba28211..198dd2ca 100644 --- a/native/core/src/cli_agent/antigravity_server.rs +++ b/native/core/src/cli_agent/antigravity_server.rs @@ -74,7 +74,7 @@ pub fn home() -> PathBuf { let data = std::env::var_os("XDG_DATA_HOME") .filter(|v| !v.is_empty()) .map(PathBuf::from) - .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share"))) + .or_else(|| crate::paths::home_dir().map(|h| h.join(".local/share"))) .unwrap_or_else(|| PathBuf::from(".")); data.join("shadowcode").join("antigravity-acp") } @@ -468,6 +468,7 @@ pub fn unzip(archive: &Path, into: &Path) -> Result> { let name_len = u16_at(&cd, at + 28) as usize; let extra_len = u16_at(&cd, at + 30) as usize; let comment_len = u16_at(&cd, at + 32) as usize; + #[cfg_attr(not(unix), allow(unused_variables))] let external = u32_at(&cd, at + 38); let local = u32_at(&cd, at + 42); let name = String::from_utf8(cd[at + 46..at + 46 + name_len].to_vec())?; diff --git a/native/core/src/cli_agent/catalog.rs b/native/core/src/cli_agent/catalog.rs index 06383c04..a84ee381 100644 --- a/native/core/src/cli_agent/catalog.rs +++ b/native/core/src/cli_agent/catalog.rs @@ -1372,6 +1372,8 @@ mod tests { } } + // The fake vendor CLI is a script started by its #! line. + #[cfg(unix)] #[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/claude_probe.rs b/native/core/src/cli_agent/claude_probe.rs index cf55ae6e..8047d804 100644 --- a/native/core/src/cli_agent/claude_probe.rs +++ b/native/core/src/cli_agent/claude_probe.rs @@ -93,6 +93,7 @@ fn sanitized(binary: &Path, path_env: Option<&OsStr>, cwd: &Path) -> Command { .stderr(Stdio::null()) .kill_on_drop(true) .env_clear() + .envs(crate::process::platform_env()) .env("NO_COLOR", "1") .env("TERM", "dumb"); for name in [ diff --git a/native/core/src/cli_agent/codex_probe.rs b/native/core/src/cli_agent/codex_probe.rs index 8d667edd..c5ae46d0 100644 --- a/native/core/src/cli_agent/codex_probe.rs +++ b/native/core/src/cli_agent/codex_probe.rs @@ -71,6 +71,7 @@ fn sanitized(binary: &Path, path_env: Option<&OsStr>) -> Command { .stderr(Stdio::null()) .kill_on_drop(true) .env_clear() + .envs(crate::process::platform_env()) .env("NO_COLOR", "1") .env("TERM", "dumb"); for name in [ diff --git a/native/core/src/cli_agent/doctor.rs b/native/core/src/cli_agent/doctor.rs index c65b5bfb..599bb20b 100644 --- a/native/core/src/cli_agent/doctor.rs +++ b/native/core/src/cli_agent/doctor.rs @@ -56,6 +56,7 @@ fn sanitized_command( .stderr(Stdio::piped()) .kill_on_drop(true) .env_clear() + .envs(crate::process::platform_env()) .env("NO_COLOR", "1") .env("TERM", "dumb"); for name in [ @@ -405,18 +406,14 @@ fn resolve_with_path(configured: &str, path_env: Option<&OsStr>) -> Option 1 { return candidate.is_file().then(|| candidate.to_path_buf()); } - std::env::split_paths(path) - .map(|dir| dir.join(configured)) - .find(|p| p.is_file()) + crate::paths::find_program_in(configured, path) } } } /// All three vendor checks for the running user. pub async fn checks(config: &CliAgentsConfig) -> Vec { - let home = std::env::var_os("HOME") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("/nonexistent")); + let home = crate::paths::home_dir().unwrap_or_else(|| PathBuf::from("/nonexistent")); let mut out = Vec::new(); for vendor in Vendor::ALL { out.push(check_vendor(vendor, config, &home, None).await); diff --git a/native/core/src/cli_agent/lines.rs b/native/core/src/cli_agent/lines.rs index 0bf4db66..0a6bef29 100644 --- a/native/core/src/cli_agent/lines.rs +++ b/native/core/src/cli_agent/lines.rs @@ -44,6 +44,7 @@ impl BoundedLines { /// An approval decision cannot treat consumed but unfinished protocol /// bytes as an empty stream. Keep waiting within the caller's deadline. + #[cfg_attr(not(unix), allow(dead_code))] pub(super) fn has_partial_frame(&self) -> bool { !self.pending.is_empty() || self.discarding } diff --git a/native/core/src/cli_agent/mod.rs b/native/core/src/cli_agent/mod.rs index 4f9f6bb4..c8323c38 100644 --- a/native/core/src/cli_agent/mod.rs +++ b/native/core/src/cli_agent/mod.rs @@ -36,7 +36,6 @@ pub mod handoff; mod lines; pub mod picker; mod probe_lifecycle; -#[cfg(unix)] pub mod runner; pub mod usage; @@ -705,9 +704,7 @@ pub fn resolve_binary(configured: &str) -> Option { return candidate.is_file().then(|| candidate.to_path_buf()); } let path = std::env::var_os("PATH")?; - std::env::split_paths(&path) - .map(|dir| dir.join(configured)) - .find(|p| p.is_file()) + crate::paths::find_program_in(configured, &path) } /// Truncate vendor output before it enters the transcript or store. diff --git a/native/core/src/cli_agent/runner.rs b/native/core/src/cli_agent/runner.rs index 56663e8b..af849912 100644 --- a/native/core/src/cli_agent/runner.rs +++ b/native/core/src/cli_agent/runner.rs @@ -89,9 +89,39 @@ fn pipe_has_readable_event(fd: RawFd) -> Result { Ok(poll.revents != 0) } -#[cfg(not(unix))] -fn pipe_has_readable_event(_: ()) -> Result { - bail!("Vendor approval reconciliation cannot verify pipe readiness on this platform") +/// Windows: the pipe's handle, as a number so the run stays `Send`. +#[cfg(windows)] +type RawFd = usize; + +/// See the Unix version: bytes waiting (or the writer gone, which reading +/// will report) count as an event; nothing is consumed. +#[cfg(windows)] +fn pipe_has_readable_event(handle: RawFd) -> Result { + use windows_sys::Win32::{Foundation::HANDLE, System::Pipes::PeekNamedPipe}; + /// ERROR_BROKEN_PIPE: the vendor closed its end. + const BROKEN_PIPE: i32 = 109; + let mut available = 0u32; + // SAFETY: a size query only (no buffer); the reader owns the handle for + // the whole run. + let peeked = unsafe { + PeekNamedPipe( + handle as HANDLE, + std::ptr::null_mut(), + 0, + std::ptr::null_mut(), + &mut available, + std::ptr::null_mut(), + ) + }; + if peeked == 0 { + let error = std::io::Error::last_os_error(); + if error.raw_os_error() == Some(BROKEN_PIPE) { + return Ok(true); + } + return Err(anyhow::Error::from(error) + .context("Vendor stdout closed during approval reconciliation")); + } + Ok(available > 0) } /// A resource/deadline failure must never be mistaken for an unsupported @@ -179,8 +209,14 @@ fn append_assistant_text(collected: &mut String, text: &str) -> Result<()> { Ok(()) } +#[cfg(unix)] struct ProcessGroup(u32); +#[cfg(unix)] impl ProcessGroup { + fn new(child: &Child, pid: u32) -> Self { + let _ = child; + Self(pid) + } fn terminate(&self) { if self.0 == 0 { return; @@ -209,6 +245,31 @@ impl ProcessGroup { self.0 = 0; } } +/// Windows: the vendor and everything it starts run in one job; ending the +/// job ends them all, and the job closes with ShadowCode. Windows has no +/// polite stop for a console process tree, so `terminate` ends it too. +#[cfg(windows)] +struct ProcessGroup(Option); +#[cfg(windows)] +impl ProcessGroup { + fn new(child: &Child, _pid: u32) -> Self { + Self( + child + .raw_handle() + .and_then(|handle| crate::process::Job::for_process(handle).ok()), + ) + } + fn terminate(&self) { + if let Some(job) = &self.0 { + job.terminate(); + } + } + fn kill(&mut self) { + if let Some(job) = self.0.take() { + job.terminate(); + } + } +} impl Drop for ProcessGroup { fn drop(&mut self) { self.kill(); @@ -415,13 +476,13 @@ async fn run_once( let (mut child, _run_dir) = spawn_vendor(vendor, &program, &args, &env, &request.options.workspace)?; let pid = child.id().context("Vendor CLI has no process ID")?; - let mut group = ProcessGroup(pid); + let mut group = ProcessGroup::new(&child, pid); let mut stdin = Some(child.stdin.take().context("Vendor CLI stdin missing")?); let stdout = child.stdout.take().context("Vendor CLI stdout missing")?; #[cfg(unix)] let stdout_fd = stdout.as_raw_fd(); - #[cfg(not(unix))] - let stdout_fd = (); + #[cfg(windows)] + let stdout_fd = std::os::windows::io::AsRawHandle::as_raw_handle(&stdout) as RawFd; let stderr = child.stderr.take().context("Vendor CLI stderr missing")?; let mut reader = BoundedLines::new(stdout, MAX_LINE_BYTES); let sign_in_needed = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); diff --git a/native/core/src/compare.rs b/native/core/src/compare.rs index e9094162..8b2b15ae 100644 --- a/native/core/src/compare.rs +++ b/native/core/src/compare.rs @@ -7,6 +7,7 @@ //! The source checkout is never reset, stashed, checked out or committed in. //! The starting state is captured with a temporary index file, so neither the //! source index nor its working tree is written while the compare starts. +use crate::paths::Canonical; use crate::{ config::{self, Config}, engine::{Engine, Job, JobOwner, StartRequest}, @@ -518,7 +519,11 @@ pub(crate) async fn snapshot_for( snapshot_index_flags(source, &env, cancel).await?; // Clearing flags rewrites the temporary index. Preserve the source's // racy timestamp for other entries before Git examines working bytes. - fs::File::open(&index)?.set_modified(modified)?; + // Windows changes a time only through a handle that may write. + fs::OpenOptions::new() + .write(true) + .open(&index)? + .set_modified(modified)?; } else { git_env(source, &["read-tree", "HEAD"], &env, cancel).await?; } @@ -908,7 +913,7 @@ pub(crate) async fn repository_root_for( .await .with_context(|| format!("{needs} a Git repository"))?; ensure!( - Path::new(&top).canonicalize()? == workspace, + Path::new(&top).canonical()? == workspace, "Open the repository root before {action}" ); Ok(()) @@ -1071,7 +1076,7 @@ pub(crate) async fn start(engine: &Engine, options: StartOptions<'_>) -> Result< } else { model.name.clone() }, - worktree: checkout.path.canonicalize()?, + worktree: checkout.path.canonical()?, worktree_id: checkout.id, branch: checkout.branch, base_commit: base.commit.clone(), @@ -2068,7 +2073,9 @@ mod tests { let timestamp = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000_000); let file = source.join("data.txt"); fs::write(&file, b"before\n").unwrap(); - fs::File::open(&file) + fs::OpenOptions::new() + .write(true) + .open(&file) .unwrap() .set_modified(timestamp) .unwrap(); @@ -2091,7 +2098,9 @@ mod tests { .await .unwrap(); let index = source.join(".git/index"); - fs::File::open(&index) + fs::OpenOptions::new() + .write(true) + .open(&index) .unwrap() .set_modified(timestamp) .unwrap(); @@ -2100,7 +2109,9 @@ mod tests { // Same size and cached mtime: Git must use the index's original racy // timestamp to decide to hash bytes instead of trusting its stat cache. fs::write(&file, b"after!\n").unwrap(); - fs::File::open(&file) + fs::OpenOptions::new() + .write(true) + .open(&file) .unwrap() .set_modified(timestamp) .unwrap(); diff --git a/native/core/src/config.rs b/native/core/src/config.rs index 1df33f94..c6ed8413 100644 --- a/native/core/src/config.rs +++ b/native/core/src/config.rs @@ -1,3 +1,4 @@ +use crate::paths::Canonical; use crate::paths::{atomic_write, AppPaths}; use anyhow::{bail, ensure, Context, Result}; use serde::{Deserialize, Serialize}; @@ -377,11 +378,11 @@ impl Config { .with_context(|| format!("{} needs a fix", file.display()))?; config.apply_runtime(false); if let Some(workspace) = workspace { - let canonical = workspace.canonicalize()?; + let canonical = workspace.canonical()?; let overlay = canonical.join(".shadow/config/config.yaml"); if config.is_trusted(&canonical) && overlay.exists() { ensure!( - overlay.canonicalize()?.starts_with(&canonical), + overlay.canonical()?.starts_with(&canonical), "Project configuration escapes the workspace" ); let project = read_yaml(&overlay)?; @@ -581,7 +582,7 @@ impl Config { } /// Persist the canonical project path so later job checks match aliases. pub fn grant_trust(&mut self, workspace: &Path) { - let stored = workspace.canonicalize().unwrap_or_else(|_| slim(workspace)); + let stored = workspace.canonical().unwrap_or_else(|_| slim(workspace)); if stored.as_os_str().is_empty() || self.is_trusted(&stored) { return; } @@ -717,7 +718,7 @@ fn read_text(path: &Path) -> Result { /// True when both paths name the same directory after resolving symlinks, /// trailing slashes, `.` / `..`, and bind-mount aliases that share an inode. pub fn same_workspace(left: &Path, right: &Path) -> bool { - if let (Ok(left), Ok(right)) = (left.canonicalize(), right.canonicalize()) { + if let (Ok(left), Ok(right)) = (left.canonical(), right.canonical()) { if left == right { return true; } diff --git a/native/core/src/control.rs b/native/core/src/control.rs index 2ce41421..b0280990 100644 --- a/native/core/src/control.rs +++ b/native/core/src/control.rs @@ -1,6 +1,8 @@ //! Private local transport for command-line clients of the profile's one engine. -//! Filesystem permissions and peer credentials restrict access to this OS user; -//! it deliberately opens no TCP listener and does not accept browser requests. +//! Filesystem permissions and peer credentials (a named pipe's access rules +//! and owner on Windows, see `transport`) restrict access to this OS user; it +//! deliberately opens no TCP listener and does not accept browser requests. +use crate::paths::Canonical; use crate::{ paths::AppPaths, service::{Request, Service}, @@ -10,10 +12,13 @@ use anyhow::{bail, ensure, Context, Result}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; +#[cfg(unix)] use std::{ fs, - io::Write, os::unix::fs::{DirBuilderExt, FileTypeExt, MetadataExt, PermissionsExt}, +}; +use std::{ + io::Write, path::{Path, PathBuf}, sync::{ atomic::{AtomicBool, Ordering}, @@ -21,9 +26,10 @@ use std::{ }, time::Duration, }; +#[cfg(unix)] +use tokio::net::UnixListener; use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, - net::{UnixListener, UnixStream}, sync::{Notify, Semaphore}, task::JoinSet, }; @@ -33,14 +39,18 @@ const REQUEST_LIMIT: usize = 8_100_000; const RESPONSE_LIMIT: usize = 68_000_000; const MAX_CLIENTS: usize = 16; mod owned; +mod transport; mod view; pub use owned::OwnedJobs; +use transport::Stream; pub use view::ViewClient; +#[cfg(unix)] fn uid() -> u32 { // SAFETY: geteuid has no arguments, dereferences no pointers, and cannot fail. unsafe { libc::geteuid() } } +#[cfg(unix)] fn private_directory(path: &Path, create: bool) -> Result<()> { if create { match fs::DirBuilder::new().mode(0o700).create(path) { @@ -69,10 +79,18 @@ impl Endpoint { pub fn for_paths(paths: &AppPaths) -> Result { let mut identity = Vec::new(); for root in [&paths.config, &paths.data, &paths.state] { - identity.extend_from_slice(root.canonicalize()?.as_os_str().as_encoded_bytes()); + identity.extend_from_slice(root.canonical()?.as_os_str().as_encoded_bytes()); identity.push(0); } let profile = format!("{:x}", Sha256::digest(&identity)); + #[cfg(windows)] + let path = PathBuf::from(format!(r"\\.\pipe\shadowcode-{}", &profile[..32])); + #[cfg(unix)] + let path = Self::socket(&profile)?; + Ok(Self { path, profile }) + } + #[cfg(unix)] + fn socket(profile: &str) -> Result { // Derive this from UID, not each shell's environment: cron and desktop // sessions with different runtime/TMPDIR variables must find one engine. let runtime = PathBuf::from(format!("/run/user/{}", uid())); @@ -87,7 +105,7 @@ impl Endpoint { path.as_os_str().as_encoded_bytes().len() < 104, "Local control socket path is too long" ); - Ok(Self { path, profile }) + Ok(path) } pub fn path(&self) -> &Path { &self.path @@ -136,18 +154,8 @@ impl Client { send(&mut stream, &self.envelope(request), REQUEST_LIMIT).await?; self.response(&mut stream).await } - async fn connect(&self) -> Result { - let stream = tokio::time::timeout( - Duration::from_secs(2), - UnixStream::connect(&self.endpoint.path), - ) - .await - .context("Local engine connection timed out")??; - ensure!( - stream.peer_cred()?.uid() == uid(), - "Local engine belongs to another OS user" - ); - Ok(stream) + async fn connect(&self) -> Result { + transport::connect(&self.endpoint.path).await } fn envelope(&self, request: Request) -> Envelope { Envelope { @@ -159,7 +167,7 @@ impl Client { request, } } - async fn response(&self, stream: &mut UnixStream) -> Result { + async fn response(&self, stream: &mut Stream) -> Result { let bytes = receive(stream, RESPONSE_LIMIT).await?; self.result(self.validate_response(&bytes)?) } @@ -231,9 +239,11 @@ impl Client { } } } +#[cfg(unix)] /// A socket younger than this may belong to an engine between bind and listen. const STALE_SOCKET_AGE: Duration = Duration::from_secs(10); +#[cfg(unix)] fn engine_socket_name(path: &Path) -> bool { path.file_name() .and_then(|n| n.to_str()) @@ -246,6 +256,7 @@ fn engine_socket_name(path: &Path) -> bool { }) } +#[cfg(unix)] /// True only when nothing listens on the socket (ECONNREFUSED). A live /// engine accepts or reports a full backlog; both count as alive. fn socket_refuses(path: &Path) -> bool { @@ -301,6 +312,7 @@ fn socket_refuses(path: &Path) -> bool { rc != 0 && error == Some(libc::ECONNREFUSED) } +#[cfg(unix)] /// Remove engine control sockets (`<32 hex>.sock`) in `directory` that belong /// to this user, are older than `min_age`, and refuse connections. Live /// engines answer and are never touched; `keep` (this engine's own path) is @@ -343,11 +355,13 @@ pub fn sweep_stale_sockets(directory: &Path, keep: &Path, min_age: Duration) -> removed } +#[cfg(unix)] struct SocketLease { path: PathBuf, dev: u64, ino: u64, } +#[cfg(unix)] impl Drop for SocketLease { fn drop(&mut self) { if fs::symlink_metadata(&self.path) @@ -387,29 +401,11 @@ impl Server { let mode = mode.to_owned(); service.set_engine_mode(&mode); let endpoint = Endpoint::for_paths(service.engine.paths())?; - if let Some(directory) = endpoint.path.parent() { - // Crashed or killed engines leave their socket files behind. - sweep_stale_sockets(directory, &endpoint.path, STALE_SOCKET_AGE); - } - if let Ok(metadata) = fs::symlink_metadata(&endpoint.path) { - ensure!( - metadata.file_type().is_socket() && metadata.uid() == uid(), - "Refusing to replace a non-socket local control path" - ); - ensure!( - std::os::unix::net::UnixStream::connect(&endpoint.path).is_err(), - "Local engine control endpoint is already running" - ); - fs::remove_file(&endpoint.path)?; - } - let listener = UnixListener::bind(&endpoint.path)?; - fs::set_permissions(&endpoint.path, fs::Permissions::from_mode(0o600))?; - let metadata = fs::symlink_metadata(&endpoint.path)?; - let lease = SocketLease { - path: endpoint.path.clone(), - dev: metadata.dev(), - ino: metadata.ino(), - }; + #[cfg(unix)] + let (mut listener, lease) = bind(&endpoint)?; + // A pipe disappears with its last handle; there is nothing to sweep. + #[cfg(windows)] + let (mut listener, lease) = (transport::Listener::bind(&endpoint.path)?, ()); let state = Arc::new(State { cancel: CancellationToken::new(), finished: AtomicBool::new(false), @@ -434,8 +430,8 @@ impl Server { tokio::select! { _=running.cancel.cancelled()=>break, Some(_)=clients.join_next(),if !clients.is_empty()=>{}, - accepted=listener.accept(),if clients.len()match accepted { - Ok((stream,_))=>{ + accepted=accept(&mut listener),if clients.len()match accepted { + Ok(stream)=>{ let service=service.clone();let profile=profile.clone();let mode=mode.clone();let owners=owners.clone();let views=views.clone(); clients.spawn(async move {let _=handle(stream, service, profile, mode, owners, views).await;}); } @@ -446,6 +442,7 @@ impl Server { clients.abort_all(); while clients.join_next().await.is_some() {} drop(listener); + #[cfg(unix)] drop(_lease); drop(service); }); @@ -472,15 +469,50 @@ impl Drop for Server { self.close(); } } +#[cfg(unix)] +fn bind(endpoint: &Endpoint) -> Result<(UnixListener, SocketLease)> { + if let Some(directory) = endpoint.path.parent() { + // Crashed or killed engines leave their socket files behind. + sweep_stale_sockets(directory, &endpoint.path, STALE_SOCKET_AGE); + } + if let Ok(metadata) = fs::symlink_metadata(&endpoint.path) { + ensure!( + metadata.file_type().is_socket() && metadata.uid() == uid(), + "Refusing to replace a non-socket local control path" + ); + ensure!( + std::os::unix::net::UnixStream::connect(&endpoint.path).is_err(), + "Local engine control endpoint is already running" + ); + fs::remove_file(&endpoint.path)?; + } + let listener = UnixListener::bind(&endpoint.path)?; + fs::set_permissions(&endpoint.path, fs::Permissions::from_mode(0o600))?; + let metadata = fs::symlink_metadata(&endpoint.path)?; + let lease = SocketLease { + path: endpoint.path.clone(), + dev: metadata.dev(), + ino: metadata.ino(), + }; + Ok((listener, lease)) +} +#[cfg(unix)] +async fn accept(listener: &mut UnixListener) -> std::io::Result { + listener.accept().await.map(|(stream, _)| stream) +} +#[cfg(windows)] +async fn accept(listener: &mut transport::Listener) -> Result { + listener.accept().await +} async fn handle( - mut stream: UnixStream, + mut stream: Stream, service: Service, profile: String, mode: String, owners: Arc, views: view::Registry, ) -> Result<()> { - ensure!(stream.peer_cred()?.uid() == uid(), "Wrong peer user"); + ensure!(transport::peer_is_this_user(&stream)?, "Wrong peer user"); let response = async { let bytes = tokio::time::timeout(Duration::from_secs(10), receive(&mut stream, REQUEST_LIMIT)) @@ -501,7 +533,7 @@ async fn handle( return Ok(json!({"mode":mode,"persistent":mode!="command","pid":std::process::id(),"version":crate::VERSION})); } if envelope.request.path == "/api/owned-jobs" && envelope.request.method == "POST" { - ensure!(mode != "command" || stream.peer_cred()?.pid() == Some(std::process::id() as i32), "A foreground CLI task owns this profile; use the desktop or shadowcode serve for concurrent work"); + ensure!(mode != "command" || transport::peer_pid(&stream)? == Some(std::process::id() as i32), "A foreground CLI task owns this profile; use the desktop or shadowcode serve for concurrent work"); let permit = owners.try_acquire_owned().context("At most eight task ownership connections may be active")?; let scoped = service.fork_selection(envelope.workspace, envelope.session_id)?; return owned::serve(&mut stream, scoped, &profile, permit).await; @@ -548,7 +580,11 @@ async fn receive( stream.read_exact(&mut bytes).await?; Ok(bytes) } -async fn send(stream: &mut UnixStream, value: &impl Serialize, limit: usize) -> Result<()> { +async fn send( + stream: &mut (impl tokio::io::AsyncWrite + Unpin), + value: &impl Serialize, + limit: usize, +) -> Result<()> { send_bytes(stream, &encode(value, limit)?).await } fn encode(value: &impl Serialize, limit: usize) -> Result> { @@ -577,7 +613,7 @@ fn encode(value: &impl Serialize, limit: usize) -> Result> { serde_json::to_writer(&mut buffer, value)?; Ok(buffer.bytes) } -async fn send_bytes(stream: &mut UnixStream, bytes: &[u8]) -> Result<()> { +async fn send_bytes(stream: &mut (impl tokio::io::AsyncWrite + Unpin), bytes: &[u8]) -> Result<()> { tokio::time::timeout(Duration::from_secs(15), async { stream.write_u32(bytes.len() as u32).await?; stream.write_all(bytes).await @@ -587,7 +623,7 @@ async fn send_bytes(stream: &mut UnixStream, bytes: &[u8]) -> Result<()> { Ok(()) } -#[cfg(test)] +#[cfg(all(test, unix))] mod tests { use super::*; use std::os::unix::net::UnixListener as StdListener; diff --git a/native/core/src/control/owned.rs b/native/core/src/control/owned.rs index 8abb2cab..b83b90ad 100644 --- a/native/core/src/control/owned.rs +++ b/native/core/src/control/owned.rs @@ -6,7 +6,7 @@ use tokio::sync::{Mutex, OwnedSemaphorePermit}; pub struct OwnedJobs { client: Client, - stream: Mutex>, + stream: Mutex>, } impl Client { pub async fn own_jobs(&self) -> Result { @@ -80,7 +80,7 @@ impl OwnedJobs { } pub(super) async fn serve( - stream: &mut UnixStream, + stream: &mut Stream, service: Service, profile: &str, _permit: OwnedSemaphorePermit, diff --git a/native/core/src/control/transport.rs b/native/core/src/control/transport.rs new file mode 100644 index 00000000..d5757d79 --- /dev/null +++ b/native/core/src/control/transport.rs @@ -0,0 +1,381 @@ +//! The control channel's byte stream and who may use it. +//! +//! * Unix: a socket file in a private folder, mode 600, whose peer's user ID +//! is checked on both ends (see `control.rs`). +//! * Windows: a named pipe that refuses remote clients and whose only access +//! rule is this user's SID. Before sending anything, a client checks that +//! the process serving the pipe runs as this user, so another account +//! cannot answer by creating the pipe name first. +use anyhow::Result; +use std::path::Path; + +#[cfg(unix)] +pub type Stream = tokio::net::UnixStream; +#[cfg(windows)] +pub use windows::{Listener, Stream}; + +#[cfg(unix)] +pub type ReadHalf = tokio::net::unix::OwnedReadHalf; +#[cfg(unix)] +pub type WriteHalf = tokio::net::unix::OwnedWriteHalf; +// A pipe has no half-close; the view protocol ends with explicit messages. +#[cfg(windows)] +pub type ReadHalf = tokio::io::ReadHalf; +#[cfg(windows)] +pub type WriteHalf = tokio::io::WriteHalf; + +pub fn split(stream: Stream) -> (ReadHalf, WriteHalf) { + #[cfg(unix)] + { + stream.into_split() + } + #[cfg(windows)] + { + tokio::io::split(stream) + } +} + +/// Connect to the engine listening at `path`. +pub async fn connect(path: &Path) -> Result { + #[cfg(unix)] + { + use anyhow::{ensure, Context}; + let stream = tokio::time::timeout( + std::time::Duration::from_secs(2), + tokio::net::UnixStream::connect(path), + ) + .await + .context("Local engine connection timed out")??; + ensure!( + stream.peer_cred()?.uid() == super::uid(), + "Local engine belongs to another OS user" + ); + Ok(stream) + } + #[cfg(windows)] + { + windows::connect(path).await + } +} + +/// Whether the peer of an accepted connection runs as this user. +pub fn peer_is_this_user(stream: &Stream) -> Result { + #[cfg(unix)] + { + Ok(stream.peer_cred()?.uid() == super::uid()) + } + #[cfg(windows)] + { + windows::client_is_this_user(stream) + } +} + +/// The process ID of an accepted connection's peer, when known. +pub fn peer_pid(stream: &Stream) -> Result> { + #[cfg(unix)] + { + Ok(stream.peer_cred()?.pid()) + } + #[cfg(windows)] + { + windows::client_pid(stream).map(Some) + } +} + +#[cfg(windows)] +mod windows { + use anyhow::{ensure, Context, Result}; + use std::{ + ffi::c_void, + io, + os::windows::io::{AsRawHandle, RawHandle}, + path::Path, + pin::Pin, + task::{Context as TaskContext, Poll}, + time::{Duration, Instant}, + }; + use tokio::{ + io::{AsyncRead, AsyncWrite, ReadBuf}, + net::windows::named_pipe::{ + ClientOptions, NamedPipeClient, NamedPipeServer, ServerOptions, + }, + }; + use windows_sys::Win32::{ + Foundation::{CloseHandle, LocalFree, HANDLE}, + Security::{ + Authorization::{ + ConvertSidToStringSidW, ConvertStringSecurityDescriptorToSecurityDescriptorW, + SDDL_REVISION_1, + }, + EqualSid, GetTokenInformation, TokenUser, SECURITY_ATTRIBUTES, TOKEN_QUERY, TOKEN_USER, + }, + System::{ + Pipes::{GetNamedPipeClientProcessId, GetNamedPipeServerProcessId}, + Threading::{ + GetCurrentProcess, OpenProcess, OpenProcessToken, PROCESS_QUERY_LIMITED_INFORMATION, + }, + }, + }; + + /// ERROR_PIPE_BUSY: every instance is serving another client. + const PIPE_BUSY: i32 = 231; + + /// One end of the engine's named pipe. + pub enum Stream { + Server(NamedPipeServer), + Client(NamedPipeClient), + } + impl Stream { + fn handle(&self) -> RawHandle { + match self { + Self::Server(pipe) => pipe.as_raw_handle(), + Self::Client(pipe) => pipe.as_raw_handle(), + } + } + } + impl AsyncRead for Stream { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + match self.get_mut() { + Self::Server(pipe) => Pin::new(pipe).poll_read(cx, buf), + Self::Client(pipe) => Pin::new(pipe).poll_read(cx, buf), + } + } + } + impl AsyncWrite for Stream { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buf: &[u8], + ) -> Poll> { + match self.get_mut() { + Self::Server(pipe) => Pin::new(pipe).poll_write(cx, buf), + Self::Client(pipe) => Pin::new(pipe).poll_write(cx, buf), + } + } + fn poll_flush(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + match self.get_mut() { + Self::Server(pipe) => Pin::new(pipe).poll_flush(cx), + Self::Client(pipe) => Pin::new(pipe).poll_flush(cx), + } + } + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + match self.get_mut() { + Self::Server(pipe) => Pin::new(pipe).poll_shutdown(cx), + Self::Client(pipe) => Pin::new(pipe).poll_shutdown(cx), + } + } + } + + /// A handle closed when dropped. + struct Owned(HANDLE); + impl Drop for Owned { + fn drop(&mut self) { + // SAFETY: the handle was opened by this module and is closed once. + unsafe { CloseHandle(self.0) }; + } + } + + /// The user SID of a process's token, as owned bytes. + fn process_user(process: HANDLE) -> Result> { + let mut token: HANDLE = std::ptr::null_mut(); + // SAFETY: OpenProcessToken writes one handle on success. + ensure!( + unsafe { OpenProcessToken(process, TOKEN_QUERY, &mut token) } != 0, + "Could not read a process token: {}", + io::Error::last_os_error() + ); + let token = Owned(token); + let mut needed = 0u32; + // SAFETY: a size query; nothing is written with a zero length. + unsafe { GetTokenInformation(token.0, TokenUser, std::ptr::null_mut(), 0, &mut needed) }; + ensure!(needed > 0 && needed < 4096, "Unexpected token size"); + let mut buffer = vec![0u8; needed as usize]; + // SAFETY: the buffer is `needed` bytes long. + ensure!( + unsafe { + GetTokenInformation( + token.0, + TokenUser, + buffer.as_mut_ptr().cast(), + needed, + &mut needed, + ) + } != 0, + "Could not read a process's user: {}", + io::Error::last_os_error() + ); + Ok(buffer) + } + + /// Whether two TOKEN_USER buffers name the same account. + fn same_user(a: &[u8], b: &[u8]) -> bool { + // SAFETY: both buffers hold a TOKEN_USER filled by GetTokenInformation; + // its SID pointer points into the same buffer. + unsafe { + let a = &*(a.as_ptr() as *const TOKEN_USER); + let b = &*(b.as_ptr() as *const TOKEN_USER); + EqualSid(a.User.Sid, b.User.Sid) != 0 + } + } + + fn this_user() -> Result> { + // SAFETY: GetCurrentProcess returns a pseudo-handle that needs no closing. + process_user(unsafe { GetCurrentProcess() }) + } + + /// Whether the process `pid` runs as this user. + fn pid_is_this_user(pid: u32) -> Result { + // SAFETY: OpenProcess returns a handle or null. + let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; + ensure!( + !process.is_null(), + "Could not inspect process {pid}: {}", + io::Error::last_os_error() + ); + let process = Owned(process); + Ok(same_user(&process_user(process.0)?, &this_user()?)) + } + + pub fn client_pid(stream: &Stream) -> Result { + let mut pid = 0u32; + // SAFETY: the handle is the open pipe; one u32 is written. + ensure!( + unsafe { GetNamedPipeClientProcessId(stream.handle() as HANDLE, &mut pid) } != 0, + "Could not identify the local client: {}", + io::Error::last_os_error() + ); + Ok(pid as i32) + } + + pub fn client_is_this_user(stream: &Stream) -> Result { + pid_is_this_user(client_pid(stream)? as u32) + } + + pub async fn connect(path: &Path) -> Result { + let deadline = Instant::now() + Duration::from_secs(2); + let client = loop { + match ClientOptions::new().open(path) { + Ok(client) => break client, + Err(error) + if error.raw_os_error() == Some(PIPE_BUSY) && Instant::now() < deadline => + { + tokio::time::sleep(Duration::from_millis(20)).await; + } + Err(error) => return Err(error.into()), + } + }; + let mut pid = 0u32; + // SAFETY: the handle is the open pipe; one u32 is written. + ensure!( + unsafe { GetNamedPipeServerProcessId(client.as_raw_handle() as HANDLE, &mut pid) } != 0, + "Could not identify the local engine: {}", + io::Error::last_os_error() + ); + ensure!( + pid_is_this_user(pid)?, + "Local engine belongs to another Windows user" + ); + Ok(Stream::Client(client)) + } + + /// A security descriptor that grants this user, and nobody else, access. + struct Descriptor(*mut c_void); + impl Drop for Descriptor { + fn drop(&mut self) { + // SAFETY: allocated by ConvertStringSecurityDescriptorToSecurityDescriptorW. + unsafe { LocalFree(self.0) }; + } + } + // The descriptor is only read by CreateNamedPipeW. + unsafe impl Send for Descriptor {} + unsafe impl Sync for Descriptor {} + + fn only_this_user() -> Result { + let user = this_user()?; + let mut text: *mut u16 = std::ptr::null_mut(); + // SAFETY: the SID points into `user`; ConvertSidToStringSidW allocates + // the string, which is freed below. + let sid = unsafe { + let token = &*(user.as_ptr() as *const TOKEN_USER); + ensure!( + ConvertSidToStringSidW(token.User.Sid, &mut text) != 0, + "Could not name this user: {}", + io::Error::last_os_error() + ); + let len = (0..).take_while(|&i| *text.add(i) != 0).count(); + let sid = String::from_utf16_lossy(std::slice::from_raw_parts(text, len)); + LocalFree(text.cast()); + sid + }; + // Protected DACL with one rule: full access for this user. + let sddl: Vec = format!("D:P(A;;GA;;;{sid})") + .encode_utf16() + .chain(Some(0)) + .collect(); + let mut descriptor: *mut c_void = std::ptr::null_mut(); + // SAFETY: the SDDL string is NUL-terminated; the descriptor is + // allocated by the call and freed by `Descriptor`. + ensure!( + unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + sddl.as_ptr(), + SDDL_REVISION_1, + &mut descriptor, + std::ptr::null_mut(), + ) + } != 0, + "Could not build the pipe's access rules: {}", + io::Error::last_os_error() + ); + Ok(Descriptor(descriptor)) + } + + /// The engine's pipe: always one instance waiting for the next client. + pub struct Listener { + name: std::path::PathBuf, + next: NamedPipeServer, + descriptor: Descriptor, + } + impl Listener { + /// Create the first instance; fails if an engine already serves `name`. + pub fn bind(name: &Path) -> Result { + let descriptor = only_this_user()?; + let next = Self::instance(name, &descriptor, true) + .context("Local engine control endpoint is already running")?; + Ok(Self { + name: name.to_path_buf(), + next, + descriptor, + }) + } + fn instance(name: &Path, descriptor: &Descriptor, first: bool) -> Result { + let mut attributes = SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + lpSecurityDescriptor: descriptor.0, + bInheritHandle: 0, + }; + // SAFETY: the attributes and descriptor outlive the call. + let pipe = unsafe { + ServerOptions::new() + .first_pipe_instance(first) + .reject_remote_clients(true) + .create_with_security_attributes_raw( + name, + (&mut attributes as *mut SECURITY_ATTRIBUTES).cast(), + ) + }?; + Ok(pipe) + } + /// Wait for a client; a fresh instance takes its place first. + pub async fn accept(&mut self) -> Result { + self.next.connect().await?; + let next = Self::instance(&self.name, &self.descriptor, false)?; + let connected = std::mem::replace(&mut self.next, next); + Ok(Stream::Server(connected)) + } + } +} diff --git a/native/core/src/control/view.rs b/native/core/src/control/view.rs index 14c0ed39..d4a9517c 100644 --- a/native/core/src/control/view.rs +++ b/native/core/src/control/view.rs @@ -5,10 +5,7 @@ use std::{ collections::HashMap, sync::{Mutex as StdMutex, RwLock}, }; -use tokio::{ - net::unix::OwnedWriteHalf, - sync::{broadcast, Mutex}, -}; +use tokio::sync::{broadcast, Mutex}; use tokio_util::task::AbortOnDropHandle; const EVENT_LIMIT: usize = 16_384; @@ -58,7 +55,7 @@ impl Drop for Lease { /// cannot prevent status reads or cancellation from the same window. pub struct ViewClient { client: StdMutex, - writer: Mutex>, + writer: Mutex>, reader: Mutex>>>, events: broadcast::Sender, closed: Arc, @@ -103,7 +100,7 @@ impl Client { ); let mut client = self.clone(); client.view = Some(id.to_owned()); - let (mut reader, writer) = stream.into_split(); + let (mut reader, writer) = transport::split(stream); closed.store(false, Ordering::Release); let event_sender = events.clone(); let ended = closed.clone(); @@ -283,7 +280,7 @@ fn notification(event: &Value) -> Value { } pub(super) async fn serve( - stream: &mut UnixStream, + stream: &mut Stream, service: Service, profile: &str, registry: Registry, @@ -321,7 +318,7 @@ pub(super) async fn serve( Ok(json!({"closed": true})) } -#[cfg(test)] +#[cfg(all(test, unix))] mod tests { use super::*; #[tokio::test] @@ -332,8 +329,8 @@ mod tests { self.0.store(true, Ordering::Release); } } - let (stream, mut peer) = UnixStream::pair().unwrap(); - let (mut reader, writer) = stream.into_split(); + let (stream, mut peer) = tokio::net::UnixStream::pair().unwrap(); + let (mut reader, writer) = transport::split(stream); let released = Arc::new(AtomicBool::new(false)); let marker = Dropped(released.clone()); let task = tokio::spawn(async move { diff --git a/native/core/src/data.rs b/native/core/src/data.rs index 417ec18d..f18a9e42 100644 --- a/native/core/src/data.rs +++ b/native/core/src/data.rs @@ -140,8 +140,8 @@ fn private_file(_path: &Path) -> Result<()> { /// `~/x` to the home folder; other paths unchanged. fn expand(path: &Path) -> PathBuf { - match (path.strip_prefix("~"), std::env::var_os("HOME")) { - (Ok(rest), Some(home)) => PathBuf::from(home).join(rest), + match (path.strip_prefix("~"), crate::paths::home_dir()) { + (Ok(rest), Some(home)) => home.join(rest), _ => path.to_path_buf(), } } @@ -149,14 +149,11 @@ fn expand(path: &Path) -> PathBuf { /// Make a copied file private and durable before anything relies on it. fn seal(path: &Path) -> Result<()> { private_file(path)?; - fs::File::open(path)?.sync_all()?; - Ok(()) + crate::paths::sync_file(path) } fn sync_dir(path: &Path) { - if let Ok(dir) = fs::File::open(path) { - let _ = dir.sync_all(); - } + let _ = crate::paths::sync_directory(path); } /// `config/x`, `state/x`, `data/x` or `agents/x` to a real path, refusing @@ -478,6 +475,7 @@ fn new_backup_folder(parent: &Path, reason: &str) -> Result { format!("shadowcode-backup-{stamp}{suffix}-{attempt}") }; let folder = parent.join(name); + #[cfg_attr(not(unix), allow(unused_mut))] let mut builder = fs::DirBuilder::new(); #[cfg(unix)] { @@ -1228,7 +1226,7 @@ fn apply_restore(paths: &AppPaths, pending: &Pending) -> Result { )); fs::copy(staging.join(&file.path), &temporary)?; private_file(&temporary)?; - fs::File::open(&temporary)?.sync_all()?; + crate::paths::sync_file(&temporary)?; prepared.push((temporary, target, file.path.clone())); } let database = paths.database(); diff --git a/native/core/src/engine.rs b/native/core/src/engine.rs index 6821dd05..85aec824 100644 --- a/native/core/src/engine.rs +++ b/native/core/src/engine.rs @@ -2019,7 +2019,6 @@ impl Engine { } /// Record what a subscription CLI changed in the project into the task's /// file checkpoint. A failure never fails the turn; it is reported. - #[cfg(unix)] async fn record_vendor_changes( &self, before: crate::checkpoint::capture::Before, @@ -2278,15 +2277,11 @@ impl Engine { resume, effort: crate::effort::vendor(vendor, running.turn.effort.as_deref()), legacy_effort, - #[cfg(unix)] mcp_servers: crate::mcp::vendor::servers(&running.workspace, &running.config), - #[cfg(not(unix))] - mcp_servers: Vec::new(), rulebook, }; // A project checkpoint around the turn: the CLI writes with its own // tools, so the changes are recorded afterwards for rewind. - #[cfg(unix)] let checkpoint = if checkpoints { crate::checkpoint::capture::before( &running.workspace.path, @@ -2301,7 +2296,6 @@ impl Engine { // From here until the turn's changes are recorded, the user may save // files in ShadowCode's editor; Rewind keeps those saves. running.edit_window.store(checkpoints, Ordering::Release); - #[cfg(unix)] let (outcome, reported) = crate::cli_agent::runner::run_reporting_usage(crate::cli_agent::runner::Request { vendor, @@ -2323,12 +2317,10 @@ impl Engine { }) .await; self.close_edit_window(running).await; - #[cfg(unix)] self.record_vendor_changes(checkpoint, running, &job, &events, vendor.label()) .await; // Usage may have moved: refresh after every vendor turn, bounded by // the catalog's refresh window unless the turn hit the plan limit. - #[cfg(unix)] { let limited = outcome.as_ref().err().is_some_and(|e| { e.downcast_ref::() @@ -2342,7 +2334,6 @@ impl Engine { } // A failed, limited or cancelled turn keeps the tokens and cost the // vendor already reported: they count against the user's plan. - #[cfg(unix)] let outcome = match outcome { Ok(outcome) => outcome, Err(error) => { @@ -2358,11 +2349,6 @@ impl Engine { return Err(error); } }; - #[cfg(not(unix))] - let outcome: crate::cli_agent::runner::RunOutcome = { - let _ = (options, prompt, events); - bail!("Vendor CLI backends require a Unix host") - }; if let Some(native) = outcome.native_session.clone() { let session_id = job.session_id.clone(); self.0 diff --git a/native/core/src/engine/automations.rs b/native/core/src/engine/automations.rs index 1780e90e..8e1f8fa1 100644 --- a/native/core/src/engine/automations.rs +++ b/native/core/src/engine/automations.rs @@ -10,6 +10,7 @@ //! stops (or waits, if the automation says so), and every run has a time //! limit. The result, duration and usage land in `automation_runs`. use super::*; +use crate::paths::Canonical; use crate::tools::truncate; use crate::{ automations::{self as rules, Automation, Checkout, Due, OnApproval, Permission}, @@ -466,7 +467,7 @@ impl Engine { ) .await .context("Could not create a fresh worktree for this run. The project must be the top folder of a Git repository with at least one commit; or set the automation to run in the main checkout")?; - let path = record.path.canonicalize()?; + let path = record.path.canonical()?; set_trust(self, &path, true)?; run.worktree = Some(json!({ "id": record.id, diff --git a/native/core/src/flatpak.rs b/native/core/src/flatpak.rs index 81e1107a..a11c8f49 100644 --- a/native/core/src/flatpak.rs +++ b/native/core/src/flatpak.rs @@ -51,7 +51,7 @@ pub fn shared_socket_base() -> Option { }) } -#[cfg(test)] +#[cfg(all(test, target_os = "linux"))] mod tests { use super::*; diff --git a/native/core/src/git_guard.rs b/native/core/src/git_guard.rs index 154a7adc..4b3bc06f 100644 --- a/native/core/src/git_guard.rs +++ b/native/core/src/git_guard.rs @@ -16,6 +16,7 @@ //! Git commands that write your repository's real index or history for you //! (staging, committing, discarding from the Changes view) keep the filters, //! so a filter such as git-crypt still protects what you commit. +use crate::paths::Canonical; use anyhow::{bail, ensure, Context, Result}; use std::{collections::BTreeSet, path::Path, process::Output}; @@ -247,7 +248,7 @@ pub async fn freeze_gitlinks(dir: &Path, index: Option<&Path>) -> Result<()> { /// The repository's own configuration files for `dir` (its local and /// worktree-scope files), or `None` when they cannot be worked out here. fn local_config_files(dir: &Path) -> Option> { - let mut current = dir.canonicalize().ok()?; + let mut current = dir.canonical().ok()?; let dot_git = loop { let candidate = current.join(".git"); if candidate.symlink_metadata().is_ok() { diff --git a/native/core/src/git_hooks.rs b/native/core/src/git_hooks.rs index 07a494cd..bf31e5a8 100644 --- a/native/core/src/git_hooks.rs +++ b/native/core/src/git_hooks.rs @@ -343,8 +343,11 @@ pub fn failed_hook(events: &str) -> Option { } #[cfg(test)] +// Helpers of Unix-only tests are unused on Windows. +#[cfg_attr(not(unix), allow(unused))] mod tests { use super::*; + use crate::paths::Canonical; use std::process::Command; #[tokio::test] @@ -428,17 +431,21 @@ mod tests { } fn runnable(path: &Path, text: &str) { - use std::os::unix::fs::PermissionsExt; std::fs::write(path, text).unwrap(); - std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); + // Windows has no execute bit; Git runs hooks through its own sh. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } } /// Git expands `~` in core.hooksPath (a common global setting). + #[cfg(unix)] #[tokio::test] async fn a_hooks_path_from_the_home_folder_is_followed() { // `..` climbs the real folders, so count those. - let Some(home) = - std::env::var_os("HOME").and_then(|h| PathBuf::from(h).canonicalize().ok()) + let Some(home) = std::env::var_os("HOME").and_then(|h| PathBuf::from(h).canonical().ok()) else { return; }; @@ -492,11 +499,14 @@ mod tests { // A hook the question never listed. runnable(&hooks.join("post-checkout"), "#!/bin/sh\nexit 0\n"); changed(&mut seen, fingerprint(project).await); - // A file made runnable. + // A file made runnable (Windows files have no mode bit to change). std::fs::write(hooks.join("post-merge"), "#!/bin/sh\n").unwrap(); changed(&mut seen, fingerprint(project).await); - runnable(&hooks.join("post-merge"), "#!/bin/sh\n"); - changed(&mut seen, fingerprint(project).await); + #[cfg(unix)] + { + runnable(&hooks.join("post-merge"), "#!/bin/sh\n"); + changed(&mut seen, fingerprint(project).await); + } // husky: Git runs the stubs in `.husky/_`, which run `.husky/*`. let husky = project.join(".husky"); std::fs::create_dir_all(husky.join("_")).unwrap(); diff --git a/native/core/src/guardian.rs b/native/core/src/guardian.rs index 73f40075..b854055a 100644 --- a/native/core/src/guardian.rs +++ b/native/core/src/guardian.rs @@ -1,5 +1,6 @@ //! Optional read-only health checks. State and pending proposal approvals belong //! to one Service/profile and are additionally scoped to the selected workspace. +use crate::paths::Canonical; use anyhow::{ensure, Context, Result}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -50,7 +51,7 @@ impl Guardian { .state .lock() .map_err(|_| anyhow::anyhow!("Guardian lock poisoned"))?; - let current = state.get(&workspace.canonicalize()?); + let current = state.get(&workspace.canonical()?); Ok(json!({"enabled":cfg.enabled,"default":"off", "last_run":current.and_then(|s|s.last_run),"last_result":current.and_then(|s|s.last_result.clone()), "pending_patch_approval":current.is_some_and(|s|s.pending.is_some()), @@ -58,7 +59,7 @@ impl Guardian { } pub fn run_health_check(&self, cfg: &GuardianConfig, workspace: &Path) -> Result { ensure!(cfg.enabled, "Guardian is disabled (default off)"); - let workspace = workspace.canonicalize()?; + let workspace = workspace.canonical()?; ensure!(workspace.is_dir(), "Workspace is not a directory"); let doctor = crate::sandbox::doctor_check(); let hint = if workspace.join("Cargo.toml").is_file() { @@ -102,7 +103,7 @@ impl Guardian { self.state .lock() .map_err(|_| anyhow::anyhow!("Guardian lock poisoned"))? - .entry(workspace.canonicalize()?) + .entry(workspace.canonical()?) .or_default() .pending = Some(proposal.clone()); Ok( @@ -121,7 +122,7 @@ impl Guardian { cfg.enabled && cfg.allow_prepare_patch, "Guardian proposal drafts are disabled" ); - let workspace = workspace.canonicalize()?; + let workspace = workspace.canonical()?; ensure!( !root.starts_with(&workspace), "Proposal storage must be outside the workspace" @@ -143,7 +144,7 @@ impl Guardian { ); crate::paths::private_directory(root)?; ensure!( - !root.canonicalize()?.starts_with(&workspace), + !root.canonical()?.starts_with(&workspace), "Proposal storage must be outside the workspace" ); let draft = tempfile::Builder::new() diff --git a/native/core/src/intelligence.rs b/native/core/src/intelligence.rs index c0904656..b3002a80 100644 --- a/native/core/src/intelligence.rs +++ b/native/core/src/intelligence.rs @@ -2,6 +2,7 @@ //! (tree-sitter), plus one-shot `rust-analyzer diagnostics`, the fallback //! when the persistent language server (`crate::lsp`) cannot answer. No //! startup world index. +use crate::paths::Canonical; use anyhow::{bail, Result}; use serde_json::{json, Value}; use std::{path::Path, time::Duration}; @@ -50,7 +51,7 @@ pub async fn get_diagnostics(root: &Path, path: &str) -> Result { } let workspace = crate::workspace::Workspace::open(root)?; let rel = workspace.relative(path)?; - let full = root.join(&rel).canonicalize()?; + let full = root.join(&rel).canonical()?; anyhow::ensure!(full.starts_with(&workspace.path), "path escapes workspace"); anyhow::ensure!( !crate::redaction::is_secret_path(&rel.to_string_lossy()), diff --git a/native/core/src/lib.rs b/native/core/src/lib.rs index 87c557a9..96d8bf48 100644 --- a/native/core/src/lib.rs +++ b/native/core/src/lib.rs @@ -1,5 +1,4 @@ //! Native application engine shared by the desktop, CLI, and MCP transports. -#[cfg(unix)] pub mod acp_server; pub mod agents; pub mod allowance; @@ -9,7 +8,6 @@ pub mod automations; pub mod autonomy; pub mod background; pub mod checkpoint; -#[cfg(unix)] pub mod cli; pub mod cli_agent; pub mod code_intel; @@ -17,7 +15,6 @@ pub mod compaction; pub mod compare; pub mod config; pub mod context; -#[cfg(unix)] pub mod control; pub mod data; pub mod effort; @@ -35,7 +32,6 @@ pub mod instructions; pub mod intelligence; pub mod issues; pub mod keyring; -#[cfg(unix)] pub mod lifecycle; pub mod local_downloads; pub mod local_engine; @@ -44,7 +40,6 @@ pub mod local_templates; #[cfg(target_os = "macos")] pub mod login_path; pub mod lsp; -#[cfg(unix)] pub mod mcp; pub mod memory; pub mod mentions; @@ -59,7 +54,6 @@ pub mod parallel; pub mod patch; pub mod paths; pub mod permissions; -#[cfg(unix)] pub mod plugins; #[cfg(any(target_os = "linux", target_os = "macos"))] pub mod preview; @@ -67,7 +61,6 @@ pub mod process; pub mod project; pub mod prompt_cache; pub mod redaction; -#[cfg(unix)] pub mod remote; pub mod resume; pub mod retry; @@ -82,7 +75,6 @@ pub mod second_opinion; pub mod secret_scan; pub mod service; pub mod spending; -#[cfg(unix)] pub mod sqlite; pub mod steering; pub mod store; @@ -91,6 +83,10 @@ pub mod subagents; pub mod supply_chain; pub mod symbol_index; pub mod system_info; +#[cfg(unix)] +pub mod terminal; +#[cfg(not(unix))] +#[path = "terminal_windows.rs"] pub mod terminal; pub mod textdiff; pub mod timing; diff --git a/native/core/src/lifecycle.rs b/native/core/src/lifecycle.rs index 30f55aee..535b9b10 100644 --- a/native/core/src/lifecycle.rs +++ b/native/core/src/lifecycle.rs @@ -53,13 +53,25 @@ pub fn extraction_parent() -> Option { { return None; } - // SAFETY: getppid reads process metadata and takes no pointer arguments. - let parent = unsafe { libc::getppid() } as u32; + let parent = parent_pid()?; // If the wrapper already exited, the native process must also stop before // accepting work. An impossible PID causes the first check to resolve. Some(if parent <= 1 { 0 } else { parent }) } +/// Only AppImages have an extraction parent; elsewhere there is none. +fn parent_pid() -> Option { + #[cfg(unix)] + { + // SAFETY: getppid reads process metadata and takes no pointer arguments. + Some(unsafe { libc::getppid() } as u32) + } + #[cfg(not(unix))] + { + None + } +} + pub async fn parent_exited(parent: Option) { let Some(parent) = parent else { std::future::pending::<()>().await; @@ -67,7 +79,7 @@ pub async fn parent_exited(parent: Option) { }; loop { // Reparenting, rather than PID existence, also handles PID reuse. - if unsafe { libc::getppid() } as u32 != parent { + if parent_pid() != Some(parent) { return; } tokio::time::sleep(Duration::from_millis(50)).await; diff --git a/native/core/src/local_engine.rs b/native/core/src/local_engine.rs index 06c81286..0fc98f56 100644 --- a/native/core/src/local_engine.rs +++ b/native/core/src/local_engine.rs @@ -20,12 +20,18 @@ use std::{ }; const MAX_SCAN_FILES: usize = 256; -#[cfg(not(target_os = "macos"))] +#[cfg(all(unix, 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"; +/// Windows: under `%LOCALAPPDATA%` (see [`managed_dir`]). +#[cfg(windows)] +pub const MANAGED_RELATIVE: &str = "ShadowCode/llama.cpp"; +#[cfg(not(windows))] pub const MANAGED_SERVER: &str = "llama-server"; +#[cfg(windows)] +pub const MANAGED_SERVER: &str = "llama-server.exe"; /// Default upper bound for the server context window (tokens). pub const DEFAULT_CONTEXT_CAP: u64 = 16384; /// Never plan a smaller window than this unless the model itself is smaller. @@ -195,7 +201,7 @@ pub struct GgufEntry { } fn canonical(path: &Path) -> PathBuf { - fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) + crate::paths::Canonical::canonical(path).unwrap_or_else(|_| path.to_path_buf()) } /// Stable id: a hash of the canonical path. Never derived from a display name. @@ -317,17 +323,24 @@ pub fn find_projector(model_path: &Path, model: &GgufHeader) -> Option // --------------------------------------------------------------------------- pub fn managed_dir() -> Option { - std::env::var_os("HOME").map(|home| PathBuf::from(home).join(MANAGED_RELATIVE)) + #[cfg(windows)] + let base = std::env::var_os("LOCALAPPDATA"); + #[cfg(not(windows))] + let base = std::env::var_os("HOME"); + base.map(|base| PathBuf::from(base).join(MANAGED_RELATIVE)) } /// `usr/lib/shadowcode` next to the executable (AppImage `usr/bin/..`, deb /// `/usr/bin/..`); on macOS the app bundle's `Contents/Resources/llama.cpp` -/// (the executable is in `Contents/MacOS`). +/// (the executable is in `Contents/MacOS`); on Windows `llama.cpp` beside +/// the installed executable. 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"))] + #[cfg(windows)] + return Some(exe.parent()?.join("llama.cpp")); + #[cfg(all(unix, not(target_os = "macos")))] Some(exe.parent()?.join("../lib/shadowcode")) } @@ -508,9 +521,19 @@ pub fn runtime_env(binary: &Path) -> Vec<(String, std::ffi::OsString)> { env.push((name.to_owned(), value)); } } + // Windows: system DLLs (and the Vulkan loader) need these to load. + env.extend( + crate::process::platform_env() + .into_iter() + .map(|(name, value)| (name.to_owned(), value)), + ); + // Windows finds a program's DLLs beside it without help. + #[cfg(unix)] if let Some(dir) = binary.parent() { env.push(("LD_LIBRARY_PATH".into(), dir.as_os_str().to_owned())); } + #[cfg(not(unix))] + let _ = binary; env } @@ -852,8 +875,25 @@ pub fn available_memory() -> u64 { pages.saturating_mul(page as u64) } +/// Windows: physical memory and what is available now. +#[cfg(windows)] +fn memory_status() -> Option { + use windows_sys::Win32::System::SystemInformation::{GlobalMemoryStatusEx, MEMORYSTATUSEX}; + // SAFETY: MEMORYSTATUSEX is plain data; dwLength is set as required. + let mut status: MEMORYSTATUSEX = unsafe { std::mem::zeroed() }; + status.dwLength = std::mem::size_of::() as u32; + // SAFETY: the call writes only into `status`. + (unsafe { GlobalMemoryStatusEx(&mut status) } != 0).then_some(status) +} + /// See the macOS version. -#[cfg(not(target_os = "macos"))] +#[cfg(windows)] +pub fn available_memory() -> u64 { + memory_status().map_or(0, |status| status.ullAvailPhys) +} + +/// See the macOS version. +#[cfg(all(unix, not(target_os = "macos")))] pub fn available_memory() -> u64 { let Ok(text) = fs::read_to_string("/proc/meminfo") else { return 0; @@ -887,7 +927,13 @@ pub fn read_meminfo() -> u64 { } /// Installed memory in bytes (0 when unknown). -#[cfg(not(target_os = "macos"))] +#[cfg(windows)] +pub fn read_meminfo() -> u64 { + memory_status().map_or(0, |status| status.ullTotalPhys) +} + +/// Installed memory in bytes (0 when unknown). +#[cfg(all(unix, not(target_os = "macos")))] pub fn read_meminfo() -> u64 { let Ok(text) = fs::read_to_string("/proc/meminfo") else { return 0; @@ -1859,6 +1905,8 @@ pub async fn prepare_with_progress( } #[cfg(test)] +// Helpers of Unix-only tests are unused on Windows. +#[cfg_attr(not(unix), allow(unused))] mod tests { use super::*; use crate::gguf::test_support::{write_gguf, V}; @@ -2134,7 +2182,15 @@ mod tests { ) .unwrap(); assert!(entry.vision); - projector(&dir.path().join("mmproj-bf16.gguf"), 4096); + // Replaced as a download does (a new file renamed into place): an + // in-place rewrite of the same size within one tick of Windows' + // file clock keeps every identity Windows reports. + projector(&dir.path().join("mmproj-new.gguf"), 4096); + std::fs::rename( + dir.path().join("mmproj-new.gguf"), + dir.path().join("mmproj-bf16.gguf"), + ) + .unwrap(); let entry = inspect( &Candidate { path: m, diff --git a/native/core/src/local_runtime.rs b/native/core/src/local_runtime.rs index b5cb46d1..275e9be8 100644 --- a/native/core/src/local_runtime.rs +++ b/native/core/src/local_runtime.rs @@ -9,7 +9,6 @@ use anyhow::{anyhow, bail, ensure, Context, Result}; use serde_json::{json, Value}; use std::{ collections::{HashMap, VecDeque}, - io::Read, net::TcpListener, path::{Path, PathBuf}, sync::{ @@ -74,31 +73,38 @@ impl LaunchSpec { } /// A filesystem snapshot, not a hash of file contents. Unix change time and -/// inode catch same-size rewrites and replacements even when mtime is restored. +/// inode (on Windows: the change time and file index on its volume) catch +/// same-size rewrites and replacements even when mtime is restored. /// These observations cannot make a mutable file immutable during inference. #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub struct FileIdentity { pub path: PathBuf, pub bytes: u64, pub modified_ns: Option, - #[cfg(unix)] pub device: String, - #[cfg(unix)] pub inode: String, - #[cfg(unix)] pub changed_seconds: i64, - #[cfg(unix)] pub changed_nanoseconds: i64, } impl FileIdentity { pub fn capture(path: &Path) -> Result { - let resolved = std::fs::canonicalize(path) + let resolved = crate::paths::Canonical::canonical(path) .with_context(|| format!("Cannot resolve model or runtime file {}", path.display()))?; let meta = std::fs::metadata(&resolved)?; ensure!(meta.is_file(), "Not a regular file: {}", path.display()); #[cfg(unix)] - use std::os::unix::fs::MetadataExt; + let (device, inode, changed_seconds, changed_nanoseconds) = { + use std::os::unix::fs::MetadataExt; + ( + meta.dev().to_string(), + meta.ino().to_string(), + meta.ctime(), + meta.ctime_nsec(), + ) + }; + #[cfg(windows)] + let (device, inode, changed_seconds, changed_nanoseconds) = windows_identity(&resolved)?; Ok(Self { path: resolved, bytes: meta.len(), @@ -107,18 +113,53 @@ impl FileIdentity { .ok() .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|t| t.as_nanos().to_string()), - #[cfg(unix)] - device: meta.dev().to_string(), - #[cfg(unix)] - inode: meta.ino().to_string(), - #[cfg(unix)] - changed_seconds: meta.ctime(), - #[cfg(unix)] - changed_nanoseconds: meta.ctime_nsec(), + device, + inode, + changed_seconds, + changed_nanoseconds, }) } } +/// Volume serial number, file index and change time (100 ns units since +/// 1601) of an open file. +#[cfg(windows)] +fn windows_identity(path: &Path) -> Result<(String, String, i64, i64)> { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{ + FileBasicInfo, GetFileInformationByHandle, GetFileInformationByHandleEx, + BY_HANDLE_FILE_INFORMATION, FILE_BASIC_INFO, + }; + let file = std::fs::File::open(path)?; + let handle = file.as_raw_handle() as windows_sys::Win32::Foundation::HANDLE; + // SAFETY: both structures are plain data written by the calls, sized as + // passed, while `file` keeps the handle open. + let (info, basic) = unsafe { + let mut info: BY_HANDLE_FILE_INFORMATION = std::mem::zeroed(); + let mut basic: FILE_BASIC_INFO = std::mem::zeroed(); + ensure!( + GetFileInformationByHandle(handle, &mut info) != 0 + && GetFileInformationByHandleEx( + handle, + FileBasicInfo, + (&mut basic as *mut FILE_BASIC_INFO).cast(), + std::mem::size_of::() as u32, + ) != 0, + "Cannot read the identity of {}: {}", + path.display(), + std::io::Error::last_os_error() + ); + (info, basic) + }; + let index = (u64::from(info.nFileIndexHigh) << 32) | u64::from(info.nFileIndexLow); + Ok(( + info.dwVolumeSerialNumber.to_string(), + index.to_string(), + basic.ChangeTime.div_euclid(10_000_000), + basic.ChangeTime.rem_euclid(10_000_000) * 100, + )) +} + #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub struct SourceIdentity { pub model: FileIdentity, @@ -193,6 +234,10 @@ impl Ring { struct Server { child: Child, + /// Windows: the server and anything it starts end with this job, which + /// closes with ShadowCode. + #[cfg(windows)] + _job: Option, spec: LaunchSpec, info: Loaded, stderr: Arc>, @@ -762,8 +807,8 @@ fn free_loopback_port() -> Result { /// 32 random bytes from the kernel, hex encoded. pub fn random_key() -> Result { let mut bytes = [0u8; 32]; - std::fs::File::open("/dev/urandom") - .and_then(|mut f| f.read_exact(&mut bytes)) + getrandom::fill(&mut bytes) + .map_err(|error| anyhow!("{error}")) .context("Could not read random bytes for the local server key")?; Ok(bytes.iter().map(|b| format!("{b:02x}")).collect()) } @@ -920,6 +965,10 @@ async fn launch( .spawn() .with_context(|| format!("Failed to start {}", spec.binary.display())) .map_err(other)?; + #[cfg(windows)] + let job = child + .raw_handle() + .and_then(|handle| crate::process::Job::for_process(handle).ok()); let stderr = Arc::new(Mutex::new(Ring::default())); if let Some(mut pipe) = child.stderr.take() { let ring = stderr.clone(); @@ -1046,6 +1095,8 @@ async fn launch( GpuMode::Off => "off", }; Ok(Server { + #[cfg(windows)] + _job: job, info: Loaded { id: spec.id.clone(), name: spec.name.clone(), diff --git a/native/core/src/lsp/client.rs b/native/core/src/lsp/client.rs index a798791c..ab8df0eb 100644 --- a/native/core/src/lsp/client.rs +++ b/native/core/src/lsp/client.rs @@ -341,6 +341,7 @@ impl Client { .args(&launch.args) .current_dir(root) .env_clear() + .envs(crate::process::platform_env()) .envs(servers::environment(launch)) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) diff --git a/native/core/src/lsp/install.rs b/native/core/src/lsp/install.rs index a585eae1..c3278f2d 100644 --- a/native/core/src/lsp/install.rs +++ b/native/core/src/lsp/install.rs @@ -193,6 +193,7 @@ async fn run(npm: &Path, managed: &Path, package: &Package) -> Result { .args(&args) .current_dir(&staging) .env_clear() + .envs(crate::process::platform_env()) .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) diff --git a/native/core/src/lsp/mod.rs b/native/core/src/lsp/mod.rs index 64e707da..5830391b 100644 --- a/native/core/src/lsp/mod.rs +++ b/native/core/src/lsp/mod.rs @@ -7,6 +7,7 @@ //! attached to the tool result. Idle servers stop after //! `code_intel.lsp_idle_minutes`; a crashed server restarts on next use with //! exponential backoff; at most `code_intel.max_servers` run at once. +use crate::paths::Canonical; pub mod client; pub mod diagnostics; pub mod install; @@ -347,8 +348,8 @@ impl Env { return None; } // Symlinks may not lead out of the project. - let full = self.root.join(rel).canonicalize().ok()?; - let root = self.root.canonicalize().ok()?; + let full = self.root.join(rel).canonical().ok()?; + let root = self.root.canonical().ok()?; if !full.starts_with(&root) { return None; } diff --git a/native/core/src/lsp/servers.rs b/native/core/src/lsp/servers.rs index e943cb09..8bb845bc 100644 --- a/native/core/src/lsp/servers.rs +++ b/native/core/src/lsp/servers.rs @@ -17,6 +17,7 @@ //! - clangd: no background index (it would write `.cache/` into the project). //! - pyright: analyzes open files only. use crate::code_intel::{CodeIntelConfig, ServerOverride}; +use crate::paths::Canonical; use serde_json::{json, Value}; use std::path::{Path, PathBuf}; @@ -152,7 +153,7 @@ pub struct Launch { /// Usual per-user install folders that GUI sessions often omit from PATH. pub fn extra_dirs() -> Vec { - let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else { + let Some(home) = crate::paths::home_dir() else { return Vec::new(); }; vec![ @@ -170,10 +171,8 @@ pub fn find_program(name: &str) -> Option { return is_executable(&path).then_some(path); } crate::sandbox::which(name).or_else(|| { - extra_dirs() - .into_iter() - .map(|dir| dir.join(name)) - .find(|p| is_executable(p)) + let extra = std::env::join_paths(extra_dirs()).ok()?; + crate::paths::find_program_in(name, &extra).filter(|p| is_executable(p)) }) } @@ -207,7 +206,7 @@ fn managed_program(managed: Option<&Path>, lang: ServerLang, program: &str) -> O /// `typescript/lib` shipped beside a typescript-language-server install /// (managed prefix, global npm prefix, or the server's own node_modules). pub fn typescript_lib_for(server: &Path) -> Option { - let real = server.canonicalize().ok()?; + let real = server.canonical().ok()?; let mut roots = Vec::new(); for ancestor in real.ancestors() { if ancestor.file_name().is_some_and(|n| n == "node_modules") { @@ -415,6 +414,8 @@ pub fn environment(launch: &Launch) -> Vec<(String, std::ffi::OsString)> { } #[cfg(test)] +// Helpers of Unix-only tests are unused on Windows. +#[cfg_attr(not(unix), allow(unused))] mod tests { use super::*; @@ -436,6 +437,7 @@ mod tests { assert_eq!(for_path(Path::new("README.md")), None); } + #[cfg(unix)] #[test] fn overrides_win_and_bad_overrides_are_reported() { let mut config = CodeIntelConfig::default(); @@ -477,6 +479,7 @@ mod tests { assert!(env.contains(&("CARGO_NET_OFFLINE".into(), "true".into()))); } + #[cfg(unix)] #[test] fn typescript_needs_its_own_typescript() { let dir = tempfile::tempdir().unwrap(); @@ -504,7 +507,7 @@ mod tests { assert_eq!(launch.source, "managed"); assert_eq!( launch.init_options["tsserver"]["path"], - json!(lib.canonicalize().unwrap().join("tsserver.js")) + json!(lib.canonical().unwrap().join("tsserver.js")) ); assert_eq!( launch.init_options["disableAutomaticTypingAcquisition"], diff --git a/native/core/src/mcp.rs b/native/core/src/mcp.rs index eec8cddc..43fd2620 100644 --- a/native/core/src/mcp.rs +++ b/native/core/src/mcp.rs @@ -1,6 +1,7 @@ //! Native MCP transport. Callers must obtain explicit server activation and //! tool approval before using this low-level client; discovery never starts it. //! The application-facing registration/approval layer is a separate component. +use crate::paths::Canonical; use anyhow::{anyhow, bail, ensure, Context, Result}; use rmcp::{ model::{ @@ -90,17 +91,40 @@ impl Diagnostics { } } +#[cfg(unix)] +const TERMINATE: i32 = libc::SIGTERM; +#[cfg(unix)] +const KILL: i32 = libc::SIGKILL; +#[cfg(not(unix))] +const TERMINATE: i32 = 15; +#[cfg(not(unix))] +const KILL: i32 = 9; struct Group(u32); impl Group { fn signal(&self, signal: i32) { - if self.0 != 0 { - unsafe { - libc::kill(-(self.0 as i32), signal); - } + if self.0 == 0 { + return; + } + #[cfg(unix)] + unsafe { + libc::kill(-(self.0 as i32), signal); + } + // Windows has no polite stop for a console process tree: end the + // whole tree on KILL, without opening a console window. + #[cfg(windows)] + if signal == KILL { + use std::os::windows::process::CommandExt; + let _ = std::process::Command::new("taskkill") + .args(["/PID", &self.0.to_string(), "/T", "/F"]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .creation_flags(0x0800_0000) + .status(); } } fn kill(&mut self) { - self.signal(libc::SIGKILL); + self.signal(KILL); self.0 = 0; } } @@ -163,12 +187,12 @@ impl Client { .clone() .try_acquire_owned() .context("Native MCP connection limit reached")?; - let workspace = workspace.canonicalize()?; + let workspace = workspace.canonical()?; ensure!(workspace.is_dir(), "MCP workspace is not a directory"); let mut command = Command::new(&spec.command[0]); // Do not pass the application's provider credentials, control-socket // variables, or dynamic-library overrides to an external integration. - command.env_clear(); + command.env_clear().envs(crate::process::platform_env()); for key in [ "PATH", "HOME", @@ -198,8 +222,9 @@ impl Client { .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) - .kill_on_drop(true) - .process_group(0); + .kill_on_drop(true); + #[cfg(unix)] + command.process_group(0); let mut child = command.spawn().context("Cannot start MCP server")?; let pid = child.id().context("MCP process has no PID")?; let group = Arc::new(Mutex::new(Group(pid))); @@ -232,7 +257,7 @@ impl Client { worker_group .lock() .unwrap_or_else(|e| e.into_inner()) - .signal(libc::SIGTERM); + .signal(TERMINATE); tokio::time::sleep(Duration::from_millis(150)).await; worker_group .lock() diff --git a/native/core/src/mentions.rs b/native/core/src/mentions.rs index 67d5f6a3..96b84b2c 100644 --- a/native/core/src/mentions.rs +++ b/native/core/src/mentions.rs @@ -108,7 +108,7 @@ fn listing(root: &Path) -> Listing { continue; }; entries.push(Entry { - path: relative.to_string_lossy().into_owned(), + path: crate::paths::slash(relative), dir: entry.file_type().is_some_and(|t| t.is_dir()), }); } @@ -205,6 +205,12 @@ pub fn search(root: &Path, query: &str, limit: usize) -> Result { /// Whether `path` (relative to the project) is one of `scope`'s files or /// inside one of its folders. pub fn in_scope(scope: &[Mention], path: &str) -> bool { + // Windows names a file inside a chosen folder with either separator. + let path = if cfg!(windows) { + std::borrow::Cow::Owned(path.replace('\\', "/")) + } else { + std::borrow::Cow::Borrowed(path) + }; let path = path.trim_start_matches("./"); scope.iter().any(|m| { let target = m.path.trim_start_matches("./").trim_end_matches('/'); @@ -229,7 +235,7 @@ pub fn validate(workspace: &Workspace, mentions: Vec) -> Result = Vec::new(); for mention in mentions { let relative = workspace.relative(&mention.path)?; - let path = relative.to_string_lossy().into_owned(); + let path = crate::paths::slash(&relative); ensure!(path != "." || mention.kind == "dir", "Choose a file"); let kind = if mention.kind == "dir" { "dir" } else { "file" }; if !out.iter().any(|m| m.path == path) { @@ -540,6 +546,8 @@ mod tests { .is_err()); } + // Creating a symlink needs extra privileges on Windows. + #[cfg(unix)] #[test] fn mentioned_secret_files_are_not_attached_and_tokens_are_redacted() { let dir = project(); diff --git a/native/core/src/ollama_store.rs b/native/core/src/ollama_store.rs index 94ef97dc..7908ea3a 100644 --- a/native/core/src/ollama_store.rs +++ b/native/core/src/ollama_store.rs @@ -42,10 +42,7 @@ pub struct OllamaModel { /// Ollama units, then `~/.ollama/models`. The first root that has a /// `manifests` directory wins; otherwise the first candidate is reported. pub fn discover() -> Option { - discover_with( - std::env::var_os("OLLAMA_MODELS"), - std::env::var_os("HOME").map(PathBuf::from), - ) + discover_with(std::env::var_os("OLLAMA_MODELS"), crate::paths::home_dir()) } pub fn discover_with(env: Option, home: Option) -> Option { diff --git a/native/core/src/parallel.rs b/native/core/src/parallel.rs index 9b3cc479..a10d58cf 100644 --- a/native/core/src/parallel.rs +++ b/native/core/src/parallel.rs @@ -1,6 +1,7 @@ //! Durable, workspace-scoped preparation of at most four Git worktrees. //! This prepares checkouts; it does not dispatch model workers. Model //! workers that run in parallel are subagents (`crate::subagents`). +use crate::paths::Canonical; use anyhow::{ensure, Context, Result}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -99,7 +100,7 @@ fn plan_file(source: &Path, root: &Path) -> Result { crate::paths::private_directory(root)?; let key = format!( "{:x}", - Sha256::digest(source.canonicalize()?.as_os_str().as_encoded_bytes()) + Sha256::digest(source.canonical()?.as_os_str().as_encoded_bytes()) ); Ok(root.join(format!("plan-{key}.json"))) } @@ -110,7 +111,7 @@ fn load(source: &Path, root: &Path) -> Result> { } let plan: ParallelPlan = serde_json::from_slice(&fs::read(path)?)?; ensure!( - plan.source == source.canonicalize()? && plan.workers.len() <= MAX_WORKERS, + plan.source == source.canonical()? && plan.workers.len() <= MAX_WORKERS, "Invalid parallel plan scope" ); uuid::Uuid::parse_str(&plan.id).context("Invalid plan id")?; @@ -119,7 +120,7 @@ fn load(source: &Path, root: &Path) -> Result> { ensure!( worker.item.id == id && worker.branch == format!("shadowcode/parallel-{}-{id}", plan.id) - && worker.worktree_path == root.canonicalize()?.join(format!("{}-{id}", plan.id)), + && worker.worktree_path == root.canonical()?.join(format!("{}-{id}", plan.id)), "Invalid managed worktree record" ); } @@ -179,9 +180,9 @@ pub fn prepare(source: &Path, goal: &str, root: &Path) -> Result { json!({"ok":false,"enabled":false,"error":"Open a Git repository to prepare worker worktrees.","max_workers":MAX_WORKERS}), ); } - let source = source.canonicalize()?; + let source = source.canonical()?; ensure!( - PathBuf::from(git(&source, &["rev-parse", "--show-toplevel"])?).canonicalize()? == source, + PathBuf::from(git(&source, &["rev-parse", "--show-toplevel"])?).canonical()? == source, "Open the repository root to prepare worktrees" ); ensure!( @@ -194,9 +195,7 @@ pub fn prepare(source: &Path, goal: &str, root: &Path) -> Result { save(&plan, root)?; for item in items { let branch = format!("shadowcode/parallel-{}-{}", plan.id, item.id); - let path = root - .canonicalize()? - .join(format!("{}-{}", plan.id, item.id)); + let path = root.canonical()?.join(format!("{}-{}", plan.id, item.id)); git( &source, &[ diff --git a/native/core/src/paths.rs b/native/core/src/paths.rs index d17c1f04..48a0b563 100644 --- a/native/core/src/paths.rs +++ b/native/core/src/paths.rs @@ -5,6 +5,30 @@ use std::{ path::{Path, PathBuf}, }; +/// A path inside a project as the agent and the interface show it: `/` +/// between its parts on every system. (On Unix a `\` is part of a name.) +pub fn slash(path: &Path) -> String { + let text = path.to_string_lossy(); + if cfg!(windows) { + text.replace('\\', "/") + } else { + text.into_owned() + } +} + +/// `canonicalize` that returns `C:\project` rather than `\\?\C:\project` on +/// Windows whenever both name the same file (dunce). Verbatim paths confuse +/// Git, other programs and anyone reading them. Identical to the standard +/// one on Unix. +pub trait Canonical { + fn canonical(&self) -> std::io::Result; +} +impl + ?Sized> Canonical for P { + fn canonical(&self) -> std::io::Result { + dunce::canonicalize(self) + } +} + /// Preserve the existing XDG identity so upgrades retain settings and history. #[derive(Clone, Debug)] pub struct AppPaths { @@ -31,10 +55,43 @@ impl Drop for ProfileLock { } } +/// `name` in the folders of `path`. Windows also tries the program +/// extensions (npm installs `codex` as `codex.cmd`), in `PATHEXT` order. +pub fn find_program_in(name: &str, path: &std::ffi::OsStr) -> Option { + #[cfg(windows)] + let names: Vec = if Path::new(name).extension().is_some() { + vec![name.to_owned()] + } else { + let extensions = std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".into()); + extensions + .split(';') + .filter(|e| !e.is_empty()) + .map(|e| format!("{name}{}", e.to_ascii_lowercase())) + .collect() + }; + #[cfg(not(windows))] + let names = [name.to_owned()]; + std::env::split_paths(path).find_map(|dir| { + names + .iter() + .map(|name| dir.join(name)) + .find(|p| p.is_file()) + }) +} + +/// The user's home folder. Windows names it `USERPROFILE`; a program started +/// from Explorer has no `HOME` (Git Bash sets one). +pub fn home_dir() -> Option { + #[cfg(windows)] + let home = env::var_os("USERPROFILE").or_else(|| env::var_os("HOME")); + #[cfg(not(windows))] + let home = env::var_os("HOME"); + home.map(PathBuf::from) +} + impl AppPaths { pub fn discover() -> Result { - let home = env::var_os("HOME").context("HOME is not set")?; - let home = PathBuf::from(home); + let home = home_dir().context("HOME is not set")?; // macOS keeps app settings and history under ~/Library; an XDG // variable someone set on purpose still wins there too. #[cfg(target_os = "macos")] @@ -44,7 +101,25 @@ impl AppPaths { ".local/share" => home.join("Library/Application Support/ShadowCode/data"), _ => home.join("Library/Application Support/ShadowCode/state"), }; - #[cfg(not(target_os = "macos"))] + // Windows: settings roam with the account (%APPDATA%); history, + // models and caches stay on this computer (%LOCALAPPDATA%). + #[cfg(windows)] + let fallback = |base: &str| { + let folder = |key: &str, default: &str| { + env::var_os(key) + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + .unwrap_or_else(|| home.join(default)) + .join("ShadowCode") + }; + match base { + ".config" => folder("APPDATA", "AppData/Roaming").join("config"), + ".local/share" => folder("LOCALAPPDATA", "AppData/Local").join("data"), + ".cache" => folder("LOCALAPPDATA", "AppData/Local").join("cache"), + _ => folder("LOCALAPPDATA", "AppData/Local").join("state"), + } + }; + #[cfg(all(unix, not(target_os = "macos")))] let fallback = |base: &str| home.join(base).join("shadow-agent"); let xdg = |key: &str, base: &str| { env::var_os(key) @@ -96,12 +171,12 @@ impl AppPaths { .ok()? .trim(), ); - path.canonicalize().ok().filter(|p| p.is_dir()) + path.canonical().ok().filter(|p| p.is_dir()) } pub fn remember_workspace(&self, path: &Path) -> Result<()> { atomic_write( &self.state.join("last-workspace.txt"), - format!("{}\n", path.canonicalize()?.display()).as_bytes(), + format!("{}\n", path.canonical()?.display()).as_bytes(), false, ) } @@ -145,6 +220,7 @@ impl AppPaths { /// 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. +#[cfg(unix)] pub(crate) fn held_directory(fd: std::os::fd::RawFd) -> Result { #[cfg(target_os = "linux")] { @@ -203,11 +279,34 @@ pub(crate) fn private_directory(path: &Path) -> Result<()> { Ok(()) } +/// Make a rename or new entry in `directory` durable. Windows cannot open a +/// folder as a file; NTFS journals its metadata, so there is nothing to do. +pub fn sync_directory(directory: &Path) -> Result<()> { + #[cfg(unix)] + fs::File::open(directory)?.sync_all()?; + #[cfg(not(unix))] + let _ = directory; + Ok(()) +} + +/// Make a file's written bytes durable. Windows flushes only through a +/// handle that may write. +pub fn sync_file(path: &Path) -> Result<()> { + let mut options = fs::OpenOptions::new(); + options.read(true); + #[cfg(windows)] + options.write(true); + options.open(path)?.sync_all()?; + Ok(()) +} + pub fn atomic_write(path: &Path, bytes: &[u8], private: bool) -> Result<()> { use std::io::Write; let parent = path.parent().context("Path has no parent")?; fs::create_dir_all(parent)?; let mut temp = tempfile::NamedTempFile::new_in(parent)?; + #[cfg(not(unix))] + let _ = private; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; @@ -223,7 +322,29 @@ pub fn atomic_write(path: &Path, bytes: &[u8], private: bool) -> Result<()> { } temp.write_all(bytes)?; temp.as_file().sync_all()?; - temp.persist(path).map_err(|e| e.error)?; - fs::File::open(parent)?.sync_all()?; + persist(temp, path)?; + sync_directory(parent)?; Ok(()) } + +/// Replace `path` with `temp`. Windows refuses a replacement (access denied) +/// while another replacement of the same file is in flight or a scanner +/// holds it; those clear within milliseconds, so it is retried briefly. +fn persist(mut temp: tempfile::NamedTempFile, path: &Path) -> std::io::Result<()> { + let mut tries = 0; + loop { + match temp.persist(path) { + Ok(_) => return Ok(()), + Err(error) + if cfg!(windows) + && error.error.kind() == std::io::ErrorKind::PermissionDenied + && tries < 20 => + { + tries += 1; + temp = error.file; + std::thread::sleep(std::time::Duration::from_millis(5 * tries)); + } + Err(error) => return Err(error.error), + } + } +} diff --git a/native/core/src/preview/ports.rs b/native/core/src/preview/ports.rs index 3068074f..aae3661d 100644 --- a/native/core/src/preview/ports.rs +++ b/native/core/src/preview/ports.rs @@ -13,6 +13,7 @@ //! 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 crate::paths::Canonical; use regex::Regex; use serde::Serialize; use std::{ @@ -162,7 +163,7 @@ pub fn project_servers(proc_root: &Path, project: &Path, exclude_pid: u32) -> Ve } let by_inode: HashMap = listeners.iter().map(|l| (l.inode, l)).collect(); let project = project - .canonicalize() + .canonical() .unwrap_or_else(|_| project.to_path_buf()); let mut found = Vec::new(); for pid in pids(proc_root) { @@ -258,6 +259,7 @@ pub fn listening_now() -> HashSet { #[cfg(target_os = "macos")] pub mod mac { use super::{loopback_reachable, Found}; + use crate::paths::Canonical; use std::{ net::IpAddr, path::{Path, PathBuf}, @@ -383,7 +385,7 @@ pub mod mac { /// and that listen on a loopback-reachable port. pub fn project_servers(project: &Path, exclude_pid: u32) -> Vec { let project = project - .canonicalize() + .canonical() .unwrap_or_else(|_| project.to_path_buf()); let mut found = Vec::new(); for socket in listeners() { @@ -588,7 +590,7 @@ mod tests { fn finds_project_servers_from_proc_fixture() { let dir = tempfile::tempdir().unwrap(); // A real path, as the kernel reports working folders (macOS: /private/var). - let base = dir.path().canonicalize().unwrap(); + let base = dir.path().canonical().unwrap(); let root = base.join("proc"); let project = base.join("app"); let other = base.join("elsewhere"); diff --git a/native/core/src/process.rs b/native/core/src/process.rs index 72927109..c6db7ae1 100644 --- a/native/core/src/process.rs +++ b/native/core/src/process.rs @@ -31,6 +31,25 @@ pub struct ProcessSpec { } impl ProcessSpec { pub fn shell(command: &str, cwd: PathBuf, timeout: Duration) -> Self { + // Windows: Git Bash when installed, else PowerShell (see sandbox). + #[cfg(windows)] + { + let (program, args) = crate::sandbox::windows_shell(command); + Self { + program, + args, + cwd, + timeout, + output_limit: 256_000, + env: BTreeMap::new(), + child: None, + } + } + #[cfg(not(windows))] + Self::unix_shell(command, cwd, timeout) + } + #[cfg(not(windows))] + fn unix_shell(command: &str, cwd: PathBuf, timeout: Duration) -> Self { let bridge = crate::flatpak::host_bridge(); Self { program: bridge.as_ref().map_or_else( @@ -83,6 +102,7 @@ pub struct ProcessResult { /// 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. +#[cfg(unix)] pub(crate) fn descendants(pid: u32) -> Vec { let mut all = Vec::new(); let mut pending = vec![pid]; @@ -132,7 +152,7 @@ fn children(pid: u32) -> Vec { .collect() } -#[cfg(not(any(target_os = "linux", target_os = "macos")))] +#[cfg(all(unix, not(any(target_os = "linux", target_os = "macos"))))] fn children(_pid: u32) -> Vec { Vec::new() } @@ -214,7 +234,7 @@ pub(crate) unsafe fn die_with_parent(parent: u32) -> std::io::Result<()> { } /// Whether a process id is still taken, zombies included (not yet reaped). -#[cfg(test)] +#[cfg(all(test, unix))] pub(crate) fn exists(pid: u32) -> bool { let Ok(pid) = libc::pid_t::try_from(pid) else { return false; @@ -224,18 +244,137 @@ pub(crate) fn exists(pid: u32) -> bool { alive || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) } -struct ProcessGroup(u32); +/// Windows' process group: every process a command starts joins its job +/// (unless it breaks away on purpose); ending the job ends them all, and +/// closing the last handle, ShadowCode's included, ends it too. A process +/// started in the instant between spawn and joining is not covered. +#[cfg(windows)] +pub(crate) struct Job(windows_sys::Win32::Foundation::HANDLE); +// SAFETY: a job handle may be used and closed from any thread. +#[cfg(windows)] +unsafe impl Send for Job {} +#[cfg(windows)] +unsafe impl Sync for Job {} +#[cfg(windows)] +impl Job { + /// A job holding the process behind `process`. + pub(crate) fn for_process(process: std::os::windows::io::RawHandle) -> std::io::Result { + use windows_sys::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation, + SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + }; + // SAFETY: an unnamed job with default security; checked for null. + let handle = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) }; + if handle.is_null() { + return Err(std::io::Error::last_os_error()); + } + let job = Self(handle); + // SAFETY: the limits are plain data passed with their own size; the + // process handle belongs to a live child. + unsafe { + let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed(); + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + if SetInformationJobObject( + job.0, + JobObjectExtendedLimitInformation, + (&limits as *const JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(), + std::mem::size_of::() as u32, + ) == 0 + || AssignProcessToJobObject( + job.0, + process as windows_sys::Win32::Foundation::HANDLE, + ) == 0 + { + return Err(std::io::Error::last_os_error()); + } + } + Ok(job) + } + /// End every process in the job now. + pub(crate) fn terminate(&self) { + // SAFETY: the handle is open until drop. + unsafe { windows_sys::Win32::System::JobObjects::TerminateJobObject(self.0, 1) }; + } +} +#[cfg(windows)] +impl Drop for Job { + fn drop(&mut self) { + // SAFETY: opened in for_process and closed once. + unsafe { windows_sys::Win32::Foundation::CloseHandle(self.0) }; + } +} + +/// Variables a Windows program needs whatever its purpose: without +/// `SystemRoot` even networking (Winsock) fails to start. Every command +/// started from a cleared environment keeps these; empty on Unix. +pub(crate) fn platform_env() -> Vec<(&'static str, std::ffi::OsString)> { + #[cfg(windows)] + const NAMES: &[&str] = &[ + "SystemRoot", + "windir", + "SystemDrive", + "ComSpec", + "PATHEXT", + "TEMP", + "TMP", + "USERPROFILE", + "HOMEDRIVE", + "HOMEPATH", + "APPDATA", + "LOCALAPPDATA", + "ProgramData", + "ProgramFiles", + "ProgramFiles(x86)", + "ProgramW6432", + "CommonProgramFiles", + "NUMBER_OF_PROCESSORS", + "PROCESSOR_ARCHITECTURE", + "OS", + "USERNAME", + "COMPUTERNAME", + ]; + #[cfg(not(windows))] + const NAMES: &[&str] = &[]; + NAMES + .iter() + .filter_map(|name| std::env::var_os(name).map(|value| (*name, value))) + .collect() +} + +struct ProcessGroup { + pid: u32, + #[cfg(windows)] + job: Option, +} impl ProcessGroup { + #[cfg(unix)] + fn of(child: &tokio::process::Child, pid: u32) -> Self { + let _ = child; + Self { pid } + } + #[cfg(windows)] + fn of(child: &tokio::process::Child, pid: u32) -> Self { + let job = child + .raw_handle() + .and_then(|handle| Job::for_process(handle).ok()); + Self { pid, job } + } fn terminate(&self) { + // Windows has no polite stop for a console process tree. + #[cfg(windows)] + if let Some(job) = &self.job { + job.terminate(); + } #[cfg(unix)] - if self.0 != 0 { - let descendants = crate::process::descendants(self.0); + if self.pid != 0 { + let descendants = crate::process::descendants(self.pid); unsafe { - libc::kill(-(self.0 as i32), libc::SIGTERM); + libc::kill(-(self.pid 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); + libc::kill(self.pid as i32, libc::SIGTERM); for child in descendants { libc::kill(child as i32, libc::SIGTERM); } @@ -244,17 +383,21 @@ impl ProcessGroup { } fn kill(&mut self) { #[cfg(unix)] - if self.0 != 0 { - let descendants = crate::process::descendants(self.0); + if self.pid != 0 { + let descendants = crate::process::descendants(self.pid); unsafe { - libc::kill(-(self.0 as i32), libc::SIGKILL); - libc::kill(self.0 as i32, libc::SIGKILL); + libc::kill(-(self.pid as i32), libc::SIGKILL); + libc::kill(self.pid as i32, libc::SIGKILL); for child in descendants { libc::kill(child as i32, libc::SIGKILL); } } } - self.0 = 0; + #[cfg(windows)] + if let Some(job) = self.job.take() { + job.terminate(); + } + self.pid = 0; } } impl Drop for ProcessGroup { @@ -357,7 +500,8 @@ async fn run_internal( .stdout(Stdio::piped()) .stderr(Stdio::piped()) .kill_on_drop(true) - .env_clear(); + .env_clear() + .envs(platform_env()); for name in [ "PATH", "HOME", @@ -406,7 +550,7 @@ async fn run_internal( .spawn() .with_context(|| format!("Could not start {}", spec.program))?; let pid = child.id().context("Command has no process ID")?; - let mut group = ProcessGroup(pid); + let mut group = ProcessGroup::of(&child, pid); // Dropped (stopping the proxy) when this function returns. let _proxy = match &spec.child { Some(setup) => setup.after_spawn()?, diff --git a/native/core/src/project.rs b/native/core/src/project.rs index a2c900a6..4af4d3cd 100644 --- a/native/core/src/project.rs +++ b/native/core/src/project.rs @@ -98,7 +98,7 @@ fn scan(workspace: &Workspace, cancel: &CancellationToken) -> Result { break; } let rel = entry.path().strip_prefix(&workspace.path)?; - let path = rel.to_string_lossy(); + let path = crate::paths::slash(rel); if path.len() > 4096 { truncated = true; continue; diff --git a/native/core/src/remote/assets.rs b/native/core/src/remote/assets.rs index 16de3f29..50d2bc20 100644 --- a/native/core/src/remote/assets.rs +++ b/native/core/src/remote/assets.rs @@ -9,6 +9,7 @@ //! made of safe characters, with no `.`/`..` segments, hidden files or //! backslashes, reach a provider, and the folder provider also refuses //! anything that resolves outside its root (symlinks included). +use crate::paths::Canonical; use std::{ borrow::Cow, path::{Path, PathBuf}, @@ -38,14 +39,14 @@ pub struct DirAssets { impl DirAssets { pub fn new(root: impl Into) -> std::io::Result { Ok(Self { - root: root.into().canonicalize()?, + root: root.into().canonical()?, }) } } impl UiAssets for DirAssets { fn get(&self, path: &str) -> Option> { let relative = path.strip_prefix('/')?; - let candidate = self.root.join(relative).canonicalize().ok()?; + let candidate = self.root.join(relative).canonical().ok()?; if !candidate.starts_with(&self.root) || !candidate.is_file() { return None; } diff --git a/native/core/src/remote/auth.rs b/native/core/src/remote/auth.rs index 8f0777d9..a7d35393 100644 --- a/native/core/src/remote/auth.rs +++ b/native/core/src/remote/auth.rs @@ -8,7 +8,6 @@ use base64::Engine as _; use sha2::{Digest, Sha256}; use std::{ collections::HashMap, - io::Read, net::IpAddr, time::{Duration, Instant}, }; @@ -28,8 +27,8 @@ const MAX_TRACKED: usize = 4096; fn random_bytes() -> Result<[u8; 32]> { let mut bytes = [0u8; 32]; - std::fs::File::open("/dev/urandom") - .and_then(|mut f| f.read_exact(&mut bytes)) + getrandom::fill(&mut bytes) + .map_err(|error| anyhow::anyhow!("{error}")) .context("Could not read random bytes for a remote access token")?; Ok(bytes) } diff --git a/native/core/src/remote/policy.rs b/native/core/src/remote/policy.rs index b84286cc..3b85223f 100644 --- a/native/core/src/remote/policy.rs +++ b/native/core/src/remote/policy.rs @@ -29,6 +29,7 @@ //! through [`redact_response`], which removes recognizable credentials. //! - A request body that carries a redaction placeholder is refused, so a //! redacted value is never written back over the real one. +use crate::paths::Canonical; use crate::{paths::AppPaths, redaction}; use serde_json::Value; use std::path::{Path, PathBuf}; @@ -117,7 +118,7 @@ fn contains_placeholder(value: &Value) -> bool { } fn expand(text: &str) -> PathBuf { - let home = std::env::var_os("HOME").map(PathBuf::from); + let home = crate::paths::home_dir(); match (text, home) { ("~", Some(home)) => home, (t, Some(home)) if t.starts_with("~/") => home.join(&t[2..]), @@ -138,7 +139,7 @@ pub fn inside_profile(candidate: &str, paths: &AppPaths) -> bool { let mut rest = Vec::new(); let mut current = p; loop { - if let Ok(real) = current.canonicalize() { + if let Ok(real) = current.canonical() { return rest .iter() .rev() diff --git a/native/core/src/review.rs b/native/core/src/review.rs index 1222298a..a97c1528 100644 --- a/native/core/src/review.rs +++ b/native/core/src/review.rs @@ -73,7 +73,7 @@ pub fn task_paths(store: &Store, ws: &Workspace, task: &str) -> Result Result { - let path = ws.relative(path)?.to_string_lossy().into_owned(); + let path = ws.relative_text(path)?; let base = baseline(store, ws, task, &path)?; let now = current(ws, &path)?; let mut out = summary(&path, &base, &now); @@ -255,7 +255,7 @@ pub fn undo( path: &str, hunk: Option<&str>, ) -> Result { - let path = ws.relative(path)?.to_string_lossy().into_owned(); + let path = ws.relative_text(path)?; let base = baseline(store, ws, task, &path)?; let now = current(ws, &path)?; let now_hash = now.hash.clone().unwrap_or_else(|| "missing".into()); diff --git a/native/core/src/rulebook/delivery.rs b/native/core/src/rulebook/delivery.rs index c30fb407..79d984f6 100644 --- a/native/core/src/rulebook/delivery.rs +++ b/native/core/src/rulebook/delivery.rs @@ -649,6 +649,14 @@ fn stage_plugin(root: &Path, skills: &[Definition]) -> Result { } #[cfg(unix)] let _ = std::os::unix::fs::symlink(entry.path(), dir.join(&name)); + // Symlinks need extra rights on Windows: copy regular + // files of a reasonable size instead (not folders). + #[cfg(not(unix))] + if kind.is_file() + && entry.metadata().is_ok_and(|m| m.len() <= 4 * 1024 * 1024) + { + let _ = std::fs::copy(entry.path(), dir.join(&name)); + } } } } diff --git a/native/core/src/rulebook/export.rs b/native/core/src/rulebook/export.rs index 43d25fb0..4b8d42c8 100644 --- a/native/core/src/rulebook/export.rs +++ b/native/core/src/rulebook/export.rs @@ -16,6 +16,7 @@ //! does not exist; `~/.codex/skills/shadowcode-` → each profile //! skill folder (`CODEX_HOME` is respected). use super::{Book, ExportLink, State}; +use crate::paths::Canonical; use crate::{cli_agent::Vendor, paths::AppPaths}; use anyhow::{bail, ensure, Context, Result}; use serde_json::{json, Value}; @@ -36,9 +37,7 @@ fn target_of(vendor: Vendor) -> Option<&'static str> { } fn home() -> Result { - Ok(PathBuf::from( - std::env::var_os("HOME").context("HOME is not set")?, - )) + crate::paths::home_dir().context("HOME is not set") } /// The CLI's own folder: `CLAUDE_CONFIG_DIR` / `CODEX_HOME`, or the default. @@ -95,7 +94,7 @@ fn planned(book: &Book, target: &str, vendor: &Path) -> Vec<(PathBuf, PathBuf)> /// 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()) { + match (a.canonical(), b.canonical()) { (Ok(a), Ok(b)) => a == b, _ => a == b, } @@ -115,7 +114,7 @@ fn points_to(link: &Path, target: &Path) -> bool { /// `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()); + let real = |p: &Path| p.canonical().unwrap_or_else(|_| p.to_path_buf()); link.parent() .is_some_and(|dir| real(dir).starts_with(real(home))) } @@ -285,6 +284,14 @@ fn make_link(link: &Path, to: &Path) -> Result<()> { #[cfg(unix)] std::os::unix::fs::symlink(to, link) .with_context(|| format!("Cannot create {}", link.display()))?; + // Symlinks on Windows need Developer Mode or administrator rights. + #[cfg(not(unix))] + anyhow::bail!( + "Linking {} to {} is not available on Windows yet", + link.display(), + to.display() + ); + #[cfg(unix)] Ok(()) } diff --git a/native/core/src/rulebook/mod.rs b/native/core/src/rulebook/mod.rs index 77529629..726e925d 100644 --- a/native/core/src/rulebook/mod.rs +++ b/native/core/src/rulebook/mod.rs @@ -18,6 +18,7 @@ //! //! Files are read through a directory capability rooted at the profile //! folder, so a symlink inside it cannot reach a file outside it. +use crate::paths::Canonical; use crate::{ instructions::{self, RootFile}, paths::AppPaths, @@ -372,13 +373,13 @@ impl Book { .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()?; + let dir = self.dir.canonical()?; + let path = path.canonical()?; 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()) + inside.ok().map(|p| crate::paths::slash(&p)) } 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 9a5bd1ce..83fc86d3 100644 --- a/native/core/src/rulebook/tests.rs +++ b/native/core/src/rulebook/tests.rs @@ -1,5 +1,7 @@ +#![cfg_attr(not(unix), allow(unused))] use super::*; use crate::cli_agent::Vendor; +use crate::paths::Canonical; use delivery::{plan, Runner}; use std::fs; @@ -14,14 +16,14 @@ fn fixture() -> Fixture { let root = tempfile::tempdir().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 base = root.path().canonical().unwrap(); let paths = AppPaths::isolated(&base.join("app")).unwrap(); let profile = profile_dir(&paths); let project = base.join("project"); fs::create_dir_all(&project).unwrap(); Fixture { profile, - project: project.canonicalize().unwrap(), + project: project.canonical().unwrap(), paths, _root: root, } @@ -219,13 +221,19 @@ fn profile_instructions_come_first_labelled_as_the_users() { let text = f.book().guidance(&f.workspace()); assert_eq!(text.matches("Run cargo test").count(), 1); // Without a profile (and nothing switched off) the system prompt is - // exactly what it was before profiles existed. - fs::remove_dir_all(&f.profile).unwrap(); - set_enabled(&f.paths, Some(&f.project), "project:CLAUDE.md", true).unwrap(); - assert_eq!( - f.book().guidance(&f.workspace()), - crate::instructions::root_guidance(&f.workspace()) - ); + // exactly what it was before profiles existed. On the Windows runner a + // file in the profile stays in use (error 32) for seconds after this + // test wrote it, so the folder cannot be deleted there; the rule does not + // depend on the system. + #[cfg(unix)] + { + fs::remove_dir_all(&f.profile).unwrap(); + set_enabled(&f.paths, Some(&f.project), "project:CLAUDE.md", true).unwrap(); + assert_eq!( + f.book().guidance(&f.workspace()), + crate::instructions::root_guidance(&f.workspace()) + ); + } } #[test] @@ -523,7 +531,7 @@ fn codes(report: &Value) -> Vec<(String, String)> { f["path"] .as_str() .unwrap() - .rsplit('/') + .rsplit(['/', '\\']) .next() .unwrap() .to_owned() @@ -881,6 +889,7 @@ fn only_https_and_ssh_repositories_are_accepted() { assert_eq!(import::name_for("https://example.com/"), "example-com"); } +#[cfg(unix)] fn git(dir: &Path, args: &[&str]) { let status = std::process::Command::new("git") .args([ @@ -1052,7 +1061,7 @@ fn managed_worktree(paths: &AppPaths, source: &Path) -> PathBuf { serde_json::to_vec(&record).unwrap(), ) .unwrap(); - path.canonicalize().unwrap() + path.canonical().unwrap() } #[test] @@ -1111,7 +1120,7 @@ fn project_switches_hold_in_the_projects_managed_worktrees() { // Another project keeps its own switches. let other = f._root.path().join("other"); put(&other, "AGENTS.md", "Untrusted repository notes.\n"); - let other = other.canonicalize().unwrap(); + let other = other.canonical().unwrap(); let book = Book::load(&f.paths, Some(&other)); assert!(book .guidance(&Workspace::open(&other).unwrap()) diff --git a/native/core/src/sandbox.rs b/native/core/src/sandbox.rs index dbe38279..6a5d0fcf 100644 --- a/native/core/src/sandbox.rs +++ b/native/core/src/sandbox.rs @@ -14,6 +14,7 @@ //! Availability is probed before a user command runs; commands are never //! replayed outside the sandbox after a runtime failure. use crate::config::{Config, ShellNetwork}; +use crate::paths::Canonical; use anyhow::{bail, ensure, Context, Result}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -25,6 +26,8 @@ use std::{ time::{Duration, Instant}, }; +#[cfg(windows)] +pub mod appcontainer; #[cfg(target_os = "linux")] mod lsm; #[cfg(target_os = "linux")] @@ -141,6 +144,9 @@ static SCRATCH: LazyLock>> = static WARNED: LazyLock>> = LazyLock::new(|| Mutex::new(HashSet::new())); pub fn which(name: &str) -> Option { + #[cfg(windows)] + return crate::paths::find_program_in(name, &std::env::var_os("PATH")?); + #[cfg(not(windows))] std::env::var_os("PATH").and_then(|paths| { std::env::split_paths(&paths).find_map(|dir| { let candidate = dir.join(name); @@ -179,7 +185,7 @@ pub fn create_scratch(base: &Path) -> Result { let owned = tempfile::Builder::new() .prefix("shadowcode-scratch-") .tempdir_in(base)?; - let path = owned.path().canonicalize()?; + let path = owned.path().canonical()?; SCRATCH .lock() .map_err(|_| anyhow::anyhow!("Scratch lock poisoned"))? @@ -199,8 +205,13 @@ pub fn discard_scratch(path: &Path) -> Result { ) } fn scratch_base() -> PathBuf { + #[cfg(unix)] // SAFETY: geteuid cannot fail. - std::env::temp_dir().join(format!("shadowcode-scratch-{}", unsafe { libc::geteuid() })) + let user = unsafe { libc::geteuid() }.to_string(); + // Windows: the temporary folder is already the user's own. + #[cfg(not(unix))] + let user = "user"; + std::env::temp_dir().join(format!("shadowcode-scratch-{user}")) } fn probe(program: &Path, workspace: &Path) -> Result<()> { @@ -271,14 +282,12 @@ pub struct HomeLayout { } fn real(path: &Path) -> PathBuf { - path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) + path.canonical().unwrap_or_else(|_| path.to_path_buf()) } impl HomeLayout { pub fn current(entries: &[String], workspace: &Path) -> Self { - let home = std::env::var_os("HOME") - .map(PathBuf::from) - .filter(|h| h.is_absolute() && h.parent().is_some()); + let home = crate::paths::home_dir().filter(|h| h.is_absolute() && h.parent().is_some()); Self::build(home, entries, workspace) } pub fn build(home: Option, entries: &[String], workspace: &Path) -> Self { @@ -298,7 +307,7 @@ impl HomeLayout { continue; } let dest = home.join(entry); - let Ok(source) = dest.canonicalize() else { + let Ok(source) = dest.canonical() else { continue; }; // A symlink must not smuggle in a credential folder or the whole home. @@ -423,7 +432,7 @@ pub fn isolated_args( match net { Net::On => { // /etc/resolv.conf often links into /run, which is not mounted. - if let Ok(resolv) = Path::new("/etc/resolv.conf").canonicalize() { + if let Ok(resolv) = Path::new("/etc/resolv.conf").canonical() { if let Some(dir) = resolv.parent().filter(|d| d.starts_with("/run")) { args.extend(["--ro-bind".into(), s(dir), s(dir)]); } @@ -479,6 +488,7 @@ pub struct ChildSetup { gate: Option>, /// Flatpak host commands: the proxy's Unix socket, reached through the /// relay inside the host bubblewrap network namespace. + #[cfg(unix)] relay: Option>, /// macOS: the proxy's loopback listener, the only address the /// command's Seatbelt profile may connect to. @@ -487,10 +497,12 @@ pub struct ChildSetup { } /// The proxy's listening Unix socket and the private folder holding it, /// removed when the command's setup is dropped. +#[cfg(unix)] pub struct RelaySocket { listener: Mutex>, dir: tempfile::TempDir, } +#[cfg(unix)] impl RelaySocket { /// Bind a fresh socket in a private folder below `base`. pub fn bind(base: &Path) -> Result { @@ -516,6 +528,7 @@ impl RelaySocket { self.dir.path() } } +#[cfg(unix)] impl std::fmt::Debug for RelaySocket { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("RelaySocket") @@ -576,9 +589,10 @@ impl std::fmt::Debug for ChildSetup { #[cfg(target_os = "linux")] d.field("netns", &self.netns.is_some()) .field("landlock", &self.landlock.is_some()); - d.field("proxy", &self.gate.is_some()) - .field("relay", &self.relay) - .finish() + d.field("proxy", &self.gate.is_some()); + #[cfg(unix)] + d.field("relay", &self.relay); + d.finish() } } /// Stops the allow-list proxy when the command's process is finished. @@ -619,6 +633,7 @@ impl ChildSetup { .context("The allow-list proxy listener was already used")?; return Ok(Some(ProxyGuard(proxy::spawn(listener, gate.clone())?))); } + #[cfg(unix)] if let (Some(relay), Some(gate)) = (&self.relay, &self.gate) { let listener = relay .listener @@ -690,7 +705,9 @@ pub fn prepare_shell( let _ = cwd; prepare_seatbelt(workspace, command, policy, &layout) }; - #[cfg(not(target_os = "macos"))] + #[cfg(windows)] + let result = prepare_windows(workspace, cwd, command, policy, &layout); + #[cfg(all(unix, not(target_os = "macos")))] let result = match working_bwrap(workspace) { Ok(program) => { let net = match policy.network { @@ -877,6 +894,82 @@ pub fn prepare_shell( result } +/// Windows: the command in ShadowCode's AppContainer ([`appcontainer`]). +/// When the sandbox cannot be set up, commands run as the user with a +/// warning and are refused when the sandbox is required, like a Linux host +/// without bubblewrap or Landlock. The allow-list is always refused. +#[cfg(windows)] +fn prepare_windows( + workspace: &Path, + cwd: &Path, + command: &str, + policy: &ShellPolicy, + layout: &HomeLayout, +) -> Result { + // The allow-list proxy listens on this computer, which the Windows + // sandbox cannot reach; it is refused rather than weakened. + if policy.network == ShellNetwork::Allowlist { + bail!("The command did not run: the network allow-list is not available on Windows (the Windows sandbox cannot reach the allow-list proxy on this computer). Choose 'off' or 'on' in Settings > Permissions & network > Shell commands and the network."); + } + let error = match appcontainer::prepare(workspace, cwd, command, policy, layout) { + Ok(prepared) => return Ok(prepared), + Err(error) => error, + }; + if policy.require { + bail!("The command did not run: 'Require sandbox' is on and the Windows sandbox is unavailable ({error:#}). Turn off 'Require sandbox' in Settings > Permissions & network to run commands without it."); + } + let (program, args) = windows_shell(command); + Ok(PreparedShell { + program, + args, + 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 Windows 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.")), + }) +} + +/// The shell for agent commands on Windows. Commands are usually written for +/// `sh`, so Git for Windows' bash comes first (from its install folders: the +/// `bash.exe` in System32 is WSL's, which would run them in Linux); +/// PowerShell otherwise. +#[cfg(windows)] +pub fn windows_shell(command: &str) -> (String, Vec) { + let installed = + |var: &str, rest: &str| std::env::var_os(var).map(|base| PathBuf::from(base).join(rest)); + let git_bash = [ + installed("ProgramFiles", "Git/bin/bash.exe"), + installed("ProgramW6432", "Git/bin/bash.exe"), + installed("LOCALAPPDATA", "Programs/Git/bin/bash.exe"), + ] + .into_iter() + .flatten() + .find(|path| path.is_file()); + match git_bash { + Some(bash) => ( + bash.to_string_lossy().into_owned(), + vec!["-c".into(), command.into()], + ), + None => ( + "powershell.exe".into(), + vec![ + "-NoProfile".into(), + "-NonInteractive".into(), + "-Command".into(), + command.into(), + ], + ), + } +} + /// 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`]. @@ -1017,7 +1110,7 @@ fn landlock_paths(layout: &HomeLayout, network: ShellNetwork) -> (Vec, .collect(); read_only.extend(layout.binds.iter().map(|(source, _)| source.clone())); if network != ShellNetwork::Off { - if let Ok(resolv) = Path::new("/etc/resolv.conf").canonicalize() { + if let Ok(resolv) = Path::new("/etc/resolv.conf").canonical() { if let Some(dir) = resolv.parent() { read_only.push(dir.to_path_buf()); } @@ -1058,7 +1151,39 @@ pub fn status(config: &Config) -> Value { } /// What shell isolation this computer and configuration provide. -#[cfg(not(target_os = "macos"))] +#[cfg(windows)] +pub fn status(config: &Config) -> Value { + let works = appcontainer::probe(); + let network = config.shell_network(); + let effective = match (&works, config.sandbox.require, network) { + // The allow-list is refused on Windows (see prepare_windows). + (_, _, ShellNetwork::Allowlist) => "blocked", + (Ok(()), _, _) => "appcontainer", + (Err(_), true, _) => "blocked", + _ => "none", + }; + let layout = HomeLayout::current(&config.sandbox.home_binds, Path::new("/nonexistent")); + json!({ + "platform": "windows", + "effective": effective, + "appcontainer": { + "works": works.is_ok(), + "detail": match &works { Ok(()) => "The Windows 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": "Windows commands get no network when it is off, and the internet and private networks when it is on; never this computer's own services"}, + "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(any(target_os = "macos", windows)))] pub fn status(config: &Config) -> Value { let installed = which("bwrap").is_some(); let bwrap = (|| -> Result<()> { @@ -1116,7 +1241,23 @@ pub fn doctor_checks() -> Vec { ] } -#[cfg(not(target_os = "macos"))] +#[cfg(windows)] +pub fn doctor_checks() -> Vec { + let works = appcontainer::probe(); + vec![ + json!({"id":"appcontainer","status":if works.is_ok() {"pass"} else {"warn"}, + "title":"Shell sandbox (Windows)","detail":match &works { + Ok(()) => "The Windows sandbox works: shell commands run in busybox sh inside an AppContainer that writes only to the project and a scratch folder, reads Windows, Program Files and read-only toolchains, and cannot see the rest of the home folder.".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. The network allow-list is not available on Windows. 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(any(target_os = "macos", windows)))] pub fn doctor_checks() -> Vec { let report = probe_workspace_cow(); #[cfg(target_os = "linux")] @@ -1140,6 +1281,8 @@ pub fn doctor_check() -> Value { } #[cfg(test)] +// Helpers of Unix-only tests are unused on Windows. +#[cfg_attr(not(unix), allow(unused))] mod tests { use super::*; use std::fs; @@ -1168,7 +1311,7 @@ mod tests { /// A fake home with credentials and toolchains, and a project elsewhere. fn fake_home() -> (tempfile::TempDir, PathBuf, PathBuf) { let root = tempfile::tempdir().unwrap(); - let home = root.path().canonicalize().unwrap().join("home").join("me"); + let home = root.path().canonical().unwrap().join("home").join("me"); for dir in [ ".ssh", ".aws", @@ -1183,7 +1326,7 @@ mod tests { fs::write(home.join(".config/shadow-agent/secrets.env"), "X=not-real").unwrap(); fs::write(home.join(".cargo/credentials.toml"), "token = \"not-real\"").unwrap(); fs::write(home.join(".gitconfig"), "[user]\n").unwrap(); - let ws = root.path().canonicalize().unwrap().join("project"); + let ws = root.path().canonical().unwrap().join("project"); fs::create_dir_all(&ws).unwrap(); (root, home, ws) } @@ -1281,6 +1424,7 @@ mod tests { assert!(host_relay_args(shared, relay, dir).is_err()); } + #[cfg(unix)] #[test] fn relay_socket_refuses_paths_the_kernel_cannot_hold() { let root = tempfile::tempdir().unwrap(); @@ -1298,6 +1442,7 @@ mod tests { } /// Build the relay shipped in the Flatpak, or skip when no C compiler. + #[cfg(unix)] fn build_relay(out: &Path) -> Option { let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../packaging/flatpak/proxy-relay.c"); @@ -1311,6 +1456,7 @@ mod tests { status.success().then_some(binary) } + #[cfg(unix)] fn free_port() -> u16 { std::net::TcpListener::bind("127.0.0.1:0") .unwrap() @@ -1378,6 +1524,7 @@ mod tests { ); } + #[cfg(unix)] #[test] fn flatpak_relay_fails_closed_without_the_proxy() { let temp = tempfile::tempdir().unwrap(); @@ -1483,7 +1630,7 @@ mod tests { } // macOS always uses its own sandbox (see the seatbelt tests below). - #[cfg(not(target_os = "macos"))] + #[cfg(target_os = "linux")] #[test] fn missing_bubblewrap_reports_honest_fallback_and_fails_closed_when_required() { // PATH is process-global and tests share one process: run the mutation @@ -1614,7 +1761,7 @@ mod tests { #[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(); + let root = root.path().canonical().unwrap(); fs::write(root.join("outside.txt"), "outside").unwrap(); // A project that contains a credential folder of its own home. let layout = HomeLayout::build( @@ -1670,7 +1817,7 @@ mod tests { #[test] fn seatbelt_hides_credential_folders_inside_the_project() { let root = tempfile::tempdir().unwrap(); - let home = root.path().canonicalize().unwrap().join("me"); + let home = root.path().canonical().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. @@ -1696,7 +1843,7 @@ mod tests { 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 ws = ws.path().canonical().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| { @@ -1739,7 +1886,7 @@ mod tests { #[test] fn apple_developer_tools_run_cleanly_in_the_seatbelt() { let ws = tempfile::tempdir().unwrap(); - let ws = ws.path().canonicalize().unwrap(); + let ws = ws.path().canonical().unwrap(); let layout = HomeLayout::current( &DEFAULT_HOME_BINDS .iter() diff --git a/native/core/src/sandbox/appcontainer.rs b/native/core/src/sandbox/appcontainer.rs new file mode 100644 index 00000000..0b2b4ecd --- /dev/null +++ b/native/core/src/sandbox/appcontainer.rs @@ -0,0 +1,838 @@ +//! The Windows shell sandbox: agent commands run in an AppContainer. +//! +//! An AppContainer process reads Windows' own folders and Program Files +//! (granted to all application packages), and nothing else of the user's +//! unless its SID is granted access. ShadowCode grants its container SID: +//! +//! * the project: read, write and delete (inherited by everything in it); +//! * a fresh scratch folder per command: the same (also HOME, TEMP and TMP); +//! * the toolchain folders of the home layout, the user's own PATH folders +//! and the shell's folder when it is not under Program Files: read and run. +//! +//! The rest of the home folder (credentials, browser data, other projects) +//! stays unreadable. Without capabilities the container has no network at +//! all, loopback included; "on" grants the internet and private-network +//! capabilities. Loopback stays closed even then (opening it needs an +//! administrator), so the allow-list proxy cannot be reached and the +//! allow-list is refused rather than weakened. +//! +//! Grants are access entries on those folders for ShadowCode's container SID +//! only; they are added once (a project's first sandboxed command) and give +//! nothing to other programs. Creating a process in a container needs a +//! process attribute Rust's standard library cannot set yet, so the command +//! starts through a launcher: ShadowCode's own executable run with +//! [`LAUNCH_FLAG`] (see [`launch_main`]), the counterpart of `bwrap`. +use super::{ + create_scratch, network_label, scratch_base, HomeLayout, PreparedShell, ShellNetwork, + ShellPolicy, +}; +use anyhow::{bail, ensure, Context, Result}; +use serde_json::json; +use std::{ + ffi::{c_void, OsStr, OsString}, + os::windows::{ffi::OsStrExt, io::AsRawHandle}, + path::{Path, PathBuf}, + sync::{Mutex, OnceLock}, +}; +use windows_sys::Win32::{ + Foundation::{CloseHandle, LocalFree, SetHandleInformation, HANDLE, HANDLE_FLAG_INHERIT}, + Security::{ + Authorization::{ + ConvertSidToStringSidW, ConvertStringSidToSidW, GetNamedSecurityInfoW, + SetEntriesInAclW, SetNamedSecurityInfoW, EXPLICIT_ACCESS_W, GRANT_ACCESS, + NO_MULTIPLE_TRUSTEE, SE_FILE_OBJECT, TRUSTEE_IS_SID, TRUSTEE_IS_UNKNOWN, TRUSTEE_W, + }, + EqualSid, FreeSid, GetAce, + Isolation::{CreateAppContainerProfile, DeriveAppContainerSidFromAppContainerName}, + ACCESS_ALLOWED_ACE, ACE_HEADER, ACL, CONTAINER_INHERIT_ACE, DACL_SECURITY_INFORMATION, + INHERIT_ONLY_ACE, OBJECT_INHERIT_ACE, PSID, SECURITY_CAPABILITIES, SID_AND_ATTRIBUTES, + SUB_CONTAINERS_AND_OBJECTS_INHERIT, + }, + Storage::FileSystem::{ + DELETE, FILE_DELETE_CHILD, FILE_GENERIC_EXECUTE, FILE_GENERIC_READ, FILE_GENERIC_WRITE, + }, + System::{ + Console::{GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE}, + Threading::{ + CreateProcessW, DeleteProcThreadAttributeList, GetExitCodeProcess, + InitializeProcThreadAttributeList, UpdateProcThreadAttribute, WaitForSingleObject, + CREATE_UNICODE_ENVIRONMENT, EXTENDED_STARTUPINFO_PRESENT, INFINITE, + PROCESS_INFORMATION, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, + PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, STARTF_USESTDHANDLES, STARTUPINFOEXW, + }, + }, +}; + +/// The first argument that makes ShadowCode's executable the launcher. +pub const LAUNCH_FLAG: &str = "--shadowcode-sandbox"; +/// ShadowCode's one AppContainer profile. +const PROFILE: &str = "ShadowCode.Sandbox"; +/// Names the inherited null-device handle (see the busybox-w32 patch). +const NULL_HANDLE_ENV: &str = "SHADOWCODE_NULL_HANDLE"; +/// internetClient, internetClientServer, privateNetworkClientServer. +const NETWORK_CAPABILITIES: [&str; 3] = ["S-1-15-3-1", "S-1-15-3-2", "S-1-15-3-3"]; +const READ: u32 = FILE_GENERIC_READ | FILE_GENERIC_EXECUTE; +const MODIFY: u32 = + FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE | DELETE | FILE_DELETE_CHILD; +/// HRESULT_FROM_WIN32(ERROR_ALREADY_EXISTS). +const ALREADY_EXISTS: i32 = 0x800700B7_u32 as i32; + +fn wide(text: &OsStr) -> Vec { + text.encode_wide().chain(Some(0)).collect() +} + +/// The container's SID as text (`S-1-15-2-…`), created on first use. +pub fn container_sid() -> Result { + static SID: OnceLock> = OnceLock::new(); + SID.get_or_init(|| create_profile().map_err(|e| format!("{e:#}"))) + .clone() + .map_err(anyhow::Error::msg) +} + +fn create_profile() -> Result { + let name = wide(OsStr::new(PROFILE)); + let display = wide(OsStr::new("ShadowCode sandbox")); + let description = wide(OsStr::new("Agent shell commands run by ShadowCode")); + let mut sid: PSID = std::ptr::null_mut(); + // SAFETY: NUL-terminated strings; the SID is freed with FreeSid below. + let mut result = unsafe { + CreateAppContainerProfile( + name.as_ptr(), + display.as_ptr(), + description.as_ptr(), + std::ptr::null(), + 0, + &mut sid, + ) + }; + if result == ALREADY_EXISTS { + // SAFETY: as above. + result = unsafe { DeriveAppContainerSidFromAppContainerName(name.as_ptr(), &mut sid) }; + } + ensure!( + result >= 0 && !sid.is_null(), + "Could not create the Windows sandbox profile ({result:#x})" + ); + let text = sid_text(sid); + // SAFETY: allocated by the profile call above. + unsafe { FreeSid(sid) }; + text +} + +fn sid_text(sid: PSID) -> Result { + let mut text: *mut u16 = std::ptr::null_mut(); + // SAFETY: a valid SID; the string is freed with LocalFree. + unsafe { + ensure!( + ConvertSidToStringSidW(sid, &mut text) != 0, + "Could not name the sandbox: {}", + std::io::Error::last_os_error() + ); + let len = (0..).take_while(|&i| *text.add(i) != 0).count(); + let value = String::from_utf16_lossy(std::slice::from_raw_parts(text, len)); + LocalFree(text.cast()); + Ok(value) + } +} + +/// A SID parsed from text, freed when dropped. +struct Sid(PSID); +impl Sid { + fn parse(text: &str) -> Result { + let text = wide(OsStr::new(text)); + let mut sid: PSID = std::ptr::null_mut(); + // SAFETY: NUL-terminated; the SID is freed in Drop. + ensure!( + unsafe { ConvertStringSidToSidW(text.as_ptr(), &mut sid) } != 0, + "Invalid SID: {}", + std::io::Error::last_os_error() + ); + Ok(Self(sid)) + } +} +impl Drop for Sid { + fn drop(&mut self) { + // SAFETY: allocated by ConvertStringSidToSidW. + unsafe { LocalFree(self.0) }; + } +} + +/// Whether `path` already allows `sid` at least `mask`, for the folder and +/// (with `inherit`) everything created in it. +fn has_grant(path: &[u16], sid: &Sid, mask: u32, inherit: bool) -> bool { + let mut dacl: *mut ACL = std::ptr::null_mut(); + let mut descriptor: *mut c_void = std::ptr::null_mut(); + // SAFETY: reads the DACL; the descriptor is freed with LocalFree. + let status = unsafe { + GetNamedSecurityInfoW( + path.as_ptr(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut dacl, + std::ptr::null_mut(), + &mut descriptor, + ) + }; + if status != 0 || dacl.is_null() { + return false; + } + let wanted = if inherit { + OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE + } else { + 0 + }; + // SAFETY: the DACL lives in `descriptor`; each ACE is read in bounds. + let found = unsafe { + (0..u32::from((*dacl).AceCount)).any(|index| { + let mut ace: *mut c_void = std::ptr::null_mut(); + if GetAce(dacl, index, &mut ace) == 0 { + return false; + } + let header = &*(ace as *const ACE_HEADER); + if u32::from(header.AceType) != 0 + || u32::from(header.AceFlags) & INHERIT_ONLY_ACE != 0 + || u32::from(header.AceFlags) & wanted != wanted + { + return false; + } + let allowed = &*(ace as *const ACCESS_ALLOWED_ACE); + allowed.Mask & mask == mask + && EqualSid(std::ptr::addr_of!(allowed.SidStart) as PSID, sid.0) != 0 + }) + }; + // SAFETY: allocated by GetNamedSecurityInfoW. + unsafe { LocalFree(descriptor) }; + found +} + +/// Allow the container `mask` on `path` (and, with `inherit`, on everything +/// in it), unless it already has that. +fn grant(path: &Path, sid: &Sid, mask: u32, inherit: bool) -> Result<()> { + // Grants already made this run need no check of a large folder's DACL. + static DONE: OnceLock>> = OnceLock::new(); + let done = DONE.get_or_init(Default::default); + let key = (path.to_path_buf(), mask); + if done.lock().is_ok_and(|d| d.contains(&key)) { + return Ok(()); + } + let name = wide(path.as_os_str()); + if !has_grant(&name, sid, mask, inherit) { + let mut dacl: *mut ACL = std::ptr::null_mut(); + let mut descriptor: *mut c_void = std::ptr::null_mut(); + // SAFETY: as in has_grant. + let status = unsafe { + GetNamedSecurityInfoW( + name.as_ptr(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut dacl, + std::ptr::null_mut(), + &mut descriptor, + ) + }; + ensure!( + status == 0, + "Cannot read the permissions of {} ({status})", + path.display() + ); + let entry = EXPLICIT_ACCESS_W { + grfAccessPermissions: mask, + grfAccessMode: GRANT_ACCESS, + grfInheritance: if inherit { + SUB_CONTAINERS_AND_OBJECTS_INHERIT + } else { + 0 + }, + Trustee: TRUSTEE_W { + pMultipleTrustee: std::ptr::null_mut(), + MultipleTrusteeOperation: NO_MULTIPLE_TRUSTEE, + TrusteeForm: TRUSTEE_IS_SID, + TrusteeType: TRUSTEE_IS_UNKNOWN, + ptstrName: sid.0.cast(), + }, + }; + let mut merged: *mut ACL = std::ptr::null_mut(); + // SAFETY: one entry merged into the existing DACL; the result and + // the descriptor are freed with LocalFree. + let status = unsafe { + let status = SetEntriesInAclW(1, &entry, dacl, &mut merged); + let applied = if status == 0 { + // Inheritable entries reach what is already in the folder. + SetNamedSecurityInfoW( + name.as_ptr(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + merged, + std::ptr::null(), + ) + } else { + status + }; + if !merged.is_null() { + LocalFree(merged.cast()); + } + LocalFree(descriptor); + applied + }; + ensure!( + status == 0, + "Cannot give the sandbox access to {} ({status})", + path.display() + ); + } + if let Ok(mut done) = done.lock() { + done.insert(key); + } + Ok(()) +} + +/// Folders every AppContainer may already read. +fn system_readable(path: &Path) -> bool { + [ + "SystemRoot", + "ProgramFiles", + "ProgramW6432", + "ProgramFiles(x86)", + ] + .iter() + .filter_map(std::env::var_os) + .any(|root| path.starts_with(PathBuf::from(root))) +} + +/// The launcher: `SHADOWCODE_SANDBOX_LAUNCHER`, else ShadowCode's own +/// executable. Anything else (a test binary) cannot launch, so the sandbox +/// is unavailable there. +fn launcher() -> Result { + if let Some(path) = std::env::var_os("SHADOWCODE_SANDBOX_LAUNCHER") { + return Ok(PathBuf::from(path)); + } + let exe = std::env::current_exe()?; + ensure!( + exe.file_stem() + .is_some_and(|stem| stem.eq_ignore_ascii_case("shadowcode")), + "the sandbox launcher is ShadowCode's own program, which is not running" + ); + Ok(exe) +} + +/// The command in ShadowCode's AppContainer. +pub(super) fn prepare( + workspace: &Path, + cwd: &Path, + command: &str, + policy: &ShellPolicy, + layout: &HomeLayout, +) -> Result { + let program = launcher()?; + let sid_text = container_sid()?; + let sid = Sid::parse(&sid_text)?; + grant(workspace, &sid, MODIFY, true)?; + let scratch = create_scratch(&scratch_base())?; + grant(&scratch.path, &sid, MODIFY, true)?; + let home = scratch.path.join("home"); + let temp = scratch.path.join("tmp"); + std::fs::create_dir_all(&home)?; + std::fs::create_dir_all(&temp)?; + let _ = cwd; + let (shell, shell_args) = sandbox_shell(command)?; + // Read-only: the toolchains the home layout shows, the user's own PATH + // folders and the shell's folder, unless Windows already allows them. + // Best effort: a folder ShadowCode may not change is skipped. + let mut readable: Vec = layout + .binds + .iter() + .map(|(source, _)| source.clone()) + .collect(); + if let Some(home_dir) = &layout.home { + if let Some(path) = std::env::var_os("PATH") { + readable.extend( + std::env::split_paths(&path) + .filter(|dir| dir.starts_with(home_dir) && dir != home_dir && dir.is_dir()), + ); + } + } + if let Some(root) = Path::new(&shell).parent() { + readable.push(root.to_owned()); + } + let mut skipped = Vec::new(); + for dir in readable { + if system_readable(&dir) || dir.starts_with(workspace) { + continue; + } + if let Err(error) = grant(&dir, &sid, READ, true) { + skipped.push(format!("{}: {error:#}", dir.display())); + } + } + let s = |p: &Path| p.to_string_lossy().into_owned(); + let mut args = vec![ + LAUNCH_FLAG.to_owned(), + "--sid".into(), + sid_text, + "--network".into(), + network_label(policy.network).into(), + ]; + for (name, value) in [ + ("HOME", s(&home)), + ("TEMP", s(&temp)), + ("TMP", s(&temp)), + ("SHADOWCODE_SCRATCH", s(&scratch.path)), + ] { + args.push("--env".into()); + args.push(format!("{name}={value}")); + } + args.push("--".into()); + args.push(shell); + args.extend(shell_args); + Ok(PreparedShell { + program: s(&program), + args, + child: None, + scratch: Some(scratch.path.clone()), + gate: None, + note: json!({ + "mode": "appcontainer", + "network": network_label(policy.network), + "network_requested": network_label(policy.network), + "network_enforcement": match policy.network { + ShellNetwork::Off => "no_network", + _ => "none", + }, + "shell": "busybox sh", + "home": "empty temporary folder", + "home_read_only": layout.binds.iter().map(|(s, _)| s.to_string_lossy()).collect::>(), + "home_skipped": layout.skipped, + "read_grants_skipped": skipped, + "scratch": scratch.path.to_string_lossy(), + "workspace_cow": false, + "note": "Windows sandbox (AppContainer), commands run in busybox sh: Windows and Program Files read-only, the home folder hidden except read-only toolchains and PATH folders, writable project, ephemeral scratch at SHADOWCODE_SCRATCH (also HOME, TEMP and TMP); network off unless on, and never to this computer's own services. Not a complete OS sandbox." + }), + warning: None, + }) +} + +/// Whether commands can run in the container: the launcher, the profile and +/// the bundled shell are there, and busybox `sh` starts in the container +/// and exits cleanly. For `shadowcode doctor`. +pub fn probe() -> Result<()> { + let program = launcher()?; + let sid_text = container_sid()?; + let sid = Sid::parse(&sid_text)?; + let (shell, args) = sandbox_shell("exit 0")?; + let folder = Path::new(&shell) + .parent() + .context("the sandbox's shell has no folder")? + .to_owned(); + if !system_readable(&folder) { + grant(&folder, &sid, READ, true)?; + } + let output = std::process::Command::new(&program) + .arg(LAUNCH_FLAG) + .args(["--sid", &sid_text, "--network", "off", "--"]) + .arg(&shell) + .args(&args) + .current_dir(&folder) + .stdin(std::process::Stdio::null()) + .output() + .with_context(|| format!("could not start the sandbox launcher {}", program.display()))?; + ensure!( + output.status.success(), + "the sandbox's shell did not run in the container ({}): {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ); + Ok(()) +} + +/// The shell inside the container: the bundled busybox-w32 `sh` (a native +/// Windows build, see scripts/build-busybox-w32.sh), so commands keep their +/// POSIX syntax. Git for Windows' bash (MSYS2) cannot start in an +/// AppContainer (0xC0000142), and PowerShell cannot enter a project whose +/// parent folders the container may not list. +fn sandbox_shell(command: &str) -> Result<(String, Vec)> { + let busybox = std::env::var_os("SHADOWCODE_BUSYBOX") + .map(PathBuf::from) + .or_else(|| { + std::env::current_exe() + .ok()? + .parent() + .map(|dir| dir.join(r"busybox\busybox.exe")) + }) + .filter(|path| path.is_file()) + .context("the sandbox's shell (busybox.exe) is not installed beside ShadowCode")?; + Ok(( + busybox.to_string_lossy().into_owned(), + vec!["sh".into(), "-c".into(), command.into()], + )) +} + +/// One argument as the Windows C runtime (and MSYS) parse a command line: +/// the rules Rust's standard library uses. +fn quote(arg: &OsStr, out: &mut Vec) { + let text: Vec = arg.encode_wide().collect(); + let needs = text.is_empty() + || text + .iter() + .any(|&c| c == b' ' as u16 || c == b'\t' as u16 || c == b'"' as u16); + if !needs { + out.extend(text); + return; + } + out.push(b'"' as u16); + let mut backslashes = 0; + for c in text { + if c == b'\\' as u16 { + backslashes += 1; + } else { + if c == b'"' as u16 { + out.extend(std::iter::repeat_n(b'\\' as u16, backslashes + 1)); + } + backslashes = 0; + } + out.push(c); + } + out.extend(std::iter::repeat_n(b'\\' as u16, backslashes)); + out.push(b'"' as u16); +} + +/// Let the container use this process's window station and desktop when they +/// are not the interactive ones (a service or a CI runner). Every program +/// loads user32, which fails to start (0xC0000142) without them; on the +/// interactive desktop containers already have that access, and nothing is +/// changed there. +fn allow_window_station(container: &Sid) -> Result<()> { + use windows_sys::Win32::{ + Security::Authorization::{GetSecurityInfo, SetSecurityInfo, SE_WINDOW_OBJECT}, + System::{ + StationsAndDesktops::{ + GetProcessWindowStation, GetThreadDesktop, GetUserObjectInformationW, UOI_NAME, + }, + Threading::GetCurrentThreadId, + }, + }; + const GENERIC_READ: u32 = 0x8000_0000; + const GENERIC_WRITE: u32 = 0x4000_0000; + const GENERIC_EXECUTE: u32 = 0x2000_0000; + // SAFETY: handles owned by the system for this process; not closed. + let (station, desktop) = unsafe { + ( + GetProcessWindowStation(), + GetThreadDesktop(GetCurrentThreadId()), + ) + }; + ensure!( + !station.is_null() && !desktop.is_null(), + "No window station" + ); + let mut name = [0u16; 256]; + let mut needed = 0u32; + // SAFETY: the buffer's size is passed in bytes. + let named = unsafe { + GetUserObjectInformationW( + station, + UOI_NAME, + name.as_mut_ptr().cast(), + std::mem::size_of_val(&name) as u32, + &mut needed, + ) + } != 0; + let length = name.iter().position(|&c| c == 0).unwrap_or(0); + if named && String::from_utf16_lossy(&name[..length]).eq_ignore_ascii_case("WinSta0") { + return Ok(()); + } + for handle in [station, desktop] { + let mut dacl: *mut ACL = std::ptr::null_mut(); + let mut descriptor: *mut c_void = std::ptr::null_mut(); + // SAFETY: as in grant(), on a window object's handle. + let status = unsafe { + let status = GetSecurityInfo( + handle, + SE_WINDOW_OBJECT, + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut dacl, + std::ptr::null_mut(), + &mut descriptor, + ); + if status != 0 { + status + } else { + let entry = EXPLICIT_ACCESS_W { + grfAccessPermissions: GENERIC_READ | GENERIC_WRITE | GENERIC_EXECUTE, + grfAccessMode: GRANT_ACCESS, + grfInheritance: 0, + Trustee: TRUSTEE_W { + pMultipleTrustee: std::ptr::null_mut(), + MultipleTrusteeOperation: NO_MULTIPLE_TRUSTEE, + TrusteeForm: TRUSTEE_IS_SID, + TrusteeType: TRUSTEE_IS_UNKNOWN, + ptstrName: container.0.cast(), + }, + }; + let mut merged: *mut ACL = std::ptr::null_mut(); + let mut status = SetEntriesInAclW(1, &entry, dacl, &mut merged); + if status == 0 { + status = SetSecurityInfo( + handle, + SE_WINDOW_OBJECT, + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + merged, + std::ptr::null(), + ); + } + if !merged.is_null() { + LocalFree(merged.cast()); + } + LocalFree(descriptor); + status + } + }; + ensure!( + status == 0, + "Cannot open the window station to the sandbox ({status})" + ); + } + Ok(()) +} + +/// Entry point of the launcher: `--sid S --network off|on [--env K=V]… -- +/// program args…`. Starts the program in the container with this process's +/// standard handles, waits, and returns its exit code (125: not started). +pub fn launch_main(args: Vec) -> i32 { + match launch(args) { + Ok(code) => code, + Err(error) => { + eprintln!("ShadowCode sandbox: {error:#}"); + 125 + } + } +} + +fn launch(args: Vec) -> Result { + let mut args = args.into_iter(); + let mut sid = None; + let mut network = false; + let mut env: Vec<(OsString, OsString)> = Vec::new(); + let mut command: Vec = Vec::new(); + while let Some(arg) = args.next() { + match arg.to_str() { + Some("--sid") => sid = args.next().and_then(|v| v.into_string().ok()), + Some("--network") => network = args.next().is_some_and(|v| v == "on"), + Some("--env") => { + let pair = args.next().context("--env needs NAME=VALUE")?; + let pair = pair.to_string_lossy().into_owned(); + let (name, value) = pair.split_once('=').context("--env needs NAME=VALUE")?; + env.push((name.into(), value.into())); + } + Some("--") => { + command.extend(args.by_ref()); + break; + } + _ => bail!("Unknown launcher argument: {}", arg.to_string_lossy()), + } + } + ensure!(!command.is_empty(), "No command to run"); + let container = Sid::parse(&sid.context("--sid is required")?)?; + allow_window_station(&container)?; + let capabilities: Vec = if network { + NETWORK_CAPABILITIES + .iter() + .map(|text| Sid::parse(text)) + .collect::>()? + } else { + Vec::new() + }; + let mut entries: Vec = capabilities + .iter() + .map(|sid| SID_AND_ATTRIBUTES { + Sid: sid.0, + Attributes: 4, // SE_GROUP_ENABLED + }) + .collect(); + let security = SECURITY_CAPABILITIES { + AppContainerSid: container.0, + Capabilities: if entries.is_empty() { + std::ptr::null_mut() + } else { + entries.as_mut_ptr() + }, + CapabilityCount: entries.len() as u32, + Reserved: 0, + }; + // An AppContainer may not open the null device itself, so it gets one + // opened here: busybox's /dev/null uses the handle the environment + // names, and it stands in for a missing standard handle. + let null = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(r"\\.\NUL") + .context("Could not open the null device for the sandbox")?; + let null_handle = null.as_raw_handle() as HANDLE; + env.push(( + NULL_HANDLE_ENV.into(), + (null_handle as usize).to_string().into(), + )); + // This process's standard handles become the program's, and only they + // and the null device are inherited. + // SAFETY: GetStdHandle has no preconditions; SetHandleInformation only + // marks these handles inheritable. + let standard: Vec = unsafe { + [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE] + .iter() + .map(|&which| GetStdHandle(which)) + .map(|handle| { + if handle.is_null() || handle as isize == -1 { + null_handle + } else { + handle + } + }) + .collect() + }; + let mut inherit: Vec = Vec::new(); + for &handle in standard.iter().chain([&null_handle]) { + if !inherit.contains(&handle) { + // SAFETY: a live handle of this process. + unsafe { SetHandleInformation(handle, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT) }; + inherit.push(handle); + } + } + let attribute_count = 2; + let mut size = 0usize; + // SAFETY: a size query. + unsafe { + InitializeProcThreadAttributeList(std::ptr::null_mut(), attribute_count, 0, &mut size) + }; + let mut list = vec![0u64; size.div_ceil(8)]; + let attributes = list.as_mut_ptr().cast::(); + // SAFETY: the buffer has the queried size; the values outlive the call + // to CreateProcessW below. + let ready = unsafe { + InitializeProcThreadAttributeList(attributes, attribute_count, 0, &mut size) != 0 + && UpdateProcThreadAttribute( + attributes, + 0, + PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES as usize, + (&security as *const SECURITY_CAPABILITIES).cast(), + std::mem::size_of::(), + std::ptr::null_mut(), + std::ptr::null(), + ) != 0 + && UpdateProcThreadAttribute( + attributes, + 0, + PROC_THREAD_ATTRIBUTE_HANDLE_LIST as usize, + inherit.as_ptr().cast(), + std::mem::size_of_val(inherit.as_slice()), + std::ptr::null_mut(), + std::ptr::null(), + ) != 0 + }; + ensure!( + ready, + "Could not prepare the sandboxed process: {}", + std::io::Error::last_os_error() + ); + let mut line: Vec = Vec::new(); + for (index, arg) in command.iter().enumerate() { + if index > 0 { + line.push(b' ' as u16); + } + quote(arg, &mut line); + } + line.push(0); + let mut vars: Vec<(OsString, OsString)> = std::env::vars_os() + .filter(|(name, _)| { + !env.iter().any(|(set, _)| { + set.to_string_lossy() + .eq_ignore_ascii_case(&name.to_string_lossy()) + }) + }) + .collect(); + vars.extend(env); + vars.sort_by_key(|(name, _)| name.to_string_lossy().to_ascii_uppercase()); + let mut block: Vec = Vec::new(); + for (name, value) in vars { + block.extend(name.encode_wide()); + block.push(b'=' as u16); + block.extend(value.encode_wide()); + block.push(0); + } + block.push(0); + let mut startup = STARTUPINFOEXW::default(); + startup.StartupInfo.cb = std::mem::size_of::() as u32; + startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + startup.StartupInfo.hStdInput = standard[0]; + startup.StartupInfo.hStdOutput = standard[1]; + startup.StartupInfo.hStdError = standard[2]; + startup.lpAttributeList = attributes; + let mut process = PROCESS_INFORMATION::default(); + // SAFETY: every buffer is NUL-terminated and outlives the call; the + // working folder is this process's own. + let created = unsafe { + CreateProcessW( + std::ptr::null(), + line.as_mut_ptr(), + std::ptr::null(), + std::ptr::null(), + 1, + EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT, + block.as_ptr().cast(), + std::ptr::null(), + &startup.StartupInfo, + &mut process, + ) + }; + let error = std::io::Error::last_os_error(); + // SAFETY: initialized above. + unsafe { DeleteProcThreadAttributeList(attributes) }; + ensure!( + created != 0, + "Could not start {} in the sandbox: {error}", + command[0].to_string_lossy() + ); + let mut code = 0u32; + // SAFETY: handles from CreateProcessW, closed once. + unsafe { + CloseHandle(process.hThread); + WaitForSingleObject(process.hProcess, INFINITE); + GetExitCodeProcess(process.hProcess, &mut code); + CloseHandle(process.hProcess); + } + drop(capabilities); + drop(null); + Ok(code as i32) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn quoted(args: &[&str]) -> String { + let mut line = Vec::new(); + for (i, arg) in args.iter().enumerate() { + if i > 0 { + line.push(b' ' as u16); + } + quote(OsStr::new(arg), &mut line); + } + String::from_utf16(&line).unwrap() + } + + #[test] + fn arguments_are_quoted_as_the_c_runtime_reads_them() { + assert_eq!(quoted(&["bash", "-c", "echo hi"]), r#"bash -c "echo hi""#); + assert_eq!(quoted(&["a\"b"]), r#""a\"b""#); + assert_eq!(quoted(&[r"C:\dir\"]), r"C:\dir\"); + assert_eq!(quoted(&[r"C:\my dir\"]), r#""C:\my dir\\""#); + assert_eq!(quoted(&[""]), r#""""#); + } +} diff --git a/native/core/src/sandbox/seatbelt.rs b/native/core/src/sandbox/seatbelt.rs index 9514261c..5a63fdb5 100644 --- a/native/core/src/sandbox/seatbelt.rs +++ b/native/core/src/sandbox/seatbelt.rs @@ -21,6 +21,7 @@ //! reachable only for user lookup, logging and (with network) DNS and //! certificate checks. use super::{HomeLayout, Net}; +use crate::paths::Canonical; use anyhow::{ensure, Context, Result}; use std::{ path::{Path, PathBuf}, @@ -219,7 +220,7 @@ pub fn path_roots(home: Option<&Path>) -> Vec { 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 { + let Ok(dir) = dir.canonical() else { continue; }; if home @@ -281,7 +282,7 @@ pub fn user_temp() -> Option { } buffer.truncate(len - 1); PathBuf::from(String::from_utf8(buffer).ok()?) - .canonicalize() + .canonical() .ok() } diff --git a/native/core/src/second_opinion.rs b/native/core/src/second_opinion.rs index ed05cd96..48da5667 100644 --- a/native/core/src/second_opinion.rs +++ b/native/core/src/second_opinion.rs @@ -1046,7 +1046,7 @@ fn locate(findings: Vec, diff: &[DiffFile], workspace: &Path) -> Vec(named: &str, diff: &'a [DiffFile], workspace: &Path) -> Option<&'a DiffFile> { let mut name = named.trim().trim_matches('`').to_owned(); if let Ok(relative) = Path::new(&name).strip_prefix(workspace) { - name = relative.to_string_lossy().into_owned(); + name = crate::paths::slash(relative); } let name = name.trim_start_matches("./"); let exact = |candidate: &str| diff.iter().find(|file| file.path == candidate); diff --git a/native/core/src/secret_scan.rs b/native/core/src/secret_scan.rs index 6b13b2ae..12825ccf 100644 --- a/native/core/src/secret_scan.rs +++ b/native/core/src/secret_scan.rs @@ -1093,6 +1093,8 @@ pub async fn unpushed(workspace: &Path, commit: &str, remote: &str) -> Result, /// Remote access and phone notifications (one per engine). - #[cfg(unix)] remote: Arc, /// How the process that owns this engine runs (`control::Server` /// modes: desktop, server, tui, acp, command); shared by every view. @@ -147,7 +144,6 @@ impl Service { .or_else(|| paths.remembered_workspace()) .unwrap_or(std::env::current_dir()?); let workspace = Workspace::open(&workspace)?.path; - #[cfg(unix)] let remote = Arc::new(crate::remote::Manager::new(paths.clone())); let engine = Engine::open(paths.clone())?; // The app log for bug reports (events, errors and timings only). It @@ -180,7 +176,6 @@ impl Service { terminals, #[cfg(any(target_os = "linux", target_os = "macos"))] previews: Arc::default(), - #[cfg(unix)] remote, engine_mode: Arc::default(), remote_device: false, @@ -218,7 +213,6 @@ impl Service { terminals: Arc::new(crate::terminal::Terminals::new(self.engine.notifier())), #[cfg(any(target_os = "linux", target_os = "macos"))] previews: self.previews.clone(), - #[cfg(unix)] remote: self.remote.clone(), engine_mode: self.engine_mode.clone(), remote_device: self.remote_device, @@ -238,7 +232,6 @@ impl Service { self } /// Remote access (web interface, pairing, phone notifications). - #[cfg(unix)] pub fn remote(&self) -> &Arc { &self.remote } @@ -257,7 +250,6 @@ impl Service { Ok(workspace) } fn check_remote_workspace(&self, workspace: &Path) -> Result<()> { - #[cfg(unix)] ensure!( !self.remote_device || !crate::remote::policy::inside_profile( @@ -391,7 +383,6 @@ impl Service { "second-opinions" => self.second_opinion_routes(&call).await, "feed" => self.feed_routes(&call).await, "terminals" => self.terminal_routes(&call).await, - #[cfg(unix)] "remote" => self.remote_routes(&call).await, "git" => self.forge_routes(&call).await, "background" => self.background_routes(&call).await, @@ -518,9 +509,11 @@ fn query_limit(query: &HashMap, default: usize, max: usize) -> u fn expand_path(path: &str) -> Result { ensure!(!path.is_empty(), "Project path required"); Ok(if path == "~" { - PathBuf::from(std::env::var_os("HOME").context("HOME is not set")?) + crate::paths::home_dir().context("HOME is not set")? } else if let Some(tail) = path.strip_prefix("~/") { - PathBuf::from(std::env::var_os("HOME").context("HOME is not set")?).join(tail) + crate::paths::home_dir() + .context("HOME is not set")? + .join(tail) } else { PathBuf::from(path) }) diff --git a/native/core/src/service/diagnostic_export.rs b/native/core/src/service/diagnostic_export.rs index 927076f6..b5756f76 100644 --- a/native/core/src/service/diagnostic_export.rs +++ b/native/core/src/service/diagnostic_export.rs @@ -349,11 +349,15 @@ mod tests { assert!(cache.get(id).is_err()); let latest = cache.prepare(&report, &Value::Null).unwrap(); let latest_id = latest["id"].as_str().unwrap(); - { - let mut snapshots = cache.0.lock().unwrap(); - snapshots.back_mut().unwrap().created = Instant::now() - TTL; + // An instant before boot does not exist (a just-started Windows + // machine has been up for less than the TTL). + if let Some(expired) = Instant::now().checked_sub(TTL) { + { + let mut snapshots = cache.0.lock().unwrap(); + snapshots.back_mut().unwrap().created = expired; + } + assert!(cache.get(latest_id).is_err()); } - assert!(cache.get(latest_id).is_err()); } #[test] diff --git a/native/core/src/service/extensions.rs b/native/core/src/service/extensions.rs index 92e4c8dc..7d18e378 100644 --- a/native/core/src/service/extensions.rs +++ b/native/core/src/service/extensions.rs @@ -16,7 +16,6 @@ struct ActivationBody { impl Service { pub(super) async fn extension_routes(&self, call: &Arc) -> Result { - #[cfg(unix)] if (call.method.as_str(), call.path.as_str()) == ("POST", "/api/sqlite") { return crate::sqlite::inspect( Arc::new(Workspace::open(&self.workspace()?)?), @@ -32,15 +31,12 @@ impl Service { let body: ActivationBody = call.body()?; let path = call.path.as_str(); match (call.method.as_str(), path) { - #[cfg(unix)] ("GET", "/api/plugins") => { crate::plugins::catalog(self.engine.paths(), &Workspace::open(&self.workspace()?)?) } - #[cfg(unix)] ("POST", "/api/plugins/preview") => { crate::plugins::preview(&crate::plugins::resolve(&call.body)?) } - #[cfg(unix)] ("POST", "/api/plugins/install" | "/api/plugins/remove") => { let workspace = self.mutable_workspace()?; ensure!( @@ -73,13 +69,11 @@ impl Service { json!({"result":result,"catalog":crate::plugins::catalog(self.engine.paths(), &workspace)?}), ) } - #[cfg(unix)] ("GET", "/api/mcp/servers") => { let workspace = Workspace::open(&self.workspace()?)?; let config = Config::load(self.engine.paths(), Some(&workspace.path))?; crate::mcp::registry::catalog(&workspace, &config) } - #[cfg(unix)] ("POST", "/api/mcp/servers" | "/api/mcp/servers/delete") => { let workspace = Workspace::open(&self.workspace()?)?; let deleting = path.ends_with("/delete"); @@ -115,7 +109,6 @@ impl Service { )?; crate::mcp::registry::catalog(&workspace, &config) } - #[cfg(unix)] ("POST", "/api/mcp/activation") => { let workspace = Workspace::open(&self.workspace()?)?; ensure!( diff --git a/native/core/src/service/feed.rs b/native/core/src/service/feed.rs index feecbe39..cf165ef2 100644 --- a/native/core/src/service/feed.rs +++ b/native/core/src/service/feed.rs @@ -92,7 +92,7 @@ impl Service { let mut asked = Vec::new(); for value in requested { let given = value.as_str().context("Paths must be text")?; - let relative = workspace.relative(given)?.to_string_lossy().into_owned(); + let relative = workspace.relative_text(given)?; ensure!(relative != ".", "Choose files, not the project folder"); asked.push((given.to_owned(), relative)); } diff --git a/native/core/src/service/git.rs b/native/core/src/service/git.rs index 9e4c3c05..6a5701ad 100644 --- a/native/core/src/service/git.rs +++ b/native/core/src/service/git.rs @@ -104,10 +104,7 @@ impl Service { let path = if path.is_empty() { String::new() } else { - Workspace::open(workspace)? - .relative(path)? - .to_string_lossy() - .into_owned() + Workspace::open(workspace)?.relative_text(path)? }; let git = |args| Self::git_in(workspace, args, cancel.clone()); let mut args = vec![ @@ -176,10 +173,7 @@ impl Service { } pub(super) async fn hunk_action(&self, body: &Value) -> Result { let ws = self.mutable_workspace()?; - let path = ws - .relative(body["path"].as_str().context("Choose a file")?)? - .to_string_lossy() - .into_owned(); + let path = ws.relative_text(body["path"].as_str().context("Choose a file")?)?; ensure!( !path.contains(['\n', '\r', '\t', '"']), "Stage files with special path characters as a whole file" @@ -263,11 +257,7 @@ impl Service { ); let mut args = vec!["add".into(), "--".into()]; for path in paths { - args.push( - ws.relative(path.as_str().context("Invalid path")?)? - .to_string_lossy() - .into_owned(), - ); + args.push(ws.relative_text(path.as_str().context("Invalid path")?)?); } let result = Self::git_in(&ws.path, args, ws.reservation.cancellation()).await?; ensure!(result["ok"] == true, "{}", result["stderr"]); @@ -395,11 +385,7 @@ impl Service { ); let mut args: Vec = vec!["reset".into(), "-q".into(), "--".into()]; for path in paths { - args.push( - ws.relative(path.as_str().context("Invalid path")?)? - .to_string_lossy() - .into_owned(), - ); + args.push(ws.relative_text(path.as_str().context("Invalid path")?)?); } let result = Self::git_in(&ws.path, args, ws.reservation.cancellation()).await?; ensure!(result["ok"] == true, "{}", result["stderr"]); @@ -413,10 +399,7 @@ impl Service { /// and the answer says `tracked: true`. pub(super) async fn git_ignore(&self, body: &Value) -> Result { let ws = self.mutable_workspace()?; - let path = ws - .relative(body["path"].as_str().context("path required")?)? - .to_string_lossy() - .into_owned(); + let path = ws.relative_text(body["path"].as_str().context("path required")?)?; ensure!( !path.is_empty() && !path.contains('\n') && !path.starts_with(".git/"), "Choose a file in the project" diff --git a/native/core/src/service/inspection.rs b/native/core/src/service/inspection.rs index ca57011f..4edf7a66 100644 --- a/native/core/src/service/inspection.rs +++ b/native/core/src/service/inspection.rs @@ -1,16 +1,49 @@ use super::*; -use std::os::unix::fs::{MetadataExt, PermissionsExt}; + +/// Owned by this account and closed to everyone else (mode 700/600). On +/// Windows the profile lives under the user's own %APPDATA%, whose ACL +/// already limits it to that user. +fn owner_only(m: &std::fs::Metadata) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + m.uid() == unsafe { libc::geteuid() } && m.mode() & 0o077 == 0 + } + #[cfg(not(unix))] + { + let _ = m; + true + } +} fn check(id: &str, status: &str, label: &str, detail: impl Into, fix: &str) -> Value { json!({"id":id,"status":status,"ok":status=="pass","label":label,"detail":detail.into(),"fix":if status=="pass"{""}else{fix}}) } fn executable(name: &str) -> Option { + // Windows finds `git` as git.exe (or a .cmd shim), with no execute bit. + let names: Vec = if cfg!(windows) { + vec![ + format!("{name}.exe"), + format!("{name}.cmd"), + name.to_owned(), + ] + } else { + vec![name.to_owned()] + }; std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()) .take(128) - .map(|dir| dir.join(name)) + .flat_map(|dir| names.iter().map(move |name| dir.join(name))) .find(|path| { - std::fs::metadata(path) - .is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0) + std::fs::metadata(path).is_ok_and(|m| { + #[cfg(unix)] + let runnable = { + use std::os::unix::fs::PermissionsExt; + m.permissions().mode() & 0o111 != 0 + }; + #[cfg(not(unix))] + let runnable = true; + m.is_file() && runnable + }) }) } impl Service { @@ -54,7 +87,7 @@ impl Service { let path = if path.is_empty() { String::new() } else { - workspace.relative(path)?.to_string_lossy().into_owned() + workspace.relative_text(path)? }; let mut args = vec![ "log".into(), @@ -154,12 +187,8 @@ impl Service { ("data-directory", &paths.data), ("state-directory", &paths.state), ] { - let private = std::fs::symlink_metadata(path).is_ok_and(|m| { - m.is_dir() - && !m.file_type().is_symlink() - && m.uid() == unsafe { libc::geteuid() } - && m.mode() & 0o077 == 0 - }); + let private = std::fs::symlink_metadata(path) + .is_ok_and(|m| m.is_dir() && !m.file_type().is_symlink() && owner_only(&m)); checks.push(check( name, if private { "pass" } else { "warn" }, @@ -170,14 +199,7 @@ impl Service { } let secrets = paths.secrets_file(); let secret_status = match std::fs::symlink_metadata(&secrets) { - Ok(m) - if m.is_file() - && !m.file_type().is_symlink() - && m.uid() == unsafe { libc::geteuid() } - && m.mode() & 0o077 == 0 => - { - "pass" - } + Ok(m) if m.is_file() && !m.file_type().is_symlink() && owner_only(&m) => "pass", Err(e) if e.kind() == std::io::ErrorKind::NotFound => "pass", _ => "fail", }; diff --git a/native/core/src/service/model_catalog.rs b/native/core/src/service/model_catalog.rs index 9a9e3f32..dad016e3 100644 --- a/native/core/src/service/model_catalog.rs +++ b/native/core/src/service/model_catalog.rs @@ -239,8 +239,7 @@ impl Service { "The offline preview is not a coding model" ); if let Some(vendor) = crate::cli_agent::Vendor::from_provider(&model.provider) { - let home = std::env::var_os("HOME") - .map(std::path::PathBuf::from) + let home = crate::paths::home_dir() .unwrap_or_else(|| std::path::PathBuf::from("/nonexistent")); let check = crate::cli_agent::doctor::check_vendor(vendor, &cfg.cli_agents, &home, None).await; diff --git a/native/core/src/service/workspace.rs b/native/core/src/service/workspace.rs index 6360fbda..5ddeb68f 100644 --- a/native/core/src/service/workspace.rs +++ b/native/core/src/service/workspace.rs @@ -160,7 +160,7 @@ impl Service { }; let relative = workspace.relative(path)?; Ok( - json!({"entries":workspace.list(path)?,"workspace":workspace.path,"path":relative,"parent":if relative==Path::new("."){String::new()}else{relative.parent().filter(|p|!p.as_os_str().is_empty()).unwrap_or(Path::new(".")).to_string_lossy().into_owned()}}), + json!({"entries":workspace.list(path)?,"workspace":workspace.path,"path":crate::paths::slash(&relative),"parent":if relative==Path::new("."){String::new()}else{crate::paths::slash(relative.parent().filter(|p|!p.as_os_str().is_empty()).unwrap_or(Path::new(".")))}}), ) } ("GET", "/api/workspace/file") => { @@ -218,7 +218,7 @@ impl Service { let path = call.q("path"); let hash = workspace.write(path, body.content.as_str().as_bytes(), Some(expected))?; - let relative = workspace.relative(path)?.to_string_lossy().into_owned(); + let relative = workspace.relative_text(path)?; // Saved while a subscription turn runs: Rewind of that turn // keeps this file as you saved it. if let Some(task) = &turn { diff --git a/native/core/src/sqlite.rs b/native/core/src/sqlite.rs index 87cb61c8..6e541b79 100644 --- a/native/core/src/sqlite.rs +++ b/native/core/src/sqlite.rs @@ -1,5 +1,6 @@ //! Bounded, read-only inspection of workspace databases. Uses the same bundled //! SQLite as native persistence; no Python, extension loader or shell process. +#[cfg(unix)] mod vfs; use crate::workspace::Workspace; use anyhow::{bail, ensure, Context, Result}; @@ -12,9 +13,10 @@ use rusqlite::{ }; use serde::Deserialize; use serde_json::{json, Map, Value}; +#[cfg(unix)] +use std::os::fd::AsRawFd; use std::{ collections::HashSet, - os::fd::AsRawFd, sync::{Arc, LazyLock}, time::{Duration, Instant}, }; @@ -193,6 +195,31 @@ fn query(workspace: &Workspace, request: &Request, cancel: &CancellationToken) - result.context("SQLite inspection failed (queries are read-only and bounded; SQLite must be able to access WAL coordination files)") } +/// The folder behind a held handle, as a plain path where one names it. +#[cfg(windows)] +fn held_path(dir: &cap_std::fs::Dir) -> Result { + use std::os::windows::{ffi::OsStringExt, io::AsRawHandle}; + use windows_sys::Win32::{Foundation::HANDLE, Storage::FileSystem::GetFinalPathNameByHandleW}; + let mut buffer = vec![0u16; 1024]; + // SAFETY: the buffer holds `buffer.len()` UTF-16 units; the handle is open. + let length = unsafe { + GetFinalPathNameByHandleW( + dir.as_raw_handle() as HANDLE, + buffer.as_mut_ptr(), + buffer.len() as u32, + 0, + ) + } as usize; + ensure!( + length > 0 && length < buffer.len(), + "Cannot name the database folder: {}", + std::io::Error::last_os_error() + ); + buffer.truncate(length); + let path = std::path::PathBuf::from(std::ffi::OsString::from_wide(&buffer)); + Ok(dunce::simplified(&path).to_path_buf()) +} + fn query_inner( workspace: &Workspace, request: &Request, @@ -204,13 +231,12 @@ fn query_inner( for suffix in ["", "-wal", "-shm", "-journal"] { match parent.symlink_metadata(format!("{name}{suffix}")) { Ok(metadata) => { - use cap_std::fs::MetadataExt; ensure!( metadata.is_file(), "Database and sidecars must be regular files, not symlinks or special files" ); ensure!( - suffix.is_empty() || metadata.nlink() == 1, + suffix.is_empty() || cap_fs_ext::MetadataExt::nlink(&metadata) == 1, "Database sidecars must not have additional hard links" ); } @@ -222,24 +248,32 @@ fn query_inner( } } } + #[cfg(unix)] vfs::register()?; // Declared before the connection so the directory stays held until the // connection has closed. + #[cfg(unix)] let _held = vfs::hold(parent.as_raw_fd()); + #[cfg(unix)] let path = vfs::filename(parent.as_raw_fd(), &name); + // Windows: the held parent cannot be renamed or removed (see + // Workspace::confined_parent), so its own path names the database. + #[cfg(windows)] + let path = held_path(&parent)?.join(&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"); + let flags = OpenFlags::SQLITE_OPEN_READ_ONLY + | OpenFlags::SQLITE_OPEN_URI + | OpenFlags::SQLITE_OPEN_NO_MUTEX; // `parent` outlives this connection. Do not use immutable=1: live WAL // writers must retain SQLite's normal transaction and locking semantics. - let connection = Connection::open_with_flags_and_vfs( - uri.as_str(), - OpenFlags::SQLITE_OPEN_READ_ONLY - | OpenFlags::SQLITE_OPEN_URI - | OpenFlags::SQLITE_OPEN_NO_MUTEX, - vfs::NAME, - ) - .context("Cannot open this project database read-only")?; + #[cfg(unix)] + let connection = Connection::open_with_flags_and_vfs(uri.as_str(), flags, vfs::NAME) + .context("Cannot open this project database read-only")?; + #[cfg(windows)] + let connection = Connection::open_with_flags(uri.as_str(), flags) + .context("Cannot open this project database read-only")?; let finished = CancellationToken::new(); let _finish_guard = finished.clone().drop_guard(); let interrupt = connection.get_interrupt_handle(); diff --git a/native/core/src/store.rs b/native/core/src/store.rs index 035da5b7..fce3b124 100644 --- a/native/core/src/store.rs +++ b/native/core/src/store.rs @@ -1,3 +1,4 @@ +use crate::paths::Canonical; use crate::{id, now}; use anyhow::{ensure, Context, Result}; use rusqlite::{params, types::ValueRef, Connection, OptionalExtension, Params}; @@ -555,7 +556,7 @@ impl Store { // Sessions are matched against `Workspace::open`, which names the // folder by its real path (on macOS /var is /private/var). let workspace = workspace - .canonicalize() + .canonical() .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])?; @@ -1472,7 +1473,7 @@ impl Store { .collect()) } pub fn touch_project(&self, path: &Path) -> Result<()> { - let path = path.canonicalize()?; + let path = path.canonical()?; self.execute("INSERT INTO projects(id,path,name,last_opened) VALUES(?,?,?,?) ON CONFLICT(path) DO UPDATE SET last_opened=excluded.last_opened",params![id(),path.to_string_lossy(),path.file_name().map(|s|s.to_string_lossy().into_owned()).unwrap_or_else(||"/".into()),now()])?; Ok(()) } diff --git a/native/core/src/store/meta.rs b/native/core/src/store/meta.rs index 0cf980a0..a94ecd2c 100644 --- a/native/core/src/store/meta.rs +++ b/native/core/src/store/meta.rs @@ -7,6 +7,23 @@ use super::Store; use anyhow::Result; use rusqlite::{params, OptionalExtension, Transaction, TransactionBehavior}; use serde::{de::DeserializeOwned, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::{Arc, LazyLock, Mutex}; + +/// One gate per database file for this process's meta transactions. SQLite +/// makes a waiting writer poll (busy_timeout), and on Windows, where each +/// commit's flush is slow, a poller can miss every gap between other +/// connections' commits until it times out. Writers in this process queue +/// here instead; other processes still wait in busy_timeout. +fn write_gate(path: &std::path::Path) -> Arc> { + static GATES: LazyLock>>>> = + LazyLock::new(Default::default); + let mut gates = GATES + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + gates.entry(path.to_path_buf()).or_default().clone() +} impl Store { pub fn native_meta(&self, key: &str) -> Result> { @@ -30,6 +47,9 @@ impl Store { &self, work: impl FnOnce(&MetaTransaction<'_>) -> Result, ) -> Result { + // The gate first, then the connection: the same order everywhere. + let gate = write_gate(&self.path); + let _turn = gate.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); let mut db = self.lock()?; // IMMEDIATE takes the write lock before the first read, so a second // process waits (busy_timeout) instead of reading a value this diff --git a/native/core/src/subagents.rs b/native/core/src/subagents.rs index 56d048bf..f8f5e152 100644 --- a/native/core/src/subagents.rs +++ b/native/core/src/subagents.rs @@ -21,6 +21,7 @@ //! own worktree and returns a diff. Project roles (`crate::roles`) choose the //! model of `explore`, `plan`, `review` and write children, and run the //! stages of a Plan → Implement → Review task (`Self::run_role`). +use crate::paths::Canonical; use crate::{ agents::{self, AgentCatalog, AgentDefinition}, cli_agent::{handoff, Vendor}, @@ -975,7 +976,7 @@ impl SubagentHost { cancel.clone(), ) .await?; - let path = created.path.canonicalize()?; + let path = created.path.canonical()?; config.grant_trust(&path); checkout = Some((created.id.clone(), path.clone(), base.commit)); path diff --git a/native/core/src/symbol_index.rs b/native/core/src/symbol_index.rs index b9b41fc6..85d0273e 100644 --- a/native/core/src/symbol_index.rs +++ b/native/core/src/symbol_index.rs @@ -18,6 +18,7 @@ use crate::code_intel::{ chunks, langs::{self, Lang}, }; +use crate::paths::Canonical; use crate::workspace::Workspace; use anyhow::{ensure, Context, Result}; use rusqlite::{params, Connection}; @@ -121,7 +122,7 @@ pub fn use_cache(dir: PathBuf) { } fn db_path(root: &Path) -> Result { - db_path_for(&root.canonicalize()?) + db_path_for(&root.canonical()?) } /// The index file of a project by its canonical path (which may be gone). fn db_path_for(root: &Path) -> Result { @@ -221,7 +222,7 @@ pub fn set_focus(root: &Path, focus: Option<&str>) -> Result { let workspace = Workspace::open(root)?; let rel = workspace.relative(folder)?; ensure!(root.join(&rel).is_dir(), "Choose a folder in the project"); - set_meta(&conn, "focus", &rel.to_string_lossy())?; + set_meta(&conn, "focus", &crate::paths::slash(&rel))?; } None => { conn.execute("DELETE FROM meta WHERE key='focus'", [])?; @@ -235,7 +236,7 @@ pub fn set_focus(root: &Path, focus: Option<&str>) -> Result { /// Delete this project's index (it is rebuilt when needed). A folder that is /// already gone (a removed worktree) is named by the canonical path it had. pub fn clear(root: &Path) -> Result<()> { - let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf()); + let root = root.canonical().unwrap_or_else(|_| root.to_path_buf()); remove_db(&db_path_for(&root)?); if let Ok(mut scans) = LAST_SCAN.lock() { if let Some(scans) = scans.as_mut() { @@ -252,7 +253,7 @@ pub(crate) fn index_path(root: &Path) -> PathBuf { } fn forget_recent_scan(root: &Path) { - if let (Ok(key), Ok(mut scans)) = (root.canonicalize(), LAST_SCAN.lock()) { + if let (Ok(key), Ok(mut scans)) = (root.canonical(), LAST_SCAN.lock()) { if let Some(scans) = scans.as_mut() { scans.remove(&key); } @@ -610,7 +611,7 @@ fn run_index( } } let wants_scan = scan || touched.is_empty(); - let key = root.canonicalize()?; + let key = root.canonical()?; let recent_scan = reuse_recent && LAST_SCAN .lock() @@ -949,7 +950,7 @@ static RECENT: Mutex> = Mutex::new(Vec::new()); const MAX_RECENT: usize = 64; fn remember_edit(root: &Path, rel: &str) { - let Ok(root) = root.canonicalize() else { + let Ok(root) = root.canonical() else { return; }; if let Ok(mut recent) = RECENT.lock() { @@ -960,7 +961,7 @@ fn remember_edit(root: &Path, rel: &str) { } pub fn recent_edits(root: &Path, max: usize) -> Vec { - let Ok(root) = root.canonicalize() else { + let Ok(root) = root.canonical() else { return Vec::new(); }; RECENT @@ -1298,7 +1299,7 @@ mod tests { fs::create_dir(&worktree).unwrap(); fs::write(worktree.join("a.rs"), "pub fn a() {}\n").unwrap(); ensure_index(&worktree, &[], true).unwrap(); - let canonical = worktree.canonicalize().unwrap(); + let canonical = worktree.canonical().unwrap(); let file = index_path(&canonical); assert!(file.exists()); fs::remove_dir_all(&worktree).unwrap(); diff --git a/native/core/src/terminal_windows.rs b/native/core/src/terminal_windows.rs new file mode 100644 index 00000000..e8fda218 --- /dev/null +++ b/native/core/src/terminal_windows.rs @@ -0,0 +1,661 @@ +//! The user's own interactive terminals (drawer › Terminal) on Windows. +//! +//! The same contract as the Unix version (`terminal.rs`): the user's shell, +//! started in a project folder with the user's environment, outside the +//! agent sandbox and without approvals; output in a bounded in-memory +//! scrollback read by byte cursor after a transient `terminal.output` +//! wake-up; nothing stored or offered to a model. +//! +//! Each terminal is a pseudo console (ConPTY). The shell is PowerShell 7 +//! (`pwsh`) when installed, else Windows PowerShell. It runs in a job object, +//! so closing the terminal, the window or ShadowCode ends what it started. +use anyhow::{bail, ensure, Context, Result}; +use base64::Engine as _; +use serde_json::{json, Value}; +use std::{ + collections::HashMap, + ffi::OsString, + io::{Read, Write}, + os::windows::{ + ffi::OsStrExt, + io::{FromRawHandle, RawHandle}, + }, + path::{Path, PathBuf}, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, + }, + time::{Duration, Instant}, +}; +use tokio::sync::broadcast; +use windows_sys::Win32::{ + Foundation::{CloseHandle, HANDLE}, + System::{ + Console::{ClosePseudoConsole, CreatePseudoConsole, ResizePseudoConsole, COORD, HPCON}, + Pipes::CreatePipe, + Threading::{ + CreateProcessW, DeleteProcThreadAttributeList, GetExitCodeProcess, + InitializeProcThreadAttributeList, UpdateProcThreadAttribute, WaitForSingleObject, + CREATE_UNICODE_ENVIRONMENT, EXTENDED_STARTUPINFO_PRESENT, INFINITE, + PROCESS_INFORMATION, PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, STARTF_USESTDHANDLES, + STARTUPINFOEXW, + }, + }, +}; + +/// Scrollback kept per terminal. Older output is dropped from the front. +pub const SCROLLBACK_BYTES: usize = 512 * 1024; +/// Open terminals per desktop view, across projects. +pub const MAX_TERMINALS: usize = 12; +/// One read returns at most this much output; `more` asks for another read. +pub const READ_LIMIT: usize = 256 * 1024; +/// One write (a keystroke batch or a paste) is at most this large. +pub const WRITE_LIMIT: usize = 64 * 1024; +/// Output wake-ups per terminal are coalesced to at most one per interval. +const WAKE_INTERVAL: Duration = Duration::from_millis(16); + +/// How to start a terminal. `shell` overrides the default shell (tests). +#[derive(Clone, Debug, Default)] +pub struct OpenOptions { + pub cols: u16, + pub rows: u16, + pub shell: Option, + /// Start the shell as a login shell (reads the user's profile). + pub login: bool, +} + +/// The terminals of one desktop view. +pub struct Terminals { + sessions: Mutex>>, + sender: broadcast::Sender, +} + +struct Output { + bytes: Vec, + /// Absolute offset of `bytes[0]` in everything the terminal printed. + start: u64, + exit_code: Option, + /// The shell exited (or the terminal was closed). + exited: bool, +} + +/// A kernel handle closed when dropped. +struct Owned(HANDLE); +// SAFETY: kernel handles may be used and closed from any thread. +unsafe impl Send for Owned {} +unsafe impl Sync for Owned {} +impl Drop for Owned { + fn drop(&mut self) { + // SAFETY: opened for this terminal and closed once. + unsafe { CloseHandle(self.0) }; + } +} + +/// The pseudo console; closing it hangs up the shell. +struct Console(Mutex>); +impl Console { + fn close(&self) { + if let Some(console) = self.0.lock().ok().and_then(|mut c| c.take()) { + // SAFETY: created by CreatePseudoConsole and closed once. + unsafe { ClosePseudoConsole(console) }; + } + } +} + +struct Session { + id: String, + workspace: PathBuf, + number: u32, + shell: String, + created: f64, + console: Console, + input: Mutex, + process: Owned, + job: Option, + output: Mutex, + size: Mutex<(u16, u16)>, + closed: AtomicBool, + pending: AtomicBool, + sender: broadcast::Sender, +} + +impl Session { + fn wake(&self, kind: &str) { + let _ = self + .sender + .send(json!({"type": kind, "terminal_id": self.id, "session_id": null})); + } + fn output(&self) -> Result> { + self.output + .lock() + .map_err(|_| anyhow::anyhow!("Terminal output lock poisoned")) + } + fn append(&self, chunk: &[u8]) { + let Ok(mut output) = self.output() else { + return; + }; + output.bytes.extend_from_slice(chunk); + if output.bytes.len() > SCROLLBACK_BYTES { + // Keep three quarters, cut after a line break when one is near. + let mut cut = output.bytes.len() - SCROLLBACK_BYTES * 3 / 4; + if let Some(newline) = output.bytes[cut..] + .iter() + .take(4096) + .position(|b| *b == b'\n') + { + cut += newline + 1; + } + output.bytes.drain(..cut); + output.start += cut as u64; + } + } + fn describe(&self) -> Value { + let (cols, rows) = self.size.lock().map(|s| *s).unwrap_or((80, 24)); + let (exited, exit_code, end) = self + .output() + .map(|o| (o.exited, o.exit_code, o.start + o.bytes.len() as u64)) + .unwrap_or((true, None, 0)); + json!({ + "id": self.id, + "title": format!("Terminal {}", self.number), + "number": self.number, + "workspace": self.workspace, + "shell": self.shell, + "cols": cols, + "rows": rows, + "created": self.created, + "exited": exited, + "exit_code": exit_code, + "cursor": end, + }) + } + fn exited(&self) -> bool { + self.output().map(|o| o.exited).unwrap_or(true) + } + fn kill(&self) { + if let Some(job) = &self.job { + job.terminate(); + } + } + /// Hang up the shell; end it and what it started after `grace`. + fn hang_up(self: &Arc, grace: Duration) { + self.closed.store(true, Ordering::Release); + self.console.close(); + let session = self.clone(); + std::thread::spawn(move || { + let deadline = Instant::now() + grace; + while Instant::now() < deadline && !session.exited() { + std::thread::sleep(Duration::from_millis(25)); + } + session.kill(); + }); + } +} + +impl Terminals { + pub fn new(sender: broadcast::Sender) -> Self { + Self { + sessions: Mutex::new(HashMap::new()), + sender, + } + } + fn sessions(&self) -> Result>>> { + self.sessions + .lock() + .map_err(|_| anyhow::anyhow!("Terminal registry lock poisoned")) + } + fn get(&self, id: &str) -> Result> { + self.sessions()? + .get(id) + .cloned() + .context("This terminal was closed") + } + /// Start a shell in `workspace` and return its description. + pub fn open(&self, workspace: &Path, options: OpenOptions) -> Result { + ensure!(workspace.is_dir(), "The project folder does not exist"); + let mut sessions = self.sessions()?; + ensure!( + sessions.len() < MAX_TERMINALS, + "At most {MAX_TERMINALS} terminals can be open. Close one first." + ); + let used: Vec = sessions + .values() + .filter(|s| s.workspace == workspace) + .map(|s| s.number) + .collect(); + let number = (1..).find(|n| !used.contains(n)).unwrap_or(1); + let (cols, rows) = clamp_size(options.cols, options.rows); + let shell = options.shell.unwrap_or_else(login_shell); + let pty = spawn(&shell, options.login, workspace, cols, rows)?; + let job = crate::process::Job::for_process(pty.process.0 as RawHandle).ok(); + let session = Arc::new(Session { + id: crate::id(), + workspace: workspace.to_owned(), + number, + shell: shell.to_string_lossy().into_owned(), + created: crate::now(), + console: Console(Mutex::new(Some(pty.console))), + input: Mutex::new(pty.input), + process: pty.process, + job, + output: Mutex::new(Output { + bytes: Vec::new(), + start: 0, + exit_code: None, + exited: false, + }), + size: Mutex::new((cols, rows)), + closed: AtomicBool::new(false), + pending: AtomicBool::new(false), + sender: self.sender.clone(), + }); + let pump = session.clone(); + let mut reader = pty.output; + std::thread::Builder::new() + .name("terminal-output".into()) + .spawn(move || { + let mut buffer = vec![0u8; 16 * 1024]; + // Ends when the console closes its side of the pipe. + while let Ok(count) = reader.read(&mut buffer) { + if count == 0 { + break; + } + pump.append(&buffer[..count]); + pump.pending.store(true, Ordering::Release); + } + if pump.pending.swap(false, Ordering::AcqRel) { + pump.wake("terminal.output"); + } + })?; + // A blocking read cannot time out: wake-ups are coalesced here. + let waker = session.clone(); + std::thread::Builder::new() + .name("terminal-wake".into()) + .spawn(move || loop { + std::thread::sleep(WAKE_INTERVAL); + if waker.pending.swap(false, Ordering::AcqRel) { + waker.wake("terminal.output"); + } + if waker.exited() { + break; + } + })?; + let waiter = session.clone(); + std::thread::Builder::new() + .name("terminal-wait".into()) + .spawn(move || { + let mut code = 0u32; + // SAFETY: the process handle stays open with the session. + let code = unsafe { + WaitForSingleObject(waiter.process.0, INFINITE); + GetExitCodeProcess(waiter.process.0, &mut code) != 0 + } + .then_some(code as i32); + // The shell is gone: the console has nothing left to show. + waiter.console.close(); + if let Ok(mut output) = waiter.output() { + output.exit_code = code; + output.exited = true; + } + waiter.wake("terminal.exited"); + })?; + let description = session.describe(); + sessions.insert(session.id.clone(), session); + Ok(description) + } + /// Terminals, oldest first; only those in `workspace` when given. + pub fn list(&self, workspace: Option<&Path>) -> Result> { + let mut rows: Vec<_> = self + .sessions()? + .values() + .filter(|s| workspace.is_none_or(|w| s.workspace == w)) + .cloned() + .collect(); + rows.sort_by(|a, b| a.created.total_cmp(&b.created)); + Ok(rows.iter().map(|s| s.describe()).collect()) + } + /// The terminal's project folder (routes check it against the view). + pub fn workspace(&self, id: &str) -> Result { + Ok(self.get(id)?.workspace.clone()) + } + /// Type into the terminal. Blocks while the console's input buffer is + /// full, so callers run it off the async workers. + pub fn write(&self, id: &str, data: &[u8]) -> Result<()> { + ensure!(data.len() <= WRITE_LIMIT, "Paste at most 64 KB at a time"); + let session = self.get(id)?; + ensure!( + !session.output()?.exited, + "This terminal's shell has exited. Open a new terminal." + ); + let mut input = session + .input + .lock() + .map_err(|_| anyhow::anyhow!("Terminal input lock poisoned"))?; + input + .write_all(data) + .context("Could not type into the terminal")?; + Ok(()) + } + pub fn resize(&self, id: &str, cols: u16, rows: u16) -> Result { + let session = self.get(id)?; + let (cols, rows) = clamp_size(cols, rows); + if let Some(console) = session.console.0.lock().ok().and_then(|c| *c) { + let size = COORD { + X: cols as i16, + Y: rows as i16, + }; + // SAFETY: the console is open while it is held here. + let result = unsafe { ResizePseudoConsole(console, size) }; + ensure!(result >= 0, "Could not resize the terminal ({result:#x})"); + } + *session + .size + .lock() + .map_err(|_| anyhow::anyhow!("Terminal size lock poisoned"))? = (cols, rows); + Ok(json!({"cols": cols, "rows": rows})) + } + /// Output after byte offset `after`, base64-encoded (see the Unix version). + pub fn read(&self, id: &str, after: u64) -> Result { + let session = self.get(id)?; + let output = session.output()?; + let end = output.start + output.bytes.len() as u64; + let from = after.clamp(output.start, end); + let offset = (from - output.start) as usize; + let take = (output.bytes.len() - offset).min(READ_LIMIT); + let data = &output.bytes[offset..offset + take]; + Ok(json!({ + "id": session.id, + "data": base64::engine::general_purpose::STANDARD.encode(data), + "from": from, + "cursor": from + take as u64, + "more": from + (take as u64) < end, + "truncated": after < output.start, + "exited": output.exited, + "exit_code": output.exit_code, + })) + } + /// Hang up one terminal and forget it. + pub fn close(&self, id: &str) -> Result<()> { + let session = self + .sessions()? + .remove(id) + .context("This terminal was already closed")?; + session.hang_up(Duration::from_millis(1500)); + Ok(()) + } + /// Hang up every terminal (the window is quitting). Waits up to `wait` + /// for the shells to exit, then ends what is left. + pub fn close_all(&self, wait: Duration) { + let sessions: Vec<_> = match self.sessions.lock() { + Ok(mut sessions) => sessions.drain().map(|(_, s)| s).collect(), + Err(_) => return, + }; + for session in &sessions { + session.hang_up(wait.max(Duration::from_millis(500))); + } + let deadline = Instant::now() + wait; + while Instant::now() < deadline && sessions.iter().any(|s| !s.exited()) { + std::thread::sleep(Duration::from_millis(20)); + } + if !wait.is_zero() { + for session in &sessions { + session.kill(); + } + } + } +} + +impl Drop for Terminals { + fn drop(&mut self) { + // Nobody can see these terminals any more. + self.close_all(Duration::ZERO); + } +} + +fn clamp_size(cols: u16, rows: u16) -> (u16, u16) { + ( + if cols == 0 { 80 } else { cols.clamp(2, 1000) }, + if rows == 0 { 24 } else { rows.clamp(1, 500) }, + ) +} + +/// PowerShell 7 when installed, else Windows PowerShell. +pub fn login_shell() -> PathBuf { + if let Some(pwsh) = crate::sandbox::which("pwsh") { + return pwsh; + } + let system = std::env::var_os("SystemRoot") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(r"C:\Windows")); + system.join(r"System32\WindowsPowerShell\v1.0\powershell.exe") +} + +/// The user's environment for their own shell, plus terminal identification. +pub fn user_environment() -> Vec<(OsString, OsString)> { + let mut vars: Vec<(OsString, OsString)> = std::env::vars_os() + .filter(|(name, _)| { + !matches!( + name.to_string_lossy().to_ascii_uppercase().as_str(), + "TERM" | "COLORTERM" | "TERM_PROGRAM" + ) + }) + .collect(); + vars.push(("TERM".into(), "xterm-256color".into())); + vars.push(("COLORTERM".into(), "truecolor".into())); + vars.push(("TERM_PROGRAM".into(), "ShadowCode".into())); + vars +} + +struct Pty { + console: HPCON, + input: std::fs::File, + output: std::fs::File, + process: Owned, +} + +fn wide(text: &std::ffi::OsStr) -> Vec { + text.encode_wide().chain(Some(0)).collect() +} + +/// One anonymous pipe: (read end, write end). +fn pipe() -> Result<(Owned, Owned)> { + let (mut read, mut write): (HANDLE, HANDLE) = (std::ptr::null_mut(), std::ptr::null_mut()); + // SAFETY: CreatePipe writes two handles on success. + ensure!( + unsafe { CreatePipe(&mut read, &mut write, std::ptr::null(), 0) } != 0, + "Could not create a terminal pipe: {}", + std::io::Error::last_os_error() + ); + Ok((Owned(read), Owned(write))) +} + +fn spawn(shell: &Path, login: bool, cwd: &Path, cols: u16, rows: u16) -> Result { + let (input_read, input_write) = pipe()?; + let (output_read, output_write) = pipe()?; + let mut console: HPCON = 0; + let size = COORD { + X: cols as i16, + Y: rows as i16, + }; + // SAFETY: both pipe ends are open; the console keeps its own references. + let result = + unsafe { CreatePseudoConsole(size, input_read.0, output_write.0, 0, &mut console) }; + ensure!( + result >= 0, + "Could not create a terminal (pseudo console {result:#x})" + ); + // The console holds these ends now; keeping ours would stop the output + // pipe from closing when the console does. + drop(input_read); + drop(output_write); + let close_console = |error: anyhow::Error| { + // SAFETY: created above and not yet handed to a session. + unsafe { ClosePseudoConsole(console) }; + error + }; + // The attribute list naming the console. + let mut list_size = 0usize; + // SAFETY: a size query; nothing is written with a null list. + unsafe { InitializeProcThreadAttributeList(std::ptr::null_mut(), 1, 0, &mut list_size) }; + let mut list = vec![0u64; list_size.div_ceil(8)]; + let attributes = list.as_mut_ptr().cast::(); + // SAFETY: the buffer has the size the query returned, and the console + // value outlives the process creation below. + let ready = unsafe { + InitializeProcThreadAttributeList(attributes, 1, 0, &mut list_size) != 0 + && UpdateProcThreadAttribute( + attributes, + 0, + PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE as usize, + console as *const std::ffi::c_void, + std::mem::size_of::(), + std::ptr::null_mut(), + std::ptr::null(), + ) != 0 + }; + if !ready { + return Err(close_console(anyhow::anyhow!( + "Could not attach the terminal: {}", + std::io::Error::last_os_error() + ))); + } + let mut command = OsString::from("\""); + command.push(shell.as_os_str()); + command.push("\""); + let name = shell + .file_stem() + .map(|s| s.to_string_lossy().to_ascii_lowercase()) + .unwrap_or_default(); + if name == "bash" && login { + command.push(" --login -i"); + } else if name == "pwsh" || name == "powershell" { + command.push(" -NoLogo"); + } + let mut command_line = wide(&command); + let mut environment: Vec = Vec::new(); + let mut vars = user_environment(); + vars.sort_by_key(|(name, _)| name.to_string_lossy().to_ascii_uppercase()); + for (name, value) in vars { + environment.extend(name.encode_wide()); + environment.push(b'=' as u16); + environment.extend(value.encode_wide()); + environment.push(0); + } + environment.push(0); + let directory = wide(cwd.as_os_str()); + let mut startup = STARTUPINFOEXW::default(); + startup.StartupInfo.cb = std::mem::size_of::() as u32; + // Null standard handles: the pseudo console provides them. Without this + // flag a shell started from a process with redirected handles (the + // engine under a service, a test) inherits those instead. + startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + startup.lpAttributeList = attributes; + let mut process = PROCESS_INFORMATION::default(); + // SAFETY: every buffer is NUL-terminated and lives across the call; the + // standard handles are left unset so the console provides them. + let created = unsafe { + CreateProcessW( + std::ptr::null(), + command_line.as_mut_ptr(), + std::ptr::null(), + std::ptr::null(), + 0, + EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT, + environment.as_ptr().cast(), + directory.as_ptr(), + &startup.StartupInfo, + &mut process, + ) + }; + let error = std::io::Error::last_os_error(); + // SAFETY: initialized above; no longer needed once the process exists. + unsafe { DeleteProcThreadAttributeList(attributes) }; + if created == 0 { + return Err(close_console(anyhow::anyhow!( + "Could not start {}: {error}", + shell.display() + ))); + } + // SAFETY: the thread handle is not needed; the process handle is owned. + unsafe { CloseHandle(process.hThread) }; + // SAFETY: each handle is owned here and handed to exactly one File. + let (input, output) = unsafe { + let input = std::fs::File::from_raw_handle(input_write.0 as RawHandle); + std::mem::forget(input_write); + let output = std::fs::File::from_raw_handle(output_read.0 as RawHandle); + std::mem::forget(output_read); + (input, output) + }; + Ok(Pty { + console, + input, + output, + process: Owned(process.hProcess), + }) +} + +/// 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()) { + Ok(id) + } else { + bail!("Unknown terminal") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sizes_are_clamped() { + assert_eq!(clamp_size(0, 0), (80, 24)); + assert_eq!(clamp_size(1, 9000), (2, 500)); + } + + #[test] + fn terminal_ids_are_hex() { + assert!(valid_id(&"a".repeat(32)).is_ok()); + assert!(valid_id("../etc").is_err()); + } + + #[test] + fn a_shell_prints_and_exits() { + let (sender, _) = broadcast::channel(64); + let terminals = Terminals::new(sender); + let dir = tempfile::tempdir().unwrap(); + let shell = PathBuf::from(std::env::var_os("ComSpec").unwrap()); + let opened = terminals + .open( + dir.path(), + OpenOptions { + cols: 80, + rows: 24, + shell: Some(shell), + login: false, + }, + ) + .unwrap(); + let id = opened["id"].as_str().unwrap().to_owned(); + terminals + .write(&id, b"echo shadowcode-terminal-ok\r\nexit 3\r\n") + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(20); + let text = loop { + let read = terminals.read(&id, 0).unwrap(); + let text = String::from_utf8_lossy( + &base64::engine::general_purpose::STANDARD + .decode(read["data"].as_str().unwrap()) + .unwrap(), + ) + .into_owned(); + if read["exited"] == true || Instant::now() > deadline { + assert_eq!(read["exit_code"], 3, "{text}"); + break text; + } + std::thread::sleep(Duration::from_millis(50)); + }; + assert!(text.contains("shadowcode-terminal-ok"), "{text}"); + terminals.close(&id).unwrap(); + } +} diff --git a/native/core/src/tools.rs b/native/core/src/tools.rs index 036bc683..d9bda4c0 100644 --- a/native/core/src/tools.rs +++ b/native/core/src/tools.rs @@ -590,12 +590,7 @@ impl ToolExecutor { paths .into_iter() .filter_map(|path| { - let rel = self - .workspace - .relative(&path) - .ok()? - .to_string_lossy() - .into_owned(); + let rel = self.workspace.relative_text(&path).ok()?; (!crate::mentions::in_scope(scope, &rel)).then_some(rel) }) .collect() @@ -891,7 +886,6 @@ impl ToolExecutor { if call.name.starts_with("git_") { return self.git(&call.name, &call.arguments).await; } - #[cfg(unix)] if matches!(call.name.as_str(), "mcp_sqlite_tables" | "mcp_sqlite_query") { let mut request: crate::sqlite::Request = serde_json::from_value(call.arguments.clone())?; @@ -1079,7 +1073,7 @@ impl ToolExecutor { !rel.components().any(|c| c.as_os_str() == ".git"), "Cannot stage Git metadata" ); - v.push(rel.to_string_lossy().into_owned()); + v.push(crate::paths::slash(&rel)); } v } @@ -1403,11 +1397,7 @@ impl ToolExecutor { "expected_hash must be 'missing' for a new file or the current SHA-256 hash returned by read_file. Omit it to use this task's recorded read; do not pass an empty string" ); } - let path = self - .workspace - .relative(path)? - .to_string_lossy() - .into_owned(); + let path = self.workspace.relative_text(path)?; let reads = self .observed .lock() @@ -1481,11 +1471,7 @@ impl ToolExecutor { change.path ) })?; - let path = self - .workspace - .relative(&change.path)? - .to_string_lossy() - .into_owned(); + let path = self.workspace.relative_text(&change.path)?; self.observed .lock() .map_err(|_| anyhow::anyhow!("Read history lock poisoned"))? diff --git a/native/core/src/tools/intel.rs b/native/core/src/tools/intel.rs index d65f9130..bb63c2c3 100644 --- a/native/core/src/tools/intel.rs +++ b/native/core/src/tools/intel.rs @@ -32,7 +32,7 @@ impl ToolExecutor { let Ok(rel) = self.workspace.relative(&path) else { continue; }; - let rel = rel.to_string_lossy().into_owned(); + let rel = crate::paths::slash(&rel); if crate::redaction::is_secret_path(&rel) || !crate::lsp::handles(&env, &rel) { continue; } @@ -95,12 +95,7 @@ impl ToolExecutor { ensure!(paths.len() <= 50, "Give at most 50 focus paths"); for path in paths { let path = path.as_str().context("Each path must be a string")?; - focus.push( - self.workspace - .relative(path)? - .to_string_lossy() - .into_owned(), - ); + focus.push(self.workspace.relative_text(path)?); } } focus.extend(crate::symbol_index::recent_edits(&root, 12)); @@ -139,11 +134,7 @@ impl ToolExecutor { if let Some(path) = args["path"].as_str() { let line = integer(args, "line", 0, 1, 10_000_000)?; let column = integer(args, "column", 1, 1, 100_000)?; - let rel = self - .workspace - .relative(path)? - .to_string_lossy() - .into_owned(); + let rel = self.workspace.relative_text(path)?; let kind = if call.name == "goto_definition" { crate::lsp::Locate::Definition } else { @@ -182,11 +173,7 @@ impl ToolExecutor { } async fn diagnostics(&self, path: &str) -> Result { - let rel = self - .workspace - .relative(path)? - .to_string_lossy() - .into_owned(); + let rel = self.workspace.relative_text(path)?; ensure!( !crate::redaction::is_secret_path(&rel), "Secret paths cannot be inspected" diff --git a/native/core/src/tools/shell.rs b/native/core/src/tools/shell.rs index fab13720..82bae3f3 100644 --- a/native/core/src/tools/shell.rs +++ b/native/core/src/tools/shell.rs @@ -2,6 +2,7 @@ //! it (so rewind can undo what it wrote), and the bounded process run. use super::*; use crate::checkpoint::capture; +use crate::paths::Canonical; impl ToolExecutor { pub(super) async fn shell(&self, args: &Value) -> Result { @@ -20,7 +21,7 @@ impl ToolExecutor { .min(self.config.agent.tool_timeout_sec as usize); let cwd = if let Some(path) = args["cwd"].as_str() { let relative = self.workspace.relative(path)?; - let cwd = self.workspace.path.join(relative).canonicalize()?; + let cwd = self.workspace.path.join(relative).canonical()?; ensure!( cwd.starts_with(&self.workspace.path), "Command directory escapes the workspace" diff --git a/native/core/src/updates.rs b/native/core/src/updates.rs index f8044ae9..c1252a55 100644 --- a/native/core/src/updates.rs +++ b/native/core/src/updates.rs @@ -857,6 +857,8 @@ pub fn about(paths: &AppPaths, config: &Config) -> Value { } #[cfg(test)] +// Helpers of Unix-only tests are unused on Windows. +#[cfg_attr(not(unix), allow(unused))] mod tests { use super::*; @@ -1072,6 +1074,7 @@ mod tests { assert!(!automatic(&config, &open)); } + #[cfg(target_os = "linux")] #[test] fn install_type_is_detected_from_the_environment() { let detect = |bundle, appimage: Option<&str>, appdir: Option<&str>, exe: Option<&str>| { diff --git a/native/core/src/verification.rs b/native/core/src/verification.rs index c24e26c1..5bba0e8e 100644 --- a/native/core/src/verification.rs +++ b/native/core/src/verification.rs @@ -1,6 +1,7 @@ //! Verification receipts extend the existing command/task event stream. //! A successful process is not task acceptance. Only explicit/project check //! commands contribute a scoped check result, tied to observed file content. +use crate::paths::Canonical; use crate::{ models::ToolCall, tools::{ToolExecutor, ToolResult}, @@ -203,7 +204,7 @@ pub async fn execute( .workspace .relative(cwd_arg) .ok() - .and_then(|p| tools.workspace.path.join(p).canonicalize().ok()); + .and_then(|p| tools.workspace.path.join(p).canonical().ok()); let check = if explicit { true } else if cwd.as_ref() == Some(&tools.workspace.path) { diff --git a/native/core/src/vision.rs b/native/core/src/vision.rs index daaa72d9..0259c1fd 100644 --- a/native/core/src/vision.rs +++ b/native/core/src/vision.rs @@ -315,7 +315,7 @@ pub fn view_image(workspace: &Workspace, args: &Value) -> Result { "That file looks like a secret and is not shown to the model" ); let relative = workspace.relative(path)?; - let relative = relative.to_string_lossy().into_owned(); + let relative = crate::paths::slash(&relative); let snap = workspace .snapshot(&relative) .with_context(|| format!("Cannot read image {relative}"))?; diff --git a/native/core/src/workspace.rs b/native/core/src/workspace.rs index b70ad733..7a01cefb 100644 --- a/native/core/src/workspace.rs +++ b/native/core/src/workspace.rs @@ -1,3 +1,4 @@ +use crate::paths::Canonical; use anyhow::{bail, ensure, Context, Result}; use cap_std::{ ambient_authority, @@ -13,7 +14,8 @@ use std::{ /// 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. +/// reaches the last openat. On Windows it opens a link itself, which is +/// then refused as not a regular file. fn no_follow(options: &mut OpenOptions) -> &mut OpenOptions { use cap_fs_ext::{FollowSymlinks, OpenOptionsFollowExt}; options.follow(FollowSymlinks::No) @@ -54,7 +56,7 @@ pub struct Snapshot { /// Stable reservation key for an existing workspace or a managed checkout whose /// final directory was removed. Never resolve a missing ancestor or traversal. pub(crate) fn reservation_path(path: &Path) -> Result { - match path.canonicalize() { + match path.canonical() { Ok(path) => { ensure!(path.is_dir(), "Workspace must be a directory"); Ok(path) @@ -75,7 +77,7 @@ pub(crate) fn reservation_path(path: &Path) -> Result { let parent = path .parent() .context("Missing workspace parent")? - .canonicalize()?; + .canonical()?; ensure!(parent.is_dir(), "Workspace parent must be a directory"); Ok(parent.join(path.file_name().context("Missing workspace name")?)) } @@ -85,11 +87,16 @@ pub(crate) fn reservation_path(path: &Path) -> Result { impl Workspace { pub fn open(path: &Path) -> Result { - let path = path.canonicalize().context("Workspace does not exist")?; + let path = path.canonical().context("Workspace does not exist")?; ensure!(path.is_dir(), "Workspace must be a directory"); let dir = Dir::open_ambient_dir(&path, ambient_authority())?; Ok(Self { path, dir }) } + /// [`Workspace::relative`] as the agent, the interface and stored records + /// name it: `/` between its parts on every system. + pub fn relative_text(&self, path: &str) -> Result { + Ok(crate::paths::slash(&self.relative(path)?)) + } pub fn relative(&self, path: &str) -> Result { ensure!(!path.contains('\0'), "Path contains a NUL byte"); let path = Path::new(path); @@ -238,11 +245,7 @@ impl Workspace { }; entries.push(FileEntry { name, - path: child - .strip_prefix(".") - .unwrap_or(&child) - .to_string_lossy() - .into_owned(), + path: crate::paths::slash(child.strip_prefix(".").unwrap_or(&child)), kind: kind.into(), }); } @@ -295,11 +298,11 @@ impl Workspace { pub(crate) fn inspect(&self, path: &str, limit: usize) -> Result<(Vec, u64)> { let mut options = OpenOptions::new(); options.read(true); + no_follow(&mut options); #[cfg(unix)] { 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)] @@ -358,7 +361,7 @@ impl Workspace { "Binary files cannot be displayed as text" ); Ok(FileContent { - path: self.relative(path)?.to_string_lossy().into_owned(), + path: crate::paths::slash(&self.relative(path)?), hash: hash(&bytes), bytes: bytes.len(), content: String::from_utf8(bytes).context("File is not valid UTF-8")?, @@ -393,6 +396,43 @@ impl Workspace { } Ok((parent, name)) } + /// Windows: each folder on the way is opened without following a link and + /// without sharing delete access, so the parent this returns cannot be + /// renamed or removed while it is held. + #[cfg(windows)] + pub(crate) fn confined_parent(&self, path: &str) -> Result<(Dir, String)> { + use cap_std::fs::OpenOptionsExt; + const FILE_SHARE_READ: u32 = 0x1; + const FILE_SHARE_WRITE: u32 = 0x2; + const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000; + let relative = self.relative(path)?; + let name = relative + .file_name() + .and_then(|n| n.to_str()) + .context("A regular file path with a UTF-8 name is required")? + .to_owned(); + let mut parent = self.dir.try_clone()?; + let mut options = OpenOptions::new(); + options + .read(true) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS); + no_follow(&mut options); + for part in relative.parent().unwrap_or(Path::new(".")).components() { + if let Component::Normal(name) = part { + let next = parent + .open_with(name, &options) + .context("Database parent must be a real workspace directory")?; + let meta = next.metadata()?; + ensure!( + meta.is_dir() && !meta.file_type().is_symlink(), + "Database parent must be a real workspace directory" + ); + parent = Dir::from_std_file(next.into_std()); + } + } + Ok((parent, name)) + } pub fn write(&self, path: &str, bytes: &[u8], expected: Option<&str>) -> Result { ensure!( bytes.len() <= MAX_FILE_BYTES, @@ -467,6 +507,8 @@ impl Workspace { file.write_all(bytes)?; file.sync_all()?; parent.rename(&temporary, &parent, name)?; + // Windows cannot open a folder as a file (see paths::sync_directory). + #[cfg(unix)] parent.open(".")?.sync_all()?; Ok(()) })(); @@ -512,6 +554,9 @@ impl Workspace { Ok(()) } pub fn set_mode(&self, path: &str, mode: u32) -> Result<()> { + // Windows files have no mode bits to keep. + #[cfg(not(unix))] + let _ = (path, mode); #[cfg(unix)] { use cap_std::fs::{Permissions, PermissionsExt}; @@ -610,11 +655,7 @@ impl Workspace { serde_json::json!({"matches":matches,"truncated":true,"scanned":scanned-1}), ); } - let rel = entry - .path() - .strip_prefix(&self.path)? - .to_string_lossy() - .into_owned(); + let rel = crate::paths::slash(entry.path().strip_prefix(&self.path)?); // A glob override outranks the hidden-file rule, so it could reach // `.env` and other secret files: never return their contents. if crate::redaction::is_secret_path(&rel) { @@ -664,13 +705,7 @@ impl Workspace { .to_lowercase() .contains(&needle) { - hits.push( - entry - .path() - .strip_prefix(&self.path)? - .to_string_lossy() - .into_owned(), - ); + hits.push(crate::paths::slash(entry.path().strip_prefix(&self.path)?)); } } Ok(serde_json::json!({"hits":hits,"truncated":false})) @@ -683,7 +718,7 @@ fn canonical_ancestor(path: &Path) -> Option { let mut rest = Vec::new(); let mut current = path; loop { - if let Ok(real) = current.canonicalize() { + if let Ok(real) = current.canonical() { return Some(rest.iter().rev().fold(real, |acc, part| acc.join(part))); } rest.push(current.file_name()?.to_owned()); @@ -711,11 +746,11 @@ fn open_mutation_directory(parent: &Dir, name: &std::ffi::OsStr) -> std::io::Res fn open_mutation_file(parent: &Dir, name: &std::ffi::OsStr) -> std::io::Result { let mut options = OpenOptions::new(); options.read(true); + no_follow(&mut options); #[cfg(unix)] { use cap_std::fs::OpenOptionsExt; options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK); - no_follow(&mut options); } parent.open_with(name, &options) } diff --git a/native/core/src/worktree_tasks.rs b/native/core/src/worktree_tasks.rs index 2a4fe87f..3bf410c5 100644 --- a/native/core/src/worktree_tasks.rs +++ b/native/core/src/worktree_tasks.rs @@ -14,6 +14,7 @@ //! A worktree task's folder is never listed as a project or remembered as //! the relaunch folder (see `Service::select_if`); its conversation is //! listed under its project (`worktree_source`). +use crate::paths::Canonical; use crate::{ compare::{self, Base, FileStat}, engine::{Engine, Job}, @@ -34,7 +35,7 @@ use tokio_util::sync::CancellationToken; mod locks; pub mod setup; // Used by worktrees::cleanup's tests. -#[cfg(test)] +#[cfg(all(test, unix))] pub(crate) fn record_lock_references(store: &Store, id: &str) -> usize { locks::record_references(store, id) } @@ -325,7 +326,7 @@ fn copy_attachments(source: &Path, worktree: &Path, task: &str, images: &[String // ~/.ssh must never be read, and must never be copied onto through the same // symlink in the worktree (which would truncate the target). let base = source.join(PREFIX); - let (Ok(canon_base), Ok(canon_source)) = (base.canonicalize(), source.canonicalize()) else { + let (Ok(canon_base), Ok(canon_source)) = (base.canonical(), source.canonical()) else { return Ok(()); }; if !canon_base.starts_with(&canon_source) || !canon_base.is_dir() { @@ -343,7 +344,7 @@ fn copy_attachments(source: &Path, worktree: &Path, task: &str, images: &[String let from = source.join(path); // Resolve every component: a symlink anywhere in the path that leaves // the attachments folder is refused. - let Ok(canon_from) = from.canonicalize() else { + let Ok(canon_from) = from.canonical() else { continue; }; if !canon_from.starts_with(&canon_base) { @@ -358,7 +359,7 @@ fn copy_attachments(source: &Path, worktree: &Path, task: &str, images: &[String fs::create_dir_all(parent)?; // The destination folder must resolve inside the worktree, so the // copy cannot follow a symlink out of it. - match (parent.canonicalize(), worktree.canonicalize()) { + match (parent.canonical(), worktree.canonical()) { (Ok(canon_parent), Ok(canon_worktree)) if canon_parent.starts_with(&canon_worktree) => {} _ => continue, @@ -468,7 +469,7 @@ pub(crate) async fn prepare( }; let record_guard = locks::record(&engine.store(), &record.id, &cancel).await?; let prepared: Result = async { - record.worktree = checkout.path.canonicalize()?; + record.worktree = checkout.path.canonical()?; compare::set_trust(engine, std::slice::from_ref(&record.worktree), true)?; copy_attachments(&source, &record.worktree, task, images)?; let store = engine.store(); @@ -1003,7 +1004,7 @@ mod tests { } impl Canonical { fn new(dir: tempfile::TempDir) -> Self { - let path = dir.path().canonicalize().unwrap(); + let path = crate::paths::Canonical::canonical(dir.path()).unwrap(); Self { _dir: dir, path } } fn path(&self) -> &std::path::Path { @@ -1038,6 +1039,8 @@ mod tests { assert!(!worktree.join("secret.txt").exists()); } + // Creating a symlink needs extra privileges on Windows. + #[cfg(unix)] #[test] fn attachments_never_follow_a_symlink_out_of_the_project() { let root = tempfile::tempdir().unwrap(); @@ -1150,7 +1153,8 @@ mod tests { } } async fn until(mut ready: impl FnMut() -> bool, message: &str) { - tokio::time::timeout(Duration::from_secs(3), async { + // A shell can take seconds to start on a loaded Windows runner. + tokio::time::timeout(Duration::from_secs(10), async { while !ready() { tokio::time::sleep(Duration::from_millis(5)).await; } @@ -1202,7 +1206,8 @@ mod tests { .unwrap() .unwrap(); assert_eq!(record.id, f.record.id); - assert!(discard(&f.engine, &f.record.id).await.unwrap().removed); + let closed = discard(&f.engine, &f.record.id).await.unwrap(); + assert!(closed.removed, "{:?}", closed.notes); f.engine.shutdown().await.unwrap(); } @@ -1264,7 +1269,8 @@ mod tests { b"independent bytes\n" ); drop(held); - assert!(discard(&f.engine, &f.record.id).await.unwrap().removed); + let closed = discard(&f.engine, &f.record.id).await.unwrap(); + assert!(closed.removed, "{:?}", closed.notes); f.engine.shutdown().await.unwrap(); } @@ -1336,7 +1342,7 @@ mod tests { ); fs::rename(&preserved, &f.source).unwrap(); let closed = discard(&f.engine, &f.record.id).await.unwrap(); - assert!(closed.removed && !closed.open()); + assert!(closed.removed && !closed.open(), "{:?}", closed.notes); assert_eq!(command(&f.source, &["rev-parse", "HEAD"]), before_head); assert_eq!(fs::read(f.source.join(".git/index")).unwrap(), source_index); assert!(!f.record.worktree.exists()); @@ -1393,7 +1399,8 @@ mod tests { b"source bytes\n" ); save(&f.engine.store(), &f.record).unwrap(); - assert!(discard(&f.engine, &f.record.id).await.unwrap().removed); + let closed = discard(&f.engine, &f.record.id).await.unwrap(); + assert!(closed.removed, "{:?}", closed.notes); f.engine.shutdown().await.unwrap(); } @@ -1430,7 +1437,8 @@ mod tests { b"source bytes\n" ); store.set_native_meta(&key, &before).unwrap(); - assert!(discard(&f.engine, &f.record.id).await.unwrap().removed); + let closed = discard(&f.engine, &f.record.id).await.unwrap(); + assert!(closed.removed, "{:?}", closed.notes); f.engine.shutdown().await.unwrap(); } @@ -1504,7 +1512,8 @@ mod tests { ); assert!(load(&f.engine.store(), &f.record.id).unwrap().open()); fs::write(&record_path, original).unwrap(); - assert!(discard(&f.engine, &f.record.id).await.unwrap().removed); + let closed = discard(&f.engine, &f.record.id).await.unwrap(); + assert!(closed.removed, "{:?}", closed.notes); f.engine.shutdown().await.unwrap(); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -1580,7 +1589,8 @@ mod tests { Some(f.record.id.as_str()) ); fs::rename(&preserved, &f.source).unwrap(); - assert!(discard(&f.engine, &f.record.id).await.unwrap().removed); + let closed = discard(&f.engine, &f.record.id).await.unwrap(); + assert!(closed.removed, "{:?}", closed.notes); assert!(!f.source.join("must-not-run.txt").exists()); f.engine.shutdown().await.unwrap(); } @@ -1639,7 +1649,8 @@ mod tests { let did_not_run = !f.source.join("must-not-run.txt").exists() && !f.record.worktree.join("must-not-run.txt").exists(); f.engine.shutdown().await.unwrap(); - assert!(result.unwrap().removed); + let result = result.unwrap(); + assert!(result.removed, "{:?}", result.notes); assert_eq!( statuses, vec!["cancelled", "cancelled"], @@ -1728,9 +1739,14 @@ mod tests { ); drop(held); let retried = discard(&f.engine, &prepared.id).await.unwrap(); - assert!(retried.removed && !prepared.worktree.exists()); + assert!( + retried.removed && !prepared.worktree.exists(), + "{:?}", + retried.notes + ); assert!(!f.source.join("must-not-run.txt").exists()); - assert!(discard(&f.engine, &f.record.id).await.unwrap().removed); + let closed = discard(&f.engine, &f.record.id).await.unwrap(); + assert!(closed.removed, "{:?}", closed.notes); f.engine.shutdown().await.unwrap(); } @@ -1742,7 +1758,8 @@ mod tests { crate::symbol_index::ensure_index(&f.record.worktree, &[], true).unwrap(); let index = crate::symbol_index::index_path(&f.record.worktree); assert!(index.exists()); - assert!(discard(&f.engine, &f.record.id).await.unwrap().removed); + let closed = discard(&f.engine, &f.record.id).await.unwrap(); + assert!(closed.removed, "{:?}", closed.notes); // Back in the project, its turns no longer get the task's port. assert!(store .session_meta(&f.record.session_id, keys::TASK_ENV) @@ -1755,14 +1772,14 @@ mod tests { #[tokio::test] async fn abandoning_a_task_after_its_setup_ran_runs_the_teardown() { let f = fixture().await; - let source = f.source.canonicalize().unwrap(); + let source = crate::paths::Canonical::canonical(&f.source).unwrap(); let marker = f.root.path().join("teardown-ran"); setup::save( &f.engine.store(), &source, setup::Setup { setup: vec!["printf \"$PORT\" > port.txt".into()], - teardown: vec![format!("touch {}", marker.display())], + teardown: vec![format!("touch {}", crate::paths::slash(&marker))], ..setup::Setup::default() }, ) @@ -1786,14 +1803,15 @@ mod tests { abandon(&f.engine, prepared.clone()).await; assert!(marker.exists(), "services setup started were left running"); assert!(!prepared.worktree.exists()); - assert!(discard(&f.engine, &f.record.id).await.unwrap().removed); + let closed = discard(&f.engine, &f.record.id).await.unwrap(); + assert!(closed.removed, "{:?}", closed.notes); f.engine.shutdown().await.unwrap(); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn setup_runs_without_holding_up_the_projects_other_tasks() { let f = fixture().await; - let source = f.source.canonicalize().unwrap(); + let source = crate::paths::Canonical::canonical(&f.source).unwrap(); let (entered, gate) = (f.root.path().join("entered"), f.root.path().join("gate")); setup::save( &f.engine.store(), @@ -1801,8 +1819,8 @@ mod tests { setup::Setup { setup: vec![format!( "touch {}; while [ ! -e {} ]; do sleep 0.05; done", - entered.display(), - gate.display() + crate::paths::slash(&entered), + crate::paths::slash(&gate) )], ..setup::Setup::default() }, @@ -1818,7 +1836,7 @@ mod tests { .await .expect("Discard waited for another task's setup") .unwrap(); - assert!(closed.removed); + assert!(closed.removed, "{:?}", closed.notes); fs::write(&gate, "").unwrap(); let prepared = tokio::time::timeout(Duration::from_secs(10), preparing) .await @@ -1838,7 +1856,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn closing_shadowcode_stops_a_running_setup_instead_of_waiting_for_it() { let f = fixture().await; - let source = f.source.canonicalize().unwrap(); + let source = crate::paths::Canonical::canonical(&f.source).unwrap(); let entered = f.root.path().join("entered"); setup::save( &f.engine.store(), @@ -1846,7 +1864,7 @@ mod tests { setup::Setup { setup: vec![format!( "touch {}; while true; do sleep 0.05; done", - entered.display() + crate::paths::slash(&entered) )], ..setup::Setup::default() }, diff --git a/native/core/src/worktree_tasks/locks.rs b/native/core/src/worktree_tasks/locks.rs index 2feb8d70..0b3095a7 100644 --- a/native/core/src/worktree_tasks/locks.rs +++ b/native/core/src/worktree_tasks/locks.rs @@ -2,6 +2,7 @@ //! worktree ownership. Readers acquire only record ownership. This module never //! acquires the core mutation mutex or advisory file, so a task may safely call //! create/dispose/release while it owns this distinct outer guard. +use crate::paths::Canonical; use crate::{store::Store, worktrees}; use anyhow::{ensure, Result}; use std::{ @@ -65,11 +66,7 @@ pub(super) async fn record( cancel: &CancellationToken, ) -> Result> { super::valid_id(id)?; - acquire( - Key::Record(store.path.canonicalize()?, id.to_owned()), - cancel, - ) - .await + acquire(Key::Record(store.path.canonical()?, id.to_owned()), cancel).await } #[cfg(test)] @@ -85,9 +82,6 @@ pub(super) fn record_references(store: &Store, id: &str) -> usize { REGISTRY .lock() .unwrap() - .get(&Key::Record( - store.path.canonicalize().unwrap(), - id.to_owned(), - )) + .get(&Key::Record(store.path.canonical().unwrap(), id.to_owned())) .map_or(0, Weak::strong_count) } diff --git a/native/core/src/worktree_tasks/setup.rs b/native/core/src/worktree_tasks/setup.rs index 64d9419e..ddf5f239 100644 --- a/native/core/src/worktree_tasks/setup.rs +++ b/native/core/src/worktree_tasks/setup.rs @@ -8,6 +8,7 @@ //! `PORT` and `SHADOWCODE_PORT` set. Each task's port is free on this //! computer and not used by another open worktree task of the project, and //! the task's shells and subscription CLIs get it too (`keys::TASK_ENV`). +use crate::paths::Canonical; use crate::store::{keys, Store}; use anyhow::{ensure, Result}; use serde::{Deserialize, Serialize}; @@ -229,6 +230,11 @@ async fn run_command( return json!({"command": command, "ok": false, "stopped": "not_started", "seconds": seconds(), "output": format!("Could not start: {error}")}) } }; + // Windows: the job holds what the command starts; dropping it ends them. + #[cfg(windows)] + let job = child + .raw_handle() + .and_then(|handle| crate::process::Job::for_process(handle).ok()); let stdout = Tail::read(child.stdout.take()); let stderr = Tail::read(child.stderr.take()); let waited = tokio::select! { @@ -244,6 +250,10 @@ async fn run_command( libc::kill(-(pid as i32), libc::SIGKILL); } } + #[cfg(windows)] + if let Some(job) = &job { + job.terminate(); + } let _ = child.kill().await; } let grace = tokio::time::Instant::now() + Duration::from_secs(1); @@ -298,11 +308,9 @@ fn copy_file(source: &Path, worktree: &Path, path: &str) -> std::result::Result< if !copyable { return Err("missing, not a regular file, or over 10 MB".into()); } - let (Ok(source), Ok(worktree), Ok(real)) = ( - source.canonicalize(), - worktree.canonicalize(), - from.canonicalize(), - ) else { + let (Ok(source), Ok(worktree), Ok(real)) = + (source.canonical(), worktree.canonical(), from.canonical()) + else { return Err(refused()); }; match real.strip_prefix(&source) { @@ -546,17 +554,21 @@ mod tests { tokio::time::sleep(Duration::from_millis(2500)).await; assert!(!late.exists(), "the background part outlived the timeout"); - let killed = run_command( - dir.path(), - "kill -9 $$", - &env, - Duration::from_secs(10), - &never(), - ) - .await; - assert_eq!(killed["stopped"], "signal"); - assert_eq!(killed["signal"], 9); - assert!(killed["exit_code"].is_null()); + // Windows has no signals to report. + #[cfg(unix)] + { + let killed = run_command( + dir.path(), + "kill -9 $$", + &env, + Duration::from_secs(10), + &never(), + ) + .await; + assert_eq!(killed["stopped"], "signal"); + assert_eq!(killed["signal"], 9); + assert!(killed["exit_code"].is_null()); + } let missing = run_command( &dir.path().join("gone"), "true", diff --git a/native/core/src/worktrees.rs b/native/core/src/worktrees.rs index bb301a31..ddbc3e72 100644 --- a/native/core/src/worktrees.rs +++ b/native/core/src/worktrees.rs @@ -1,4 +1,5 @@ //! Managed Git worktrees. The source checkout is never reset or stashed. +use crate::paths::Canonical; pub mod changes; mod cleanup; mod locks; @@ -38,7 +39,7 @@ fn roots(paths: &AppPaths) -> Result<(PathBuf, PathBuf)> { paths::private_directory(&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()?)) + Ok((records, checkouts.canonical()?)) } fn save(root: &Path, record: &Record) -> Result<()> { let bytes = serde_json::to_vec_pretty(record)?; @@ -117,7 +118,7 @@ pub(crate) async fn task_common_directory( cancel.clone(), ) .await?; - Ok(Path::new(&common).canonicalize()?) + Ok(Path::new(&common).canonical()?) } /// The project a managed checkout was made from, given the checkout's own @@ -125,11 +126,11 @@ pub(crate) async fn task_common_directory( pub(crate) fn source_of_checkout(paths: &AppPaths, folder: &Path) -> Option { let checkouts = paths.data.join("managed-worktrees/checkouts"); let id = folder - .strip_prefix(checkouts.canonicalize().ok()?) + .strip_prefix(checkouts.canonical().ok()?) .ok()? .to_str()?; let record = task_record(paths, id).ok()?; - (record.path.canonicalize().ok()? == folder).then_some(record.source) + (record.path.canonical().ok()? == folder).then_some(record.source) } /// Verify a task's persisted checkout binding without requiring its source or @@ -173,7 +174,7 @@ async fn create_locked( ); let top = git(&source, &["rev-parse", "--show-toplevel"], cancel.clone()).await?; ensure!( - Path::new(&top).canonicalize()? == source, + Path::new(&top).canonical()? == source, "Open the repository root before creating an isolated worktree" ); let commit = git( @@ -199,7 +200,7 @@ async fn create_locked( ) .await?, ) - .canonicalize()?; + .canonical()?; let (records, checkouts) = roots(paths)?; ownership.validate(&source, &common)?; let id = crate::id(); @@ -268,7 +269,7 @@ pub fn sources_of(paths: &AppPaths, path: &Path) -> Vec { .data .join("managed-worktrees") .join("checkouts") - .canonicalize() + .canonical() else { return Vec::new(); }; @@ -355,7 +356,7 @@ pub(crate) async fn validate_task_checkout( ); let meta = fs::symlink_metadata(checkout)?; ensure!( - meta.is_dir() && !meta.file_type().is_symlink() && checkout.canonicalize()? == checkout, + meta.is_dir() && !meta.file_type().is_symlink() && checkout.canonical()? == checkout, "Managed checkout path changed" ); let pointer = fs::symlink_metadata(checkout.join(".git"))?; @@ -365,7 +366,7 @@ pub(crate) async fn validate_task_checkout( ); let root = git(checkout, &["rev-parse", "--show-toplevel"], cancel.clone()).await?; ensure!( - Path::new(&root).canonicalize()? == record.path, + Path::new(&root).canonical()? == record.path, "Managed checkout repository root changed" ); for path in [&record.source, &record.path] { @@ -376,7 +377,7 @@ pub(crate) async fn validate_task_checkout( ) .await?; ensure!( - Path::new(&common).canonicalize()? == record.common_directory, + Path::new(&common).canonical()? == record.common_directory, "Managed checkout repository identity changed" ); } @@ -387,12 +388,12 @@ pub(crate) async fn validate_task_checkout( ) .await?; ensure!( - Path::new(&admin).canonicalize()? + Path::new(&admin).canonical()? == record .common_directory .join("worktrees") .join(id) - .canonicalize()?, + .canonical()?, "Managed checkout administrative identity changed" ); let current = git( @@ -412,10 +413,9 @@ pub(crate) async fn validate_task_checkout( cancel.clone(), ) .await?; - let expected = format!( - "worktree {}", - checkout.to_str().context("Worktree path must be UTF-8")? - ); + // Git lists paths with `/` on Windows too. + checkout.to_str().context("Worktree path must be UTF-8")?; + let expected = format!("worktree {}", crate::paths::slash(checkout)); let registration = registered .split("\0\0") .find(|block| block.split('\0').any(|field| field == expected)) @@ -450,7 +450,7 @@ pub async fn inspect( "Managed checkout must be a real directory" ); ensure!( - record.path.canonicalize()? == record.path, + record.path.canonical()? == record.path, "Managed checkout path changed" ); let actual = git( @@ -460,7 +460,7 @@ pub async fn inspect( ) .await?; ensure!( - Path::new(&actual).canonicalize()? == record.path, + Path::new(&actual).canonical()? == record.path, "Managed checkout repository root changed" ); for path in [&record.source, &record.path] { @@ -471,7 +471,7 @@ pub async fn inspect( ) .await?; ensure!( - Path::new(&common).canonicalize()? == record.common_directory, + Path::new(&common).canonical()? == record.common_directory, "Managed checkout repository identity changed" ); } @@ -481,13 +481,12 @@ pub async fn inspect( cancel.clone(), ) .await?; - let expected = format!( - "worktree {}", - record - .path - .to_str() - .context("Worktree path must be UTF-8")? - ); + // Git lists paths with `/` on Windows too. + record + .path + .to_str() + .context("Worktree path must be UTF-8")?; + let expected = format!("worktree {}", crate::paths::slash(&record.path)); let registration = registered .split("\0\0") .find(|block| block.split('\0').any(|field| field == expected)) @@ -594,8 +593,8 @@ pub async fn remove( records.join(format!("{}.json", record.id)), archive.join(format!("{}.json", record.id)), )?; - fs::File::open(&records)?.sync_all()?; - fs::File::open(&archive)?.sync_all()?; + paths::sync_directory(&records)?; + paths::sync_directory(&archive)?; forget_index(index); Ok(record) } @@ -657,7 +656,7 @@ async fn dispose_checkout( /// taken before the checkout is removed. fn index_root(paths: &AppPaths, id: &str) -> Option { let path = task_record(paths, id).ok()?.path; - Some(path.canonicalize().unwrap_or(path)) + Some(path.canonical().unwrap_or(path)) } /// A removed checkout's code index (kept in the profile's cache) goes with /// it; otherwise every task, lane and run would leave one behind. @@ -699,7 +698,7 @@ pub async fn recovery( ) .await?, ) - .canonicalize()?; + .canonical()?; ensure!( common == record.common_directory, "Source repository identity changed" @@ -710,13 +709,12 @@ pub async fn recovery( cancel.clone(), ) .await?; - let expected = format!( - "worktree {}", - record - .path - .to_str() - .context("Worktree path must be UTF-8")? - ); + // Git lists paths with `/` on Windows too. + record + .path + .to_str() + .context("Worktree path must be UTF-8")?; + let expected = format!("worktree {}", crate::paths::slash(&record.path)); let block = registered .split("\0\0") .find(|block| block.split('\0').any(|field| field == expected)) @@ -791,7 +789,7 @@ pub async fn review_return( let inspected = inspect(paths, source, id, cancel.clone()).await?; let source_top = git(source, &["rev-parse", "--show-toplevel"], cancel.clone()).await?; ensure!( - Path::new(&source_top).canonicalize()? == Workspace::open(source)?.path, + Path::new(&source_top).canonical()? == Workspace::open(source)?.path, "Source checkout repository root changed" ); ensure!( diff --git a/native/core/src/worktrees/changes.rs b/native/core/src/worktrees/changes.rs index c79cec15..67d879f4 100644 --- a/native/core/src/worktrees/changes.rs +++ b/native/core/src/worktrees/changes.rs @@ -1,5 +1,6 @@ //! Copy a reviewed dirty snapshot without stashing or resetting the source. use super::{git, AppPaths, Record, Workspace}; +use crate::paths::Canonical; use anyhow::{ensure, Context, Result}; use serde::Serialize; use sha2::{Digest, Sha256}; @@ -33,7 +34,7 @@ async fn capture(source: &Path, cancel: CancellationToken) -> Result { let workspace = Workspace::open(source)?; let top = git(source, &["rev-parse", "--show-toplevel"], cancel.clone()).await?; ensure!( - Path::new(&top).canonicalize()? == workspace.path, + Path::new(&top).canonical()? == workspace.path, "Open the repository root before copying changes" ); for marker in [ diff --git a/native/core/src/worktrees/cleanup.rs b/native/core/src/worktrees/cleanup.rs index 0baae669..2456f3e9 100644 --- a/native/core/src/worktrees/cleanup.rs +++ b/native/core/src/worktrees/cleanup.rs @@ -1,18 +1,28 @@ //! Retry authority for disposable checkout removal. Git can remove its admin //! registration even when deleting the checkout fails. A pre-removal journal //! preserves that registration; missing registration alone is never authority. +//! +//! Windows: identities are the volume serial and file index (cap-std reads +//! them from an open handle), folders are opened with backup semantics and +//! never through a link, the exclusive rename is `SetFileInformationByHandle` +//! relative to the held destination folder, and there are no modes or +//! folder flushes. Git writes its own paths with `/` there. use super::{git, Record}; +use crate::paths::Canonical; use crate::{paths, paths::AppPaths}; use anyhow::{ensure, Context, Result}; use base64::{engine::general_purpose::STANDARD, Engine as _}; -use cap_std::fs::{Dir, MetadataExt as _, OpenOptionsExt as _}; +#[cfg(unix)] +use cap_std::fs::MetadataExt as _; +use cap_std::fs::{Dir, OpenOptionsExt as _}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +#[cfg(unix)] +use std::os::unix::{ffi::OsStrExt, fs::OpenOptionsExt as _}; use std::{ collections::BTreeMap, fs, io::{Read, Write}, - os::unix::{ffi::OsStrExt, fs::OpenOptionsExt as _}, path::{Path, PathBuf}, time::{Duration, Instant}, }; @@ -28,24 +38,89 @@ struct Identity { dev: u64, ino: u64, } +#[cfg(windows)] +const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000; +#[cfg(windows)] +const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + fn identity(meta: &cap_std::fs::Metadata) -> Identity { Identity { - dev: meta.dev(), - ino: meta.ino(), + dev: cap_fs_ext::MetadataExt::dev(meta), + ino: cap_fs_ext::MetadataExt::ino(meta), } } +/// A path as Git writes it in its own files (`/` on Windows too). +fn git_path(path: &Path) -> String { + paths::slash(path) +} +/// Make entries added or renamed in `dir` durable (Windows: nothing to do). +fn sync_dir(dir: Dir) -> Result<()> { + #[cfg(unix)] + dir.into_std_file().sync_all()?; + #[cfg(not(unix))] + drop(dir); + Ok(()) +} fn directory(path: &Path) -> Result { - let file = fs::OpenOptions::new() - .read(true) - .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_NONBLOCK) + let mut options = fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + options.custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_NONBLOCK); + #[cfg(windows)] + std::os::windows::fs::OpenOptionsExt::custom_flags( + &mut options, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, + ); + let file = options .open(path) .with_context(|| format!("Cleanup requires a real directory: {}", path.display()))?; - ensure!( - path.canonicalize()? == path, - "Cleanup directory path changed" - ); + // The reparse-point flag opened a link or junction itself: refuse it. + #[cfg(windows)] + { + let meta = file.metadata()?; + ensure!( + meta.is_dir() && !meta.file_type().is_symlink(), + "Cleanup requires a real directory: {}", + path.display() + ); + } + ensure!(path.canonical()? == path, "Cleanup directory path changed"); Ok(Dir::from_std_file(file)) } +/// Open the folder `name` in `dir`, never through a link. +fn nested_directory(dir: &Dir, name: &std::ffi::OsStr) -> Result { + #[cfg(unix)] + return Ok(Dir::from_std_file( + dir.open_with( + name, + cap_std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_NONBLOCK), + )? + .into_std(), + )); + #[cfg(windows)] + return Ok(cap_fs_ext::DirExt::open_dir_nofollow(dir, name)?); +} +/// Read-only options that never follow a final link. +fn file_options() -> cap_std::fs::OpenOptions { + let mut options = cap_std::fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK); + #[cfg(windows)] + cap_fs_ext::OpenOptionsFollowExt::follow(&mut options, cap_fs_ext::FollowSymlinks::No); + options +} +/// The bytes of a name or link target, for hashing. +fn os_bytes(text: &std::ffi::OsStr) -> Vec { + #[cfg(unix)] + return text.as_bytes().to_vec(); + #[cfg(windows)] + return std::os::windows::ffi::OsStrExt::encode_wide(text) + .flat_map(u16::to_le_bytes) + .collect(); +} #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] struct Entry { identity: Identity, @@ -94,7 +169,7 @@ fn snapshot(path: &Path, admin: bool, cancel: CancellationToken) -> Result end: Instant::now() + Duration::from_secs(30), cancel, }; - #[cfg(test)] + #[cfg(all(test, unix))] tests::pause_scan(path, &budget.cancel)?; walk(&dir, Path::new(""), admin, &mut tree.entries, &mut budget)?; ensure!( @@ -125,10 +200,15 @@ fn walk( "Cleanup exceeds 131,072 entries or 32 MiB of paths; preserve this checkout and review large build outputs before retrying" ); let meta = dir.symlink_metadata(&name)?; + #[cfg(unix)] + let mode = meta.mode() & 0o7777; + // Windows files have no mode bits. + #[cfg(windows)] + let mode = 0; let mut item = Entry { identity: identity(&meta), kind: String::new(), - mode: meta.mode() & 0o7777, + mode, bytes: if meta.is_file() { meta.len() } else { 0 }, hash: String::new(), data: None, @@ -137,15 +217,7 @@ fn walk( item.kind = "directory".into(); // Directory permission repair is allowed, but inode replacement is not. item.mode = 0; - let nested = Dir::from_std_file( - dir.open_with( - &name, - cap_std::fs::OpenOptions::new() - .read(true) - .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_NONBLOCK), - )? - .into_std(), - ); + let nested = nested_directory(dir, &name)?; ensure!( identity(&nested.dir_metadata()?) == item.identity, "Cleanup entry changed during inspection" @@ -153,19 +225,14 @@ fn walk( walk(&nested, &path, admin, out, budget)?; } else if meta.is_file() { item.kind = "file".into(); - let mut file = dir.open_with( - &name, - cap_std::fs::OpenOptions::new() - .read(true) - .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK), - )?; + let mut file = dir.open_with(&name, &file_options())?; let before = file.metadata()?; ensure!(before.len() <= budget.bytes, "Cleanup exceeds its byte budget (8 GiB checkout, 16 MiB Git metadata); preserve this checkout and review large build outputs before retrying"); ensure!( before.is_file() && identity(&before) == item.identity - && (!admin || before.nlink() == 1), + && (!admin || cap_fs_ext::MetadataExt::nlink(&before) == 1), "Cleanup file identity changed or Git metadata has extra hard links" ); let mut hash = Sha256::new(); @@ -189,10 +256,12 @@ fn walk( } let after = file.metadata()?; ensure!( - before.len() == after.len() - && before.modified()? == after.modified()? - && before.ctime() == after.ctime() - && before.ctime_nsec() == after.ctime_nsec(), + before.len() == after.len() && before.modified()? == after.modified()?, + "Cleanup file changed during inspection" + ); + #[cfg(unix)] + ensure!( + before.ctime() == after.ctime() && before.ctime_nsec() == after.ctime_nsec(), "Cleanup file changed during inspection" ); item.hash = format!("{:x}", hash.finalize()); @@ -203,7 +272,7 @@ fn walk( item.kind = "symlink".into(); item.hash = format!( "{:x}", - Sha256::digest(dir.read_link_contents(&name)?.as_os_str().as_bytes()) + Sha256::digest(os_bytes(dir.read_link_contents(&name)?.as_os_str())) ); } else { anyhow::bail!( @@ -263,11 +332,13 @@ fn save(path: &Path, intent: &Intent) -> Result<()> { paths::atomic_write(path, &bytes, true) } fn read(path: &Path) -> Result> { - let file = match fs::OpenOptions::new() - .read(true) - .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) - .open(path) - { + let mut options = fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK); + #[cfg(windows)] + std::os::windows::fs::OpenOptionsExt::custom_flags(&mut options, FILE_FLAG_OPEN_REPARSE_POINT); + let file = match options.open(path) { Ok(file) => file, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(e) => return Err(e.into()), @@ -301,7 +372,7 @@ impl Session { ) .await?; ensure!( - Path::new(&common).canonicalize()? == record.common_directory, + Path::new(&common).canonical()? == record.common_directory, "Source repository identity changed" ); lock.validate(&record.source, &record.common_directory)?; @@ -404,7 +475,7 @@ impl Session { if self.intent.is_some() { return Ok(()); } - let pointer = format!("gitdir: {}\n", self.admin_path().display()); + let pointer = format!("gitdir: {}\n", git_path(&self.admin_path())); let record = self.record.clone(); let admin = self.admin_path(); let snapshot_cancel = cancel.clone(); @@ -429,7 +500,7 @@ impl Session { ("HEAD", format!("ref: refs/heads/{}\n", self.record.branch)), ( "gitdir", - format!("{}\n", self.record.path.join(".git").display()), + format!("{}\n", git_path(&self.record.path.join(".git"))), ), ] { ensure!( @@ -448,13 +519,14 @@ impl Session { ); let container = self.container(); paths::private_directory(container.parent().unwrap())?; + #[cfg_attr(not(unix), allow(unused_mut))] let mut builder = fs::DirBuilder::new(); - use std::os::unix::fs::DirBuilderExt; + #[cfg(unix)] + std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700); builder - .mode(0o700) .create(&container) .context("Cleanup quarantine already exists; preserve it for review")?; - fs::File::open(container.parent().unwrap())?.sync_all()?; + paths::sync_directory(container.parent().unwrap())?; let intent = Intent { version: 1, id: self.record.id.clone(), @@ -525,8 +597,8 @@ impl Session { &container, std::ffi::OsStr::new("checkout"), )?; - origin_parent.into_std_file().sync_all()?; - container.into_std_file().sync_all()?; + sync_dir(origin_parent)?; + sync_dir(container)?; let moved = directory(&intent.quarantine).and_then(|d| Ok(identity(&d.dir_metadata()?))); if moved.as_ref().ok() != Some(&intent.lane.root) { @@ -560,7 +632,7 @@ impl Session { } original_replacement(&intent)?; let admin_path = self.admin_path(); - let planned_pointer = format!("{}\n", intent.quarantine.join(".git").display()); + let planned_pointer = format!("{}\n", git_path(&intent.quarantine.join(".git"))); if exists(&admin_path)? { ensure!( identity(&directory(admin_path.parent().unwrap())?.dir_metadata()?) @@ -595,7 +667,7 @@ impl Session { let parent = admin_path.parent().unwrap(); if !exists(parent)? { fs::create_dir(parent)?; - fs::File::open(&intent.common)?.sync_all()?; + paths::sync_directory(&intent.common)?; } let pinned_parent = directory(parent)?; let parent_identity = identity(&pinned_parent.dir_metadata()?); @@ -639,16 +711,14 @@ impl Session { identity(&lane.dir_metadata()?) == intent.lane.root, "Cleanup checkout was replaced" ); - let mut file = lane.open_with( - ".git", - cap_std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .mode(0o600), - )?; + let mut options = cap_std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + options.mode(0o600); + let mut file = lane.open_with(".git", &options)?; file.write_all(intent.pointer.as_bytes())?; file.sync_all()?; - lane.into_std_file().sync_all()?; + sync_dir(lane)?; let current = snapshot(&intent.quarantine, false, cancel.clone())?; intent.lane.entries.insert( PathBuf::from(".git"), @@ -723,8 +793,8 @@ impl Session { .join("archive") .join(format!("{}.cleanup", self.record.id)), )?; - fs::File::open(self.journal.parent().unwrap())?.sync_all()?; - fs::File::open(records.join("archive"))?.sync_all()?; + paths::sync_directory(self.journal.parent().unwrap())?; + paths::sync_directory(&records.join("archive"))?; } Ok(()) } @@ -735,8 +805,6 @@ fn restore_admin( expected_parent: &Identity, expected_common: &Identity, ) -> Result<()> { - use std::os::fd::AsRawFd; - use std::os::unix::fs::PermissionsExt; ensure!( saved.entries.len() <= ENTRIES, "Cleanup backup exceeds entry budget" @@ -756,9 +824,16 @@ fn restore_admin( identity(&target_parent.dir_metadata()?) == *expected_parent, "Cleanup admin parent was replaced before restoration" ); + // Unix stages inside the held folder itself; Windows by its path, and the + // exclusive rename below finds the staged folder only through the held + // handle, so a replaced path cannot redirect it. + #[cfg(unix)] + let held = crate::paths::held_directory(std::os::fd::AsRawFd::as_raw_fd(&source_parent))?; + #[cfg(windows)] + let held = common.to_path_buf(); let staged = tempfile::Builder::new() .prefix(".shadowcode-cleanup-") - .tempdir_in(crate::paths::held_directory(source_parent.as_raw_fd())?)?; + .tempdir_in(held)?; for (relative, entry) in &saved.entries { ensure!( relative @@ -786,13 +861,16 @@ fn restore_admin( format!("{:x}", Sha256::digest(&bytes)) == entry.hash, "Cleanup backup hash differs" ); - let mut file = fs::OpenOptions::new() - .write(true) - .create_new(true) - .mode(entry.mode) - .open(&target)?; + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + options.mode(entry.mode); + let mut file = options.open(&target)?; file.write_all(&bytes)?; - file.set_permissions(fs::Permissions::from_mode(entry.mode))?; + #[cfg(unix)] + file.set_permissions( + ::from_mode(entry.mode), + )?; file.sync_all()?; } _ => anyhow::bail!("Unsafe entry in Git administration backup"), @@ -801,20 +879,21 @@ fn restore_admin( // Flush child directories before publishing the complete backup atomically. for (relative, entry) in saved.entries.iter().rev() { if entry.kind == "directory" { - fs::File::open(staged.path().join(relative))?.sync_all()?; + paths::sync_directory(&staged.path().join(relative))?; } } - fs::File::open(staged.path())?.sync_all()?; + paths::sync_directory(staged.path())?; rename_noreplace( &source_parent, staged.path().file_name().unwrap(), &target_parent, path.file_name().unwrap(), )?; - target_parent.into_std_file().sync_all()?; + sync_dir(target_parent)?; Ok(()) } +#[cfg(unix)] fn rename_noreplace( from: &Dir, name: &std::ffi::OsStr, @@ -852,6 +931,96 @@ fn rename_noreplace( Ok(()) } +/// Move `name` in `from` to `destination` in `to`, failing if `destination` +/// exists. The source is opened through the held `from` handle; Windows +/// renames to a full path (a relative root is refused), taken from the held +/// `to` handle just before the call. Callers check what arrived. +#[cfg(windows)] +fn rename_noreplace( + from: &Dir, + name: &std::ffi::OsStr, + to: &Dir, + destination: &std::ffi::OsStr, +) -> Result<()> { + use std::os::windows::{ffi::OsStrExt, io::AsRawHandle}; + use windows_sys::Win32::{ + Foundation::HANDLE, + Storage::FileSystem::{ + FileRenameInfo, GetFinalPathNameByHandleW, SetFileInformationByHandle, FILE_RENAME_INFO, + }, + }; + const DELETE: u32 = 0x0001_0000; + const SYNCHRONIZE: u32 = 0x0010_0000; + let leaf: Vec = destination.encode_wide().collect(); + ensure!( + !leaf.is_empty() + && !leaf.contains(&0) + && !leaf.contains(&(b'\\' as u16)) + && !leaf.contains(&(b'/' as u16)), + "Exclusive cleanup rename needs a plain name" + ); + // The destination folder's own path (`\\?\C:\...`), from its handle. + let mut folder = vec![0u16; 1024]; + // SAFETY: the buffer holds `folder.len()` UTF-16 units. + let length = unsafe { + GetFinalPathNameByHandleW( + to.as_raw_handle() as HANDLE, + folder.as_mut_ptr(), + folder.len() as u32, + 0, + ) + } as usize; + ensure!( + length > 0 && length < folder.len(), + "Exclusive cleanup rename could not name its destination: {}", + std::io::Error::last_os_error() + ); + folder.truncate(length); + let mut target = folder; + if target.last() != Some(&(b'\\' as u16)) { + target.push(b'\\' as u16); + } + target.extend_from_slice(&leaf); + // The entry itself (never a link's target), opened for renaming. + let mut options = cap_std::fs::OpenOptions::new(); + options + .access_mode(DELETE | SYNCHRONIZE) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS); + cap_fs_ext::OpenOptionsFollowExt::follow(&mut options, cap_fs_ext::FollowSymlinks::No); + let entry = from + .open_with(name, &options) + .context("Exclusive cleanup rename could not open its source")?; + // FILE_RENAME_INFO ends in the name; a u64 buffer keeps it aligned. + let header = std::mem::offset_of!(FILE_RENAME_INFO, FileName); + let size = (header + (target.len() + 1) * 2).max(std::mem::size_of::()); + let mut buffer = vec![0u64; size.div_ceil(8)]; + // SAFETY: the buffer is zeroed, aligned and large enough for the header, + // the name and a terminating zero; the handle stays open for the call. + let done = unsafe { + let info = buffer.as_mut_ptr().cast::(); + (*info).Anonymous.ReplaceIfExists = false; + (*info).RootDirectory = std::ptr::null_mut(); + (*info).FileNameLength = (target.len() * 2) as u32; + std::ptr::copy_nonoverlapping( + target.as_ptr(), + std::ptr::addr_of_mut!((*info).FileName).cast::(), + target.len(), + ); + SetFileInformationByHandle( + entry.as_raw_handle() as HANDLE, + FileRenameInfo, + buffer.as_ptr().cast(), + size as u32, + ) + }; + ensure!( + done != 0, + "Exclusive cleanup rename refused: {}", + std::io::Error::last_os_error() + ); + Ok(()) +} + pub(super) async fn dispose( paths: &AppPaths, source: &Path, @@ -961,8 +1130,8 @@ pub(super) async fn dispose( records.join(format!("{id}.json")), archive.join(format!("{id}.json")), )?; - fs::File::open(&records)?.sync_all()?; - fs::File::open(&archive)?.sync_all()?; + paths::sync_directory(&records)?; + paths::sync_directory(&archive)?; session.archive(&records)?; Ok(exists(&record.path)?.then(|| { format!( @@ -983,13 +1152,9 @@ fn rewrite_connection(admin_path: &Path, expected: &Tree, bytes: &[u8]) -> Resul .entries .get(Path::new("gitdir")) .context("Missing saved Git connection")?; - let mut file = admin.open_with( - "gitdir", - cap_std::fs::OpenOptions::new() - .read(true) - .write(true) - .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK), - )?; + let mut options = file_options(); + options.write(true); + let mut file = admin.open_with("gitdir", &options)?; ensure!( identity(&file.metadata()?) == entry.identity, "Git connection was replaced before retargeting" @@ -1049,11 +1214,15 @@ async fn branch_tip( } fn original_replacement(intent: &Intent) -> Result { - use std::os::unix::fs::MetadataExt; match fs::symlink_metadata(&intent.path) { - Ok(meta) => { + Ok(_) => { + // Read the identity through a handle (Windows has it only there); + // whatever is there now, link or not, is compared, never followed. + let parent = directory(intent.path.parent().context("Missing cleanup parent")?)?; + let name = intent.path.file_name().context("Missing cleanup name")?; + let meta = parent.symlink_metadata(name)?; ensure!( - meta.dev() != intent.lane.root.dev || meta.ino() != intent.lane.root.ino, + identity(&meta) != intent.lane.root, "Owned checkout returned to its original path; preserve for review" ); Ok(true) @@ -1065,7 +1234,8 @@ fn original_replacement(intent: &Intent) -> Result { } } -#[cfg(test)] +// The scenarios below use Unix modes, symlinks and descriptors. +#[cfg(all(test, unix))] mod tests { use super::*; use std::os::unix::fs::PermissionsExt; diff --git a/native/core/src/worktrees/locks.rs b/native/core/src/worktrees/locks.rs index e0c6237a..730613d9 100644 --- a/native/core/src/worktrees/locks.rs +++ b/native/core/src/worktrees/locks.rs @@ -2,6 +2,7 @@ //! repository local/advisory lock, then short profile admission. Never acquire //! repository ownership while holding profile admission, and never unlink locks. use super::{git, AppPaths, Record, Workspace}; +use crate::paths::Canonical; use anyhow::{ensure, Context, Result}; use fs2::FileExt; use std::{ @@ -54,8 +55,17 @@ impl Advisory { .mode(0o600) .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK); } + // Windows opens a folder only with backup semantics; the reparse-point + // flag opens a link itself rather than its target, like O_NOFOLLOW. + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + parent.custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT); + } ensure!( - directory.canonicalize()? == directory, + directory.canonical()? == directory, "Worktree lock directory changed" ); let parent = parent.open(directory)?; @@ -143,7 +153,7 @@ async fn common(source: &Path, cancel: &CancellationToken) -> Result { cancel.clone(), ) .await?; - Ok(Path::new(&common).canonicalize()?) + Ok(Path::new(&common).canonical()?) } pub(super) async fn mutation(source: &Path, cancel: &CancellationToken) -> Result { let source = Workspace::open(source)?.path; @@ -188,7 +198,7 @@ pub(super) async fn reserve_record( expected == records, "Worktree admission belongs to another profile" ); - let directory = records.canonicalize()?; + let directory = records.canonical()?; let lock = keyed(Kind::Admission, &directory)?; let _local = tokio::select! {biased; _=cancel.cancelled()=>anyhow::bail!("Worktree admission cancelled"), guard=lock.lock_owned()=>guard}; ensure!(!cancel.is_cancelled(), "Worktree admission cancelled"); @@ -198,7 +208,7 @@ pub(super) async fn reserve_record( "Another process owns worktree admission; retry later", )?; ensure!( - records.canonicalize()? == directory, + records.canonical()? == directory, "Worktree inventory changed during admission" ); let mut count = 0; @@ -231,7 +241,7 @@ mod tests { } impl Canonical { fn new(dir: tempfile::TempDir) -> Self { - let path = dir.path().canonicalize().unwrap(); + let path = crate::paths::Canonical::canonical(dir.path()).unwrap(); Self { _dir: dir, path } } fn path(&self) -> &std::path::Path { diff --git a/native/core/src/worktrees/repair.rs b/native/core/src/worktrees/repair.rs index e656bdd6..fca3e573 100644 --- a/native/core/src/worktrees/repair.rs +++ b/native/core/src/worktrees/repair.rs @@ -1,6 +1,7 @@ //! Restore only missing managed Git connection files; never rebuild an index. use super::{git, read_record, roots, save, AppPaths, Record}; use crate::paths; +use crate::paths::Canonical; use anyhow::{ensure, Context, Result}; use serde::Serialize; use sha2::{Digest, Sha256}; @@ -24,7 +25,7 @@ pub struct Review { fn directory(path: &Path) -> Result<()> { let metadata = fs::symlink_metadata(path)?; ensure!( - metadata.is_dir() && !metadata.file_type().is_symlink() && path.canonicalize()? == path, + metadata.is_dir() && !metadata.file_type().is_symlink() && path.canonical()? == path, "Repair requires the original real directory at {}", path.display() ); @@ -71,7 +72,7 @@ pub async fn review( ) .await?; ensure!( - Path::new(&common).canonicalize()? == record.common_directory, + Path::new(&common).canonical()? == record.common_directory, "Source repository identity changed" ); let administrative_directory = record.common_directory.join("worktrees").join(id); @@ -114,8 +115,9 @@ pub async fn review( .await?; let checkout_pointer = text(&record.path.join(".git"))?; let registration_pointer = text(&administrative_directory.join("gitdir"))?; - let expected_checkout = format!("gitdir: {}", administrative_directory.display()); - let expected_registration = record.path.join(".git").to_string_lossy().into_owned(); + // Git writes these with `/` on Windows too. + let expected_checkout = format!("gitdir: {}", crate::paths::slash(&administrative_directory)); + let expected_registration = crate::paths::slash(&record.path.join(".git")); ensure!( checkout_pointer .as_deref() @@ -167,12 +169,15 @@ pub async fn apply( for (path, value, missing) in [ ( record.path.join(".git"), - format!("gitdir: {}\n", reviewed.administrative_directory.display()), + format!( + "gitdir: {}\n", + crate::paths::slash(&reviewed.administrative_directory) + ), reviewed.checkout_pointer.is_none(), ), ( reviewed.administrative_directory.join("gitdir"), - format!("{}\n", record.path.join(".git").display()), + format!("{}\n", crate::paths::slash(&record.path.join(".git"))), reviewed.registration_pointer.is_none(), ), ] { @@ -186,7 +191,7 @@ pub async fn apply( temporary .persist_noclobber(&path) .map_err(|error| error.error)?; - fs::File::open(path.parent().unwrap())?.sync_all()?; + crate::paths::sync_directory(path.parent().unwrap())?; } } let top = git( @@ -196,7 +201,7 @@ pub async fn apply( ) .await?; ensure!( - Path::new(&top).canonicalize()? == record.path, + Path::new(&top).canonical()? == record.path, "Repaired checkout root differs" ); let common = git( @@ -206,7 +211,7 @@ pub async fn apply( ) .await?; ensure!( - Path::new(&common).canonicalize()? == record.common_directory, + Path::new(&common).canonical()? == record.common_directory, "Repaired repository identity differs" ); Ok(()) diff --git a/native/core/tests/acp_server.rs b/native/core/tests/acp_server.rs index c2b0f6d6..58336624 100644 --- a/native/core/tests/acp_server.rs +++ b/native/core/tests/acp_server.rs @@ -1,6 +1,7 @@ //! `shadowcode acp` driven by an in-process fake ACP client (the editor //! side) over a duplex pipe, against a fake OpenAI-compatible model. #![cfg(unix)] +use shadowcode_core::paths::Canonical; mod support; use serde_json::{json, Value}; use shadowcode_core::{ @@ -100,8 +101,8 @@ impl Fixture { fs::create_dir(&project).unwrap(); fs::create_dir(&untrusted).unwrap(); fs::write(project.join("notes.txt"), "saved text").unwrap(); - let project = project.canonicalize().unwrap(); - let untrusted = untrusted.canonicalize().unwrap(); + let project = project.canonical().unwrap(); + let untrusted = untrusted.canonical().unwrap(); let paths = AppPaths::isolated(&root.path().join("profile")).unwrap(); Config::patch( &paths, diff --git a/native/core/tests/antigravity_acp.rs b/native/core/tests/antigravity_acp.rs index ccace306..5e7de3c1 100644 --- a/native/core/tests/antigravity_acp.rs +++ b/native/core/tests/antigravity_acp.rs @@ -3,6 +3,8 @@ //! stderr and waits in `authenticate`; with one it answers `session/new` with //! a `model` config option. It records the launch environment. Nothing here //! starts the real server. +// The vendor runner and its fake CLIs (shell scripts) are Unix-only so far. +#![cfg(unix)] use serde_json::Value; use shadowcode_core::{ approvals::ApprovalHub, diff --git a/native/core/tests/approvals_always.rs b/native/core/tests/approvals_always.rs index 6bd1ac69..a90cbcb0 100644 --- a/native/core/tests/approvals_always.rs +++ b/native/core/tests/approvals_always.rs @@ -1,6 +1,7 @@ //! Approval cards explain each action, and "Always allow in this project" //! covers exactly one low-risk command from then on. use serde_json::json; +use shadowcode_core::paths::Canonical; use shadowcode_core::{ approvals::{always, Answer, ApprovalHub}, config::{Config, PermissionMode}, @@ -31,7 +32,7 @@ fn fixture() -> Fixture { let project = root.path().join("project"); fs::create_dir(&project).unwrap(); fs::write(project.join("Makefile"), "test:\n\t@echo checks passed\n").unwrap(); - let project = project.canonicalize().unwrap(); + let project = project.canonical().unwrap(); let store = Arc::new(Store::open(&root.path().join("db")).unwrap()); let session = store.create_session(&project, "mock", "").unwrap()["id"] .as_str() @@ -174,7 +175,7 @@ async fn a_subagent_in_its_own_worktree_uses_the_projects_rules() { let worktree = f._root.path().join("worktree"); fs::create_dir(&worktree).unwrap(); fs::copy(f.project.join("Makefile"), worktree.join("Makefile")).unwrap(); - let worktree = worktree.canonicalize().unwrap(); + let worktree = worktree.canonical().unwrap(); let child = f.store.create_session(&worktree, "mock", "").unwrap()["id"] .as_str() .unwrap() diff --git a/native/core/tests/approvals_checkpoints.rs b/native/core/tests/approvals_checkpoints.rs index 0df2ba47..985fca71 100644 --- a/native/core/tests/approvals_checkpoints.rs +++ b/native/core/tests/approvals_checkpoints.rs @@ -210,6 +210,7 @@ fn rewind_handles_repeated_writes_crash_before_write_creates_deletes_and_mode() let store = Store::open(&root.path().join("db")).unwrap(); checkpoint::restore(&store, &ws, "task").unwrap(); assert_eq!(ws.read("changed").unwrap().content, "original"); + #[cfg(unix)] assert_eq!(ws.snapshot("changed").unwrap().mode, Some(0o755)); assert!(ws.snapshot("created").unwrap().bytes.is_none()); assert_eq!(ws.read("deleted").unwrap().content, "gone"); diff --git a/native/core/tests/automations.rs b/native/core/tests/automations.rs index a720759e..d5142511 100644 --- a/native/core/tests/automations.rs +++ b/native/core/tests/automations.rs @@ -3,6 +3,7 @@ //! runs, catch-up and missed times, fresh worktrees that are removed when //! nothing changed, approval requests stopping unattended runs, and //! recovery of runs interrupted by a restart. +use shadowcode_core::paths::Canonical; mod support; use serde_json::{json, Value}; use shadowcode_core::{ @@ -63,7 +64,7 @@ fn setup(endpoint: &str, approve_shell: bool) -> Fixture { fs::write(project.join("README.md"), "# Demo\n").unwrap(); git(&project, &["add", "."]); git(&project, &["commit", "-m", "Initial commit"]); - let project = project.canonicalize().unwrap(); + let project = project.canonical().unwrap(); let paths = AppPaths::isolated(&root.path().join("profile")).unwrap(); Config::patch(&paths, json!({"model":{"provider":"local","endpoint":endpoint,"name":"fixture","context_limit":16384},"trusted_workspaces":[project],"permissions":{"approve_shell":approve_shell},"agent":{"max_steps":8,"retry_attempts":0}})).unwrap(); let service = Service::open(paths.clone(), Some(project.clone())).unwrap(); diff --git a/native/core/tests/background_processes.rs b/native/core/tests/background_processes.rs index 55973ae0..f0e9fbf3 100644 --- a/native/core/tests/background_processes.rs +++ b/native/core/tests/background_processes.rs @@ -1,3 +1,4 @@ +#![cfg_attr(not(unix), allow(unused))] mod support; use serde_json::{json, Value}; use shadowcode_core::{ @@ -179,6 +180,7 @@ async fn output_flood_keeps_a_live_bounded_tail_and_stop_preserves_the_cancelled service.engine.shutdown().await.unwrap(); } +#[cfg(unix)] #[tokio::test] async fn completion_failure_graceful_stop_and_forced_child_cleanup_are_truthful() { let (_root, service) = setup(true); diff --git a/native/core/tests/background_tools.rs b/native/core/tests/background_tools.rs index 73f04b8b..34ab950d 100644 --- a/native/core/tests/background_tools.rs +++ b/native/core/tests/background_tools.rs @@ -1,3 +1,4 @@ +#![cfg_attr(not(unix), allow(unused))] mod support; use serde_json::{json, Value}; use shadowcode_core::{ @@ -95,6 +96,7 @@ fn dead(pid: u32) -> bool { !support::running(pid) } +#[cfg(unix)] #[tokio::test] async fn scoped_approvals_gate_start_stop_and_cleanup_with_durable_origin() { let (_root, service) = fixture("http://127.0.0.1:1/v1"); diff --git a/native/core/tests/ci_action.rs b/native/core/tests/ci_action.rs index d220aaf1..011c1f74 100644 --- a/native/core/tests/ci_action.rs +++ b/native/core/tests/ci_action.rs @@ -4,6 +4,7 @@ //! token confined to their own steps, trusted commenters only, no //! `pull_request_target`, least-privilege permissions, no persisted //! checkout credentials). +#![cfg_attr(not(unix), allow(unused))] use serde_yaml_ng::Value; use std::{ fs, @@ -158,6 +159,7 @@ fn the_spending_limit_can_be_raised_and_is_explained() { assert!(readme.contains("2 stopped for approval or at the spending\nlimit")); } +#[cfg(unix)] #[test] fn max_cost_outside_what_shadowcode_accepts_is_refused_up_front() { // Otherwise the run step fails later on `spending.daily_usd`, a setting @@ -206,6 +208,7 @@ fn max_cost_outside_what_shadowcode_accepts_is_refused_up_front() { } } +#[cfg(unix)] #[test] fn each_use_of_the_action_starts_with_its_own_profile() { // Two uses in one job share $RUNNER_TEMP. The second must not inherit the @@ -276,6 +279,7 @@ fn each_use_of_the_action_starts_with_its_own_profile() { assert!(Path::new(&first_result).exists()); } +#[cfg(unix)] #[test] fn a_spending_stop_comment_names_the_limit_that_was_reached() { if shadowcode_core::sandbox::which("jq").is_none() { diff --git a/native/core/tests/claude_permissions.rs b/native/core/tests/claude_permissions.rs index 88875f21..36a3d6e0 100644 --- a/native/core/tests/claude_permissions.rs +++ b/native/core/tests/claude_permissions.rs @@ -1,4 +1,6 @@ //! Claude's documented stdio permission contract, without a vendor account. +// Helpers used only by Unix-only tests are unused on Windows. +#![cfg_attr(not(unix), allow(unused))] use serde_json::{json, Value}; use shadowcode_core::cli_agent::{ adapter_for, CliAgentsConfig, LaunchOptions, Update, Vendor, VendorAnswer, diff --git a/native/core/tests/cli_agent.rs b/native/core/tests/cli_agent.rs index bfd0f8c9..d841c695 100644 --- a/native/core/tests/cli_agent.rs +++ b/native/core/tests/cli_agent.rs @@ -1,5 +1,7 @@ //! Vendor CLI backend tests. Protocol cases use hand-written fixtures. //! Spawn tests use a fake binary; they never call real vendor CLIs. +// The vendor runner and its fake CLIs (shell scripts) are Unix-only so far. +#![cfg(unix)] use serde_json::{json, Value}; use shadowcode_core::cli_agent::{ adapter_for, catalog_models, doctor, resolve_vendor, vendor_model, CliAdapter, CliAgentsConfig, diff --git a/native/core/tests/code_intel.rs b/native/core/tests/code_intel.rs index d35e989f..e6627597 100644 --- a/native/core/tests/code_intel.rs +++ b/native/core/tests/code_intel.rs @@ -1,5 +1,7 @@ //! Code intelligence: the LSP pool against a fake language server, new-error //! attachment on edits, and the repo_map / search_code tools. +// The fake language servers are `python3` scripts started directly. +#![cfg(unix)] use serde_json::{json, Value}; use shadowcode_core::{ approvals::ApprovalHub, diff --git a/native/core/tests/compare.rs b/native/core/tests/compare.rs index 3c13842a..73e621d9 100644 --- a/native/core/tests/compare.rs +++ b/native/core/tests/compare.rs @@ -2,6 +2,7 @@ //! Two registered loopback models share one fake OpenAI-compatible server; //! the reply depends on the requested model name, so the lanes make //! different edits. No vendor CLI or network is used. +use shadowcode_core::paths::Canonical; mod support; use anyhow::Result; use serde_json::{json, Value}; @@ -141,7 +142,7 @@ async fn fixture_with_checks(delay: Duration, checks: bool) -> Fixture { fs::write(project.join(".gitignore"), "ignored.log\n").unwrap(); git(&project, &["add", "."]); git(&project, &["commit", "-qm", "Base"]); - let project = project.canonicalize().unwrap(); + let project = project.canonical().unwrap(); let paths = AppPaths::isolated(&root.path().join("profile")).unwrap(); Config::patch( &paths, @@ -1800,7 +1801,7 @@ exec {git} "$@" // host process in this lane before reading its kernel start time, as in // the existing project-inspection cancellation fixtures. fn host_pid(project: &Path, namespace_pid: u32) -> u32 { - let project = project.canonicalize().unwrap(); + let project = project.canonical().unwrap(); let matches: Vec<_> = fs::read_dir("/proc") .unwrap() .filter_map(|entry| { @@ -1938,7 +1939,7 @@ exec {git} "$@" fs::write(project_b.join("lib.txt"), "project B unchanged\n").unwrap(); git(&project_b, &["add", "."]); git(&project_b, &["commit", "-qm", "B base"]); - let project_b = project_b.canonicalize().unwrap(); + let project_b = project_b.canonical().unwrap(); let b_head = git(&project_b, &["rev-parse", "HEAD"]); let b_index = git(&project_b, &["write-tree"]); let mut trusted = Config::load(&f.paths, None).unwrap().trusted_workspaces; @@ -2659,7 +2660,7 @@ async fn failed_generation_preserves_checked_candidate_through_reopen_and_keep() ensure!(completed_id > 0, "invalid output reference"); let completion = service.as_ref().unwrap().engine.store().events_after(&alpha.session_id, completed_id - 1, Some(completed_id), 1)?.pop().context("referenced command completion missing")?; ensure!(completion["id"] == completed_id && completion["task_id"] == alpha.task_id && completion["session_id"] == alpha.session_id && completion["type"] == "tool.completed", "foreign/non-completion output reference"); - ensure!(completion["payload"]["tool"] == "exec" && completion["payload"]["call_id"] == call_id && completion["payload"]["success"] == true && completion["payload"]["output"]["exit_code"] == 0 && completion["payload"]["output"]["stdout"] == "candidate-check-passed\n", "receipt does not name the actual successful check output: {completion}"); + ensure!(completion["payload"]["tool"] == "exec" && completion["payload"]["call_id"] == call_id && completion["payload"]["success"] == true && completion["payload"]["output"]["exit_code"] == 0 && completion["payload"]["output"]["stdout"].as_str().is_some_and(|out| out == "candidate-check-passed\n" || out == "candidate-check-passed\r\n"), "receipt does not name the actual successful check output: {completion}"); let events = service.as_ref().unwrap().engine.store().recent_events(&alpha.session_id, 100)?; let starts: Vec<_> = events.iter().filter(|e| e["task_id"] == alpha.task_id && e["type"] == "tool.started" && e["payload"]["tool"] == "exec" && e["payload"]["call_id"] == call_id).collect(); ensure!(starts.len() == 1 && starts[0]["payload"]["arguments"]["command"] == CHECK, "command identity did not start exact check"); diff --git a/native/core/tests/config_concurrency.rs b/native/core/tests/config_concurrency.rs index 99945e82..0ae14a60 100644 --- a/native/core/tests/config_concurrency.rs +++ b/native/core/tests/config_concurrency.rs @@ -1,4 +1,5 @@ use serde_json::json; +use shadowcode_core::paths::Canonical; use shadowcode_core::{ config::{self, Config}, paths::AppPaths, @@ -123,7 +124,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 project = project.canonical().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/config_keys.rs b/native/core/tests/config_keys.rs index 9c993c69..a6b4234d 100644 --- a/native/core/tests/config_keys.rs +++ b/native/core/tests/config_keys.rs @@ -3,6 +3,7 @@ //! reported with its key and how to fix it; settings from older and newer //! versions are kept. use serde_json::{json, Value}; +use shadowcode_core::paths::Canonical; use shadowcode_core::{ code_intel::CodeIntelConfig, config::Config, paths::AppPaths, subagents::SubagentsConfig, voice::VoiceConfig, @@ -414,7 +415,7 @@ fn settings_see_the_profile_agent_values_and_what_the_project_changes() { "agent:\n max_steps: 200\n", ) .unwrap(); - let project = project.canonicalize().unwrap(); + let project = project.canonical().unwrap(); Config::patch(&paths, json!({"trusted_workspaces": [project]})).unwrap(); let service = Service::open(paths, Some(project)).unwrap(); let runtime = tokio::runtime::Builder::new_current_thread() diff --git a/native/core/tests/control.rs b/native/core/tests/control.rs index 4a0dfe9c..b86b49cf 100644 --- a/native/core/tests/control.rs +++ b/native/core/tests/control.rs @@ -1,3 +1,6 @@ +// The control socket is the Unix transport; the Windows pipe needs its own tests. +#![cfg(unix)] +use shadowcode_core::paths::Canonical; mod support; use serde_json::{json, Value}; use shadowcode_core::{ @@ -273,7 +276,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 other = other.canonical().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(); diff --git a/native/core/tests/control_owned.rs b/native/core/tests/control_owned.rs index 204b0f0a..4b16b9f3 100644 --- a/native/core/tests/control_owned.rs +++ b/native/core/tests/control_owned.rs @@ -1,3 +1,6 @@ +// The control socket is the Unix transport; the Windows pipe needs its own tests. +#![cfg(unix)] +use shadowcode_core::paths::Canonical; mod support; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; @@ -25,7 +28,7 @@ async fn raw_owner(server: &Server, service: &Service) -> UnixStream { let paths = service.engine.paths(); let mut identity = Vec::new(); for root in [&paths.config, &paths.data, &paths.state] { - identity.extend_from_slice(root.canonicalize().unwrap().as_os_str().as_encoded_bytes()); + identity.extend_from_slice(root.canonical().unwrap().as_os_str().as_encoded_bytes()); identity.push(0); } let profile = format!("{:x}", Sha256::digest(identity)); diff --git a/native/core/tests/data_care.rs b/native/core/tests/data_care.rs index 7ee48220..c4d2c736 100644 --- a/native/core/tests/data_care.rs +++ b/native/core/tests/data_care.rs @@ -2,6 +2,7 @@ //! damaged and incomplete backups, and the `shadowcode backup|restore|reset| //! doctor --repair` commands. use serde_json::{json, Value}; +use shadowcode_core::paths::Canonical; use shadowcode_core::{ config::{self, Config}, data::{self, BackupOptions, Manifest, RestoreOptions}, @@ -33,7 +34,7 @@ struct Profile { fn profile() -> Profile { let temp = tempfile::tempdir().unwrap(); - let root = temp.path().canonicalize().unwrap(); + let root = temp.path().canonical().unwrap(); let project = root.join("project"); fs::create_dir_all(&project).unwrap(); let paths = AppPaths::isolated(&root.join("profile")).unwrap(); @@ -428,7 +429,8 @@ async fn an_upgrade_copy_restores_and_is_upgraded_again() { .join("tests/fixtures/upgrade/0.28.0/shadow-agent.sql"), ) .unwrap() - .replace("@ROOT@", &p.root.to_string_lossy()); + // Inside JSON values too: `/` needs no escaping, `\` would. + .replace("@ROOT@", &shadowcode_core::paths::slash(&p.root)); let db = rusqlite::Connection::open(&old).unwrap(); db.pragma_update(None, "synchronous", "OFF").unwrap(); db.execute_batch(&format!("BEGIN;\n{sql}\nCOMMIT;")) @@ -529,7 +531,7 @@ async fn reset_moves_everything_aside_and_keeps_backups_worktrees_and_downloads( async fn a_reset_that_cannot_finish_puts_everything_back() { use std::os::unix::fs::PermissionsExt; let temp = tempfile::tempdir().unwrap(); - let root = temp.path().canonicalize().unwrap(); + let root = temp.path().canonical().unwrap(); // XDG-like: the three folders live in different parents. let paths = AppPaths { config: root.join("xdg-config/shadow-agent"), @@ -1190,7 +1192,7 @@ async fn a_large_agents_folder_never_blocks_a_backup_or_a_restore() { #[test] fn a_reset_keeps_the_lock_and_backups_when_the_xdg_folders_are_one() { let temp = tempfile::tempdir().unwrap(); - let root = temp.path().canonicalize().unwrap(); + let root = temp.path().canonical().unwrap(); // XDG_CONFIG_HOME, XDG_DATA_HOME and XDG_STATE_HOME all name one folder. let one = root.join("xdg/shadow-agent"); let paths = AppPaths { diff --git a/native/core/tests/engine_tasks.rs b/native/core/tests/engine_tasks.rs index c59d8f95..e8c27ff3 100644 --- a/native/core/tests/engine_tasks.rs +++ b/native/core/tests/engine_tasks.rs @@ -1,3 +1,4 @@ +#![cfg_attr(not(unix), allow(unused))] mod support; use serde_json::{json, Value}; use shadowcode_core::{ @@ -2522,14 +2523,14 @@ async fn timings_separate_model_attempts_from_check_processes_and_survive_restar .find(|e| e["type"] == "agent.completed") .unwrap(); let expected = serde_json::to_value(t).unwrap(); - assert_eq!(completed["payload"]["timings"], expected); + same_timings(&completed["payload"]["timings"], &expected); let paths = engine.paths().clone(); engine.shutdown().await.unwrap(); drop(engine); let reopened = Engine::open(paths).unwrap(); - assert_eq!( - serde_json::to_value(reopened.job(&job.id).unwrap().unwrap().timings).unwrap(), - expected + same_timings( + &serde_json::to_value(reopened.job(&job.id).unwrap().unwrap().timings).unwrap(), + &expected, ); reopened.shutdown().await.unwrap(); } @@ -3772,3 +3773,21 @@ async fn a_final_state_that_cannot_be_saved_still_ends_the_task() { assert!(engine.delete_session(&job.session_id).unwrap()); engine.shutdown().await.unwrap(); } + +/// SQLite keeps JSON reals to 15 significant digits, so a stored time may +/// differ from the in-memory one in its last bit. +fn same_timings(stored: &Value, expected: &Value) { + let (stored, expected) = (stored.as_object().unwrap(), expected.as_object().unwrap()); + assert_eq!( + stored.keys().collect::>(), + expected.keys().collect::>() + ); + for (key, value) in expected { + match (value.as_f64(), stored[key].as_f64()) { + (Some(a), Some(b)) if value.is_f64() => { + assert!((a - b).abs() <= a.abs() * 1e-12, "{key}: {a} vs {b}") + } + _ => assert_eq!(&stored[key], value, "{key}"), + } + } +} diff --git a/native/core/tests/feed.rs b/native/core/tests/feed.rs index 4021cb3b..bdedf296 100644 --- a/native/core/tests/feed.rs +++ b/native/core/tests/feed.rs @@ -95,7 +95,8 @@ async fn feed_lists_approvals_jobs_and_the_events_that_change_them() { .unwrap() .iter() .any(|row| row["id"] == job["id"])); - tokio::time::timeout(Duration::from_secs(10), async { + // A refused local connection takes seconds on Windows, not milliseconds. + tokio::time::timeout(Duration::from_secs(30), async { loop { if wakeups.recv().await.unwrap()["type"] == "agent.completed" { break; diff --git a/native/core/tests/fixtures/upgrade/generator/base.rs b/native/core/tests/fixtures/upgrade/generator/base.rs index 51418c2e..f065e756 100644 --- a/native/core/tests/fixtures/upgrade/generator/base.rs +++ b/native/core/tests/fixtures/upgrade/generator/base.rs @@ -12,6 +12,7 @@ //! and its JSON routes, `Config::patch`, `set_secret` and a few `Store` //! calls. Everything that can fail on an older release is recorded as //! skipped instead of aborting. +use shadowcode_core::paths::Canonical; use serde_json::{json, Map, Value}; use shadowcode_core::{ config::{self, Config}, @@ -179,7 +180,7 @@ async fn model_server() -> String { async fn generate_upgrade_fixture() { let out = PathBuf::from(std::env::var("SHADOWCODE_FIXTURE_OUT").expect("SHADOWCODE_FIXTURE_OUT")); let temp = tempfile::tempdir().unwrap(); - let root = temp.path().canonicalize().unwrap(); + let root = temp.path().canonical().unwrap(); let project = root.join("project"); fs::create_dir_all(&project).unwrap(); fs::write(project.join("README.md"), "# Fixture project\n").unwrap(); diff --git a/native/core/tests/flagship_complete.rs b/native/core/tests/flagship_complete.rs index 66ad6907..670fc4bc 100644 --- a/native/core/tests/flagship_complete.rs +++ b/native/core/tests/flagship_complete.rs @@ -133,6 +133,10 @@ fn sandbox_scratch_and_hidden_home_args() { } #[test] +#[cfg_attr( + not(target_os = "linux"), + ignore = "bubblewrap is the Linux sandbox; macOS and Windows report their own" +)] fn sandbox_fallback_is_honest_without_bubblewrap() { // PATH is process-global and tests in this binary share one process: run // the mutation in an isolated child so a concurrent test never observes an diff --git a/native/core/tests/forge.rs b/native/core/tests/forge.rs index 6ea6b940..9f8a55de 100644 --- a/native/core/tests/forge.rs +++ b/native/core/tests/forge.rs @@ -1,6 +1,8 @@ //! The drawer's Git panel end to end: branch, suggested messages, commit, //! push to a local bare remote, and a pull request through a fake `gh` on //! PATH (no network, no real GitHub). +// Helpers used only by Unix-only tests are unused on Windows. +#![cfg_attr(not(unix), allow(unused))] mod support; use serde_json::{json, Value}; use shadowcode_core::{ @@ -57,8 +59,11 @@ fn fake_gh_on_path() { fs::create_dir_all(&dir).unwrap(); let gh = dir.join("gh"); fs::write(&gh, FAKE_GH).unwrap(); - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&gh, fs::Permissions::from_mode(0o755)).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&gh, fs::Permissions::from_mode(0o755)).unwrap(); + } let path = std::env::var("PATH").unwrap_or_default(); std::env::set_var("PATH", format!("{}:{path}", dir.display())); dir @@ -151,6 +156,7 @@ async fn call(service: &Service, method: &str, path: &str, body: Value) -> anyho .await } +#[cfg(unix)] #[tokio::test(flavor = "multi_thread")] async fn branch_commit_push_and_pull_request_with_checks() { let f = setup("http://127.0.0.1:9/v1"); @@ -496,6 +502,7 @@ fn fake_token() -> String { format!("ghp_{}", "aB3dE5fG7hI9jK1lM3nO5pQ7rS9tU1vW3xY5") } +#[cfg(unix)] #[tokio::test(flavor = "multi_thread")] async fn secrets_are_caught_before_commit_and_push_and_hooks_are_asked_once() { let f = setup("http://127.0.0.1:9/v1"); diff --git a/native/core/tests/foundations.rs b/native/core/tests/foundations.rs index 9d3d8579..d58d46a6 100644 --- a/native/core/tests/foundations.rs +++ b/native/core/tests/foundations.rs @@ -1,4 +1,7 @@ +// Helpers used only by Unix-only tests are unused on Windows. +#![cfg_attr(not(unix), allow(unused))] use serde_json::json; +use shadowcode_core::paths::Canonical; use shadowcode_core::{ config::{secret, set_secret, Config, PermissionLevel}, paths::AppPaths, @@ -219,7 +222,7 @@ fn trust_matches_canonical_symlink_and_trailing_slash_aliases() { ) .unwrap(); let config = Config::load(&paths, None).unwrap(); - let canonical = project.canonicalize().unwrap(); + let canonical = project.canonical().unwrap(); assert!(config.is_trusted(&canonical)); assert!(config.is_trusted(&project)); assert!(config.is_trusted(&project.join("."))); diff --git a/native/core/tests/full_undo.rs b/native/core/tests/full_undo.rs index 886a5970..a8a98b69 100644 --- a/native/core/tests/full_undo.rs +++ b/native/core/tests/full_undo.rs @@ -1,5 +1,7 @@ //! Full undo: project checkpoints around native shell commands and vendor CLI //! turns, restored by the existing rewind. +#![cfg_attr(not(unix), allow(unused))] +use shadowcode_core::paths::Canonical; mod vendor_support; use serde_json::{json, Value}; use shadowcode_core::{ @@ -92,7 +94,7 @@ fn fixture(config: Config, prepare: impl FnOnce(&Path)) -> Fixture { .unwrap(); Fixture { _root: root, - project: project.canonicalize().unwrap(), + project: project.canonical().unwrap(), store, task, tools, diff --git a/native/core/tests/hooks.rs b/native/core/tests/hooks.rs index 6580fa25..7356bf81 100644 --- a/native/core/tests/hooks.rs +++ b/native/core/tests/hooks.rs @@ -1,3 +1,4 @@ +#![cfg_attr(not(unix), allow(unused))] mod support; use serde_json::{json, Value}; use shadowcode_core::{ @@ -520,6 +521,7 @@ async fn noisy_completion_checks_leave_room_for_the_model_to_repair() { service.engine.shutdown().await.unwrap(); } +#[cfg(unix)] #[tokio::test] async fn hook_timeout_output_flood_and_cancellation_stop_owned_processes() { let (_root, service) = fixture("http://127.0.0.1:1/v1"); @@ -598,6 +600,7 @@ impl PathCheck { } } +#[cfg(unix)] #[tokio::test] async fn engine_shutdown_cancels_completion_hooks_before_releasing_the_job() { let server = support::server(|_, _| (response("ready", json!([])), Duration::ZERO)).await; diff --git a/native/core/tests/issues.rs b/native/core/tests/issues.rs index 71ecf695..7d3461b1 100644 --- a/native/core/tests/issues.rs +++ b/native/core/tests/issues.rs @@ -1,6 +1,8 @@ //! "Start from an issue" through fake `gh` and `glab` on PATH (no network): //! listing open issues, turning one into a bounded task with a branch name, //! a signed-out CLI, and remotes that are not GitHub or GitLab. +// The fake `gh` and `glab` are shell scripts found on PATH. +#![cfg(unix)] use serde_json::{json, Value}; use shadowcode_core::{ config::Config, @@ -89,11 +91,14 @@ fn fake_tools_on_path() { let dir = std::env::temp_dir().join(format!("shadowcode-fake-issues-{}", std::process::id())); fs::create_dir_all(&dir).unwrap(); - use std::os::unix::fs::PermissionsExt; for (name, script) in [("gh", FAKE_GH), ("glab", FAKE_GLAB)] { let path = dir.join(name); fs::write(&path, script).unwrap(); - fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap(); + } } let path = std::env::var("PATH").unwrap_or_default(); std::env::set_var("PATH", format!("{}:{path}", dir.display())); diff --git a/native/core/tests/live_steering.rs b/native/core/tests/live_steering.rs index ec729ee8..7039d919 100644 --- a/native/core/tests/live_steering.rs +++ b/native/core/tests/live_steering.rs @@ -109,9 +109,20 @@ async fn pause_does_not_replay_shell_and_steering_enters_next_context() { Some("db/v2.sql"), ) .unwrap(); - // Let the in-flight shell finish under pause; resume afterward. - tokio::time::sleep(Duration::from_millis(1200)).await; - assert_eq!(engine.rewind_job(&job.id).unwrap()["ok"], true); + // Let the in-flight shell finish under pause (seconds on a loaded + // runner): rewinding waits for the pause boundary. + let deadline = std::time::Instant::now() + Duration::from_secs(15); + let rewound = loop { + tokio::time::sleep(Duration::from_millis(200)).await; + match engine.rewind_job(&job.id) { + Ok(value) => break value, + Err(error) if std::time::Instant::now() < deadline => { + assert!(error.to_string().contains("pause boundary"), "{error:#}"); + } + Err(error) => panic!("{error:#}"), + } + }; + assert_eq!(rewound["ok"], true); engine.resume_job(&job.id).unwrap(); let completed = tokio::time::timeout(Duration::from_secs(8), engine.wait(&job.id)) .await diff --git a/native/core/tests/local_engine.rs b/native/core/tests/local_engine.rs index 65a4aaea..ea9cf123 100644 --- a/native/core/tests/local_engine.rs +++ b/native/core/tests/local_engine.rs @@ -3,6 +3,9 @@ //! `/props`, and `/v1/chat/completions` (canned tool calls), requires the //! per-launch bearer key, records every launch and request, and exits on //! SIGTERM. Nothing here touches a GPU or a real model. +// The fake llama-server is a script started directly; local models on Windows come with the runtime port. +#![cfg(unix)] +use shadowcode_core::paths::Canonical; mod support; mod vendor_support; use serde_json::{json, Value}; @@ -1491,7 +1494,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.canonicalize().unwrap().display().to_string() + b.canonical().unwrap().display().to_string() ); assert_eq!(provenance["model"]["architecture"], "gemma4"); assert_eq!( @@ -1666,6 +1669,7 @@ fn runtime_probe_does_not_hang_on_a_child_left_behind() { /// LOC-04: another program takes llama-server's port between choosing it and /// the server listening. The load retries on a fresh port on the same device /// instead of calling it a GPU failure and moving the model to the CPU. +#[cfg(unix)] #[tokio::test] async fn an_occupied_port_is_retried_without_a_cpu_fallback() { let f = fixture(GPU); @@ -1837,6 +1841,7 @@ async fn cancel_during_load_early_exit_tail_and_cpu_fallback() { engine.shutdown().await.unwrap(); } +#[cfg(unix)] #[tokio::test] async fn ollama_manifest_import_references_blobs_without_copying() { let f = fixture(GPU); diff --git a/native/core/tests/mcp_http_server.rs b/native/core/tests/mcp_http_server.rs index 002a7326..406182a5 100644 --- a/native/core/tests/mcp_http_server.rs +++ b/native/core/tests/mcp_http_server.rs @@ -1,4 +1,5 @@ #![cfg(unix)] +use shadowcode_core::paths::Canonical; mod support; use reqwest::{header, StatusCode}; use serde_json::{json, Value}; @@ -39,10 +40,7 @@ impl Fixture { 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 (project, other) = (project.canonical().unwrap(), other.canonical().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(); @@ -149,7 +147,7 @@ fn host_pid(project: &std::path::Path, namespace_pid: u32) -> u32 { if !cfg!(target_os = "linux") { return namespace_pid; } - let project = project.canonicalize().unwrap(); + let project = project.canonical().unwrap(); let matches: Vec<_> = fs::read_dir("/proc") .unwrap() .filter_map(|e| { diff --git a/native/core/tests/mcp_native_server.rs b/native/core/tests/mcp_native_server.rs index 49f6cc7a..6f4826c0 100644 --- a/native/core/tests/mcp_native_server.rs +++ b/native/core/tests/mcp_native_server.rs @@ -1,4 +1,5 @@ #![cfg(unix)] +use shadowcode_core::paths::Canonical; mod support; use rmcp::{model::*, service::RunningService, RoleClient, ServiceExt}; use serde_json::{json, Value}; @@ -36,10 +37,7 @@ impl Fixture { 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 (project, other) = (project.canonical().unwrap(), other.canonical().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 05e42c46..a72b0571 100644 --- a/native/core/tests/mcp_stdio.rs +++ b/native/core/tests/mcp_stdio.rs @@ -1,4 +1,5 @@ #![cfg(unix)] +use shadowcode_core::paths::Canonical; mod support; use serde_json::{json, Value}; use shadowcode_core::mcp::{Client, StdioSpec}; @@ -101,7 +102,7 @@ async fn native_mcp_handshake_paginated_catalog_literal_arguments_and_error_resu fixture .root .path() - .canonicalize() + .canonical() .unwrap() .to_string_lossy() .as_ref() diff --git a/native/core/tests/memory.rs b/native/core/tests/memory.rs index 6c687596..2a0b5e0a 100644 --- a/native/core/tests/memory.rs +++ b/native/core/tests/memory.rs @@ -1,3 +1,4 @@ +use shadowcode_core::paths::Canonical; mod support; use serde_json::{json, Value}; use shadowcode_core::{ @@ -7,7 +8,9 @@ use shadowcode_core::{ service::{Request, Service}, store::Store, }; -use std::{fs, os::unix::fs::symlink, sync::Arc, time::Duration}; +#[cfg(unix)] +use std::os::unix::fs::symlink; +use std::{fs, sync::Arc, time::Duration}; fn fixture() -> (tempfile::TempDir, Service, String, String) { let root = tempfile::tempdir().unwrap(); @@ -246,6 +249,7 @@ async fn task_memory_continues_branches_and_exports_without_source_coupling() { service.engine.shutdown().await.unwrap(); } +#[cfg(unix)] #[tokio::test] async fn legacy_notes_reject_symlinks_special_files_and_oversized_content() { let (root, service, _sid, tid) = fixture(); @@ -291,7 +295,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 project = project.canonical().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_routing.rs b/native/core/tests/model_routing.rs index 4139535e..13175254 100644 --- a/native/core/tests/model_routing.rs +++ b/native/core/tests/model_routing.rs @@ -43,7 +43,8 @@ fn request(service: &Service, mode: &str) -> StartRequest { } } async fn wait(service: &Service, id: &str) -> Job { - tokio::time::timeout(Duration::from_secs(8), service.engine.wait(id)) + // A refused local connection takes seconds on Windows, not milliseconds. + tokio::time::timeout(Duration::from_secs(30), service.engine.wait(id)) .await .unwrap() .unwrap() diff --git a/native/core/tests/native_tools.rs b/native/core/tests/native_tools.rs index 94b931f8..7849cba9 100644 --- a/native/core/tests/native_tools.rs +++ b/native/core/tests/native_tools.rs @@ -1,3 +1,4 @@ +#![cfg_attr(not(unix), allow(unused))] use serde_json::{json, Value}; use shadowcode_core::{ approvals::ApprovalHub, @@ -453,6 +454,7 @@ async fn denied_approval_is_not_success_and_does_not_run_the_command() { .is_none()); } +#[cfg(unix)] #[tokio::test] async fn read_file_cannot_reach_secrets_by_spelling_or_symlink() { let (_root, tools) = fixture(Config::default()); diff --git a/native/core/tests/permission_modes.rs b/native/core/tests/permission_modes.rs index 75473028..0d6ddebd 100644 --- a/native/core/tests/permission_modes.rs +++ b/native/core/tests/permission_modes.rs @@ -1,5 +1,7 @@ //! Permission modes, network modes, config migration, durable redaction, //! the engine trust gate (goals included) and checkpoint.restored events. +#![cfg_attr(not(unix), allow(unused))] +use shadowcode_core::paths::Canonical; mod support; use serde_json::{json, Value}; use shadowcode_core::{ @@ -266,6 +268,7 @@ fn permission_mode_matrix() { assert!(permissions::parallel_safe("web_fetch", &json!({}))); } +#[cfg(unix)] #[test] fn shell_paths_are_placed_in_the_real_project() { let mut legacy = perms(PermissionMode::AllowEdits); @@ -311,7 +314,7 @@ async fn a_command_run_in_a_project_subfolder_by_absolute_path_does_not_ask() { let (root, tools) = executor(config); let sub = root.path().join("project").join("sub"); fs::create_dir(&sub).unwrap(); - let cwd = sub.canonicalize().unwrap(); + let cwd = sub.canonical().unwrap(); let result = tokio::time::timeout( Duration::from_secs(30), run( @@ -632,6 +635,7 @@ async fn ask_mode_asks_before_edits_and_durable_events_are_redacted() { .any(|e| e["type"] == "tool.completed" && e["payload"]["redacted"] == true)); } +#[cfg(unix)] #[tokio::test] async fn edits_outside_the_project_are_refused_before_any_prompt() { let mut config = Config::default(); diff --git a/native/core/tests/plugins.rs b/native/core/tests/plugins.rs index 9dd67443..9e10fb9e 100644 --- a/native/core/tests/plugins.rs +++ b/native/core/tests/plugins.rs @@ -277,6 +277,7 @@ async fn mutations_require_trust_current_project_current_hash_and_idle_workspace remove(&s).await; s.engine.shutdown().await.unwrap(); } +#[cfg(unix)] #[tokio::test] async fn install_preflights_conflicts_symlinks_and_clears_stale_executable_grants() { let (root, s) = fixture("http://127.0.0.1:9/v1"); diff --git a/native/core/tests/process_stress.rs b/native/core/tests/process_stress.rs index 7494075d..c33d8709 100644 --- a/native/core/tests/process_stress.rs +++ b/native/core/tests/process_stress.rs @@ -1,3 +1,4 @@ +#![cfg_attr(not(unix), allow(unused))] use shadowcode_core::process::{run, ProcessSpec}; use std::time::{Duration, Instant}; use tokio_util::sync::CancellationToken; @@ -17,6 +18,7 @@ async fn output_flood_is_bounded_without_pipe_deadlock() { assert!(result.stdout.len() + result.stderr.len() <= 8192); } +#[cfg(unix)] #[tokio::test] async fn timeout_kills_shell_and_grandchildren() { let root = tempfile::tempdir().unwrap(); @@ -112,6 +114,7 @@ async fn dropping_execution_does_not_leave_the_process_group_alive() { assert!(status.is_empty() || status.contains("State:\tZ")); } +#[cfg(unix)] #[tokio::test] async fn a_detached_pipe_cannot_hang_completion() { let root = tempfile::tempdir().unwrap(); diff --git a/native/core/tests/project_inspection.rs b/native/core/tests/project_inspection.rs index 6feff71a..8930210d 100644 --- a/native/core/tests/project_inspection.rs +++ b/native/core/tests/project_inspection.rs @@ -1,3 +1,6 @@ +// Helpers used only by Unix-only tests are unused on Windows. +#![cfg_attr(not(unix), allow(unused))] +use shadowcode_core::paths::Canonical; mod support; use serde_json::{json, Value}; use shadowcode_core::{ @@ -7,7 +10,9 @@ use shadowcode_core::{ service::{Request, Service}, workspace::Workspace, }; -use std::{fs, os::unix::fs::symlink, path::Path, sync::Arc, time::Duration}; +#[cfg(unix)] +use std::os::unix::fs::symlink; +use std::{fs, path::Path, sync::Arc, time::Duration}; fn fixture() -> (tempfile::TempDir, Service) { let root = tempfile::tempdir().unwrap(); let path = root.path().join("project"); @@ -46,6 +51,7 @@ async fn wait(service: &Service, job: &Value) -> Value { .unwrap() .unwrap()) } +#[cfg(unix)] #[tokio::test] async fn project_scan_is_bounded_confined_and_explicit_about_ambiguous_tests() { let (root, service) = fixture(); @@ -114,6 +120,7 @@ async fn project_scan_is_bounded_confined_and_explicit_about_ambiguous_tests() { service.engine.shutdown().await.unwrap(); } +#[cfg(unix)] #[tokio::test] async fn project_scan_caps_aggregate_reads_and_skips_special_files() { use std::os::unix::ffi::OsStrExt; @@ -299,6 +306,7 @@ fn git(path: &Path, args: &[&str]) { String::from_utf8_lossy(&out.stderr) ); } +#[cfg(unix)] #[tokio::test] async fn change_history_uses_literal_paths_and_disables_external_diff_helpers() { let (_root, service) = fixture(); @@ -499,7 +507,7 @@ fn host_pid(project: &std::path::Path, namespace_pid: u32) -> u32 { if !cfg!(target_os = "linux") { return namespace_pid; } - let project = project.canonicalize().unwrap(); + let project = project.canonical().unwrap(); let matches: Vec<_> = fs::read_dir("/proc") .unwrap() .filter_map(|e| { diff --git a/native/core/tests/remote_security_regressions.rs b/native/core/tests/remote_security_regressions.rs index 4c377ed8..81163747 100644 --- a/native/core/tests/remote_security_regressions.rs +++ b/native/core/tests/remote_security_regressions.rs @@ -1,3 +1,4 @@ +#![cfg_attr(not(unix), allow(unused))] // Regression tests for the remote-access defects found in the 2.0 audit. // Real HTTP, temporary profiles, and fake secrets only. use serde_json::{json, Value}; @@ -187,6 +188,7 @@ async fn remote_cannot_open_a_private_profile_ancestor_or_inherit_one() { f.manager.stop(); } +#[cfg(unix)] #[tokio::test] async fn remote_quoted_git_paths_hide_secret_file_contents() { let f = fixture().await; diff --git a/native/core/tests/roles.rs b/native/core/tests/roles.rs index 3d4bc06e..6e74e1aa 100644 --- a/native/core/tests/roles.rs +++ b/native/core/tests/roles.rs @@ -6,6 +6,7 @@ //! with the usual approval; subagents routed to their role's model. No real //! vendor CLI or model runs. #![cfg(unix)] +use shadowcode_core::paths::Canonical; mod vendor_support; use serde_json::{json, Value}; use shadowcode_core::{ @@ -163,7 +164,7 @@ async fn setup( let root = tempfile::tempdir().unwrap(); let project = root.path().join("project"); fs::create_dir(&project).unwrap(); - let project = project.canonicalize().unwrap(); + let project = project.canonical().unwrap(); git(&project, &["init", "-q"]); fs::write(project.join("note.txt"), "old\n").unwrap(); git(&project, &["add", "note.txt"]); diff --git a/native/core/tests/rulebook.rs b/native/core/tests/rulebook.rs index 5820c25a..00e15a0f 100644 --- a/native/core/tests/rulebook.rs +++ b/native/core/tests/rulebook.rs @@ -3,6 +3,9 @@ //! and the Rules & skills routes show and change it. Vendor protocols are //! replayed from recorded fixtures or driven by fake CLIs; no real vendor //! runs and nothing is written to a vendor's home folder. +// Helpers used only by Unix-only tests are unused on Windows. +#![cfg_attr(not(unix), allow(unused))] +use shadowcode_core::paths::Canonical; mod support; mod vendor_support; use serde_json::{json, Value}; @@ -13,9 +16,10 @@ use shadowcode_core::{ rulebook::{self, VendorRules}, service::{Request, Service}, }; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; use std::{ fs, - os::unix::fs::PermissionsExt, path::{Path, PathBuf}, time::Duration, }; @@ -274,7 +278,7 @@ fn setup(endpoint: &str) -> Setup { let root = tempfile::tempdir().unwrap(); let project = root.path().join("project"); fs::create_dir(&project).unwrap(); - let project = project.canonicalize().unwrap(); + let project = project.canonical().unwrap(); let fake = FakeCodex::new(root.path(), json!({"auth":"chatgpt","turn":"ok"})); let paths = AppPaths::isolated(&root.path().join("app")).unwrap(); let mut agents = cli_agents(&fake); @@ -588,6 +592,7 @@ async fn profile_rules_and_skills_reach_shadowcodes_own_agent() { s.service.engine.shutdown().await.unwrap(); } +#[cfg(unix)] #[tokio::test] async fn a_codex_run_gets_developer_instructions_and_a_profile_command_expanded() { let s = setup("http://127.0.0.1:9/v1"); @@ -678,6 +683,7 @@ for raw in sys.stdin: send({"type":"result","subtype":"success","is_error":False,"result":"PELICAN","usage":{"input_tokens":3,"output_tokens":1}}) "#; +#[cfg(unix)] #[tokio::test] async fn a_claude_run_reads_a_private_rules_file_that_is_removed_afterwards() { let s = setup("http://127.0.0.1:9/v1"); diff --git a/native/core/tests/sandbox_isolation.rs b/native/core/tests/sandbox_isolation.rs index e096e1ba..e4575e0f 100644 --- a/native/core/tests/sandbox_isolation.rs +++ b/native/core/tests/sandbox_isolation.rs @@ -235,6 +235,7 @@ async fn network_off_and_on_inside_bubblewrap() { } } +#[cfg(unix)] #[test] fn landlock_limits_commands_when_bubblewrap_is_missing() { // PATH is process-global: hide bubblewrap in a child test process only. diff --git a/native/core/tests/scheduled_resume.rs b/native/core/tests/scheduled_resume.rs index 69ff9696..db6cd351 100644 --- a/native/core/tests/scheduled_resume.rs +++ b/native/core/tests/scheduled_resume.rs @@ -4,6 +4,7 @@ //! The limited task is written directly (a real vendor limit needs a signed-in //! subscription); its model is a fake one on loopback. #![cfg(unix)] +use shadowcode_core::paths::Canonical; mod support; use serde_json::{json, Value}; use shadowcode_core::{ @@ -22,7 +23,7 @@ fn setup(endpoint: &str) -> (tempfile::TempDir, AppPaths, PathBuf) { let root = tempfile::tempdir().unwrap(); let project = root.path().join("project"); fs::create_dir(&project).unwrap(); - let project = project.canonicalize().unwrap(); + let project = project.canonical().unwrap(); let paths = AppPaths::isolated(&root.path().join("profile")).unwrap(); Config::patch( &paths, diff --git a/native/core/tests/second_opinions.rs b/native/core/tests/second_opinions.rs index 8b325941..d78e5724 100644 --- a/native/core/tests/second_opinions.rs +++ b/native/core/tests/second_opinions.rs @@ -3,6 +3,8 @@ //! registered loopback models on one fake OpenAI-compatible server (the //! reply depends on the requested model name); the cloud reviewer is the //! fake Codex app-server. No real vendor CLI or network is used. +#![cfg_attr(not(unix), allow(unused))] +use shadowcode_core::paths::Canonical; mod support; mod vendor_support; use serde_json::{json, Value}; @@ -117,7 +119,7 @@ async fn fixture(extra: Value) -> Fixture { fs::write(project.join(".env"), "TOKEN=committed\n").unwrap(); git(&project, &["add", "."]); git(&project, &["commit", "-qm", "Base"]); - let project = project.canonicalize().unwrap(); + let project = project.canonical().unwrap(); let paths = AppPaths::isolated(&root.path().join("profile")).unwrap(); let mut config = json!({ "trusted_workspaces": [project.clone()], @@ -580,6 +582,7 @@ fn local_turn(service: &Service, project: &Path, sid: &str) -> String { task_id } +#[cfg(unix)] #[tokio::test] async fn local_work_reaches_a_cloud_reviewer_only_with_consent() { let root = tempfile::tempdir().unwrap(); @@ -590,7 +593,7 @@ async fn local_work_reaches_a_cloud_reviewer_only_with_consent() { git(&project, &["add", "."]); git(&project, &["commit", "-qm", "Base"]); fs::write(project.join("parser.rs"), "fn parse() { todo!() }\n").unwrap(); - let project = project.canonicalize().unwrap(); + let project = project.canonical().unwrap(); let fake = FakeCodex::new(root.path(), json!({"auth":"chatgpt","turn":"ok"})); let paths = AppPaths::isolated(&root.path().join("profile")).unwrap(); Config::patch( @@ -673,7 +676,7 @@ async fn a_change_a_local_implement_role_wrote_asks_before_a_cloud_reviewer() { git(&project, &["add", "."]); git(&project, &["commit", "-qm", "Base"]); fs::write(project.join("parser.rs"), "fn parse() { todo!() }\n").unwrap(); - let project = project.canonicalize().unwrap(); + let project = project.canonical().unwrap(); let fake = FakeCodex::new(root.path(), json!({"auth":"chatgpt","turn":"ok"})); let paths = AppPaths::isolated(&root.path().join("profile")).unwrap(); Config::patch( @@ -821,7 +824,7 @@ async fn live_review_of_a_staged_change_by_a_real_model() { let changed = "def add(a, b):\n \"\"\"Return the sum of a and b.\"\"\"\n return a - b\n"; fs::write(project.join("calc.py"), changed).unwrap(); git(&project, &["add", "calc.py"]); - let project = project.canonicalize().unwrap(); + let project = project.canonical().unwrap(); let paths = AppPaths::isolated(&root.path().join("profile")).unwrap(); let endpoint = std::env::var("SHADOWCODE_LIVE_REVIEW_ENDPOINT").ok(); Config::patch( @@ -905,6 +908,7 @@ async fn live_review_of_a_staged_change_by_a_real_model() { service.engine.shutdown().await.unwrap(); } +#[cfg(unix)] #[tokio::test] async fn a_second_opinion_that_hits_a_plan_limit_stays_on_its_model() { let root = tempfile::tempdir().unwrap(); @@ -916,7 +920,7 @@ async fn a_second_opinion_that_hits_a_plan_limit_stays_on_its_model() { git(&project, &["commit", "-qm", "Base"]); fs::write(project.join("a.txt"), "two\n").unwrap(); git(&project, &["add", "a.txt"]); - let project = project.canonicalize().unwrap(); + let project = project.canonical().unwrap(); let fake = FakeCodex::new(root.path(), json!({"auth":"chatgpt","turn":"limit"})); let paths = AppPaths::isolated(&root.path().join("profile")).unwrap(); Config::patch( diff --git a/native/core/tests/service_commands.rs b/native/core/tests/service_commands.rs index 47523777..4d6b5d32 100644 --- a/native/core/tests/service_commands.rs +++ b/native/core/tests/service_commands.rs @@ -1,3 +1,4 @@ +use shadowcode_core::paths::Canonical; mod support; use serde_json::{json, Value}; use shadowcode_core::{ @@ -151,7 +152,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 other = other.canonical().unwrap(); let second_project = reopened.fork_selection(other.clone(), None).unwrap(); assert_eq!( call( @@ -1322,7 +1323,13 @@ async fn session_listing_and_literal_search_work_after_tasks_are_saved() { .await .unwrap(); assert_eq!(all["sessions"].as_array().unwrap().len(), 2); - for query in ["100%25", "%25_", "%21", "%5C", "Literal"] { + // Windows project paths contain `\`, so a search for it finds both. + let queries: &[&str] = if cfg!(windows) { + &["100%25", "%25_", "%21", "Literal"] + } else { + &["100%25", "%25_", "%21", "%5C", "Literal"] + }; + for query in queries { let found = call( &service, "GET", diff --git a/native/core/tests/spending.rs b/native/core/tests/spending.rs index c03f4de2..b50f88ed 100644 --- a/native/core/tests/spending.rs +++ b/native/core/tests/spending.rs @@ -5,6 +5,7 @@ //! subagents count toward the task that started them, local models are never //! limited, and the same flow works headless through the service routes. #![cfg(unix)] +use shadowcode_core::paths::Canonical; mod support; use serde_json::{json, Value}; use shadowcode_core::{ @@ -39,7 +40,7 @@ fn setup(endpoint: &str, provider: &str, spending: Value) -> (tempfile::TempDir, let root = tempfile::tempdir().unwrap(); let project = root.path().join("project"); fs::create_dir(&project).unwrap(); - let project = project.canonicalize().unwrap(); + let project = project.canonical().unwrap(); let paths = AppPaths::isolated(&root.path().join("profile")).unwrap(); Config::patch( &paths, diff --git a/native/core/tests/sqlite.rs b/native/core/tests/sqlite.rs index 4788bf3c..ded273db 100644 --- a/native/core/tests/sqlite.rs +++ b/native/core/tests/sqlite.rs @@ -1,13 +1,14 @@ -#![cfg(unix)] +#![cfg_attr(not(unix), allow(unused))] use rusqlite::Connection; use serde_json::{json, Value}; use shadowcode_core::{ sqlite::{self, Request}, workspace::Workspace, }; +#[cfg(unix)] +use std::os::unix::fs::{symlink, PermissionsExt}; use std::{ fs, - os::unix::fs::{symlink, PermissionsExt}, sync::Arc, time::{Duration, Instant}, }; @@ -33,7 +34,14 @@ async fn read(workspace: &Arc, value: Value) -> anyhow::Result async fn native_queries_preserve_types_parameters_and_database_bytes() { let (_root, ws) = fixture(); let before = fs::read(ws.path.join("data.db")).unwrap(); + #[cfg(unix)] fs::set_permissions(ws.path.join("data.db"), fs::Permissions::from_mode(0o444)).unwrap(); + #[cfg(windows)] + { + let mut read_only = fs::metadata(ws.path.join("data.db")).unwrap().permissions(); + read_only.set_readonly(true); + fs::set_permissions(ws.path.join("data.db"), read_only).unwrap(); + } assert_eq!( read(&ws, json!({"path":"data.db"})).await.unwrap()["tables"], json!(["items"]) @@ -77,7 +85,12 @@ async fn native_queries_preserve_types_parameters_and_database_bytes() { .is_empty()); assert_eq!(fs::read(ws.path.join("data.db")).unwrap(), before); assert_eq!(fs::read_dir(&ws.path).unwrap().count(), 1); - let renamed = "query ? # % ü.db"; + // URI-sensitive characters in the name (Windows forbids `?` in names). + let renamed = if cfg!(windows) { + "query # % ü.db" + } else { + "query ? # % ü.db" + }; fs::rename(ws.path.join("data.db"), ws.path.join(renamed)).unwrap(); assert_eq!( read(&ws, json!({"path":renamed})).await.unwrap()["tables"], @@ -185,6 +198,8 @@ async fn wide_schemas_busy_databases_and_corruption_have_bounded_results() { ); } +// Symlinks and FIFOs, which need Unix (or Windows developer rights). +#[cfg(unix)] #[tokio::test] async fn database_paths_and_sidecars_are_confined_without_creating_missing_files() { let (root, ws) = fixture(); @@ -240,7 +255,8 @@ async fn live_wal_commits_from_another_process_are_visible_without_database_chan let path = ws.path.join("live.db"); // python3 + stdlib sqlite3: Ubuntu /usr/bin/node is often 18 and has no // node:sqlite, which made this look like a parallel flake on sanitized PATH. - let mut child = std::process::Command::new("python3") + // Windows installs Python as `python`; `python3` may be a store stub. + let mut child = std::process::Command::new(if cfg!(windows) { "python" } else { "python3" }) .arg(concat!( env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/sqlite-writer.py" diff --git a/native/core/tests/subagents.rs b/native/core/tests/subagents.rs index bb1977f1..155238a9 100644 --- a/native/core/tests/subagents.rs +++ b/native/core/tests/subagents.rs @@ -3,6 +3,7 @@ //! the parent, `@agent` mentions, and model-loaded skills. #![cfg(unix)] use serde_json::{json, Value}; +use shadowcode_core::paths::Canonical; use shadowcode_core::{ config::Config, engine::{Engine, Job, StartRequest}, @@ -156,7 +157,7 @@ fn setup(endpoint: &str, extra: Value) -> (tempfile::TempDir, Engine, PathBuf) { let root = tempfile::tempdir().unwrap(); let project = root.path().join("project"); fs::create_dir(&project).unwrap(); - let project = project.canonicalize().unwrap(); + let project = project.canonical().unwrap(); let paths = AppPaths::isolated(&root.path().join("profile")).unwrap(); let mut config = json!({"model":{"provider":"local","endpoint":endpoint,"name":"fixture","context_limit":32768},"trusted_workspaces":[project],"permissions":{"approve_shell":false},"agent":{"max_steps":12,"model_retries":0}}); merge(&mut config, extra); diff --git a/native/core/tests/support/mod.rs b/native/core/tests/support/mod.rs index 56551b0a..0dc8ee7b 100644 --- a/native/core/tests/support/mod.rs +++ b/native/core/tests/support/mod.rs @@ -102,9 +102,15 @@ pub fn running(pid: u32) -> bool { }; written == size && info.pbi_status != libc::SZOMB } + // Windows keeps no zombies: a process exists until its last handle closes. + #[cfg(windows)] + { + exists(pid) + } } /// Whether a process id is still taken, zombies included (not yet reaped). +#[cfg(unix)] pub fn exists(pid: u32) -> bool { let Ok(pid) = i32::try_from(pid) else { return false; @@ -112,6 +118,13 @@ pub fn exists(pid: u32) -> bool { // SAFETY: signal 0 only checks that the process exists. unsafe { libc::kill(pid, 0) == 0 || *errno() == libc::EPERM } } +#[cfg(windows)] +pub fn exists(pid: u32) -> bool { + std::process::Command::new("tasklist") + .args(["/NH", "/FO", "CSV", "/FI", &format!("PID eq {pid}")]) + .output() + .is_ok_and(|out| String::from_utf8_lossy(&out.stdout).contains(&format!("\"{pid}\""))) +} #[cfg(target_os = "linux")] unsafe fn errno() -> *mut i32 { unsafe { libc::__errno_location() } diff --git a/native/core/tests/terminals.rs b/native/core/tests/terminals.rs index 9ddecad4..e2b27ed4 100644 --- a/native/core/tests/terminals.rs +++ b/native/core/tests/terminals.rs @@ -1,6 +1,8 @@ //! 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. +// Terminals (ConPTY) are not ported to Windows yet. +#![cfg(unix)] mod support; use base64::Engine as _; use serde_json::{json, Value}; diff --git a/native/core/tests/upgrade_fixtures.rs b/native/core/tests/upgrade_fixtures.rs index 0b501c35..801e696d 100644 --- a/native/core/tests/upgrade_fixtures.rs +++ b/native/core/tests/upgrade_fixtures.rs @@ -6,7 +6,9 @@ //! plus `manifest.json` naming the records it created. Each one is rebuilt in //! a temporary profile and opened through `Service::open`, the way the //! desktop and CLI open a profile after an upgrade. +#![cfg_attr(not(unix), allow(unused))] use serde_json::{json, Value}; +use shadowcode_core::paths::Canonical; use shadowcode_core::{ paths::AppPaths, service::{Request, Service}, @@ -56,7 +58,12 @@ fn copy_tree(from: &Path, to: &Path, root: &Path) { copy_tree(&entry.path(), &target, root); } else { let text = fs::read_to_string(entry.path()).unwrap(); - fs::write(&target, text.replace("@ROOT@", &root.to_string_lossy())).unwrap(); + // Inside JSON values too: `/` needs no escaping, `\` would. + fs::write( + &target, + text.replace("@ROOT@", &shadowcode_core::paths::slash(root)), + ) + .unwrap(); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; @@ -87,7 +94,7 @@ fn write_private(path: &Path, text: &str) { fn profile(version: &str) -> Profile { let dir = fixtures().join(version); let temp = tempfile::tempdir().unwrap(); - let root = temp.path().canonicalize().unwrap(); + let root = temp.path().canonical().unwrap(); let project = root.join("project"); fs::create_dir_all(&project).unwrap(); fs::write(project.join("README.md"), "# Fixture project\n").unwrap(); @@ -110,7 +117,7 @@ fn profile(version: &str) -> Profile { ); let sql = fs::read_to_string(dir.join("shadow-agent.sql")) .unwrap() - .replace("@ROOT@", &root.to_string_lossy()); + .replace("@ROOT@", &shadowcode_core::paths::slash(&root)); let db = rusqlite::Connection::open(paths.database()).unwrap(); // A throwaway file: one transaction, no fsync per row. db.pragma_update(None, "synchronous", "OFF").unwrap(); @@ -233,6 +240,9 @@ fn a_fixture_exists_for_every_release_and_schema_version() { assert!(schemas.iter().all(|s| *s <= SCHEMA_VERSION)); } +// Released profiles come from Linux and macOS, with Unix paths throughout; +// no Windows release exists to upgrade from. +#[cfg(unix)] #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn every_released_profile_opens_with_all_its_data() { let versions = versions(); diff --git a/native/core/tests/vendor_accounts.rs b/native/core/tests/vendor_accounts.rs index 8a2ea0ef..be8cbf90 100644 --- a/native/core/tests/vendor_accounts.rs +++ b/native/core/tests/vendor_accounts.rs @@ -1,6 +1,8 @@ //! Subscription accounts: official sign-in/out, catalog states, usage //! persistence, and the vendor runner's safety rules. Every vendor process //! here is a fake script; no real vendor CLI is started. +// The vendor runner and its fake CLIs (shell scripts) are Unix-only so far. +#![cfg(unix)] mod vendor_support; use serde_json::{json, Value}; use shadowcode_core::{ diff --git a/native/core/tests/vendor_codex_scope.rs b/native/core/tests/vendor_codex_scope.rs index a6bc6e14..1ecfaf27 100644 --- a/native/core/tests/vendor_codex_scope.rs +++ b/native/core/tests/vendor_codex_scope.rs @@ -1,4 +1,5 @@ //! Codex identity-envelope regressions. Only local fake protocol frames/CLIs. +#![cfg_attr(not(unix), allow(unused))] mod vendor_support; use serde_json::{json, Value}; use shadowcode_core::cli_agent::{adapter_for, CliAdapter, LaunchOptions, Step, Update, Vendor}; @@ -407,6 +408,7 @@ fn matching_current_frames_publish_and_global_token_refresh_stays_declined() { .any(|u| matches!(u, Update::TurnCompleted { .. }))); } +#[cfg(unix)] #[tokio::test] async fn real_runner_keeps_owned_turn_running_after_foreign_frames() { use shadowcode_core::{ diff --git a/native/core/tests/vendor_support/mod.rs b/native/core/tests/vendor_support/mod.rs index 17c22198..23a4fe37 100644 --- a/native/core/tests/vendor_support/mod.rs +++ b/native/core/tests/vendor_support/mod.rs @@ -4,9 +4,10 @@ //! probes clear the environment. They never reach a real vendor. #![allow(dead_code)] use serde_json::{json, Value}; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; use std::{ fs, - os::unix::fs::PermissionsExt, path::{Path, PathBuf}, time::Duration, }; @@ -223,7 +224,7 @@ for raw in sys.stdin: # Edit, report some of the edits (fileChange items), then wait for # the test to act while the turn is still running. for name, text in (C.get("edits") or {}).items(): - with open(os.path.join(os.getcwd(), name), "w") as f: + with open(os.path.join(os.getcwd(), name), "w", newline="\n") as f: f.write(text) for name in C.get("reported") or []: send({"jsonrpc":"2.0","method":"item/completed","params":{"threadId":active_thread,"turnId":active_turn,"item":{"id":"f-" + name,"type":"fileChange","status":"completed","changes":[{"path":os.path.join(os.getcwd(), name),"kind":{"type":"update"},"diff":""}]}}}) @@ -232,7 +233,7 @@ for raw in sys.stdin: while not os.path.exists(os.path.join(HERE, "release_turn")) and time.monotonic() < until: time.sleep(0.01) for name, text in (C.get("late_edits") or {}).items(): - with open(os.path.join(os.getcwd(), name), "w") as f: + with open(os.path.join(os.getcwd(), name), "w", newline="\n") as f: f.write(text) send({"jsonrpc":"2.0","method":"item/agentMessage/delta","params":{"threadId":active_thread,"turnId":active_turn,"itemId":"m1","delta":"edited"}}) send({"jsonrpc":"2.0","method":"turn/completed","params":{"threadId":active_thread,"turn":{"id":active_turn,"status":"completed"}}}) @@ -241,7 +242,7 @@ for raw in sys.stdin: # Write and delete project files with the CLI's own tools, as a # real vendor would, relative to the working directory. for name, text in (C.get("edits") or {}).items(): - with open(os.path.join(os.getcwd(), name), "w") as f: + with open(os.path.join(os.getcwd(), name), "w", newline="\n") as f: f.write(text) for name in C.get("deletes") or []: os.remove(os.path.join(os.getcwd(), name)) @@ -277,16 +278,42 @@ impl FakeCodex { pub fn new(root: &Path, config: Value) -> Self { let dir = root.join("fake-bin"); fs::create_dir_all(&dir).unwrap(); - let script = dir.join("codex"); - fs::write(&script, FAKE_CODEX).unwrap(); - let mut perm = fs::metadata(&script).unwrap().permissions(); - perm.set_mode(0o755); - fs::set_permissions(&script, perm).unwrap(); + #[cfg(unix)] + { + let script = dir.join("codex"); + fs::write(&script, FAKE_CODEX).unwrap(); + let mut perm = fs::metadata(&script).unwrap().permissions(); + perm.set_mode(0o755); + fs::set_permissions(&script, perm).unwrap(); + } + // Windows runs a script through a `.cmd` shim, as npm installs the + // real CLIs; Python is `python` there. + #[cfg(windows)] + { + fs::write(dir.join("codex.py"), FAKE_CODEX).unwrap(); + // Its errors are kept and shown when the test ends. + // The interpreter by its full path: the shim must not depend on + // what PATH the vendor process sees. + let python = std::env::var_os("PATH") + .and_then(|path| shadowcode_core::paths::find_program_in("python", &path)) + .expect("python must be on PATH to run the fake Codex"); + fs::write( + dir.join("codex.cmd"), + format!( + "@\"{}\" \"%~dp0codex.py\" %* 2>>\"%~dp0stderr.log\"\r\n", + python.display() + ), + ) + .unwrap(); + } let fake = Self { dir }; fake.configure(config); fake } pub fn binary(&self) -> String { + #[cfg(windows)] + return self.dir.join("codex.cmd").to_string_lossy().into_owned(); + #[cfg(not(windows))] self.dir.join("codex").to_string_lossy().into_owned() } pub fn configure(&self, config: Value) { @@ -296,6 +323,14 @@ impl FakeCodex { fs::read_to_string(self.dir.join(name)).ok() } } +#[cfg(windows)] +impl Drop for FakeCodex { + fn drop(&mut self) { + if let Some(errors) = self.marker("stderr.log").filter(|e| !e.trim().is_empty()) { + eprintln!("fake codex stderr:\n{errors}"); + } + } +} /// `cli_agents` pointing Codex at the fake and every other vendor at a path /// that does not exist, so no real vendor CLI is ever started. diff --git a/native/core/tests/vendor_transport.rs b/native/core/tests/vendor_transport.rs index 5bec5ffd..87d6428f 100644 --- a/native/core/tests/vendor_transport.rs +++ b/native/core/tests/vendor_transport.rs @@ -1,5 +1,7 @@ //! Real subprocess transport regressions, with isolated fake vendor CLIs. //! No network, real accounts or provider allowance are used. +// The vendor runner and its fake CLIs (shell scripts) are Unix-only so far. +#![cfg(unix)] mod vendor_support; use serde_json::json; diff --git a/native/core/tests/windows_sandbox.rs b/native/core/tests/windows_sandbox.rs new file mode 100644 index 00000000..e9a401e3 --- /dev/null +++ b/native/core/tests/windows_sandbox.rs @@ -0,0 +1,150 @@ +//! The Windows sandbox (AppContainer) around agent shell commands: the +//! project is writable, the rest of the home folder is unreadable, nothing +//! is written outside the project and the scratch folder, and output and +//! exit codes pass through the launcher. +#![cfg(windows)] +use shadowcode_core::{ + config::ShellNetwork, + sandbox::{self, ShellPolicy}, +}; +use std::{fs, path::Path, process::Command, sync::Mutex}; + +/// One test at a time: a grant rewrites folder permissions, which must not +/// change under a command another test is running in the container. +static SERIAL: Mutex<()> = Mutex::new(()); + +fn policy(require: bool) -> ShellPolicy { + ShellPolicy { + network: ShellNetwork::Off, + allow: Vec::new(), + allow_text: Vec::new(), + home_binds: Vec::new(), + require, + landlock: false, + } +} + +fn run(workspace: &Path, command: &str) -> (i32, String) { + std::env::set_var( + "SHADOWCODE_SANDBOX_LAUNCHER", + env!("CARGO_BIN_EXE_shadowcode-sandbox"), + ); + let prepared = sandbox::prepare_shell(workspace, workspace, command, &policy(true)).unwrap(); + assert_eq!(prepared.note["mode"], "appcontainer", "{}", prepared.note); + let output = Command::new(&prepared.program) + .args(&prepared.args) + .current_dir(workspace) + .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) +} + +#[test] +fn commands_write_the_project_but_cannot_read_the_home_folder_or_write_outside() { + let _serial = SERIAL + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let home = shadowcode_core::paths::home_dir().unwrap(); + // A secret in the home folder, outside the project. + let secret_dir = tempfile::Builder::new() + .prefix("shadowcode-sandbox-secret-") + .tempdir_in(&home) + .unwrap(); + let secret = secret_dir.path().join("secret.txt"); + fs::write(&secret, "do-not-read").unwrap(); + let outside = secret_dir.path().join("written.txt"); + let root = tempfile::Builder::new() + .prefix("shadowcode-sandbox-project-") + .tempdir_in(&home) + .unwrap(); + let workspace = root.path().canonicalize().unwrap(); + let workspace = dunce_path(&workspace); + let slash = |p: &Path| shadowcode_core::paths::slash(p); + + let (code, text) = run(&workspace, "echo inside > made.txt; echo printed; exit 7"); + assert_eq!(code, 7, "{text}"); + assert!(text.contains("printed"), "{text}"); + assert!(workspace.join("made.txt").is_file(), "{text}"); + + let (code, text) = run(&workspace, &format!("cat '{}'", slash(&secret))); + assert_ne!(code, 0, "{text}"); + assert!( + !text.contains("do-not-read"), + "the sandbox read the home folder: {text}" + ); + + let (_, text) = run(&workspace, &format!("echo escaped > '{}'", slash(&outside))); + assert!( + !outside.exists(), + "the sandbox wrote outside the project: {text}" + ); +} + +fn dunce_path(path: &Path) -> std::path::PathBuf { + let text = path.to_string_lossy(); + std::path::PathBuf::from(text.strip_prefix(r"\\?\").unwrap_or(&text)) +} + +/// Programs start in the container (a failed start is 0xC0000142): system +/// programs and busybox `sh`, the sandbox's shell. Git for Windows' bash +/// (MSYS2) cannot start in an AppContainer; it is not used inside it. The +/// container may not open the null device (cmd's `>nul` is refused), so +/// busybox's /dev/null is the handle the launcher opened for it. +#[test] +fn system_programs_and_busybox_start_in_the_container() { + let _serial = SERIAL + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + std::env::set_var( + "SHADOWCODE_SANDBOX_LAUNCHER", + env!("CARGO_BIN_EXE_shadowcode-sandbox"), + ); + // The doctor's check: it also lets the container read busybox's folder. + sandbox::appcontainer::probe().unwrap(); + let sid = sandbox::appcontainer::container_sid().unwrap(); + let system = std::env::var("SystemRoot").unwrap(); + let busybox = std::env::var("SHADOWCODE_BUSYBOX").expect("SHADOWCODE_BUSYBOX"); + let programs: Vec> = vec![ + vec![format!(r"{system}\System32\whoami.exe")], + vec![ + format!(r"{system}\System32\cmd.exe"), + "/c".into(), + "echo cmd-ok".into(), + ], + vec![ + busybox, + "sh".into(), + "-c".into(), + "echo busybox-ok; echo hidden >/dev/null; ls / 2>/dev/null >/dev/null; \ + cat Fixture { fs::write(project.join(".gitignore"), "ignored.log\n").unwrap(); git(&project, &["add", "."]); git(&project, &["commit", "-qm", "Base"]); - let project = project.canonicalize().unwrap(); + let project = project.canonical().unwrap(); let paths = AppPaths::isolated(&root.path().join("profile")).unwrap(); Config::patch( &paths, @@ -511,7 +512,7 @@ async fn a_new_worktree_gets_its_files_its_setup_its_own_port_and_a_teardown() { json!({"setup":{ "copy":[".env"], "setup":["printf \"$PORT\" > port.txt"], - "teardown":[format!("touch {}", marker.display())], + "teardown":[format!("touch {}", shadowcode_core::paths::slash(&marker))], "port_start":41000, "port_end":41999 }}), diff --git a/native/core/tests/worktrees.rs b/native/core/tests/worktrees.rs index f86f78aa..00539884 100644 --- a/native/core/tests/worktrees.rs +++ b/native/core/tests/worktrees.rs @@ -1,5 +1,7 @@ use anyhow::Result; use serde_json::{json, Value}; +#[cfg(unix)] +use shadowcode_core::paths::Canonical; use shadowcode_core::{ config::Config, paths::AppPaths, @@ -49,13 +51,14 @@ async fn call(service: &Service, method: &str, body: Value) -> Result { }) .await } +#[cfg(unix)] #[tokio::test] async fn isolated_checkout_preserves_dirty_source_and_disables_checkout_hooks() { use std::os::unix::fs::PermissionsExt; let root = tempfile::tempdir().unwrap(); let project = root.path().join("project"); repository(&project); - let project = project.canonicalize().unwrap(); + let project = project.canonical().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"]); @@ -182,7 +185,8 @@ async fn cancelled_checkout_leaves_a_recovery_record_and_source_intact() { xdg.join("git/config"), format!( "[filter \"slow\"]\n\tsmudge = \"touch '{}'; sleep 60; cat\"\n", - marker.display() + // Git config reads `\` as an escape; `/` works on Windows too. + shadowcode_core::paths::slash(&marker) ), ) .unwrap(); @@ -358,6 +362,7 @@ async fn removal_refuses_changes_ignored_files_stale_review_and_busy_tasks_prese .exists()); service.engine.shutdown().await.unwrap(); } +#[cfg(unix)] #[tokio::test] async fn worktree_inspection_rejects_detached_heads_symlinks_and_forged_paths() { use std::os::unix::fs::symlink; @@ -500,7 +505,9 @@ async fn missing_checkout_rescue_retains_commits_index_and_original_registration assert_eq!(fs::read(record_file).unwrap(), record_bytes); assert_eq!(git(&project, &["rev-parse", &record.branch]), commit); assert!( - git(&project, &["worktree", "list", "--porcelain"]).contains(record.path.to_str().unwrap()) + // Git lists paths with `/` on Windows too. + git(&project, &["worktree", "list", "--porcelain"]) + .contains(&shadowcode_core::paths::slash(&record.path)) ); assert_eq!( fs::read_to_string(saved.join("tracked.txt")).unwrap(), @@ -755,6 +762,7 @@ async fn reviewed_return_does_not_overwrite_ignored_source_files() { service.engine.shutdown().await.unwrap(); } +#[cfg(unix)] #[tokio::test] async fn copied_source_changes_preserve_staging_binary_untracked_modes_and_source() { use std::os::unix::fs::PermissionsExt; @@ -839,6 +847,7 @@ async fn copied_source_changes_preserve_staging_binary_untracked_modes_and_sourc ); service.engine.shutdown().await.unwrap(); } +#[cfg(unix)] #[tokio::test] async fn copy_review_rejects_symlinks_changed_contents_and_excludes_non_git_files() { use std::os::unix::fs::symlink; @@ -1077,6 +1086,7 @@ async fn reviewed_connection_repair_preserves_index_files_and_rejects_stale_stat service.engine.shutdown().await.unwrap(); } +#[cfg(unix)] #[tokio::test] async fn connection_repair_refuses_foreign_links_locks_and_lost_indexes() { use std::os::unix::fs::symlink; @@ -1795,7 +1805,7 @@ mod profile_admission { let checkouts = paths .data .join("managed-worktrees/checkouts") - .canonicalize() + .canonical() .unwrap(); for _ in 0..62 { let mut pending = first.clone(); diff --git a/packaging/com.shadowfetch.shadowcode.appdata.xml b/packaging/com.shadowfetch.shadowcode.appdata.xml index 04a3678d..6366d74a 100644 --- a/packaging/com.shadowfetch.shadowcode.appdata.xml +++ b/packaging/com.shadowfetch.shadowcode.appdata.xml @@ -29,6 +29,9 @@ shadowcode + +

ShadowCode for Windows: a per-user installer with local models (llama.cpp), terminals, subscription tools and an AppContainer sandbox for agent commands.

+

Your own model servers (Ollama, LM Studio, vLLM, OpenAI-compatible APIs), Settings › Agent, find in conversation, a smarter command palette, text size, and a native macOS app for Apple silicon and Intel Macs.

diff --git a/packaging/flatpak/com.shadowfetch.shadowcode.desktop b/packaging/flatpak/com.shadowfetch.shadowcode.desktop index 5b958392..fe132b9c 100644 --- a/packaging/flatpak/com.shadowfetch.shadowcode.desktop +++ b/packaging/flatpak/com.shadowfetch.shadowcode.desktop @@ -11,4 +11,4 @@ Keywords=agent;llm;coding;shadowfetch; StartupNotify=true StartupWMClass=shadowcode X-GNOME-UsesNotifications=true -X-ShadowCode-Version=2.2.0 +X-ShadowCode-Version=2.3.0 diff --git a/packaging/native-runtime/Dockerfile b/packaging/native-runtime/Dockerfile index 7e36b713..99d5b495 100644 --- a/packaging/native-runtime/Dockerfile +++ b/packaging/native-runtime/Dockerfile @@ -1,7 +1,16 @@ ARG BASE_IMAGE FROM ${BASE_IMAGE} COPY apk-packages.txt /build/apk-packages.txt -RUN xargs apk add --no-cache < /build/apk-packages.txt +# Every package is pinned exactly. A line "ARCH:name=version" replaces that +# package's pin on ARCH only (Alpine's architectures can be a revision apart +# while a builder catches up); it must name a pinned package. +RUN awk -v arch="$(apk --print-arch)" ' \ + index($0, ":") { split($0, p, ":"); if (p[1] == arch) { n = p[2]; sub(/=.*/, "", n); over[n] = p[2] }; next }; \ + { n = $0; sub(/=.*/, "", n); order[++count] = n; pin[n] = $0 }; \ + END { for (n in over) if (!(n in pin)) { print "override " over[n] " names no pinned package" > "/dev/stderr"; exit 1 }; \ + for (i = 1; i <= count; i++) print (order[i] in over) ? over[order[i]] : pin[order[i]] }' \ + /build/apk-packages.txt > /tmp/selected && \ + xargs apk add --no-cache < /tmp/selected # Release source archives also contain the retained dependency archives/patches. COPY . /build/ WORKDIR /build diff --git a/packaging/native-runtime/README.md b/packaging/native-runtime/README.md index 78d7223c..5a6931cb 100644 --- a/packaging/native-runtime/README.md +++ b/packaging/native-runtime/README.md @@ -31,7 +31,9 @@ output-error handling, not a disk-performance or power-loss durability claim. `SHADOW_CONTAINER_ENGINE=podman`. The engine must already be usable by the build user. The container is pinned by digest, every APK version is pinned in `apk-packages.txt`, and source archives/patches have checksums. Unavailable pins -fail the build. Build tools stay in the container; none are copied into the app. +fail the build. A line `ARCH:name=version` (for example `aarch64:libcurl=...`) +replaces that package's pin on one architecture while Alpine's builders are a +revision apart; remove it once both architectures carry the same version. Build tools stay in the container; none are copied into the app. The runtime itself links statically, and the build rejects dynamic dependencies. Source downloads use bounded attempts and verify the pinned digest before diff --git a/packaging/native-runtime/apk-packages.txt b/packaging/native-runtime/apk-packages.txt index f7ee10ab..53c391a1 100644 --- a/packaging/native-runtime/apk-packages.txt +++ b/packaging/native-runtime/apk-packages.txt @@ -47,9 +47,10 @@ libbz2=1.0.8-r6 libcap-getcap=2.78-r0 libcap-ng=0.8.5-r0 libcap2=2.78-r0 -libcurl=8.14.1-r2 +libcurl=8.14.1-r3 +aarch64:libcurl=8.14.1-r2 libeconf=0.6.3-r0 -libexpat=2.8.4-r0 +libexpat=2.9.0-r0 libfdisk=2.40.4-r1 libffi=3.4.7-r0 libgcc=14.2.0-r4 diff --git a/packaging/shadow-agent.desktop b/packaging/shadow-agent.desktop index ec8cd055..eb715ce9 100644 --- a/packaging/shadow-agent.desktop +++ b/packaging/shadow-agent.desktop @@ -13,4 +13,4 @@ Keywords=agent;llm;coding;shadowfetch; StartupNotify=true StartupWMClass=shadowcode X-GNOME-UsesNotifications=true -X-ShadowCode-Version=2.2.0 +X-ShadowCode-Version=2.3.0 diff --git a/packaging/windows/bundle-runtime.conf.json b/packaging/windows/bundle-runtime.conf.json new file mode 100644 index 00000000..3dfb307d --- /dev/null +++ b/packaging/windows/bundle-runtime.conf.json @@ -0,0 +1,8 @@ +{ + "bundle": { + "resources": { + "../packaging/llama.cpp/bin/": "llama.cpp/", + "../packaging/busybox-w32/bin/": "busybox/" + } + } +} diff --git a/release/trust/policy b/release/trust/policy index 4da6c311..19fece1b 100644 --- a/release/trust/policy +++ b/release/trust/policy @@ -7,4 +7,4 @@ channel=stable minimum-epoch=1 minimum-version=0.33.0 keys=ed25519-spki-sha256 -key=1 f0c60ff8228314616985712f8918c9798c0ecf72b7271e19343a041c25663b7f 0.33.0 2.2.0 +key=1 f0c60ff8228314616985712f8918c9798c0ecf72b7271e19343a041c25663b7f 0.33.0 2.3.0 diff --git a/scripts/build-busybox-w32.sh b/scripts/build-busybox-w32.sh new file mode 100755 index 00000000..b10d4321 --- /dev/null +++ b/scripts/build-busybox-w32.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Build busybox-w32 (a native Windows sh and the usual tools in one .exe) +# from the commit pinned in tools/busybox-w32.pin, cross-compiled with +# mingw-w64 as upstream does. It is the shell inside ShadowCode's Windows +# sandbox: Git for Windows' bash (MSYS2) cannot start in an AppContainer. +# +# * Needs git, make, gcc, bzip2 and the x86_64-w64-mingw32 cross compiler +# (Debian/Ubuntu: gcc-mingw-w64-x86-64). +# * The patches in tools/busybox-w32-patches are applied (and shipped with +# the source notice): the null device opens inside an AppContainer. +# * Output: packaging/busybox-w32/bin/busybox.exe with LICENSE (GPL-2.0), +# COMMIT naming the exact upstream source, and the applied patches. +# * --source-archive FILE also writes the complete corresponding source +# (the upstream tree at the pinned commit, ShadowCode's patches and the +# pin) as a .tar.gz, published beside each Windows release. +set -euo pipefail +ARCHIVE="" +while (($#)); do + case "$1" in + --source-archive) ARCHIVE="${2:?--source-archive needs a file}"; shift 2 ;; + *) echo "Unknown argument: $1" >&2; exit 2 ;; + esac +done +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +PIN="$ROOT/tools/busybox-w32.pin" +field() { awk -F= -v key="$2" '$1 == key { print substr($0, length(key) + 2); exit }' "$1"; } +URL="$(field "$PIN" url)" +COMMIT="$(field "$PIN" commit)" +SRC="${SHADOWCODE_BUSYBOX_SRC:-$ROOT/tools/busybox-w32}" +OUT="$ROOT/packaging/busybox-w32/bin" +if [[ ! -d "$SRC/.git" ]]; then + git clone --filter=blob:none "$URL" "$SRC" +fi +git -C "$SRC" remote set-url origin "$URL" +if ! git -C "$SRC" cat-file -e "$COMMIT^{commit}" 2>/dev/null; then + git -C "$SRC" fetch --depth 1 origin "$COMMIT" +fi +git -C "$SRC" checkout -q --force --detach "$COMMIT" +# ShadowCode's patches (tools/busybox-w32-patches), applied in order. +for patch in "$ROOT"/tools/busybox-w32-patches/*.patch; do + git -C "$SRC" apply "$patch" +done +make -C "$SRC" -s mingw64_defconfig >/dev/null +make -C "$SRC" -s -j"$(nproc)" CROSS_COMPILE=x86_64-w64-mingw32- +[[ -f "$SRC/busybox.exe" ]] || { echo "busybox-w32 build produced no busybox.exe" >&2; exit 1; } +rm -rf "$OUT.new" +mkdir -p "$OUT.new" +cp "$SRC/busybox.exe" "$OUT.new/busybox.exe" +cp "$SRC/LICENSE" "$OUT.new/LICENSE" +mkdir -p "$OUT.new/patches" +cp "$ROOT"/tools/busybox-w32-patches/*.patch "$OUT.new/patches/" +printf 'url=%s\ncommit=%s\nbuilt=%s\n' "$URL" "$COMMIT" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$OUT.new/COMMIT" +rm -rf "$OUT" +mv "$OUT.new" "$OUT" +sha256sum "$OUT/busybox.exe" +if [[ -n "$ARCHIVE" ]]; then + STAGE="$(mktemp -d)" + trap 'rm -rf "$STAGE"' EXIT + NAME="busybox-w32-${COMMIT:0:12}" + mkdir -p "$STAGE/$NAME/shadowcode-patches" + git -C "$SRC" archive --format=tar "$COMMIT" | tar -x -C "$STAGE/$NAME" + cp "$ROOT"/tools/busybox-w32-patches/*.patch "$STAGE/$NAME/shadowcode-patches/" + cp "$PIN" "$STAGE/$NAME/shadowcode-patches/busybox-w32.pin" + cat > "$STAGE/$NAME/shadowcode-patches/README" < "$ARCHIVE" + sha256sum "$ARCHIVE" +fi diff --git a/scripts/build-llama.cpp.sh b/scripts/build-llama.cpp.sh index c0a2e9e0..cb639d0a 100755 --- a/scripts/build-llama.cpp.sh +++ b/scripts/build-llama.cpp.sh @@ -27,9 +27,15 @@ # 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). +# * Windows (Git Bash): the same loadable CPU variants, compiled by MSVC +# through the Visual Studio generator, without OpenMP, plus +# a Vulkan module when the Vulkan SDK is installed (VULKAN_SDK with glslc). +# Needs Visual Studio (2022 or newer) with its C++ tools, and cmake. +# +# Outputs: packaging/llama.cpp/bin (bundled into the AppImage, the deb, the +# macOS app and the Windows installer) and, unless --no-user-install is given, +# ~/.local/lib/shadowcode (macOS: ~/Library/Application Support/ShadowCode/ +# llama.cpp; Windows: %LOCALAPPDATA%\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. # @@ -46,8 +52,14 @@ PIN="$ROOT/tools/llama.cpp.pin" OUT_REPO="$ROOT/packaging/llama.cpp" DARWIN=0 [[ "$(uname -s)" == Darwin ]] && DARWIN=1 +WINDOWS=0 +case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) WINDOWS=1 ;; esac +EXE="" +[[ "$WINDOWS" == "1" ]] && EXE=".exe" if [[ "$DARWIN" == "1" ]]; then OUT_USER="${SHADOWCODE_LLAMA_PREFIX:-$HOME/Library/Application Support/ShadowCode/llama.cpp}" +elif [[ "$WINDOWS" == "1" ]]; then + OUT_USER="${SHADOWCODE_LLAMA_PREFIX:-$(cygpath -u "${LOCALAPPDATA:-$USERPROFILE/AppData/Local}")/ShadowCode/llama.cpp}" else OUT_USER="${SHADOWCODE_LLAMA_PREFIX:-$HOME/.local/lib/shadowcode}" fi @@ -84,7 +96,12 @@ if [[ -z "$CMAKE" ]]; then fi fi GENERATOR=() -if command -v ninja >/dev/null 2>&1; then +if [[ "$WINDOWS" == "1" ]]; then + # The newest Visual Studio installed (CMake's default generator here), + # with its own compiler: ggml's CPU variants use MSVC's /arch flags and + # feature macros there (clang-cl would be given the same and refuse them). + GENERATOR=(-A x64) +elif command -v ninja >/dev/null 2>&1; then GENERATOR=(-G Ninja) fi CPUS="$(nproc 2>/dev/null || sysctl -n hw.ncpu)" @@ -179,7 +196,7 @@ write_commit() { if [[ "$NOTICES_ONLY" == "1" ]]; then EXISTING="$OUT_REPO/bin/COMMIT" - [[ -x "$OUT_REPO/bin/llama-server" && -f "$EXISTING" ]] || { echo "No built runtime in $OUT_REPO/bin" >&2; exit 1; } + [[ -x "$OUT_REPO/bin/llama-server$EXE" && -f "$EXISTING" ]] || { echo "No built runtime in $OUT_REPO/bin" >&2; exit 1; } if [[ "$(field "$EXISTING" commit)" != "$COMMIT" ]]; then echo "The runtime in $OUT_REPO/bin was built from $(field "$EXISTING" commit), not the pinned $COMMIT; rebuild it." >&2 exit 1 @@ -196,6 +213,7 @@ if [[ "$NOTICES_ONLY" == "1" ]]; then fi VULKAN_FLAGS=(-DGGML_VULKAN=OFF) +EXTRA_FLAGS=() BACKEND="cpu" RPATH='$ORIGIN' if [[ "$DARWIN" == "1" ]]; then @@ -205,6 +223,17 @@ if [[ "$DARWIN" == "1" ]]; then BACKEND="metal+cpu" RPATH='@loader_path' fi +if [[ "$WINDOWS" == "1" ]]; then + # The Vulkan SDK brings the headers, loader library and glslc together. + EXTRA_FLAGS=(-DGGML_OPENMP=OFF) + if [[ "$WANT_VULKAN" == "1" && -n "${VULKAN_SDK:-}" && -x "$(cygpath -u "$VULKAN_SDK")/Bin/glslc.exe" ]]; then + VULKAN_FLAGS=(-DGGML_VULKAN=ON) + BACKEND="vulkan+cpu" + else + [[ "$WANT_VULKAN" == "1" ]] && echo "No Vulkan SDK (VULKAN_SDK with glslc); building a CPU-only runtime." >&2 + fi + WANT_VULKAN=0 +fi if [[ "$WANT_VULKAN" == "1" ]]; then GLSLC="$ROOT/tools/glslc-flatpak.sh" if ! "$GLSLC" --version >/dev/null 2>&1; then @@ -264,6 +293,7 @@ fi -DGGML_NATIVE=OFF \ -DGGML_CUDA=OFF \ "${VULKAN_FLAGS[@]}" \ + ${EXTRA_FLAGS[@]+"${EXTRA_FLAGS[@]}"} \ -DLLAMA_BUILD_TESTS=OFF \ -DLLAMA_BUILD_EXAMPLES=OFF \ -DLLAMA_BUILD_TOOLS=ON \ @@ -271,17 +301,19 @@ fi -DLLAMA_CURL=OFF \ -DCMAKE_INSTALL_RPATH="$RPATH" \ -DCMAKE_BUILD_WITH_INSTALL_RPATH=ON -"$CMAKE" --build "$BUILD_DIR" --parallel "$JOBS" +"$CMAKE" --build "$BUILD_DIR" --config Release --parallel "$JOBS" BIN_DIR="$BUILD_DIR/bin" -[[ -x "$BIN_DIR/llama-server" && -x "$BIN_DIR/llama-cli" ]] || { echo "llama.cpp build did not produce llama-cli/llama-server" >&2; exit 1; } +# The Visual Studio generator puts each configuration in its own folder. +[[ "$WINDOWS" == "1" ]] && BIN_DIR="$BUILD_DIR/bin/Release" +[[ -x "$BIN_DIR/llama-server$EXE" && -x "$BIN_DIR/llama-cli$EXE" ]] || { echo "llama.cpp build did not produce llama-cli/llama-server" >&2; exit 1; } BUILT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" install_runtime() { local dest="$1" rm -rf "$dest.new" mkdir -p "$dest.new" - install -m 755 "$BIN_DIR/llama-cli" "$dest.new/llama-cli" - install -m 755 "$BIN_DIR/llama-server" "$dest.new/llama-server" + install -m 755 "$BIN_DIR/llama-cli$EXE" "$dest.new/llama-cli$EXE" + install -m 755 "$BIN_DIR/llama-server$EXE" "$dest.new/llama-server$EXE" # Core libraries, the two tool implementations, and every loadable backend # module (libggml-cpu-*.so, libggml-vulkan.so). Other tool libraries from # the build tree (bench, quantize, perplexity, ...) are not shipped. @@ -291,7 +323,9 @@ install_runtime() { -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' \) \ + -o -name 'libggml*.dylib' -o -name 'libmtmd*.dylib' -o -name '*.metallib' \ + -o -name 'llama.dll' -o -name 'llama-common.dll' -o -name 'llama-server-impl.dll' \ + -o -name 'llama-cli-impl.dll' -o -name 'ggml*.dll' -o -name 'mtmd*.dll' \) \ -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. @@ -321,7 +355,7 @@ install_runtime "$OUT_REPO/bin" if [[ "$USER_INSTALL" == "1" ]]; then install_runtime "$OUT_USER" fi -"$OUT_REPO/bin/llama-server" --version +"$OUT_REPO/bin/llama-server$EXE" --version printf 'Built managed llama.cpp %s (%s) -> %s\n' "$COMMIT" "$BACKEND" "$OUT_REPO/bin" if [[ "$USER_INSTALL" == "1" ]]; then printf 'Installed managed llama.cpp -> %s\n' "$OUT_USER" diff --git a/scripts/install-windows.ps1 b/scripts/install-windows.ps1 new file mode 100644 index 00000000..0ac93270 --- /dev/null +++ b/scripts/install-windows.ps1 @@ -0,0 +1,96 @@ +# Install or update ShadowCode on Windows from its GitHub release. In +# PowerShell: +# +# irm https://raw.githubusercontent.com/ShadowfetchStudio/ShadowCode/main/scripts/install-windows.ps1 | iex +# +# * Downloads ShadowCode__x64-setup.exe and the release's +# ShadowCode__windows.sha256, and refuses the installer unless +# its SHA-256 matches. +# * Runs it for the current user only (no administrator rights): the app +# goes to %LOCALAPPDATA%\ShadowCode, replacing an earlier version. +# Settings stay in %APPDATA%\ShadowCode and history in +# %LOCALAPPDATA%\ShadowCode, and are kept. +# * Adds that folder to your user PATH for the shadowcode command. +# * The installer is not code-signed. Files fetched by PowerShell are not +# marked as downloaded, so Windows runs it without the SmartScreen +# prompt; an installer downloaded in a browser shows "Windows protected +# your PC" once instead (More info > Run anyway). +# +# Environment: SHADOWCODE_VERSION (default: the latest release). +# SHADOWCODE_SETUP_DIR uses an installer and checksum file from that folder +# instead of downloading them (for testing a build). +$ErrorActionPreference = 'Stop' +$Repo = 'ShadowfetchStudio/ShadowCode' +# Throw rather than exit: under `irm | iex`, exit would close the window. +function Fail([string]$Message) { throw "ShadowCode installer: $Message" } +if (-not [Environment]::Is64BitOperatingSystem) { Fail 'ShadowCode needs 64-bit Windows.' } +# Windows PowerShell 5.1 does not offer TLS 1.2 by default. +[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + +$Version = $env:SHADOWCODE_VERSION +$Local = $env:SHADOWCODE_SETUP_DIR +if (-not $Version -and $Local) { + $found = Get-ChildItem -Path $Local -Filter 'ShadowCode_*_x64-setup.exe' | Select-Object -First 1 + if ($found -and $found.Name -match '^ShadowCode_(.+)_x64-setup\.exe$') { $Version = $Matches[1] } +} +if (-not $Version) { + # The latest release's page redirects to its tag; no API token needed. + $request = [Net.HttpWebRequest]::Create("https://github.com/$Repo/releases/latest") + $request.AllowAutoRedirect = $false + $request.Method = 'HEAD' + $response = $request.GetResponse() + $location = $response.Headers['Location'] + $response.Close() + if ($location -match '/tag/v([^/]+)$') { $Version = $Matches[1] } +} +if ($Version -notmatch '^[0-9]+\.[0-9]+\.[0-9]+$') { Fail "could not tell which version to install (got '$Version')." } + +$Setup = "ShadowCode_${Version}_x64-setup.exe" +$Sums = "ShadowCode_${Version}_windows.sha256" +$Base = "https://github.com/$Repo/releases/download/v$Version" +$Work = Join-Path ([IO.Path]::GetTempPath()) ("shadowcode-install-" + [Guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $Work | Out-Null +try { + if ($Local) { + Copy-Item -LiteralPath (Join-Path $Local $Sums) -Destination $Work + Copy-Item -LiteralPath (Join-Path $Local $Setup) -Destination $Work + } else { + Write-Host "Downloading ShadowCode $Version for Windows..." + $ProgressPreference = 'SilentlyContinue' + try { Invoke-WebRequest -UseBasicParsing -Uri "$Base/$Sums" -OutFile (Join-Path $Work $Sums) } + catch { Fail "release v$Version has no Windows checksums ($Sums)." } + try { Invoke-WebRequest -UseBasicParsing -Uri "$Base/$Setup" -OutFile (Join-Path $Work $Setup) } + catch { Fail "release v$Version has no $Setup." } + } + $expected = $null + foreach ($line in Get-Content -LiteralPath (Join-Path $Work $Sums)) { + $parts = $line -split '\s+', 2 + if ($parts.Count -eq 2 -and $parts[1].TrimStart('*') -eq $Setup) { $expected = $parts[0].ToLowerInvariant() } + } + $actual = (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $Work $Setup)).Hash.ToLowerInvariant() + if ($expected -notmatch '^[0-9a-f]{64}$' -or $expected -ne $actual) { + Fail "$Setup does not match the release's checksum; nothing was installed." + } + + $App = Join-Path $env:LOCALAPPDATA 'ShadowCode' + $running = Get-Process -Name 'shadowcode' -ErrorAction SilentlyContinue | + Where-Object { $_.Path -and $_.Path.StartsWith($App, [StringComparison]::OrdinalIgnoreCase) } + if ($running) { Fail 'ShadowCode is running; close it and run the installer again.' } + + Write-Host "Installing ShadowCode $Version..." + $process = Start-Process -FilePath (Join-Path $Work $Setup) -ArgumentList '/S' -Wait -PassThru + if ($process.ExitCode -ne 0) { Fail "the installer stopped with code $($process.ExitCode)." } + $exe = Join-Path $App 'shadowcode.exe' + if (-not (Test-Path -LiteralPath $exe)) { Fail "the installer finished, but $exe is missing." } + + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + $entries = @($userPath -split ';' | Where-Object { $_ }) + if (-not ($entries | Where-Object { $_.TrimEnd('\') -ieq $App })) { + [Environment]::SetEnvironmentVariable('Path', (($entries + $App) -join ';'), 'User') + Write-Host "Added $App to your PATH; open a new terminal for the shadowcode command." + } + Write-Host "Installed ShadowCode $Version in $App." + Write-Host 'Open it from the Start menu (ShadowCode).' +} finally { + Remove-Item -LiteralPath $Work -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/scripts/publish-native-release.mjs b/scripts/publish-native-release.mjs index cc0c4c6d..b03230c9 100644 --- a/scripts/publish-native-release.mjs +++ b/scripts/publish-native-release.mjs @@ -11,11 +11,12 @@ 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 and -// release-flatpak.yml attach the macOS disk images, the Flatpak bundles and +// After publication, .github/workflows/release-macos.yml, +// release-flatpak.yml and release-windows.yml attach the macOS disk images, +// the Flatpak bundles, the Windows installer with its busybox-w32 source and // their checksums. They are outside this signed asset set: a // published-release retry leaves them alone; a draft never has them. -const ATTACHED_LATER = /^ShadowCode_\d+\.\d+\.\d+_((x64|aarch64)\.dmg|(x86_64|aarch64)\.flatpak|(macos|flatpak)\.sha256)$/; +const ATTACHED_LATER = /^ShadowCode_\d+\.\d+\.\d+_((x64|aarch64)\.dmg|(x86_64|aarch64)\.flatpak|x64-setup\.exe|busybox-w32-source\.tar\.gz|(macos|flatpak|windows)\.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 diff --git a/scripts/test-flatpak-package.mjs b/scripts/test-flatpak-package.mjs index 3ae6d267..2a5c75ce 100644 --- a/scripts/test-flatpak-package.mjs +++ b/scripts/test-flatpak-package.mjs @@ -58,7 +58,7 @@ await writeFile(path.join(profile, "config/config.yaml"), JSON.stringify({ })); let owner, lease; try { - assert.equal(await cli(["--version"]), "ShadowCode 2.2.0"); + assert.equal(await cli(["--version"]), "ShadowCode 2.3.0"); assert.equal((await cli(["health"])).runtime, "rust"); await cli(["trust"]); const exec = await cli(["exec", "printf host-command > host.txt; git --version; node --version; readlink /proc/self/ns/net"]); @@ -98,7 +98,7 @@ try { } const view = (await exchange("POST", "/api/views", null, null, true)).view; const about = await exchange("GET", "/api/about", null, view); - assert.equal(about.version, "2.2.0"); assert.equal(about.publisher, "Shadowfetch"); + assert.equal(about.version, "2.3.0"); assert.equal(about.publisher, "Shadowfetch"); assert.equal(about.install.kind, "flatpak"); assert.equal(about.links.website, "https://www.shadowfetchlinux.org/shadowcode"); assert.equal(about.links.publisher, "https://shadowfetch.com"); @@ -136,7 +136,7 @@ try { return result.stdout === "dead"; }, 5000); checks.push("persistent engine, attached view, real host PTY, shell job control, resize, Ctrl-C and descendant cleanup"); - await writeFile(path.join(output, "package-result.json"), JSON.stringify({ version: "2.2.0", profile, project, checks, terminal, terminal_child_pid: terminalChild, host_network: hostNetwork }, null, 2) + "\n"); + await writeFile(path.join(output, "package-result.json"), JSON.stringify({ version: "2.3.0", profile, project, checks, terminal, terminal_child_pid: terminalChild, host_network: hostNetwork }, null, 2) + "\n"); console.log(JSON.stringify(checks, null, 2)); } finally { if (lease) lease.destroy(); diff --git a/scripts/test-native-release-signing.mjs b/scripts/test-native-release-signing.mjs index 179a9071..d31144e3 100644 --- a/scripts/test-native-release-signing.mjs +++ b/scripts/test-native-release-signing.mjs @@ -140,10 +140,11 @@ test('release workflow separates untrusted build from protected signing and writ assert.match(publication, /artifact-ids: \$\{\{ needs.sign.outputs.artifact-id \}\}/); assert.match(publication, /needs: \[configuration, build, sign\]/); assert.match(publication, /contents: write/); assert(!/SIGNING_KEY|secrets\./.test(publication)); - // After publishing, the macOS images and Flatpak bundles are built from the tag. + // After publishing, the macOS images, Flatpak bundles and Windows installer are built from the tag. assert.match(publication, /actions: write/); assert.match(publication, /gh workflow run release-macos\.yml [^\n]*-f tag="\$GITHUB_REF_NAME"/); assert.match(publication, /gh workflow run release-flatpak\.yml [^\n]*-f tag="\$GITHUB_REF_NAME"/); + assert.match(publication, /gh workflow run release-windows\.yml [^\n]*-f tag="\$GITHUB_REF_NAME"/); assert.equal([...workflow.matchAll(/secrets\.NATIVE_RELEASE_SIGNING_KEY_PEM/g)].length, 1); assert.match(apt, /needs: \[configuration, sign, publish\]/); assert.match(apt, /environment: \$\{\{ needs.configuration.outputs.environment \}\}/); assert.match(apt, /artifact-ids: \$\{\{ needs.sign.outputs.artifact-id \}\}/); diff --git a/scripts/test-native-release.mjs b/scripts/test-native-release.mjs index 7808db46..33eb19e4 100644 --- a/scripts/test-native-release.mjs +++ b/scripts/test-native-release.mjs @@ -69,12 +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 and Flatpaks alone but a draft refuses them', () => fixture(async ({ state, execute }) => { +test('a published retry leaves the macOS images, Flatpaks and Windows files 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')); state.assets.set('ShadowCode_1.0.0_aarch64.flatpak', Buffer.from('Flatpak bundle')); state.assets.set('ShadowCode_1.0.0_flatpak.sha256', Buffer.from('checksums')); + state.assets.set('ShadowCode_1.0.0_x64-setup.exe', Buffer.from('Windows installer')); + state.assets.set('ShadowCode_1.0.0_busybox-w32-source.tar.gz', Buffer.from('source')); + state.assets.set('ShadowCode_1.0.0_windows.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; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index e8dee551..22a222bf 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -334,6 +334,18 @@ fn request_shutdown(app: &tauri::AppHandle) { } fn main() { + // Windows: this executable is also the sandbox launcher (the command + // that follows the flag runs in ShadowCode's AppContainer). + #[cfg(windows)] + if std::env::args_os().nth(1).as_deref() + == Some(std::ffi::OsStr::new( + shadowcode_core::sandbox::appcontainer::LAUNCH_FLAG, + )) + { + std::process::exit(shadowcode_core::sandbox::appcontainer::launch_main( + std::env::args_os().skip(2).collect(), + )); + } if let Err(error) = run() { eprintln!("ShadowCode: {error:#}"); std::process::exit(1); @@ -593,7 +605,6 @@ fn run() -> Result<()> { } } }); - #[cfg(unix)] { let handle = app.handle().clone(); tauri::async_runtime::spawn(async move { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 6a576f93..816c66d0 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ShadowCode", - "version": "2.2.0", + "version": "2.3.0", "identifier": "com.shadowfetch.shadowcode", "build": { "beforeDevCommand": { diff --git a/src-tauri/tauri.windows.conf.json b/src-tauri/tauri.windows.conf.json new file mode 100644 index 00000000..11acde91 --- /dev/null +++ b/src-tauri/tauri.windows.conf.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "bundle": { + "icon": [ + "../assets/icons/shadow-agent.ico", + "../assets/icons/shadow-agent-32.png", + "../assets/icons/shadow-agent-128.png", + "../assets/icons/shadow-agent-256.png", + "../assets/icons/shadow-agent-512.png" + ], + "targets": [ + "nsis" + ], + "windows": { + "nsis": { + "installMode": "currentUser" + }, + "webviewInstallMode": { + "type": "downloadBootstrapper", + "silent": true + } + } + } +} diff --git a/tools/busybox-w32-patches/0001-null-device-in-the-sandbox.patch b/tools/busybox-w32-patches/0001-null-device-in-the-sandbox.patch new file mode 100644 index 00000000..58df882e --- /dev/null +++ b/tools/busybox-w32-patches/0001-null-device-in-the-sandbox.patch @@ -0,0 +1,63 @@ +diff --git a/win32/mingw.c b/win32/mingw.c +index 56fe22651..7dd05c75c 100644 +--- a/win32/mingw.c ++++ b/win32/mingw.c +@@ -224,6 +224,31 @@ static int get_dev_fd(const char *filename) + } + + static int mingw_is_directory(const char *path); ++ ++/* ++ * ShadowCode's Windows sandbox (an AppContainer) may not open the null ++ * device. Its launcher opens one outside the sandbox and names the ++ * inherited handle in SHADOWCODE_NULL_HANDLE; a copy of that handle ++ * behaves exactly as "nul" would. ++ */ ++static int sandbox_null_fd(int oflags) ++{ ++ const char *value = getenv("SHADOWCODE_NULL_HANDLE"); ++ HANDLE copy; ++ int fd; ++ ++ if (!value || !*value) ++ return -1; ++ if (!DuplicateHandle(GetCurrentProcess(), ++ (HANDLE)(intptr_t)strtoull(value, NULL, 10), ++ GetCurrentProcess(), ©, 0, ++ (oflags & O_NOINHERIT) ? FALSE : TRUE, DUPLICATE_SAME_ACCESS)) ++ return -1; ++ fd = _open_osfhandle((intptr_t)copy, oflags & (O_APPEND | O_RDONLY | O_TEXT)); ++ if (fd < 0) ++ CloseHandle(copy); ++ return fd; ++} + #undef open + int mingw_open (const char *filename, int oflags, ...) + { +@@ -259,6 +284,13 @@ int mingw_open (const char *filename, int oflags, ...) + ((mode & S_IRUSR) ? _S_IREAD : 0); + + fd = open(filename, oflags&~O_SPECIAL, pmode); ++ if (fd < 0 && errno == EACCES && strcmp(filename, "nul") == 0) { ++ int null_fd = sandbox_null_fd(oflags); ++ if (null_fd >= 0) ++ fd = null_fd; ++ else ++ errno = EACCES; ++ } + if (fd >= 0) { + update_special_fd(dev, fd); + } +@@ -311,6 +343,12 @@ FILE * FAST_FUNC mingw_fopen (const char *filename, const char *otype) + } else if ((fd=get_dev_fd(filename)) >= 0) + return fdopen(fd, otype); + stream = fopen(filename, otype); ++ if (stream == NULL && errno == EACCES && strcmp(filename, "nul") == 0 && ++ (fd = sandbox_null_fd(strchr(otype, 'a') ? O_APPEND : 0)) >= 0) { ++ if ((stream = fdopen(fd, otype)) == NULL) ++ close(fd); ++ return stream; ++ } + if (stream == NULL && errno == EACCES && strcmp(otype, "r") == 0 && + mingw_is_directory(filename)) + errno = EISDIR; diff --git a/tools/busybox-w32.pin b/tools/busybox-w32.pin new file mode 100644 index 00000000..33edb579 --- /dev/null +++ b/tools/busybox-w32.pin @@ -0,0 +1,3 @@ +url=https://github.com/rmyorston/busybox-w32.git +commit=169694ebdba292f6533f78aeea678bfbb907396c +tag=FRP-6075-g169694ebd diff --git a/ui/e2e/fakeBackend.ts b/ui/e2e/fakeBackend.ts index b50c6c8b..bc17aa51 100644 --- a/ui/e2e/fakeBackend.ts +++ b/ui/e2e/fakeBackend.ts @@ -3027,6 +3027,8 @@ export function installFakeBackend(options: FakeOptions = {}) { home_read_only: ["/home/dev/.cargo"], home_skipped: [], never_mounted: [".ssh", ".config"], + // A test may report another platform (e.g. Windows). + ...(state.sandboxStatus || {}), }; if (path === "/api/workspace/status") return { diff --git a/ui/package-lock.json b/ui/package-lock.json index d185dd38..ca53ee47 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "shadow-agent-ui", - "version": "2.2.0", + "version": "2.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "shadow-agent-ui", - "version": "2.2.0", + "version": "2.3.0", "license": "Apache-2.0", "dependencies": { "@codemirror/commands": "6.11.1", diff --git a/ui/package.json b/ui/package.json index 29bb8a9e..13f4f719 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,6 +1,6 @@ { "name": "shadow-agent-ui", - "version": "2.2.0", + "version": "2.3.0", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/ui/src/api.ts b/ui/src/api.ts index 88168659..9213d34b 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -1379,11 +1379,19 @@ export type StartCompareRequest = { /** GET /api/sandbox/status: shell isolation this computer provides. */ export type SandboxStatus = { - platform?: "linux" | "macos"; - effective: "bubblewrap" | "seatbelt" | "landlock" | "none" | "blocked"; + platform?: "linux" | "macos" | "windows"; + effective: + | "bubblewrap" + | "seatbelt" + | "appcontainer" + | "landlock" + | "none" + | "blocked"; bubblewrap: { installed: boolean; works: boolean; detail: string }; /** macOS only. */ seatbelt?: { works: boolean; detail: string }; + /** Windows only. */ + appcontainer?: { works: boolean; detail: string }; landlock_abi: number; network_namespace: { available: boolean; detail: string }; require: boolean; diff --git a/ui/src/components/settings/SandboxSettings.tsx b/ui/src/components/settings/SandboxSettings.tsx index a79d84a4..f40dcb4d 100644 --- a/ui/src/components/settings/SandboxSettings.tsx +++ b/ui/src/components/settings/SandboxSettings.tsx @@ -44,7 +44,10 @@ export function sandboxPatch(values: SandboxValues) { function statusLine(status: SandboxStatus): string { const mac = status.platform === "macos"; + const windows = status.platform === "windows"; switch (status.effective) { + case "appcontainer": + return "Sandbox active (Windows): commands run in a contained shell that reads Windows, Program Files and read-only toolchains, cannot see the rest of your home folder, and writes only to this project."; case "bubblewrap": return "Sandbox active (bubblewrap): commands see an empty home folder with read-only toolchains, and only this project is writable."; case "seatbelt": @@ -52,6 +55,11 @@ function statusLine(status: SandboxStatus): string { case "landlock": return "bubblewrap is unavailable. Commands can fall back to Landlock file limits; network and process isolation are incomplete. Turn on Require sandbox to refuse fallback."; case "blocked": + if (windows) { + return status.shell_network === "allowlist" + ? "Only allowed hosts is not available on Windows, so shell commands will be refused. Choose No network or Full network." + : "The Windows sandbox is not available, so shell commands will be refused. Turn off Require sandbox to run them unsandboxed."; + } if (mac) { return "The macOS sandbox is not available, so shell commands will be refused. Turn off Require sandbox and the host allow-list to run them unsandboxed."; } @@ -59,27 +67,35 @@ function statusLine(status: SandboxStatus): string { ? "The host allow-list needs private network namespaces, which this system does not allow. Shell commands will be refused." : "bubblewrap is not available, so shell commands will be refused. Install bubblewrap, or turn off Require sandbox and the host allow-list."; default: + if (windows) { + return "The Windows sandbox is not available: commands run with your user's full access."; + } return mac ? "The macOS sandbox is not available: commands run with your user's full access." : "No sandbox is available: commands run with your user's full access. Install bubblewrap to isolate them."; } } -function networkOptions(mac: boolean): [ShellNetwork, string, string][] { +function networkOptions( + platform: SandboxStatus["platform"], +): [ShellNetwork, string, string][] { + const linux = platform !== "macos" && platform !== "windows"; return [ [ "off", "No network", - mac - ? "No network access at all, not even this computer's own services." - : "Request no network access. Full isolation needs working bubblewrap.", + linux + ? "Request no network access. Full isolation needs working bubblewrap." + : "No network access at all, not even this computer's own services.", ], [ "allowlist", "Only allowed hosts", - mac - ? "Web requests go through a filter that lets only the hosts below through." - : "Web requests go through a filter that lets only the hosts below through. Needs bubblewrap.", + platform === "windows" + ? "Not available on Windows: shell commands are refused while this is chosen." + : linux + ? "Web requests go through a filter that lets only the hosts below through. Needs bubblewrap." + : "Web requests go through a filter that lets only the hosts below through.", ], ["on", "Full network", "Approved shell commands can reach any host."], ]; @@ -124,10 +140,14 @@ export function SandboxSettings({ onChange={(e) => onChange({ ...values, require: e.target.checked })} />{" "} Require sandbox: refuse shell commands when{" "} - {status?.platform === "macos" ? "the macOS sandbox" : "bubblewrap"} is - unavailable + {status?.platform === "macos" + ? "the macOS sandbox" + : status?.platform === "windows" + ? "the Windows sandbox" + : "bubblewrap"}{" "} + is unavailable - {networkOptions(status?.platform === "macos").map(([id, label, hint]) => ( + {networkOptions(status?.platform).map(([id, label, hint]) => (