From 6afd2f4c0391911bb5a5e5dd9c225332fa506f00 Mon Sep 17 00:00:00 2001 From: Kai <300677314+kai-openswarm@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:55:18 -0700 Subject: [PATCH 1/4] tests: the backend suite is portable to Windows The suite had never run on Windows in CI, and a first run there failed twelve tests for reasons that have nothing to do with the code under test: - charmap: three tests read fixture/source files with the platform default encoding (test_browser_provider_agnostic x2, test_skill_replay_vs_send_script) -> read as utf-8, which is what the files are - POSIX-only mechanisms asserted as facts: the shutdown fuse (a documented no-op on Windows), SIGCONT in the ghost reaper, st_mode bits in the credential store -> skipif os.name == 'nt' with the reason spelled out - test_browser_login_handoff pointed at '/nonexistent-dir-xyz' to get an unwritable path; Windows can create it -> a path under a regular file, which is unwritable everywhere - test_the_mac_branch_does_not_try_gcm trusted the host's IS_WIN; on Windows the GCM branch runs (correctly) -> the test pins the flag it means to test Two of the failures were real portability defects in the code, not the tests: list_skill_files and check_publish_capability reported os.path.relpath paths, so on Windows the API answered backslash paths that the frontend joins with '/' and that the SKILL.md-first ordering compares as strings. Both now report posix relative paths on every OS. Ubuntu behaviour is unchanged (the touched tests and the consumers of the two code paths: 72 passed). --- backend/apps/outputs/publish_capability.py | 2 +- backend/apps/skills/skills.py | 3 ++- backend/tests/test_browser_cookies_windows.py | 4 +++- backend/tests/test_browser_login_handoff.py | 7 +++++-- backend/tests/test_browser_provider_agnostic.py | 4 ++-- backend/tests/test_credential_store.py | 1 + backend/tests/test_reap_ghost_runtimes.py | 3 +++ backend/tests/test_shutdown_fuse_disarms.py | 7 +++++++ backend/tests/test_skill_replay_vs_send_script.py | 2 +- 9 files changed, 25 insertions(+), 8 deletions(-) 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/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_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_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_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_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" From 68642da4bfb3f25675a4c0cb96779ef402167b54 Mon Sep 17 00:00:00 2001 From: Kai <300677314+kai-openswarm@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:04:23 -0700 Subject: [PATCH 2/4] agents: the current_time prompt pin renders on Windows too compose_turn_system_prompt built the block with strftime('%-d') and strftime('%-I'). Those are glibc extensions; on Windows the C runtime raises ValueError("Invalid format string"), the surrounding except swallowed it, and the pin was silently dropped, so on Windows the agent had no notion of "now" (the very thing the block exists to provide). Format the day/hour without the non-portable codes. test_system_prompt now asserts the pin carries a real date and time, which is what fails on Windows without this. --- .../agents/manager/prompt/compose_turn_system_prompt.py | 7 +++++-- backend/tests/test_system_prompt.py | 6 ++++++ 2 files changed, 11 insertions(+), 2 deletions(-) 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/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(): From acec9302a692b48c27ff13aea84bf88103c5d9a9 Mon Sep 17 00:00:00 2001 From: Kai <300677314+kai-openswarm@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:24:31 -0700 Subject: [PATCH 3/4] tests: five more Windows-portability fixes for the backend suite Named by running the suite on windows-latest with the first batch applied: - swarm skill export (backend/apps/swarm/entities/skills.py): supporting-file keys are built with os.path.relpath, so a bundle exported on Windows carried "scripts\go.py" and the importer, which joins the key onto the destination, would recreate a file literally named that on macOS/Linux. Normalise to "/"; test_skills_folders asserted exactly this. - test_app_export_no_stale_files: the fixture wrote the workspace files in text mode, so on Windows the export bytes were CRLF and the byte comparison failed; write with newline="\n". - test_bundled_extracted_modules: the .bin/vite launch shim was a symlink with a forward-slash relative target, which Windows cannot resolve (exists() is False). A plain file is what npm writes there and is what the check reads. - test_disk_resilience: the directory-fsync test guarded on hasattr(os, "O_RDONLY"), which Windows has; json_store skips the directory fsync on Windows by design, so skip the test there. - test_browser_metrics: POSIX mode bits are asserted on POSIX only. --- backend/apps/swarm/entities/skills.py | 4 +++- backend/tests/test_app_export_no_stale_files.py | 7 ++++--- backend/tests/test_browser_metrics.py | 7 ++++--- backend/tests/test_bundled_extracted_modules.py | 5 ++++- backend/tests/test_disk_resilience.py | 8 +++++--- 5 files changed, 20 insertions(+), 11 deletions(-) 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_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_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_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 = [] From 3dd891c040cc9e0b95762e41f01d6c335e964d0b Mon Sep 17 00:00:00 2001 From: Kai <300677314+kai-openswarm@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:26:56 -0700 Subject: [PATCH 4/4] tests: test_service keeps the settings temp file beside the settings file The autouse fixture pointed SETTINGS_FILE at tmp_path but left the store's DATA_DIR at the real data dir. atomic_write_settings stages its temp file in DATA_DIR and os.replace()s it onto SETTINGS_FILE, so on a runner where the repo checkout and the temp dir sit on different drives the replace fails with "[WinError 17] The system cannot move the file to a different disk drive"; p_get_install_id swallows that and falls back to an unpersisted id, and test_install_id_persisted fails on the KeyError. Redirect DATA_DIR alongside SETTINGS_FILE, as production keeps them, which also stops the suite from dropping temp files into backend/data. --- backend/tests/test_service.py | 7 +++++++ 1 file changed, 7 insertions(+) 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)