diff --git a/.github/workflows/native-service.yml b/.github/workflows/native-service.yml index bf4875750..a9952ab06 100644 --- a/.github/workflows/native-service.yml +++ b/.github/workflows/native-service.yml @@ -116,6 +116,13 @@ jobs: uv venv .native-venv uv pip install --python .native-venv\Scripts\python.exe "powercontext[cli,server] @ $wheelUri" pytest + - name: Verify binary cursor persistence and restart compatibility + shell: pwsh + run: >- + .native-venv\Scripts\python.exe -m pytest -q + tests/test_cursor_secret.py + tests/test_server.py::test_server_reuses_file_backed_cursor_secret_across_restarts + - name: Exercise the real Task Scheduler lifecycle shell: pwsh run: >- @@ -127,6 +134,13 @@ jobs: if: failure() shell: pwsh run: | + Get-ChildItem -LiteralPath "$env:RUNNER_TEMP\powercontext-native-tests" -Recurse -File -ErrorAction SilentlyContinue | + Where-Object { $_.Name -in @('server.stdout.log', 'server.stderr.log') } | + ForEach-Object { + Write-Output "::group::$($_.FullName)" + Get-Content -LiteralPath $_.FullName -Tail 100 + Write-Output "::endgroup::" + } schtasks.exe /Query /TN "$env:POWERCONTEXT_NATIVE_SERVICE_IDENTIFIER" /XML /HRESULT if ($LASTEXITCODE -ne 0) { exit 0 } schtasks.exe /Query /TN "$env:POWERCONTEXT_NATIVE_SERVICE_IDENTIFIER" /FO LIST /V /HRESULT diff --git a/src/powercontext/server/cursor_secret.py b/src/powercontext/server/cursor_secret.py index 66310ae28..be8815fff 100644 --- a/src/powercontext/server/cursor_secret.py +++ b/src/powercontext/server/cursor_secret.py @@ -53,7 +53,9 @@ def _load_or_create(path: Path) -> bytes: generated = secrets.token_bytes(_CURSOR_SECRET_BYTES) try: - descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + # Windows text descriptors translate LF bytes even when using os.write. + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0) + descriptor = os.open(path, flags, 0o600) except FileExistsError: return _read(path) try: diff --git a/tests/test_cursor_secret.py b/tests/test_cursor_secret.py index 613813b37..74988d447 100644 --- a/tests/test_cursor_secret.py +++ b/tests/test_cursor_secret.py @@ -14,8 +14,10 @@ from __future__ import annotations +import os import stat +import pytest from pydantic import SecretStr from powercontext.builtin.persistence.oceanbase import OceanBaseConfig @@ -23,7 +25,16 @@ from powercontext.server.cursor_secret import resolve_cursor_secret -def test_file_backed_sqlite_reuses_private_cursor_secret(tmp_path) -> None: +@pytest.fixture +def generated_secret(monkeypatch) -> bytes: + # Include LF and other control bytes so Windows text translation cannot hide + # behind a randomly generated key that happens not to contain a newline. + secret = bytes(range(32)) + monkeypatch.setattr("powercontext.server.cursor_secret.secrets.token_bytes", lambda size: secret[:size]) + return secret + + +def test_file_backed_sqlite_reuses_private_cursor_secret(tmp_path, generated_secret) -> None: database_path = tmp_path / "powercontext.db" config = SQLiteConfig(url=f"sqlite+aiosqlite:///{database_path}") @@ -31,9 +42,10 @@ def test_file_backed_sqlite_reuses_private_cursor_secret(tmp_path) -> None: second = resolve_cursor_secret(config, None) key_path = tmp_path / ".powercontext.db.cursor-key" - assert first == second - assert first is not None and len(first) == 32 - assert stat.S_IMODE(key_path.stat().st_mode) == 0o600 + assert first == second == generated_secret + assert key_path.read_bytes() == generated_secret + if os.name != "nt": + assert stat.S_IMODE(key_path.stat().st_mode) == 0o600 def test_explicit_cursor_secret_overrides_database_storage(tmp_path) -> None: @@ -46,13 +58,17 @@ def test_explicit_cursor_secret_overrides_database_storage(tmp_path) -> None: assert not (tmp_path / ".powercontext.db.cursor-key").exists() -def test_remote_database_without_override_reuses_local_persisted_secret(tmp_path, monkeypatch) -> None: +def test_remote_database_without_override_reuses_local_persisted_secret( + tmp_path, monkeypatch, generated_secret +) -> None: monkeypatch.setenv("POWERCONTEXT_HOME", str(tmp_path)) config = OceanBaseConfig(url=SecretStr("mysql+aoceanbase://user:password@127.0.0.1:2881/test?charset=utf8mb4")) first = resolve_cursor_secret(config, None) second = resolve_cursor_secret(config, None) - assert first == second - assert first is not None and len(first) == 32 - assert stat.S_IMODE((tmp_path / "cursor-signing.key").stat().st_mode) == 0o600 + key_path = tmp_path / "cursor-signing.key" + assert first == second == generated_secret + assert key_path.read_bytes() == generated_secret + if os.name != "nt": + assert stat.S_IMODE(key_path.stat().st_mode) == 0o600 diff --git a/tests/test_server.py b/tests/test_server.py index 88bfc84b4..75916df8e 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -180,7 +180,8 @@ def test_server_settings_configure_default_project_skill_targets(tmp_path, monke assert all(target.allow_managed_publish for target in settings.external_skills.targets) -def test_server_reuses_file_backed_cursor_secret_across_restarts(tmp_path) -> None: +def test_server_reuses_file_backed_cursor_secret_across_restarts(tmp_path, monkeypatch) -> None: + monkeypatch.setattr("powercontext.server.cursor_secret.secrets.token_bytes", lambda size: b"\n" * size) database = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'runtime.db'}") settings = ServerSettings( database=database,