Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/native-service.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: >-
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion src/powercontext/server/cursor_secret.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
32 changes: 24 additions & 8 deletions tests/test_cursor_secret.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,26 +14,38 @@

from __future__ import annotations

import os
import stat

import pytest
from pydantic import SecretStr

from powercontext.builtin.persistence.oceanbase import OceanBaseConfig
from powercontext.builtin.persistence.sqlite import SQLiteConfig
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}")

first = resolve_cursor_secret(config, 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:
Expand All @@ -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
3 changes: 2 additions & 1 deletion tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading