From fa4f91e69f148e167939a6ec19806847ee499623 Mon Sep 17 00:00:00 2001 From: Simon <68971770+simonether@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:57:54 +0500 Subject: [PATCH 1/4] Run natively on Windows (1.6.0) On Windows `uvx kwork-mcp` failed at import: the state directory, token store and write ledger relied on fcntl, POSIX modes and uids. Platform code now lives in private_fs, with the Win32 calls in windows.py (ctypes, no new dependencies). Windows creates state directories with an owner-only protected DACL, accepts an existing one only if its owner and DACL, inheritable entries included, admit no one but the user, SYSTEM and Administrators, and outside the profile requires that no one else can rename or re-permission the directories above it, as POSIX rejects shared-writable ancestors. It rejects junctions and ambiguous path syntax, checks token and lock files through their handles and the ledger before each connection, seals tokens with DPAPI bound to the account (a record it cannot open asks for a new login), locks with msvcrt and replaces the token with a write-through rename. State stays in ~/.local/state/kwork-mcp under the profile, not in AppData, which packaged (MSIX) clients see virtualized. POSIX behavior and the token format are unchanged. login warns when run elevated, points non-console runs to PowerShell, prints cmd/PowerShell quoting, the Cursor config path and the Edit Config hint for Claude Desktop, and both entry points write UTF-8 to redirected output. CI type-checks the Windows branches and runs the suite and the wheel smoke test on windows-2025. --- .env.example | 3 +- .gitattributes | 2 + .github/workflows/ci.yml | 38 ++ AGENTS.md | 22 +- CHANGELOG.md | 31 +- README.md | 61 ++- docs/architecture.md | 8 +- docs/configuration.md | 39 +- docs/security.md | 62 ++- pyproject.toml | 7 + scripts/smoke-wheel.sh | 22 +- server.json | 4 +- site/index.html | 46 +- src/kwork_mcp/__init__.py | 17 + src/kwork_mcp/bootstrap.py | 72 ++- src/kwork_mcp/config.py | 19 +- src/kwork_mcp/coordination.py | 29 +- src/kwork_mcp/private_fs.py | 347 ++++++++++++++ src/kwork_mcp/security.py | 160 +++++-- src/kwork_mcp/version.py | 2 +- src/kwork_mcp/windows.py | 375 +++++++++++++++ tests/platforms.py | 39 ++ tests/test_auth_bootstrap.py | 114 +++-- tests/test_config_models_errors_more.py | 2 + tests/test_config_security.py | 8 +- tests/test_coordination.py | 4 +- tests/test_coordination_more.py | 3 + tests/test_hardening.py | 3 +- tests/test_mcp_protocol.py | 2 +- tests/test_release_metadata.py | 20 +- tests/test_security_more.py | 34 +- tests/test_short_commands.py | 11 +- tests/test_windows_support.py | 605 ++++++++++++++++++++++++ 33 files changed, 1994 insertions(+), 217 deletions(-) create mode 100644 .gitattributes create mode 100644 src/kwork_mcp/private_fs.py create mode 100644 src/kwork_mcp/windows.py create mode 100644 tests/platforms.py create mode 100644 tests/test_windows_support.py diff --git a/.env.example b/.env.example index e6b3c4a..498d67c 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,8 @@ # NEVER put KWORK_TOKEN, KWORK_LOGIN, KWORK_PASSWORD, KWORK_PHONE_LAST or # KWORK_PROXY_URL in Codex/Claude MCP config, .env, argv, or a launcher command. # Run `uvx kwork-mcp@ login` once in a real TTY; it prompts privately -# and stores the verified token/optional proxy under 0600. +# and stores the verified token/optional proxy under 0600 (owner-only DACL and +# DPAPI on Windows). # The server serves the single account bound by login. Set the ID only when # several accounts are bound, to pick one of them. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..614b2c2 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Shell scripts run in Git Bash on Windows too, which cannot run CRLF scripts. +*.sh text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff7725b..ca516e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,7 @@ jobs: - run: uv run ruff check . - run: uv run ruff format --check . - run: uv run mypy + - run: uv run mypy --platform win32 tests: name: Tests / Python ${{ matrix.python-version }} @@ -69,6 +70,43 @@ jobs: --cov-report=term-missing --cov-report=xml + windows: + name: Windows / Python ${{ matrix.python-version }} + runs-on: windows-2025 + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + python-version: ["3.12", "3.14"] + defaults: + run: + shell: bash + steps: + # Check out as on Linux; .gitattributes keeps shell scripts LF for everyone else. + - name: Keep LF line endings + run: git config --global core.autocrlf false + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install exact uv and Python + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + version: "0.11.32" + checksum: "acfde570451cfdb8689fa159a138ee805ba4e241c466432750302c86254b0984" + python-version: ${{ matrix.python-version }} + enable-cache: true + cache-dependency-glob: uv.lock + - run: uv sync --locked --all-groups + # Coverage is gated on Linux, where the POSIX branches run. + - run: uv run pytest tests/ -v + - run: uv build --no-sources --wheel + - name: Smoke-test installed wheel + run: | + wheel="$(find dist -maxdepth 1 -name '*.whl' -print -quit)" + test -n "$wheel" + bash scripts/smoke-wheel.sh "$wheel" + package: name: Build and inspect distributions runs-on: ubuntu-24.04 diff --git a/AGENTS.md b/AGENTS.md index 21cd728..23d1175 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,13 +2,14 @@ Stdio MCP gateway for the Kwork freelance marketplace: 22 tools (18 typed reads + a durable `prepare → commit → reconcile` write protocol) on FastMCP 4 / MCP SDK 2 over the pinned -`kwork==0.2.0` client. Python 3.12–3.14, managed with `uv`. +`kwork==0.2.0` client. Python 3.12–3.14 on macOS, Linux and Windows, managed with `uv`. ## Commands - `uv run ruff check .` — lint - `uv run ruff format --check .` — format check - `uv run mypy` — strict type check +- `uv run mypy --platform win32` — the same check for the Windows branches - `uv run python -m pytest tests/ -q` — tests - `uv run python -m pytest tests/ -q --cov=kwork_mcp --cov-report=term-missing` — tests + coverage (gate: 92% branch) - `uv run kwork-mcp` — start the server (stdio); needs a bootstrapped account @@ -26,7 +27,9 @@ src/kwork_mcp/ session.py KworkSessionManager: lazy auth, account identity checks, call_read / call_write_step coordination.py CoordinationStore: shared SQLite — rate limits, circuits, cursors, write ledger, writer lock contracts.py pinned pykwork signatures and generic route params (enforce_route_params) - security.py SecureTokenStore, sanitize_external / redact_text, log redaction + security.py SecureTokenStore, state directory checks, sanitize_external / redact_text, log redaction + private_fs.py platform layer: POSIX modes or Windows owner/DACL checks, locks, DPAPI sealing + windows.py Win32 calls through ctypes (imported only on Windows) errors.py GatewayError taxonomy, classify_upstream_error models.py ResultEnvelope, records, write requests, WriteState middleware.py strict, sanitized tool-input validation @@ -52,6 +55,17 @@ site/ GitHub Pages landing page (Russian), deployed by .github/wor - **Secretless steady state.** The server accepts only safe env (`KWORK_PERSIST_TOKEN=true`, `KWORK_WRITES`, limits, `KWORK_STATE_DIR`, optional `KWORK_EXPECTED_USER_ID`). Login, password, token, phone and proxy go only through `kwork-mcp login` into the account store. +- **Platforms.** All OS differences go through `private_fs`. POSIX keeps state private with + 0700/0600 modes, an fd-anchored `O_NOFOLLOW` directory walk and `flock`. Windows has no mode + bits: state directories are created with an owner-only protected DACL (ancestors outside the + profile must not let others rename or re-permission them), token and lock files are checked + by owner and DACL through their handles and the SQLite ledger by path before each + connection, tokens are sealed with DPAPI bound to the account scope, and locks use + `msvcrt.locking`. The default state stays in the profile (`~/.local/state/kwork-mcp`), not + in AppData, which packaged (MSIX) clients see virtualized. + Write `if sys.platform == "win32": ... else: ...` rather than an early return, so that + `mypy --platform win32` sees no unreachable code; coverage skips the Windows branches and + `windows.py`, which run on the Windows CI job. - **Account selection.** Without `KWORK_EXPECTED_USER_ID` the server serves the single account that login stored a token for (`load_server_config`). With none or several it still starts without a session: every tool answers `auth_required` or `account_binding_required` @@ -123,8 +137,8 @@ site/ GitHub Pages landing page (Russian), deployed by .github/wor - NEVER perform a remote write outside the ledger, and never auto-retry a remote write. - ALWAYS use native Russian in user-facing strings; keep code comments in English (ruff RUF003 rejects Cyrillic look-alike letters in comments). -- ALWAYS run `uv run ruff check . && uv run mypy && uv run python -m pytest tests/ -q` before a - commit. +- ALWAYS run `uv run ruff check . && uv run mypy && uv run mypy --platform win32 && + uv run python -m pytest tests/ -q` before a commit. - Live checks against a real account are read-only unless the owner explicitly allows a write; anonymize any captured response before it becomes a fixture. diff --git a/CHANGELOG.md b/CHANGELOG.md index f3a3609..6639c69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,34 @@ ## [Unreleased] +## [1.6.0] - 2026-10-04 + +### Added + +- Поддержка Windows 10 и 11 без WSL. Раньше `uvx kwork-mcp` на Windows падал при + импорте. Состояние по умолчанию хранится в домашнем каталоге, как на macOS и + Linux: `%USERPROFILE%\.local\state\kwork-mcp`. Права `0700`/`0600` на Windows + ничего не значат, поэтому вместо них проверяются владелец и DACL: каталоги + состояния создаются с доступом только для вашей учётной записи и SYSTEM, а файлы + состояния проверяются по владельцу и DACL перед каждым использованием. Токен + зашифрован DPAPI для текущего пользователя Windows и привязан к аккаунту: другой + пользователь его не прочитает, а файл, скопированный под имя другого аккаунта, не + откроется. + Межпроцессные блокировки на Windows идут через `msvcrt`. Свой `KWORK_STATE_DIR` + вне профиля принимается, только если другие пользователи не могут переименовать + каталоги над ним. На macOS и Linux поведение не изменилось. +- `login` на Windows предупреждает, если запущен от имени администратора: это может + быть другая учётная запись, и сервер в агенте не увидит вход. Если токен не + расшифровывается (сброс пароля Windows, перенос профиля), сервер отвечает + `auth_expired` и просит выполнить login заново. +- `kwork-mcp login` на Windows печатает команды с кавычками для PowerShell и cmd, + путь к конфигу Cursor (`%USERPROFILE%\.cursor\mcp.json`) и подсказку открыть + конфиг Claude Desktop через Settings → Developer → Edit Config. Вывод в pipe или + файл и логи сервера на Windows идут в UTF-8, а не в кодировке ANSI, поэтому + русский текст не ломает команду и читается в логах клиента. +- CI проверяет типы Windows-веток (`mypy --platform win32`) и прогоняет тесты и + установку собранного пакета на Windows. + ### Fixed - Описание `KWORK_WRITES` в `server.json` для MCP Registry больше не упоминает окно @@ -313,7 +341,8 @@ - Legacy `KWORK_TOKEN_FILE`, implicit `.env` loading and in-process-only limiter. - Experimental MCP Tasks and out-of-scope pipeline/business integrations. -[Unreleased]: https://github.com/simonether/kwork-mcp/compare/v1.5.2...HEAD +[Unreleased]: https://github.com/simonether/kwork-mcp/compare/v1.6.0...HEAD +[1.6.0]: https://github.com/simonether/kwork-mcp/releases/tag/v1.6.0 [1.5.2]: https://github.com/simonether/kwork-mcp/releases/tag/v1.5.2 [1.5.1]: https://github.com/simonether/kwork-mcp/releases/tag/v1.5.1 [1.5.0]: https://github.com/simonether/kwork-mcp/releases/tag/v1.5.0 diff --git a/README.md b/README.md index 3043d73..34ea9e1 100644 --- a/README.md +++ b/README.md @@ -30,15 +30,22 @@ Code, Claude Desktop, Codex, Cursor и другим MCP-клиентам. Аге ## Быстрый старт -Нужны macOS или Linux, Python 3.12–3.14 и [uv](https://docs.astral.sh/uv/). -Windows не поддерживается. +Нужны macOS, Linux или Windows 10/11, Python 3.12–3.14 и [uv](https://docs.astral.sh/uv/). +На Windows uv ставится в PowerShell: + +```powershell +powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" +``` + +Если агент работает внутри WSL, для kwork-mcp это Linux со своим домашним каталогом: +выполните `login` там же, где запускается агент. ### 1. Войдите в Kwork (один раз) -В обычном терминале: +В обычном терминале (на Windows в PowerShell или Windows Terminal): ```bash -uvx kwork-mcp@1.5.2 login +uvx kwork-mcp@1.6.0 login ``` Команда скрыто спросит логин и пароль Kwork, а также, по желанию, последние 4 цифры @@ -48,8 +55,9 @@ uvx kwork-mcp@1.5.2 login Найден аккаунт Kwork: your_name (user_id=123456). Привязать его? [y/N]: да ``` -После подтверждения токен сохраняется в защищённое хранилище на вашем компьютере -(`~/.local/state/kwork-mcp`). Логин и пароль нигде не сохраняются. В конце команда +После подтверждения токен сохраняется в защищённое хранилище на вашем компьютере: +`~/.local/state/kwork-mcp`, на Windows `%USERPROFILE%\.local\state\kwork-mcp`. Логин и пароль +нигде не сохраняются. В конце команда напечатает готовые команды подключения для Claude Code и Codex и блок для Claude Desktop и Cursor. @@ -58,29 +66,30 @@ Desktop и Cursor. **Claude Code:** ```bash -claude mcp add kwork --scope user -- uvx kwork-mcp@1.5.2 +claude mcp add kwork --scope user -- uvx kwork-mcp@1.6.0 ``` **Codex:** ```bash -codex mcp add kwork -- uvx kwork-mcp@1.5.2 +codex mcp add kwork -- uvx kwork-mcp@1.6.0 ``` -**Cursor:** [![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/install-mcp?name=kwork&config=eyJjb21tYW5kIjoidXZ4IiwiYXJncyI6WyJrd29yay1tY3BAMS41LjIiXX0%3D) +**Cursor:** [![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/install-mcp?name=kwork&config=eyJjb21tYW5kIjoidXZ4IiwiYXJncyI6WyJrd29yay1tY3BAMS42LjAiXX0%3D)
Claude Desktop и другие клиенты Claude Desktop: Settings → Developer → Edit Config, файл `claude_desktop_config.json`. -Cursor без кнопки: `~/.cursor/mcp.json` или `.cursor/mcp.json` проекта. +Cursor без кнопки: `~/.cursor/mcp.json` (на Windows `%USERPROFILE%\.cursor\mcp.json`) +или `.cursor/mcp.json` проекта. ```json { "mcpServers": { "kwork": { "command": "uvx", - "args": ["kwork-mcp@1.5.2"] + "args": ["kwork-mcp@1.6.0"] } } } @@ -102,9 +111,9 @@ JSON. Перезапустите клиент и попросите агента: «проверь статус аккаунта Kwork». Он вызовет `account_status` и покажет ваш `user_id` и имя. -Без клиента то же видно в терминале: `uvx kwork-mcp@1.5.2 status` покажет, с каким +Без клиента то же видно в терминале: `uvx kwork-mcp@1.6.0 status` покажет, с каким аккаунтом и сайтом запустится сервер, не обращаясь к Kwork. Сменить аккаунт: -`uvx kwork-mcp@1.5.2 logout`, затем снова `login`. +`uvx kwork-mcp@1.6.0 logout`, затем снова `login`. ## Отправка откликов и сообщений @@ -128,7 +137,7 @@ Codex то же включает `default_tools_approval_mode = "writes"` в н ```bash claude mcp remove kwork --scope user -claude mcp add kwork --scope user -e KWORK_WRITES=auto -- uvx kwork-mcp@1.5.2 +claude mcp add kwork --scope user -e KWORK_WRITES=auto -- uvx kwork-mcp@1.6.0 ``` В режиме `auto` от чужих команд в текстах проектов и сообщений защищает только @@ -148,7 +157,7 @@ claude mcp add kwork --scope user -e KWORK_WRITES=auto -- uvx kwork-mcp@1.5.2 `KWORK_SITE=com`. Например, для Claude Code вторым сервером рядом с kwork.ru: ```bash -claude mcp add kwork-com --scope user -e KWORK_SITE=com -- uvx kwork-mcp@1.5.2 +claude mcp add kwork-com --scope user -e KWORK_SITE=com -- uvx kwork-mcp@1.6.0 ``` Аккаунт и токен у kwork.ru и kwork.com общие, поэтому заново входить не нужно. @@ -161,23 +170,23 @@ claude mcp add kwork-com --scope user -e KWORK_SITE=com -- uvx kwork-mcp@1.5.2 | Что видите | Что делать | |---|---| -| `auth_required` или `auth_expired` | Входа нет или он истёк: выполните `uvx kwork-mcp@1.5.2 login` и перезапустите клиент | -| `account_binding_required` | Вы входили в несколько аккаунтов: укажите нужный в `KWORK_EXPECTED_USER_ID` или удалите лишний вход командой `uvx kwork-mcp@1.5.2 logout ` | -| Непонятно, какой аккаунт и сайт использует сервер | `uvx kwork-mcp@1.5.2 status` покажет это без запросов к Kwork | +| `auth_required` или `auth_expired` | Входа нет или он истёк: выполните `uvx kwork-mcp@1.6.0 login` и перезапустите клиент | +| `account_binding_required` | Вы входили в несколько аккаунтов: укажите нужный в `KWORK_EXPECTED_USER_ID` или удалите лишний вход командой `uvx kwork-mcp@1.6.0 logout ` | +| Непонятно, какой аккаунт и сайт использует сервер | `uvx kwork-mcp@1.6.0 status` покажет это без запросов к Kwork | | Сервер не стартует, «некорректная конфигурация: …» | Проверьте названные переменные `KWORK_*` в конфиге клиента | | «KWORK_ENABLE_WRITES удалена в 1.5.0» | Замените её на `KWORK_WRITES=off`, `confirm` или `auto` | | `write_disabled` | Сервер запущен с `KWORK_WRITES=off`, доступно только чтение | | `captcha` | Войдите в Kwork в браузере, пройдите капчу, затем повторите `login` | -| Claude Desktop не видит сервер | Возьмите блок для Claude Desktop из вывода `login` (в нём полный путь к `uvx`) или укажите путь из `which uvx`, затем перезапустите приложение | +| Claude Desktop не видит сервер | Возьмите блок для Claude Desktop из вывода `login` (в нём полный путь к `uvx`) или укажите путь из `which uvx` (на Windows `where uvx`), затем перезапустите приложение | | `ambiguous_write` с `related_write_id` | Отправка с неизвестным результатом блокирует новые. Попросите агента выполнить `reconcile_write` для этого ID | | Сверка долго не сходится | Проверьте операцию на сайте Kwork, указанном в поле `site` у `pending-writes`, и зафиксируйте исход вручную (команды ниже) | Ручная фиксация исхода запускается с теми же `KWORK_*` переменными, что у сервера: ```bash -uvx kwork-mcp@1.5.2 pending-writes -uvx kwork-mcp@1.5.2 resolve-write succeeded # операция на Kwork прошла -uvx kwork-mcp@1.5.2 resolve-write absent # операции на Kwork нет +uvx kwork-mcp@1.6.0 pending-writes +uvx kwork-mcp@1.6.0 resolve-write succeeded # операция на Kwork прошла +uvx kwork-mcp@1.6.0 resolve-write absent # операции на Kwork нет ``` ## Инструменты @@ -212,8 +221,10 @@ uvx kwork-mcp@1.5.2 resolve-write absent # операции на перед каждой записью. - Тексты проектов, профилей и сообщений помечаются как внешние данные, а не инструкции для агента. -- Токен лежит в файлах с правами `0600`. Приложение их не шифрует, поэтому - используйте шифрование диска. +- На macOS и Linux токен лежит в файлах с правами `0600`. Приложение его не + шифрует, поэтому используйте шифрование диска. +- На Windows каталог с токеном открыт только вашей учётной записи и SYSTEM, а сам + токен зашифрован DPAPI: прочитать его может только ваш пользователь Windows. - Лимиты запросов к Kwork общие для всех процессов одного аккаунта. Подробно: [модель безопасности](docs/security.md), [настройки](docs/configuration.md), @@ -226,7 +237,7 @@ git clone https://github.com/simonether/kwork-mcp.git cd kwork-mcp uv sync --locked --dev uv run ruff check . && uv run ruff format --check . -uv run mypy +uv run mypy && uv run mypy --platform win32 uv run pytest tests/ -q --cov=kwork_mcp ``` diff --git a/docs/architecture.md b/docs/architecture.md index 45f750a..cb6e632 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -44,7 +44,9 @@ flowchart LR Fingerprint общей rate/circuit/write policy закрепляется в metadata: процесс с несовместимой конфигурацией того же state directory завершается fail-loud. - **Secure credential store** атомарно сохраняет account-scoped session token и - optional proxy URL под межпроцессным `flock`. + optional proxy URL под межпроцессным lock (`flock`, на Windows `msvcrt.locking`). +- **Platform layer** (`private_fs`, Win32 calls в `windows.py`) прячет различия ОС: + POSIX modes и `flock` либо Windows owner/DACL checks, DPAPI и `msvcrt` locks. - **Upstream adapter** фиксирует `kwork==0.2.0`, ограничивает generic route params и сохраняет error payloads, которые исходная библиотека могла потерять. @@ -105,7 +107,9 @@ State root открывается через проверенную FD-цепо по одному `readlink` hop с повторным обходом и loop limit, поэтому target не может скрыть writable intermediate alias. Missing tail создаётся и открывается только через `mkdirat/openat(O_NOFOLLOW)`; final directory обязан быть current-owned -exact `0700`. +exact `0700`. На Windows FD-цепочки нет: недостающие каталоги создаются с +owner-only protected DACL, а final directory и каждый state file проверяются по +owner и DACL (см. [security.md](security.md#windows)). ## Межпроцессная координация diff --git a/docs/configuration.md b/docs/configuration.md index 1233a98..3232961 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -98,7 +98,7 @@ Codex/Claude MCP configuration, даже если host помечает их к | Переменная | Default | Ограничения | |---|---:|---| | `KWORK_TIMEOUT` | `30` | 1–120 секунд | -| `KWORK_STATE_DIR` | XDG/`~/.local/state/kwork-mcp` | Только абсолютный путь | +| `KWORK_STATE_DIR` | XDG/`~/.local/state/kwork-mcp`; Windows: `%USERPROFILE%\.local\state\kwork-mcp` | Только абсолютный путь | | `KWORK_LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR` | `KWORK_TOKEN_FILE` удалён и намеренно вызывает ошибку конфигурации. Один общий @@ -108,14 +108,16 @@ owner/mode/regular-file/no-symlink и обязательного `get_me`. Serve legacy-файл не читает и автоматически не импортирует. После validated import legacy source сохраняется; удалите его вручную только после проверки нового flow. -State directory должен принадлежать текущему OS user и иметь exact mode `0700`. +На Linux/macOS state directory должен принадлежать текущему OS user и иметь exact +mode `0700`. Проверяется вся physical ancestor chain: допустимы только root/current-owner каталоги без group/other write. Один sticky shared temp boundary разрешён, но обычный `0777` parent, вложенный shared boundary, чужой owner, final symlink и special bits отклоняются. Trusted non-final aliases раскрываются по одному hop с повторной проверкой, а missing components открываются через FD-relative `O_NOFOLLOW`, поэтому alias target/race не может скрыть writable ancestor. -`coordination.sqlite3`, credential/lock files должны иметь `0600`. Account record +`coordination.sqlite3`, credential/lock files должны иметь `0600` (Windows-правила +ниже). Account record содержит verified token и optional proxy URL. Legacy record без proxy означает direct connection. Record с `https` proxy или без явного port (такой proxy не работал и в 1.0.0rc1) при загрузке отклоняется как @@ -123,9 +125,26 @@ Legacy record без proxy означает direct connection. Record с `https` удалить proxy, остановите процессы account/state, повторите login и перезапустите MCP; уже открытый client record не перечитывает. Не размещайте общий state на NFS или другом filesystem без надёжных POSIX locks/SQLite semantics. -Production runtime использует `fcntl`/`flock` и поддерживает Linux/macOS, но не -Windows. ACL/MAC/mount policy не должны давать другим principals write вопреки -mode bits; при сомнении используйте отдельный local private volume. +На Linux/macOS runtime использует `fcntl`/`flock`. ACL/MAC/mount policy не должны +давать другим principals write вопреки mode bits; при сомнении используйте +отдельный local private volume. + +На Windows mode bits не действуют, и вместо них проверяются owner и DACL. +Default state directory — `%USERPROFILE%\.local\state\kwork-mcp`, `XDG_STATE_HOME` не учитывается. Не +`%LOCALAPPDATA%`: упакованные (MSIX) приложения вроде Claude Desktop видят свою +копию AppData, и сервер, запущенный клиентом, разошёлся бы с терминалом. +Gateway создаёт недостающие каталоги с доступом только для текущего user и +SYSTEM. Существующий каталог принимается, если его owner — текущий user, SYSTEM +или Administrators и DACL не пускает никого другого. Каталог, созданный вручную в +корне диска, обычно наследует `Authenticated Users: Modify` и отклоняется как +`state_directory_acl_not_private`: укажите несуществующий путь, gateway создаст его +сам. Для пути вне профиля пользователя каталоги выше не должны позволять другим +пользователям переименовать их (`state_directory_untrusted_writable_ancestor`): +папка в корне диска, созданная вручную, обычно позволяет. Сетевые пути, junction в +конце пути и FAT/exFAT не подходят. Token record на Windows дополнительно +зашифрован DPAPI текущего user; после сброса пароля Windows или переноса профиля +он не открывается, и сервер просит выполнить login заново (`auth_expired`). +Подробно — в [модели безопасности](security.md#windows). ## Rate limits и resilience @@ -246,10 +265,12 @@ Bootstrap prompts идут в stderr, success — один allowlisted JSON obje URL/authority и userinfo динамически добавляются в redaction set для логов и внешних payload; отдельные proxy user/host fragments — только от 8 символов (`MIN_DISTINCTIVE_SECRET_LENGTH`), чтобы короткое `user` не портило обычные данные. -Credential record защищён mode `0600`, но не application-level encryption: -используйте шифрование диска, private backups и отдельную OS account. +На Linux/macOS credential record защищён mode `0600`, но не application-level +encryption: используйте шифрование диска, private backups и отдельную OS account. +На Windows record зашифрован DPAPI и открывается только под тем же Windows user. Если filesystem error произошёл после atomic `os.replace`, но до подтверждённого -directory `fsync`, результат возвращается как `credential_update_unknown`: новый +directory `fsync` и повторной проверки файла (на Windows directory `fsync` нет), +результат возвращается как `credential_update_unknown`: новый record уже может быть видим. Не считайте это гарантированным сохранением старого token и сначала повторно проверьте store/bootstrap. diff --git a/docs/security.md b/docs/security.md index f25b23b..372e849 100644 --- a/docs/security.md +++ b/docs/security.md @@ -37,6 +37,58 @@ non-sticky `0777/child-0700` намеренно неприемлем. компрометация root/current OS user находятся вне гарантии. Для таких окружений нужен отдельный private local state volume и операционная проверка ACL/mount. +### Windows + +Mode bits на Windows не ограничивают доступ, а directory нельзя открыть как FD, +поэтому POSIX walk заменён проверкой owner и DACL (`private_fs`, Win32 calls в +`windows.py`). Trusted principals те же, что у OpenSSH for Windows для private +keys: текущий user, SYSTEM и Administrators (они и так могут забрать любой файл). + +- Каждый недостающий компонент пути создаётся `CreateDirectoryW` сразу с protected + DACL `D:P(A;OICI;FA;;;)(A;OICI;FA;;;SY)`, поэтому каталог ни на миг не + получает унаследованный, возможно общий, DACL родителя. +- Final directory не может быть symlink или junction (name-surrogate reparse + point). Его owner обязан быть trusted principal, а каждая allow entry DACL — для + trusted principal или OWNER RIGHTS (`S-1-3-4`, его даёт `os.mkdir(path, 0o700)` в + Python 3.12.4+; owner уже проверен). Inherit-only entries тоже учитываются: они + достались бы каждому новому файлу. Исключение — CREATOR OWNER, который + превращается в создателя файла. Deny entries доступ не расширяют. Отсутствующий + DACL означает полный доступ для всех и отклоняется. +- Token и lock files проверяются по owner и DACL через открытый handle + (`GetSecurityInfo`); token, кроме того, сверяется с файлом, прошедшим lstat (тот же + volume и file ID). `coordination.sqlite3`, как и на POSIX, проверяется по пути + перед каждым соединением. +- Каталоги выше state directory внутри профиля пользователя и над ним (`C:\`, + `C:\Users`) не проверяются: по умолчанию другие пользователи не могут их + переименовать или сменить им права. Для пути вне профиля каждый каталог выше, включая + корень диска, должен принадлежать user, SYSTEM, Administrators или + TrustedInstaller, и никто другой не может иметь на нём DELETE, WRITE_DAC, + WRITE_OWNER, FILE_DELETE_CHILD или GENERIC_ALL (DELETE на корне диска безвреден). + Иначе: `state_directory_untrusted_ancestor_owner` или + `state_directory_untrusted_writable_ancestor`. Это аналог POSIX-запрета + shared-writable ancestors: кто может переименовать родителя, тот может подменить + ledger между проверкой и открытием. +- Token record шифруется `CryptProtectData` текущего Windows user с entropy + `kwork-mcp token `. Другой user не расшифрует record и не подложит свой, + а файл, скопированный под имя другого аккаунта, не откроется. Record, который + DPAPI не открывает (другой user, сброс пароля Windows, перенос профиля), даёт + `auth_expired` с подсказкой выполнить login: новый вход перезаписывает его без + расшифровки. Ledger и lock files не шифруются: их защищает DACL. +- `login` в окне администратора предупреждает: повышение прав может означать другую + учётную запись Windows (другой administrator или скрытая учётная запись + Administrator Protection), и токен попал бы в её профиль под её ключ DPAPI. +- Межпроцессные locks — `msvcrt.locking` на первом байте lock file; Windows снимает + их при закрытии handle или завершении процесса. Directory `fsync` недоступен, + поэтому token record заменяется `MoveFileExW` с `MOVEFILE_WRITE_THROUGH`. Rename и + unlink до секунды повторяются при sharing violation (антивирус или индексатор + держат свежий файл); remote writes это не затрагивает. +- UNC, device (`\\?\`, `\\.\`) и drive-relative paths, alternate data streams, + reserved device names (`NUL`, `COM1`) и имена с точкой или пробелом в конце + отклоняются как `state_directory_path_invalid`. + +Вне гарантии на Windows: компрометация текущего user или administrator, сетевые +диски и FAT/exFAT (там нет DACL, такой state directory отклоняется). + ## Credentials и account identity - `.env` из cwd никогда не загружается. @@ -67,9 +119,12 @@ non-sticky `0777/child-0700` намеренно неприемлем. validation. - Credential record содержит подтверждённые `user_id`/`username`, token и optional proxy URL. Его dataclass repr скрывает credential fields. -- Token и lock открываются с `O_NOFOLLOW`, проверкой regular file, owner и mode. +- Token и lock открываются с `O_NOFOLLOW`, проверкой regular file, owner и mode. На + Windows `O_NOFOLLOW` нет: symlink на чужой файл не пройдёт проверку owner и DACL + через handle. - Запись record использует private tempfile, `fsync`, atomic `os.replace` и directory - `fsync` под межпроцессным `flock`. + `fsync` под межпроцессным `flock` (на Windows `msvcrt.locking`, без directory + `fsync`). - До replace сбой сохраняет старый record побайтно; после replace сбой durability классифицируется `credential_update_unknown`, потому что новый record уже может быть видим. @@ -81,7 +136,8 @@ non-sticky `0777/child-0700` намеренно неприемлем. Bootstrap сначала берёт account writer lock, затем credential flock. Он не может ротировать token/proxy посреди multi-step write; concurrent bootstrap получает typed `write_in_progress`. Legacy `~/.kwork_token` читается только после явного -согласия и проверок regular-file/current-owner/exact-0600/no-symlink/inode +согласия и проверок regular-file/current-owner/exact-0600 (на Windows owner и +DACL)/no-symlink/inode stability/size/UTF-8/single-line. Normal startup его никогда не импортирует. Закрытие bootstrap client имеет абсолютный пятисекундный deadline и bounded cancel-drain. После deadline account/token locks освобождаются; если store уже diff --git a/pyproject.toml b/pyproject.toml index 4d74874..d3def54 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ classifiers = [ "Development Status :: 5 - Production/Stable", "License :: OSI Approved :: MIT License", "Operating System :: MacOS :: MacOS X", + "Operating System :: Microsoft :: Windows", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.12", @@ -103,6 +104,8 @@ required-version = ">=0.11.6" [tool.coverage.run] branch = true source = ["kwork_mcp"] +# The Win32 calls run only on the Windows CI job, which has no gate. +omit = ["*/kwork_mcp/windows.py"] [tool.coverage.report] fail_under = 92 @@ -112,6 +115,10 @@ exclude_also = [ "if TYPE_CHECKING:", "if __name__ == .__main__.:", "raise NotImplementedError", + 'if sys\.platform == "win32":', +] +partial_also = [ + 'if sys\.platform != "win32":', ] [tool.mypy] diff --git a/scripts/smoke-wheel.sh b/scripts/smoke-wheel.sh index 94b4c1d..f84bbf1 100644 --- a/scripts/smoke-wheel.sh +++ b/scripts/smoke-wheel.sh @@ -14,6 +14,12 @@ cleanup() { } trap cleanup EXIT cd "$smoke_dir" +# Native Windows programs need a Windows path; Git Bash ships cygpath. +if command -v cygpath >/dev/null; then + state_dir="$(cygpath -m "$smoke_dir")/state" +else + state_dir="${smoke_dir}/state" +fi runner=(uv run --isolated --no-project --with "$wheel" --) @@ -24,7 +30,7 @@ import kwork_mcp from kwork_mcp.bootstrap import run_bootstrap_cli from kwork_mcp.server import create_server -assert kwork_mcp.__version__ == "1.5.2" +assert kwork_mcp.__version__ == "1.6.0" assert callable(run_bootstrap_cli) create_server() scripts = { @@ -39,9 +45,10 @@ assert scripts == { PY "${runner[@]}" kwork-mcp-bootstrap --help >/dev/null -[[ "$("${runner[@]}" kwork-mcp-bootstrap --version)" == "1.5.2" ]] +# Python on Windows ends printed lines with CRLF; the comparisons drop the CR. +[[ "$("${runner[@]}" kwork-mcp-bootstrap --version | tr -d '\r')" == "1.6.0" ]] "${runner[@]}" kwork-mcp --help >/dev/null -[[ "$("${runner[@]}" kwork-mcp --version)" == "1.5.2" ]] +[[ "$("${runner[@]}" kwork-mcp --version | tr -d '\r')" == "1.6.0" ]] set +e "${runner[@]}" kwork-mcp login login.stdout 2>login.stderr @@ -52,16 +59,16 @@ set -e # status is offline: with no stored login it explains and creates nothing. set +e -KWORK_STATE_DIR="${smoke_dir}/state" "${runner[@]}" kwork-mcp status >status.stdout 2>status.stderr +KWORK_STATE_DIR="$state_dir" "${runner[@]}" kwork-mcp status >status.stdout 2>status.stderr status_status="$?" set -e [[ "$status_status" -eq 2 ]] -grep -q "kwork-mcp 1.5.2" status.stdout +grep -q "kwork-mcp 1.6.0" status.stdout [[ ! -e "${smoke_dir}/state" ]] # Directory inspections start the bare server with no account: it must list the # tools, answer every call with auth_required and create no state. -KWORK_STATE_DIR="${smoke_dir}/state" "${runner[@]}" python -I - <<'PY' +KWORK_STATE_DIR="$state_dir" "${runner[@]}" python -I - <<'PY' import json import subprocess import sys @@ -78,7 +85,8 @@ server = subprocess.Popen( stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, - text=True, + # MCP speaks UTF-8; the locale default on Windows is the ANSI code page. + encoding="utf-8", ) assert server.stdin is not None and server.stdout is not None answers = {} diff --git a/server.json b/server.json index 28603af..5f73cd6 100644 --- a/server.json +++ b/server.json @@ -3,7 +3,7 @@ "name": "io.github.simonether/kwork-mcp", "title": "Kwork", "description": "Account-bound, typed and durable stdio MCP gateway for the Kwork freelance marketplace", - "version": "1.5.2", + "version": "1.6.0", "websiteUrl": "https://github.com/simonether/kwork-mcp", "repository": { "url": "https://github.com/simonether/kwork-mcp", @@ -13,7 +13,7 @@ { "registryType": "pypi", "identifier": "kwork-mcp", - "version": "1.5.2", + "version": "1.6.0", "runtimeHint": "uvx", "transport": { "type": "stdio" diff --git a/site/index.html b/site/index.html index 49cabad..842b74b 100644 --- a/site/index.html +++ b/site/index.html @@ -56,8 +56,8 @@ "url": "https://simonether.github.io/kwork-mcp/", "image": "https://simonether.github.io/kwork-mcp/og.png", "applicationCategory": "DeveloperApplication", - "operatingSystem": "macOS, Linux", - "softwareVersion": "1.5.2", + "operatingSystem": "macOS, Linux, Windows", + "softwareVersion": "1.6.0", "softwareRequirements": "Python 3.12–3.14, uv", "programmingLanguage": "Python", "inLanguage": "ru", @@ -83,10 +83,10 @@ {"@type": "Question", "name": "Это официальный инструмент Kwork?", "acceptedAnswer": {"@type": "Answer", "text": "Нет. Это независимый проект с открытым кодом под лицензией MIT. Kwork его не выпускает и не поддерживает."}}, {"@type": "Question", "name": "Может ли агент откликнуться на проект без меня?", "acceptedAnswer": {"@type": "Answer", "text": "Только если вы сами это разрешили. По умолчанию агент показывает точный текст, цену и получателя и отправляет только после вашего «да». С KWORK_WRITES=auto он отправляет сам, с KWORK_WRITES=off только читает."}}, {"@type": "Question", "name": "Что будет, если связь оборвётся при отправке?", "acceptedAnswer": {"@type": "Answer", "text": "Сервер не повторит запись сам и не угадает исход. Новые отправки для аккаунта ждут, пока агент не сверит результат с Kwork или вы не разберёте запись вручную командой kwork-mcp resolve-write."}}, - {"@type": "Question", "name": "Где хранятся логин и пароль?", "acceptedAnswer": {"@type": "Answer", "text": "Нигде. Их спрашивает только kwork-mcp login через скрытый ввод, а на диск сохраняется токен в ~/.local/state/kwork-mcp с правами 0600. Токен не шифруется, поэтому включите шифрование диска."}}, - {"@type": "Question", "name": "Как сменить аккаунт?", "acceptedAnswer": {"@type": "Answer", "text": "Выполните в терминале uvx kwork-mcp@1.5.2 logout, а затем login с другим аккаунтом. Команда status покажет, с каким аккаунтом и сайтом запустится сервер, без запросов к Kwork."}}, + {"@type": "Question", "name": "Где хранятся логин и пароль?", "acceptedAnswer": {"@type": "Answer", "text": "Нигде. Их спрашивает только kwork-mcp login через скрытый ввод, а на диск сохраняется токен: на macOS и Linux в ~/.local/state/kwork-mcp с правами 0600, на Windows в %USERPROFILE%\\.local\\state\\kwork-mcp, зашифрованный DPAPI. На macOS и Linux токен не шифруется, поэтому включите шифрование диска."}}, + {"@type": "Question", "name": "Как сменить аккаунт?", "acceptedAnswer": {"@type": "Answer", "text": "Выполните в терминале uvx kwork-mcp@1.6.0 logout, а затем login с другим аккаунтом. Команда status покажет, с каким аккаунтом и сайтом запустится сервер, без запросов к Kwork."}}, {"@type": "Question", "name": "Какие клиенты поддерживаются?", "acceptedAnswer": {"@type": "Answer", "text": "Claude Code, Claude Desktop, Codex, Cursor и другие MCP-клиенты, которые умеют запускать локальный сервер через stdio."}}, - {"@type": "Question", "name": "Работает ли на Windows?", "acceptedAnswer": {"@type": "Answer", "text": "Нет, только macOS и Linux."}}, + {"@type": "Question", "name": "Работает ли на Windows?", "acceptedAnswer": {"@type": "Answer", "text": "Да, на Windows 10 и 11 без WSL. uv ставится одной командой в PowerShell, дальше всё так же, как на macOS и Linux."}}, {"@type": "Question", "name": "Сколько стоит?", "acceptedAnswer": {"@type": "Answer", "text": "Нисколько: kwork-mcp распространяется бесплатно под лицензией MIT."}} ] } @@ -136,10 +136,10 @@

