diff --git a/backend/apps/agents/manager/prompt/compose_turn_system_prompt.py b/backend/apps/agents/manager/prompt/compose_turn_system_prompt.py index 4c949788e..51afac498 100644 --- a/backend/apps/agents/manager/prompt/compose_turn_system_prompt.py +++ b/backend/apps/agents/manager/prompt/compose_turn_system_prompt.py @@ -59,10 +59,13 @@ def compose_turn_system_prompt( tz_name = tz_name or "UTC" now_local = datetime.now(ZoneInfo(tz_name)) tz_abbr = now_local.strftime("%Z") or tz_name + # No `%-d` / `%-I`: those are glibc extensions. On Windows strftime raises ValueError on + # them, the except below swallowed it, and the agent shipped without a clock on that OS. + hour = now_local.strftime("%I:%M %p").lstrip("0") time_ctx = ( "\n" - f"Today is {now_local.strftime('%A, %B %-d, %Y')}.\n" - f"Local time: {now_local.strftime('%-I:%M %p')} {tz_abbr} ({tz_name}).\n" + f"Today is {now_local.strftime('%A, %B')} {now_local.day}, {now_local.year}.\n" + f"Local time: {hour} {tz_abbr} ({tz_name}).\n" "Use this as ground truth for any date/time/day-of-week question. The timezone also " "gives the user's coarse region; when they say 'here' or 'near me' without a place, " "infer the likely city from it (say you inferred it) instead of claiming you can't know.\n" diff --git a/backend/apps/outputs/publish_capability.py b/backend/apps/outputs/publish_capability.py index eee884777..5ec326d9a 100644 --- a/backend/apps/outputs/publish_capability.py +++ b/backend/apps/outputs/publish_capability.py @@ -69,7 +69,7 @@ def p_api_callers(root: str) -> List[str]: continue with open(full, "r", encoding="utf-8", errors="replace") as fh: if P_API_CALL.search(fh.read()): - hits.append(os.path.relpath(full, root)) + hits.append(os.path.relpath(full, root).replace(os.sep, "/")) except OSError: continue return sorted(hits) diff --git a/backend/apps/skills/skills.py b/backend/apps/skills/skills.py index 878252fe8..ca3872c20 100644 --- a/backend/apps/skills/skills.py +++ b/backend/apps/skills/skills.py @@ -503,7 +503,8 @@ async def list_skill_files(skill_id: str): dirs[:] = [d for d in dirs if not d.startswith(".")] for n in sorted(names): path = os.path.join(root, n) - rel = os.path.relpath(path, base_abs) + # API paths are posix on every OS: the frontend joins them with "/" and the SKILL.md-first sort compares them as strings. + rel = os.path.relpath(path, base_abs).replace(os.sep, "/") if n.startswith(".") or os.path.getsize(path) > 512_000: continue try: diff --git a/backend/apps/swarm/entities/skills.py b/backend/apps/swarm/entities/skills.py index f8134a3cf..161156e7b 100644 --- a/backend/apps/swarm/entities/skills.py +++ b/backend/apps/swarm/entities/skills.py @@ -100,7 +100,9 @@ def p_read_supporting_files(skill_dir: str) -> dict[str, bytes]: for root, p_dirs, names in os.walk(skill_dir): for n in names: full = os.path.join(root, n) - rel = os.path.relpath(full, skill_dir) + # Forward slashes: these keys travel inside .swarm bundles, and the importer joins them + # onto the destination on whatever OS opens the bundle. + rel = os.path.relpath(full, skill_dir).replace(os.sep, "/") if rel == "SKILL.md" or n.startswith("."): continue try: diff --git a/backend/tests/test_app_export_no_stale_files.py b/backend/tests/test_app_export_no_stale_files.py index 00aeb247c..90e457fdc 100644 --- a/backend/tests/test_app_export_no_stale_files.py +++ b/backend/tests/test_app_export_no_stale_files.py @@ -34,7 +34,7 @@ def p_make_app(ws_root, *, workspace: bool) -> str: if wsid: folder = ws_root / wsid folder.mkdir() - (folder / "app.py").write_text("print('v1')\n") + (folder / "app.py").write_text("print('v1')\n", newline="\n") o = Output( name="Demo", description="", icon="view_quilt", input_schema={"type": "object", "properties": {}, "required": []}, @@ -50,8 +50,9 @@ def test_workspace_app_export_omits_stale_inline_files(p_ws_root): o = workspace_io.load_output(oid) folder = p_ws_root / o.workspace_id # agent edit -> v2 on disk: modify app.py + add new.py (output.files stays v1) - (folder / "app.py").write_text("print('v2 EDITED')\n") - (folder / "new.py").write_text("print('v2 NEW')\n") + # newline="\n": the export is compared byte-for-byte below, and text mode would write CRLF on Windows. + (folder / "app.py").write_text("print('v2 EDITED')\n", newline="\n") + (folder / "new.py").write_text("print('v2 NEW')\n", newline="\n") exp = AppExportable.load(oid) payload = exp.serialize(P_Ctx()) diff --git a/backend/tests/test_browser_cookies_windows.py b/backend/tests/test_browser_cookies_windows.py index b275d94e0..576bc2229 100644 --- a/backend/tests/test_browser_cookies_windows.py +++ b/backend/tests/test_browser_cookies_windows.py @@ -64,8 +64,10 @@ def test_truncated_ciphertext_degrades(on_windows): assert bc.decrypt_cookie_value(b"v10" + NONCE, KEY) is None -def test_the_mac_branch_does_not_try_gcm(): +def test_the_mac_branch_does_not_try_gcm(monkeypatch): """Same bytes, no Windows flag: the CBC branch must not accidentally accept a GCM blob.""" + # Pin the flag rather than the host: on a Windows runner IS_WIN is naturally True and the GCM branch would (correctly) decrypt. + monkeypatch.setattr(bc, "IS_WIN", False) assert bc.decrypt_cookie_value(p_win_blob(b"sessionid=abc123"), KEY) is None diff --git a/backend/tests/test_browser_login_handoff.py b/backend/tests/test_browser_login_handoff.py index 81a34c1e1..11a2b5479 100644 --- a/backend/tests/test_browser_login_handoff.py +++ b/backend/tests/test_browser_login_handoff.py @@ -47,9 +47,12 @@ def test_first_seen_preserved_last_login_advances(): assert second["last_login"] >= first["last_login"] -def test_record_login_is_fail_open(monkeypatch): +def test_record_login_is_fail_open(monkeypatch, tmp_path): # an unwritable path must not raise; the run just treats it as a fresh sign-in next time - monkeypatch.setattr(h, "P_STORE_PATH", "/nonexistent-dir-xyz/authenticated_domains.json") + # (a path UNDER a regular file is unwritable on every OS; a bare "/nonexistent-dir" is creatable on Windows) + blocker = tmp_path / "not-a-dir" + blocker.write_text("x", encoding="utf-8") + monkeypatch.setattr(h, "P_STORE_PATH", str(blocker / "authenticated_domains.json")) h.record_login("x.com") # no exception assert h.is_authenticated("x.com") is False diff --git a/backend/tests/test_browser_metrics.py b/backend/tests/test_browser_metrics.py index c02d06873..43cc72fbc 100644 --- a/backend/tests/test_browser_metrics.py +++ b/backend/tests/test_browser_metrics.py @@ -119,6 +119,7 @@ def test_task_secrets_are_scrubbed_from_tasks_jsonl(tmp_path, monkeypatch): line = open(p_os.path.join(str(tmp_path), "tasks.jsonl")).read() assert "hunter2" not in line and "sk-abc" not in line assert "password [redacted]" in line - # owner-only file perms - mode = p_os.stat(p_os.path.join(str(tmp_path), "tasks.jsonl")).st_mode & 0o777 - assert mode == 0o600 + # owner-only file perms (POSIX mode bits; Windows has no equivalent to assert on) + if p_os.name == "posix": + mode = p_os.stat(p_os.path.join(str(tmp_path), "tasks.jsonl")).st_mode & 0o777 + assert mode == 0o600 diff --git a/backend/tests/test_browser_provider_agnostic.py b/backend/tests/test_browser_provider_agnostic.py index 0ee14a9df..44ce9f1a6 100644 --- a/backend/tests/test_browser_provider_agnostic.py +++ b/backend/tests/test_browser_provider_agnostic.py @@ -27,7 +27,7 @@ def p_model_ids_in_code(path: pathlib.Path): """Every concrete model id appearing in a STRING literal, with its line number.""" hits = [] - with path.open() as f: + with path.open(encoding="utf-8") as f: for tok in tokenize.generate_tokens(f.readline): if tok.type != tokenize.STRING: continue @@ -74,7 +74,7 @@ def test_every_aux_call_asks_for_a_tier(): call_re = re.compile(r"preferred_tier\s*=\s*\"([^\"]+)\"") bad = [] for path in sorted(P_BROWSER_DIR.glob("*.py")): - for i, line in enumerate(path.read_text().splitlines(), 1): + for i, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): for m in call_re.finditer(line): if m.group(1) not in ("haiku", "sonnet", "opus"): bad.append(f"{path.name}:{i} -> {m.group(1)}") diff --git a/backend/tests/test_bundled_extracted_modules.py b/backend/tests/test_bundled_extracted_modules.py index 21d6ed21b..ae04ea5c4 100644 --- a/backend/tests/test_bundled_extracted_modules.py +++ b/backend/tests/test_bundled_extracted_modules.py @@ -42,7 +42,10 @@ def test_warm_cache_is_complete_requires_the_sentinel(tmp_path): assert vt.warm_cache_is_complete(str(nm)) is False bindir = nm / ".bin" bindir.mkdir() - (bindir / "vite").symlink_to("../vite/bin/vite.js") + # A plain file, not a symlink: on Windows a relative symlink target with forward slashes does not + # resolve (exists() is False), and npm writes .bin shims as files there anyway. The check under test + # is os.path.exists, which the two are equivalent for. + (bindir / "vite").write_text("#!/usr/bin/env node\n") assert vt.warm_cache_is_complete(str(nm)) is False (tmp_path / ".install-complete").write_text("digest") assert vt.warm_cache_is_complete(str(nm)) is True diff --git a/backend/tests/test_credential_store.py b/backend/tests/test_credential_store.py index df305bdfa..c32960ec4 100644 --- a/backend/tests/test_credential_store.py +++ b/backend/tests/test_credential_store.py @@ -121,6 +121,7 @@ async def test_restoring_a_refresh_token_round_trips(p_router): @pytest.mark.asyncio +@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits; Windows ACLs do not surface through st_mode") async def test_written_db_is_owner_only(p_router): await store.apply_to_connection("conn-1", changes={}, drop=["refreshToken"]) mode = stat.S_IMODE(os.stat(store.db_path()).st_mode) diff --git a/backend/tests/test_disk_resilience.py b/backend/tests/test_disk_resilience.py index e94c2b023..9cb973359 100644 --- a/backend/tests/test_disk_resilience.py +++ b/backend/tests/test_disk_resilience.py @@ -79,9 +79,11 @@ def tracking_replace(src, dst): def test_atomic_write_fsyncs_directory_after_rename(tmp_path, monkeypatch): - # POSIX only: the rename is only durable once the parent dir is fsync'd. - if not hasattr(os, "O_RDONLY"): - pytest.skip("directory fsync not applicable on this platform") + # POSIX only: the rename is only durable once the parent dir is fsync'd. Windows exposes + # os.O_RDONLY but cannot open a directory with it, and json_store skips the dir fsync there + # by design, so the old hasattr guard never fired where it was needed. + if os.name == "nt": + pytest.skip("directory fsync is not possible on Windows; json_store skips it there") from backend.config import json_store fsync_targets = [] diff --git a/backend/tests/test_reap_ghost_runtimes.py b/backend/tests/test_reap_ghost_runtimes.py index ec9647fa6..560087d52 100644 --- a/backend/tests/test_reap_ghost_runtimes.py +++ b/backend/tests/test_reap_ghost_runtimes.py @@ -7,6 +7,8 @@ import os from unittest.mock import patch +import pytest + from backend.apps.outputs import reap_ghost_runtimes as mod @@ -163,6 +165,7 @@ class P_Out: assert rg.find_ghost_runtime_pids() == [], "a live backend's own app runtime must never be killed" +@pytest.mark.skipif(os.name == "nt", reason="SIGSTOP/SIGCONT freezing is POSIX-only; the reaper never pauses runtimes on Windows") def test_a_frozen_ghost_is_thawed_before_being_signalled(monkeypatch): """Idle app runtimes are parked with SIGSTOP, and a STOPPED process never handles SIGTERM: it queues it and lives forever. Found live as a frozen `bash run.sh` that had survived every reap diff --git a/backend/tests/test_service.py b/backend/tests/test_service.py index bc9085fbe..9e443c197 100644 --- a/backend/tests/test_service.py +++ b/backend/tests/test_service.py @@ -36,9 +36,16 @@ def patch_settings(tmp_path): })) import backend.apps.settings.store as settings_mod old = settings_mod.SETTINGS_FILE + old_dir = settings_mod.DATA_DIR settings_mod.SETTINGS_FILE = str(sf) + # The atomic writer stages its temp file in DATA_DIR and os.replace()s it onto SETTINGS_FILE, so + # the two must live together as they do in production. Left at the real data dir, the temp file + # lands in the repo's backend/data and, on a runner where that is another drive than tmp_path, + # the replace fails with WinError 17 (and the writer's caller quietly falls back). + settings_mod.DATA_DIR = str(tmp_path) yield settings_mod.SETTINGS_FILE = old + settings_mod.DATA_DIR = old_dir @pytest.fixture(autouse=True) diff --git a/backend/tests/test_shutdown_fuse_disarms.py b/backend/tests/test_shutdown_fuse_disarms.py index f81e4eef8..342527500 100644 --- a/backend/tests/test_shutdown_fuse_disarms.py +++ b/backend/tests/test_shutdown_fuse_disarms.py @@ -9,8 +9,15 @@ skip: a green-looking run that never executed. """ +import os + +import pytest + from backend.apps.service.shutdown_fuse import arm_shutdown_fuse, disarm_shutdown_fuse, fuse_armed +# The fuse walks the process tree with pgrep and is deliberately a no-op on Windows (arm returns without a timer), so there is nothing to stand down there. +pytestmark = pytest.mark.skipif(os.name == "nt", reason="the shutdown fuse is POSIX-only; arm is a no-op on Windows") + def test_arming_then_disarming_leaves_no_live_timer() -> None: arm_shutdown_fuse() diff --git a/backend/tests/test_skill_replay_vs_send_script.py b/backend/tests/test_skill_replay_vs_send_script.py index d3f142c71..21797ed3a 100644 --- a/backend/tests/test_skill_replay_vs_send_script.py +++ b/backend/tests/test_skill_replay_vs_send_script.py @@ -61,7 +61,7 @@ def test_the_agent_uses_the_predicate_rather_than_reinventing_the_condition(): """The whole point of extracting it. If someone re-inlines the check, these tests would keep passing while the live path regressed, which is exactly how this bug survived the first time.""" src = (bs.__file__).replace("browser_skills.py", "browser_agent.py") - with open(src) as f: + with open(src, encoding="utf-8") as f: text = f.read() assert "browser_skills.replay_owns_nav(" in text assert "p_skip_prestage_for_skill = bool(" not in text, "the condition was re-inlined" diff --git a/backend/tests/test_system_prompt.py b/backend/tests/test_system_prompt.py index 2b431f768..58a1e5293 100644 --- a/backend/tests/test_system_prompt.py +++ b/backend/tests/test_system_prompt.py @@ -3,6 +3,8 @@ App Builder skill. The context builders are mocked to None so the test is deterministic and doesn't depend on dashboard/tool disk state.""" +import re + import pytest from unittest.mock import patch @@ -26,6 +28,10 @@ def test_base_composition_includes_default_and_time_pin(): out = p_compose(session) assert "You are a helpful agent." in out assert "" in out # the wall-clock pin is always appended + # The pin carries a real date and time on every OS (the block is built with portable strftime + # codes; a formatting error would be swallowed and the whole pin would vanish). + assert re.search(r"Today is [A-Z][a-z]+, [A-Z][a-z]+ [1-9]\d?, \d{4}\.", out), out + assert re.search(r"Local time: [1-9]\d?:\d{2} [AP]M ", out), out def test_view_builder_appends_live_skill_block():