diff --git a/.github/workflows/native-service.yml b/.github/workflows/native-service.yml index 46837ca89..bf4875750 100644 --- a/.github/workflows/native-service.yml +++ b/.github/workflows/native-service.yml @@ -94,3 +94,47 @@ jobs: name: macos-native-service-logs path: ${{ runner.temp }}/powercontext-native-tests/**/logs/* if-no-files-found: ignore + + windows-task-scheduler: + runs-on: windows-latest + env: + POWERCONTEXT_RUN_NATIVE_SERVICE_TESTS: "1" + POWERCONTEXT_NATIVE_SERVICE_IDENTIFIER: PowerContext-Native-${{ github.run_id }}-${{ github.run_attempt }} + steps: + - name: Check out + uses: actions/checkout@v7 + + - name: Set up the environment + uses: ./.github/actions/setup-python-env + + - name: Install the built distribution in a fresh virtual environment + shell: pwsh + run: | + uv build --wheel + $wheel = (Get-ChildItem dist\*.whl | Select-Object -First 1).FullName + $wheelUri = "file:///" + $wheel.Replace('\', '/') + uv venv .native-venv + uv pip install --python .native-venv\Scripts\python.exe "powercontext[cli,server] @ $wheelUri" pytest + + - name: Exercise the real Task Scheduler lifecycle + shell: pwsh + run: >- + .native-venv\Scripts\python.exe -m pytest -q + --basetemp "$env:RUNNER_TEMP\powercontext-native-tests" + tests/native/test_personal_service_lifecycle.py + + - name: Capture Task Scheduler diagnostics after failure + if: failure() + shell: pwsh + run: | + 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 + + - name: Upload Windows native-service logs after failure + if: failure() + uses: actions/upload-artifact@v7 + with: + name: windows-native-service-logs + path: ${{ runner.temp }}/powercontext-native-tests/**/logs/* + if-no-files-found: ignore diff --git a/README.md b/README.md index f89525bc6..9ba33c7db 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,17 @@ uv tool install "powercontext[cli,server]==0.1.0" # uv tool install "powercontext[cli,server] @ git+https://github.com/oceanbase/powercontext.git@master" ``` -Start a local Server in its own terminal: +For a persistent personal Server that survives terminal closure and can start again after login, install the native current-user service: + +```bash +powercontext service install # Uninstall with `powercontext service uninstall` +powercontext service status +``` + +On Windows, the installer asks whether to enable startup at the next login when no login option is supplied; pressing +Enter keeps it disabled. Use `--start-on-login` or `--no-start-on-login` to choose explicitly. + +If you want to use it foreground in a terminal, you can run: ```bash powercontext server run diff --git a/README_CN.md b/README_CN.md index 64a74a834..8e47b4c7c 100644 --- a/README_CN.md +++ b/README_CN.md @@ -33,7 +33,17 @@ uv tool install "powercontext[cli,server]==0.1.0" # uv tool install "powercontext[cli,server] @ git+https://github.com/oceanbase/powercontext.git@master" ``` -在单独的终端中启动本地 Server: +如需让个人 Server 在终端关闭后继续运行,并可以在下次登录后再次启动,请安装原生当前用户服务: + +```bash +powercontext service install # 卸载请运行 `powercontext service uninstall` +powercontext service status +``` + +在 Windows 上,未提供登录启动选项时,安装器会询问是否在下次登录时自动启动;直接按 Enter 的默认选择是不启用。 +需要显式选择时,请使用 `--start-on-login` 或 `--no-start-on-login`。 + +如果想在终端以前台方式使用,可以运行: ```bash powercontext server run diff --git a/README_JP.md b/README_JP.md index dfe260cc1..3da0cafd8 100644 --- a/README_JP.md +++ b/README_JP.md @@ -33,7 +33,18 @@ uv tool install "powercontext[cli,server]==0.1.0" # uv tool install "powercontext[cli,server] @ git+https://github.com/oceanbase/powercontext.git@master" ``` -別のターミナルでローカル Server を起動します: +ターミナルを閉じても動作を続け、次回ログイン時に再び起動できる個人用 Server を実行するには、現在のユーザー用 +ネイティブサービスをインストールします: + +```bash +powercontext service install # Uninstall with `powercontext service uninstall` +powercontext service status +``` + +Windows では、ログイン起動のオプションを指定しない場合、次回ログイン時の自動起動を有効にするか確認します。 +Enter を押すと既定では無効のままです。明示的に選ぶ場合は `--start-on-login` または `--no-start-on-login` を指定します。 + +ターミナルでフォアグラウンド実行する場合は、次のコマンドを使用できます: ```bash powercontext server run diff --git a/docs/en/docs/how-to/deploy-server.md b/docs/en/docs/how-to/deploy-server.md index b4c449ded..a1e7ee16e 100644 --- a/docs/en/docs/how-to/deploy-server.md +++ b/docs/en/docs/how-to/deploy-server.md @@ -5,7 +5,7 @@ description: Run PowerContext with persistent data, health checks, authenticatio # Deploy the Server -`powercontext server run` is a foreground process. On a personal macOS or Linux workstation, PowerContext can register +`powercontext server run` is a foreground process. On a personal macOS, Linux, or Windows workstation, PowerContext can register that same Server runner with the native current-user service manager. Managed deployments should continue to use a container platform or an administrator-owned service manager. @@ -18,9 +18,11 @@ powercontext service install powercontext service status ``` -Linux uses `systemd --user` and writes logs to the user journal. macOS uses a per-user LaunchAgent and writes stdout -and stderr below the PowerContext user data directory. `service status` reports the exact log selector or path. The -installer never requests administrator privileges and accepts only a loopback Server bind. +On Windows, the command asks whether to enable startup at the current user's next login when neither +`--start-on-login` nor `--no-start-on-login` is supplied; pressing Enter keeps login auto-start disabled. Use either +option for a non-interactive choice. + +Linux uses `systemd --user` and writes logs to the user journal. macOS uses a per-user LaunchAgent, and Windows uses a current-user Task Scheduler task; both write stdout and stderr below the PowerContext user data directory. `service status` reports the exact log selector or path. For an explicit Server configuration, protect the environment file before installing: @@ -30,7 +32,14 @@ powercontext config validate --env-file /path/to/powercontext.env powercontext service install --env-file /path/to/powercontext.env ``` -The native definition stores only the absolute file path and non-content file identity metadata; it does not copy +On Windows, remove inherited access and grant the file only to the current user, `SYSTEM`, and local `Administrators` before validation, for example: + +```powershell +icacls $env:USERPROFILE\powercontext.env /inheritance:r /grant:r "$env:USERNAME:(F)" "SYSTEM:(F)" "Administrators:(F)" +``` + +The native definition stores only the absolute file path and non-content file identity metadata. On Windows this +includes the current user's owner SID, which is revalidated whenever the launcher starts. It does not copy credentials or the caller's shell environment. Re-run `service install` after upgrading PowerContext or changing the environment file. Remove the registration without deleting Server data or logs with: diff --git a/docs/en/docs/how-to/install-and-run.md b/docs/en/docs/how-to/install-and-run.md index 9ec07c483..4d22deee7 100644 --- a/docs/en/docs/how-to/install-and-run.md +++ b/docs/en/docs/how-to/install-and-run.md @@ -11,7 +11,7 @@ Server startup, seekDB, diagnostics, and updates for readers who already know wh ## Install the application -You need Python 3.11 or newer, Git, and [`uv`](https://docs.astral.sh/uv/) on macOS or Linux. Then install +You need Python 3.11 or newer, Git, and [`uv`](https://docs.astral.sh/uv/) on macOS, Linux, or Windows. Then install PowerContext directly from a Git ref: ```bash diff --git a/docs/en/docs/reference/configuration.md b/docs/en/docs/reference/configuration.md index bc8f34d4f..40e40b0f3 100644 --- a/docs/en/docs/reference/configuration.md +++ b/docs/en/docs/reference/configuration.md @@ -29,7 +29,8 @@ export POWERCONTEXT_HOME=/srv/powercontext Without an override, the default is: - Linux: `$XDG_DATA_HOME/powercontext`, or `~/.local/share/powercontext`; -- macOS: `~/Library/Application Support/powercontext`. +- macOS: `~/Library/Application Support/powercontext`; +- Windows: `%LOCALAPPDATA%\\powercontext`. The default SQLite database is `powercontext.db` in this directory. Scheduled processing uses `scheduler.db` in the same directory. diff --git a/docs/en/rfcs/1299_local_server_availability_and_service_installation.md b/docs/en/rfcs/1299_local_server_availability_and_service_installation.md index 03d4e070b..7e060ecf4 100644 --- a/docs/en/rfcs/1299_local_server_availability_and_service_installation.md +++ b/docs/en/rfcs/1299_local_server_availability_and_service_installation.md @@ -173,7 +173,7 @@ lifecycle commands. The initial distribution contract is: ```text -powercontext service install +powercontext service install [--start-on-login | --no-start-on-login] powercontext service uninstall powercontext service status ``` @@ -193,7 +193,9 @@ Install performs these steps: endpoint with an invalid response is a conflict and fails before native state changes. 5. Render and validate an artifact containing the fixed ownership marker, package version, definition version, intended endpoint, and launcher command. -6. Create or update only PowerContext's personal Server registration and enable it for future user logins. +6. Create or update only PowerContext's personal Server registration. On Windows, when neither login option is + supplied, ask whether to add the current-user login trigger; the prompt defaults to no. The explicit + `--start-on-login` and `--no-start-on-login` options select the behavior without prompting. 7. Start it immediately by default unless step 4 found an already-live PowerContext Server. 8. Report registration, definition, native manager, liveness, and log-location facts after the operation. @@ -275,8 +277,9 @@ current-user domain, configures explicit PowerContext-owned per-user stdout and ### Windows -The Windows adapter is a `Task Scheduler` task triggered when the current user logs on. It runs as that user and never -as `SYSTEM`. A hidden process window is acceptable. The launcher redirects Server output to explicit +The Windows adapter is a `Task Scheduler` task triggered when login auto-start is selected. It runs as that user and +never as `SYSTEM`; when login auto-start is disabled, it has no login trigger. A hidden process window is acceptable. +The launcher redirects Server output to explicit PowerContext-owned per-user log files because Task Scheduler history is not Server stdout or stderr. The adapter does not install a Windows Service. @@ -287,7 +290,9 @@ rendering and ownership tests. ## Configuration and credentials -The service installer records the executable, required arguments, and non-secret service metadata. It does not copy +The service installer records the executable, required arguments, and non-secret service metadata. For a Windows +environment file, that metadata includes the current user's owner SID and the launcher revalidates it on every start. +It does not copy the caller's complete environment, shell profile, API keys, bearer tokens, or provider credentials into a native registration artifact. diff --git a/docs/zh/docs/how-to/deploy-server.md b/docs/zh/docs/how-to/deploy-server.md index e4e2f1eb3..9f71cdc22 100644 --- a/docs/zh/docs/how-to/deploy-server.md +++ b/docs/zh/docs/how-to/deploy-server.md @@ -5,8 +5,7 @@ description: 使用持久化数据、健康检查、鉴权和安全网络边界 # 部署 Server -`powercontext server run` 是前台进程。在个人 macOS 或 Linux 工作站上,PowerContext 可以把同一个 Server runner 注册到 -原生当前用户服务管理器。托管部署仍应使用容器平台或管理员拥有的服务管理器。 +`powercontext server run` 是前台进程。在个人 macOS、Linux 或 Windows 工作站上,PowerContext 可以把同一个 Server runner 注册到原生当前用户服务管理器。托管部署仍应使用容器平台或管理员拥有的服务管理器。 ## 运行持久个人 Server @@ -17,9 +16,12 @@ powercontext service install powercontext service status ``` -Linux 使用 `systemd --user`,日志进入 user journal;macOS 使用当前用户 LaunchAgent,stdout 和 stderr 写入 -PowerContext 用户数据目录。`service status` 会返回精确的日志 selector 或路径。安装器不请求管理员权限,并且只接受 -loopback Server bind。 +Linux 使用 `systemd --user`,日志进入 user journal;macOS 使用当前用户 LaunchAgent;Windows 使用当前用户的 Task Scheduler task。macOS 和 Windows 的 stdout、stderr 写入 PowerContext 用户数据目录。 + +`service status` 会返回精确的日志 selector 或路径。 + +在 Windows 上,如果没有提供 `--start-on-login` 或 `--no-start-on-login`,命令会询问是否在当前用户下次登录时 +自动启动;直接按 Enter 的默认选择是不启用。需要非交互选择时,请提供其中一个选项。 使用显式 Server 配置时,先保护并验证环境文件: @@ -29,7 +31,14 @@ powercontext config validate --env-file /path/to/powercontext.env powercontext service install --env-file /path/to/powercontext.env ``` -原生定义只记录环境文件的绝对路径和不含内容的文件 identity metadata,不复制 credential 或调用者的 shell environment。 +在 Windows 上,校验前需要移除继承权限,只授予当前用户、`SYSTEM` 和本机 `Administrators` 访问权限,例如: + +```powershell +icacls $env:USERPROFILE\powercontext.env /inheritance:r /grant:r "$env:USERNAME:(F)" "SYSTEM:(F)" "Administrators:(F)" +``` + +原生定义只记录环境文件的绝对路径和不含内容的文件 identity metadata;在 Windows 上还记录当前用户的 owner SID, +launcher 每次启动都会重新校验它。不复制 credential 或调用者的 shell environment。 升级 PowerContext 或修改环境文件后应重新执行 `service install`。以下命令会删除注册,但保留 Server 数据和日志: ```bash diff --git a/docs/zh/docs/how-to/install-and-run.md b/docs/zh/docs/how-to/install-and-run.md index 8f193ee2d..f562f793b 100644 --- a/docs/zh/docs/how-to/install-and-run.md +++ b/docs/zh/docs/how-to/install-and-run.md @@ -11,7 +11,7 @@ description: 从 Git 安装 PowerContext,并运行本地 Server。 ## 安装应用 -需要在 macOS 或 Linux 上准备 Python 3.11 或更新版本、Git 和 +需要在 macOS、Linux 或 Windows 上准备 Python 3.11 或更新版本、Git 和 [`uv`](https://docs.astral.sh/uv/),然后从指定 Git ref 直接安装 PowerContext: ```bash diff --git a/docs/zh/docs/reference/configuration.md b/docs/zh/docs/reference/configuration.md index 3522e9445..f7dff1642 100644 --- a/docs/zh/docs/reference/configuration.md +++ b/docs/zh/docs/reference/configuration.md @@ -25,7 +25,8 @@ export POWERCONTEXT_HOME=/srv/powercontext 未覆盖时,默认目录为: - Linux:`$XDG_DATA_HOME/powercontext`,未设置时为 `~/.local/share/powercontext`; -- macOS:`~/Library/Application Support/powercontext`。 +- macOS:`~/Library/Application Support/powercontext`; +- Windows:`%LOCALAPPDATA%\\powercontext`。 默认 SQLite 数据库是该目录下的 `powercontext.db`。启用定时处理时,调度状态保存在同一目录的 `scheduler.db`。 diff --git a/docs/zh/rfcs/1299_local_server_availability_and_service_installation.md b/docs/zh/rfcs/1299_local_server_availability_and_service_installation.md index 0e04dbd48..5515b2d18 100644 --- a/docs/zh/rfcs/1299_local_server_availability_and_service_installation.md +++ b/docs/zh/rfcs/1299_local_server_availability_and_service_installation.md @@ -158,7 +158,7 @@ Server role。 初始 distribution 契约为: ```text -powercontext service install +powercontext service install [--start-on-login | --no-start-on-login] powercontext service uninstall powercontext service status ``` @@ -167,6 +167,10 @@ powercontext service status 管理员安装模式。它们从本地 `ServerSettings` 推导 endpoint;根命令的 Client `--server-url` 选项和 `ClientSettings.server_url` 都不会选择 service target。 +在 Windows 上,如果没有显式提供 `--start-on-login` 或 `--no-start-on-login`,Install 会询问是否启用当前用户 +登录触发器,直接按 Enter 的默认选择是不启用。提供任一显式选项后不会再次询问;Linux 和 macOS 不提供关闭 +其原生用户服务正常启动行为的选项。 + ### Install Install 执行以下步骤: @@ -176,10 +180,10 @@ Install 执行以下步骤: 3. 解析 distribution 所有的内部 launcher 对应的绝对、非 shell 命令。 4. 探测目标 endpoint:有效的 PowerContext liveness 响应会跳过立即启动;端口被占用但响应无效时,作为冲突在改变 原生状态前失败。 -5. 渲染并验证包含固定 ownership marker、package version、definition version、目标 endpoint 和 launcher command - 的注册产物。 -6. 只创建或更新 PowerContext 的个人 Server 注册,并为后续用户登录启用。 -7. 除非第 4 步发现 PowerContext Server 已 live,否则默认立即启动。 +5. 渲染并验证包含固定 ownership marker、package version、definition version、目标 endpoint、launcher command + 和登录启动选择的注册产物。 +6. 只创建或更新 PowerContext 的个人 Server 注册;Windows 根据交互式回答或显式选项决定是否写入当前用户登录触发器。 +7. 除非第 4 步发现 PowerContext Server 已 live,否则立即启动本次安装的服务;登录启动选择只影响后续用户登录。 8. 操作完成后报告 registration、definition、原生 manager、liveness 和 log location 事实。 使用相同目标定义重复安装应成功,且不产生语义变化。如果 PowerContext 拥有的定义已过期,则在原生 manager 支持时 @@ -252,9 +256,10 @@ privileged helper。 ### Windows -Windows adapter 使用在当前用户登录时触发的 `Task Scheduler` task。它以该用户身份运行,绝不使用 `SYSTEM`。 -允许隐藏 process window。由于 Task Scheduler history 不是 Server stdout 或 stderr,launcher 会把 Server 输出重定向 -到明确的 PowerContext-owned 当前用户日志文件。该 adapter 不安装 Windows Service。 +Windows adapter 使用当前用户的 `Task Scheduler` task;选择登录自启时,在当前用户登录时触发,选择不自启时不写入登录 +触发器。它以该用户身份运行,绝不使用 `SYSTEM`。允许隐藏 process window。由于 Task Scheduler history 不是 Server +stdout 或 stderr,launcher 会把 Server 输出重定向到明确的 PowerContext-owned 当前用户日志文件。该 adapter 不安装 +Windows Service。 原生 identifier 和 path 是一个服务对应一组固定的项目常量。每个产物都包含稳定的 ownership marker 和 definition version,使 status 和 uninstall 在兼容的 package rename 后仍能区分 PowerContext-owned definition 与外部资源。 @@ -262,7 +267,8 @@ version,使 status 和 uninstall 在兼容的 package rename 后仍能区分 P ## Configuration and credentials -服务安装器记录 executable、必需参数和不敏感的 service metadata。它不会把调用者的完整 environment、shell profile、 +服务安装器记录 executable、必需参数和不敏感的 service metadata。Windows 环境文件的 metadata 还包含当前用户的 +owner SID,launcher 每次启动都会重新校验该 SID。它不会把调用者的完整 environment、shell profile、 API key、bearer token 或 provider credential 复制进原生注册产物。 因此,初始个人服务模式依赖原生当前用户服务环境中可获得的配置。`powercontext service status` 和 diff --git a/src/powercontext/cli/config.py b/src/powercontext/cli/config.py index 166b84edb..d948734d2 100644 --- a/src/powercontext/cli/config.py +++ b/src/powercontext/cli/config.py @@ -24,7 +24,7 @@ import shutil import sys import tempfile -from collections.abc import Iterator, Mapping, Sequence +from collections.abc import Generator, Mapping, Sequence from contextlib import contextmanager from dataclasses import dataclass from datetime import UTC, datetime @@ -914,7 +914,7 @@ def _validate_server_settings(values: Mapping[str, str]) -> None: @contextmanager -def _temporary_environment(values: Mapping[str, str], *, clear: set[str]) -> Iterator[None]: +def _temporary_environment(values: Mapping[str, str], *, clear: set[str]) -> Generator[None, None, None]: original = {name: os.environ.get(name) for name in clear | set(values)} try: for name in clear: diff --git a/src/powercontext/cli/env_file.py b/src/powercontext/cli/env_file.py index 95f9e97f2..9ebf7453c 100644 --- a/src/powercontext/cli/env_file.py +++ b/src/powercontext/cli/env_file.py @@ -18,7 +18,7 @@ import os import re -from collections.abc import Collection, Iterator, Mapping, MutableMapping +from collections.abc import Collection, Generator, Mapping, MutableMapping from contextlib import contextmanager from pathlib import Path @@ -159,7 +159,7 @@ def apply_environment_file( @contextmanager -def environment_file_context(path: Path, *, override: bool = False) -> Iterator[Mapping[str, str]]: +def environment_file_context(path: Path, *, override: bool = False) -> Generator[Mapping[str, str], None, None]: """Apply a file for one process scope, then restore every affected value.""" loaded = read_environment_file(path) @@ -173,7 +173,7 @@ def environment_context( *, override: bool = False, clear: Collection[str] = (), -) -> Iterator[None]: +) -> Generator[None, None, None]: """Apply parsed values for one process scope, then restore every affected value.""" loaded = dict(values) diff --git a/src/powercontext/service/adapters/__init__.py b/src/powercontext/service/adapters/__init__.py index 58aad2f02..747dd6027 100644 --- a/src/powercontext/service/adapters/__init__.py +++ b/src/powercontext/service/adapters/__init__.py @@ -30,6 +30,10 @@ def native_service_adapter() -> NativeServiceAdapter: from powercontext.service.adapters.launchd import LaunchdUserAdapter return LaunchdUserAdapter() + if sys.platform == "win32": + from powercontext.service.adapters.windows import WindowsTaskSchedulerAdapter + + return WindowsTaskSchedulerAdapter() return UnsupportedAdapter(f"personal service installation is not supported on {sys.platform}") diff --git a/src/powercontext/service/adapters/base.py b/src/powercontext/service/adapters/base.py index 7c1c90bf8..9624c5d47 100644 --- a/src/powercontext/service/adapters/base.py +++ b/src/powercontext/service/adapters/base.py @@ -19,6 +19,7 @@ import base64 import json import os +import sys import tempfile from contextlib import suppress from pathlib import Path @@ -167,7 +168,11 @@ def atomic_write(path: Path, content: bytes, *, mode: int = 0o644) -> None: descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) temporary = Path(temporary_name) try: - os.fchmod(descriptor, mode) + fchmod = getattr(os, "fchmod", None) + if fchmod is not None: + fchmod(descriptor, mode) + else: + os.chmod(temporary, mode) with os.fdopen(descriptor, "wb") as output: output.write(content) output.flush() @@ -202,6 +207,21 @@ def definition_state( return DefinitionState.CURRENT +def service_python_executable() -> str: + """Return the Python executable suitable for a native personal service.""" + + executable = os.path.abspath(sys.executable) + if sys.platform != "win32": + return executable + pythonw = Path(executable).with_name("pythonw.exe") + if not pythonw.is_file(): + raise ServiceError( # noqa: TRY003 + f"Windows personal services require pythonw.exe beside the active Python executable: {pythonw}", + exit_code=2, + ) + return str(pythonw) + + __all__ = [ "NativeServiceAdapter", "UnsupportedAdapter", @@ -210,4 +230,5 @@ def definition_state( "definition_state", "encode_metadata", "inspect_artifact", + "service_python_executable", ] diff --git a/src/powercontext/service/adapters/launchd.py b/src/powercontext/service/adapters/launchd.py index a68646139..beec18f15 100644 --- a/src/powercontext/service/adapters/launchd.py +++ b/src/powercontext/service/adapters/launchd.py @@ -61,7 +61,8 @@ def __init__( user_home = home or Path.home() self.artifact_path = user_home / "Library" / "LaunchAgents" / f"{self.identifier}.plist" self.lock_path = self.artifact_path.with_name(f".{self.identifier}.lock") - self._uid = os.getuid() if uid is None else uid + getuid = getattr(os, "getuid", None) + self._uid = (getuid() if getuid is not None else 0) if uid is None else uid @property def _domain(self) -> str: diff --git a/src/powercontext/service/adapters/windows.py b/src/powercontext/service/adapters/windows.py new file mode 100644 index 000000000..e52f6ae52 --- /dev/null +++ b/src/powercontext/service/adapters/windows.py @@ -0,0 +1,663 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Windows Task Scheduler adapter for the personal PowerContext Server.""" + +from __future__ import annotations + +import csv +import io +import json +import os +import shutil +import subprocess +import sys +import time +import xml.etree.ElementTree as ET +from collections.abc import Sequence +from pathlib import Path + +from powercontext.service.adapters.base import ( + atomic_write, + decode_metadata, + encode_metadata, + inspect_artifact, + service_python_executable, +) +from powercontext.service.model import ( + DEFINITION_VERSION, + OWNERSHIP_MARKER, + ManagerOwnershipState, + ManagerRegistration, + ManagerState, + NativeRegistration, + RegistrationState, + ServiceDefinition, + ServiceError, + SupportState, +) + +_TASK_NAMESPACE = "http://schemas.microsoft.com/windows/2004/02/mit/task" +_TASK_IDENTIFIER = r"\PowerContext Personal Server" +_TASK_ARTIFACT_NAME = "personal-server.xml" +_METADATA_PREFIX = "X-PowerContext-Metadata: " +_DESCRIPTION = "Managed by PowerContext." +_RESTART_INTERVAL = "PT1M" +_RESTART_COUNT = "3" +_COMMAND_TIMEOUT_SECONDS = 30 +_TASK_TIMEOUT_SECONDS = 10.0 +_MAX_TASK_XML_BYTES = 1024 * 1024 +_TASK_NOT_FOUND_HRESULT = 0x80070002 +_TASK_HAS_NOT_RUN_RESULT = 0x41303 +_TASK_STATUS_RESULT_RANGE = range(0x41300, 0x41400) +_BUILTIN_SERVICE_SIDS = {"s-1-5-18", "s-1-5-19", "s-1-5-20"} +_BUILTIN_SERVICE_ACCOUNTS = {"system", "local service", "network service"} + + +class WindowsTaskSchedulerAdapter: + """Manage one current-user Task Scheduler task without administrator privileges.""" + + identifier = _TASK_IDENTIFIER + + def __init__( + self, + *, + home: Path | None = None, + config_home: Path | None = None, + identifier: str | None = None, + user_account: str | None = None, + user_sid: str | None = None, + ) -> None: + self.identifier = _normalize_identifier(identifier or type(self).identifier) + profile_root = home or Path.home() + root = ( + Path(config_home) + if config_home is not None + else Path(os.environ.get("LOCALAPPDATA", profile_root / "AppData" / "Local")) + ) + self.artifact_path = root / "PowerContext" / "Services" / _TASK_ARTIFACT_NAME + self.lock_path = self.artifact_path.with_name(".personal-server.lock") + self._user_account_override = user_account + self._user_sid_override = user_sid + + def platform_support(self) -> tuple[SupportState, str]: + if sys.platform != "win32": + return SupportState.UNSUPPORTED, "Task Scheduler personal services are available only on Windows" + if shutil.which("schtasks.exe") is None: + return SupportState.UNSUPPORTED, "schtasks.exe is not installed or is not on PATH" + if shutil.which("powershell.exe") is None: + return SupportState.UNSUPPORTED, "powershell.exe is not installed or is not on PATH" + try: + account, sid = self._user_identity() + except ServiceError as error: + return SupportState.UNSUPPORTED, str(error) + try: + service_python_executable() + except ServiceError as error: + return SupportState.UNSUPPORTED, str(error) + if _is_service_account(account, sid): + return SupportState.UNSUPPORTED, "personal services must run as the current interactive user" + return SupportState.SUPPORTED, "Windows Task Scheduler is available" + + def support(self) -> tuple[SupportState, str]: + support, detail = self.platform_support() + if support is SupportState.UNSUPPORTED: + return support, detail + result = self._run("/Query", "/TN", self.identifier, "/XML", "/HRESULT", check=False) + if result.returncode == 0 or _is_task_not_found(result): + return SupportState.SUPPORTED, "the current user's Task Scheduler is available" + return SupportState.UNSUPPORTED, "the current user's Task Scheduler is unavailable" + _command_detail( + result.stderr + ) + + def inspect(self) -> NativeRegistration: + state, content, detail = inspect_artifact(self.artifact_path) + if state is not RegistrationState.INSTALLED or content is None: + return NativeRegistration(state, content=content, detail=detail) + try: + root = _parse_xml(content) + definition = _definition_from_task(root) + expected = self.render(definition) + except (ET.ParseError, TypeError, ValueError, ServiceError) as error: + return NativeRegistration(RegistrationState.INVALID, content=content, detail=str(error)) + if ( + definition.ownership != OWNERSHIP_MARKER + or definition.definition_version != DEFINITION_VERSION + or expected != content + or _text(_child(root, "RegistrationInfo"), "URI") != self.identifier + ): + return NativeRegistration( + RegistrationState.INVALID, + content=content, + detail="the installed Task Scheduler definition does not match its PowerContext metadata", + ) + return NativeRegistration(RegistrationState.INSTALLED, definition=definition, content=content) + + def loaded_registration(self) -> ManagerRegistration: + result = self._run("/Query", "/TN", self.identifier, "/XML", "/HRESULT", check=False) + if _is_task_not_found(result): + return ManagerRegistration(ManagerOwnershipState.NOT_LOADED) + if result.returncode != 0: + return ManagerRegistration( + ManagerOwnershipState.UNKNOWN, + detail=f"cannot inspect loaded Task Scheduler task{_command_detail(result.stderr)}", + ) + try: + root = _parse_xml(result.stdout) + definition = _definition_from_task(root) + except (ET.ParseError, TypeError, ValueError) as error: + return ManagerRegistration(ManagerOwnershipState.FOREIGN, detail=str(error)) + if definition.ownership != OWNERSHIP_MARKER or definition.definition_version != DEFINITION_VERSION: + return ManagerRegistration( + ManagerOwnershipState.FOREIGN, + definition=definition, + detail=f"loaded Task Scheduler task {self.identifier} has an unsupported PowerContext definition", + ) + detail = self._task_mismatch(root, definition) + if detail is not None: + return ManagerRegistration( + ManagerOwnershipState.FOREIGN, + definition=definition, + detail=f"loaded Task Scheduler task {self.identifier} does not match the PowerContext definition: {detail}", + ) + return ManagerRegistration(ManagerOwnershipState.OWNED, definition=definition) + + def render(self, definition: ServiceDefinition) -> bytes: + account, sid = self._user_identity() + root = ET.Element(_tag("Task"), {"version": "1.3"}) + + registration = ET.SubElement(root, _tag("RegistrationInfo")) + ET.SubElement(registration, _tag("Author")).text = "PowerContext" + ET.SubElement( + registration, _tag("Description") + ).text = f"{_DESCRIPTION}\n{_METADATA_PREFIX}{encode_metadata(definition)}" + ET.SubElement(registration, _tag("URI")).text = self.identifier + + if definition.start_on_login: + triggers = ET.SubElement(root, _tag("Triggers")) + logon = ET.SubElement(triggers, _tag("LogonTrigger")) + ET.SubElement(logon, _tag("Enabled")).text = "true" + ET.SubElement(logon, _tag("UserId")).text = account + + principals = ET.SubElement(root, _tag("Principals")) + principal = ET.SubElement(principals, _tag("Principal"), {"id": "Author"}) + ET.SubElement(principal, _tag("UserId")).text = sid + ET.SubElement(principal, _tag("LogonType")).text = "InteractiveToken" + ET.SubElement(principal, _tag("RunLevel")).text = "LeastPrivilege" + + settings = ET.SubElement(root, _tag("Settings")) + for name, value in ( + ("MultipleInstancesPolicy", "IgnoreNew"), + ("DisallowStartIfOnBatteries", "false"), + ("StopIfGoingOnBatteries", "false"), + ("AllowHardTerminate", "true"), + ("StartWhenAvailable", "true"), + ("Hidden", "true"), + ("ExecutionTimeLimit", "PT0S"), + ("UseUnifiedSchedulingEngine", "true"), + ("Priority", "7"), + ): + ET.SubElement(settings, _tag(name)).text = value + restart = ET.SubElement(settings, _tag("RestartOnFailure")) + ET.SubElement(restart, _tag("Interval")).text = _RESTART_INTERVAL + ET.SubElement(restart, _tag("Count")).text = _RESTART_COUNT + + actions = ET.SubElement(root, _tag("Actions"), {"Context": "Author"}) + execute = ET.SubElement(actions, _tag("Exec")) + arguments = _launcher_arguments(definition) + ET.SubElement(execute, _tag("Command")).text = arguments[0] + ET.SubElement(execute, _tag("Arguments")).text = subprocess.list2cmdline(arguments[1:]) + ET.SubElement(execute, _tag("WorkingDirectory")).text = definition.data_dir + + ET.register_namespace("", _TASK_NAMESPACE) + # ``schtasks /Create /XML`` requires the Windows XML encoding rather than a UTF-8 file. + return ET.tostring(root, encoding="utf-16", xml_declaration=True) + + def write(self, content: bytes) -> None: + definition = _definition_from_task(_parse_xml(content)) + (Path(definition.data_dir) / "logs").mkdir(mode=0o700, parents=True, exist_ok=True) + atomic_write(self.artifact_path, content) + + def restore(self, content: bytes | None) -> None: + if content is None: + self.artifact_path.unlink(missing_ok=True) + else: + atomic_write(self.artifact_path, content) + + def reload(self) -> None: + return None + + def enable(self) -> None: + registration = self.inspect() + if registration.definition is None: + raise ServiceError(registration.detail or "the Task Scheduler definition is not installed") + _require_owned_or_not_loaded(self.loaded_registration()) + self._run( + "/Create", + "/TN", + self.identifier, + "/XML", + str(self.artifact_path), + "/F", + "/HRESULT", + ) + self._run("/Change", "/TN", self.identifier, "/ENABLE", "/HRESULT") + + def start(self, *, reload_definition: bool) -> None: + loaded = self.loaded_registration() + _require_owned_or_not_loaded(loaded) + if loaded.state is ManagerOwnershipState.OWNED and reload_definition: + state = self.manager_state() + if state is ManagerState.UNKNOWN: + raise ServiceError( # noqa: TRY003 + f"cannot determine whether Task Scheduler task {self.identifier} is running" + ) + if state is ManagerState.ACTIVE: + self._run("/End", "/TN", self.identifier, "/HRESULT") + self._wait_for_inactive() + self._run("/Run", "/TN", self.identifier, "/HRESULT") + + def stop(self) -> None: + loaded = self.loaded_registration() + _require_owned_or_not_loaded(loaded) + if loaded.state is ManagerOwnershipState.NOT_LOADED: + return + state = self.manager_state() + if state is ManagerState.UNKNOWN: + raise ServiceError( # noqa: TRY003 + f"cannot determine whether Task Scheduler task {self.identifier} is running" + ) + if state is ManagerState.ACTIVE: + self._run("/End", "/TN", self.identifier, "/HRESULT") + self._wait_for_inactive() + + def disable(self) -> None: + _require_owned_or_not_loaded(self.loaded_registration()) + if self.loaded_registration().state is ManagerOwnershipState.NOT_LOADED: + return + self._run("/Change", "/TN", self.identifier, "/DISABLE", "/HRESULT") + + def remove(self) -> None: + loaded = self.loaded_registration() + _require_owned_or_not_loaded(loaded) + if loaded.state is not ManagerOwnershipState.NOT_LOADED: + self._run("/Delete", "/TN", self.identifier, "/F", "/HRESULT") + self.artifact_path.unlink(missing_ok=True) + + def manager_state(self) -> ManagerState: + result = self._run_task_info(check=False) + if result.returncode != 0: + return ManagerState.UNKNOWN + try: + values = json.loads(result.stdout) + except json.JSONDecodeError: + return ManagerState.UNKNOWN + if not isinstance(values, dict): + return ManagerState.UNKNOWN + status = values.get("State") + if not isinstance(status, str): + return ManagerState.UNKNOWN + status = status.casefold() + if status == "notfound": + return ManagerState.INACTIVE + if status == "running": + return ManagerState.ACTIVE + if status in {"ready", "disabled", "queued"}: + last_result = _last_result(values.get("LastTaskResult")) + return ManagerState.FAILED if last_result not in {None, 0} else ManagerState.INACTIVE + return ManagerState.UNKNOWN + + def log_location(self, definition: ServiceDefinition | None) -> str | None: + if definition is None: + return None + return str(Path(definition.data_dir) / "logs") + + def uninstall_recovery(self, stage: str) -> str: + commands = { + "stop": f'schtasks.exe /End /TN "{self.identifier}" /HRESULT', + "disable": f'schtasks.exe /Change /TN "{self.identifier}" /DISABLE /HRESULT', + "remove": f'schtasks.exe /Delete /TN "{self.identifier}" /F /HRESULT', + "reload": f'schtasks.exe /Query /TN "{self.identifier}" /XML /HRESULT', + } + return commands.get(stage, f'schtasks.exe /Query /TN "{self.identifier}" /XML /HRESULT') + + def _user_identity(self) -> tuple[str, str]: + account, sid = _current_user_identity() if sys.platform == "win32" else _test_user_identity() + return self._user_account_override or account, self._user_sid_override or sid + + def _task_mismatch(self, root: ET.Element, definition: ServiceDefinition) -> str | None: + account, sid = self._user_identity() + expected_arguments = _launcher_arguments(definition) + registration = _child(root, "RegistrationInfo") + triggers = _child(root, "Triggers") + logon = _child(triggers, "LogonTrigger") if triggers is not None else None + principals = _child(root, "Principals") + principal = _child(principals, "Principal") if principals is not None else None + settings = _child(root, "Settings") + actions = _child(root, "Actions") + execute = _child(actions, "Exec") if actions is not None else None + structure_mismatch = _task_structure_mismatch(root, definition) + if structure_mismatch is not None: + return structure_mismatch + if definition.start_on_login: + logon_matches = logon is not None and ( + _text(logon, "Enabled").casefold() in {"", "true"} + and _text(logon, "UserId").casefold() in {account.casefold(), sid.casefold()} + ) + else: + logon_matches = logon is None + checks = ( + (_text(registration, "URI") == self.identifier, "URI"), + (logon_matches, "logon trigger"), + (_text(principal, "UserId").casefold() in {account.casefold(), sid.casefold()}, "principal user"), + (_text(principal, "LogonType") == "InteractiveToken", "logon type"), + (_text(principal, "RunLevel") in {"", "LeastPrivilege"}, "run level"), + (_text(settings, "MultipleInstancesPolicy") == "IgnoreNew", "multiple-instance policy"), + (_text(settings, "DisallowStartIfOnBatteries").casefold() == "false", "battery start policy"), + (_text(settings, "StopIfGoingOnBatteries").casefold() == "false", "battery stop policy"), + (_text(settings, "StartWhenAvailable").casefold() == "true", "start-when-available policy"), + (_text(settings, "Hidden").casefold() == "true", "hidden-window policy"), + (_text(_child(settings, "RestartOnFailure"), "Interval") == _RESTART_INTERVAL, "restart interval"), + (_text(_child(settings, "RestartOnFailure"), "Count") == _RESTART_COUNT, "restart count"), + (_same_path(_text(execute, "Command"), expected_arguments[0]), "launcher executable"), + (_text(execute, "Arguments") == subprocess.list2cmdline(expected_arguments[1:]), "launcher arguments"), + (_same_path(_text(execute, "WorkingDirectory"), definition.data_dir), "working directory"), + ) + for matches, name in checks: + if not matches: + return name + return None + + def _wait_for_inactive(self) -> None: + deadline = time.monotonic() + _TASK_TIMEOUT_SECONDS + while time.monotonic() < deadline: + state = self.manager_state() + if state is ManagerState.INACTIVE: + return + if state is ManagerState.UNKNOWN: + raise ServiceError( # noqa: TRY003 + f"cannot verify that Task Scheduler task {self.identifier} stopped" + ) + time.sleep(0.05) + raise ServiceError(f"Task Scheduler task {self.identifier} did not stop") # noqa: TRY003 + + def _run(self, *arguments: str, check: bool = True) -> subprocess.CompletedProcess[str]: + command = ["schtasks.exe", *arguments] + try: + result = subprocess.run( # noqa: S603 + command, + capture_output=True, + text=True, + timeout=_COMMAND_TIMEOUT_SECONDS, + check=False, + ) + except (OSError, subprocess.SubprocessError) as error: + raise ServiceError(f"failed to execute schtasks.exe: {error}") from error # noqa: TRY003 + if check and result.returncode != 0: + detail = _command_detail(result.stderr or result.stdout) + raise ServiceError(f"schtasks.exe {arguments[0]} failed{detail}") # noqa: TRY003 + return result + + def _run_task_info(self, *, check: bool = True) -> subprocess.CompletedProcess[str]: + task_path, task_name = _task_path_and_name(self.identifier) + environment = os.environ.copy() + environment["POWERCONTEXT_TASK_PATH"] = task_path + environment["POWERCONTEXT_TASK_NAME"] = task_name + script = ( + "$taskPath = $env:POWERCONTEXT_TASK_PATH; " + "$taskName = $env:POWERCONTEXT_TASK_NAME; " + "$task = Get-ScheduledTask -TaskPath $taskPath -TaskName $taskName -ErrorAction SilentlyContinue; " + "if ($null -eq $task) { " + "[pscustomobject]@{ State = 'NotFound'; LastTaskResult = 0 } | ConvertTo-Json -Compress; exit 0 " + "}; " + "$info = Get-ScheduledTaskInfo -TaskPath $taskPath -TaskName $taskName -ErrorAction SilentlyContinue; " + "if ($null -eq $info) { [Console]::Error.WriteLine('Task Scheduler info unavailable'); exit 1 }; " + "[pscustomobject]@{ State = [string]$task.State; LastTaskResult = [int64]$info.LastTaskResult } " + "| ConvertTo-Json -Compress" + ) + try: + result = subprocess.run( # noqa: S603 + ["powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], # noqa: S607 + capture_output=True, + text=True, + timeout=_COMMAND_TIMEOUT_SECONDS, + check=False, + env=environment, + ) + except (OSError, subprocess.SubprocessError) as error: + raise ServiceError(f"failed to execute powershell.exe: {error}") from error # noqa: TRY003 + if check and result.returncode != 0: + detail = _command_detail(result.stderr or result.stdout) + raise ServiceError(f"powershell.exe Task Scheduler state query failed{detail}") # noqa: TRY003 + return result + + +def _launcher_arguments(definition: ServiceDefinition) -> list[str]: + log_dir = Path(definition.data_dir) / "logs" + return [ + *definition.launcher_arguments(), + "--stdout", + str(log_dir / "server.stdout.log"), + "--stderr", + str(log_dir / "server.stderr.log"), + ] + + +def _definition_from_task(root: ET.Element) -> ServiceDefinition: + registration = _child(root, "RegistrationInfo") + description = _text(registration, "Description") + metadata = next( + ( + line.strip()[len(_METADATA_PREFIX) :] + for line in description.splitlines() + if line.strip().startswith(_METADATA_PREFIX) + ), + None, + ) + if _DESCRIPTION not in description or metadata is None: + raise ValueError("Task Scheduler task is missing the PowerContext ownership metadata") # noqa: TRY003 + return decode_metadata(metadata) + + +def _parse_xml(content: bytes | str) -> ET.Element: + size = len(content) if isinstance(content, bytes) else len(content.encode("utf-8")) + if size > _MAX_TASK_XML_BYTES: + raise ValueError("Task Scheduler definition is too large") # noqa: TRY003 + root = ET.fromstring(content) # noqa: S314 - Task Scheduler supplies a bounded local XML document. + if _local_name(root.tag) != "Task": + raise ValueError("Task Scheduler definition has an unexpected root element") # noqa: TRY003 + return root + + +def _tag(name: str) -> str: + return f"{{{_TASK_NAMESPACE}}}{name}" + + +def _local_name(tag: str) -> str: + return tag.rsplit("}", 1)[-1] + + +def _child(parent: ET.Element | None, name: str) -> ET.Element | None: + if parent is None: + return None + return next((child for child in parent if _local_name(child.tag) == name), None) + + +def _has_exact_children(parent: ET.Element | None, expected: tuple[str, ...]) -> bool: + return parent is not None and tuple(_local_name(child.tag) for child in parent) == expected + + +def _has_exact_attributes(parent: ET.Element | None, expected: dict[str, str]) -> bool: + return parent is not None and parent.attrib == expected + + +def _has_children( + parent: ET.Element | None, + *, + required: tuple[str, ...], + optional: tuple[str, ...] = (), +) -> bool: + if parent is None: + return False + names = tuple(_local_name(child.tag) for child in parent) + return ( + all(names.count(name) == 1 for name in required) + and all(names.count(name) <= 1 for name in optional) + and all(name in required or name in optional for name in names) + ) + + +def _task_structure_mismatch(root: ET.Element, definition: ServiceDefinition) -> str | None: + triggers = _child(root, "Triggers") + principals = _child(root, "Principals") + actions = _child(root, "Actions") + return next( + ( + mismatch + for mismatch in ( + _action_structure_mismatch(actions), + _principal_structure_mismatch(principals), + _trigger_structure_mismatch(triggers, definition), + ) + if mismatch is not None + ), + None, + ) + + +def _action_structure_mismatch(actions: ET.Element | None) -> str | None: + execute = _child(actions, "Exec") + if not _has_exact_attributes(actions, {"Context": "Author"}): + return "action attributes" + if not _has_exact_children(actions, ("Exec",)): + return "action structure" + if not _has_exact_attributes(execute, {}): + return "exec action attributes" + if not _has_exact_children(execute, ("Command", "Arguments", "WorkingDirectory")): + return "exec action structure" + return None + + +def _principal_structure_mismatch(principals: ET.Element | None) -> str | None: + principal = _child(principals, "Principal") + if not _has_exact_children(principals, ("Principal",)): + return "principal structure" + if not _has_exact_attributes(principal, {"id": "Author"}): + return "principal attributes" + if not _has_children(principal, required=("UserId", "LogonType"), optional=("RunLevel",)): + return "principal element structure" + return None + + +def _trigger_structure_mismatch(triggers: ET.Element | None, definition: ServiceDefinition) -> str | None: + logon = _child(triggers, "LogonTrigger") + if definition.start_on_login and not _has_exact_children(triggers, ("LogonTrigger",)): + return "trigger structure" + if definition.start_on_login and not _has_children(logon, required=("UserId",), optional=("Enabled",)): + return "logon trigger structure" + if not definition.start_on_login and triggers is not None and not _has_exact_children(triggers, ()): + return "trigger structure" + return None + + +def _text(parent: ET.Element | None, name: str) -> str: + child = _child(parent, name) + return "" if child is None or child.text is None else child.text.strip() + + +def _normalize_identifier(identifier: str) -> str: + value = identifier.strip() + if not value: + raise ValueError("Task Scheduler task identifier must not be empty") # noqa: TRY003 + if not value.startswith("\\"): + value = f"\\{value}" + if value.endswith("\\") or any(character in value for character in "\x00\r\n"): + raise ValueError("Task Scheduler task identifier is invalid") # noqa: TRY003 + return value + + +def _task_path_and_name(identifier: str) -> tuple[str, str]: + parent, separator, name = identifier.rpartition("\\") + if not separator or not name: + raise ValueError("Task Scheduler task identifier is invalid") # noqa: TRY003 + return (parent + "\\") if parent else "\\", name + + +def _same_path(actual: str, expected: str) -> bool: + if not actual: + return False + return os.path.normcase(os.path.abspath(actual)) == os.path.normcase(os.path.abspath(expected)) + + +def _is_service_account(account: str, sid: str) -> bool: + return ( + sid.casefold() in _BUILTIN_SERVICE_SIDS or account.rsplit("\\", 1)[-1].casefold() in _BUILTIN_SERVICE_ACCOUNTS + ) + + +def _current_user_identity() -> tuple[str, str]: + try: + result = subprocess.run( + ["whoami.exe", "/user", "/fo", "csv", "/nh"], # noqa: S607 + capture_output=True, + text=True, + timeout=10, + check=False, + ) + except (OSError, subprocess.SubprocessError) as error: + raise ServiceError(f"cannot determine the current Windows user: {error}") from error # noqa: TRY003 + if result.returncode != 0: + raise ServiceError(f"cannot determine the current Windows user{_command_detail(result.stderr)}") # noqa: TRY003 + for row in csv.reader(io.StringIO(result.stdout)): + sid = next((value.strip() for value in reversed(row) if value.strip().upper().startswith("S-1-")), None) + if sid is not None and row: + return row[0].strip(), sid + raise ServiceError("cannot determine the current Windows user SID") # noqa: TRY003 + + +def _test_user_identity() -> tuple[str, str]: + account = os.environ.get("USERNAME") or os.environ.get("USER") or "current-user" + return account, "S-1-5-21-0-0-0-1000" + + +def _is_task_not_found(result: subprocess.CompletedProcess[str]) -> bool: + return result.returncode & 0xFFFFFFFF == _TASK_NOT_FOUND_HRESULT + + +def _last_result(value: object) -> int | None: + if isinstance(value, bool): + return None + if isinstance(value, int): + result = value + elif isinstance(value, str): + try: + result = int(value.strip(), 0) + except ValueError: + return None + else: + return None + return None if result in _TASK_STATUS_RESULT_RANGE or result == _TASK_HAS_NOT_RUN_RESULT else result + + +def _command_detail(output: str) -> str: + detail = " ".join(output.strip().splitlines()) + return f": {detail[:500]}" if detail else "" + + +def _require_owned_or_not_loaded(registration: ManagerRegistration) -> None: + if registration.state in {ManagerOwnershipState.FOREIGN, ManagerOwnershipState.UNKNOWN}: + raise ServiceError(registration.detail or "cannot verify the loaded Task Scheduler task ownership") + + +__all__: Sequence[str] = ["WindowsTaskSchedulerAdapter"] diff --git a/src/powercontext/service/cli.py b/src/powercontext/service/cli.py index 8352dd1e1..9878c23a4 100644 --- a/src/powercontext/service/cli.py +++ b/src/powercontext/service/cli.py @@ -17,6 +17,7 @@ from __future__ import annotations import json +import sys from pathlib import Path from typing import Annotated @@ -49,17 +50,38 @@ def install( Path | None, typer.Option(help="Load persistent Server and provider settings from this protected environment file."), ] = None, + start_on_login: Annotated[ + bool | None, + typer.Option( + "--start-on-login/--no-start-on-login", + help="Start the Server automatically when the current user logs in (Windows).", + ), + ] = None, ) -> None: - """Install, enable, and start the personal Server service.""" - + """Install the personal Server service and optionally start it at user login.""" + + if start_on_login is None: + start_on_login = ( + typer.confirm("Enable automatic Server startup when you log in?", default=False) + if sys.platform == "win32" + else True + ) + if not start_on_login and sys.platform != "win32": + typer.echo("--no-start-on-login is currently supported only on Windows.", err=True) + raise typer.Exit(code=2) try: - status = _controller().install(env_file=env_file) + status = _controller().install(env_file=env_file, start_on_login=start_on_login) except (OSError, ServiceError) as error: typer.echo(f"PowerContext personal service installation failed: {error}", err=True) if isinstance(error, ServiceError) and error.status is not None: _write_status(error.status, json_output=False) raise typer.Exit(code=error.exit_code if isinstance(error, ServiceError) else 1) from error - typer.echo("PowerContext personal service installed.") + message = ( + "PowerContext personal service installed with login auto-start." + if start_on_login + else "PowerContext personal service installed without login auto-start." + ) + typer.echo(message) _write_status(status, json_output=False) diff --git a/src/powercontext/service/controller.py b/src/powercontext/service/controller.py index b4858fd7b..fc5601b0b 100644 --- a/src/powercontext/service/controller.py +++ b/src/powercontext/service/controller.py @@ -19,17 +19,18 @@ import os import sys import time -from collections.abc import Callable, Iterator +from collections.abc import Callable, Generator from contextlib import contextmanager, nullcontext, suppress from dataclasses import replace from importlib.metadata import version from pathlib import Path +from typing import cast from powercontext.cli.env_file import environment_context from powercontext.paths import POWERCONTEXT_HOME_ENV, powercontext_data_dir from powercontext.server.configuration import ServerConfigurationError, server_settings_context from powercontext.service.adapters import NativeServiceAdapter, native_service_adapter -from powercontext.service.adapters.base import definition_state +from powercontext.service.adapters.base import definition_state, service_python_executable from powercontext.service.environment import ProtectedEnvironmentFileError, load_protected_environment_file from powercontext.service.model import ( DEFINITION_VERSION, @@ -67,11 +68,16 @@ def __init__( self._probe = probe self._sleep = sleep - def install(self, *, env_file: Path | None = None) -> ServiceStatus: + def install(self, *, env_file: Path | None = None, start_on_login: bool = True) -> ServiceStatus: + if not start_on_login and sys.platform != "win32": + raise ServiceError( # noqa: TRY003 + "disabling login auto-start is currently supported only on Windows", + exit_code=2, + ) support, detail = self._adapter.support() if support is SupportState.UNSUPPORTED: raise ServiceError(detail) - definition = self._build_definition(env_file) + definition = self._build_definition(env_file, start_on_login=start_on_login) initial_probe = self._probe(definition.endpoint) if initial_probe.state is ProbeState.CONFLICT: raise ServiceError( # noqa: TRY003 @@ -155,7 +161,7 @@ def registration_status(self) -> ServiceStatus: installed_definition = definition_state( definition, package_version=installed_version, - python_executable=sys.executable, + python_executable=service_python_executable(), ) return ServiceStatus( support=support, @@ -229,7 +235,7 @@ def uninstall(self) -> ServiceStatus: self._run_uninstall_stage("reload", self._adapter.reload) return self.status() - def _build_definition(self, env_file: Path | None) -> ServiceDefinition: + def _build_definition(self, env_file: Path | None, *, start_on_login: bool) -> ServiceDefinition: try: loaded_env = load_protected_environment_file(env_file) if env_file is not None else None except ProtectedEnvironmentFileError as error: @@ -272,10 +278,11 @@ def _build_definition(self, env_file: Path | None) -> ServiceDefinition: ownership=OWNERSHIP_MARKER, definition_version=DEFINITION_VERSION, package_version=version("powercontext"), - python_executable=os.path.abspath(sys.executable), + python_executable=service_python_executable(), endpoint=endpoint, data_dir=data_dir, env_file=loaded_env.identity if loaded_env is not None else None, + start_on_login=start_on_login, ) def _run_uninstall_stage(self, stage: str, operation: Callable[[], None]) -> None: @@ -353,7 +360,12 @@ def _require_mutable_manager_registration(registration: ManagerRegistration) -> @contextmanager -def _service_lock(path: Path, *, timeout: float = 5.0) -> Iterator[None]: +def _service_lock(path: Path, *, timeout: float = 5.0) -> Generator[None, None, None]: + if os.name == "nt": + with _windows_service_lock(path, timeout=timeout): + yield + return + import fcntl path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) @@ -377,6 +389,40 @@ def _service_lock(path: Path, *, timeout: float = 5.0) -> Iterator[None]: os.close(descriptor) +@contextmanager +def _windows_service_lock(path: Path, *, timeout: float) -> Generator[None, None, None]: + import msvcrt + + # These Windows-only members are missing from the stdlib type stubs. + msvcrt_members = vars(msvcrt) + locking = cast(Callable[[int, int, int], None], msvcrt_members["locking"]) + lock_nonblocking = cast(int, msvcrt_members["LK_NBLCK"]) + lock_unlock = cast(int, msvcrt_members["LK_UNLCK"]) + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + descriptor = os.open(path, os.O_CREAT | os.O_RDWR, 0o600) + try: + if os.fstat(descriptor).st_size == 0: + os.write(descriptor, b"\0") + deadline = time.monotonic() + timeout + while True: + try: + os.lseek(descriptor, 0, os.SEEK_SET) + locking(descriptor, lock_nonblocking, 1) + break + except OSError: + if time.monotonic() >= deadline: + raise ServiceError( # noqa: TRY003 + "another PowerContext service operation is still running" + ) from None + time.sleep(0.05) + yield + finally: + with suppress(OSError): + os.lseek(descriptor, 0, os.SEEK_SET) + locking(descriptor, lock_unlock, 1) + os.close(descriptor) + + def _endpoint(host: str, port: int) -> str: normalized = host.strip("[]") rendered_host = f"[{normalized}]" if ":" in normalized else normalized diff --git a/src/powercontext/service/environment.py b/src/powercontext/service/environment.py index 79450accf..6160eeed8 100644 --- a/src/powercontext/service/environment.py +++ b/src/powercontext/service/environment.py @@ -16,11 +16,15 @@ from __future__ import annotations +import ctypes import os +import re import stat +import subprocess from contextlib import suppress from dataclasses import dataclass from pathlib import Path +from typing import Any from powercontext.cli.env_file import EnvironmentFileError, parse_environment from powercontext.service.model import EnvironmentFileIdentity @@ -54,15 +58,16 @@ def load_protected_environment_file( ) descriptor = os.open(candidate, flags) before = os.fstat(descriptor) - _validate_protection(candidate, before) - identity = EnvironmentFileIdentity.from_stat(candidate, before) + owner_sid = _validate_protection(candidate, before) + identity = EnvironmentFileIdentity.from_stat(candidate, before, owner_sid=owner_sid) if expected is not None and identity != expected: raise ProtectedEnvironmentFileError( # noqa: TRY003, TRY301 f"--env-file changed since the personal service was installed: {candidate}" ) content = _read_utf8(descriptor, candidate) after = os.fstat(descriptor) - if EnvironmentFileIdentity.from_stat(candidate, after) != identity: + after_owner_sid = _validate_protection(candidate, after) + if EnvironmentFileIdentity.from_stat(candidate, after, owner_sid=after_owner_sid) != identity: raise ProtectedEnvironmentFileError( # noqa: TRY003, TRY301 f"--env-file changed while it was being read: {candidate}" ) @@ -91,9 +96,11 @@ def environment_identity_is_current(identity: EnvironmentFileIdentity) -> bool: return True -def _validate_protection(path: Path, status: os.stat_result) -> None: +def _validate_protection(path: Path, status: os.stat_result) -> str | None: if not stat.S_ISREG(status.st_mode): raise ProtectedEnvironmentFileError(f"--env-file must be a regular file: {path}") # noqa: TRY003 + if os.name == "nt": + return _validate_windows_protection(path) if status.st_uid != os.getuid(): raise ProtectedEnvironmentFileError( # noqa: TRY003 f"--env-file must be owned by the current user: {path}" @@ -102,6 +109,156 @@ def _validate_protection(path: Path, status: os.stat_result) -> None: raise ProtectedEnvironmentFileError( # noqa: TRY003 f"--env-file must be accessible only by its owner; run `chmod 600 {path}`" ) + return None + + +def _validate_windows_protection(path: Path) -> str: + """Require a Windows ACL limited to the interactive user and trusted OS admins.""" + + account, sid = _windows_user_identity() + owner_sid = _windows_file_owner_sid(path) + if owner_sid.casefold() != sid.casefold(): + raise ProtectedEnvironmentFileError( # noqa: TRY003 + f"--env-file must be owned by the current user (owner SID {sid}): {path}" + ) + try: + result = subprocess.run( # noqa: S603 + ["icacls.exe", str(path)], # noqa: S607 + capture_output=True, + text=True, + timeout=10, + check=False, + ) + except (OSError, subprocess.SubprocessError) as error: + raise ProtectedEnvironmentFileError(f"cannot inspect the --env-file ACL: {error}") from error # noqa: TRY003 + if result.returncode != 0: + raise ProtectedEnvironmentFileError( # noqa: TRY003 + f"cannot inspect the --env-file ACL: {' '.join(result.stderr.strip().splitlines())[:300]}" + ) + + allowed = { + account.casefold(), + sid.casefold(), + "nt authority\\system", + "builtin\\administrators", + "owner rights", + } + principals: set[str] = set() + for line in result.stdout.splitlines(): + entry = line.strip() + match = re.search(r"(?P(?:\([^)]+\))+)$", entry) + if match is None: + continue + prefix = entry[: match.start()].casefold() + sid_match = re.search(r"s-1(?:-\d+)+", prefix, re.IGNORECASE) + if sid_match is not None: + principal = sid_match.group(0).casefold() + else: + principal = next( + ( + candidate + for candidate in ( + account.casefold(), + "nt authority\\system", + "builtin\\administrators", + "owner rights", + ) + if candidate in prefix + ), + "", + ) + principals.add(principal) + if principal not in allowed: + raise ProtectedEnvironmentFileError( # noqa: TRY003 + "--env-file ACL grants access to an unexpected account; restrict it to the current user, " + f"SYSTEM, and Administrators: {path}" + ) + if not principals.intersection({account.casefold(), sid.casefold(), "owner rights"}): + raise ProtectedEnvironmentFileError( # noqa: TRY003 + f"--env-file ACL does not grant the current user access: {path}" + ) + return owner_sid + + +def _windows_file_owner_sid(path: Path) -> str: + """Read the file owner SID through the Windows security API.""" + + owner = ctypes.c_void_p() + security_descriptor = ctypes.c_void_p() + local_free: Any = None + try: + win_dll = getattr(ctypes, "WinDLL") # noqa: B009 + win_error = getattr(ctypes, "WinError") # noqa: B009 + get_last_error = getattr(ctypes, "get_last_error") # noqa: B009 + advapi32 = win_dll("Advapi32", use_last_error=True) + kernel32 = win_dll("Kernel32", use_last_error=True) + local_free = kernel32.LocalFree + local_free.argtypes = [ctypes.c_void_p] + local_free.restype = ctypes.c_void_p + + get_named_security_info = advapi32.GetNamedSecurityInfoW + get_named_security_info.argtypes = [ + ctypes.c_wchar_p, + ctypes.c_uint32, + ctypes.c_uint32, + ctypes.POINTER(ctypes.c_void_p), + ctypes.POINTER(ctypes.c_void_p), + ctypes.POINTER(ctypes.c_void_p), + ctypes.POINTER(ctypes.c_void_p), + ctypes.POINTER(ctypes.c_void_p), + ] + get_named_security_info.restype = ctypes.c_uint32 + error_code = get_named_security_info( + str(path), + 1, # SE_FILE_OBJECT + 0x00000001, # OWNER_SECURITY_INFORMATION + ctypes.byref(owner), + None, + None, + None, + ctypes.byref(security_descriptor), + ) + if error_code: + raise win_error(error_code) + + convert_sid = advapi32.ConvertSidToStringSidW + convert_sid.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_wchar_p)] + convert_sid.restype = ctypes.c_int + owner_text = ctypes.c_wchar_p() + if not convert_sid(owner, ctypes.byref(owner_text)) or owner_text.value is None: + raise win_error(get_last_error()) + try: + return owner_text.value + finally: + local_free(owner_text) + except (AttributeError, OSError, TypeError, ValueError) as error: + raise ProtectedEnvironmentFileError(f"cannot inspect the --env-file owner: {error}") from error # noqa: TRY003 + finally: + if security_descriptor.value and local_free is not None: + local_free(security_descriptor) + + +def _windows_user_identity() -> tuple[str, str]: + try: + result = subprocess.run( + ["whoami.exe", "/user", "/fo", "csv", "/nh"], # noqa: S607 + capture_output=True, + text=True, + timeout=10, + check=False, + ) + except (OSError, subprocess.SubprocessError) as error: + raise ProtectedEnvironmentFileError(f"cannot determine the current Windows user: {error}") from error # noqa: TRY003 + if result.returncode != 0: + raise ProtectedEnvironmentFileError( # noqa: TRY003 + f"cannot determine the current Windows user: {' '.join(result.stderr.strip().splitlines())[:300]}" + ) + for line in result.stdout.splitlines(): + fields = [field.strip().strip('"') for field in line.split(",")] + sid = next((field for field in reversed(fields) if field.upper().startswith("S-1-")), None) + if sid is not None and fields: + return fields[0], sid + raise ProtectedEnvironmentFileError("cannot determine the current Windows user SID") # noqa: TRY003 def _read_utf8(descriptor: int, path: Path) -> str: diff --git a/src/powercontext/service/launcher.py b/src/powercontext/service/launcher.py index b33c8fc90..867dd8a10 100644 --- a/src/powercontext/service/launcher.py +++ b/src/powercontext/service/launcher.py @@ -18,7 +18,11 @@ import argparse import logging +import sys +from collections.abc import Generator +from contextlib import ExitStack, contextmanager, redirect_stderr, redirect_stdout from pathlib import Path +from typing import TextIO from urllib.parse import urlsplit from powercontext.server import cli as server_cli @@ -31,6 +35,22 @@ logger = logging.getLogger(__name__) +@contextmanager +def _redirect_output(stdout_path: Path | None, stderr_path: Path | None) -> Generator[None, None, None]: + with ExitStack() as stack: + stdout = stack.enter_context(_open_output(stdout_path)) if stdout_path is not None else sys.stdout + stderr = stack.enter_context(_open_output(stderr_path)) if stderr_path is not None else sys.stderr + with redirect_stdout(stdout), redirect_stderr(stderr): + yield + + +@contextmanager +def _open_output(path: Path) -> Generator[TextIO, None, None]: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8", buffering=1) as output: + yield output + + def main(arguments: list[str] | None = None) -> int: parser = argparse.ArgumentParser(prog="powercontext-personal-service-launcher") parser.add_argument("--endpoint", required=True) @@ -42,29 +62,33 @@ def main(arguments: list[str] | None = None) -> int: parser.add_argument("--env-file-modified-ns", type=int) parser.add_argument("--env-file-owner-uid", type=int) parser.add_argument("--env-file-mode", type=int) + parser.add_argument("--env-file-owner-sid") + parser.add_argument("--stdout", type=Path) + parser.add_argument("--stderr", type=Path) options = parser.parse_args(arguments) try: - environment: dict[str, str] | None = None - if options.env_file is not None: - identity = _environment_identity(options) - environment = load_protected_environment_file(options.env_file, expected=identity).values - with server_settings_context(environment=environment, data_dir=options.data_dir) as settings: - expected = _endpoint(settings.http.host, settings.http.port) - if expected != options.endpoint or not is_loopback_host(settings.http.host): - logger.error( - "Registered personal service endpoint does not match the current loopback Server configuration" - ) - return 1 - probe = probe_server(options.endpoint) - if probe.state is ProbeState.LIVE: - logger.info("PowerContext Server is already live; the personal service launcher will exit") + with _redirect_output(options.stdout, options.stderr): + environment: dict[str, str] | None = None + if options.env_file is not None: + identity = _environment_identity(options) + environment = load_protected_environment_file(options.env_file, expected=identity).values + with server_settings_context(environment=environment, data_dir=options.data_dir) as settings: + expected = _endpoint(settings.http.host, settings.http.port) + if expected != options.endpoint or not is_loopback_host(settings.http.host): + logger.error( + "Registered personal service endpoint does not match the current loopback Server configuration" + ) + return 1 + probe = probe_server(options.endpoint) + if probe.state is ProbeState.LIVE: + logger.info("PowerContext Server is already live; the personal service launcher will exit") + return 0 + if probe.state is ProbeState.CONFLICT: + logger.error("Personal service endpoint conflict: %s", probe.detail) + return 1 + server_cli._run_configured_server(settings) return 0 - if probe.state is ProbeState.CONFLICT: - logger.error("Personal service endpoint conflict: %s", probe.detail) - return 1 - server_cli._run_configured_server(settings) - return 0 except (ProtectedEnvironmentFileError, ServerConfigurationError) as error: logger.error("Personal service configuration is invalid: %s", error) # noqa: TRY400 return 1 @@ -86,8 +110,9 @@ def _environment_identity(options: argparse.Namespace) -> EnvironmentFileIdentit "modified_ns": options.env_file_modified_ns, "owner_uid": options.env_file_owner_uid, "mode": options.env_file_mode, + "owner_sid": options.env_file_owner_sid, } - if any(value is None for value in fields.values()): + if any(value is None for name, value in fields.items() if name != "owner_sid"): raise ProtectedEnvironmentFileError("the installed --env-file identity is incomplete") # noqa: TRY003 return EnvironmentFileIdentity(path=str(options.env_file), **fields) diff --git a/src/powercontext/service/model.py b/src/powercontext/service/model.py index 6e6e8a291..b8bc7a1ce 100644 --- a/src/powercontext/service/model.py +++ b/src/powercontext/service/model.py @@ -81,9 +81,16 @@ class EnvironmentFileIdentity: modified_ns: int owner_uid: int mode: int + owner_sid: str | None = None @classmethod - def from_stat(cls, path: Path, status: os.stat_result) -> EnvironmentFileIdentity: + def from_stat( + cls, + path: Path, + status: os.stat_result, + *, + owner_sid: str | None = None, + ) -> EnvironmentFileIdentity: return cls( path=os.path.abspath(path), device=status.st_dev, @@ -92,6 +99,7 @@ def from_stat(cls, path: Path, status: os.stat_result) -> EnvironmentFileIdentit modified_ns=status.st_mtime_ns, owner_uid=status.st_uid, mode=stat.S_IMODE(status.st_mode), + owner_sid=owner_sid, ) @classmethod @@ -108,9 +116,18 @@ class ServiceDefinition: endpoint: str data_dir: str env_file: EnvironmentFileIdentity | None = None + start_on_login: bool = True def as_dict(self) -> dict[str, Any]: - return asdict(self) + payload = asdict(self) + # Omit the default so definitions written before this option was added remain byte-for-byte valid. + if self.start_on_login: + payload.pop("start_on_login", None) + environment = payload.get("env_file") + if isinstance(environment, dict) and environment.get("owner_sid") is None: + # POSIX identities do not have a Windows owner SID. Keep their metadata compatible with older files. + environment.pop("owner_sid", None) + return payload @classmethod def from_dict(cls, value: object) -> ServiceDefinition: @@ -126,7 +143,7 @@ def from_dict(cls, value: object) -> ServiceDefinition: "data_dir", "env_file", } - if set(payload) != expected: + if set(payload) not in (expected, expected | {"start_on_login"}): raise ValueError("service definition fields do not match the supported contract") # noqa: TRY003 environment = payload["env_file"] env_file = None @@ -142,7 +159,11 @@ def from_dict(cls, value: object) -> ServiceDefinition: modified_ns=_required_int(environment_payload, "modified_ns"), owner_uid=_optional_int(environment_payload, "owner_uid", default=-1), mode=_optional_int(environment_payload, "mode", default=-1), + owner_sid=_optional_string(environment_payload, "owner_sid", default=None), ) + start_on_login = payload.get("start_on_login", True) + if not isinstance(start_on_login, bool): + raise TypeError("service definition field 'start_on_login' must be a boolean") # noqa: TRY003 return cls( ownership=_required_string(payload, "ownership"), definition_version=_required_int(payload, "definition_version"), @@ -151,6 +172,7 @@ def from_dict(cls, value: object) -> ServiceDefinition: endpoint=_required_string(payload, "endpoint"), data_dir=_required_string(payload, "data_dir"), env_file=env_file, + start_on_login=start_on_login, ) def launcher_arguments( @@ -185,6 +207,8 @@ def launcher_arguments( "--env-file-mode", str(self.env_file.mode), )) + if self.env_file.owner_sid is not None: + arguments.extend(("--env-file-owner-sid", self.env_file.owner_sid)) return arguments @@ -285,6 +309,17 @@ def _optional_int(value: dict[str, object], name: str, *, default: int) -> int: return _required_int(value, name) +def _optional_string(value: dict[str, object], name: str, *, default: str | None) -> str | None: + if name not in value: + return default + field = value[name] + if field is None and default is None: + return None + if not isinstance(field, str) or not field: + raise TypeError(f"service definition field {name!r} must be a non-empty string") # noqa: TRY003 + return field + + __all__ = [ "DEFINITION_VERSION", "OWNERSHIP_MARKER", diff --git a/src/powercontext_service_bootstrap/__main__.py b/src/powercontext_service_bootstrap/__main__.py index 2c31bb56c..7c2f21e6b 100644 --- a/src/powercontext_service_bootstrap/__main__.py +++ b/src/powercontext_service_bootstrap/__main__.py @@ -143,10 +143,8 @@ def _read_attempts(path: Path) -> list[float]: return [] try: status = os.fstat(descriptor) - if ( - not stat.S_ISREG(status.st_mode) - or status.st_uid != os.getuid() - or status.st_mode & (stat.S_IRWXG | stat.S_IRWXO) + if not stat.S_ISREG(status.st_mode) or ( + os.name != "nt" and (status.st_uid != _posix_uid() or status.st_mode & (stat.S_IRWXG | stat.S_IRWXO)) ): raise ValueError("unsafe retry-budget state") # noqa: TRY003 with os.fdopen(descriptor, encoding="utf-8") as source: @@ -172,7 +170,11 @@ def _atomic_write(path: Path, content: bytes) -> None: descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) temporary = Path(temporary_name) try: - os.fchmod(descriptor, 0o600) + fchmod = getattr(os, "fchmod", None) + if fchmod is not None: + fchmod(descriptor, 0o600) + else: + os.chmod(temporary, 0o600) with os.fdopen(descriptor, "wb") as output: descriptor = -1 output.write(content) @@ -199,5 +201,12 @@ def _remove_declared_token(arguments: list[str] | None) -> None: return +def _posix_uid() -> int: + getuid = getattr(os, "getuid", None) + if getuid is None: + return -1 + return int(getuid()) + + if __name__ == "__main__": raise SystemExit(main()) diff --git a/tests/claude_code_plugin/test_hook.py b/tests/claude_code_plugin/test_hook.py index c55295be2..0b6be5135 100644 --- a/tests/claude_code_plugin/test_hook.py +++ b/tests/claude_code_plugin/test_hook.py @@ -19,7 +19,7 @@ import sys import threading import time -from collections.abc import Iterator +from collections.abc import Generator from contextlib import contextmanager from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from types import ModuleType @@ -29,7 +29,7 @@ @contextmanager -def _serve(handler: type[BaseHTTPRequestHandler]) -> Iterator[str]: +def _serve(handler: type[BaseHTTPRequestHandler]) -> Generator[str, None, None]: server = ThreadingHTTPServer(("127.0.0.1", 0), handler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() diff --git a/tests/codex_plugin/test_recall.py b/tests/codex_plugin/test_recall.py index bd2650790..4a3404865 100644 --- a/tests/codex_plugin/test_recall.py +++ b/tests/codex_plugin/test_recall.py @@ -21,7 +21,7 @@ import sys import threading import time -from collections.abc import Iterator +from collections.abc import Generator from contextlib import contextmanager, suppress from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path @@ -32,7 +32,7 @@ @contextmanager -def _serve(handler: type[BaseHTTPRequestHandler]) -> Iterator[str]: +def _serve(handler: type[BaseHTTPRequestHandler]) -> Generator[str, None, None]: server = ThreadingHTTPServer(("127.0.0.1", 0), handler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() diff --git a/tests/native/test_personal_service_lifecycle.py b/tests/native/test_personal_service_lifecycle.py index 2a2f95fb4..c5802d8d7 100644 --- a/tests/native/test_personal_service_lifecycle.py +++ b/tests/native/test_personal_service_lifecycle.py @@ -22,21 +22,27 @@ import sys import time import uuid +import xml.etree.ElementTree as ET from collections.abc import Callable from contextlib import suppress +from importlib.metadata import version from pathlib import Path from typing import Any import pytest -from powercontext.service.adapters.base import NativeServiceAdapter +from powercontext.service.adapters.base import NativeServiceAdapter, service_python_executable from powercontext.service.adapters.launchd import LaunchdUserAdapter from powercontext.service.adapters.systemd import SystemdUserAdapter +from powercontext.service.adapters.windows import WindowsTaskSchedulerAdapter from powercontext.service.controller import ServiceController from powercontext.service.model import ( + DEFINITION_VERSION, + OWNERSHIP_MARKER, ManagerOwnershipState, ManagerState, RegistrationState, + ServiceDefinition, ServiceError, ServiceStatus, SupportState, @@ -66,7 +72,7 @@ def test_native_personal_service_lifecycle(tmp_path: Path) -> None: loaded = adapter.loaded_registration() assert loaded.state is ManagerOwnershipState.OWNED assert loaded.definition is not None - assert loaded.definition.python_executable == os.path.abspath(sys.executable) + assert loaded.definition.python_executable == service_python_executable() adapter.stop() if isinstance(adapter, LaunchdUserAdapter): @@ -101,7 +107,7 @@ def test_native_service_definition_matches_running_process(tmp_path: Path) -> No assert registration.definition is not None assert loaded.state is ManagerOwnershipState.OWNED assert loaded.definition == registration.definition - assert registration.definition.python_executable == os.path.abspath(sys.executable) + assert registration.definition.python_executable == service_python_executable() content = adapter.artifact_path.read_bytes() if isinstance(adapter, LaunchdUserAdapter): payload = plistlib.loads(content) @@ -111,6 +117,23 @@ def test_native_service_definition_matches_running_process(tmp_path: Path) -> No assert "PathState" in payload["KeepAlive"] assert payload["StandardOutPath"].endswith("logs/server.stdout.log") assert payload["StandardErrorPath"].endswith("logs/server.stderr.log") + elif isinstance(adapter, WindowsTaskSchedulerAdapter): + namespace = "{http://schemas.microsoft.com/windows/2004/02/mit/task}" + payload = ET.fromstring(content) # noqa: S314 + logon_trigger = payload.find(f"{namespace}Triggers/{namespace}LogonTrigger") + logon_type = payload.find(f"{namespace}Principals/{namespace}Principal/{namespace}LogonType") + run_level = payload.find(f"{namespace}Principals/{namespace}Principal/{namespace}RunLevel") + hidden = payload.find(f"{namespace}Settings/{namespace}Hidden") + restart_count = payload.find(f"{namespace}Settings/{namespace}RestartOnFailure/{namespace}Count") + command = payload.find(f"{namespace}Actions/{namespace}Exec/{namespace}Command") + assert logon_trigger is not None + assert logon_type is not None and logon_type.text == "InteractiveToken" + assert run_level is not None and run_level.text == "LeastPrivilege" + assert hidden is not None and hidden.text == "true" + assert restart_count is not None and restart_count.text == "3" + assert command is not None and Path(command.text or "").name.casefold() == "pythonw.exe" + log_location = adapter.log_location(registration.definition) + assert log_location is not None and log_location.endswith("logs") else: unit = content.decode() assert f'ExecStart="{os.path.abspath(sys.executable)}"' in unit @@ -124,6 +147,29 @@ def test_native_service_definition_matches_running_process(tmp_path: Path) -> No _cleanup(adapter) +@pytest.mark.skipif(sys.platform != "win32", reason="Task Scheduler login-trigger behavior is Windows-specific") +def test_native_windows_service_can_disable_login_trigger(tmp_path: Path) -> None: + adapter = _native_adapter(suffix="manual") + controller = ServiceController(adapter) + + try: + installed = controller.install(env_file=_environment_file(tmp_path), start_on_login=False) + + assert installed.ok + assert installed.manager is ManagerState.ACTIVE + loaded = adapter.loaded_registration() + assert loaded.state is ManagerOwnershipState.OWNED + content = adapter.artifact_path.read_bytes() + namespace = "{http://schemas.microsoft.com/windows/2004/02/mit/task}" + payload = ET.fromstring(content) # noqa: S314 + assert payload.find(f"{namespace}Triggers/{namespace}LogonTrigger") is None + assert adapter.manager_state() is ManagerState.ACTIVE + finally: + with suppress(Exception): + controller.uninstall() + _cleanup(adapter) + + def test_native_service_rejects_foreign_registration(tmp_path: Path) -> None: adapter = _native_adapter() environment = _environment_file(tmp_path) @@ -180,13 +226,13 @@ def test_native_service_retry_and_exit_classification(tmp_path: Path) -> None: adapter.artifact_path.write_bytes(plistlib.dumps(payload)) try: - _run("launchctl", "enable", f"gui/{os.getuid()}/{adapter.identifier}") - _run("launchctl", "bootstrap", f"gui/{os.getuid()}", str(adapter.artifact_path)) + _run("launchctl", "enable", f"gui/{_current_uid()}/{adapter.identifier}") + _run("launchctl", "bootstrap", f"gui/{_current_uid()}", str(adapter.artifact_path)) _wait_for(lambda: not token.exists() and _attempt_count(state) == 3, timeout=20) _wait_for(lambda: adapter.manager_state() is ManagerState.INACTIVE) assert adapter.manager_state() is ManagerState.INACTIVE - result = _run("launchctl", "print", f"gui/{os.getuid()}/{adapter.identifier}") + result = _run("launchctl", "print", f"gui/{_current_uid()}/{adapter.identifier}") assert "last exit code = 0" in result.stdout finally: _remove_foreign_registration(adapter) @@ -209,14 +255,22 @@ def _native_adapter(*, suffix: str | None = None) -> NativeServiceAdapter: identifier = f"powercontext-native-{suffix}-{unique}.service" assert identifier.startswith("powercontext-native-") return SystemdUserAdapter(identifier=identifier) + if sys.platform == "win32": + base_identifier = configured or f"PowerContext-Native-{unique}" + identifier = base_identifier if suffix is None else f"{base_identifier}-{suffix}" + assert identifier.startswith("PowerContext-Native-") + return WindowsTaskSchedulerAdapter(identifier=identifier) pytest.skip(f"no native personal-service adapter for {sys.platform}") def _environment_file(tmp_path: Path) -> Path: environment = tmp_path / "powercontext.env" + data_dir = str(tmp_path / "data") + if os.name == "nt": + data_dir = f'"{data_dir}"' environment.write_text( "\n".join(( - f"POWERCONTEXT_HOME={tmp_path / 'data'}", + f"POWERCONTEXT_HOME={data_dir}", f"POWERCONTEXT_SERVER_HTTP_PORT={_unused_loopback_port()}", "POWERCONTEXT_SERVER_DASHBOARD_ENABLED=false", "", @@ -224,6 +278,26 @@ def _environment_file(tmp_path: Path) -> Path: encoding="utf-8", ) environment.chmod(0o600) + if os.name == "nt": + account = subprocess.run( + ["whoami.exe"], # noqa: S607 + capture_output=True, + text=True, + timeout=10, + check=True, + ).stdout.strip() + # Hosted Windows runners can create pytest's temporary files with an + # inherited owner that differs from the account running the test. + _run("icacls.exe", str(environment), "/setowner", account) + _run( + "icacls.exe", + str(environment), + "/inheritance:r", + "/grant:r", + f"{account}:(F)", + "SYSTEM:(F)", + "Administrators:(F)", + ) return environment @@ -282,8 +356,35 @@ def _load_foreign_registration(adapter: NativeServiceAdapter, tmp_path: Path) -> "RunAtLoad": True, }) ) - _run("launchctl", "enable", f"gui/{os.getuid()}/{adapter.identifier}") - _run("launchctl", "bootstrap", f"gui/{os.getuid()}", str(foreign)) + _run("launchctl", "enable", f"gui/{_current_uid()}/{adapter.identifier}") + _run("launchctl", "bootstrap", f"gui/{_current_uid()}", str(foreign)) + elif isinstance(adapter, WindowsTaskSchedulerAdapter): + foreign = tmp_path / "foreign.xml" + definition = ServiceDefinition( + ownership=OWNERSHIP_MARKER, + definition_version=DEFINITION_VERSION, + package_version=version("powercontext"), + python_executable=os.path.abspath(sys.executable), + endpoint="http://127.0.0.1:1", + data_dir=str(tmp_path / "foreign-data"), + env_file=None, + ) + payload = ET.fromstring(adapter.render(definition)) # noqa: S314 + namespace = "{http://schemas.microsoft.com/windows/2004/02/mit/task}" + description = payload.find(f"{namespace}RegistrationInfo/{namespace}Description") + assert description is not None + description.text = "Foreign Task" + foreign.write_bytes(ET.tostring(payload, encoding="utf-16", xml_declaration=True)) + _run( + "schtasks.exe", + "/Create", + "/TN", + adapter.identifier, + "/XML", + str(foreign), + "/F", + "/HRESULT", + ) else: _run( "systemd-run", @@ -298,9 +399,12 @@ def _load_foreign_registration(adapter: NativeServiceAdapter, tmp_path: Path) -> def _remove_foreign_registration(adapter: NativeServiceAdapter) -> None: if isinstance(adapter, LaunchdUserAdapter): - target = f"gui/{os.getuid()}/{adapter.identifier}" + target = f"gui/{_current_uid()}/{adapter.identifier}" _run_ignoring_failure("launchctl", "bootout", target) _run_ignoring_failure("launchctl", "disable", target) + elif isinstance(adapter, WindowsTaskSchedulerAdapter): + _run_ignoring_failure("schtasks.exe", "/End", "/TN", adapter.identifier, "/HRESULT") + _run_ignoring_failure("schtasks.exe", "/Delete", "/TN", adapter.identifier, "/F", "/HRESULT") else: _run_ignoring_failure("systemctl", "--user", "stop", adapter.identifier) _run_ignoring_failure("systemctl", "--user", "reset-failed", adapter.identifier) @@ -326,3 +430,8 @@ def _run_ignoring_failure(*arguments: str) -> None: timeout=30, check=False, ) + + +def _current_uid() -> int: + getuid = getattr(os, "getuid", None) + return int(getuid()) if getuid is not None else 0 diff --git a/tests/test_inference_endpoints.py b/tests/test_inference_endpoints.py index 32f95de99..1544c1e20 100644 --- a/tests/test_inference_endpoints.py +++ b/tests/test_inference_endpoints.py @@ -18,7 +18,7 @@ import json import logging import threading -from collections.abc import Iterator +from collections.abc import Generator from contextlib import contextmanager from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path @@ -111,7 +111,7 @@ def log_message(self, format: str, *args: Any) -> None: # noqa: A002 @contextmanager -def _model_server() -> Iterator[tuple[_RecordingModelServer, str]]: +def _model_server() -> Generator[tuple[_RecordingModelServer, str], None, None]: server = _RecordingModelServer() thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() diff --git a/tests/test_service.py b/tests/test_service.py index fbcae8f90..78ba0740e 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -21,16 +21,18 @@ import subprocess import sys import threading +import xml.etree.ElementTree as ET from dataclasses import replace from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from importlib.metadata import version from pathlib import Path -from typing import cast +from typing import Any, cast from unittest.mock import Mock import pytest from typer.testing import CliRunner +import powercontext.service.cli as service_cli import powercontext_service_bootstrap.__main__ as service_bootstrap from powercontext.paths import POWERCONTEXT_HOME_ENV, powercontext_data_dir from powercontext.service import launcher as service_launcher @@ -38,13 +40,14 @@ from powercontext.service.adapters.base import decode_metadata, definition_state, encode_metadata from powercontext.service.adapters.launchd import LaunchdUserAdapter from powercontext.service.adapters.systemd import SystemdUserAdapter +from powercontext.service.adapters.windows import WindowsTaskSchedulerAdapter from powercontext.service.cli import app as service_app from powercontext.service.controller import ServiceController +from powercontext.service.environment import load_protected_environment_file from powercontext.service.model import ( DEFINITION_VERSION, OWNERSHIP_MARKER, DefinitionState, - EnvironmentFileIdentity, LivenessState, ManagerOwnershipState, ManagerRegistration, @@ -72,7 +75,34 @@ def _definition(tmp_path: Path, **overrides: object) -> ServiceDefinition: "env_file": None, **overrides, } - return ServiceDefinition(**values) + return ServiceDefinition(**cast(dict[str, Any], values)) + + +def _secure_windows_file(path: Path) -> None: + if os.name != "nt": + return + account = subprocess.run( + ["whoami.exe"], # noqa: S607 + capture_output=True, + text=True, + timeout=10, + check=True, + ).stdout.strip() + subprocess.run( + [ # noqa: S607 + "icacls.exe", + str(path), + "/inheritance:r", + "/grant:r", + f"{account}:(F)", + "SYSTEM:(F)", + "Administrators:(F)", + ], + capture_output=True, + text=True, + timeout=10, + check=True, + ) class FakeAdapter: @@ -304,6 +334,19 @@ def test_service_controller_installs_and_starts_one_native_registration(tmp_path assert adapter.events == ["write", "reload", "enable", "start:True"] +@pytest.mark.skipif(sys.platform != "win32", reason="login auto-start opt-out is Windows-specific") +def test_service_controller_can_install_without_login_autostart(tmp_path: Path) -> None: + adapter = FakeAdapter(tmp_path) + controller = ServiceController(adapter, probe=_manager_probe(adapter), sleep=lambda _: None) + + status = controller.install(start_on_login=False) + + assert status.ok + assert adapter.definition is not None and not adapter.definition.start_on_login + assert adapter.manager is ManagerState.ACTIVE + assert adapter.events == ["write", "reload", "enable", "start:True"] + + def test_service_install_is_idempotent_when_definition_is_current(tmp_path: Path) -> None: adapter = FakeAdapter(tmp_path) controller = ServiceController(adapter, probe=_manager_probe(adapter), sleep=lambda _: None) @@ -548,6 +591,7 @@ def test_service_install_accepts_a_private_environment_file( environment = tmp_path / "powercontext.env" environment.write_text("POWERCONTEXT_SERVER_HTTP_PORT=8123\n", encoding="utf-8") environment.chmod(0o600) + _secure_windows_file(environment) adapter = FakeAdapter(tmp_path) controller = ServiceController(adapter, probe=_manager_probe(adapter), sleep=lambda _: None) @@ -567,6 +611,7 @@ def test_service_install_does_not_inherit_an_unrecorded_shell_data_directory( environment = tmp_path / "powercontext.env" environment.write_text("POWERCONTEXT_SERVER_HTTP_PORT=8123\n", encoding="utf-8") environment.chmod(0o600) + _secure_windows_file(environment) ambient_data = tmp_path / "ambient-data" monkeypatch.setenv(POWERCONTEXT_HOME_ENV, str(ambient_data)) adapter = FakeAdapter(tmp_path) @@ -582,8 +627,18 @@ def test_service_install_rejects_a_group_readable_environment_file(tmp_path: Pat environment = tmp_path / "powercontext.env" environment.write_text("POWERCONTEXT_SERVER_HTTP_PORT=8123\n", encoding="utf-8") environment.chmod(0o640) + if os.name == "nt": + _secure_windows_file(environment) + subprocess.run( + ["icacls.exe", str(environment), "/grant", "*S-1-5-32-545:(R)"], # noqa: S607 + capture_output=True, + text=True, + timeout=10, + check=True, + ) - with pytest.raises(ServiceError, match="chmod 600"): + expected = "unexpected account" if os.name == "nt" else "chmod 600" + with pytest.raises(ServiceError, match=expected): ServiceController(FakeAdapter(tmp_path)).install(env_file=environment) @@ -598,7 +653,7 @@ def test_systemd_definition_round_trips_and_detects_tampering(tmp_path: Path) -> assert installed.state is RegistrationState.INSTALLED assert installed.definition == definition - assert f'"{executable}"'.encode() in rendered + assert f'"{executable.replace(chr(92), chr(92) * 2)}"'.encode() in rendered adapter.artifact_path.write_bytes(rendered + b"# changed\n") assert adapter.inspect().state is RegistrationState.INVALID @@ -662,7 +717,7 @@ def test_launchd_definition_round_trips_with_argument_array_and_logs( assert installed.state is RegistrationState.INSTALLED assert installed.definition == definition assert payload["ProgramArguments"][0] == executable - assert payload["StandardOutPath"].endswith("logs/server.stdout.log") + assert payload["StandardOutPath"].replace("\\", "/").endswith("logs/server.stdout.log") retry_token = Path(definition.data_dir) / "logs" / "launchd-retry.enabled" assert payload["KeepAlive"] == {"PathState": {str(retry_token): True}} assert payload["ProgramArguments"][1:3] == ["-m", "powercontext_service_bootstrap"] @@ -696,6 +751,164 @@ def test_launchd_definition_round_trips_with_argument_array_and_logs( ] +def test_windows_definition_round_trips_with_task_scheduler_logs(tmp_path: Path) -> None: + adapter = WindowsTaskSchedulerAdapter( + config_home=tmp_path, + identifier=r"\PowerContext Test", + user_account=r"CONTOSO\alice", + user_sid="S-1-5-21-100-200-300-1001", + ) + definition = _definition(tmp_path) + rendered = adapter.render(definition) + + adapter.write(rendered) + installed = adapter.inspect() + root = ET.fromstring(rendered) # noqa: S314 + namespace = "{http://schemas.microsoft.com/windows/2004/02/mit/task}" + + assert installed.state is RegistrationState.INSTALLED + assert installed.definition == definition + assert rendered.startswith(b"\xff\xfe") + uri = root.find(f"{namespace}RegistrationInfo/{namespace}URI") + principal_user = root.find(f"{namespace}Principals/{namespace}Principal/{namespace}UserId") + hidden = root.find(f"{namespace}Settings/{namespace}Hidden") + restart_count = root.find(f"{namespace}Settings/{namespace}RestartOnFailure/{namespace}Count") + logon_user = root.find(f"{namespace}Triggers/{namespace}LogonTrigger/{namespace}UserId") + arguments = root.find(f"{namespace}Actions/{namespace}Exec/{namespace}Arguments") + assert uri is not None and uri.text == r"\PowerContext Test" + assert principal_user is not None and principal_user.text == "S-1-5-21-100-200-300-1001" + assert hidden is not None and hidden.text == "true" + assert restart_count is not None and restart_count.text == "3" + assert logon_user is not None and logon_user.text == r"CONTOSO\alice" + assert arguments is not None + assert arguments.text is not None and arguments.text.endswith( + r"--stderr " + str(Path(definition.data_dir) / "logs" / "server.stderr.log") + ) + assert (Path(definition.data_dir) / "logs").is_dir() + + +def test_windows_definition_can_disable_login_trigger(tmp_path: Path) -> None: + adapter = WindowsTaskSchedulerAdapter( + config_home=tmp_path, + identifier=r"\PowerContext Test", + user_account=r"CONTOSO\alice", + user_sid="S-1-5-21-100-200-300-1001", + ) + definition = _definition(tmp_path, start_on_login=False) + rendered = adapter.render(definition) + root = ET.fromstring(rendered) # noqa: S314 + namespace = "{http://schemas.microsoft.com/windows/2004/02/mit/task}" + + adapter.write(rendered) + + assert root.find(f"{namespace}Triggers/{namespace}LogonTrigger") is None + assert adapter.inspect().definition == definition + + +def test_windows_loaded_registration_requires_owned_task_shape( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + adapter = WindowsTaskSchedulerAdapter( + config_home=tmp_path, + identifier=r"\PowerContext Test", + user_account=r"CONTOSO\alice", + user_sid="S-1-5-21-100-200-300-1001", + ) + definition = _definition(tmp_path) + rendered = adapter.render(definition) + output = rendered.decode("utf-16") + run = Mock(return_value=subprocess.CompletedProcess(["schtasks.exe"], 0, output, "")) + monkeypatch.setattr(adapter, "_run", run) + + assert adapter.loaded_registration().state is ManagerOwnershipState.OWNED + + foreign = output.replace("true", "false", 1) + run.return_value = subprocess.CompletedProcess(["schtasks.exe"], 0, foreign, "") + + registration = adapter.loaded_registration() + + assert registration.state is ManagerOwnershipState.FOREIGN + assert registration.detail is not None + assert "hidden-window policy" in registration.detail + + +@pytest.mark.parametrize("extra_parent", ["Actions", "Triggers", "Principals"]) +def test_windows_loaded_registration_rejects_extra_task_elements( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + extra_parent: str, +) -> None: + adapter = WindowsTaskSchedulerAdapter( + config_home=tmp_path, + identifier=r"\PowerContext Test", + user_account=r"CONTOSO\alice", + user_sid="S-1-5-21-100-200-300-1001", + ) + definition = _definition(tmp_path) + root = ET.fromstring(adapter.render(definition)) # noqa: S314 + parent = next(child for child in root if child.tag.endswith(extra_parent)) + ET.SubElement( + parent, + parent.tag.rsplit("}", 1)[0] + + "}" + + { + "Actions": "Exec", + "Triggers": "TimeTrigger", + "Principals": "Principal", + }[extra_parent], + ) + output = ET.tostring(root, encoding="utf-16", xml_declaration=True).decode("utf-16") + monkeypatch.setattr( + adapter, + "_run", + Mock(return_value=subprocess.CompletedProcess(["schtasks.exe"], 0, output, "")), + ) + + registration = adapter.loaded_registration() + + assert registration.state is ManagerOwnershipState.FOREIGN + assert registration.detail is not None + assert "structure" in registration.detail + + +@pytest.mark.parametrize( + ("payload", "expected"), + [ + ({"State": "Running", "LastTaskResult": 0}, ManagerState.ACTIVE), + ({"State": "Ready", "LastTaskResult": 0x41303}, ManagerState.INACTIVE), + ({"State": "Ready", "LastTaskResult": 1}, ManagerState.FAILED), + ({"State": "Disabled", "LastTaskResult": 0}, ManagerState.INACTIVE), + ({"State": "Running", "LastTaskResult": 0, "状态": "正在运行"}, ManagerState.ACTIVE), + ], +) +def test_windows_manager_state_uses_locale_independent_task_info( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + payload: dict[str, object], + expected: ManagerState, +) -> None: + adapter = WindowsTaskSchedulerAdapter(config_home=tmp_path) + monkeypatch.setattr( + adapter, + "_run_task_info", + Mock(return_value=subprocess.CompletedProcess(["powershell.exe"], 0, json.dumps(payload), "")), + ) + + assert adapter.manager_state() is expected + + +def test_windows_uninstall_recovery_uses_scoped_task_commands(tmp_path: Path) -> None: + adapter = WindowsTaskSchedulerAdapter( + config_home=tmp_path, + identifier=r"\PowerContext Test", + ) + + assert adapter.uninstall_recovery("stop") == 'schtasks.exe /End /TN "\\PowerContext Test" /HRESULT' + assert adapter.uninstall_recovery("disable") == 'schtasks.exe /Change /TN "\\PowerContext Test" /DISABLE /HRESULT' + assert adapter.uninstall_recovery("remove") == 'schtasks.exe /Delete /TN "\\PowerContext Test" /F /HRESULT' + + def test_launchd_inspect_accepts_only_an_intact_legacy_owned_definition(tmp_path: Path) -> None: adapter = LaunchdUserAdapter(home=tmp_path, uid=501) definition = _definition(tmp_path, definition_version=1) @@ -1134,7 +1347,7 @@ def test_service_install_cli_renders_post_commit_failure_status(monkeypatch: pyt controller.install.side_effect = ServiceError("start failed", status=status) monkeypatch.setattr("powercontext.service.cli._controller", lambda: controller) - result = CliRunner().invoke(service_app, ["install"]) + result = CliRunner().invoke(service_app, ["install", "--start-on-login"]) assert result.exit_code == 1 assert "installation failed: start failed" in result.output @@ -1144,6 +1357,32 @@ def test_service_install_cli_renders_post_commit_failure_status(monkeypatch: pyt assert "logs: fake logs" in result.output +def test_service_install_cli_prompts_for_login_autostart(monkeypatch: pytest.MonkeyPatch) -> None: + status = ServiceStatus( + support=SupportState.SUPPORTED, + registration=RegistrationState.INSTALLED, + definition=DefinitionState.CURRENT, + manager=ManagerState.INACTIVE, + server_liveness=LivenessState.UNREACHABLE, + endpoint="http://127.0.0.1:8000", + log_location="fake logs", + manager_ownership=ManagerOwnershipState.OWNED, + ) + controller = Mock() + controller.install.return_value = status + confirm = Mock(return_value=False) + monkeypatch.setattr(service_cli, "_controller", lambda: controller) + monkeypatch.setattr(service_cli.sys, "platform", "win32") + monkeypatch.setattr(service_cli.typer, "confirm", confirm) + + result = CliRunner().invoke(service_app, ["install"]) + + assert result.exit_code == 0 + confirm.assert_called_once_with("Enable automatic Server startup when you log in?", default=False) + controller.install.assert_called_once_with(env_file=None, start_on_login=False) + assert "without login auto-start" in result.output + + def test_service_uninstall_cli_renders_partial_failure_status(monkeypatch: pytest.MonkeyPatch) -> None: status = ServiceStatus( support=SupportState.SUPPORTED, @@ -1197,6 +1436,7 @@ def test_service_launcher_pins_the_recorded_data_directory( environment = tmp_path / "powercontext.env" environment.write_text(f"{POWERCONTEXT_HOME_ENV}={tmp_path / 'other-data'}\n", encoding="utf-8") environment.chmod(0o600) + _secure_windows_file(environment) recorded_data = tmp_path / "recorded-data" observed_data: list[Path] = [] monkeypatch.setattr( @@ -1213,7 +1453,7 @@ def test_service_launcher_pins_the_recorded_data_directory( definition = _definition( tmp_path, data_dir=str(recorded_data), - env_file=EnvironmentFileIdentity.from_path(environment), + env_file=load_protected_environment_file(environment).identity, ) exit_code = service_launcher.main(definition.launcher_arguments()[3:]) @@ -1239,6 +1479,41 @@ def test_service_launcher_does_not_start_over_an_existing_powercontext_server( run_server.assert_not_called() +def test_service_launcher_can_redirect_server_output_to_owned_log_files( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + stdout_path = tmp_path / "logs" / "server.stdout.log" + stderr_path = tmp_path / "logs" / "server.stderr.log" + monkeypatch.setattr( + service_launcher, + "probe_server", + lambda _endpoint: ProbeResult(ProbeState.UNREACHABLE, "not listening"), + ) + + def run_server(_settings: object) -> None: + print("server output") + print("server error", file=sys.stderr) + + monkeypatch.setattr(service_launcher.server_cli, "_run_configured_server", run_server) + + exit_code = service_launcher.main([ + "--endpoint", + "http://127.0.0.1:8000", + "--data-dir", + str(tmp_path / "data"), + "--stdout", + str(stdout_path), + "--stderr", + str(stderr_path), + ]) + + assert exit_code == 0 + assert stdout_path.read_text(encoding="utf-8") == "server output\n" + assert stderr_path.read_text(encoding="utf-8") == "server error\n" + + +@pytest.mark.skipif(os.name == "nt", reason="launchd bootstrap retry state is POSIX-specific") def test_launchd_launcher_stops_after_a_bounded_number_of_rapid_failures( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_service_environment.py b/tests/test_service_environment.py index 3bb32a576..4b1ba111b 100644 --- a/tests/test_service_environment.py +++ b/tests/test_service_environment.py @@ -15,6 +15,7 @@ from __future__ import annotations import os +import subprocess import sys from importlib.metadata import version from pathlib import Path @@ -39,10 +40,42 @@ def _environment_file(tmp_path: Path, content: str = "POWERCONTEXT_SERVER_HTTP_P path = tmp_path / "powercontext.env" path.write_text(content, encoding="utf-8") path.chmod(0o600) + if os.name == "nt": + _secure_windows_file(path) return path +def _secure_windows_file(path: Path) -> None: + account = subprocess.run( + ["whoami.exe"], # noqa: S607 + capture_output=True, + text=True, + timeout=10, + check=True, + ).stdout.strip() + subprocess.run( + [ # noqa: S607 + "icacls.exe", + str(path), + "/inheritance:r", + "/grant:r", + f"{account}:(F)", + "SYSTEM:(F)", + "Administrators:(F)", + ], + capture_output=True, + text=True, + timeout=10, + check=True, + ) + + def _definition(tmp_path: Path, environment: Path) -> ServiceDefinition: + identity = ( + load_protected_environment_file(environment).identity + if os.name == "nt" + else EnvironmentFileIdentity.from_path(environment) + ) return ServiceDefinition( ownership=OWNERSHIP_MARKER, definition_version=DEFINITION_VERSION, @@ -50,7 +83,7 @@ def _definition(tmp_path: Path, environment: Path) -> ServiceDefinition: python_executable=os.path.abspath(sys.executable), endpoint="http://127.0.0.1:8123", data_dir=str(tmp_path / "data"), - env_file=EnvironmentFileIdentity.from_path(environment), + env_file=identity, ) @@ -61,15 +94,32 @@ def test_secure_env_loader_accepts_owned_0600_regular_file(tmp_path: Path) -> No assert loaded.path == environment assert loaded.values == {"POWERCONTEXT_SERVER_HTTP_PORT": "8123"} - assert loaded.identity.owner_uid == os.getuid() - assert loaded.identity.mode == 0o600 + if os.name == "nt": + assert loaded.identity.owner_uid == 0 + assert loaded.identity.mode == 0o666 + assert loaded.identity.owner_sid is not None + else: + assert loaded.identity.owner_uid == os.getuid() + assert loaded.identity.mode == 0o600 + assert loaded.identity.owner_sid is None def test_secure_env_loader_rejects_group_readable_file(tmp_path: Path) -> None: environment = _environment_file(tmp_path) - environment.chmod(0o640) + if os.name == "nt": + subprocess.run( + ["icacls.exe", str(environment), "/grant", "*S-1-5-32-545:(R)"], # noqa: S607 + capture_output=True, + text=True, + timeout=10, + check=True, + ) + expected = "unexpected account" + else: + environment.chmod(0o640) + expected = "accessible only by its owner" - with pytest.raises(ProtectedEnvironmentFileError, match="accessible only by its owner"): + with pytest.raises(ProtectedEnvironmentFileError, match=expected): load_protected_environment_file(environment) @@ -83,22 +133,40 @@ def test_secure_env_loader_rejects_symbolic_link(tmp_path: Path) -> None: load_protected_environment_file(link) +@pytest.mark.skipif(os.name == "nt", reason="Windows uses ACL identities rather than POSIX user ids") def test_secure_env_loader_rejects_owner_mismatch(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: environment = _environment_file(tmp_path) - current_uid = os.getuid() + getuid = getattr(os, "getuid", None) + assert getuid is not None + current_uid = int(getuid()) monkeypatch.setattr(service_environment.os, "getuid", lambda: current_uid + 1) with pytest.raises(ProtectedEnvironmentFileError, match="owned by the current user"): load_protected_environment_file(environment) +@pytest.mark.skipif(os.name != "nt", reason="Windows uses ACL owner SIDs rather than POSIX user ids") +def test_secure_env_loader_rejects_windows_owner_sid_mismatch( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + environment = _environment_file(tmp_path) + monkeypatch.setattr(service_environment, "_windows_file_owner_sid", lambda _path: "S-1-5-21-foreign") + + with pytest.raises(ProtectedEnvironmentFileError, match="owned by the current user"): + load_protected_environment_file(environment) + + @pytest.mark.parametrize("mutation", ["content", "mode"]) def test_secure_env_loader_rejects_recorded_identity_drift(tmp_path: Path, mutation: str) -> None: environment = _environment_file(tmp_path) - identity = EnvironmentFileIdentity.from_path(environment) + identity = _definition(tmp_path, environment).env_file + assert identity is not None if mutation == "content": environment.write_text("POWERCONTEXT_SERVER_HTTP_PORT=9000\n", encoding="utf-8") else: + if os.name == "nt": + pytest.skip("Windows chmod does not change the ACL identity contract") environment.chmod(0o400) with pytest.raises(ProtectedEnvironmentFileError, match="changed since"): @@ -106,6 +174,8 @@ def test_secure_env_loader_rejects_recorded_identity_drift(tmp_path: Path, mutat def test_secure_env_loader_rejects_atomic_replacement_before_open(tmp_path: Path) -> None: + if os.name == "nt": + pytest.skip("Windows sharing semantics do not permit this POSIX replacement fixture") environment = _environment_file(tmp_path) identity = EnvironmentFileIdentity.from_path(environment) replacement = tmp_path / "replacement.env" @@ -121,6 +191,8 @@ def test_secure_env_loader_uses_opened_inode_when_path_is_replaced( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: + if os.name == "nt": + pytest.skip("Windows sharing semantics do not permit this POSIX replacement fixture") environment = _environment_file(tmp_path) identity = EnvironmentFileIdentity.from_path(environment) replacement = tmp_path / "replacement.env" @@ -145,6 +217,8 @@ def test_secure_env_loader_detects_mutation_during_read( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: + if os.name == "nt": + pytest.skip("Windows sharing semantics do not permit this POSIX mutation fixture") environment = _environment_file(tmp_path) real_read = service_environment.os.read mutated = False @@ -163,6 +237,7 @@ def read_then_mutate(descriptor: int, size: int) -> bytes: load_protected_environment_file(environment) +@pytest.mark.skipif(os.name == "nt", reason="Windows permission drift is covered by ACL validation") def test_definition_state_reports_permission_only_env_drift_as_stale(tmp_path: Path) -> None: environment = _environment_file(tmp_path) definition = _definition(tmp_path, environment) @@ -184,7 +259,16 @@ def test_launcher_rejects_env_drift_without_starting_server( ) -> None: environment = _environment_file(tmp_path) definition = _definition(tmp_path, environment) - environment.chmod(0o640) + if os.name == "nt": + subprocess.run( + ["icacls.exe", str(environment), "/grant", "*S-1-5-32-545:(R)"], # noqa: S607 + capture_output=True, + text=True, + timeout=10, + check=True, + ) + else: + environment.chmod(0o640) runner = Mock() monkeypatch.setattr(service_launcher.server_cli, "_run_configured_server", runner)