MCP-сервер для Kwork

Установить
- uvx kwork-mcp@1.5.2 login + uvx kwork-mcp@1.6.0 login
-

Бесплатно и с открытым кодом (MIT). macOS и Linux, Python 3.12–3.14. Версия 1.5.2 на PyPI.

+

Бесплатно и с открытым кодом (MIT). macOS, Linux и Windows, Python 3.12–3.14. Версия 1.6.0 на PyPI.

@@ -237,7 +237,7 @@

Каждая отправка с вашего «да»

  • Пароль не попадает в конфиг

    -

    Логин и пароль вводятся один раз через скрытый ввод и нигде не сохраняются. Токен лежит на вашем компьютере в файлах с правами 0600.

    +

    Логин и пароль вводятся один раз через скрытый ввод и нигде не сохраняются. Токен лежит на вашем компьютере в файлах, открытых только вашей учётной записи.

  • Только ваш аккаунт

    @@ -256,17 +256,21 @@

    Чужой текст не команда

    Установка

    -

    Нужны macOS или Linux, Python 3.12–3.14 и uv. Windows не поддерживается.

    +

    Нужны macOS, Linux или Windows 10/11, Python 3.12–3.14 и uv. На Windows uv ставится в PowerShell:

    +
    +
    PowerShell
    +
    powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
    +
    1. Войдите в Kwork (один раз)

      -

      Запустите в обычном терминале:

      +

      Запустите в обычном терминале (на Windows в PowerShell):

      терминал
      -
      uvx kwork-mcp@1.5.2 login
      +
      uvx kwork-mcp@1.6.0 login
      -

      Команда скрыто спросит логин и пароль Kwork, покажет найденный аккаунт и после подтверждения сохранит токен в ~/.local/state/kwork-mcp. В конце она напечатает готовые команды подключения, а для Claude Desktop — блок с полным путём к uvx.

      +

      Команда скрыто спросит логин и пароль Kwork, покажет найденный аккаунт и после подтверждения сохранит токен в ~/.local/state/kwork-mcp (на Windows %USERPROFILE%\.local\state\kwork-mcp). В конце она напечатает готовые команды подключения, а для Claude Desktop — блок с полным путём к uvx.

    2. Подключите агента

      @@ -276,20 +280,20 @@

      Подключите агента

      Claude Code

      терминал
      -
      claude mcp add kwork --scope user -- uvx kwork-mcp@1.5.2
      +
      claude mcp add kwork --scope user -- uvx kwork-mcp@1.6.0

    Codex

    терминал
    -
    codex mcp add kwork -- uvx kwork-mcp@1.5.2
    +
    codex mcp add kwork -- uvx kwork-mcp@1.6.0

    Cursor

    -

    Добавить в Cursor

    -

    Кнопка откроет Cursor и предложит добавить сервер. Вручную: блок из вкладки «Claude Desktop» в ~/.cursor/mcp.json.

    +

    Добавить в Cursor

    +

    Кнопка откроет Cursor и предложит добавить сервер. Вручную: блок из вкладки «Claude Desktop» в ~/.cursor/mcp.json (на Windows %USERPROFILE%\.cursor\mcp.json).

    Claude Desktop

    @@ -300,7 +304,7 @@

    Claude Desktop

    "mcpServers": { "kwork": { "command": "uvx", - "args": ["kwork-mcp@1.5.2"] + "args": ["kwork-mcp@1.6.0"] } } } @@ -312,7 +316,7 @@

    Claude Desktop

  • Проверьте

    -

    Перезапустите клиент и попросите агента: проверь статус аккаунта Kwork. Он покажет ваш user_id и имя. Без клиента то же видно в терминале: uvx kwork-mcp@1.5.2 status.

    +

    Перезапустите клиент и попросите агента: проверь статус аккаунта Kwork. Он покажет ваш user_id и имя. Без клиента то же видно в терминале: uvx kwork-mcp@1.6.0 status.

    Каждую отправку агент сначала покажет вам на подтверждение. Чтобы он отправлял сам, добавьте в конфиг KWORK_WRITES=auto, для одного только чтения — KWORK_WRITES=off (в Claude Code: -e KWORK_WRITES=off). Подробности, решение проблем и ручная сверка отправок описаны в README.

  • @@ -408,11 +412,11 @@

    Вопросы

    Где хранятся логин и пароль? -

    Нигде. Их спрашивает только kwork-mcp login через скрытый ввод, а на диск сохраняется токен в ~/.local/state/kwork-mcp с правами 0600. Токен не шифруется, поэтому включите шифрование диска.

    +

    Нигде. Их спрашивает только kwork-mcp login через скрытый ввод, а на диск сохраняется токен: на macOS и Linux в ~/.local/state/kwork-mcp с правами 0600, на Windows в %USERPROFILE%\.local\state\kwork-mcp, зашифрованный DPAPI. На macOS и Linux токен не шифруется, поэтому включите шифрование диска.

    Как сменить аккаунт? -

    Выполните в терминале uvx kwork-mcp@1.5.2 logout, а затем login с другим аккаунтом. Команда status покажет, с каким аккаунтом и сайтом запустится сервер, без запросов к Kwork.

    +

    Выполните в терминале uvx kwork-mcp@1.6.0 logout, а затем login с другим аккаунтом. Команда status покажет, с каким аккаунтом и сайтом запустится сервер, без запросов к Kwork.

    Какие клиенты поддерживаются? @@ -420,7 +424,7 @@

    Вопросы

    Работает ли на Windows? -

    Нет, только macOS и Linux.

    +

    Да, на Windows 10 и 11 без WSL. uv ставится одной командой в PowerShell, дальше всё так же, как на macOS и Linux.

    Сколько стоит? diff --git a/src/kwork_mcp/__init__.py b/src/kwork_mcp/__init__.py index 69ed99d..5864d9d 100644 --- a/src/kwork_mcp/__init__.py +++ b/src/kwork_mcp/__init__.py @@ -1,5 +1,6 @@ from __future__ import annotations +import io import sys from collections.abc import Sequence @@ -9,7 +10,23 @@ __all__ = ["__version__", "main"] +def use_utf8_streams() -> None: + """Write stdout and stderr as UTF-8. + + On Windows Python encodes redirected output (a pipe, a file, Git Bash) in + the ANSI code page, which may lack Cyrillic and is not what MCP clients + read. The console itself is UTF-8 already. The MCP channel wraps the + stdout buffer on its own and is not affected. + """ + + for stream in (sys.stdout, sys.stderr): + if isinstance(stream, io.TextIOWrapper): + stream.reconfigure(encoding="utf-8", errors="backslashreplace") + + def main(argv: Sequence[str] | None = None) -> None: + if sys.platform == "win32": + use_utf8_streams() args = list(sys.argv[1:] if argv is None else argv) if args: # Arguments select the human terminal commands (login, pending-writes, diff --git a/src/kwork_mcp/bootstrap.py b/src/kwork_mcp/bootstrap.py index fc11d11..4c39e91 100644 --- a/src/kwork_mcp/bootstrap.py +++ b/src/kwork_mcp/bootstrap.py @@ -9,6 +9,7 @@ import os import shlex import shutil +import subprocess import sys import warnings from collections.abc import Awaitable, Callable, Sequence @@ -20,10 +21,12 @@ from kwork.schema.actor import Actor from pydantic import SecretStr, ValidationError, model_validator +from kwork_mcp import private_fs from kwork_mcp.config import ( AccountSelectionError, KworkConfig, bound_account_ids, + client_default_state_dir, contains_unsafe_text_codepoint, load_server_config, secret_server_environment_present, @@ -71,8 +74,8 @@ Для kwork.com задайте KWORK_SITE=com. Аккаунт и токен у kwork.ru и kwork.com общие, поэтому повторный вход при смене сайта не нужен. -Если существует legacy ~/.kwork_token с owner=current user и mode 0600, login -предложит проверить и импортировать его. Сервер legacy-файл никогда не +Если существует legacy ~/.kwork_token, доступный только вам (на macOS и Linux: +owner=current user и mode 0600), login предложит проверить и импортировать его. Сервер legacy-файл никогда не импортирует. Отправка с неизвестным исходом (submission_unknown) блокирует новые отправки @@ -562,7 +565,7 @@ async def _run_write_admin( return 2 write_id, outcome = rest if not (stdin.isatty() and stderr.isatty()): - stderr.write("Ошибка: resolve-write требует настоящий интерактивный TTY.\n") + stderr.write(_not_a_terminal("Ошибка: resolve-write требует настоящий интерактивный TTY.")) return 2 record = await coordinator.get_write(write_id, scope=scope) if record is None: @@ -621,6 +624,48 @@ async def _run_write_admin( } +def _not_a_terminal(message: str) -> str: + if sys.platform == "win32": + # Git Bash in its own mintty window hands programs pipes, not a console. + message += " На Windows запустите команду в PowerShell или Windows Terminal." + return message + "\n" + + +def _warn_if_elevated(stderr: TextIO) -> None: + # An elevated window may run as another Windows account (another admin, or + # the hidden one of Administrator Protection): the token would land in its + # profile, sealed for it, and the client's server would not find it. + if sys.platform == "win32": + try: + elevated = private_fs.is_elevated() + except OSError: + elevated = False + if elevated: + stderr.write( + "Внимание: login запущен от имени администратора. Если это другая учётная запись Windows, " + "сервер в агенте не увидит вход. Надёжнее запустить login в обычном окне PowerShell.\n" + ) + + +def _command_line(args: list[str]) -> str: + """Quote a command for the user's shell: PowerShell or cmd on Windows, a POSIX shell elsewhere.""" + + if sys.platform == "win32": + line = subprocess.list2cmdline(args) + else: + line = shlex.join(args) + return line + + +def _desktop_config_files() -> str: + if sys.platform == "win32": + # A packaged (MSIX) Claude Desktop keeps the file in its own AppData copy. + files = r"claude_desktop_config.json (Settings → Developer → Edit Config) или %USERPROFILE%\.cursor\mcp.json" + else: + files = "claude_desktop_config.json или ~/.cursor/mcp.json" + return files + + def _connection_instructions(actor: Actor, config: KworkConfig, *, legacy_file_retained: bool) -> str: """What to run next, with every setting the server needs to find this account.""" @@ -634,9 +679,9 @@ def _connection_instructions(actor: Actor, config: KworkConfig, *, legacy_file_r environment.append(f"KWORK_EXPECTED_USER_ID={actor.id}") if config.site != "ru": environment.append(f"KWORK_SITE={config.site}") - # A client may start the server without XDG_STATE_HOME, so anything but - # the plain default location is passed explicitly. - if config.state_dir != Path.home() / ".local" / "state" / "kwork-mcp": + # A client starts the server with a trimmed environment, so anything but + # the location it finds on its own is passed explicitly. + if config.state_dir != client_default_state_dir(): environment.append(f"KWORK_STATE_DIR={config.state_dir}") name = "kwork" if config.site == "ru" else f"kwork-{config.site}" claude = ["claude", "mcp", "add", name, "--scope", "user"] @@ -654,10 +699,10 @@ def _connection_instructions(actor: Actor, config: KworkConfig, *, legacy_file_r f"Аккаунт {_terminal_safe(str(actor.username))} (user_id {actor.id}) подключён.", "", "Добавьте kwork-mcp в агента. Claude Code:", - " " + _terminal_safe(shlex.join(claude + server)), + " " + _terminal_safe(_command_line(claude + server)), "Codex:", - " " + _terminal_safe(shlex.join(codex + server)), - 'Claude Desktop и Cursor: в "mcpServers" файла claude_desktop_config.json или ~/.cursor/mcp.json', + " " + _terminal_safe(_command_line(codex + server)), + f'Claude Desktop и Cursor: в "mcpServers" файла {_desktop_config_files()}', " " + _terminal_safe(f'"{name}": ' + json.dumps(desktop, ensure_ascii=False)), "", "Каждую отправку агент сначала покажет вам на подтверждение. Чтобы он отправлял сам, добавьте " @@ -697,7 +742,7 @@ async def _run_logout( return 2 try: if not (stdin.isatty() and stderr.isatty()): - stderr.write("Ошибка: logout запускается только в интерактивном терминале (TTY).\n") + stderr.write(_not_a_terminal("Ошибка: logout запускается только в интерактивном терминале (TTY).")) return 2 config = _base_bootstrap_config() account_id = account_arg if account_arg is not None else _selected_account(config) @@ -856,8 +901,9 @@ async def run_bootstrap_cli( stderr.write("Ошибка: не удалось проверить, что login запущен в интерактивном терминале (TTY).\n") return 2 if not interactive: - stderr.write("Ошибка: login запускается только в интерактивном терминале (TTY).\n") + stderr.write(_not_a_terminal("Ошибка: login запускается только в интерактивном терминале (TTY).")) return 2 + _warn_if_elevated(stderr) try: base = _base_bootstrap_config() @@ -986,6 +1032,10 @@ async def run_bootstrap_cli( def main(argv: Sequence[str] | None = None) -> None: + if sys.platform == "win32": + from kwork_mcp import use_utf8_streams + + use_utf8_streams() args = list(sys.argv[1:] if argv is None else argv) code = asyncio.run( run_bootstrap_cli( diff --git a/src/kwork_mcp/config.py b/src/kwork_mcp/config.py index 0b39591..4490a54 100644 --- a/src/kwork_mcp/config.py +++ b/src/kwork_mcp/config.py @@ -9,6 +9,7 @@ import hashlib import os import re +import sys import unicodedata from collections.abc import Mapping from pathlib import Path @@ -25,9 +26,21 @@ def _default_state_dir() -> Path: - base = os.environ.get("XDG_STATE_HOME") - if base: - return Path(base).expanduser() / "kwork-mcp" + # Windows keeps it in the profile as well, not in AppData: packaged (MSIX) + # apps such as Claude Desktop see their own copy of AppData, so the server + # they start and the terminal would keep separate state. + xdg = os.environ.get("XDG_STATE_HOME") if sys.platform != "win32" else None + base = Path(xdg).expanduser() if xdg else Path.home() / ".local" / "state" + return base / "kwork-mcp" + + +def client_default_state_dir() -> Path: + """The state directory of a server that an MCP client starts without KWORK_STATE_DIR. + + Clients pass servers a trimmed environment without XDG_STATE_HOME; the + home directory (USERPROFILE on Windows) is always there. + """ + return Path.home() / ".local" / "state" / "kwork-mcp" diff --git a/src/kwork_mcp/coordination.py b/src/kwork_mcp/coordination.py index 3dd7862..9bf1724 100644 --- a/src/kwork_mcp/coordination.py +++ b/src/kwork_mcp/coordination.py @@ -4,7 +4,6 @@ import asyncio import base64 -import fcntl import hashlib import hmac import json @@ -22,6 +21,7 @@ from pathlib import Path from typing import Any, TypeVar +from kwork_mcp import private_fs from kwork_mcp.config import KworkConfig from kwork_mcp.errors import AmbiguousWriteError, ContractDriftError, GatewayError from kwork_mcp.models import ErrorCode, ErrorInfo, WriteAction, WriteState @@ -118,15 +118,16 @@ def _initialize(self) -> None: pass else: try: - os.fchmod(fd, 0o600) + private_fs.restrict_file(fd) finally: os.close(fd) info = self._path.lstat() - if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode): + if private_fs.is_link(info) or not stat.S_ISREG(info.st_mode): raise GatewayError(ErrorCode.VALIDATION, diagnostic="coordination_db_not_regular") - if hasattr(os, "geteuid") and info.st_uid != os.geteuid(): + problem = private_fs.file_problem(info, path=self._path) + if problem == "wrong_owner": raise GatewayError(ErrorCode.VALIDATION, diagnostic="coordination_db_wrong_owner") - if stat.S_IMODE(info.st_mode) != 0o600: + if problem is not None: raise GatewayError(ErrorCode.VALIDATION, diagnostic="coordination_db_permissions") with closing(self._connect()) as conn: @@ -298,15 +299,14 @@ def _initialize(self) -> None: str(prepared_row["write_id"]), ), ) - os.chmod(self._path, 0o600) + private_fs.restrict_path(self._path) def _connect(self) -> sqlite3.Connection: info = self._path.lstat() if ( - stat.S_ISLNK(info.st_mode) + private_fs.is_link(info) or not stat.S_ISREG(info.st_mode) - or stat.S_IMODE(info.st_mode) != 0o600 - or (hasattr(os, "geteuid") and info.st_uid != os.geteuid()) + or private_fs.file_problem(info, path=self._path) is not None ): raise GatewayError( ErrorCode.VALIDATION, @@ -337,20 +337,21 @@ def _acquire_writer_lock(self, scope: str) -> int: fd = -1 try: fd = os.open(path, flags, 0o600) - os.fchmod(fd, 0o600) + private_fs.restrict_file(fd) info = os.fstat(fd) if not stat.S_ISREG(info.st_mode): raise GatewayError( ErrorCode.VALIDATION, diagnostic="writer_lock_not_regular", ) - if hasattr(os, "geteuid") and info.st_uid != os.geteuid(): + problem = private_fs.file_problem(info, fd=fd) + if problem is not None: raise GatewayError( ErrorCode.VALIDATION, - diagnostic="writer_lock_wrong_owner", + diagnostic=f"writer_lock_{problem}", ) try: - fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + private_fs.try_lock(fd) except BlockingIOError as exc: raise GatewayError( ErrorCode.WRITE_IN_PROGRESS, @@ -368,7 +369,7 @@ def _acquire_writer_lock(self, scope: str) -> int: @staticmethod def _release_writer_lock(fd: int) -> None: try: - fcntl.flock(fd, fcntl.LOCK_UN) + private_fs.unlock(fd) finally: os.close(fd) diff --git a/src/kwork_mcp/private_fs.py b/src/kwork_mcp/private_fs.py new file mode 100644 index 0000000..bccab87 --- /dev/null +++ b/src/kwork_mcp/private_fs.py @@ -0,0 +1,347 @@ +"""Owner-only state files and interprocess locks on POSIX and Windows. + +POSIX keeps the state private with modes: 0700 directories and 0600 files +owned by the effective uid. Windows mode bits mean nothing, so there the owner +and the DACL are checked instead, new state directories get an owner-only +protected DACL, and stored tokens are sealed with DPAPI: another Windows user +can neither read a token nor plant one. +""" + +from __future__ import annotations + +import errno +import os +import stat +import sys +import time +from collections.abc import Callable, Sequence +from pathlib import Path, PureWindowsPath + +if sys.platform == "win32": + import msvcrt + + from kwork_mcp import windows +else: + import fcntl + +SYSTEM_SID = "S-1-5-18" +ADMINISTRATORS_SID = "S-1-5-32-544" +# OWNER RIGHTS: whoever currently owns the object. os.mkdir(path, 0o700) grants it +# on Windows since Python 3.12.4. +OWNER_RIGHTS_SID = "S-1-3-4" +# CREATOR OWNER: replaced by the creator in what a directory passes on. +CREATOR_OWNER_SID = "S-1-3-0" +# NT SERVICE\TrustedInstaller owns the system drive root. +TRUSTED_INSTALLER_SID = "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464" +# Rights that let their holder rename, replace or re-permission a directory. +_DELETE = 0x00010000 +_WRITE_DAC = 0x00040000 +_WRITE_OWNER = 0x00080000 +_FILE_DELETE_CHILD = 0x00000040 +_GENERIC_ALL = 0x10000000 +# Reparse tags with this bit redirect to another name: symlinks, junctions. +_NAME_SURROGATE_TAG = 0x20000000 +_SHARING_VIOLATION_WAIT_SECONDS = 1.0 +_SHARING_VIOLATION_ERRORS = frozenset({5, 32, 33}) +_RESERVED_DEVICE_NAMES = frozenset( + {"CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$"} + | {f"COM{number}" for number in range(1, 10)} + | {f"LPT{number}" for number in range(1, 10)} +) + + +AccessEntries = Sequence[tuple[str, str | None, int, bool]] + + +def windows_acl_problem( + owner: str | None, + entries: AccessEntries | None, + *, + user_sid: str, + directory: bool = False, +) -> str | None: + """Why a Windows state file or directory is not private to `user_sid`, or None. + + Like OpenSSH for Windows with private keys, it trusts the user, SYSTEM and + Administrators, who can take any file anyway; OWNER RIGHTS is trusted too, + because the owner must be one of them. `entries` are the DACL entries as + (kind, sid, mask, inherit_only); a missing DACL grants everyone access. A + directory must not pass anyone else on to the files created in it either, + so its inherit-only entries count too. + """ + + trusted = {user_sid, SYSTEM_SID, ADMINISTRATORS_SID} + if owner not in trusted: + return "wrong_owner" + if entries is None: + return "acl_not_private" + allowed = trusted | {OWNER_RIGHTS_SID} + if directory: + allowed.add(CREATOR_OWNER_SID) + for kind, sid, mask, inherit_only in entries: + if (inherit_only and not directory) or kind == "deny": + continue + if kind == "allow" and (mask == 0 or sid in allowed): + continue + return "acl_not_private" + return None + + +def windows_ancestor_problem( + owner: str | None, + entries: AccessEntries | None, + *, + user_sid: str, + root: bool, +) -> str | None: + """Why someone else could swap a directory above the state directory, or None. + + The Windows counterpart of rejecting group- or world-writable POSIX + ancestors. The owner can rewrite the DACL, so it must be trusted, and no one + else may hold DELETE, WRITE_DAC, WRITE_OWNER, FILE_DELETE_CHILD or + GENERIC_ALL. DELETE on a drive root is harmless: a root cannot be renamed. + """ + + trusted = {user_sid, SYSTEM_SID, ADMINISTRATORS_SID, TRUSTED_INSTALLER_SID} + if owner not in trusted: + return "untrusted_ancestor_owner" + if entries is None: + return "untrusted_writable_ancestor" + dangerous = _WRITE_DAC | _WRITE_OWNER | _FILE_DELETE_CHILD | _GENERIC_ALL | (0 if root else _DELETE) + allowed = trusted | {OWNER_RIGHTS_SID} + for kind, sid, mask, inherit_only in entries: + if inherit_only or kind == "deny": + continue + if kind == "allow" and (not mask & dangerous or sid in allowed): + continue + return "untrusted_writable_ancestor" + return None + + +def windows_path_is_ambiguous(path: PureWindowsPath) -> bool: + """Reject Windows path syntax that may name something other than it reads. + + Only `X:\\...` paths pass: UNC and device paths, drive-relative paths, + alternate data streams, reserved device names and names that Windows trims + (trailing dot or space) are refused. + """ + + drive = path.drive + if len(drive) != 2 or drive[1] != ":" or not drive[0].isascii() or not drive[0].isalpha() or not path.root: + return True + for part in path.parts[1:]: + device = part.split(".", 1)[0].rstrip(" ").upper() + if ":" in part or part.endswith((".", " ")) or device in _RESERVED_DEVICE_NAMES: + return True + return False + + +def try_lock(fd: int) -> None: + """Take an exclusive lock on an open file or raise BlockingIOError at once. + + The lock belongs to the open file: closing it or the death of the process + releases it, so a crash never leaves a stale lock behind. + """ + + if sys.platform == "win32": + # msvcrt locks bytes from the current position; lock files stay empty. + os.lseek(fd, 0, os.SEEK_SET) + try: + msvcrt.locking(fd, msvcrt.LK_NBLCK, 1) + except OSError as exc: + if exc.errno in {errno.EACCES, errno.EDEADLK}: + raise BlockingIOError(errno.EAGAIN, "the lock is held") from exc + raise + else: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + + +def unlock(fd: int) -> None: + if sys.platform == "win32": + os.lseek(fd, 0, os.SEEK_SET) + msvcrt.locking(fd, msvcrt.LK_UNLCK, 1) + else: + fcntl.flock(fd, fcntl.LOCK_UN) + + +def restrict_file(fd: int) -> None: + """Make an open state file owner-only. + + POSIX sets 0600, which the umask may have narrowed or a stale file may + lack. On Windows the file inherits the owner-only DACL of the state + directory and is checked through its handle instead. + """ + + if sys.platform != "win32": + os.fchmod(fd, 0o600) + + +def restrict_path(path: Path) -> None: + if sys.platform != "win32": + os.chmod(path, 0o600) + + +def is_link(info: os.stat_result) -> bool: + """A symlink or, on Windows, any reparse point that redirects, such as a junction.""" + + if sys.platform == "win32": + reparse = info.st_file_attributes & stat.FILE_ATTRIBUTE_REPARSE_POINT + redirects = bool(reparse and info.st_reparse_tag & _NAME_SURROGATE_TAG) + else: + redirects = False + return redirects or stat.S_ISLNK(info.st_mode) + + +def file_problem(info: os.stat_result, *, fd: int | None = None, path: Path | None = None) -> str | None: + """Why a state file is not private to the current user, or None. + + POSIX reads the owner and mode from `info`: `wrong_owner` or + `permissions_must_be_0600`. Windows reads the owner and DACL of the open + file `fd`, or of `path` when nothing is open: `wrong_owner`, + `acl_not_private` or `acl_unreadable`. + """ + + if sys.platform == "win32": + problem = _windows_problem(fd=fd, path=path) + elif info.st_uid != os.geteuid(): + problem = "wrong_owner" + elif stat.S_IMODE(info.st_mode) != 0o600: + problem = "permissions_must_be_0600" + else: + problem = None + return problem + + +def directory_problem(path: Path) -> str | None: # pragma: no cover - Windows only + """Why a Windows state directory is not private, as `file_problem` reports it. + + POSIX checks directories during the fd-anchored walk in `security`. + """ + + return _windows_problem(path=path, directory=True) + + +def ancestor_problem(path: Path, *, root: bool) -> str | None: # pragma: no cover - Windows only + """Why someone else could swap the Windows directory `path`, or None.""" + + if sys.platform == "win32": + try: + owner, entries = windows.path_security(path) + return windows_ancestor_problem(owner, entries, user_sid=windows.current_user_sid(), root=root) + except OSError: + return "acl_unreadable" + else: + raise NotImplementedError + + +def _windows_problem( # pragma: no cover - Windows only + *, + fd: int | None = None, + path: Path | None = None, + directory: bool = False, +) -> str | None: + if sys.platform == "win32": + try: + if fd is not None: + owner, entries = windows.handle_security(fd) + elif path is not None: + owner, entries = windows.path_security(path) + else: + raise ValueError("fd or path is required") + return windows_acl_problem(owner, entries, user_sid=windows.current_user_sid(), directory=directory) + except OSError: + return "acl_unreadable" + else: + raise NotImplementedError + + +def is_elevated() -> bool: + """Whether a Windows process runs with an administrator token; always False on POSIX.""" + + if sys.platform == "win32": + elevated = windows.is_elevated() + else: + elevated = False + return elevated + + +def create_private_directory(path: Path) -> None: # pragma: no cover - Windows only + """Create a Windows directory open only to the current user and SYSTEM.""" + + if sys.platform == "win32": + windows.create_private_directory(path) + else: + raise NotImplementedError + + +def fsync_directory(path: Path) -> None: + """Make a rename or unlink in `path` durable. + + Windows cannot open a directory as a file; NTFS journals the change itself. + """ + + if sys.platform != "win32": + directory_fd = os.open(path, os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + + +def replace(source: str, target: Path) -> None: + """os.replace that waits out a brief Windows sharing violation. + + Windows also writes the rename through to disk, since it has no directory + fsync to follow it. + """ + + if sys.platform == "win32": + _wait_out_sharing_violation(lambda: windows.replace(source, target)) + else: + _wait_out_sharing_violation(lambda: os.replace(source, target)) + + +def unlink(path: Path) -> None: + _wait_out_sharing_violation(path.unlink) + + +def _wait_out_sharing_violation(operation: Callable[[], None]) -> None: + # An antivirus scanner or the search indexer may hold a fresh file open + # for a moment on Windows; POSIX renames and unlinks never wait. + if sys.platform == "win32": + deadline = time.monotonic() + _SHARING_VIOLATION_WAIT_SECONDS + delay = 0.01 + while True: + try: + operation() + return + except PermissionError as exc: + if exc.winerror not in _SHARING_VIOLATION_ERRORS or time.monotonic() >= deadline: + raise + time.sleep(delay) + delay = min(delay * 2, 0.2) + else: + operation() + + +def seal(data: bytes, *, purpose: str) -> bytes: + """Bind a stored secret to the current OS user. + + Windows encrypts it with DPAPI, keyed to the user and to `purpose`, so no + other user can read it and a file copied from another account or user + fails to open. POSIX stores it as is under mode 0600. + """ + + if sys.platform == "win32": + sealed = windows.protect(data, entropy=purpose.encode()) + else: + sealed = data + return sealed + + +def unseal(data: bytes, *, purpose: str) -> bytes: + if sys.platform == "win32": + opened = windows.unprotect(data, entropy=purpose.encode()) + else: + opened = data + return opened diff --git a/src/kwork_mcp/security.py b/src/kwork_mcp/security.py index 83e6faa..8e6e08f 100644 --- a/src/kwork_mcp/security.py +++ b/src/kwork_mcp/security.py @@ -4,7 +4,6 @@ import asyncio import contextlib -import fcntl import json import math import os @@ -18,12 +17,13 @@ from contextlib import asynccontextmanager from dataclasses import asdict, dataclass, field, is_dataclass from datetime import UTC, datetime -from pathlib import Path +from pathlib import Path, PureWindowsPath from typing import Any, cast from loguru import logger from pydantic import BaseModel, JsonValue +from kwork_mcp import private_fs from kwork_mcp.config import ( KworkConfig, canonicalize_percent_escape_case, @@ -271,6 +271,82 @@ def validate_lexical(candidate: Path, *, user_supplied: bool) -> None: def ensure_secure_directory(path: Path) -> Path: + """Create a private state directory, or check an existing one, and return its path.""" + + if sys.platform == "win32": + directory = _ensure_secure_windows_directory(path) + else: + directory = _ensure_secure_posix_directory(path) + return directory + + +def _ensure_secure_windows_directory(path: Path) -> Path: # pragma: no cover - Windows only + """Create missing components owner-only and check the final directory. + + Windows cannot open a directory as a file descriptor, so the POSIX + fd-anchored walk has no counterpart. Every component this creates gets an + owner-only protected DACL; the final directory must not be a junction or + symlink, and its owner and DACL must be private. Directories above it + outside the user's profile must not let anyone else rename or + re-permission them, as POSIX rejects shared-writable ancestors; Windows + itself keeps the profile and the directories above it closed. + """ + + raw = os.fspath(path) + if ( + not path.is_absolute() + or "\x00" in raw + or contains_unsafe_text_codepoint(raw) + or ".." in path.parts + or private_fs.windows_path_is_ambiguous(PureWindowsPath(raw)) + ): + raise _security_error("state_directory_path_invalid") + profile = _windows_profile() + + def check_ancestor(directory: Path, *, root: bool) -> None: + if profile is not None and ( + directory == profile or profile in directory.parents or directory in profile.parents + ): + return + problem = private_fs.ancestor_problem(directory, root=root) + if problem is not None: + raise _security_error(f"state_directory_{problem}") + + current = Path(path.anchor) + check_ancestor(current, root=True) + components = path.parts[1:] + for index, component in enumerate(components): + current = current / component + final = index == len(components) - 1 + try: + try: + info = current.lstat() if final else current.stat() + except FileNotFoundError: + with contextlib.suppress(FileExistsError): + private_fs.create_private_directory(current) + info = current.lstat() if final else current.stat() + except OSError as exc: + raise _security_error(f"state_directory_chain_error:{type(exc).__name__}") from exc + if final and private_fs.is_link(info): + raise _security_error("state_directory_final_symlink") + if not stat.S_ISDIR(info.st_mode): + raise _security_error("state_directory_component_not_directory") + if not final: + check_ancestor(current, root=False) + problem = private_fs.directory_problem(path) + if problem is not None: + raise _security_error(f"state_directory_{problem}") + return path + + +def _windows_profile() -> Path | None: # pragma: no cover - Windows only + try: + return Path.home() + except RuntimeError: + return None + + +def _ensure_secure_posix_directory(path: Path) -> Path: """Create a private directory through a trusted, fd-anchored ancestor chain.""" canonical = _resolve_trusted_state_aliases(path) @@ -349,12 +425,11 @@ def _validate_private_file(path: Path, *, allow_missing: bool = True) -> os.stat raise except OSError as exc: raise _security_error(f"private_file_error:{type(exc).__name__}") from exc - if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode): + if private_fs.is_link(info) or not stat.S_ISREG(info.st_mode): raise _security_error("private_file_not_regular") - if hasattr(os, "geteuid") and info.st_uid != os.geteuid(): - raise _security_error("private_file_wrong_owner") - if stat.S_IMODE(info.st_mode) != 0o600: - raise _security_error("private_file_permissions_must_be_0600") + problem = private_fs.file_problem(info, path=path) + if problem is not None: + raise _security_error(f"private_file_{problem}") return info @@ -366,7 +441,7 @@ def read_private_secret_file(path: Path, *, max_bytes: int = 4096) -> str | None return None if info.st_size > max_bytes: raise _security_error("private_secret_file_too_large") - flags = os.O_RDONLY + flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) if hasattr(os, "O_NOFOLLOW"): flags |= os.O_NOFOLLOW fd = -1 @@ -375,8 +450,7 @@ def read_private_secret_file(path: Path, *, max_bytes: int = 4096) -> str | None opened = os.fstat(fd) if ( not stat.S_ISREG(opened.st_mode) - or stat.S_IMODE(opened.st_mode) != 0o600 - or (hasattr(os, "geteuid") and opened.st_uid != os.geteuid()) + or private_fs.file_problem(opened, fd=fd) is not None or opened.st_dev != info.st_dev or opened.st_ino != info.st_ino ): @@ -478,8 +552,14 @@ def create( ) +def _token_seal_purpose(scope: str) -> str: + # Binds a sealed record to its account: a file copied to another + # account's name fails to open. + return f"kwork-mcp token {scope}" + + class SecureTokenStore: - """Account-scoped token files guarded by an interprocess flock.""" + """Account-scoped token files guarded by an interprocess lock.""" def __init__(self, state_dir: Path, *, lock_timeout: float = 20.0) -> None: self._state_dir = ensure_secure_directory(state_dir) @@ -512,18 +592,17 @@ def acquire_lock(self, scope: str) -> int: except FileExistsError: fd = os.open(lock_path, open_flags) else: - os.fchmod(fd, 0o600) + private_fs.restrict_file(fd) info = os.fstat(fd) if not stat.S_ISREG(info.st_mode): raise _security_error("token_lock_not_regular") - if hasattr(os, "geteuid") and info.st_uid != os.geteuid(): - raise _security_error("token_lock_wrong_owner") - if stat.S_IMODE(info.st_mode) != 0o600: - raise _security_error("token_lock_permissions_must_be_0600") + problem = private_fs.file_problem(info, fd=fd) + if problem is not None: + raise _security_error(f"token_lock_{problem}") deadline = time.monotonic() + self._lock_timeout while True: try: - fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + private_fs.try_lock(fd) break except BlockingIOError as exc: if time.monotonic() >= deadline: @@ -548,7 +627,7 @@ def acquire_lock(self, scope: str) -> int: @staticmethod def release_lock(fd: int) -> None: try: - fcntl.flock(fd, fcntl.LOCK_UN) + private_fs.unlock(fd) finally: os.close(fd) @@ -559,7 +638,7 @@ def load_locked(self, scope: str) -> TokenRecord | None: return None if info.st_size > 65_536: raise _security_error("token_file_too_large") - flags = os.O_RDONLY + flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) if hasattr(os, "O_NOFOLLOW"): flags |= os.O_NOFOLLOW fd = -1 @@ -568,16 +647,22 @@ def load_locked(self, scope: str) -> TokenRecord | None: opened = os.fstat(fd) if ( not stat.S_ISREG(opened.st_mode) - or stat.S_IMODE(opened.st_mode) != 0o600 - or (hasattr(os, "geteuid") and opened.st_uid != os.geteuid()) + or private_fs.file_problem(opened, fd=fd) is not None or opened.st_dev != info.st_dev or opened.st_ino != info.st_ino ): raise _security_error("token_file_changed_during_open") - handle = os.fdopen(fd, encoding="utf-8") + handle = os.fdopen(fd, "rb") fd = -1 with handle: - payload = json.load(handle) + sealed = handle.read() + try: + plain = private_fs.unseal(sealed, purpose=_token_seal_purpose(scope)) + except OSError as exc: # pragma: no cover - only DPAPI can fail here + # Another Windows user, a reset password or a moved profile: + # only a new login can replace the record. + raise GatewayError(ErrorCode.AUTH_EXPIRED, diagnostic="token_sealed_for_another_user") from exc + payload = json.loads(plain.decode("utf-8")) except (OSError, UnicodeError, json.JSONDecodeError, TypeError) as exc: raise _security_error(f"invalid_token_file:{type(exc).__name__}") from exc finally: @@ -616,12 +701,16 @@ def save_locked(self, scope: str, record: TokenRecord) -> None: self._validate_scope_record(scope, record) target = self._token_path(scope) _validate_private_file(target) - payload = json.dumps( + encoded = json.dumps( asdict(record), ensure_ascii=False, sort_keys=True, separators=(",", ":"), ).encode() + try: + payload = private_fs.seal(encoded, purpose=_token_seal_purpose(scope)) + except OSError as exc: # pragma: no cover - only DPAPI can fail here + raise _security_error(f"token_seal_error:{type(exc).__name__}") from exc if len(payload) > 65_536: raise _security_error("token_record_too_large") temp_fd = -1 @@ -629,7 +718,7 @@ def save_locked(self, scope: str, record: TokenRecord) -> None: replaced = False try: temp_fd, temp_name = tempfile.mkstemp(prefix=".token-", dir=self._directory) - os.fchmod(temp_fd, 0o600) + private_fs.restrict_file(temp_fd) view = memoryview(payload) while view: written = os.write(temp_fd, view) @@ -639,13 +728,9 @@ def save_locked(self, scope: str, record: TokenRecord) -> None: os.fsync(temp_fd) os.close(temp_fd) temp_fd = -1 - os.replace(temp_name, target) + private_fs.replace(temp_name, target) replaced = True - directory_fd = os.open(self._directory, os.O_RDONLY) - try: - os.fsync(directory_fd) - finally: - os.close(directory_fd) + private_fs.fsync_directory(self._directory) _validate_private_file(target, allow_missing=False) except GatewayError as exc: if replaced: @@ -667,8 +752,9 @@ def save_locked(self, scope: str, record: TokenRecord) -> None: if temp_fd >= 0: os.close(temp_fd) if temp_name: - with contextlib.suppress(FileNotFoundError): - os.unlink(temp_name) + # A failed cleanup must not replace the error being raised. + with contextlib.suppress(OSError): + private_fs.unlink(Path(temp_name)) def _validate_scope_record(self, scope: str, record: TokenRecord) -> None: safe_scope = self._safe_name(scope) @@ -684,12 +770,8 @@ def delete_locked(self, scope: str) -> None: if _validate_private_file(path) is None: return try: - path.unlink() - directory_fd = os.open(self._directory, os.O_RDONLY) - try: - os.fsync(directory_fd) - finally: - os.close(directory_fd) + private_fs.unlink(path) + private_fs.fsync_directory(self._directory) except OSError as exc: raise _security_error(f"token_delete_error:{type(exc).__name__}") from exc diff --git a/src/kwork_mcp/version.py b/src/kwork_mcp/version.py index 3981624..20ad6a5 100644 --- a/src/kwork_mcp/version.py +++ b/src/kwork_mcp/version.py @@ -1,3 +1,3 @@ """Application version exposed in package metadata and the MCP handshake.""" -__version__ = "1.5.2" +__version__ = "1.6.0" diff --git a/src/kwork_mcp/windows.py b/src/kwork_mcp/windows.py new file mode 100644 index 0000000..fa59d26 --- /dev/null +++ b/src/kwork_mcp/windows.py @@ -0,0 +1,375 @@ +"""Win32 security calls through ctypes: owners, DACLs, private directories and DPAPI. + +Only `private_fs` imports this module, and only on Windows. The assert keeps +mypy from checking it against POSIX stubs; CI checks it with --platform win32. +""" + +from __future__ import annotations + +import ctypes +import msvcrt +import sys +from collections.abc import Callable +from ctypes import wintypes +from functools import cache +from pathlib import Path +from typing import Any + +assert sys.platform == "win32" + +# (kind, sid, mask, inherit_only): kind is allow, deny or other; an +# inherit-only entry does not apply to the object itself, only to what is +# created inside it later. +AccessEntry = tuple[str, str | None, int, bool] + +_SE_FILE_OBJECT = 1 +_OWNER_SECURITY_INFORMATION = 0x00000001 +_DACL_SECURITY_INFORMATION = 0x00000004 +_TOKEN_QUERY = 0x0008 +_TOKEN_USER = 1 +_TOKEN_ELEVATION = 20 +_ACL_SIZE_INFORMATION = 2 +_SDDL_REVISION_1 = 1 +_CRYPTPROTECT_UI_FORBIDDEN = 0x1 +_MOVEFILE_REPLACE_EXISTING = 0x1 +_MOVEFILE_WRITE_THROUGH = 0x8 +_INHERIT_ONLY_ACE = 0x08 +# ACCESS_ALLOWED and ACCESS_ALLOWED_CALLBACK keep the SID right after the mask. +_ALLOWED_ACE_TYPES = frozenset({0x0, 0x9}) +# ACCESS_DENIED, ACCESS_DENIED_OBJECT, ACCESS_DENIED_CALLBACK, ACCESS_DENIED_CALLBACK_OBJECT. +_DENIED_ACE_TYPES = frozenset({0x1, 0x6, 0xA, 0xC}) + + +class _AclSizeInformation(ctypes.Structure): + _fields_ = ( + ("AceCount", wintypes.DWORD), + ("AclBytesInUse", wintypes.DWORD), + ("AclBytesFree", wintypes.DWORD), + ) + + +class _AceHeader(ctypes.Structure): + _fields_ = ( + ("AceType", ctypes.c_ubyte), + ("AceFlags", ctypes.c_ubyte), + ("AceSize", wintypes.WORD), + ) + + +class _SecurityAttributes(ctypes.Structure): + _fields_ = ( + ("nLength", wintypes.DWORD), + ("lpSecurityDescriptor", ctypes.c_void_p), + ("bInheritHandle", wintypes.BOOL), + ) + + +class _DataBlob(ctypes.Structure): + _fields_ = ( + ("cbData", wintypes.DWORD), + ("pbData", ctypes.POINTER(ctypes.c_char)), + ) + + +def _function(library: ctypes.WinDLL, name: str, restype: Any, *argtypes: Any) -> Any: + function = getattr(library, name) + function.argtypes = argtypes + function.restype = restype + return function + + +_advapi32 = ctypes.WinDLL("advapi32", use_last_error=True) +_crypt32 = ctypes.WinDLL("crypt32", use_last_error=True) +_kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) +_out_pointer = ctypes.POINTER(ctypes.c_void_p) + +_GetSecurityInfo = _function( + _advapi32, + "GetSecurityInfo", + wintypes.DWORD, + wintypes.HANDLE, + ctypes.c_int, + wintypes.DWORD, + _out_pointer, + _out_pointer, + _out_pointer, + _out_pointer, + _out_pointer, +) +_GetNamedSecurityInfoW = _function( + _advapi32, + "GetNamedSecurityInfoW", + wintypes.DWORD, + wintypes.LPCWSTR, + ctypes.c_int, + wintypes.DWORD, + _out_pointer, + _out_pointer, + _out_pointer, + _out_pointer, + _out_pointer, +) +_ConvertSidToStringSidW = _function( + _advapi32, + "ConvertSidToStringSidW", + wintypes.BOOL, + ctypes.c_void_p, + ctypes.POINTER(wintypes.LPWSTR), +) +_GetAclInformation = _function( + _advapi32, + "GetAclInformation", + wintypes.BOOL, + ctypes.c_void_p, + ctypes.c_void_p, + wintypes.DWORD, + ctypes.c_int, +) +_GetAce = _function(_advapi32, "GetAce", wintypes.BOOL, ctypes.c_void_p, wintypes.DWORD, _out_pointer) +_OpenProcessToken = _function( + _advapi32, + "OpenProcessToken", + wintypes.BOOL, + wintypes.HANDLE, + wintypes.DWORD, + ctypes.POINTER(wintypes.HANDLE), +) +_GetTokenInformation = _function( + _advapi32, + "GetTokenInformation", + wintypes.BOOL, + wintypes.HANDLE, + ctypes.c_int, + ctypes.c_void_p, + wintypes.DWORD, + ctypes.POINTER(wintypes.DWORD), +) +_ConvertStringSecurityDescriptorToSecurityDescriptorW = _function( + _advapi32, + "ConvertStringSecurityDescriptorToSecurityDescriptorW", + wintypes.BOOL, + wintypes.LPCWSTR, + wintypes.DWORD, + _out_pointer, + ctypes.POINTER(wintypes.ULONG), +) +_CryptProtectData = _function( + _crypt32, + "CryptProtectData", + wintypes.BOOL, + ctypes.POINTER(_DataBlob), + wintypes.LPCWSTR, + ctypes.POINTER(_DataBlob), + ctypes.c_void_p, + ctypes.c_void_p, + wintypes.DWORD, + ctypes.POINTER(_DataBlob), +) +_CryptUnprotectData = _function( + _crypt32, + "CryptUnprotectData", + wintypes.BOOL, + ctypes.POINTER(_DataBlob), + ctypes.POINTER(wintypes.LPWSTR), + ctypes.POINTER(_DataBlob), + ctypes.c_void_p, + ctypes.c_void_p, + wintypes.DWORD, + ctypes.POINTER(_DataBlob), +) +_GetCurrentProcess = _function(_kernel32, "GetCurrentProcess", wintypes.HANDLE) +_CloseHandle = _function(_kernel32, "CloseHandle", wintypes.BOOL, wintypes.HANDLE) +_MoveFileExW = _function(_kernel32, "MoveFileExW", wintypes.BOOL, wintypes.LPCWSTR, wintypes.LPCWSTR, wintypes.DWORD) +_LocalFree = _function(_kernel32, "LocalFree", ctypes.c_void_p, ctypes.c_void_p) +_CreateDirectoryW = _function( + _kernel32, + "CreateDirectoryW", + wintypes.BOOL, + wintypes.LPCWSTR, + ctypes.POINTER(_SecurityAttributes), +) + + +def _last_error() -> OSError: + return ctypes.WinError(ctypes.get_last_error()) + + +def _sid_string(sid: int | None) -> str: + text = wintypes.LPWSTR() + if not _ConvertSidToStringSidW(sid, ctypes.byref(text)): + raise _last_error() + try: + return str(text.value) + finally: + _LocalFree(ctypes.cast(text, ctypes.c_void_p)) + + +@cache +def current_user_sid() -> str: + """The SID of the user this process runs as, as S-1-5-21-...""" + + token = wintypes.HANDLE() + if not _OpenProcessToken(_GetCurrentProcess(), _TOKEN_QUERY, ctypes.byref(token)): + raise _last_error() + try: + size = wintypes.DWORD() + # The first call only reports the buffer size it needs. + _GetTokenInformation(token, _TOKEN_USER, None, 0, ctypes.byref(size)) + buffer = ctypes.create_string_buffer(size.value) + if not _GetTokenInformation(token, _TOKEN_USER, buffer, size, ctypes.byref(size)): + raise _last_error() + # TOKEN_USER starts with SID_AND_ATTRIBUTES, whose first field is the SID. + return _sid_string(ctypes.c_void_p.from_buffer(buffer).value) + finally: + _CloseHandle(token) + + +def is_elevated() -> bool: + """Whether this process runs with an elevated (administrator) token.""" + + token = wintypes.HANDLE() + if not _OpenProcessToken(_GetCurrentProcess(), _TOKEN_QUERY, ctypes.byref(token)): + raise _last_error() + try: + elevated = wintypes.DWORD() + size = wintypes.DWORD() + if not _GetTokenInformation( + token, _TOKEN_ELEVATION, ctypes.byref(elevated), ctypes.sizeof(elevated), ctypes.byref(size) + ): + raise _last_error() + return bool(elevated.value) + finally: + _CloseHandle(token) + + +def _access_entries(dacl: int) -> list[AccessEntry]: + size = _AclSizeInformation() + if not _GetAclInformation(dacl, ctypes.byref(size), ctypes.sizeof(size), _ACL_SIZE_INFORMATION): + raise _last_error() + entries: list[AccessEntry] = [] + for index in range(size.AceCount): + ace = ctypes.c_void_p() + if not _GetAce(dacl, index, ctypes.byref(ace)) or not ace.value: + raise _last_error() + header = _AceHeader.from_address(ace.value) + inherit_only = bool(header.AceFlags & _INHERIT_ONLY_ACE) + if header.AceType in _ALLOWED_ACE_TYPES: + mask_address = ace.value + ctypes.sizeof(_AceHeader) + mask = wintypes.DWORD.from_address(mask_address).value + sid = _sid_string(mask_address + ctypes.sizeof(wintypes.DWORD)) + entries.append(("allow", sid, int(mask), inherit_only)) + elif header.AceType in _DENIED_ACE_TYPES: + entries.append(("deny", None, 0, inherit_only)) + else: + entries.append(("other", None, 0, inherit_only)) + return entries + + +def _security(read: Callable[[Any, Any, Any], int]) -> tuple[str | None, list[AccessEntry] | None]: + owner = ctypes.c_void_p() + dacl = ctypes.c_void_p() + descriptor = ctypes.c_void_p() + status = read(ctypes.byref(owner), ctypes.byref(dacl), ctypes.byref(descriptor)) + if status: + raise ctypes.WinError(status) + try: + owner_sid = _sid_string(owner.value) if owner.value else None + # A missing DACL grants everyone full access. + return owner_sid, _access_entries(dacl.value) if dacl.value else None + finally: + _LocalFree(descriptor) + + +_REQUESTED = _OWNER_SECURITY_INFORMATION | _DACL_SECURITY_INFORMATION + + +def handle_security(fd: int) -> tuple[str | None, list[AccessEntry] | None]: + """Owner SID and effective DACL entries of an open file.""" + + handle = msvcrt.get_osfhandle(fd) + return _security( + lambda owner, dacl, descriptor: _GetSecurityInfo( + handle, _SE_FILE_OBJECT, _REQUESTED, owner, None, dacl, None, descriptor + ) + ) + + +def path_security(path: Path) -> tuple[str | None, list[AccessEntry] | None]: + """Owner SID and effective DACL entries of a file or directory.""" + + return _security( + lambda owner, dacl, descriptor: _GetNamedSecurityInfoW( + str(path), _SE_FILE_OBJECT, _REQUESTED, owner, None, dacl, None, descriptor + ) + ) + + +def replace(source: str, target: Path) -> None: + """os.replace that returns only once the rename is on disk. + + NTFS journals the rename but may still lose it on power loss; the token + store has no directory fsync on Windows to make up for it. + """ + + if not _MoveFileExW(source, str(target), _MOVEFILE_REPLACE_EXISTING | _MOVEFILE_WRITE_THROUGH): + raise _last_error() + + +def create_private_directory(path: Path) -> None: + """Create `path` open only to the current user and SYSTEM. + + The protected DACL is set by CreateDirectoryW itself, so the directory + never exists with the inherited, possibly shared, DACL of its parent. + Raises FileExistsError when `path` already exists. + """ + + sddl = f"D:P(A;OICI;FA;;;{current_user_sid()})(A;OICI;FA;;;SY)" + descriptor = ctypes.c_void_p() + if not _ConvertStringSecurityDescriptorToSecurityDescriptorW( + sddl, _SDDL_REVISION_1, ctypes.byref(descriptor), None + ): + raise _last_error() + try: + attributes = _SecurityAttributes(ctypes.sizeof(_SecurityAttributes), descriptor.value, False) + if not _CreateDirectoryW(str(path), ctypes.byref(attributes)): + raise _last_error() + finally: + _LocalFree(descriptor) + + +def _blob(data: bytes) -> tuple[_DataBlob, ctypes.Array[ctypes.c_char]]: + buffer = ctypes.create_string_buffer(data, len(data)) + return _DataBlob(len(data), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_char))), buffer + + +def _take_blob(blob: _DataBlob) -> bytes: + try: + return ctypes.string_at(blob.pbData, blob.cbData) + finally: + _LocalFree(ctypes.cast(blob.pbData, ctypes.c_void_p)) + + +def protect(data: bytes, *, entropy: bytes) -> bytes: + """Encrypt `data` with DPAPI for the current Windows user.""" + + source, _source_buffer = _blob(data) + salt, _salt_buffer = _blob(entropy) + sealed = _DataBlob() + if not _CryptProtectData( + ctypes.byref(source), None, ctypes.byref(salt), None, None, _CRYPTPROTECT_UI_FORBIDDEN, ctypes.byref(sealed) + ): + raise _last_error() + return _take_blob(sealed) + + +def unprotect(data: bytes, *, entropy: bytes) -> bytes: + """Decrypt DPAPI data; fails for another user, other entropy or changed bytes.""" + + source, _source_buffer = _blob(data) + salt, _salt_buffer = _blob(entropy) + opened = _DataBlob() + if not _CryptUnprotectData( + ctypes.byref(source), None, ctypes.byref(salt), None, None, _CRYPTPROTECT_UI_FORBIDDEN, ctypes.byref(opened) + ): + raise _last_error() + return _take_blob(opened) diff --git a/tests/platforms.py b/tests/platforms.py new file mode 100644 index 0000000..d02337b --- /dev/null +++ b/tests/platforms.py @@ -0,0 +1,39 @@ +"""Markers and checks for tests that depend on one operating system's file semantics.""" + +from __future__ import annotations + +import stat +import sys +from pathlib import Path + +import pytest + +from kwork_mcp import private_fs + +posix_only = pytest.mark.skipif( + sys.platform == "win32", + reason="POSIX modes, uids, umask, symlinks or fd-relative directory walks", +) +windows_only = pytest.mark.skipif( + sys.platform != "win32", + reason="Windows ACLs, DPAPI or msvcrt locks", +) + + +def assert_private_file(path: Path) -> None: + """Mode 0600 on POSIX; an owner-only owner and DACL on Windows.""" + + info = path.stat() + if sys.platform == "win32": + assert private_fs.file_problem(info, path=path) is None + else: + assert stat.S_IMODE(info.st_mode) == 0o600 + + +def assert_private_directory(path: Path) -> None: + """Mode 0700 on POSIX; an owner-only owner and DACL on Windows.""" + + if sys.platform == "win32": + assert private_fs.directory_problem(path) is None + else: + assert stat.S_IMODE(path.stat().st_mode) == 0o700 diff --git a/tests/test_auth_bootstrap.py b/tests/test_auth_bootstrap.py index 2ce08fd..3c2ebee 100644 --- a/tests/test_auth_bootstrap.py +++ b/tests/test_auth_bootstrap.py @@ -4,7 +4,6 @@ import io import json import os -import stat import threading from pathlib import Path from typing import Any @@ -16,6 +15,7 @@ import kwork_mcp.bootstrap as bootstrap_module import kwork_mcp.security as security_module +from kwork_mcp import private_fs from kwork_mcp.bootstrap import bootstrap_account, run_bootstrap_cli from kwork_mcp.config import KworkConfig, secret_server_environment_present from kwork_mcp.coordination import CoordinationStore @@ -28,6 +28,7 @@ ) from kwork_mcp.session import KworkSessionManager from kwork_mcp.version import __version__ +from tests.platforms import assert_private_file, posix_only class AuthClient: @@ -344,17 +345,15 @@ async def test_scope_record_mismatch_is_rejected_before_network(tmp_path: Path) config = credentialless_config(tmp_path / "state") store = SecureTokenStore(config.state_dir) token_path = config.state_dir / "tokens" / "account-42.json" - token_path.write_text( - json.dumps( - { - "version": 1, - "user_id": 99, - "username": "wrong", - "token": "wrong-token", - "saved_at": "2026-07-27T00:00:00+00:00", - } - ) - ) + payload = { + "version": 1, + "user_id": 99, + "username": "wrong", + "token": "wrong-token", + "saved_at": "2026-07-27T00:00:00+00:00", + } + sealed = private_fs.seal(json.dumps(payload).encode(), purpose=security_module._token_seal_purpose("account-42")) + token_path.write_bytes(sealed) os.chmod(token_path, 0o600) factory_calls = 0 @@ -493,7 +492,7 @@ async def test_bootstrap_fresh_login_atomically_replaces_only_after_identity_che assert saved.user_id == 42 assert saved.username == "verified" assert saved.token == "new-token" - assert stat.S_IMODE((config.state_dir / "tokens" / "account-42.json").stat().st_mode) == 0o600 + assert_private_file(config.state_dir / "tokens" / "account-42.json") @pytest.mark.asyncio @@ -824,6 +823,7 @@ def factory(config: KworkConfig) -> AuthClient: assert stored.proxy_url is None +@posix_only @pytest.mark.asyncio async def test_bootstrap_cli_validates_and_imports_legacy_token_explicitly( tmp_path: Path, @@ -881,6 +881,7 @@ def factory(config: KworkConfig) -> AuthClient: assert stored.proxy_url == "socks5://proxy-user:proxy-pass@proxy.example:1080" +@posix_only @pytest.mark.asyncio async def test_insecure_legacy_import_fails_before_network_and_preserves_store( tmp_path: Path, @@ -959,7 +960,7 @@ async def test_bootstrap_cli_rejects_non_tty_and_unknown_argv_without_reflection [ (["--help"], "kwork-mcp-bootstrap"), (["-h"], "kwork-mcp-bootstrap"), - (["--version"], "1.5.2"), + (["--version"], "1.6.0"), ], ) async def test_bootstrap_help_and_version_need_no_tty_or_configuration( @@ -1560,17 +1561,11 @@ async def test_post_replace_unknown_survives_caller_cancel_during_close( store = SecureTokenStore(config.state_dir) old = TokenRecord.create(user_id=42, username="old", token="old-token") save_record(store, old) - real_fsync = os.fsync - fsync_calls = 0 - def fail_post_replace_directory_fsync(fd: int) -> None: - nonlocal fsync_calls - fsync_calls += 1 - if fsync_calls == 2: - raise OSError("synthetic post-replace durability failure") - real_fsync(fd) + def fail_post_replace_directory_fsync(_directory: Path) -> None: + raise OSError("synthetic post-replace durability failure") - monkeypatch.setattr(security_module.os, "fsync", fail_post_replace_directory_fsync) + monkeypatch.setattr(security_module.private_fs, "fsync_directory", fail_post_replace_directory_fsync) close_entered = asyncio.Event() close_release = asyncio.Event() client = BlockingCloseAuthClient( @@ -1627,17 +1622,11 @@ async def test_post_replace_primary_survives_release_error_and_unlocks( config.state_dir, protected_detail=protected_detail, ) - real_fsync = os.fsync - fsync_calls = 0 - def fail_post_replace_directory_fsync(fd: int) -> None: - nonlocal fsync_calls - fsync_calls += 1 - if fsync_calls == 2: - raise OSError("synthetic post-replace durability failure") - real_fsync(fd) + def fail_post_replace_directory_fsync(_directory: Path) -> None: + raise OSError("synthetic post-replace durability failure") - monkeypatch.setattr(security_module.os, "fsync", fail_post_replace_directory_fsync) + monkeypatch.setattr(security_module.private_fs, "fsync_directory", fail_post_replace_directory_fsync) client = AuthClient( Actor(id=42, username="new"), fresh_token="new-token", @@ -1897,17 +1886,40 @@ def test_token_replace_post_commit_failure_is_typed_as_ambiguous( username="new", token="new-token", ) - real_fsync = os.fsync - fsync_calls = 0 - def fail_directory_fsync(fd: int) -> None: - nonlocal fsync_calls - fsync_calls += 1 - if fsync_calls == 2: - raise OSError("synthetic post-replace failure") - real_fsync(fd) + def fail_directory_fsync(_directory: Path) -> None: + raise OSError("synthetic post-replace failure") + + monkeypatch.setattr(security_module.private_fs, "fsync_directory", fail_directory_fsync) + fd = store.acquire_lock("account-42") + try: + with pytest.raises(GatewayError) as caught: + store.save_locked("account-42", replacement) + finally: + store.release_lock(fd) + + assert caught.value.code is ErrorCode.CREDENTIAL_UPDATE_UNKNOWN + assert caught.value.reconciliation_required is True + assert load_record(store) == replacement + + +def test_token_replace_failing_its_final_check_is_typed_as_ambiguous( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # On Windows the re-check after replace (owner and DACL) is the only step + # that can fail once the new record is visible. + store = SecureTokenStore(tmp_path / "state") + save_record(store, TokenRecord.create(user_id=42, username="old", token="old-token")) + replacement = TokenRecord.create(user_id=42, username="new", token="new-token") + real_validate = security_module._validate_private_file + + def reject_after_replace(path: Path, *, allow_missing: bool = True) -> os.stat_result | None: + if not allow_missing: + raise GatewayError(ErrorCode.VALIDATION, diagnostic="private_file_acl_not_private") + return real_validate(path, allow_missing=allow_missing) - monkeypatch.setattr(security_module.os, "fsync", fail_directory_fsync) + monkeypatch.setattr(security_module, "_validate_private_file", reject_after_replace) fd = store.acquire_lock("account-42") try: with pytest.raises(GatewayError) as caught: @@ -1916,6 +1928,7 @@ def fail_directory_fsync(fd: int) -> None: store.release_lock(fd) assert caught.value.code is ErrorCode.CREDENTIAL_UPDATE_UNKNOWN + assert caught.value.diagnostic == "token_replace_commit_durability_unknown" assert caught.value.reconciliation_required is True assert load_record(store) == replacement @@ -1932,9 +1945,9 @@ def test_read_private_secret_file_accepts_one_terminal_newline(tmp_path: Path) - @pytest.mark.parametrize( "kind", [ - "symlink", + pytest.param("symlink", marks=posix_only), "directory", - "wrong_mode", + pytest.param("wrong_mode", marks=posix_only), "oversize", "invalid_utf8", "multiline", @@ -1950,31 +1963,31 @@ def test_read_private_secret_file_rejects_unsafe_sources( path = tmp_path / "legacy-token" if kind == "symlink": target = tmp_path / "target" - target.write_text("token") + target.write_text("token", encoding="utf-8") os.chmod(target, 0o600) path.symlink_to(target) elif kind == "directory": path.mkdir(mode=0o700) elif kind == "wrong_mode": - path.write_text("token") + path.write_text("token", encoding="utf-8") os.chmod(path, 0o640) elif kind == "oversize": - path.write_text("x" * 4097) + path.write_text("x" * 4097, encoding="utf-8") os.chmod(path, 0o600) elif kind == "invalid_utf8": path.write_bytes(b"\xff\xfe") os.chmod(path, 0o600) elif kind == "multiple_newlines": - path.write_text("token\n\n") + path.write_text("token\n\n", encoding="utf-8") os.chmod(path, 0o600) elif kind == "leading_tab": - path.write_text("\ttoken\n") + path.write_text("\ttoken\n", encoding="utf-8") os.chmod(path, 0o600) elif kind == "bidi_control": - path.write_text("token\u202e") + path.write_text("token\u202e", encoding="utf-8") os.chmod(path, 0o600) else: - path.write_text("first\nsecond\n") + path.write_text("first\nsecond\n", encoding="utf-8") os.chmod(path, 0o600) with pytest.raises(GatewayError) as caught: @@ -1982,6 +1995,7 @@ def test_read_private_secret_file_rejects_unsafe_sources( assert caught.value.code is ErrorCode.VALIDATION +@posix_only def test_read_private_secret_file_rejects_wrong_owner( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_config_models_errors_more.py b/tests/test_config_models_errors_more.py index 8c20395..a37c9a2 100644 --- a/tests/test_config_models_errors_more.py +++ b/tests/test_config_models_errors_more.py @@ -34,8 +34,10 @@ WriteAction, ) from kwork_mcp.server import create_server +from tests.platforms import posix_only +@posix_only def test_default_state_dir_honors_xdg_and_falls_back_to_home( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/test_config_security.py b/tests/test_config_security.py index ea7fda6..8770427 100644 --- a/tests/test_config_security.py +++ b/tests/test_config_security.py @@ -1,7 +1,6 @@ from __future__ import annotations import os -import stat from pathlib import Path import pytest @@ -16,6 +15,7 @@ redact_text, sanitize_external, ) +from tests.platforms import assert_private_directory, assert_private_file, posix_only def test_configuration_never_loads_dotenv_from_cwd( @@ -69,6 +69,7 @@ def test_secret_inputs_are_hidden_in_validation_errors(tmp_path: Path) -> None: assert "input_value=" not in rendered +@posix_only def test_secure_directory_rejects_permissions_and_symlinks(tmp_path: Path) -> None: unsafe = tmp_path / "unsafe" unsafe.mkdir(mode=0o755) @@ -93,14 +94,15 @@ def test_token_store_roundtrip_is_private_atomic_and_account_scoped(tmp_path: Pa store.save_locked("account-42", record) assert store.load_locked("account-42") == record token_path = state_dir / "tokens" / "account-42.json" - assert stat.S_IMODE(token_path.stat().st_mode) == 0o600 - assert stat.S_IMODE((state_dir / "tokens").stat().st_mode) == 0o700 + assert_private_file(token_path) + assert_private_directory(state_dir / "tokens") store.delete_locked("account-42") assert store.load_locked("account-42") is None finally: store.release_lock(fd) +@posix_only def test_token_store_rejects_symlink_and_world_readable_token(tmp_path: Path) -> None: state_dir = tmp_path / "state" store = SecureTokenStore(state_dir) diff --git a/tests/test_coordination.py b/tests/test_coordination.py index 7d544b4..6ef5a8a 100644 --- a/tests/test_coordination.py +++ b/tests/test_coordination.py @@ -5,7 +5,6 @@ import multiprocessing import os import sqlite3 -import stat import time import warnings from collections.abc import Callable @@ -19,6 +18,7 @@ from kwork_mcp.coordination import CoordinationStore, CursorCodec, pages_from_paging from kwork_mcp.errors import GatewayError from kwork_mcp.models import ErrorCode, WriteAction, WriteState +from tests.platforms import assert_private_file @pytest.mark.asyncio @@ -423,7 +423,7 @@ def test_rate_limit_is_shared_between_processes(tmp_path: Path) -> None: process.join(timeout=5) assert process.exitcode == 0 assert timestamps[1] - timestamps[0] >= 0.40 - assert stat.S_IMODE((state_dir / "coordination.sqlite3").stat().st_mode) == 0o600 + assert_private_file(state_dir / "coordination.sqlite3") def test_sqlite_connections_do_not_emit_resource_warnings( diff --git a/tests/test_coordination_more.py b/tests/test_coordination_more.py index 3ec9089..fdee727 100644 --- a/tests/test_coordination_more.py +++ b/tests/test_coordination_more.py @@ -16,6 +16,7 @@ from kwork_mcp.coordination import CoordinationStore, CursorCodec, pages_from_paging from kwork_mcp.errors import GatewayError from kwork_mcp.models import ErrorCode, ErrorInfo, WriteAction, WriteState +from tests.platforms import posix_only def _create_legacy_database(path: Path, version: int) -> None: @@ -221,6 +222,7 @@ async def test_v2_committing_write_migrates_to_unknown_and_blocks_duplicates( assert claimed.state is WriteState.COMMITTING +@posix_only def test_coordination_rejects_symlink_permissions_and_post_init_tamper( config_factory: Callable[..., KworkConfig], tmp_path: Path, @@ -250,6 +252,7 @@ def test_coordination_rejects_symlink_permissions_and_post_init_tamper( assert changed.value.diagnostic == "coordination_db_security_changed" +@posix_only def test_fresh_coordination_database_is_private_under_restrictive_umask( tmp_path: Path, ) -> None: diff --git a/tests/test_hardening.py b/tests/test_hardening.py index 85d1cdd..243a382 100644 --- a/tests/test_hardening.py +++ b/tests/test_hardening.py @@ -131,8 +131,9 @@ def test_main_reports_invalid_configuration_without_traceback( expected: str, ) -> None: _clear_kwork_environment(monkeypatch) - # The default state directory lives under HOME; never read the real one. + # The default state directory lives in the home directory; never read the real one. monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) monkeypatch.delenv("XDG_STATE_HOME", raising=False) for name, value in env.items(): monkeypatch.setenv(name, value) diff --git a/tests/test_mcp_protocol.py b/tests/test_mcp_protocol.py index 23f9611..1d5181c 100644 --- a/tests/test_mcp_protocol.py +++ b/tests/test_mcp_protocol.py @@ -387,7 +387,7 @@ async def test_in_memory_handshake_tools_schemas_annotations_and_results( assert negotiated.protocol_version == protocol_version assert negotiated.server_info is not None assert negotiated.server_info.name == "kwork" - assert negotiated.server_info.version == __version__ == "1.5.2" + assert negotiated.server_info.version == __version__ == "1.6.0" assert negotiated.instructions == server_instructions("auto") assert negotiated.server_capabilities is not None assert negotiated.server_capabilities.tools is not None diff --git a/tests/test_release_metadata.py b/tests/test_release_metadata.py index ec9b045..b805dca 100644 --- a/tests/test_release_metadata.py +++ b/tests/test_release_metadata.py @@ -14,7 +14,7 @@ def test_registry_metadata_advertises_only_safe_server_environment() -> None: - metadata = json.loads((ROOT / "server.json").read_text()) + metadata = json.loads((ROOT / "server.json").read_text(encoding="utf-8")) package = metadata["packages"][0] variables = package["environmentVariables"] by_name = {item["name"]: item for item in variables} @@ -34,7 +34,7 @@ def test_registry_metadata_advertises_only_safe_server_environment() -> None: def test_example_environment_has_no_active_secret_assignment() -> None: active_names = { line.partition("=")[0].strip() - for line in (ROOT / ".env.example").read_text().splitlines() + for line in (ROOT / ".env.example").read_text(encoding="utf-8").splitlines() if line.strip() and not line.lstrip().startswith("#") and "=" in line } assert active_names.isdisjoint(SERVER_SECRET_ENV_NAMES) @@ -42,11 +42,11 @@ def test_example_environment_has_no_active_secret_assignment() -> None: def test_versions_and_console_entrypoints_are_consistent() -> None: - project = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"] - registry = json.loads((ROOT / "server.json").read_text()) + project = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"] + registry = json.loads((ROOT / "server.json").read_text(encoding="utf-8")) assert project["dynamic"] == ["version"] - assert registry["version"] == __version__ == "1.5.2" + assert registry["version"] == __version__ == "1.6.0" assert registry["packages"][0]["version"] == __version__ assert project["scripts"] == { "kwork-mcp": "kwork_mcp:main", @@ -56,7 +56,7 @@ def test_versions_and_console_entrypoints_are_consistent() -> None: def test_install_instructions_pin_the_current_version() -> None: for name in ("README.md", "site/index.html"): - text = (ROOT / name).read_text() + text = (ROOT / name).read_text(encoding="utf-8") assert set(re.findall(r"kwork-mcp(?:==|@)([0-9][0-9A-Za-z.]*)", text)) == {__version__}, name # The "Add to Cursor" link carries its own base64 copy of the config. configs = re.findall(r"install-mcp\?name=kwork&(?:amp;)?config=([A-Za-z0-9+/%=]+)", text) @@ -65,12 +65,12 @@ def test_install_instructions_pin_the_current_version() -> None: config = json.loads(base64.b64decode(unquote(encoded))) assert config == {"command": "uvx", "args": [f"kwork-mcp@{__version__}"]}, name - site = (ROOT / "site" / "index.html").read_text() + site = (ROOT / "site" / "index.html").read_text(encoding="utf-8") assert f'"softwareVersion": "{__version__}"' in site def test_release_workflow_fails_closed_for_prerelease_registry_publish() -> None: - workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text() + workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8") assert "source_is_prerelease = Version(__version__).is_prerelease" in workflow assert "source_is_prerelease != event_is_prerelease" in workflow @@ -82,12 +82,12 @@ def test_release_workflow_fails_closed_for_prerelease_registry_publish() -> None def test_workflows_use_explicit_locked_mode_without_conflicting_environment() -> None: for name in ("ci.yml", "release.yml"): - workflow = (ROOT / ".github" / "workflows" / name).read_text() + workflow = (ROOT / ".github" / "workflows" / name).read_text(encoding="utf-8") assert "UV_FROZEN" not in workflow assert "uv sync --locked --all-groups" in workflow def test_release_asset_upload_does_not_require_a_checkout() -> None: - workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text() + workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8") assert 'gh release upload "$RELEASE_TAG" dist/* --repo "$GITHUB_REPOSITORY"' in workflow diff --git a/tests/test_security_more.py b/tests/test_security_more.py index bbc7f46..5151ec8 100644 --- a/tests/test_security_more.py +++ b/tests/test_security_more.py @@ -16,6 +16,7 @@ from yarl import URL import kwork_mcp.security as security_module +from kwork_mcp import private_fs from kwork_mcp.config import KworkConfig, proxy_redaction_secrets from kwork_mcp.errors import GatewayError from kwork_mcp.models import ErrorCode @@ -28,6 +29,7 @@ redact_text, sanitize_external, ) +from tests.platforms import assert_private_directory, posix_only def _assert_validation(error: pytest.ExceptionInfo[GatewayError], diagnostic: str) -> None: @@ -45,15 +47,23 @@ def test_secure_directory_creates_private_parents_and_rejects_regular_file( ) -> None: nested = tmp_path / "private" / "nested" ensure_secure_directory(nested) - assert stat.S_IMODE(nested.stat().st_mode) == 0o700 + assert_private_directory(tmp_path / "private") + assert_private_directory(nested) regular = tmp_path / "regular" regular.write_text("not a directory") with pytest.raises(GatewayError) as caught: ensure_secure_directory(regular) - assert caught.value.diagnostic == "state_directory_chain_error:NotADirectoryError" + # POSIX opens the path as a directory; Windows looks at its type first. + expected = ( + "state_directory_component_not_directory" + if sys.platform == "win32" + else "state_directory_chain_error:NotADirectoryError" + ) + assert caught.value.diagnostic == expected +@posix_only def test_secure_directory_supports_execute_only_trusted_ancestor( tmp_path: Path, ) -> None: @@ -66,6 +76,7 @@ def test_secure_directory_supports_execute_only_trusted_ancestor( assert stat.S_IMODE(state.stat().st_mode) == 0o700 +@posix_only def test_secure_directory_creation_is_private_under_restrictive_umask( tmp_path: Path, ) -> None: @@ -79,6 +90,7 @@ def test_secure_directory_creation_is_private_under_restrictive_umask( assert stat.S_IMODE(state.stat().st_mode) == 0o700 +@posix_only def test_secure_directory_wraps_creation_failure( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -92,6 +104,7 @@ def fail_mkdir(*_args: Any, **_kwargs: Any) -> None: _assert_validation(caught, "state_directory_chain_error:PermissionError") +@posix_only def test_secure_directory_rejects_real_nonsticky_writable_ancestor( tmp_path: Path, ) -> None: @@ -111,6 +124,7 @@ def test_secure_directory_rejects_real_nonsticky_writable_ancestor( ) +@posix_only def test_secure_directory_allows_one_sticky_temp_boundary_but_not_nested_shared( tmp_path: Path, ) -> None: @@ -132,6 +146,7 @@ def test_secure_directory_allows_one_sticky_temp_boundary_but_not_nested_shared( ) +@posix_only @pytest.mark.parametrize("unsafe_mode", [0o710, 0o1700, 0o2700, 0o4700]) def test_secure_directory_requires_exact_final_mode( tmp_path: Path, @@ -145,6 +160,7 @@ def test_secure_directory_requires_exact_final_mode( _assert_validation(caught, "state_directory_permissions_must_be_0700") +@posix_only def test_secure_directory_resolves_only_trusted_nonfinal_aliases( tmp_path: Path, ) -> None: @@ -167,6 +183,7 @@ def test_secure_directory_resolves_only_trusted_nonfinal_aliases( _assert_validation(caught, "state_directory_untrusted_symlink_ancestor") +@posix_only def test_secure_directory_checks_every_alias_hop_in_target_chain( tmp_path: Path, ) -> None: @@ -187,6 +204,7 @@ def test_secure_directory_checks_every_alias_hop_in_target_chain( assert not (target / "state").exists() +@posix_only def test_secure_directory_resolves_relative_alias_and_rejects_alias_loop( tmp_path: Path, ) -> None: @@ -205,6 +223,7 @@ def test_secure_directory_resolves_relative_alias_and_rejects_alias_loop( _assert_validation(caught, "state_directory_alias_loop") +@posix_only def test_secure_directory_wraps_readlink_failure_without_following( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -227,6 +246,7 @@ def fail_readlink(_path: Path) -> str: assert not (target / "state").exists() +@posix_only def test_secure_directory_symlink_injection_race_is_never_followed( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -288,13 +308,17 @@ def test_secure_directory_rejects_ambiguous_lexical_paths(unsafe: Path) -> None: _assert_validation(caught, "state_directory_path_invalid") -def test_token_store_rejects_invalid_scope_and_non_regular_lock(tmp_path: Path) -> None: +def test_token_store_rejects_invalid_scope(tmp_path: Path) -> None: store = SecureTokenStore(tmp_path / "state") for scope in ("../escape", "UPPERCASE", "", "a" * 97): with pytest.raises(GatewayError) as caught: store.load_locked(scope) _assert_validation(caught, "invalid_token_scope") + +@posix_only +def test_token_store_rejects_a_directory_as_its_lock(tmp_path: Path) -> None: + store = SecureTokenStore(tmp_path / "state") lock = tmp_path / "state" / "tokens" / "account-1.lock" lock.mkdir(mode=0o700) with pytest.raises(GatewayError) as caught: @@ -302,6 +326,7 @@ def test_token_store_rejects_invalid_scope_and_non_regular_lock(tmp_path: Path) _assert_validation(caught, "token_lock_error:IsADirectoryError") +@posix_only def test_fresh_token_lock_is_private_under_restrictive_umask(tmp_path: Path) -> None: old_umask = os.umask(0o777) fd: int | None = None @@ -318,6 +343,7 @@ def test_fresh_token_lock_is_private_under_restrictive_umask(tmp_path: Path) -> assert stat.S_IMODE(lock.stat().st_mode) == 0o600 +@posix_only def test_existing_token_lock_permissions_are_rejected_without_repair( tmp_path: Path, ) -> None: @@ -623,7 +649,7 @@ def test_token_store_rejects_malformed_records( ) -> None: store = SecureTokenStore(tmp_path / "state") token_path = tmp_path / "state" / "tokens" / "account-42.json" - token_path.write_text(payload) + token_path.write_bytes(private_fs.seal(payload.encode(), purpose=security_module._token_seal_purpose("account-42"))) os.chmod(token_path, 0o600) with pytest.raises(GatewayError) as caught: store.load_locked("account-42") diff --git a/tests/test_short_commands.py b/tests/test_short_commands.py index b2c5d60..8fe956a 100644 --- a/tests/test_short_commands.py +++ b/tests/test_short_commands.py @@ -3,6 +3,7 @@ from __future__ import annotations import io +import json import os from pathlib import Path from types import SimpleNamespace @@ -18,6 +19,7 @@ AccountSelectionError, KworkConfig, bound_account_ids, + client_default_state_dir, load_server_config, select_bound_account, ) @@ -25,6 +27,7 @@ from kwork_mcp.security import SecureTokenStore, TokenRecord from kwork_mcp.server import create_server from kwork_mcp.version import __version__ +from tests.platforms import posix_only class TTYBuffer(io.StringIO): @@ -38,6 +41,7 @@ def state_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: if name.upper().startswith("KWORK_"): monkeypatch.delenv(name, raising=False) monkeypatch.setenv("HOME", str(tmp_path / "home")) + monkeypatch.setenv("USERPROFILE", str(tmp_path / "home")) monkeypatch.delenv("XDG_STATE_HOME", raising=False) path = tmp_path / "state" path.mkdir(mode=0o700) @@ -347,7 +351,7 @@ def test_login_prints_the_shortest_commands_for_the_default_setup( state_dir: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - default = Path(os.environ["HOME"]) / ".local" / "state" / "kwork-mcp" + default = client_default_state_dir() monkeypatch.setenv("KWORK_STATE_DIR", str(default)) lines = _instructions().splitlines() @@ -379,6 +383,7 @@ def deny(_self: Path) -> Any: assert "-e KWORK_EXPECTED_USER_ID=42" in _instructions() +@posix_only def test_login_quotes_a_state_directory_with_spaces( state_dir: Path, monkeypatch: pytest.MonkeyPatch, @@ -586,7 +591,7 @@ def test_login_prints_a_claude_desktop_entry_with_the_absolute_uvx_path( assert ( f'"kwork-com": {{"command": "/opt/tools/bin/uvx", "args": ["kwork-mcp@{__version__}"], ' - f'"env": {{"KWORK_SITE": "com", "KWORK_STATE_DIR": "{state_dir}"}}}}' + f'"env": {{"KWORK_SITE": "com", "KWORK_STATE_DIR": {json.dumps(str(state_dir), ensure_ascii=False)}}}}}' ) in output @@ -595,7 +600,7 @@ def test_login_falls_back_to_plain_uvx_when_it_is_not_on_path( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr("kwork_mcp.bootstrap.shutil.which", lambda _name: None) - default = Path(os.environ["HOME"]) / ".local" / "state" / "kwork-mcp" + default = client_default_state_dir() monkeypatch.setenv("KWORK_STATE_DIR", str(default)) assert f'"kwork": {{"command": "uvx", "args": ["kwork-mcp@{__version__}"]}}' in _instructions() diff --git a/tests/test_windows_support.py b/tests/test_windows_support.py new file mode 100644 index 0000000..ae95415 --- /dev/null +++ b/tests/test_windows_support.py @@ -0,0 +1,605 @@ +"""Windows support: owner and DACL checks, path syntax, locks, sealed tokens and client hints. + +The pure pieces run everywhere; the Windows-only tests exercise the real +Win32 calls on the Windows CI job. +""" + +from __future__ import annotations + +import io +import json +import os +import shutil +import subprocess +import sys +import uuid +from collections.abc import Callable, Iterator +from pathlib import Path, PureWindowsPath +from typing import Any + +import pytest +from kwork.schema.actor import Actor + +import kwork_mcp +from kwork_mcp import private_fs +from kwork_mcp.bootstrap import _command_line, _connection_instructions, _warn_if_elevated, run_bootstrap_cli +from kwork_mcp.bootstrap import main as bootstrap_main +from kwork_mcp.config import KworkConfig, _default_state_dir, client_default_state_dir +from kwork_mcp.coordination import CoordinationStore +from kwork_mcp.errors import GatewayError +from kwork_mcp.models import ErrorCode +from kwork_mcp.security import SecureTokenStore, TokenRecord, ensure_secure_directory +from tests.platforms import posix_only, windows_only + +USER = "S-1-5-21-1004336348-1177238915-682003330-1001" +OTHER_USER = "S-1-5-21-1004336348-1177238915-682003330-1002" +USERS = "S-1-5-32-545" +EVERYONE = "S-1-1-0" + +# --- who may open a Windows object + +FULL = 0x1F01FF +MODIFY = 0x1301BF +READ_EXECUTE = 0x1200A9 +AUTHENTICATED_USERS = "S-1-5-11" + + +def allow(sid: str, mask: int = FULL, *, inherit_only: bool = False) -> tuple[str, str | None, int, bool]: + return ("allow", sid, mask, inherit_only) + + +def test_owner_only_dacl_with_system_and_administrators_is_private() -> None: + entries = [ + allow(USER), + allow(private_fs.SYSTEM_SID), + allow(private_fs.ADMINISTRATORS_SID), + ("deny", None, 0, False), + ] + + assert private_fs.windows_acl_problem(USER, entries, user_sid=USER) is None + assert private_fs.windows_acl_problem(private_fs.ADMINISTRATORS_SID, entries, user_sid=USER) is None + + +@pytest.mark.parametrize("owner", [OTHER_USER, USERS, None]) +def test_an_object_owned_by_someone_else_is_not_private(owner: str | None) -> None: + assert private_fs.windows_acl_problem(owner, [allow(USER)], user_sid=USER) == "wrong_owner" + + +@pytest.mark.parametrize( + "entries", + [ + None, + [allow(USER), allow(USERS, READ_EXECUTE)], + [allow(EVERYONE)], + [allow(OTHER_USER, 0x1)], + [allow(USER), ("other", None, 0, False)], + ], + ids=["null-dacl", "users-read", "everyone-full", "other-user", "unknown-ace"], +) +def test_a_dacl_that_lets_anyone_else_in_is_not_private(entries: list[Any] | None) -> None: + assert private_fs.windows_acl_problem(USER, entries, user_sid=USER) == "acl_not_private" + + +def test_owner_rights_count_as_the_trusted_owner() -> None: + # os.mkdir(path, 0o700) on Windows grants SYSTEM, Administrators and OWNER RIGHTS. + entries = [allow(private_fs.SYSTEM_SID), allow(private_fs.ADMINISTRATORS_SID), allow(private_fs.OWNER_RIGHTS_SID)] + + assert private_fs.windows_acl_problem(USER, entries, user_sid=USER) is None + assert private_fs.windows_acl_problem(private_fs.OWNER_RIGHTS_SID, entries, user_sid=USER) == "wrong_owner" + + +def test_an_allow_entry_without_rights_grants_nothing() -> None: + entries = [allow(USER), allow(EVERYONE, 0)] + + assert private_fs.windows_acl_problem(USER, entries, user_sid=USER) is None + + +def test_a_directory_must_not_pass_anyone_else_on_to_new_files() -> None: + entries = [allow(USER), allow(USERS, READ_EXECUTE, inherit_only=True)] + + # A file ignores entries meant for what is created inside it later. + assert private_fs.windows_acl_problem(USER, entries, user_sid=USER) is None + assert private_fs.windows_acl_problem(USER, entries, user_sid=USER, directory=True) == "acl_not_private" + + +def test_a_directory_may_pass_rights_on_to_the_creator() -> None: + entries = [allow(USER), allow(private_fs.CREATOR_OWNER_SID, 0x10000000, inherit_only=True)] + + assert private_fs.windows_acl_problem(USER, entries, user_sid=USER, directory=True) is None + + +# --- who may swap a directory above the state directory + + +def test_a_system_managed_ancestor_is_trusted() -> None: + # C:\ on Windows 10/11: TrustedInstaller owns it; users may add folders and + # pass Modify on to them, but may not rename or re-permission the root. + entries = [ + allow(private_fs.ADMINISTRATORS_SID), + allow(private_fs.SYSTEM_SID), + allow(USERS, READ_EXECUTE), + allow(AUTHENTICATED_USERS, MODIFY, inherit_only=True), + allow(AUTHENTICATED_USERS, 0x4), + ] + + problem = private_fs.windows_ancestor_problem(private_fs.TRUSTED_INSTALLER_SID, entries, user_sid=USER, root=True) + + assert problem is None + + +def test_an_ancestor_others_may_rename_is_rejected() -> None: + # A folder made under a drive root inherits Authenticated Users: Modify. + entries = [allow(USER), allow(AUTHENTICATED_USERS, MODIFY)] + + assert ( + private_fs.windows_ancestor_problem(USER, entries, user_sid=USER, root=False) == "untrusted_writable_ancestor" + ) + # Modify on a drive root grants DELETE, which cannot rename a root. + assert private_fs.windows_ancestor_problem(USER, entries, user_sid=USER, root=True) is None + + +@pytest.mark.parametrize( + "entries", + [None, [allow(EVERYONE, 0x40)], [allow(OTHER_USER, 0x40000)], [("other", None, 0, False)]], + ids=["null-dacl", "delete-child", "write-dac", "unknown-ace"], +) +def test_an_ancestor_others_may_take_over_is_rejected(entries: list[Any] | None) -> None: + problem = private_fs.windows_ancestor_problem(USER, entries, user_sid=USER, root=True) + + assert problem == "untrusted_writable_ancestor" + + +def test_an_ancestor_owned_by_someone_else_is_rejected() -> None: + problem = private_fs.windows_ancestor_problem(OTHER_USER, [allow(USER)], user_sid=USER, root=False) + + assert problem == "untrusted_ancestor_owner" + + +# --- Windows path syntax that can name something else + + +@pytest.mark.parametrize( + "raw", + [ + r"C:\Users\Имя Фамилия\AppData\Local\kwork-mcp", + r"D:\state\kwork.mcp", + ], +) +def test_ordinary_windows_paths_are_accepted(raw: str) -> None: + assert private_fs.windows_path_is_ambiguous(PureWindowsPath(raw)) is False + + +@pytest.mark.parametrize( + "raw", + [ + r"\\server\share\kwork", + r"\\?\C:\kwork", + r"\\.\C:\kwork", + r"\kwork", + r"C:kwork", + r"C:\kwork:stream", + r"C:\state\NUL", + r"C:\state\con.txt", + r"C:\state\COM1", + r"C:\state\name.", + r"C:\state\name ", + r"C:\state\..\other", + ], +) +def test_ambiguous_windows_paths_are_rejected(raw: str) -> None: + assert private_fs.windows_path_is_ambiguous(PureWindowsPath(raw)) is True + + +# --- interprocess locks + + +def test_a_lock_is_exclusive_until_released(tmp_path: Path) -> None: + path = tmp_path / "state.lock" + first = os.open(path, os.O_RDWR | os.O_CREAT, 0o600) + second = os.open(path, os.O_RDWR) + try: + private_fs.try_lock(first) + with pytest.raises(BlockingIOError): + private_fs.try_lock(second) + private_fs.unlock(first) + private_fs.try_lock(second) + private_fs.unlock(second) + finally: + os.close(first) + os.close(second) + + +def test_closing_the_file_releases_its_lock(tmp_path: Path) -> None: + path = tmp_path / "state.lock" + first = os.open(path, os.O_RDWR | os.O_CREAT, 0o600) + private_fs.try_lock(first) + os.close(first) + + second = os.open(path, os.O_RDWR) + try: + private_fs.try_lock(second) + private_fs.unlock(second) + finally: + os.close(second) + + +# --- sealed secrets + + +def test_a_sealed_secret_opens_with_the_same_purpose() -> None: + secret = b'{"token":"sealed-token-sentinel"}' + + sealed = private_fs.seal(secret, purpose="kwork-mcp token account-42") + + assert private_fs.unseal(sealed, purpose="kwork-mcp token account-42") == secret + + +@posix_only +def test_posix_relies_on_file_modes_and_stores_secrets_as_is() -> None: + assert private_fs.seal(b"secret", purpose="any") == b"secret" + + +@windows_only +def test_windows_encrypts_a_sealed_secret_for_the_current_user_and_purpose() -> None: + sealed = private_fs.seal(b"sealed-token-sentinel", purpose="kwork-mcp token account-42") + + assert b"sealed-token-sentinel" not in sealed + with pytest.raises(OSError): + private_fs.unseal(sealed, purpose="kwork-mcp token account-77") + + +# --- where the state lives + + +def test_windows_keeps_state_in_the_profile_not_in_app_data( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + # Windows shows packaged (MSIX) apps such as Claude Desktop their own copy + # of AppData, so the server they start and the terminal would split state. + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "xdg")) + monkeypatch.setenv("LOCALAPPDATA", str(tmp_path / "local")) + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path / "profile")) + + assert _default_state_dir() == tmp_path / "profile" / ".local" / "state" / "kwork-mcp" + assert client_default_state_dir() == _default_state_dir() + + +def test_posix_clients_start_the_server_without_xdg_state_home( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "xdg")) + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path / "home")) + + assert client_default_state_dir() == tmp_path / "home" / ".local" / "state" / "kwork-mcp" + + +# --- what login prints on Windows + + +def test_windows_commands_are_quoted_for_powershell_and_cmd(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(sys, "platform", "win32") + + command = _command_line( + ["claude", "mcp", "add", "kwork", "-e", r"KWORK_STATE_DIR=C:\Users\Имя Фамилия\kwork", "--", "uvx", "kwork"] + ) + + assert command == r'claude mcp add kwork -e "KWORK_STATE_DIR=C:\Users\Имя Фамилия\kwork" -- uvx kwork' + + +def test_windows_login_names_the_windows_client_config_files( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + for name in tuple(os.environ): + if name.upper().startswith("KWORK_"): + monkeypatch.delenv(name, raising=False) + uvx = r"C:\Users\Имя Фамилия\.local\bin\uvx.exe" + monkeypatch.setattr(shutil, "which", lambda _name: uvx) + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path / "profile")) + state_dir = tmp_path / "profile" / ".local" / "state" / "kwork-mcp" + config = KworkConfig(expected_user_id=42, state_dir=state_dir) + + output = _connection_instructions(Actor(id=42, username="found-user"), config, legacy_file_retained=False) + + assert "KWORK_STATE_DIR" not in output + assert json.dumps(uvx, ensure_ascii=False) in output + # A packaged Claude Desktop keeps its config in its own AppData copy. + assert "Settings → Developer → Edit Config" in output + assert "%APPDATA%" not in output + assert r"%USERPROFILE%\.cursor\mcp.json" in output + assert "~/.cursor" not in output + + +@pytest.mark.asyncio +@pytest.mark.parametrize("argv", [[], ["logout"]]) +async def test_windows_points_a_non_terminal_run_to_powershell( + monkeypatch: pytest.MonkeyPatch, argv: list[str] +) -> None: + # Git Bash in its own mintty window hands programs pipes, not a console. + monkeypatch.setattr(sys, "platform", "win32") + stderr = io.StringIO() + + code = await run_bootstrap_cli(argv, stdin=io.StringIO(), stdout=io.StringIO(), stderr=stderr) + + assert code == 2 + assert "PowerShell" in stderr.getvalue() + + +@pytest.mark.parametrize(("elevated", "warned"), [(True, True), (False, False), (OSError("no token"), False)]) +def test_windows_login_warns_when_run_as_administrator( + monkeypatch: pytest.MonkeyPatch, + elevated: bool | OSError, + warned: bool, +) -> None: + # An elevated window may belong to another Windows account, whose profile + # and DPAPI key the client's server never sees. + def is_elevated() -> bool: + if isinstance(elevated, OSError): + raise elevated + return elevated + + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(private_fs, "is_elevated", is_elevated) + stderr = io.StringIO() + + _warn_if_elevated(stderr) + + assert ("администратора" in stderr.getvalue()) is warned + + +# --- console encoding + + +@pytest.mark.parametrize("name", ["stdout", "stderr"]) +def test_windows_output_streams_are_switched_to_utf8(monkeypatch: pytest.MonkeyPatch, name: str) -> None: + raw = io.BytesIO() + monkeypatch.setattr(sys, name, io.TextIOWrapper(raw, encoding="cp1252")) + + kwork_mcp.use_utf8_streams() + stream = getattr(sys, name) + stream.write("сервер запущен ✓") + stream.flush() + + assert raw.getvalue().decode("utf-8") == "сервер запущен ✓" + + +def test_unusual_output_streams_are_left_alone(monkeypatch: pytest.MonkeyPatch) -> None: + stream = io.StringIO() + monkeypatch.setattr(sys, "stdout", stream) + monkeypatch.setattr(sys, "stderr", stream) + + kwork_mcp.use_utf8_streams() + + assert sys.stdout is stream + assert sys.stderr is stream + + +def test_windows_cli_entry_point_switches_to_utf8_before_writing(monkeypatch: pytest.MonkeyPatch) -> None: + raw = io.BytesIO() + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(sys, "stdout", io.TextIOWrapper(raw, encoding="cp1252")) + + bootstrap_main(["--help"]) + sys.stdout.flush() + + assert "login" in raw.getvalue().decode("utf-8") + + +# --- the real Win32 calls + + +def _grant(path: Path, grant: str) -> None: + subprocess.run(["icacls", str(path), "/grant", grant], check=True, capture_output=True) + + +def _validation_diagnostic(caught: pytest.ExceptionInfo[GatewayError]) -> str | None: + return caught.value.diagnostic + + +@windows_only +def test_windows_state_directory_gets_an_owner_only_protected_dacl(tmp_path: Path) -> None: + shared = tmp_path / "shared" + shared.mkdir() + # Everyone may write in the parent and would inherit into a plain mkdir. + _grant(shared, "*S-1-1-0:(OI)(CI)F") + + state = ensure_secure_directory(shared / "nested" / "state") + + assert state.is_dir() + assert private_fs.directory_problem(state) is None + assert private_fs.directory_problem(shared / "nested") is None + + +@windows_only +def test_windows_rejects_a_state_directory_that_others_can_open(tmp_path: Path) -> None: + state = tmp_path / "state" + state.mkdir() + _grant(state, "*S-1-1-0:(OI)(CI)F") + + with pytest.raises(GatewayError) as caught: + ensure_secure_directory(state) + + assert _validation_diagnostic(caught) == "state_directory_acl_not_private" + + +@windows_only +def test_windows_rejects_a_junction_as_the_state_directory(tmp_path: Path) -> None: + target = ensure_secure_directory(tmp_path / "target") + junction = tmp_path / "junction" + subprocess.run(["cmd", "/c", "mklink", "/J", str(junction), str(target)], check=True, capture_output=True) + + with pytest.raises(GatewayError) as caught: + ensure_secure_directory(junction) + + assert _validation_diagnostic(caught) == "state_directory_final_symlink" + + +@windows_only +def test_windows_rejects_ambiguous_state_paths(tmp_path: Path) -> None: + with pytest.raises(GatewayError) as caught: + ensure_secure_directory(tmp_path / "NUL") + + assert _validation_diagnostic(caught) == "state_directory_path_invalid" + + +def _save(store: SecureTokenStore, scope: str, record: TokenRecord) -> None: + lock = store.acquire_lock(scope) + try: + store.save_locked(scope, record) + finally: + store.release_lock(lock) + + +def _load(store: SecureTokenStore, scope: str) -> TokenRecord | None: + lock = store.acquire_lock(scope) + try: + return store.load_locked(scope) + finally: + store.release_lock(lock) + + +@windows_only +def test_windows_token_file_is_encrypted_and_bound_to_its_account(tmp_path: Path) -> None: + store = SecureTokenStore(tmp_path / "state") + record = TokenRecord.create(user_id=42, username="found-user", token="stored-token-sentinel") + _save(store, "account-42", record) + stored = tmp_path / "state" / "tokens" / "account-42.json" + + assert b"stored-token-sentinel" not in stored.read_bytes() + assert _load(store, "account-42") == record + + (tmp_path / "state" / "tokens" / "account-77.json").write_bytes(stored.read_bytes()) + with pytest.raises(GatewayError) as caught: + _load(store, "account-77") + # A record DPAPI cannot open here asks for a new login, not a config fix. + assert caught.value.code is ErrorCode.AUTH_EXPIRED + assert caught.value.diagnostic == "token_sealed_for_another_user" + + +@windows_only +def test_windows_rejects_a_token_file_that_others_can_read(tmp_path: Path) -> None: + store = SecureTokenStore(tmp_path / "state") + _save(store, "account-42", TokenRecord.create(user_id=42, username="found-user", token="stored-token-sentinel")) + _grant(tmp_path / "state" / "tokens" / "account-42.json", "*S-1-5-32-545:(R)") + + with pytest.raises(GatewayError) as caught: + _load(store, "account-42") + + assert _validation_diagnostic(caught) == "private_file_acl_not_private" + + +@windows_only +def test_windows_rejects_a_token_lock_that_others_can_open(tmp_path: Path) -> None: + store = SecureTokenStore(tmp_path / "state") + store.release_lock(store.acquire_lock("account-42")) + _grant(tmp_path / "state" / "tokens" / "account-42.lock", "*S-1-5-32-545:(R)") + + with pytest.raises(GatewayError) as caught: + store.acquire_lock("account-42") + + assert _validation_diagnostic(caught) == "token_lock_acl_not_private" + + +@windows_only +def test_windows_rejects_a_ledger_that_others_can_open(config_factory: Callable[..., KworkConfig]) -> None: + config = config_factory() + store = CoordinationStore(config) + _grant(store.path, "*S-1-5-32-545:(R)") + + with pytest.raises(GatewayError) as caught: + CoordinationStore(config) + + assert _validation_diagnostic(caught) == "coordination_db_permissions" + + +@windows_only +@pytest.mark.asyncio +async def test_windows_rejects_a_writer_lock_that_others_can_open( + config_factory: Callable[..., KworkConfig], +) -> None: + store = CoordinationStore(config_factory()) + async with store.writer_guard("account-42"): + pass + (lock,) = store.path.parent.glob("writer-*.lock") + _grant(lock, "*S-1-5-32-545:(R)") + + with pytest.raises(GatewayError) as caught: + async with store.writer_guard("account-42"): + pass + + assert _validation_diagnostic(caught) == "writer_lock_acl_not_private" + + +@windows_only +def test_windows_replace_waits_out_a_brief_sharing_violation( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + source = tmp_path / "new" + target = tmp_path / "current" + source.write_bytes(b"new") + target.write_bytes(b"old") + real_replace = private_fs.windows.replace + attempts = 0 + + def busy_twice(src: str, dst: Path) -> None: + nonlocal attempts + attempts += 1 + if attempts <= 2: + # ERROR_SHARING_VIOLATION, as when an antivirus holds the file. + raise OSError(None, "busy", None, 32) + real_replace(src, dst) + + monkeypatch.setattr(private_fs.windows, "replace", busy_twice) + + private_fs.replace(str(source), target) + + assert attempts == 3 + assert target.read_bytes() == b"new" + + +@windows_only +def test_windows_rejects_a_state_directory_that_passes_access_on(tmp_path: Path) -> None: + state = tmp_path / "state" + state.mkdir() + # Inherit-only: Users get nothing on the directory, but read on every new file. + _grant(state, "*S-1-5-32-545:(OI)(CI)(IO)(R)") + + with pytest.raises(GatewayError) as caught: + ensure_secure_directory(state) + + assert _validation_diagnostic(caught) == "state_directory_acl_not_private" + + +@pytest.fixture +def outside_profile(tmp_path: Path) -> Iterator[Path]: + """A fresh directory name at the drive root, outside the user's profile.""" + + path = Path(tmp_path.anchor) / f"kwork-mcp-test-{uuid.uuid4().hex}" + try: + yield path + finally: + shutil.rmtree(path, ignore_errors=True) + + +@windows_only +def test_windows_accepts_a_state_directory_it_creates_outside_the_profile(outside_profile: Path) -> None: + state = ensure_secure_directory(outside_profile / "state") + + assert private_fs.directory_problem(state) is None + + +@windows_only +def test_windows_rejects_a_state_directory_under_a_folder_others_may_rename(outside_profile: Path) -> None: + outside_profile.mkdir() + _grant(outside_profile, "*S-1-5-11:(OI)(CI)M") + + with pytest.raises(GatewayError) as caught: + ensure_secure_directory(outside_profile / "state") + + assert _validation_diagnostic(caught) == "state_directory_untrusted_writable_ancestor" From 1ac9f58a43ae762098e6f3b40ca98bf2ea53a569 Mon Sep 17 00:00:00 2001 From: Simon <68971770+simonether@users.noreply.github.com> Date: Sun, 4 Oct 2026 22:16:29 +0500 Subject: [PATCH 2/4] Connect Claude Desktop and Cursor from login Claude Desktop's Edit Config only reveals claude_desktop_config.json, so connecting it meant editing JSON by hand, worst of all on Windows. After the account is bound, login now finds Claude Desktop (macOS, Windows including the Microsoft Store build with its own AppData copy, community Linux builds) and Cursor, asks, and adds the server entry with the absolute uvx path. Other servers and settings stay as they are, the previous file is kept as .bak, a config that is not plain JSON is never touched, and another server under the same name is replaced only after a separate question. The manual block is printed only for clients that were not connected. --- AGENTS.md | 3 +- CHANGELOG.md | 5 + README.md | 15 ++- site/index.html | 5 +- src/kwork_mcp/bootstrap.py | 138 ++++++++++++++++++++----- src/kwork_mcp/clients.py | 144 ++++++++++++++++++++++++++ tests/conftest.py | 7 ++ tests/test_client_setup.py | 105 +++++++++++++++++++ tests/test_clients.py | 190 +++++++++++++++++++++++++++++++++++ tests/test_short_commands.py | 4 +- 10 files changed, 582 insertions(+), 34 deletions(-) create mode 100644 src/kwork_mcp/clients.py create mode 100644 tests/test_client_setup.py create mode 100644 tests/test_clients.py diff --git a/AGENTS.md b/AGENTS.md index 23d1175..c92be02 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,8 @@ durable `prepare → commit → reconcile` write protocol) on FastMCP 4 / MCP SD ``` src/kwork_mcp/ __init__.py main(): bare command = stdio server (rejects secret env, no banner); args → terminal CLI - bootstrap.py terminal CLI: login (TTY auth → store, prints client commands), status, logout, write resolution + bootstrap.py terminal CLI: login (TTY auth → store, connects clients, prints commands), status, logout, write resolution + clients.py finds Claude Desktop / Cursor configs (incl. the MSIX Claude) and adds the server with a .bak config.py KworkConfig (pydantic-settings, KWORK_ prefix), bound-account selection, proxy validation server.py create_server(): FastMCP app, lifespan, SERVER_INSTRUCTIONS, unknown-tool guard session.py KworkSessionManager: lazy auth, account identity checks, call_read / call_write_step diff --git a/CHANGELOG.md b/CHANGELOG.md index 6639c69..413983c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,11 @@ быть другая учётная запись, и сервер в агенте не увидит вход. Если токен не расшифровывается (сброс пароля Windows, перенос профиля), сервер отвечает `auth_expired` и просит выполнить login заново. +- `kwork-mcp login` подключает Claude Desktop и Cursor сам: если они установлены, он + спрашивает и дописывает сервер в их конфиг (на Windows и в версию Claude из Microsoft + Store), не трогая остальные серверы и сохраняя прежний файл как `.bak`. Конфиг, который + не разбирается как обычный JSON, не меняется, а другой сервер с тем же именем + заменяется только после отдельного вопроса. Править JSON руками больше не нужно. - `kwork-mcp login` на Windows печатает команды с кавычками для PowerShell и cmd, путь к конфигу Cursor (`%USERPROFILE%\.cursor\mcp.json`) и подсказку открыть конфиг Claude Desktop через Settings → Developer → Edit Config. Вывод в pipe или diff --git a/README.md b/README.md index 34ea9e1..ae02eab 100644 --- a/README.md +++ b/README.md @@ -56,10 +56,13 @@ uvx kwork-mcp@1.6.0 login ``` После подтверждения токен сохраняется в защищённое хранилище на вашем компьютере: -`~/.local/state/kwork-mcp`, на Windows `%USERPROFILE%\.local\state\kwork-mcp`. Логин и пароль -нигде не сохраняются. В конце команда -напечатает готовые команды подключения для Claude Code и Codex и блок для Claude -Desktop и Cursor. +`~/.local/state/kwork-mcp`, на Windows `%USERPROFILE%\.local\state\kwork-mcp`. Логин и +пароль нигде не сохраняются. + +Если на компьютере есть Claude Desktop или Cursor, команда спросит, подключить ли к ним +kwork-mcp, и сама допишет сервер в их конфиг. Остальные серверы в конфиге не меняются, +прежняя версия файла остаётся рядом как `.bak`. После этого перезапустите клиент. Для +Claude Code и Codex команда напечатает готовые команды подключения. ### 2. Подключите агента @@ -77,8 +80,10 @@ codex mcp add kwork -- uvx kwork-mcp@1.6.0 **Cursor:** [![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/install-mcp?name=kwork&config=eyJjb21tYW5kIjoidXZ4IiwiYXJncyI6WyJrd29yay1tY3BAMS42LjAiXX0%3D) +**Claude Desktop:** `login` подключает его сам, после этого перезапустите приложение. +
    -Claude Desktop и другие клиенты +Подключить вручную: Claude Desktop и другие клиенты Claude Desktop: Settings → Developer → Edit Config, файл `claude_desktop_config.json`. Cursor без кнопки: `~/.cursor/mcp.json` (на Windows `%USERPROFILE%\.cursor\mcp.json`) diff --git a/site/index.html b/site/index.html index 842b74b..e81fe13 100644 --- a/site/index.html +++ b/site/index.html @@ -270,7 +270,7 @@

    Войдите в Kwork (один раз)

    терминал
    uvx kwork-mcp@1.6.0 login
    -

    Команда скрыто спросит логин и пароль Kwork, покажет найденный аккаунт и после подтверждения сохранит токен в ~/.local/state/kwork-mcp (на Windows %USERPROFILE%\.local\state\kwork-mcp). В конце она напечатает готовые команды подключения, а для Claude Desktop — блок с полным путём к uvx.

    +

    Команда скрыто спросит логин и пароль Kwork, покажет найденный аккаунт и после подтверждения сохранит токен в ~/.local/state/kwork-mcp (на Windows %USERPROFILE%\.local\state\kwork-mcp). Если у вас есть Claude Desktop или Cursor, она предложит подключить kwork-mcp к ним сама, а для Claude Code и Codex напечатает готовые команды.

  • Подключите агента

    @@ -297,7 +297,8 @@

    Cursor

    Claude Desktop

    -

    Settings → Developer → Edit Config, файл claude_desktop_config.json.

    +

    login подключит Claude Desktop сам: ответьте «да» на его вопрос и перезапустите приложение. Остальные серверы в конфиге не меняются.

    +

    Вручную: Settings → Developer → Edit Config, файл claude_desktop_config.json.

    claude_desktop_config.json
    {
    diff --git a/src/kwork_mcp/bootstrap.py b/src/kwork_mcp/bootstrap.py
    index 4c39e91..7bfc18b 100644
    --- a/src/kwork_mcp/bootstrap.py
    +++ b/src/kwork_mcp/bootstrap.py
    @@ -12,7 +12,7 @@
     import subprocess
     import sys
     import warnings
    -from collections.abc import Awaitable, Callable, Sequence
    +from collections.abc import Awaitable, Callable, Collection, Sequence
     from dataclasses import dataclass
     from datetime import UTC, datetime
     from pathlib import Path
    @@ -21,7 +21,7 @@
     from kwork.schema.actor import Actor
     from pydantic import SecretStr, ValidationError, model_validator
     
    -from kwork_mcp import private_fs
    +from kwork_mcp import clients, private_fs
     from kwork_mcp.config import (
         AccountSelectionError,
         KworkConfig,
    @@ -64,8 +64,9 @@
     login спрашивает логин, пароль, последние 4 цифры телефона и прокси скрытым
     вводом в настоящем терминале; через аргументы и .env они не принимаются. Если
     KWORK_EXPECTED_USER_ID не задан, login покажет найденный аккаунт и попросит
    -подтвердить привязку; если задан, аккаунт обязан с ним совпасть. В конце login
    -печатает команды, которые подключают сервер к Claude Code и Codex.
    +подтвердить привязку; если задан, аккаунт обязан с ним совпасть. Затем login
    +предлагает подключить сервер к найденным Claude Desktop и Cursor (дописывает их
    +конфиг, прежний сохраняет как .bak) и печатает команды для Claude Code и Codex.
     
     Если вход выполнен для одного аккаунта, сервер и команды выше работают с ним
     сами. Если аккаунтов несколько, укажите нужный в KWORK_EXPECTED_USER_ID или
    @@ -75,8 +76,8 @@
     общие, поэтому повторный вход при смене сайта не нужен.
     
     Если существует legacy ~/.kwork_token, доступный только вам (на macOS и Linux:
    -owner=current user и mode 0600), login предложит проверить и импортировать его. Сервер legacy-файл никогда не
    -импортирует.
    +owner=current user и mode 0600), login предложит проверить и импортировать его.
    +Сервер legacy-файл никогда не импортирует.
     
     Отправка с неизвестным исходом (submission_unknown) блокирует новые отправки
     аккаунта, пока её не сверит reconcile_write. Если сверка не приходит к выводу,
    @@ -657,17 +658,29 @@ def _command_line(args: list[str]) -> str:
         return line
     
     
    -def _desktop_config_files() -> str:
    +def _client_config_locations() -> dict[str, str]:
         if sys.platform == "win32":
    -        # A packaged (MSIX) Claude Desktop keeps the file in its own AppData copy.
    -        files = r"claude_desktop_config.json (Settings → Developer → Edit Config) или %USERPROFILE%\.cursor\mcp.json"
    +        cursor = r"%USERPROFILE%\.cursor\mcp.json"
         else:
    -        files = "claude_desktop_config.json или ~/.cursor/mcp.json"
    -    return files
    +        cursor = "~/.cursor/mcp.json"
    +    # Settings → Developer → Edit Config finds the file, wherever this build keeps it.
    +    return {"Claude Desktop": "claude_desktop_config.json (Settings → Developer → Edit Config)", "Cursor": cursor}
    +
    +
    +def _restart_hint(client: str) -> str:
    +    if client == "Cursor":
    +        hint = "Перезапустите Cursor, если он открыт."
    +    elif sys.platform == "win32":
    +        hint = "Перезапустите Claude Desktop: закройте его через значок в трее и откройте снова."
    +    elif sys.platform == "darwin":
    +        hint = "Перезапустите Claude Desktop: Cmd+Q и откройте снова."
    +    else:
    +        hint = "Перезапустите Claude Desktop."
    +    return hint
     
     
    -def _connection_instructions(actor: Actor, config: KworkConfig, *, legacy_file_retained: bool) -> str:
    -    """What to run next, with every setting the server needs to find this account."""
    +def _server_environment(actor: Actor, config: KworkConfig) -> list[str]:
    +    """Settings a client must pass for the server to find this account."""
     
         try:
             several_accounts = len(bound_account_ids(config.state_dir)) > 1
    @@ -683,27 +696,102 @@ def _connection_instructions(actor: Actor, config: KworkConfig, *, legacy_file_r
         # the location it finds on its own is passed explicitly.
         if config.state_dir != client_default_state_dir():
             environment.append(f"KWORK_STATE_DIR={config.state_dir}")
    -    name = "kwork" if config.site == "ru" else f"kwork-{config.site}"
    +    return environment
    +
    +
    +def _server_name(config: KworkConfig) -> str:
    +    return "kwork" if config.site == "ru" else f"kwork-{config.site}"
    +
    +
    +def _client_entry(actor: Actor, config: KworkConfig) -> dict[str, Any]:
    +    # Claude Desktop and Cursor start servers without the shell PATH, so they
    +    # get the absolute uvx path of the terminal that ran login.
    +    entry: dict[str, Any] = {"command": shutil.which("uvx") or "uvx", "args": [f"kwork-mcp@{__version__}"]}
    +    environment = _server_environment(actor, config)
    +    if environment:
    +        entry["env"] = dict(item.split("=", 1) for item in environment)
    +    return entry
    +
    +
    +def _connect_clients(
    +    actor: Actor,
    +    config: KworkConfig,
    +    *,
    +    stdin: TextIO,
    +    stdout: TextIO,
    +    stderr: TextIO,
    +) -> set[str]:
    +    """Offer to add the server to each Claude Desktop or Cursor config found; return the clients set up."""
    +
    +    name = _server_name(config)
    +    entry = _client_entry(actor, config)
    +    connected: set[str] = set()
    +    for client in clients.find_clients():
    +        location = _terminal_safe(str(client.path))
    +        try:
    +            current = clients.existing_entry(client.path, name)
    +        except clients.ClientConfigError:
    +            stdout.write(
    +                f"{client.name}: конфиг {location} не разобран как JSON и не изменён, добавьте блок ниже вручную.\n"
    +            )
    +            continue
    +        if current == entry:
    +            connected.add(client.name)
    +            stdout.write(f"{client.name}: kwork-mcp уже подключён.\n")
    +            continue
    +        if current is not None and not clients.is_kwork_mcp_entry(current):
    +            question = f"В {client.name} уже есть другой сервер «{name}». Заменить его на kwork-mcp? [y/N]: "
    +        else:
    +            question = f"Подключить kwork-mcp к {client.name}? [y/N]: "
    +        if _visible_prompt(question, stdin=stdin, stderr=stderr).casefold() not in _CONFIRMATIONS:
    +            continue
    +        try:
    +            result = clients.register_server(client.path, name, entry)
    +        except (clients.ClientConfigError, OSError):
    +            stdout.write(f"{client.name}: не удалось записать {location}, добавьте блок ниже вручную.\n")
    +            continue
    +        connected.add(client.name)
    +        stdout.write(f"{client.name}: kwork-mcp подключён. {_restart_hint(client.name)}\n")
    +        if result.backup is not None:
    +            stdout.write(f"  Прежний конфиг сохранён в {_terminal_safe(str(result.backup))}\n")
    +    return connected
    +
    +
    +def _account_connected(actor: Actor) -> str:
    +    return f"Аккаунт {_terminal_safe(str(actor.username))} (user_id {actor.id}) подключён.\n"
    +
    +
    +def _connection_instructions(
    +    actor: Actor,
    +    config: KworkConfig,
    +    *,
    +    legacy_file_retained: bool,
    +    connected: Collection[str] = (),
    +) -> str:
    +    """What to run next, with every setting the server needs to find this account."""
    +
    +    environment = _server_environment(actor, config)
    +    name = _server_name(config)
         claude = ["claude", "mcp", "add", name, "--scope", "user"]
         codex = ["codex", "mcp", "add", name]
         for item in environment:
             claude += ["-e", item]
             codex += ["--env", item]
         server = ["--", "uvx", f"kwork-mcp@{__version__}"]
    -    # Claude Desktop starts servers without the shell PATH, so it gets the
    -    # absolute uvx path of the terminal that ran login.
    -    desktop: dict[str, Any] = {"command": shutil.which("uvx") or "uvx", "args": [f"kwork-mcp@{__version__}"]}
    -    if environment:
    -        desktop["env"] = dict(item.split("=", 1) for item in environment)
         lines = [
    -        f"Аккаунт {_terminal_safe(str(actor.username))} (user_id {actor.id}) подключён.",
             "",
    -        "Добавьте kwork-mcp в агента. Claude Code:",
    +        ("Другие агенты. Claude Code:" if connected else "Добавьте kwork-mcp в агента. Claude Code:"),
             "  " + _terminal_safe(_command_line(claude + server)),
             "Codex:",
             "  " + _terminal_safe(_command_line(codex + server)),
    -        f'Claude Desktop и Cursor: в "mcpServers" файла {_desktop_config_files()}',
    -        "  " + _terminal_safe(f'"{name}": ' + json.dumps(desktop, ensure_ascii=False)),
    +    ]
    +    locations = {client: where for client, where in _client_config_locations().items() if client not in connected}
    +    if locations:
    +        lines += [
    +            f'{" и ".join(locations)}: в "mcpServers" файла {" или ".join(locations.values())}',
    +            "  " + _terminal_safe(f'"{name}": ' + json.dumps(_client_entry(actor, config), ensure_ascii=False)),
    +        ]
    +    lines += [
             "",
             "Каждую отправку агент сначала покажет вам на подтверждение. Чтобы он отправлял сам, добавьте "
             "KWORK_WRITES=auto, для одного только чтения KWORK_WRITES=off.",
    @@ -1009,7 +1097,9 @@ async def run_bootstrap_cli(
                 token_store=store,
                 client_factory=client_factory,
             )
    -        stdout.write(_connection_instructions(actor, config, legacy_file_retained=used_legacy))
    +        stdout.write(_account_connected(actor))
    +        connected = _connect_clients(actor, config, stdin=stdin, stdout=stdout, stderr=stderr)
    +        stdout.write(_connection_instructions(actor, config, legacy_file_retained=used_legacy, connected=connected))
             return 0
         except (EOFError, KeyboardInterrupt, getpass.GetPassWarning):
             stderr.write("Вход отменён: сохранённый токен не изменён.\n")
    diff --git a/src/kwork_mcp/clients.py b/src/kwork_mcp/clients.py
    new file mode 100644
    index 0000000..14825e8
    --- /dev/null
    +++ b/src/kwork_mcp/clients.py
    @@ -0,0 +1,144 @@
    +"""Find Claude Desktop and Cursor configs and add the kwork-mcp server to them.
    +
    +`kwork-mcp login` offers this so nobody has to edit JSON by hand. A config is
    +touched only after the user agrees, only if it parses as plain JSON, and the
    +previous version is kept next to it as `.bak`.
    +"""
    +
    +from __future__ import annotations
    +
    +import json
    +import os
    +import shutil
    +import sys
    +import tempfile
    +from dataclasses import dataclass
    +from pathlib import Path
    +from typing import Any, Literal
    +
    +from kwork_mcp import private_fs
    +
    +
    +class ClientConfigError(Exception):
    +    """The client config cannot be read as plain JSON; it was not changed."""
    +
    +
    +@dataclass(frozen=True, slots=True)
    +class ClientConfig:
    +    name: str
    +    path: Path
    +
    +
    +@dataclass(frozen=True, slots=True)
    +class Registration:
    +    outcome: Literal["added", "updated", "unchanged"]
    +    backup: Path | None
    +
    +
    +def _claude_desktop_dir() -> Path:
    +    if sys.platform == "win32":
    +        # The Microsoft Store (MSIX) build sees its own copy of AppData; a
    +        # config in the real AppData would be ignored.
    +        local = os.environ.get("LOCALAPPDATA")
    +        packages = (Path(local) if local else Path.home() / "AppData" / "Local") / "Packages"
    +        for package in sorted(packages.glob("Claude_*")):
    +            packaged = package / "LocalCache" / "Roaming" / "Claude"
    +            if packaged.is_dir():
    +                return packaged
    +        roaming = os.environ.get("APPDATA")
    +        directory = (Path(roaming) if roaming else Path.home() / "AppData" / "Roaming") / "Claude"
    +    elif sys.platform == "darwin":
    +        directory = Path.home() / "Library" / "Application Support" / "Claude"
    +    else:
    +        # No official Linux build; community builds follow XDG.
    +        xdg = os.environ.get("XDG_CONFIG_HOME")
    +        directory = (Path(xdg) if xdg else Path.home() / ".config") / "Claude"
    +    return directory
    +
    +
    +def find_clients() -> list[ClientConfig]:
    +    """The clients on this computer that have been started at least once."""
    +
    +    found = []
    +    claude = _claude_desktop_dir()
    +    if claude.is_dir():
    +        found.append(ClientConfig("Claude Desktop", claude / "claude_desktop_config.json"))
    +    cursor = Path.home() / ".cursor"
    +    if cursor.is_dir():
    +        found.append(ClientConfig("Cursor", cursor / "mcp.json"))
    +    return found
    +
    +
    +def is_kwork_mcp_entry(entry: object) -> bool:
    +    """Whether a config entry starts kwork-mcp, in any version or from source."""
    +
    +    if not isinstance(entry, dict):
    +        return False
    +    words = [entry.get("command"), *(entry.get("args") or [])]
    +    return any(
    +        isinstance(word, str) and (word == "kwork-mcp" or word.startswith(("kwork-mcp@", "kwork-mcp==")))
    +        for word in words
    +    )
    +
    +
    +def _read(path: Path) -> tuple[dict[str, Any], dict[str, Any]] | None:
    +    """The config and its mcpServers, or None when the file does not exist."""
    +
    +    try:
    +        text = path.read_text(encoding="utf-8-sig")
    +    except FileNotFoundError:
    +        return None
    +    except (OSError, UnicodeError) as exc:
    +        raise ClientConfigError(type(exc).__name__) from exc
    +    try:
    +        config = json.loads(text) if text.strip() else {}
    +    except json.JSONDecodeError as exc:
    +        raise ClientConfigError("invalid_json") from exc
    +    if not isinstance(config, dict):
    +        raise ClientConfigError("not_an_object")
    +    servers = config.setdefault("mcpServers", {})
    +    if not isinstance(servers, dict):
    +        raise ClientConfigError("servers_not_an_object")
    +    return config, servers
    +
    +
    +def existing_entry(path: Path, name: str) -> object:
    +    """The entry `name` in the config, or None; raises ClientConfigError like `register_server`."""
    +
    +    read = _read(path)
    +    return None if read is None else read[1].get(name)
    +
    +
    +def register_server(path: Path, name: str, entry: dict[str, Any]) -> Registration:
    +    """Set mcpServers[name] to `entry`, keeping everything else in the config."""
    +
    +    read = _read(path)
    +    if read is None:
    +        servers: dict[str, Any] = {}
    +        config: dict[str, Any] = {"mcpServers": servers}
    +    else:
    +        config, servers = read
    +    previous = servers.get(name)
    +    if previous == entry:
    +        return Registration("unchanged", None)
    +    servers[name] = entry
    +    payload = (json.dumps(config, ensure_ascii=False, indent=2) + "\n").encode()
    +
    +    backup = None
    +    if read is not None:
    +        backup = path.with_name(path.name + ".bak")
    +        # copy2 keeps the mode: the config may hold other servers' secrets.
    +        shutil.copy2(path, backup)
    +    fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
    +    try:
    +        with os.fdopen(fd, "wb") as handle:
    +            handle.write(payload)
    +            handle.flush()
    +            os.fsync(handle.fileno())
    +        if read is not None:
    +            shutil.copymode(path, temp_name)
    +        private_fs.replace(temp_name, path)
    +    except BaseException:
    +        Path(temp_name).unlink(missing_ok=True)
    +        raise
    +    return Registration("updated" if previous is not None else "added", backup)
    diff --git a/tests/conftest.py b/tests/conftest.py
    index 4a4748e..2a462ae 100644
    --- a/tests/conftest.py
    +++ b/tests/conftest.py
    @@ -50,3 +50,10 @@ def coordinator(config_factory: Callable[..., KworkConfig]) -> CoordinationStore
     
     def actor(user_id: int = 42, username: str = "fixture-user") -> Actor:
         return Actor(id=user_id, username=username)
    +
    +
    +@pytest.fixture(autouse=True)
    +def _no_real_client_configs(monkeypatch: pytest.MonkeyPatch) -> None:
    +    # login offers to edit Claude Desktop and Cursor configs; no test may find
    +    # the real ones on the machine that runs the suite.
    +    monkeypatch.setattr("kwork_mcp.clients.find_clients", lambda: [])
    diff --git a/tests/test_client_setup.py b/tests/test_client_setup.py
    new file mode 100644
    index 0000000..3a340d7
    --- /dev/null
    +++ b/tests/test_client_setup.py
    @@ -0,0 +1,105 @@
    +"""`kwork-mcp login` connects Claude Desktop and Cursor itself when the user agrees."""
    +
    +from __future__ import annotations
    +
    +import asyncio
    +import json
    +from pathlib import Path
    +from typing import Any
    +
    +import pytest
    +
    +from kwork_mcp import clients
    +from kwork_mcp.config import KworkConfig
    +from kwork_mcp.version import __version__
    +from tests.test_bootstrap_discovery import AuthClient, _environment, _run
    +
    +
    +@pytest.fixture
    +def claude_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
    +    directory = tmp_path / "Claude"
    +    directory.mkdir()
    +    path = directory / "claude_desktop_config.json"
    +    monkeypatch.setattr(clients, "find_clients", lambda: [clients.ClientConfig("Claude Desktop", path)])
    +    monkeypatch.setattr("kwork_mcp.bootstrap.shutil.which", lambda name: f"/opt/tools/bin/{name}")
    +    _environment(monkeypatch, tmp_path / "state")
    +    return path
    +
    +
    +def _expected_entry(tmp_path: Path) -> dict[str, Any]:
    +    return {
    +        "command": "/opt/tools/bin/uvx",
    +        "args": [f"kwork-mcp@{__version__}"],
    +        "env": {"KWORK_STATE_DIR": str(tmp_path / "state")},
    +    }
    +
    +
    +def _login(tmp_path: Path, answers: str) -> tuple[int, str, str]:
    +    auth_clients: list[AuthClient] = []
    +    configs: list[KworkConfig] = []
    +    return asyncio.run(_run(tmp_path, confirmation=answers, clients=auth_clients, configs=configs))
    +
    +
    +def test_login_adds_the_server_to_claude_desktop_after_a_yes(tmp_path: Path, claude_config: Path) -> None:
    +    code, stdout, stderr = _login(tmp_path, "да\nда\n")
    +
    +    assert code == 0
    +    assert "Подключить kwork-mcp к Claude Desktop? [y/N]" in stderr
    +    assert json.loads(claude_config.read_text(encoding="utf-8")) == {"mcpServers": {"kwork": _expected_entry(tmp_path)}}
    +    assert "Claude Desktop: kwork-mcp подключён." in stdout
    +    assert "Другие агенты. Claude Code:" in stdout
    +    # The manual block remains only for the client that was not set up.
    +    assert "Claude Desktop и Cursor" not in stdout
    +    assert 'Cursor: в "mcpServers" файла' in stdout
    +
    +
    +def test_login_keeps_other_servers_and_backs_the_config_up(tmp_path: Path, claude_config: Path) -> None:
    +    claude_config.write_text(json.dumps({"mcpServers": {"files": {"command": "npx", "args": ["fs"]}}}))
    +
    +    code, stdout, _stderr = _login(tmp_path, "да\nда\n")
    +
    +    assert code == 0
    +    servers = json.loads(claude_config.read_text(encoding="utf-8"))["mcpServers"]
    +    assert servers == {"files": {"command": "npx", "args": ["fs"]}, "kwork": _expected_entry(tmp_path)}
    +    assert str(claude_config.with_name("claude_desktop_config.json.bak")) in stdout
    +
    +
    +def test_login_leaves_the_config_alone_after_a_no(tmp_path: Path, claude_config: Path) -> None:
    +    code, stdout, _stderr = _login(tmp_path, "да\nнет\n")
    +
    +    assert code == 0
    +    assert not claude_config.exists()
    +    assert 'Claude Desktop и Cursor: в "mcpServers" файла' in stdout
    +
    +
    +def test_login_asks_before_replacing_another_server_named_kwork(tmp_path: Path, claude_config: Path) -> None:
    +    other = {"mcpServers": {"kwork": {"command": "node", "args": ["/opt/other-kwork/index.js"]}}}
    +    claude_config.write_text(json.dumps(other))
    +
    +    code, _stdout, stderr = _login(tmp_path, "да\nнет\n")
    +
    +    assert code == 0
    +    assert "уже есть другой сервер «kwork»" in stderr
    +    assert json.loads(claude_config.read_text(encoding="utf-8")) == other
    +
    +
    +def test_login_never_edits_a_config_that_is_not_plain_json(tmp_path: Path, claude_config: Path) -> None:
    +    claude_config.write_text('{"mcpServers": {},}')
    +
    +    code, stdout, stderr = _login(tmp_path, "да\n")
    +
    +    assert code == 0
    +    assert "Подключить" not in stderr
    +    assert "не изменён" in stdout
    +    assert claude_config.read_text() == '{"mcpServers": {},}'
    +    assert 'Claude Desktop и Cursor: в "mcpServers" файла' in stdout
    +
    +
    +def test_login_does_not_ask_again_when_the_server_is_already_there(tmp_path: Path, claude_config: Path) -> None:
    +    claude_config.write_text(json.dumps({"mcpServers": {"kwork": _expected_entry(tmp_path)}}))
    +
    +    code, stdout, stderr = _login(tmp_path, "да\n")
    +
    +    assert code == 0
    +    assert "Подключить" not in stderr
    +    assert "Claude Desktop: kwork-mcp уже подключён." in stdout
    diff --git a/tests/test_clients.py b/tests/test_clients.py
    new file mode 100644
    index 0000000..42735cf
    --- /dev/null
    +++ b/tests/test_clients.py
    @@ -0,0 +1,190 @@
    +"""Finding Claude Desktop and Cursor configs and adding kwork-mcp to them safely."""
    +
    +from __future__ import annotations
    +
    +import json
    +import os
    +import stat
    +import sys
    +from pathlib import Path
    +
    +import pytest
    +
    +from kwork_mcp import clients
    +from kwork_mcp.clients import ClientConfigError, find_clients, is_kwork_mcp_entry, register_server
    +from tests.platforms import posix_only
    +
    +ENTRY = {"command": "/opt/tools/bin/uvx", "args": ["kwork-mcp@1.6.0"]}
    +
    +# --- where the configs live
    +
    +
    +@pytest.fixture
    +def home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
    +    path = tmp_path / "home"
    +    path.mkdir()
    +    monkeypatch.setattr(Path, "home", classmethod(lambda cls: path))
    +    for name in ("APPDATA", "LOCALAPPDATA", "XDG_CONFIG_HOME"):
    +        monkeypatch.delenv(name, raising=False)
    +    return path
    +
    +
    +def _names(found: list[clients.ClientConfig]) -> dict[str, Path]:
    +    return {client.name: client.path for client in found}
    +
    +
    +def test_nothing_is_offered_when_no_client_was_ever_started(home: Path) -> None:
    +    assert find_clients() == []
    +
    +
    +def test_macos_claude_desktop_and_cursor(home: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    +    monkeypatch.setattr(sys, "platform", "darwin")
    +    (home / "Library" / "Application Support" / "Claude").mkdir(parents=True)
    +    (home / ".cursor").mkdir()
    +
    +    assert _names(find_clients()) == {
    +        "Claude Desktop": home / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json",
    +        "Cursor": home / ".cursor" / "mcp.json",
    +    }
    +
    +
    +def test_windows_claude_desktop_in_roaming_app_data(home: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    +    monkeypatch.setattr(sys, "platform", "win32")
    +    monkeypatch.setenv("APPDATA", str(home / "AppData" / "Roaming"))
    +    (home / "AppData" / "Roaming" / "Claude").mkdir(parents=True)
    +
    +    assert _names(find_clients()) == {
    +        "Claude Desktop": home / "AppData" / "Roaming" / "Claude" / "claude_desktop_config.json",
    +    }
    +
    +
    +def test_windows_store_claude_desktop_keeps_its_own_config(home: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    +    # The Microsoft Store (MSIX) build sees a private copy of AppData, so a
    +    # config written to the real one would be ignored.
    +    monkeypatch.setattr(sys, "platform", "win32")
    +    monkeypatch.setenv("APPDATA", str(home / "AppData" / "Roaming"))
    +    monkeypatch.setenv("LOCALAPPDATA", str(home / "AppData" / "Local"))
    +    (home / "AppData" / "Roaming" / "Claude").mkdir(parents=True)
    +    packaged = home / "AppData" / "Local" / "Packages" / "Claude_pzs8sxrjxfjjc" / "LocalCache" / "Roaming" / "Claude"
    +    packaged.mkdir(parents=True)
    +
    +    assert _names(find_clients()) == {"Claude Desktop": packaged / "claude_desktop_config.json"}
    +
    +
    +def test_linux_community_claude_desktop_follows_xdg(home: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    +    monkeypatch.setattr(sys, "platform", "linux")
    +    monkeypatch.setenv("XDG_CONFIG_HOME", str(home / "config"))
    +    (home / "config" / "Claude").mkdir(parents=True)
    +    (home / ".cursor").mkdir()
    +
    +    assert _names(find_clients()) == {
    +        "Claude Desktop": home / "config" / "Claude" / "claude_desktop_config.json",
    +        "Cursor": home / ".cursor" / "mcp.json",
    +    }
    +
    +
    +# --- writing the entry
    +
    +
    +def test_a_missing_config_is_created_with_the_server(tmp_path: Path) -> None:
    +    path = tmp_path / "claude_desktop_config.json"
    +
    +    result = register_server(path, "kwork", ENTRY)
    +
    +    assert result.outcome == "added"
    +    assert result.backup is None
    +    assert json.loads(path.read_text(encoding="utf-8")) == {"mcpServers": {"kwork": ENTRY}}
    +
    +
    +def test_other_servers_and_settings_are_kept_and_the_old_file_is_backed_up(tmp_path: Path) -> None:
    +    path = tmp_path / "claude_desktop_config.json"
    +    original = {"globalShortcut": "Ctrl+Space", "mcpServers": {"files": {"command": "npx", "args": ["fs"]}}}
    +    path.write_text(json.dumps(original), encoding="utf-8")
    +
    +    result = register_server(path, "kwork", ENTRY)
    +
    +    assert result.outcome == "added"
    +    assert json.loads(path.read_text(encoding="utf-8")) == {
    +        "globalShortcut": "Ctrl+Space",
    +        "mcpServers": {"files": {"command": "npx", "args": ["fs"]}, "kwork": ENTRY},
    +    }
    +    assert result.backup == tmp_path / "claude_desktop_config.json.bak"
    +    assert json.loads(result.backup.read_text(encoding="utf-8")) == original
    +
    +
    +def test_an_older_kwork_mcp_entry_is_updated(tmp_path: Path) -> None:
    +    path = tmp_path / "mcp.json"
    +    path.write_text(json.dumps({"mcpServers": {"kwork": {"command": "uvx", "args": ["kwork-mcp@1.5.2"]}}}))
    +
    +    assert register_server(path, "kwork", ENTRY).outcome == "updated"
    +    assert json.loads(path.read_text(encoding="utf-8"))["mcpServers"]["kwork"] == ENTRY
    +
    +
    +def test_the_same_entry_is_left_untouched(tmp_path: Path) -> None:
    +    path = tmp_path / "mcp.json"
    +    path.write_text(json.dumps({"mcpServers": {"kwork": ENTRY}}))
    +    before = path.read_bytes()
    +
    +    result = register_server(path, "kwork", ENTRY)
    +
    +    assert result.outcome == "unchanged"
    +    assert path.read_bytes() == before
    +    assert not (tmp_path / "mcp.json.bak").exists()
    +
    +
    +def test_a_byte_order_mark_from_notepad_is_accepted(tmp_path: Path) -> None:
    +    path = tmp_path / "claude_desktop_config.json"
    +    path.write_bytes(b"\xef\xbb\xbf" + json.dumps({"mcpServers": {}}).encode())
    +
    +    assert register_server(path, "kwork", ENTRY).outcome == "added"
    +    assert not path.read_bytes().startswith(b"\xef\xbb\xbf")
    +
    +
    +@pytest.mark.parametrize(
    +    "content",
    +    [
    +        '{"mcpServers": {"files": {}},}',
    +        "// my servers\n{}",
    +        "[]",
    +        '{"mcpServers": []}',
    +        b"\xff\xfe".decode("latin-1"),
    +    ],
    +    ids=["trailing-comma", "comment", "array", "servers-not-object", "not-utf8"],
    +)
    +def test_a_config_that_is_not_plain_json_is_never_touched(tmp_path: Path, content: str) -> None:
    +    path = tmp_path / "claude_desktop_config.json"
    +    path.write_bytes(content.encode("latin-1"))
    +    before = path.read_bytes()
    +
    +    with pytest.raises(ClientConfigError):
    +        register_server(path, "kwork", ENTRY)
    +
    +    assert path.read_bytes() == before
    +    assert sorted(item.name for item in tmp_path.iterdir()) == ["claude_desktop_config.json"]
    +
    +
    +@posix_only
    +def test_the_config_keeps_its_permissions(tmp_path: Path) -> None:
    +    path = tmp_path / "mcp.json"
    +    path.write_text("{}")
    +    os.chmod(path, 0o600)
    +
    +    register_server(path, "kwork", ENTRY)
    +
    +    assert stat.S_IMODE(path.stat().st_mode) == 0o600
    +    assert stat.S_IMODE((tmp_path / "mcp.json.bak").stat().st_mode) == 0o600
    +
    +
    +@pytest.mark.parametrize(
    +    ("entry", "ours"),
    +    [
    +        (ENTRY, True),
    +        ({"command": "uvx", "args": ["kwork-mcp@1.5.2"]}, True),
    +        ({"command": "uv", "args": ["--directory", "/src/kwork-mcp", "run", "kwork-mcp"]}, True),
    +        ({"command": "node", "args": ["/opt/other-kwork/index.js"]}, False),
    +        ({"command": "uvx", "args": ["kwork-mcp-fork"]}, False),
    +        ("not an object", False),
    +    ],
    +)
    +def test_only_kwork_mcp_entries_count_as_ours(entry: object, ours: bool) -> None:
    +    assert is_kwork_mcp_entry(entry) is ours
    diff --git a/tests/test_short_commands.py b/tests/test_short_commands.py
    index 8fe956a..1b0bc1b 100644
    --- a/tests/test_short_commands.py
    +++ b/tests/test_short_commands.py
    @@ -14,7 +14,7 @@
     from kwork.schema.actor import Actor
     
     import kwork_mcp
    -from kwork_mcp.bootstrap import _connection_instructions, run_bootstrap_cli
    +from kwork_mcp.bootstrap import _account_connected, _connection_instructions, run_bootstrap_cli
     from kwork_mcp.config import (
         AccountSelectionError,
         KworkConfig,
    @@ -356,7 +356,7 @@ def test_login_prints_the_shortest_commands_for_the_default_setup(
     
         lines = _instructions().splitlines()
     
    -    assert lines[0] == "Аккаунт found-user (user_id 42) подключён."
    +    assert _account_connected(Actor(id=42, username="found-user")) == "Аккаунт found-user (user_id 42) подключён.\n"
         assert f"  claude mcp add kwork --scope user -- uvx kwork-mcp@{__version__}" in lines
         assert f"  codex mcp add kwork -- uvx kwork-mcp@{__version__}" in lines
         assert any("https://simonether.github.io/kwork-mcp/" in line for line in lines)
    
    From b3e402abf0c38863dde824e2a9248c855a600f13 Mon Sep 17 00:00:00 2001
    From: Simon <68971770+simonether@users.noreply.github.com>
    Date: Sun, 4 Oct 2026 22:17:13 +0500
    Subject: [PATCH 3/4] Prepare 1.6.0rc1 for testing on Windows
    
    ---
     README.md                      | 30 +++++++++++++++---------------
     scripts/smoke-wheel.sh         |  8 ++++----
     server.json                    |  4 ++--
     site/index.html                | 22 +++++++++++-----------
     src/kwork_mcp/version.py       |  2 +-
     tests/test_auth_bootstrap.py   |  2 +-
     tests/test_mcp_protocol.py     |  2 +-
     tests/test_release_metadata.py |  2 +-
     8 files changed, 36 insertions(+), 36 deletions(-)
    
    diff --git a/README.md b/README.md
    index ae02eab..7dc6923 100644
    --- a/README.md
    +++ b/README.md
    @@ -45,7 +45,7 @@ powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | ie
     В обычном терминале (на Windows в PowerShell или Windows Terminal):
     
     ```bash
    -uvx kwork-mcp@1.6.0 login
    +uvx kwork-mcp@1.6.0rc1 login
     ```
     
     Команда скрыто спросит логин и пароль Kwork, а также, по желанию, последние 4 цифры
    @@ -69,16 +69,16 @@ Claude Code и Codex команда напечатает готовые кома
     **Claude Code:**
     
     ```bash
    -claude mcp add kwork --scope user -- uvx kwork-mcp@1.6.0
    +claude mcp add kwork --scope user -- uvx kwork-mcp@1.6.0rc1
     ```
     
     **Codex:**
     
     ```bash
    -codex mcp add kwork -- uvx kwork-mcp@1.6.0
    +codex mcp add kwork -- uvx kwork-mcp@1.6.0rc1
     ```
     
    -**Cursor:** [![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/install-mcp?name=kwork&config=eyJjb21tYW5kIjoidXZ4IiwiYXJncyI6WyJrd29yay1tY3BAMS42LjAiXX0%3D)
    +**Cursor:** [![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/install-mcp?name=kwork&config=eyJjb21tYW5kIjoidXZ4IiwiYXJncyI6WyJrd29yay1tY3BAMS42LjByYzEiXX0%3D)
     
     **Claude Desktop:** `login` подключает его сам, после этого перезапустите приложение.
     
    @@ -94,7 +94,7 @@ Cursor без кнопки: `~/.cursor/mcp.json` (на Windows `%USERPROFILE%\.c
       "mcpServers": {
         "kwork": {
           "command": "uvx",
    -      "args": ["kwork-mcp@1.6.0"]
    +      "args": ["kwork-mcp@1.6.0rc1"]
         }
       }
     }
    @@ -116,9 +116,9 @@ JSON.
     Перезапустите клиент и попросите агента: «проверь статус аккаунта Kwork». Он
     вызовет `account_status` и покажет ваш `user_id` и имя.
     
    -Без клиента то же видно в терминале: `uvx kwork-mcp@1.6.0 status` покажет, с каким
    +Без клиента то же видно в терминале: `uvx kwork-mcp@1.6.0rc1 status` покажет, с каким
     аккаунтом и сайтом запустится сервер, не обращаясь к Kwork. Сменить аккаунт:
    -`uvx kwork-mcp@1.6.0 logout`, затем снова `login`.
    +`uvx kwork-mcp@1.6.0rc1 logout`, затем снова `login`.
     
     ## Отправка откликов и сообщений
     
    @@ -142,7 +142,7 @@ Codex то же включает `default_tools_approval_mode = "writes"` в н
     
     ```bash
     claude mcp remove kwork --scope user
    -claude mcp add kwork --scope user -e KWORK_WRITES=auto -- uvx kwork-mcp@1.6.0
    +claude mcp add kwork --scope user -e KWORK_WRITES=auto -- uvx kwork-mcp@1.6.0rc1
     ```
     
     В режиме `auto` от чужих команд в текстах проектов и сообщений защищает только
    @@ -162,7 +162,7 @@ claude mcp add kwork --scope user -e KWORK_WRITES=auto -- uvx kwork-mcp@1.6.0
     `KWORK_SITE=com`. Например, для Claude Code вторым сервером рядом с kwork.ru:
     
     ```bash
    -claude mcp add kwork-com --scope user -e KWORK_SITE=com -- uvx kwork-mcp@1.6.0
    +claude mcp add kwork-com --scope user -e KWORK_SITE=com -- uvx kwork-mcp@1.6.0rc1
     ```
     
     Аккаунт и токен у kwork.ru и kwork.com общие, поэтому заново входить не нужно.
    @@ -175,9 +175,9 @@ claude mcp add kwork-com --scope user -e KWORK_SITE=com -- uvx kwork-mcp@1.6.0
     
     | Что видите | Что делать |
     |---|---|
    -| `auth_required` или `auth_expired` | Входа нет или он истёк: выполните `uvx kwork-mcp@1.6.0 login` и перезапустите клиент |
    -| `account_binding_required` | Вы входили в несколько аккаунтов: укажите нужный в `KWORK_EXPECTED_USER_ID` или удалите лишний вход командой `uvx kwork-mcp@1.6.0 logout ` |
    -| Непонятно, какой аккаунт и сайт использует сервер | `uvx kwork-mcp@1.6.0 status` покажет это без запросов к Kwork |
    +| `auth_required` или `auth_expired` | Входа нет или он истёк: выполните `uvx kwork-mcp@1.6.0rc1 login` и перезапустите клиент |
    +| `account_binding_required` | Вы входили в несколько аккаунтов: укажите нужный в `KWORK_EXPECTED_USER_ID` или удалите лишний вход командой `uvx kwork-mcp@1.6.0rc1 logout ` |
    +| Непонятно, какой аккаунт и сайт использует сервер | `uvx kwork-mcp@1.6.0rc1 status` покажет это без запросов к Kwork |
     | Сервер не стартует, «некорректная конфигурация: …» | Проверьте названные переменные `KWORK_*` в конфиге клиента |
     | «KWORK_ENABLE_WRITES удалена в 1.5.0» | Замените её на `KWORK_WRITES=off`, `confirm` или `auto` |
     | `write_disabled` | Сервер запущен с `KWORK_WRITES=off`, доступно только чтение |
    @@ -189,9 +189,9 @@ claude mcp add kwork-com --scope user -e KWORK_SITE=com -- uvx kwork-mcp@1.6.0
     Ручная фиксация исхода запускается с теми же `KWORK_*` переменными, что у сервера:
     
     ```bash
    -uvx kwork-mcp@1.6.0 pending-writes
    -uvx kwork-mcp@1.6.0 resolve-write  succeeded   # операция на Kwork прошла
    -uvx kwork-mcp@1.6.0 resolve-write  absent      # операции на Kwork нет
    +uvx kwork-mcp@1.6.0rc1 pending-writes
    +uvx kwork-mcp@1.6.0rc1 resolve-write  succeeded   # операция на Kwork прошла
    +uvx kwork-mcp@1.6.0rc1 resolve-write  absent      # операции на Kwork нет
     ```
     
     ## Инструменты
    diff --git a/scripts/smoke-wheel.sh b/scripts/smoke-wheel.sh
    index f84bbf1..0e6bfc6 100644
    --- a/scripts/smoke-wheel.sh
    +++ b/scripts/smoke-wheel.sh
    @@ -30,7 +30,7 @@ import kwork_mcp
     from kwork_mcp.bootstrap import run_bootstrap_cli
     from kwork_mcp.server import create_server
     
    -assert kwork_mcp.__version__ == "1.6.0"
    +assert kwork_mcp.__version__ == "1.6.0rc1"
     assert callable(run_bootstrap_cli)
     create_server()
     scripts = {
    @@ -46,9 +46,9 @@ PY
     
     "${runner[@]}" kwork-mcp-bootstrap --help >/dev/null
     # Python on Windows ends printed lines with CRLF; the comparisons drop the CR.
    -[[ "$("${runner[@]}" kwork-mcp-bootstrap --version | tr -d '\r')" == "1.6.0" ]]
    +[[ "$("${runner[@]}" kwork-mcp-bootstrap --version | tr -d '\r')" == "1.6.0rc1" ]]
     "${runner[@]}" kwork-mcp --help >/dev/null
    -[[ "$("${runner[@]}" kwork-mcp --version | tr -d '\r')" == "1.6.0" ]]
    +[[ "$("${runner[@]}" kwork-mcp --version | tr -d '\r')" == "1.6.0rc1" ]]
     
     set +e
     "${runner[@]}" kwork-mcp login login.stdout 2>login.stderr
    @@ -63,7 +63,7 @@ KWORK_STATE_DIR="$state_dir" "${runner[@]}" kwork-mcp status >status.stdout 2>st
     status_status="$?"
     set -e
     [[ "$status_status" -eq 2 ]]
    -grep -q "kwork-mcp 1.6.0" status.stdout
    +grep -q "kwork-mcp 1.6.0rc1" status.stdout
     [[ ! -e "${smoke_dir}/state" ]]
     
     # Directory inspections start the bare server with no account: it must list the
    diff --git a/server.json b/server.json
    index 5f73cd6..910fa44 100644
    --- a/server.json
    +++ b/server.json
    @@ -3,7 +3,7 @@
       "name": "io.github.simonether/kwork-mcp",
       "title": "Kwork",
       "description": "Account-bound, typed and durable stdio MCP gateway for the Kwork freelance marketplace",
    -  "version": "1.6.0",
    +  "version": "1.6.0rc1",
       "websiteUrl": "https://github.com/simonether/kwork-mcp",
       "repository": {
         "url": "https://github.com/simonether/kwork-mcp",
    @@ -13,7 +13,7 @@
         {
           "registryType": "pypi",
           "identifier": "kwork-mcp",
    -      "version": "1.6.0",
    +      "version": "1.6.0rc1",
           "runtimeHint": "uvx",
           "transport": {
             "type": "stdio"
    diff --git a/site/index.html b/site/index.html
    index e81fe13..b23cdaa 100644
    --- a/site/index.html
    +++ b/site/index.html
    @@ -57,7 +57,7 @@
           "image": "https://simonether.github.io/kwork-mcp/og.png",
           "applicationCategory": "DeveloperApplication",
           "operatingSystem": "macOS, Linux, Windows",
    -      "softwareVersion": "1.6.0",
    +      "softwareVersion": "1.6.0rc1",
           "softwareRequirements": "Python 3.12–3.14, uv",
           "programmingLanguage": "Python",
           "inLanguage": "ru",
    @@ -84,7 +84,7 @@
             {"@type": "Question", "name": "Может ли агент откликнуться на проект без меня?", "acceptedAnswer": {"@type": "Answer", "text": "Только если вы сами это разрешили. По умолчанию агент показывает точный текст, цену и получателя и отправляет только после вашего «да». С KWORK_WRITES=auto он отправляет сам, с KWORK_WRITES=off только читает."}},
             {"@type": "Question", "name": "Что будет, если связь оборвётся при отправке?", "acceptedAnswer": {"@type": "Answer", "text": "Сервер не повторит запись сам и не угадает исход. Новые отправки для аккаунта ждут, пока агент не сверит результат с Kwork или вы не разберёте запись вручную командой kwork-mcp resolve-write."}},
             {"@type": "Question", "name": "Где хранятся логин и пароль?", "acceptedAnswer": {"@type": "Answer", "text": "Нигде. Их спрашивает только kwork-mcp login через скрытый ввод, а на диск сохраняется токен: на macOS и Linux в ~/.local/state/kwork-mcp с правами 0600, на Windows в %USERPROFILE%\\.local\\state\\kwork-mcp, зашифрованный DPAPI. На macOS и Linux токен не шифруется, поэтому включите шифрование диска."}},
    -        {"@type": "Question", "name": "Как сменить аккаунт?", "acceptedAnswer": {"@type": "Answer", "text": "Выполните в терминале uvx kwork-mcp@1.6.0 logout, а затем login с другим аккаунтом. Команда status покажет, с каким аккаунтом и сайтом запустится сервер, без запросов к Kwork."}},
    +        {"@type": "Question", "name": "Как сменить аккаунт?", "acceptedAnswer": {"@type": "Answer", "text": "Выполните в терминале uvx kwork-mcp@1.6.0rc1 logout, а затем login с другим аккаунтом. Команда status покажет, с каким аккаунтом и сайтом запустится сервер, без запросов к Kwork."}},
             {"@type": "Question", "name": "Какие клиенты поддерживаются?", "acceptedAnswer": {"@type": "Answer", "text": "Claude Code, Claude Desktop, Codex, Cursor и другие MCP-клиенты, которые умеют запускать локальный сервер через stdio."}},
             {"@type": "Question", "name": "Работает ли на Windows?", "acceptedAnswer": {"@type": "Answer", "text": "Да, на Windows 10 и 11 без WSL. uv ставится одной командой в PowerShell, дальше всё так же, как на macOS и Linux."}},
             {"@type": "Question", "name": "Сколько стоит?", "acceptedAnswer": {"@type": "Answer", "text": "Нисколько: kwork-mcp распространяется бесплатно под лицензией MIT."}}
    @@ -136,10 +136,10 @@ 

    MCP-сервер для Kwork

    Установить
    - uvx kwork-mcp@1.6.0 login + uvx kwork-mcp@1.6.0rc1 login
    -

    Бесплатно и с открытым кодом (MIT). macOS, Linux и Windows, Python 3.12–3.14. Версия 1.6.0 на PyPI.

    +

    Бесплатно и с открытым кодом (MIT). macOS, Linux и Windows, Python 3.12–3.14. Версия 1.6.0rc1 на PyPI.

    @@ -268,7 +268,7 @@

    Войдите в Kwork (один раз)

    Запустите в обычном терминале (на Windows в PowerShell):

    терминал
    -
    uvx kwork-mcp@1.6.0 login
    +
    uvx kwork-mcp@1.6.0rc1 login

    Команда скрыто спросит логин и пароль Kwork, покажет найденный аккаунт и после подтверждения сохранит токен в ~/.local/state/kwork-mcp (на Windows %USERPROFILE%\.local\state\kwork-mcp). Если у вас есть Claude Desktop или Cursor, она предложит подключить kwork-mcp к ним сама, а для Claude Code и Codex напечатает готовые команды.

  • @@ -280,19 +280,19 @@

    Подключите агента

    Claude Code

    терминал
    -
    claude mcp add kwork --scope user -- uvx kwork-mcp@1.6.0
    +
    claude mcp add kwork --scope user -- uvx kwork-mcp@1.6.0rc1

    Codex

    терминал
    -
    codex mcp add kwork -- uvx kwork-mcp@1.6.0
    +
    codex mcp add kwork -- uvx kwork-mcp@1.6.0rc1

    Cursor

    -

    Добавить в Cursor

    +

    Добавить в Cursor

    Кнопка откроет Cursor и предложит добавить сервер. Вручную: блок из вкладки «Claude Desktop» в ~/.cursor/mcp.json (на Windows %USERPROFILE%\.cursor\mcp.json).

    @@ -305,7 +305,7 @@

    Claude Desktop

    "mcpServers": { "kwork": { "command": "uvx", - "args": ["kwork-mcp@1.6.0"] + "args": ["kwork-mcp@1.6.0rc1"] } } }
    @@ -317,7 +317,7 @@

    Claude Desktop

  • Проверьте

    -

    Перезапустите клиент и попросите агента: проверь статус аккаунта Kwork. Он покажет ваш user_id и имя. Без клиента то же видно в терминале: uvx kwork-mcp@1.6.0 status.

    +

    Перезапустите клиент и попросите агента: проверь статус аккаунта Kwork. Он покажет ваш user_id и имя. Без клиента то же видно в терминале: uvx kwork-mcp@1.6.0rc1 status.

    Каждую отправку агент сначала покажет вам на подтверждение. Чтобы он отправлял сам, добавьте в конфиг KWORK_WRITES=auto, для одного только чтения — KWORK_WRITES=off (в Claude Code: -e KWORK_WRITES=off). Подробности, решение проблем и ручная сверка отправок описаны в README.

  • @@ -417,7 +417,7 @@

    Вопросы

    Как сменить аккаунт? -

    Выполните в терминале uvx kwork-mcp@1.6.0 logout, а затем login с другим аккаунтом. Команда status покажет, с каким аккаунтом и сайтом запустится сервер, без запросов к Kwork.

    +

    Выполните в терминале uvx kwork-mcp@1.6.0rc1 logout, а затем login с другим аккаунтом. Команда status покажет, с каким аккаунтом и сайтом запустится сервер, без запросов к Kwork.

    Какие клиенты поддерживаются? diff --git a/src/kwork_mcp/version.py b/src/kwork_mcp/version.py index 20ad6a5..cf5013a 100644 --- a/src/kwork_mcp/version.py +++ b/src/kwork_mcp/version.py @@ -1,3 +1,3 @@ """Application version exposed in package metadata and the MCP handshake.""" -__version__ = "1.6.0" +__version__ = "1.6.0rc1" diff --git a/tests/test_auth_bootstrap.py b/tests/test_auth_bootstrap.py index 3c2ebee..82217e8 100644 --- a/tests/test_auth_bootstrap.py +++ b/tests/test_auth_bootstrap.py @@ -960,7 +960,7 @@ async def test_bootstrap_cli_rejects_non_tty_and_unknown_argv_without_reflection [ (["--help"], "kwork-mcp-bootstrap"), (["-h"], "kwork-mcp-bootstrap"), - (["--version"], "1.6.0"), + (["--version"], "1.6.0rc1"), ], ) async def test_bootstrap_help_and_version_need_no_tty_or_configuration( diff --git a/tests/test_mcp_protocol.py b/tests/test_mcp_protocol.py index 1d5181c..112eba4 100644 --- a/tests/test_mcp_protocol.py +++ b/tests/test_mcp_protocol.py @@ -387,7 +387,7 @@ async def test_in_memory_handshake_tools_schemas_annotations_and_results( assert negotiated.protocol_version == protocol_version assert negotiated.server_info is not None assert negotiated.server_info.name == "kwork" - assert negotiated.server_info.version == __version__ == "1.6.0" + assert negotiated.server_info.version == __version__ == "1.6.0rc1" assert negotiated.instructions == server_instructions("auto") assert negotiated.server_capabilities is not None assert negotiated.server_capabilities.tools is not None diff --git a/tests/test_release_metadata.py b/tests/test_release_metadata.py index b805dca..444002a 100644 --- a/tests/test_release_metadata.py +++ b/tests/test_release_metadata.py @@ -46,7 +46,7 @@ def test_versions_and_console_entrypoints_are_consistent() -> None: registry = json.loads((ROOT / "server.json").read_text(encoding="utf-8")) assert project["dynamic"] == ["version"] - assert registry["version"] == __version__ == "1.6.0" + assert registry["version"] == __version__ == "1.6.0rc1" assert registry["packages"][0]["version"] == __version__ assert project["scripts"] == { "kwork-mcp": "kwork_mcp:main", From 0c85f36ca33de4d12deb418bfb59908780ccbb56 Mon Sep 17 00:00:00 2001 From: Simon <68971770+simonether@users.noreply.github.com> Date: Sun, 4 Oct 2026 22:51:06 +0500 Subject: [PATCH 4/4] Rewrite a client config in place when it cannot be replaced On Windows the first login could not replace the Microsoft Store Claude Desktop config, while the same write succeeded a moment later: something held the file briefly. When the rename is refused, the config is now rewritten in place after the backup, and a failure names its error class and Windows error code instead of a bare 'could not write'. --- src/kwork_mcp/bootstrap.py | 11 +++++++++-- src/kwork_mcp/clients.py | 19 ++++++++++++++++++- tests/test_client_setup.py | 20 ++++++++++++++++++++ tests/test_clients.py | 27 +++++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 3 deletions(-) diff --git a/src/kwork_mcp/bootstrap.py b/src/kwork_mcp/bootstrap.py index 7bfc18b..2bbfe83 100644 --- a/src/kwork_mcp/bootstrap.py +++ b/src/kwork_mcp/bootstrap.py @@ -713,6 +713,12 @@ def _client_entry(actor: Actor, config: KworkConfig) -> dict[str, Any]: return entry +def _failure_code(exc: BaseException) -> str: + # The class and the Windows error code are safe to show and tell the cause. + winerror = getattr(exc, "winerror", None) + return f"{type(exc).__name__}, WinError {winerror}" if winerror else type(exc).__name__ + + def _connect_clients( actor: Actor, config: KworkConfig, @@ -747,8 +753,9 @@ def _connect_clients( continue try: result = clients.register_server(client.path, name, entry) - except (clients.ClientConfigError, OSError): - stdout.write(f"{client.name}: не удалось записать {location}, добавьте блок ниже вручную.\n") + except (clients.ClientConfigError, OSError) as exc: + reason = _failure_code(exc) + stdout.write(f"{client.name}: не удалось записать {location} ({reason}), добавьте блок ниже вручную.\n") continue connected.add(client.name) stdout.write(f"{client.name}: kwork-mcp подключён. {_restart_hint(client.name)}\n") diff --git a/src/kwork_mcp/clients.py b/src/kwork_mcp/clients.py index 14825e8..f62a711 100644 --- a/src/kwork_mcp/clients.py +++ b/src/kwork_mcp/clients.py @@ -137,8 +137,25 @@ def register_server(path: Path, name: str, entry: dict[str, Any]) -> Registratio os.fsync(handle.fileno()) if read is not None: shutil.copymode(path, temp_name) - private_fs.replace(temp_name, path) + try: + private_fs.replace(temp_name, path) + except PermissionError: + if read is None: + raise + # An antivirus scan or the client itself may hold the file so that + # it cannot be replaced; it can still be rewritten, and the backup + # above keeps the previous version. + _overwrite(path, payload) + Path(temp_name).unlink(missing_ok=True) except BaseException: Path(temp_name).unlink(missing_ok=True) raise return Registration("updated" if previous is not None else "added", backup) + + +def _overwrite(path: Path, payload: bytes) -> None: + with path.open("r+b") as handle: + handle.write(payload) + handle.truncate() + handle.flush() + os.fsync(handle.fileno()) diff --git a/tests/test_client_setup.py b/tests/test_client_setup.py index 3a340d7..04e47d1 100644 --- a/tests/test_client_setup.py +++ b/tests/test_client_setup.py @@ -103,3 +103,23 @@ def test_login_does_not_ask_again_when_the_server_is_already_there(tmp_path: Pat assert code == 0 assert "Подключить" not in stderr assert "Claude Desktop: kwork-mcp уже подключён." in stdout + + +def test_login_names_the_error_when_the_config_cannot_be_written( + tmp_path: Path, + claude_config: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + def refuse(*_args: Any) -> clients.Registration: + error = PermissionError(13, "in use") + error.winerror = 32 # type: ignore[attr-defined] + raise error + + monkeypatch.setattr(clients, "register_server", refuse) + + code, stdout, _stderr = _login(tmp_path, "да\nда\n") + + assert code == 0 + assert "не удалось записать" in stdout + assert "PermissionError, WinError 32" in stdout + assert 'Claude Desktop и Cursor: в "mcpServers" файла' in stdout diff --git a/tests/test_clients.py b/tests/test_clients.py index 42735cf..a72cb3d 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -188,3 +188,30 @@ def test_the_config_keeps_its_permissions(tmp_path: Path) -> None: ) def test_only_kwork_mcp_entries_count_as_ours(entry: object, ours: bool) -> None: assert is_kwork_mcp_entry(entry) is ours + + +def test_a_config_that_cannot_be_replaced_is_written_in_place( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # An antivirus scan or the client itself may hold the file so that it + # cannot be replaced; it can still be rewritten, and the backup is made first. + path = tmp_path / "claude_desktop_config.json" + path.write_text(json.dumps({"mcpServers": {"files": {"command": "npx"}}}), encoding="utf-8") + + def refuse(_source: str, _target: Path) -> None: + raise PermissionError(13, "in use") + + monkeypatch.setattr(clients.private_fs, "replace", refuse) + + result = register_server(path, "kwork", ENTRY) + + assert result.outcome == "added" + assert json.loads(path.read_text(encoding="utf-8"))["mcpServers"]["kwork"] == ENTRY + assert json.loads((tmp_path / "claude_desktop_config.json.bak").read_text(encoding="utf-8")) == { + "mcpServers": {"files": {"command": "npx"}} + } + assert sorted(item.name for item in tmp_path.iterdir()) == [ + "claude_desktop_config.json", + "claude_desktop_config.json.bak", + ]