test(#3664): Windows pytest-harness batch #3 (#4274) (#4276)

rodboev test-only #4274 (test_issue609, test_sprint3, + 2 others). Linux
assertions stay at full strictness. 63 tests pass. No version stamp (tests-only).
Note: #4273 was NOT included — it touches api/workspace.py runtime (home-expansion
in the blocked-roots boundary), needs a separate full gate.

Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
This commit is contained in:
nesquena-hermes 2026-06-15 15:57:18 -07:00 committed by GitHub
parent 1bf2ef9f5e
commit 40591f146f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 53 additions and 26 deletions

View file

@ -5,7 +5,7 @@
### Internal
- **Windows pytest-harness compatibility (#3664).** Hardened the test suite to run on Windows: profile-home fallback paths are path-normalized, strict POSIX file-mode (`0o600`) assertions are gated behind `os.name != "nt"` (Linux still asserts them at full strictness), the conftest cleanup handles Windows process-tree/port teardown and the Py3.12+ `shutil.rmtree` `onexc` shim, and tests that require `fork`/`fcntl` carry `@requires_fork` / `@requires_fcntl` markers (which never skip on Linux). Test-only — no runtime or app behavior change, no Linux CI behavior change. (#4254, #4255, #4256, #4257, #4259, #4263, #4266)
- **Windows pytest-harness compatibility (#3664).** Hardened the test suite to run on Windows: profile-home fallback paths are path-normalized, strict POSIX file-mode (`0o600`) assertions are gated behind `os.name != "nt"` (Linux still asserts them at full strictness), the conftest cleanup handles Windows process-tree/port teardown and the Py3.12+ `shutil.rmtree` `onexc` shim, and tests that require `fork`/`fcntl` carry `@requires_fork` / `@requires_fcntl` markers (which never skip on Linux). Test-only — no runtime or app behavior change, no Linux CI behavior change. (#4254, #4255, #4256, #4257, #4259, #4263, #4266, #4274)
## [v0.51.445] — 2026-06-15 — Release PF (scope gateway watcher to the active profile)

View file

@ -274,8 +274,7 @@ class TestFilePathEndpointBehaviour:
body, status = _post("/api/file/path", {"session_id": sid, "path": "."})
assert status == 200, body
assert body.get("ok") is True
# Path should be absolute (starts with /).
assert body.get("path", "").startswith("/"), body
assert Path(body.get("path", "")).is_absolute(), body
def test_does_not_404_on_missing_file(self):
"""Copy-path on a stale-but-recently-deleted file must still

View file

@ -1,6 +1,7 @@
import json
import subprocess
import time
from pathlib import Path
from types import SimpleNamespace
import pytest
@ -15,6 +16,7 @@ def _isolate_sessions(tmp_path, monkeypatch):
session_dir.mkdir()
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
monkeypatch.setenv("GIT_CEILING_DIRECTORIES", str(tmp_path.parent.resolve()))
SESSIONS.clear()
yield session_dir
SESSIONS.clear()
@ -32,7 +34,7 @@ def test_worktree_metadata_round_trips_through_session_file(_isolate_sessions):
s.save()
raw = json.loads(s.path.read_text(encoding="utf-8"))
assert raw["worktree_path"].endswith(".worktrees/hermes-1234")
assert Path(raw["worktree_path"]).as_posix().endswith(".worktrees/hermes-1234")
assert raw["worktree_branch"] == "hermes/hermes-1234"
assert raw["worktree_repo_root"].endswith("repo")
assert raw["worktree_created_at"] == 123.5
@ -133,7 +135,7 @@ def test_create_worktree_for_workspace_calls_agent_setup_with_repo_root(tmp_path
info = worktrees.create_worktree_for_workspace(nested)
assert seen["repo_root"] == str(repo.resolve())
assert info["path"].endswith(".worktrees/hermes-test")
assert Path(info["path"]).as_posix().endswith(".worktrees/hermes-test")
assert info["branch"] == "hermes/hermes-test"
assert info["repo_root"] == str(repo.resolve())
assert info["created_at"] >= now

View file

@ -61,10 +61,13 @@ def test_path_outside_boot_default_and_home_is_rejected(monkeypatch, tmp_path):
boot_default = tmp_path / "data" / "workspace"
boot_default.mkdir(parents=True)
home = tmp_path / "home"
home.mkdir()
outside = tmp_path / "other_mount" / "secret"
outside.mkdir(parents=True)
monkeypatch.setattr(ws_mod, "_BOOT_DEFAULT_WORKSPACE", str(boot_default))
monkeypatch.setattr(Path, "home", classmethod(lambda cls: home))
with pytest.raises(ValueError, match="outside the user home"):
resolve_trusted_workspace(str(outside))
@ -95,10 +98,13 @@ def test_path_traversal_via_dotdot_does_not_escape_boot_default(monkeypatch, tmp
boot_default = tmp_path / "data" / "workspace"
boot_default.mkdir(parents=True)
home = tmp_path / "home"
home.mkdir()
sibling = tmp_path / "data" / "private"
sibling.mkdir(parents=True)
monkeypatch.setattr(ws_mod, "_BOOT_DEFAULT_WORKSPACE", str(boot_default))
monkeypatch.setattr(Path, "home", classmethod(lambda cls: home))
# `boot_default/../private` resolves to `tmp_path/data/private`, which is
# NOT a child of boot_default and not under home — must reject.

View file

@ -1,8 +1,11 @@
"""Sprint 3 tests: cron API, skills API, memory API, input validation."""
import json, uuid, urllib.request, urllib.error
import json, pathlib, shutil, tempfile, urllib.request, urllib.error
from tests._pytest_port import BASE
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
def get(path):
with urllib.request.urlopen(BASE + path, timeout=10) as r:
return json.loads(r.read()), r.status
@ -16,6 +19,15 @@ def post(path, body=None):
except urllib.error.HTTPError as e:
return json.loads(e.read()), e.code
def make_outside_trusted_dir(prefix):
home_root = pathlib.Path.home().resolve()
temp_root = pathlib.Path(tempfile.gettempdir()).resolve()
base_root = temp_root if temp_root != home_root and home_root not in (temp_root, *temp_root.parents) else REPO_ROOT
outside_root = base_root / ".tmp-outside-trusted"
outside_root.mkdir(exist_ok=True)
return pathlib.Path(tempfile.mkdtemp(prefix=f"{prefix}-", dir=outside_root))
def make_session_tracked(created_list, ws=None):
"""Create a session and register it with the cleanup fixture."""
import pathlib as _pathlib
@ -208,21 +220,25 @@ def test_session_update_unknown_id_returns_404():
def test_session_update_rejects_workspace_outside_trusted_root(tmp_path):
d, _ = post("/api/session/new", {})
sid = d["session"]["session_id"]
outside = tmp_path / "outside"
outside.mkdir(parents=True, exist_ok=True)
result, status = post("/api/session/update", {"session_id": sid, "workspace": str(outside)})
assert status == 400
assert "outside" in result.get("error", "").lower()
outside = make_outside_trusted_dir("outside")
try:
result, status = post("/api/session/update", {"session_id": sid, "workspace": str(outside)})
assert status == 400
assert "outside" in result.get("error", "").lower()
finally:
shutil.rmtree(outside, ignore_errors=True)
def test_chat_start_rejects_workspace_outside_trusted_root(tmp_path):
d, _ = post("/api/session/new", {})
sid = d["session"]["session_id"]
outside = tmp_path / "outside-chat"
outside.mkdir(parents=True, exist_ok=True)
result, status = post("/api/chat/start", {"session_id": sid, "message": "hello", "workspace": str(outside)})
assert status == 400
assert "outside" in result.get("error", "").lower()
outside = make_outside_trusted_dir("outside-chat")
try:
result, status = post("/api/chat/start", {"session_id": sid, "message": "hello", "workspace": str(outside)})
assert status == 400
assert "outside" in result.get("error", "").lower()
finally:
shutil.rmtree(outside, ignore_errors=True)
def test_workspace_add_allows_external_valid_paths(tmp_path):
@ -251,19 +267,23 @@ def test_legacy_chat_rejects_workspace_outside_trusted_root(tmp_path):
"""Legacy /api/chat must use the same trusted workspace validation as /api/chat/start."""
d, _ = post("/api/session/new", {})
sid = d["session"]["session_id"]
outside = tmp_path / "outside-legacy-chat"
outside.mkdir(parents=True, exist_ok=True)
result, status = post("/api/chat", {"session_id": sid, "message": "hello", "workspace": str(outside)})
assert status == 400
assert "outside" in result.get("error", "").lower()
outside = make_outside_trusted_dir("outside-legacy-chat")
try:
result, status = post("/api/chat", {"session_id": sid, "message": "hello", "workspace": str(outside)})
assert status == 400
assert "outside" in result.get("error", "").lower()
finally:
shutil.rmtree(outside, ignore_errors=True)
def test_session_new_rejects_workspace_outside_trusted_root(tmp_path):
outside = tmp_path / "outside-new"
outside.mkdir(parents=True, exist_ok=True)
result, status = post("/api/session/new", {"workspace": str(outside)})
assert status == 400
assert "outside" in result.get("error", "").lower()
outside = make_outside_trusted_dir("outside-new")
try:
result, status = post("/api/session/new", {"workspace": str(outside)})
assert status == 400
assert "outside" in result.get("error", "").lower()
finally:
shutil.rmtree(outside, ignore_errors=True)
def test_session_search_returns_matches(cleanup_test_sessions):