Skip to content

Commit 3d580ef

Browse files
feat: add Windows support (#44)
Co-authored-by: agent-of-mkmeral <agent-of-mkmeral@users.noreply.github.com>
1 parent 2d5d5cb commit 3d580ef

5 files changed

Lines changed: 72 additions & 28 deletions

File tree

.github/workflows/ci.yml

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ jobs:
1616
strategy:
1717
fail-fast: false
1818
matrix:
19-
os: [ubuntu-latest, macos-latest]
19+
os: [ubuntu-latest, macos-latest, windows-latest]
2020
runs-on: ${{ matrix.os }}
2121
steps:
2222
- uses: actions/checkout@v6
@@ -43,7 +43,7 @@ jobs:
4343
strategy:
4444
fail-fast: false
4545
matrix:
46-
os: [ubuntu-latest, macos-latest]
46+
os: [ubuntu-latest, macos-latest, windows-latest]
4747
python: ["3.10", "3.11", "3.12", "3.13", "3.14"]
4848
runs-on: ${{ matrix.os }}
4949
steps:
@@ -62,17 +62,32 @@ jobs:
6262
with:
6363
python-version: ${{ matrix.python }}
6464

65+
# Create the venv and put its bin/Scripts dir on PATH so the
66+
# subsequent steps work identically on Unix (.venv/bin) and
67+
# Windows (.venv/Scripts).
6568
- name: Create virtualenv
66-
run: python -m venv .venv
69+
shell: bash
70+
run: |
71+
python -m venv .venv
72+
# GITHUB_PATH entries must be native paths. In Git Bash on Windows
73+
# $PWD is an MSYS path (/d/a/shell/shell) that the runner's Windows
74+
# PATH cannot resolve, so the venv would be silently ignored and
75+
# later steps would fall back to the host interpreter. Convert to a
76+
# native Windows path with cygpath so .venv\Scripts is actually used.
77+
if [ "$RUNNER_OS" == "Windows" ]; then
78+
cygpath -w "$PWD/.venv/Scripts" >> "$GITHUB_PATH"
79+
else
80+
echo "$PWD/.venv/bin" >> "$GITHUB_PATH"
81+
fi
6782
6883
- name: Install build deps
69-
run: .venv/bin/pip install maturin pytest
84+
run: pip install maturin pytest
7085

7186
- name: Build and install wheel
72-
run: .venv/bin/maturin develop --release
87+
run: maturin develop --release
7388

7489
- name: pytest
75-
run: .venv/bin/pytest tests/python -v
90+
run: pytest tests/python -v
7691

7792
audit:
7893
name: Security audit
@@ -115,7 +130,7 @@ jobs:
115130
strategy:
116131
fail-fast: false
117132
matrix:
118-
os: [ubuntu-latest, macos-latest]
133+
os: [ubuntu-latest, macos-latest, windows-latest]
119134
node: ["20", "22", "24"]
120135
runs-on: ${{ matrix.os }}
121136
steps:
@@ -134,6 +149,15 @@ jobs:
134149
with:
135150
node-version: ${{ matrix.node }}
136151

152+
# The package.json build scripts use `$(npm run --silent host-triple)`
153+
# command substitution. npm runs script bodies with its configured
154+
# script-shell, which defaults to cmd.exe on Windows (no `$(...)`
155+
# support). Point it at the Git Bash that ships on the windows-latest
156+
# runner so the substitution works identically across platforms.
157+
- name: Use bash as npm script-shell (Windows)
158+
if: runner.os == 'Windows'
159+
run: npm config set script-shell bash
160+
137161
- name: npm install
138162
# package-lock.json is gitignored, so `npm ci` can't run; use install.
139163
run: npm install

.github/workflows/release.yml

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ jobs:
6060
- os: ubuntu-latest
6161
target: aarch64-unknown-linux-gnu
6262
manylinux: "2_28"
63+
- os: windows-latest
64+
target: x86_64-pc-windows-msvc
6365
runs-on: ${{ matrix.os }}
6466
steps:
6567
- uses: actions/checkout@v6
@@ -158,7 +160,7 @@ jobs:
158160
set -euo pipefail
159161
echo "Dists:"; ls -1 dist/
160162
missing=()
161-
for target in aarch64-apple-darwin x86_64-apple-darwin x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu; do
163+
for target in aarch64-apple-darwin x86_64-apple-darwin x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu x86_64-pc-windows-msvc; do
162164
# Maturin encodes the target in the wheel filename's platform tag:
163165
# aarch64-apple-darwin → macosx_*_arm64
164166
# x86_64-apple-darwin → macosx_*_x86_64
@@ -169,6 +171,7 @@ jobs:
169171
x86_64-apple-darwin) pattern="macosx_*_x86_64" ;;
170172
x86_64-unknown-linux-gnu) pattern="manylinux_*_x86_64" ;;
171173
aarch64-unknown-linux-gnu) pattern="manylinux_*_aarch64" ;;
174+
x86_64-pc-windows-msvc) pattern="win_amd64" ;;
172175
esac
173176
if ! ls dist/*${pattern}*.whl >/dev/null 2>&1; then
174177
missing+=("$target")
@@ -228,6 +231,8 @@ jobs:
228231
target: x86_64-unknown-linux-gnu
229232
- os: ubuntu-latest
230233
target: aarch64-unknown-linux-gnu
234+
- os: windows-latest
235+
target: x86_64-pc-windows-msvc
231236
runs-on: ${{ matrix.os }}
232237
steps:
233238
- uses: actions/checkout@v6
@@ -285,9 +290,9 @@ jobs:
285290
native.js
286291
native.d.ts
287292
288-
# ── Pack all 5 npm packages (fan-in from the per-platform builds) ─────────
293+
# ── Pack all 6 npm packages (fan-in from the per-platform builds) ─────────
289294
# Assembles the exact publishable set — the main @strands-agents/shell package
290-
# plus the 4 per-platform packages — using the napi-rs v3 release flow, then
295+
# plus the 5 per-platform packages — using the napi-rs v3 release flow, then
291296
# packs each to a .tgz and uploads them. Download the `npm-packages` artifact
292297
# to install/test the real tarballs locally before any publish happens.
293298
node-pack:
@@ -346,18 +351,18 @@ jobs:
346351
for d in npm/*/; do (cd "$d" && npm pack --pack-destination "$GITHUB_WORKSPACE/dist-npm"); done
347352
348353
- name: Verify the packaged set matches the publish matrix
349-
# Guard against drift: the publish stage uses a static matrix of the 4
350-
# platform packages (+ the main package = 5 total). If package.json's
354+
# Guard against drift: the publish stage uses a static matrix of the 5
355+
# platform packages (+ the main package = 6 total). If package.json's
351356
# napi.targets gains/loses a target, the packed count changes here and
352357
# this fails the run BEFORE anything is published — prompting whoever
353358
# changed the targets to update node-publish-platform's matrix to match.
354359
run: |
355360
set -euo pipefail
356-
expected=5
361+
expected=6
357362
actual=$(ls dist-npm/*.tgz | wc -l | tr -d ' ')
358363
echo "Packed tarballs ($actual):"; ls -1 dist-npm/*.tgz
359364
if [ "$actual" -ne "$expected" ]; then
360-
echo "::error::Expected $expected npm packages (1 main + 4 platform) but packed $actual. If napi.targets changed, update node-publish-platform's matrix to match."
365+
echo "::error::Expected $expected npm packages (1 main + 5 platform) but packed $actual. If napi.targets changed, update node-publish-platform's matrix to match."
361366
exit 1
362367
fi
363368
@@ -374,7 +379,7 @@ jobs:
374379
name: npm-packages
375380
path: dist-npm/*.tgz
376381

377-
# ── Publish the 4 per-platform packages (one independently retriable job each)
382+
# ── Publish the 5 per-platform packages (one independently retriable job each)
378383
# Each leg publishes exactly one tarball that node-inspect packed + uploaded,
379384
# so a flaky single platform can be re-run on its own via "Re-run failed jobs"
380385
# without touching the others. The matrix is static — its entries must match
@@ -401,6 +406,7 @@ jobs:
401406
- strands-agents-shell-darwin-arm64
402407
- strands-agents-shell-linux-x64-gnu
403408
- strands-agents-shell-linux-arm64-gnu
409+
- strands-agents-shell-win32-x64-msvc
404410
env:
405411
V: ${{ needs.version.outputs.version }}
406412
steps:

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@
2424
"x86_64-apple-darwin",
2525
"aarch64-apple-darwin",
2626
"x86_64-unknown-linux-gnu",
27-
"aarch64-unknown-linux-gnu"
27+
"aarch64-unknown-linux-gnu",
28+
"x86_64-pc-windows-msvc"
2829
]
2930
},
3031
"engines": {

src/vfs_kernel.rs

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -121,20 +121,27 @@ impl VfsKernel {
121121
canon_base: &std::path::Path,
122122
) -> io::Result<Fd> {
123123
if flags.read && !flags.write {
124-
// Open the file, then verify via /proc/self/fd that the
125-
// opened fd still points within the bind mount. This
126-
// eliminates the TOCTOU between canonicalize and read.
127124
let file = std::fs::File::open(host_path)?;
128-
use std::os::unix::io::AsRawFd;
129-
let fd_path = format!("/proc/self/fd/{}", file.as_raw_fd());
130-
if let Ok(real) = std::fs::read_link(&fd_path)
131-
&& !real.starts_with(canon_base)
125+
126+
// Defense-in-depth TOCTOU re-check (Linux-only): /proc/self/fd has
127+
// no portable equivalent, and the canonical-path check above is the
128+
// primary guard, so this extra layer is simply skipped elsewhere.
129+
#[cfg(target_os = "linux")]
132130
{
133-
return Err(io::Error::new(
134-
io::ErrorKind::PermissionDenied,
135-
"access denied: path escaped bind mount",
136-
));
131+
use std::os::unix::io::AsRawFd;
132+
let fd_path = format!("/proc/self/fd/{}", file.as_raw_fd());
133+
if let Ok(real) = std::fs::read_link(&fd_path)
134+
&& !real.starts_with(canon_base)
135+
{
136+
return Err(io::Error::new(
137+
io::ErrorKind::PermissionDenied,
138+
"access denied: path escaped bind mount",
139+
));
140+
}
137141
}
142+
#[cfg(not(target_os = "linux"))]
143+
let _ = canon_base; // only consumed by the Linux-only check above
144+
138145
use std::io::Read;
139146
let mut data = Vec::new();
140147
let mut file = file;

tests/shell_integration.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4277,6 +4277,9 @@ fn builder_bind_direct_readonly_host() {
42774277
}));
42784278
}
42794279

4280+
// Uses std::os::unix::fs::symlink — symlink creation differs on Windows
4281+
// (requires elevated privileges), so this escape test is Unix-only.
4282+
#[cfg(unix)]
42804283
#[test]
42814284
fn bind_direct_symlink_escape_blocked() {
42824285
let (rt, local) = rt();
@@ -5349,7 +5352,7 @@ umask = "077"
53495352
53505353
[[bind]]
53515354
mode = "copy"
5352-
source = "{}"
5355+
source = '{}'
53535356
destination = "/workspace"
53545357
readonly = true
53555358
@@ -7381,6 +7384,9 @@ expect!(cmd_sleep_zero, "command sleep 0 && echo ok", "ok");
73817384

73827385
// ── dangling symlink escape prevention ──────────────────────────────
73837386

7387+
// Uses std::os::unix::fs::symlink — symlink creation differs on Windows
7388+
// (requires elevated privileges), so this escape test is Unix-only.
7389+
#[cfg(unix)]
73847390
#[test]
73857391
fn bind_direct_dangling_symlink_blocked() {
73867392
let (rt, local) = rt();

0 commit comments

Comments
 (0)