From 0432c4ae8ad5eaca4efba52b6ebf6feffc75e69a Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Mon, 20 Jul 2026 13:46:33 -0400 Subject: [PATCH 01/10] =?UTF-8?q?feat(xray-proxy):=20=D0=B4=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=BF=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D1=8F=D0=B5=D0=BC=D0=B0=D1=8F=20=D1=83=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2=D0=BA=D0=B0=20Xray=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BC=D0=B0=D0=BD=D0=B4=D0=BE=D0=B9=20install?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - пользователь без root должен получать проверенный бинарник Xray 26.3.27 одной явной командой, без риска для живого managed-экземпляра. - Что: - команда install [--replace]: пин 26.3.27, маппинг x86_64/aarch64, скачивание urllib + проверка SHA2-256 из официального четырёхзаписного .dgst, staging внутри ~/.local/bin и один атомарный os.replace; - границы размеров (64 MiB архив/бинарник, 4 KiB .dgst, счёт limit+1, сверка Content-Length) и guard managed-режима через start-lock; - типизированная StartLockBusyError, offline-тесты реального загрузчика через подмену urlopen, снапшоты дерева ФС в отказных путях; - README/спека обновлены, включая справку о последней версии с allowInsecure (25.12.8, удалена в 26.2.6); тикет 04 закрыт, леджер дополнен итогами эксперимента. - Проверка: - PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests — 79 OK. - bash tests/test_proxy_launchers.sh — 8 PASS. Co-Authored-By: Claude Fable 5 --- .../issues/04-verified-xray-installation.md | 162 +++- .scratch/headless-xray-proxy/token-ledger.md | 28 + .../2026-07-15-headless-xray-proxy-design.md | 34 +- tests/test_xray_proxy.py | 714 ++++++++++++++++++ tests/test_xray_proxy_managed.py | 18 + xray-proxy/.local/bin/xray-proxy | 317 +++++++- xray-proxy/README.md | 36 +- 7 files changed, 1266 insertions(+), 43 deletions(-) diff --git a/.scratch/headless-xray-proxy/issues/04-verified-xray-installation.md b/.scratch/headless-xray-proxy/issues/04-verified-xray-installation.md index 6d14e1a..dfc4593 100644 --- a/.scratch/headless-xray-proxy/issues/04-verified-xray-installation.md +++ b/.scratch/headless-xray-proxy/issues/04-verified-xray-installation.md @@ -1,28 +1,144 @@ # 04 — Проверяемая пользовательская установка Xray -**What to build:** Пользователь без root устанавливает официальный Xray одной -явной командой и получает проверенный бинарник нужной версии без скрытой замены -существующей установки или записи за пределами пользовательских каталогов. +**What to build:** Пользователь без root устанавливает официальный Xray +одной явной командой `install` и получает проверенный бинарник +зафиксированной версии, без скрытой замены существующей установки, без +записи за пределами пользовательских каталогов и без риска для живого +managed-экземпляра. -**Blocked by:** 01 — Внешний прокси и переносимый Bash-интерфейс. +**Prerequisites:** 01, 03, 06 — все done. Наследуемые решения: одна +поддержанная версия Xray 26.3.27 (06); идентичность managed-процессов по +`/proc/PID/exe` и start-lock (03); Python 3.8+ stdlib-only, переносимость +Ubuntu / РЕД ОС / Astra / Alt. -**Status:** ready-for-agent +**Status:** done -- [ ] `install` поддерживает Linux x86_64 и aarch64, по умолчанию выбирает - зафиксированную версию 26.3.27 и принимает другую только через явный параметр - версии, не объявляя произвольный релиз поддержанным. -- [ ] Архив и файл контрольной суммы скачиваются из официального релиза, после - чего SHA-256 проверяется до распаковки и установки; несовпадение прекращает - операцию без изменения установленного бинарника. -- [ ] Работа ведётся во временном пользовательском каталоге, извлечённый бинарник - сначала проходит проверку `version`, а затем атомарно устанавливается в - пользовательский каталог исполняемых файлов. -- [ ] Существующий бинарник другой версии не перезаписывается без `--replace`; - ошибка, отказ или прерывание не оставляют частично установленный файл. -- [ ] После установки `command -v xray` обязан указывать на установленный файл; - иначе команда завершается с понятной подсказкой исправить `PATH`. -- [ ] Установщик не использует sudo, systemd или системные каталоги, а команды - `start` и запуск без подкоманды никогда не начинают скачивание. -- [ ] Автоматические тесты проверяют обе архитектуры, корректную и повреждённую - контрольную сумму, существующую версию, `--replace`, атомарность и отсутствие - записи вне изолированного пользовательского каталога. +## Зафиксированный протокол установки + +- **Только версия 26.3.27** — та же, что принимает `VERSION_LINE`. + Никакого параметра версии: будущий апгрейд — отдельный тикет, меняющий + пин скачивания, ворота рантайма, фикстуры и документацию вместе. +- **Источник:** `https://github.com/XTLS/Xray-core/releases/download/v26.3.27/`. + Маппинг архитектур (чистая функция, по `platform.machine()`): + `x86_64`/`amd64` → `Xray-linux-64.zip`; `aarch64`/`arm64` → + `Xray-linux-arm64-v8a.zip`; прочее — понятный отказ. +- **Проверка:** скачивается `<архив>.dgst`. Фактический формат официальных + файлов — четыре записи `MD5=`, `SHA1=`, `SHA2-256=`, `SHA2-512=` + (исправлено 2026-07-20 по находке CODE-ревью: однострочного `SHA256=` + в релизах нет). Принимается ровно одна запись `SHA2-256= <64 hex в + нижнем регистре>`; дубликаты, uppercase, отсутствие записи или мусор — + отказ. Хэш архива считается `hashlib.sha256`; несовпадение или + неожиданный формат — отказ до распаковки, установленный бинарник не + тронут. +- **Границы размеров:** скачивание и распаковка ограничены + зафиксированными лимитами (архив, `.dgst`, извлекаемый бинарник); + фактически прочитанные байты считаются с limit+1; несоответствие + объявленному `Content-Length` или превышение лимита — отказ без + исчерпания диска/памяти. +- **Инструменты:** `urllib.request` для скачивания, `zipfile` для + извлечения члена `xray`. Явно исключены: tar, GPG-подписи, curl, wget, + пакетные менеджеры, sudo, systemd, системные каталоги. +- **Атомарность:** приватный staging-каталог создаётся ВНУТРИ + `~/.local/bin` (одна файловая система — `os.replace` без EXDEV); + скачивание и распаковка туда; кандидату ставится режим 0755; кандидат + проходит проверку `version` (те же ворота 26.3.27); затем один + `os.replace` в `~/.local/bin/xray`. До этого вызова существующий файл + назначения не изменяется. Staging убирается при любой ошибке и + прерывании (best-effort); fsync/power-loss-гарантии не требуются. + +## Чекбоксы + +- [x] Команда `install [--replace]` добавлена в парсер и диспетчеризуется + ДО `load_config()`: установка не требует машинного конфига и + VLESS-профиля. `start` и запуск без подкоманды по-прежнему никогда не + начинают скачивание. +- [x] Защита managed-режима: до любого скачивания и изменения назначения + `install` берёт существующий start-lock и держит его до конца + `os.replace`. Lock занят — немедленный отказ с подсказкой «сначала + stop». При взятом lock допускаются только отсутствующий state или + доказуемо устаревший (по классификации 03 — stale); живой, сиротский + или непроверяемый state — отказ. +- [x] Поведение при существующем назначении `~/.local/bin/xray`: + - отсутствует → установка; + - обычный исполняемый файл, отвечающий `26.3.27` → no-op без сети + (успех, сообщение «уже установлено»); + - `--replace` → принудительная переустановка с полной проверкой; + - иной файл (не отвечает 26.3.27, неисполняемый, битый) или симлинк → + отказ без `--replace`; с `--replace` заменяется сам путь, симлинк не + разыменовывается; + - каталог → всегда понятный отказ. +- [x] После установки: проверка `shutil.which("xray")` — резолвится ли в + установленный `~/.local/bin/xray`. Нет — ненулевой код с подсказкой + добавить `~/.local/bin` в начало `PATH`; готовая установка при этом НЕ + откатывается. +- [x] Тестовый шов: чистая функция «архитектура → имя ассета» и + подменяемый загрузчик (callable), который тесты заменяют in-process. + Пользовательского оверрайда базового URL нет. +- [x] Автоматические тесты (без root, без сети; временные HOME/PATH/state): + - маппинг обеих архитектур (patch значения архитектуры) и отказ на + неподдержанной; + - корректный `.dgst` → установка; повреждённый хэш и неожиданный формат + `.dgst` → отказ до распаковки, назначение не тронуто; + - фикстуры: локальный ZIP с фейковым исполняемым, печатающим + `Xray 26.3.27 ...`, и парные валидный/битый `.dgst`; + - существующая корректная установка → no-op без обращения к загрузчику; + `--replace` → переустановка; симлинк/каталог/битый файл — по таблице + выше; + - атомарность: инъецированная ошибка после скачивания / при распаковке / + при проверке `version` → назначение не изменено, staging убран; + - запись только в пределах staging/назначения и lock-файла: сверка + полного дерева временной ФС до/после; + - защита managed: при живом managed-экземпляре (реальная фикстура 03) + `install --replace` отказывает с подсказкой «сначала stop», бинарник + и state не тронуты; + - `shutil.which`-проверка: отсутствие каталога в PATH и конкурирующий + более ранний `xray` в PATH. +- [x] `xray-proxy/README.md` обновлён: одна поддержанная версия 26.3.27 + (упоминания 25.12.8 удалены), использование `install [--replace]`, + маппинг x86_64/aarch64, SHA-256-проверка, поведение PATH, отказ при + живом managed-экземпляре. + +## Сохранить, не трогать + +- Существующее поведение `resolve_xray`, ворот `VERSION_LINE`, + managed-режима и всех 49 зелёных тестов + 8 shell-проверок. +- Секреты: install не касается профилей и не выводит их; общее правило + «VLESS/UUID/ключи никогда в stdout/stderr/argv» остаётся. +- `bash/.bash_functions`, установщик dotfiles — вне объёма. +- Никаких коммитов. + +## Отчёт исполнителя + +1. Изменённые файлы со строкой «зачем» на каждый. +2. Команды тестов и счётчики (baseline: 49 OK / 8 PASS — новых станет + больше, указать числа). +3. Чекбоксы: закрыт / не закрыт и почему. +4. Принятые по ходу решения и сомнения, если есть. + +## Резолюция (2026-07-20) + +Конвейер пройден целиком: постановка → Codex spec-review (high, 5 MAJOR + +3 MINOR + 1 NIT, переписана) → реализация (Codex, medium — эксперимент) → +саморевью (found 6, fixed 6) → два слепых ревью (CODE: 2 MAJOR + 2 MINOR; +TASK: 3 NIT) → триаж (FIXED 7, REJECTED 0) → узкие перепроверки +(оба FIXES_CONFIRMED) → приёмка оркестратором. + +Ключевое: + +- Команда `install [--replace]`: пин 26.3.27, маппинг архитектур чистой + функцией, `urllib.request` + `zipfile`, staging внутри `~/.local/bin`, + один `os.replace`, `version`-ворота кандидата, guard по start-lock. +- CODE-1 (MAJOR) оказался дефектом постановки: spec-review зафиксировал + несуществующий однострочный формат `SHA256=`; реальные `.dgst` — четыре + записи. Протокол в тикете исправлен (см. выше), парсер строгий: ровно + одна `SHA2-256= <64 hex в нижнем регистре>`. +- Границы размеров: 64 MiB архив/бинарник, 4 KiB `.dgst`, счёт limit+1, + сверка Content-Length; offline-покрытие реального `download_file` через + подмену `urllib.request.urlopen`. +- `StartLockBusyError` вместо парсинга текста ошибки; снапшоты дерева ФС + в тестах отказных путей; PATH-тест конкурирующего бинарника. +- README дополнён (по просьбе пользователя): последняя версия с + `allowInsecure` — 25.12.8, опция удалена в 26.2.6; CLI их не ставит. + +Тесты: 79 OK (baseline 49, +30) + 8 PASS shell-проверок. Затраты — в +`.scratch/headless-xray-proxy/token-ledger.md`. diff --git a/.scratch/headless-xray-proxy/token-ledger.md b/.scratch/headless-xray-proxy/token-ledger.md index 372e42d..fae540c 100644 --- a/.scratch/headless-xray-proxy/token-ledger.md +++ b/.scratch/headless-xray-proxy/token-ledger.md @@ -39,6 +39,15 @@ _Заполняется по ходу; сравнивать построчно | 03: перепроверка TASK-1..3 | Claude Opus (резюм субагента) | default | инкремент ~7.4k (суммарно 80 560) | 8 tool uses, 1.7 мин; FIXES_CONFIRMED | | 03: перепроверка CODE-1..5 | Codex-ревьюер (resume) | medium | инкремент: свежих in ~112k, out ~3.2k | 20 команд; FIXES_CONFIRMED | +| 04: ревью постановки | Codex gpt-5.6-sol | high | in 2 469 937 (кэш 2 352 896 → свежих ~117k), out 17 051 (reasoning 10 128) | 54 команды; NEEDS_REWORK: 5 MAJOR + 3 MINOR + 1 NIT | +| 04: реализация | Codex gpt-5.6-sol | **medium** | in 2 941 682 (кэш 2 828 288 → свежих ~113k), out 18 521 (reasoning 5 278) | 50 команд; +~600 строк; 63 OK (+14) + 8 PASS | +| 04: саморевью | Codex (resume) | medium | инкремент: свежих in ~50k, out ~17.2k (reasoning ~9.2k) | 38 команд; found 6 fixed 6; 72 OK + 8 PASS | +| 04: слепое ревью «по задаче» | Claude Opus (субагент) | default | 53 463 суммарно | 18 tool uses, 5.7 мин; 3 NIT | +| 04: слепое ревью «по коду» | Codex gpt-5.6-sol (свежий тред) | high | in 1 774 005 (кэш 1 676 288 → свежих ~98k), out 10 402 (reasoning 5 481) | 46 команд; 2 MAJOR + 2 MINOR (CODE-1 — дефект постановки: реальный формат .dgst) | +| 04: триаж/починка | Codex (resume) | medium | инкремент: свежих in ~237k, out ~58.9k (reasoning ~24k) | FIXED 7, REJECTED 0; 79 OK (+30 к baseline) + 8 PASS | +| 04: перепроверка TASK-1..3 | Claude Opus (резюм субагента) | default | инкремент ~7.7k (суммарно 61 135) | 6 tool uses, 1.7 мин; FIXES_CONFIRMED | +| 04: перепроверка CODE-1..4 | Codex-ревьюер (resume) | medium | инкремент: свежих in ~207k, out ~12.5k (reasoning ~6.3k) | FIXES_CONFIRMED | + Итог 03 (реализация на MEDIUM): Codex — свежий input ~636k + output ~77k (ревью постановки 82k/16k, реализация ~248k/29.5k, саморевью 53k/11.6k, CODE-ревью 85k/12.8k, триаж 55.5k/16.9k, перепроверка 112k/3.2k); @@ -49,6 +58,25 @@ Claude-доля — 80.5k суммарно (TASK-ревью + перепрове \* usage первого (убитого) хода не был зафиксирован (нет `turn.completed`); цифры — из resume-хода, по масштабу покрывают весь тред. +Итог 04 (реализация на MEDIUM): Codex — свежий input ~822k + output ~135k +(ревью постановки 117k/17k, реализация 113k/18.5k, саморевью 50k/17.2k, +CODE-ревью 98k/10.4k, триаж 237k/58.9k, перепроверка 207k/12.5k); +Claude-доля — 61k суммарно (TASK-ревью + перепроверка). Слепые ревью +нашли 2 MAJOR + 2 MINOR + 3 NIT, причём CODE-1 (MAJOR) — дефект +постановки (spec-review на high выдумал однострочный `SHA256=`), т.е. +на счету реализации-medium фактически 1 MAJOR (границы размеров). +Примечательно: 04-medium вышла ДОРОЖЕ 06-high по свежим токенам +(~822k+135k против ~735k+97k) — экономию реализации съели дорогой триаж +(7 находок, включая переделку под исправленный протокол) и перепроверки. + +Сводка эксперимента (свежий input + output Codex; MAJOR от слепых ревью): + +| Задача | Effort реализации | Codex токены | Claude | MAJOR | +|---|---|---|---|---| +| 06 | high (baseline) | ~735k + 97k | 175k | 1 | +| 03 | medium | ~636k + 77k | 80.5k | 4 (гонки) | +| 04 | medium | ~822k + 135k | 61k | 2 (1 — вина постановки) | + ## Наблюдения - Кэш у Codex покрывает ~93% входа ревью-этапа: длинные треды с большим diff --git a/docs/specs/2026-07-15-headless-xray-proxy-design.md b/docs/specs/2026-07-15-headless-xray-proxy-design.md index f1fe7f8..05c6829 100644 --- a/docs/specs/2026-07-15-headless-xray-proxy-design.md +++ b/docs/specs/2026-07-15-headless-xray-proxy-design.md @@ -266,20 +266,30 @@ Hiddify Manager может добавлять `allowInsecure=true` и `insecure= `xray-proxy install` — отдельная явная операция. Она устанавливает Xray в `~/.local/bin`, скачивает официальный архив и файл контрольной суммы, затем -проверяет SHA-256. Версия по умолчанию зафиксирована в коде клиента на текущем -на момент реализации стабильном релизе 26.3.27. Пользователь может явно указать -другую версию через `--version`; произвольная версия не становится заявленной -как поддерживаемая только из-за успешной установки. Официальные релизы -публикуют необходимые файлы `.dgst`: +проверяет SHA-256. Поддерживается только зафиксированный релиз 26.3.27; +параметра версии нет. Официальные релизы публикуют необходимые файлы `.dgst`: [релиз 26.3.27](https://github.com/XTLS/Xray-core/releases/tag/v26.3.27). -Первая версия установщика поддерживает Linux x86_64 и aarch64. Архив -распаковывается во временный пользовательский каталог, проверенный бинарник -сначала запускается с `version`, а затем атомарно переносится в -`~/.local/bin/xray`. Существующий файл другой версии не перезаписывается без -явного `--replace`. После установки CLI проверяет, что `command -v xray` -указывает именно на установленный файл; иначе завершается с подсказкой исправить -`PATH`. `start` всегда показывает фактический путь и версию выбранного бинарника. +Для Linux `x86_64`/`amd64` используется `Xray-linux-64.zip`, для +`aarch64`/`arm64` — `Xray-linux-arm64-v8a.zip`; остальные архитектуры +отклоняются. Официальный `.dgst` содержит записи `MD5`, `SHA1`, `SHA2-256` и +`SHA2-512`; принимается ровно один полный набор со строчными hex-значениями, +для проверки архива используется `SHA2-256`. Дубликаты, uppercase, отсутствие +записи и мусор отклоняются. Архив и извлечённый бинарник ограничены 64 MiB, +`.dgst` — 4 KiB; фактический размер считается с limit+1 и должен совпадать с +объявленным `Content-Length`. Архив распаковывается в закрытый staging-каталог +внутри `~/.local/bin`, проверенный бинарник запускается с `version`, после чего +одним `os.replace` атомарно переносится в `~/.local/bin/xray`. + +Корректная существующая версия 26.3.27 даёт no-op без сети. Другой файл или +симлинк не заменяется без `--replace`, а каталог не заменяется никогда. +Start-lock берётся до проверки назначения и удерживается до завершения +`os.replace`; живой, сиротский или неподтверждённый managed-state запрещает +установку с подсказкой сначала выполнить `stop`. +После установки CLI проверяет, что первый `xray` из `PATH` разрешается в +установленный файл; иначе завершается с подсказкой поставить `~/.local/bin` в +начало `PATH`, не откатывая готовую установку. `start` всегда показывает +фактический путь и версию выбранного бинарника. Перед созданием JSON CLI разрешает первый `xray` из `PATH` до одного пути и запускает этим файлом `xray version`. Первая строка должна начинаться с точного diff --git a/tests/test_xray_proxy.py b/tests/test_xray_proxy.py index 63e017a..96c509a 100644 --- a/tests/test_xray_proxy.py +++ b/tests/test_xray_proxy.py @@ -1,11 +1,16 @@ #!/usr/bin/env python3 +import fcntl +import http.client import json +import hashlib import os import runpy import subprocess import sys import tempfile import unittest +import urllib.error +import zipfile from unittest import mock from pathlib import Path @@ -14,6 +19,31 @@ REPO_ROOT = Path(__file__).resolve().parents[1] CLI = REPO_ROOT / "xray-proxy/.local/bin/xray-proxy" +class FakeUrlResponse: + def __init__(self, data=b"", content_length=None, read_error=None): + self.data = data + self.position = 0 + self.read_error = read_error + self.headers = {} + if content_length is not None: + self.headers["Content-Length"] = str(content_length) + + def __enter__(self): + return self + + def __exit__(self, _type, _value, _traceback): + return False + + def read(self, size=-1): + if self.read_error is not None: + raise self.read_error + if size < 0: + size = len(self.data) - self.position + chunk = self.data[self.position : self.position + size] + self.position += len(chunk) + return chunk + + def checking_socket_setup(outcome="success"): if outcome == "success": outcome_line = " return Connection()\n" @@ -517,5 +547,689 @@ class XrayProxyCliTest(unittest.TestCase): self.assertIn("address", result.stderr) +class XrayInstallTest(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) + self.home = self.root / "home" + self.state_home = self.root / "state" + self.home.mkdir() + self.environment = { + "HOME": str(self.home), + "XDG_STATE_HOME": str(self.state_home), + "PATH": str(self.home / ".local/bin"), + } + self.module = runpy.run_path(str(CLI)) + self.destination = self.home / ".local/bin/xray" + self.archive = self.root / "fixture.zip" + self.write_archive("installed") + + def tearDown(self): + self.temporary.cleanup() + + @staticmethod + def fake_xray(label="fixture", version="26.3.27"): + return ( + "#!/bin/sh\n" + "if [ \"$1\" = version ]; then\n" + " printf '%s\\n' 'Xray {} ({})'\n" + " exit 0\n" + "fi\n" + "exit 64\n".format(version, label) + ).encode("ascii") + + def write_archive(self, label="fixture", version="26.3.27", member="xray"): + with zipfile.ZipFile(str(self.archive), "w") as archive: + archive.writestr(member, self.fake_xray(label, version)) + + @staticmethod + def official_digest(archive_hash): + return ( + "MD5= {}\n" + "SHA1= {}\n" + "SHA2-256= {}\n" + "SHA2-512= {}\n".format( + "1" * 32, + "2" * 40, + archive_hash, + "3" * 128, + ) + ) + + def downloader(self, bad_hash=False, digest_text=None, fail_after_archive=False): + calls = [] + + def download(url, destination, _max_bytes): + calls.append(url) + if url.endswith(".dgst"): + if fail_after_archive: + raise self.module["ProxyError"]("инъецированная ошибка загрузки") + archive_hash = hashlib.sha256(self.archive.read_bytes()).hexdigest() + if bad_hash: + archive_hash = "0" * 64 + text = digest_text + if text is None: + text = self.official_digest(archive_hash) + destination.write_text(text, encoding="ascii") + else: + destination.write_bytes(self.archive.read_bytes()) + + return download, calls + + def install(self, replace=False, downloader=None, machine="x86_64", path=None): + if downloader is None: + downloader, _calls = self.downloader() + environment = dict(self.environment) + if path is not None: + environment["PATH"] = path + globals_ = self.module["command_install"].__globals__ + with mock.patch.dict(os.environ, environment, clear=False): + with mock.patch.object(globals_["platform"], "machine", return_value=machine): + return self.module["command_install"](replace, downloader) + + def write_destination(self, content=None, executable=True): + self.destination.parent.mkdir(parents=True, exist_ok=True) + self.destination.write_bytes(content or self.fake_xray("existing")) + self.destination.chmod(0o755 if executable else 0o644) + + def tree_snapshot(self): + snapshot = {} + for path in self.root.rglob("*"): + relative = path.relative_to(self.root).as_posix() + info = path.lstat() + mode = info.st_mode & 0o777 + if path.is_symlink(): + snapshot[relative] = ("symlink", mode, os.readlink(str(path))) + elif path.is_dir(): + snapshot[relative] = ("directory", mode) + elif path.is_file(): + digest = hashlib.sha256(path.read_bytes()).hexdigest() + snapshot[relative] = ("file", mode, digest) + else: + snapshot[relative] = ("other", mode) + return snapshot + + def tree_snapshot_without_install_lock(self): + snapshot = self.tree_snapshot() + for path in ("state/xray-proxy/lock", "state/xray-proxy", "state"): + snapshot.pop(path, None) + return snapshot + + def assert_no_staging(self): + if self.destination.parent.exists(): + self.assertEqual( + [], list(self.destination.parent.glob(".xray-install-*")) + ) + + def assert_install_lock_released(self): + lock_path = self.state_home / "xray-proxy/lock" + with lock_path.open("r+", encoding="ascii") as lock: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + + def test_architecture_mapping_is_pure_and_rejects_unknown(self): + asset_for = self.module["xray_asset_for_architecture"] + for machine in ("x86_64", "amd64"): + self.assertEqual("Xray-linux-64.zip", asset_for(machine)) + for machine in ("aarch64", "arm64"): + self.assertEqual("Xray-linux-arm64-v8a.zip", asset_for(machine)) + with self.assertRaisesRegex(self.module["ProxyError"], "не поддерживается"): + asset_for("riscv64") + + def test_valid_digest_installs_verified_executable_and_only_expected_files(self): + downloader, calls = self.downloader() + self.assertEqual(0, self.install(downloader=downloader)) + + self.assertEqual(0o755, self.destination.stat().st_mode & 0o777) + self.assertIn(b"installed", self.destination.read_bytes()) + self.assertEqual(2, len(calls)) + self.assertTrue(calls[0].endswith("/Xray-linux-64.zip")) + self.assertEqual(calls[0] + ".dgst", calls[1]) + self.assertEqual( + { + "fixture.zip", + "home", + "home/.local", + "home/.local/bin", + "home/.local/bin/xray", + "state", + "state/xray-proxy", + "state/xray-proxy/lock", + }, + set(self.tree_snapshot()), + ) + self.assert_no_staging() + + def test_platform_machine_selects_arm64_asset(self): + downloader, calls = self.downloader() + self.assertEqual( + 0, self.install(downloader=downloader, machine="aarch64") + ) + self.assertTrue(calls[0].endswith("/Xray-linux-arm64-v8a.zip")) + self.assertEqual(calls[0] + ".dgst", calls[1]) + + def test_candidate_is_private_while_zip_bytes_are_written(self): + os_module = self.module["extract_xray_candidate"].__globals__["os"] + real_fdopen = os_module.fdopen + observed_modes = [] + + def checked_fdopen(descriptor, *arguments, **keywords): + descriptor_path = os.readlink("/proc/self/fd/{}".format(descriptor)) + if Path(descriptor_path).name == "xray": + observed_modes.append(os.fstat(descriptor).st_mode & 0o777) + return real_fdopen(descriptor, *arguments, **keywords) + + with mock.patch.object( + os_module, "fdopen", side_effect=checked_fdopen + ): + self.assertEqual(0, self.install()) + + self.assertEqual([0o600], observed_modes) + self.assertEqual(0o755, self.destination.stat().st_mode & 0o777) + + def test_bad_hash_and_digest_format_preserve_destination_before_extraction(self): + valid_hash = "a" * 64 + cases = ( + self.downloader(bad_hash=True)[0], + self.downloader(digest_text="SHA256= " + valid_hash + "\n")[0], + self.downloader( + digest_text=self.official_digest(valid_hash).replace( + "SHA2-256= " + valid_hash + "\n", "" + ) + )[0], + self.downloader( + digest_text=self.official_digest(valid_hash) + + "SHA2-256= " + + valid_hash + + "\n" + )[0], + self.downloader( + digest_text=self.official_digest(valid_hash) + "garbage\n" + )[0], + self.downloader( + digest_text=self.official_digest(valid_hash).replace( + valid_hash, "a" * 63 + ) + )[0], + self.downloader( + digest_text=self.official_digest(valid_hash).replace( + valid_hash, "a" * 65 + ) + )[0], + ) + for downloader in cases: + with self.subTest(downloader=downloader): + sentinel = b"destination sentinel" + self.write_destination(sentinel) + before = self.tree_snapshot_without_install_lock() + with self.assertRaises(self.module["ProxyError"]): + self.install(replace=True, downloader=downloader) + self.assertEqual(sentinel, self.destination.read_bytes()) + self.assert_no_staging() + self.assertEqual(before, self.tree_snapshot_without_install_lock()) + + def test_bad_or_truncated_archive_is_rejected_before_zip_open(self): + full_archive = self.archive.read_bytes() + + def truncated_downloader(url, destination, _max_bytes): + if url.endswith(".dgst"): + digest = hashlib.sha256(full_archive).hexdigest() + destination.write_text(self.official_digest(digest), encoding="ascii") + else: + destination.write_bytes(full_archive[: len(full_archive) // 2]) + + zip_file = self.module["extract_xray_candidate"].__globals__["zipfile"] + with mock.patch.object( + zip_file, "ZipFile", side_effect=AssertionError("ZIP открыт до SHA-256") + ) as opened: + with self.assertRaisesRegex(self.module["ProxyError"], "SHA-256"): + self.install(downloader=truncated_downloader) + opened.assert_not_called() + self.assertFalse(self.destination.exists()) + self.assert_no_staging() + + def test_real_downloader_installs_complete_declared_responses(self): + archive = self.archive.read_bytes() + digest = self.official_digest(hashlib.sha256(archive).hexdigest()).encode( + "ascii" + ) + responses = [ + FakeUrlResponse(archive, content_length=len(archive)), + FakeUrlResponse(digest, content_length=len(digest)), + ] + urllib_request = self.module["download_file"].__globals__["urllib"].request + + with mock.patch.object(urllib_request, "urlopen", side_effect=responses): + self.assertEqual( + 0, self.install(downloader=self.module["download_file"]) + ) + + def test_real_downloader_refuses_length_errors_and_releases_install(self): + archive = self.archive.read_bytes() + cases = ( + ( + "early EOF", + FakeUrlResponse(archive, content_length=len(archive) + 1), + ), + ( + "extra bytes", + FakeUrlResponse(archive, content_length=len(archive) - 1), + ), + ( + "declared oversized", + FakeUrlResponse( + b"", + content_length=self.module["MAX_ARCHIVE_BYTES"] + 1, + ), + ), + ("url error", urllib.error.URLError("offline")), + ( + "http error", + FakeUrlResponse(read_error=http.client.HTTPException("broken HTTP")), + ), + ( + "incomplete read", + FakeUrlResponse( + read_error=http.client.IncompleteRead(b"partial", 10) + ), + ), + ) + urllib_request = self.module["download_file"].__globals__["urllib"].request + + for label, outcome in cases: + with self.subTest(label=label): + sentinel = b"destination sentinel" + self.write_destination(sentinel) + before = self.tree_snapshot_without_install_lock() + with mock.patch.object(urllib_request, "urlopen", side_effect=[outcome]): + with self.assertRaises(self.module["ProxyError"]): + self.install( + replace=True, downloader=self.module["download_file"] + ) + self.assertEqual(sentinel, self.destination.read_bytes()) + self.assert_no_staging() + self.assert_install_lock_released() + self.assertEqual(before, self.tree_snapshot_without_install_lock()) + + def test_real_downloader_counts_limit_plus_one_without_content_length(self): + urllib_request = self.module["download_file"].__globals__["urllib"].request + globals_ = self.module["command_install"].__globals__ + sentinel = b"destination sentinel" + self.write_destination(sentinel) + + with mock.patch.dict(globals_, {"MAX_ARCHIVE_BYTES": 8}): + response = FakeUrlResponse(b"123456789") + with mock.patch.object(urllib_request, "urlopen", return_value=response): + with self.assertRaisesRegex(self.module["ProxyError"], "размер"): + self.install( + replace=True, downloader=self.module["download_file"] + ) + + self.assertEqual(sentinel, self.destination.read_bytes()) + self.assert_no_staging() + self.assert_install_lock_released() + + def test_uppercase_digest_is_rejected_before_zip_open(self): + digest = hashlib.sha256(self.archive.read_bytes()).hexdigest().upper() + downloader, _calls = self.downloader( + digest_text=self.official_digest(digest) + ) + zip_file = self.module["extract_xray_candidate"].__globals__["zipfile"] + with mock.patch.object( + zip_file, "ZipFile", side_effect=AssertionError("ZIP открыт до digest") + ) as opened: + with self.assertRaisesRegex(self.module["ProxyError"], "SHA-256"): + self.install(downloader=downloader) + opened.assert_not_called() + self.assertFalse(self.destination.exists()) + self.assert_no_staging() + + def test_existing_verified_install_is_noop_without_downloader(self): + self.write_destination() + downloader = mock.Mock(side_effect=AssertionError("network used")) + self.assertEqual(0, self.install(downloader=downloader)) + downloader.assert_not_called() + + def test_existing_version_gate_keeps_timeout_and_avoids_downloader(self): + self.write_destination() + downloader = mock.Mock(side_effect=AssertionError("network used")) + subprocess_module = self.module["xray_version"].__globals__["subprocess"] + timeout = subprocess.TimeoutExpired([str(self.destination), "version"], 5) + + with mock.patch.object(subprocess_module, "run", side_effect=timeout) as run: + with self.assertRaisesRegex(self.module["ProxyError"], "--replace"): + self.install(downloader=downloader) + + downloader.assert_not_called() + self.assertEqual(5, run.call_args.kwargs["timeout"]) + self.assertEqual(self.fake_xray("existing"), self.destination.read_bytes()) + + def test_replace_reinstalls_verified_existing_file(self): + self.write_destination(self.fake_xray("old")) + downloader, calls = self.downloader() + self.assertEqual(0, self.install(replace=True, downloader=downloader)) + self.assertIn(b"installed", self.destination.read_bytes()) + self.assertEqual(2, len(calls)) + + def test_destination_table_for_symlink_directory_and_bad_files(self): + self.destination.parent.mkdir(parents=True) + target = self.root / "target" + target.write_bytes(b"target sentinel") + self.destination.symlink_to(target) + with self.assertRaisesRegex(self.module["ProxyError"], "--replace"): + self.install() + self.assertTrue(self.destination.is_symlink()) + self.assertEqual(0, self.install(replace=True)) + self.assertFalse(self.destination.is_symlink()) + self.assertEqual(b"target sentinel", target.read_bytes()) + + self.destination.unlink() + self.destination.mkdir() + for replace in (False, True): + with self.subTest(directory=True, replace=replace): + with self.assertRaisesRegex(self.module["ProxyError"], "каталог"): + self.install(replace=replace) + self.destination.rmdir() + + for content, executable in ((b"broken", True), (self.fake_xray(), False)): + with self.subTest(content=content, executable=executable): + self.write_destination(content, executable) + with self.assertRaisesRegex(self.module["ProxyError"], "--replace"): + self.install() + self.assertEqual(0, self.install(replace=True)) + self.assertIn(b"installed", self.destination.read_bytes()) + self.destination.unlink() + + def test_parent_symlink_cannot_redirect_install_outside_home(self): + outside = self.root / "outside" + outside.mkdir() + (self.home / ".local").symlink_to(outside, target_is_directory=True) + downloader = mock.Mock() + + with self.assertRaisesRegex(self.module["ProxyError"], "домашнего каталога"): + self.install(downloader=downloader) + + downloader.assert_not_called() + self.assertEqual([], list(outside.iterdir())) + + def test_managed_state_guard_only_allows_absent_or_stale(self): + guard = self.module["require_install_safe_managed_state"] + globals_ = guard.__globals__ + present = mock.Mock() + present.lstat.return_value = mock.Mock() + with mock.patch.dict( + globals_, + { + "state_path": mock.Mock(return_value=present), + "read_managed_state": mock.Mock(return_value={"state": "fixture"}), + }, + ): + for outcome in ("healthy", "orphan", "unverifiable"): + with self.subTest(outcome=outcome): + globals_["managed_state_outcome"] = mock.Mock(return_value=outcome) + with self.assertRaisesRegex(self.module["ProxyError"], "stop"): + guard() + globals_["managed_state_outcome"] = mock.Mock(return_value="stale") + guard() + + absent = mock.Mock() + absent.lstat.side_effect = FileNotFoundError() + with mock.patch.dict( + globals_, {"state_path": mock.Mock(return_value=absent)} + ): + guard() + + def test_command_classifies_managed_state_before_even_noop_destination(self): + self.write_destination() + downloader = mock.Mock(side_effect=AssertionError("network used")) + guard = mock.Mock( + side_effect=self.module["ProxyError"]( + "managed Xray работает; сначала выполните xray-proxy stop" + ) + ) + globals_ = self.module["command_install"].__globals__ + + with mock.patch.dict( + globals_, {"require_install_safe_managed_state": guard} + ): + with self.assertRaisesRegex(self.module["ProxyError"], "stop"): + self.install(downloader=downloader) + + guard.assert_called_once_with() + downloader.assert_not_called() + self.assertEqual(self.fake_xray("existing"), self.destination.read_bytes()) + + def test_download_extraction_and_version_errors_are_atomic_and_clean_staging(self): + failures = [] + failures.append(self.downloader(fail_after_archive=True)[0]) + missing_member = self.root / "missing-member.zip" + with zipfile.ZipFile(str(missing_member), "w") as archive: + archive.writestr("not-xray", b"data") + + def missing_member_downloader(url, destination, _max_bytes): + source = missing_member if not url.endswith(".dgst") else None + if source is not None: + destination.write_bytes(source.read_bytes()) + else: + digest = hashlib.sha256(missing_member.read_bytes()).hexdigest() + destination.write_text(self.official_digest(digest), encoding="ascii") + + failures.append(missing_member_downloader) + self.write_archive("wrong", version="26.3.26") + failures.append(self.downloader()[0]) + + for downloader in failures: + with self.subTest(downloader=downloader): + sentinel = b"destination sentinel" + self.write_destination(sentinel) + before = self.tree_snapshot_without_install_lock() + with self.assertRaises(self.module["ProxyError"]): + self.install(replace=True, downloader=downloader) + self.assertEqual(sentinel, self.destination.read_bytes()) + self.assert_no_staging() + self.assertEqual(before, self.tree_snapshot_without_install_lock()) + + def test_corrupt_zip_with_matching_digest_is_atomic_and_cleaned(self): + corrupt = b"not a ZIP archive" + + def corrupt_downloader(url, destination, _max_bytes): + if url.endswith(".dgst"): + digest = hashlib.sha256(corrupt).hexdigest() + destination.write_text(self.official_digest(digest), encoding="ascii") + else: + destination.write_bytes(corrupt) + + sentinel = b"destination sentinel" + self.write_destination(sentinel) + before = self.tree_snapshot_without_install_lock() + with self.assertRaisesRegex(self.module["ProxyError"], "извлечь"): + self.install(replace=True, downloader=corrupt_downloader) + self.assertEqual(sentinel, self.destination.read_bytes()) + self.assert_no_staging() + self.assertEqual(before, self.tree_snapshot_without_install_lock()) + + def test_injected_oversized_archive_and_digest_are_bounded(self): + globals_ = self.module["command_install"].__globals__ + cases = ( + ("MAX_ARCHIVE_BYTES", len(self.archive.read_bytes()) - 1, self.downloader()[0]), + ( + "MAX_DIGEST_BYTES", + 8, + self.downloader(digest_text=self.official_digest("a" * 64))[0], + ), + ) + for constant, limit, downloader in cases: + with self.subTest(constant=constant): + sentinel = b"destination sentinel" + self.write_destination(sentinel) + with mock.patch.dict(globals_, {constant: limit}): + with self.assertRaisesRegex(self.module["ProxyError"], "размер"): + self.install(replace=True, downloader=downloader) + self.assertEqual(sentinel, self.destination.read_bytes()) + self.assert_no_staging() + + def test_zip_member_declared_over_limit_is_rejected_before_extraction(self): + globals_ = self.module["command_install"].__globals__ + binary_size = len(self.fake_xray()) + sentinel = b"destination sentinel" + self.write_destination(sentinel) + + with mock.patch.dict(globals_, {"MAX_XRAY_BYTES": binary_size - 1}): + with self.assertRaisesRegex(self.module["ProxyError"], "размер"): + self.install(replace=True) + + self.assertEqual(sentinel, self.destination.read_bytes()) + self.assert_no_staging() + + def test_streamed_zip_output_cannot_exceed_limit_even_if_metadata_lies(self): + class FakeInfo: + filename = "xray" + file_size = 8 + + @staticmethod + def is_dir(): + return False + + class FakeArchive: + def __enter__(self): + return self + + def __exit__(self, _type, _value, _traceback): + return False + + @staticmethod + def infolist(): + return [FakeInfo()] + + @staticmethod + def open(_member): + return FakeUrlResponse(b"123456789") + + globals_ = self.module["command_install"].__globals__ + zip_file = globals_["zipfile"] + sentinel = b"destination sentinel" + self.write_destination(sentinel) + with mock.patch.dict(globals_, {"MAX_XRAY_BYTES": 8}): + with mock.patch.object(zip_file, "ZipFile", return_value=FakeArchive()): + with self.assertRaisesRegex(self.module["ProxyError"], "размер"): + self.install(replace=True) + self.assertEqual(sentinel, self.destination.read_bytes()) + self.assert_no_staging() + + def test_interruption_preserves_destination_cleans_staging_and_releases_lock(self): + sentinel = b"destination sentinel" + self.write_destination(sentinel) + before = self.tree_snapshot_without_install_lock() + + def interrupted_downloader(_url, destination, _max_bytes): + destination.write_bytes(b"partial") + raise KeyboardInterrupt() + + with self.assertRaises(KeyboardInterrupt): + self.install(replace=True, downloader=interrupted_downloader) + + self.assertEqual(sentinel, self.destination.read_bytes()) + self.assert_no_staging() + lock_path = self.state_home / "xray-proxy/lock" + with lock_path.open("r+", encoding="ascii") as lock: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + self.assertEqual(before, self.tree_snapshot_without_install_lock()) + + def test_start_lock_is_held_during_download_and_through_replace(self): + downloader, _calls = self.downloader() + lock_path = self.state_home / "xray-proxy/lock" + + def assert_lock_held(): + with lock_path.open("r+", encoding="ascii") as competing: + with self.assertRaises(BlockingIOError): + fcntl.flock(competing, fcntl.LOCK_EX | fcntl.LOCK_NB) + + def checked_downloader(url, destination, max_bytes): + assert_lock_held() + downloader(url, destination, max_bytes) + + os_module = self.module["command_install"].__globals__["os"] + real_replace = os_module.replace + + def checked_replace(source, destination): + real_replace(source, destination) + assert_lock_held() + + with mock.patch.object(os_module, "replace", side_effect=checked_replace): + self.assertEqual(0, self.install(downloader=checked_downloader)) + + with lock_path.open("r+", encoding="ascii") as released: + fcntl.flock(released, fcntl.LOCK_EX | fcntl.LOCK_NB) + + def test_unsupported_architecture_refuses_before_downloading(self): + downloader = mock.Mock() + with self.assertRaisesRegex(self.module["ProxyError"], "не поддерживается"): + self.install(downloader=downloader, machine="riscv64") + downloader.assert_not_called() + self.assertFalse(self.destination.exists()) + + def test_path_missing_or_competing_returns_error_without_rollback(self): + cases = (("empty", False), ("competitor", True)) + for directory_name, competing in cases: + with self.subTest(directory_name=directory_name): + path_dir = self.root / directory_name + path_dir.mkdir() + if competing: + competitor = path_dir / "xray" + competitor.write_bytes(self.fake_xray("competitor")) + competitor.chmod(0o755) + path = str(path_dir) + os.pathsep + str(self.destination.parent) + else: + path = str(path_dir) + with self.assertRaisesRegex(self.module["ProxyError"], "PATH"): + self.install(path=path) + self.assertTrue(self.destination.is_file()) + self.destination.unlink() + + def test_busy_install_hint_uses_exception_type_not_message_text(self): + globals_ = self.module["acquire_install_lock"].__globals__ + busy_error = self.module["StartLockBusyError"]("completely changed text") + with mock.patch.dict( + globals_, {"acquire_start_lock": mock.Mock(side_effect=busy_error)} + ): + with self.assertRaisesRegex(self.module["ProxyError"], "stop"): + self.module["acquire_install_lock"]() + + def test_path_comparison_handles_relative_entry_and_internal_bin_symlink(self): + local = self.home / ".local" + actual_bin = self.home / "private-bin" + local.mkdir() + actual_bin.mkdir() + (local / "bin").symlink_to(actual_bin, target_is_directory=True) + + self.assertEqual(0, self.install()) + self.assertTrue((actual_bin / "xray").is_file()) + + previous_cwd = Path.cwd() + try: + os.chdir(str(actual_bin)) + self.assertEqual(0, self.install(path=".")) + finally: + os.chdir(str(previous_cwd)) + + def test_install_dispatches_before_machine_config_loading(self): + self.write_destination() + config = self.root / "config/proxy" + config.mkdir(parents=True) + (config / "config").write_text("not an ini\n", encoding="ascii") + environment = os.environ.copy() + environment.update(self.environment) + environment["PATH"] = self.environment["PATH"] + os.pathsep + os.environ["PATH"] + environment["XDG_CONFIG_HOME"] = str(self.root / "config") + result = subprocess.run( + [str(CLI), "install"], + text=True, + capture_output=True, + env=environment, + check=False, + ) + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn("уже установлен", result.stdout) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_xray_proxy_managed.py b/tests/test_xray_proxy_managed.py index 456c310..e431d5b 100644 --- a/tests/test_xray_proxy_managed.py +++ b/tests/test_xray_proxy_managed.py @@ -473,6 +473,24 @@ class ManagedXrayCliTest(unittest.TestCase): profile_text, ) + def test_live_managed_instance_rejects_install_replace_without_changes(self): + profile_text = self.tcp_profile() + self.write_profile(profile_text) + process = self.start() + state_before = self.managed_state_path().read_bytes() + xray_before = self.xray.read_bytes() + + result = self.run_cli("install", "--replace") + + self.assertNotEqual(0, result.returncode) + self.assertIn("сначала", result.stderr) + self.assertIn("stop", result.stderr) + self.assertEqual(state_before, self.managed_state_path().read_bytes()) + self.assertEqual(xray_before, self.xray.read_bytes()) + self.assertFalse((self.home / ".local/bin/xray").exists()) + self.assert_no_secrets(result.stdout + result.stderr, profile_text) + self.stop_and_collect(process) + def test_stale_cleanup_waits_for_start_lock_and_preserves_files(self): state_dir = self.state_home / "xray-proxy" state_dir.mkdir(parents=True, mode=0o700) diff --git a/xray-proxy/.local/bin/xray-proxy b/xray-proxy/.local/bin/xray-proxy index 35d9c44..c13ee01 100755 --- a/xray-proxy/.local/bin/xray-proxy +++ b/xray-proxy/.local/bin/xray-proxy @@ -12,18 +12,24 @@ if sys.version_info < (3, 8): import argparse import configparser import fcntl +import hashlib import http.client import ipaddress import json import os +import platform import re import shutil import signal import socket import stat import subprocess +import tempfile import time +import urllib.error +import urllib.request import uuid +import zipfile from pathlib import Path from urllib.parse import parse_qsl, unquote, urlsplit @@ -33,7 +39,17 @@ DEFAULT_NAME = "Hiddify" DEFAULT_ADDRESS = "hproxy.local" DEFAULT_PORT = 12334 DEFAULT_NO_PROXY_EXTRA = (".concept-software.ru",) -VERSION_LINE = re.compile(r"^Xray (26\.3\.27)(?: .*)?$") +XRAY_VERSION = "26.3.27" +VERSION_LINE = re.compile( + r"^Xray ({0})(?: .*)?$".format(re.escape(XRAY_VERSION)) +) +XRAY_RELEASE_BASE_URL = ( + "https://github.com/XTLS/Xray-core/releases/download/v{}/".format(XRAY_VERSION) +) +MAX_ARCHIVE_BYTES = 64 * 1024 * 1024 +MAX_DIGEST_BYTES = 4 * 1024 +MAX_XRAY_BYTES = 64 * 1024 * 1024 +STREAM_CHUNK_BYTES = 64 * 1024 QUERY_KEYS = { "type", "security", @@ -75,6 +91,10 @@ class ProxyError(Exception): pass +class StartLockBusyError(ProxyError): + pass + + class ProxyConfig: def __init__(self, proxy_type, name, address, port, no_proxy_extra): self.proxy_type = proxy_type @@ -580,6 +600,293 @@ def xray_version(path): return match.group(1) +def xray_asset_for_architecture(machine): + normalized = machine.lower() + if normalized in ("x86_64", "amd64"): + return "Xray-linux-64.zip" + if normalized in ("aarch64", "arm64"): + return "Xray-linux-arm64-v8a.zip" + raise ProxyError("архитектура {} не поддерживается установщиком Xray".format(machine)) + + +def download_file(url, destination, max_bytes): + try: + with urllib.request.urlopen(url, timeout=30) as response: + raw_length = response.headers.get("Content-Length") + if raw_length is None: + declared_length = None + elif re.fullmatch(r"[0-9]+", raw_length) is None: + raise ProxyError("сервер сообщил неверный Content-Length") + else: + declared_length = int(raw_length) + if declared_length > max_bytes: + raise ProxyError("скачиваемый файл превышает допустимый размер") + with destination.open("wb") as stream: + actual_length = 0 + while True: + read_size = min( + STREAM_CHUNK_BYTES, max_bytes - actual_length + 1 + ) + chunk = response.read(read_size) + if not chunk: + break + actual_length += len(chunk) + if actual_length > max_bytes: + raise ProxyError("скачиваемый файл превышает допустимый размер") + if declared_length is not None and actual_length > declared_length: + raise ProxyError( + "фактический размер не совпадает с Content-Length" + ) + stream.write(chunk) + if declared_length is not None and actual_length != declared_length: + raise ProxyError("фактический размер не совпадает с Content-Length") + except ProxyError: + raise + except (OSError, urllib.error.URLError, http.client.HTTPException) as error: + raise ProxyError("не удалось скачать официальный Xray: {}".format(error)) + + +def expected_archive_sha256(digest_path): + try: + with digest_path.open("rb") as stream: + digest_bytes = stream.read(MAX_DIGEST_BYTES + 1) + except (OSError, UnicodeError) as error: + raise ProxyError("не удалось прочитать файл SHA-256: {}".format(error)) + if len(digest_bytes) > MAX_DIGEST_BYTES: + raise ProxyError("файл SHA-256 превышает допустимый размер") + try: + digest_text = digest_bytes.decode("ascii") + except UnicodeError as error: + raise ProxyError("не удалось прочитать файл SHA-256: {}".format(error)) + lines = digest_text.split("\n") + if lines and lines[-1] == "": + lines.pop() + expected_lengths = { + "MD5": 32, + "SHA1": 40, + "SHA2-256": 64, + "SHA2-512": 128, + } + records = {} + for line in lines: + match = re.fullmatch( + r"(MD5|SHA1|SHA2-256|SHA2-512)= ([0-9a-f]+)", line + ) + if match is None: + raise ProxyError("файл SHA-256 имеет неожиданный формат") + name, value = match.groups() + if name in records or len(value) != expected_lengths[name]: + raise ProxyError("файл SHA-256 имеет неожиданный формат") + records[name] = value + if set(records) != set(expected_lengths): + raise ProxyError("файл SHA-256 имеет неожиданный формат") + return records["SHA2-256"] + + +def archive_sha256(archive_path): + digest = hashlib.sha256() + total = 0 + try: + with archive_path.open("rb") as stream: + while True: + chunk = stream.read( + min(STREAM_CHUNK_BYTES, MAX_ARCHIVE_BYTES - total + 1) + ) + if not chunk: + break + total += len(chunk) + if total > MAX_ARCHIVE_BYTES: + raise ProxyError("архив Xray превышает допустимый размер") + digest.update(chunk) + except ProxyError: + raise + except OSError as error: + raise ProxyError("не удалось прочитать архив Xray: {}".format(error)) + return digest.hexdigest() + + +def extract_xray_candidate(archive_path, candidate_path): + try: + with zipfile.ZipFile(str(archive_path)) as archive: + members = [item for item in archive.infolist() if item.filename == "xray"] + if len(members) != 1 or members[0].is_dir(): + raise ProxyError("официальный архив не содержит ровно один файл xray") + if members[0].file_size > MAX_XRAY_BYTES: + raise ProxyError("извлекаемый xray превышает допустимый размер") + with archive.open(members[0]) as source: + descriptor = os.open( + str(candidate_path), + os.O_WRONLY | os.O_CREAT | os.O_EXCL, + 0o600, + ) + with os.fdopen(descriptor, "wb") as destination: + extracted = 0 + while True: + chunk = source.read( + min(STREAM_CHUNK_BYTES, MAX_XRAY_BYTES - extracted + 1) + ) + if not chunk: + break + extracted += len(chunk) + if extracted > MAX_XRAY_BYTES: + raise ProxyError( + "извлекаемый xray превышает допустимый размер" + ) + destination.write(chunk) + if extracted != members[0].file_size: + raise ProxyError("размер извлечённого xray не совпадает с ZIP") + candidate_path.chmod(0o755) + except ProxyError: + raise + except (OSError, KeyError, zipfile.BadZipFile) as error: + raise ProxyError("не удалось извлечь xray из проверенного архива: {}".format(error)) + + +def install_destination(): + return Path.home() / ".local/bin/xray" + + +def require_install_parent_within_home(parent): + try: + home = Path.home().resolve(strict=True) + resolved_parent = parent.resolve(strict=False) + resolved_parent.relative_to(home) + except (OSError, RuntimeError, ValueError): + raise ProxyError( + "каталог установки должен находиться внутри домашнего каталога пользователя" + ) + + +def require_install_safe_managed_state(): + path = state_path() + try: + path.lstat() + except FileNotFoundError: + return + except OSError: + raise ProxyError( + "состояние managed-режима не удалось проверить; сначала выполните xray-proxy stop" + ) + try: + state = read_managed_state() + outcome = managed_state_outcome(state) + except ProxyError: + raise ProxyError( + "состояние managed-режима не подтверждено; сначала выполните xray-proxy stop" + ) + if outcome != "stale": + raise ProxyError( + "managed Xray работает или не подтверждён; сначала выполните xray-proxy stop" + ) + + +def acquire_install_lock(): + try: + return acquire_start_lock() + except StartLockBusyError: + raise ProxyError( + "другая операция удерживает блокировку; сначала выполните xray-proxy stop" + ) + + +def existing_installation_outcome(destination, replace): + try: + info = destination.lstat() + except FileNotFoundError: + return "install" + except OSError as error: + raise ProxyError("не удалось проверить назначение {}: {}".format(destination, error)) + if stat.S_ISDIR(info.st_mode): + raise ProxyError("назначение {} является каталогом".format(destination)) + if replace: + return "install" + if stat.S_ISREG(info.st_mode) and os.access(str(destination), os.X_OK): + try: + xray_version(str(destination)) + except ProxyError: + pass + else: + return "already-installed" + raise ProxyError( + "назначение {} уже существует; для проверенной замены используйте --replace".format( + destination + ) + ) + + +def verify_installed_xray_on_path(destination): + found = shutil.which("xray") + try: + matches = found is not None and Path(found).resolve(strict=True) == destination.resolve( + strict=True + ) + except (OSError, RuntimeError): + matches = False + if not matches: + raise ProxyError( + "Xray установлен в {}, но PATH выбирает другой файл; добавьте ~/.local/bin в начало PATH".format( + destination + ) + ) + + +def command_install(replace=False, downloader=download_file): + destination = install_destination() + lock = acquire_install_lock() + try: + require_install_safe_managed_state() + require_install_parent_within_home(destination.parent) + outcome = existing_installation_outcome(destination, replace) + if outcome == "already-installed": + print("Xray {} уже установлен: {}".format(XRAY_VERSION, destination)) + else: + asset = xray_asset_for_architecture(platform.machine()) + try: + destination.parent.mkdir(parents=True, exist_ok=True, mode=0o755) + except OSError as error: + raise ProxyError( + "не удалось создать каталог установки {}: {}".format( + destination.parent, error + ) + ) + require_install_parent_within_home(destination.parent) + if not destination.parent.is_dir(): + raise ProxyError( + "каталог установки {} не является каталогом".format(destination.parent) + ) + staging = Path( + tempfile.mkdtemp(prefix=".xray-install-", dir=str(destination.parent)) + ) + try: + staging.chmod(0o700) + archive_path = staging / asset + digest_path = staging / (asset + ".dgst") + candidate_path = staging / "xray" + downloader( + XRAY_RELEASE_BASE_URL + asset, + archive_path, + MAX_ARCHIVE_BYTES, + ) + downloader( + XRAY_RELEASE_BASE_URL + asset + ".dgst", + digest_path, + MAX_DIGEST_BYTES, + ) + expected = expected_archive_sha256(digest_path) + if archive_sha256(archive_path) != expected: + raise ProxyError("SHA-256 архива Xray не совпадает") + extract_xray_candidate(archive_path, candidate_path) + xray_version(str(candidate_path)) + os.replace(str(candidate_path), str(destination)) + finally: + shutil.rmtree(str(staging), ignore_errors=True) + print("Xray {} установлен: {}".format(XRAY_VERSION, destination)) + finally: + lock.close() + verify_installed_xray_on_path(destination) + return 0 + + def run_xray_test(path, config_path): try: completed = subprocess.run( @@ -840,7 +1147,7 @@ def acquire_start_lock(): except BlockingIOError: if "lock" in locals(): lock.close() - raise ProxyError("другой управляемый запуск уже удерживает блокировку") + raise StartLockBusyError("другой управляемый запуск уже удерживает блокировку") except OSError as error: if "lock" in locals(): lock.close() @@ -1195,6 +1502,10 @@ def build_parser(): description="Безопасный запуск команд через явно настроенный прокси.", ) subparsers = parser.add_subparsers(dest="subcommand") + install_parser = subparsers.add_parser("install", help="установить проверенный Xray") + install_parser.add_argument( + "--replace", action="store_true", help="проверенно заменить существующее назначение" + ) subparsers.add_parser("status", help="проверить доступность активного прокси") subparsers.add_parser("env", help="вывести восемь proxy-переменных") start_parser = subparsers.add_parser("start", help="запустить управляемый Xray") @@ -1214,6 +1525,8 @@ def main(): return 2 try: + if arguments.subcommand == "install": + return command_install(arguments.replace) if arguments.subcommand == "stop": return command_stop() config = load_config() diff --git a/xray-proxy/README.md b/xray-proxy/README.md index 927e0fd..b151419 100644 --- a/xray-proxy/README.md +++ b/xray-proxy/README.md @@ -89,6 +89,7 @@ chmod 600 ~/.config/xray-proxy/default.url ## Команды ```bash +xray-proxy install [--replace] xray-proxy status xray-proxy start [-f PROFILE] xray-proxy stop @@ -101,12 +102,32 @@ pcodex [ARG...] myip ``` +`install` без root устанавливает единственную поддержанную версию Xray +`26.3.27` в `~/.local/bin/xray`. Для `x86_64`/`amd64` используется официальный +`Xray-linux-64.zip`, для `aarch64`/`arm64` — `Xray-linux-arm64-v8a.zip`. +Остальные архитектуры отклоняются. CLI скачивает рядом файл `.dgst`, проверяет +единственную запись `SHA2-256= <64 lowercase hex>` в официальном +четырёхстрочном формате, извлекает `xray` в закрытый временный каталог внутри +`~/.local/bin`, проверяет `xray version` и только затем атомарно меняет +назначение. Дубликаты, uppercase, пропущенные и лишние записи отклоняются. +Архив и извлечённый бинарник ограничены 64 MiB, `.dgst` — 4 KiB; объявленный +`Content-Length` должен точно совпадать с фактически прочитанным размером. + +Существующая корректная версия `26.3.27` даёт успешный no-op без сети. Другой, +битый, неисполняемый файл или симлинк не заменяется без `--replace`; каталог не +заменяется никогда. Если после установки `PATH` выбирает не этот файл, команда +завершается с ошибкой и предлагает поставить `~/.local/bin` в начало `PATH`, +но проверенная установка сохраняется. Родительский симлинк принимается, только +если разрешённый каталог остаётся внутри `HOME`. Во время живого или +неподтверждённого managed-экземпляра установка запрещена: сначала выполните +`xray-proxy stop`. + Для `external` команды `status`, `env` и `run` проверяют настроенный адрес и порт. Для `xray` они дополнительно требуют закрытое состояние именно того супервизора и дочернего Xray, который запустил CLI. Один открытый порт или чужой PID не принимается. `run` никогда не запускает Xray скрыто. -`start` поддерживает Xray `25.12.8` и `26.3.27`, сначала выполняет +`start` поддерживает только Xray `26.3.27`, сначала выполняет `xray version` и `xray run -test`, затем остаётся на переднем плане. Используйте отдельную панель tmux. После готовности один loopback-порт принимает HTTP и SOCKS. `stop` ограниченно ждёт завершения супервизора и Xray, очищает служебные @@ -134,11 +155,14 @@ export PROXY_PREFLIGHT_TIMEOUT=2 Xray 26.3.27 использует `pcs` и `vcn`, а устаревшие `allowInsecure` и `insecure` не переносит. Истинное legacy-значение даёт предупреждение, но -обычная проверка сертификата остаётся включённой. Xray 25.12.8 предназначен -только для явной старой совместимости: согласованное `true` переносится как -`allowInsecure=true` с предупреждением об отключённой проверке сертификата; -`pcs` и `vcn` для этой версии запрещены. Автоматического перехода между -версиями или типами прокси нет. +обычная проверка сертификата остаётся включённой. Автоматического перехода +между версиями или типами прокси нет. + +Для справки при ручном конфигурировании: последняя версия Xray, где +`allowInsecure` ещё применяется, — +[25.12.8](https://github.com/XTLS/Xray-core/releases/tag/v25.12.8); опция +удалена в [26.2.6](https://github.com/XTLS/Xray-core/releases/tag/v26.2.6). +CLI такие версии не поддерживает и не устанавливает. Сводка скрывает UUID и ключи. Содержимое XHTTP `path` не показывается — только его длина. Сгенерированный JSON и состояние лежат в закрытом каталоге From 086ff98db7b957028242779073b161e454fdb740 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Tue, 21 Jul 2026 03:41:39 -0400 Subject: [PATCH 02/10] =?UTF-8?q?docs(xray-proxy):=20=D0=B7=D0=B0=D1=84?= =?UTF-8?q?=D0=B8=D0=BA=D1=81=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D1=8B=20?= =?UTF-8?q?=D0=B2=D1=8B=D0=B2=D0=BE=D0=B4=D1=8B=20=D1=8D=D0=BA=D1=81=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B8=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=20=D0=BF=D0=BE?= =?UTF-8?q?=20=D1=8D=D1=84=D1=84=D0=BE=D1=80=D1=82=D1=83=20=D0=B2=20=D0=BB?= =?UTF-8?q?=D0=B5=D0=B4=D0=B6=D0=B5=D1=80=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - решения по калибровке эффорта (2026-07-21) должны быть видны в самом проекте, а не только в плейбуке оркестратора. - Что: - в «Наблюдения» леджера добавлен итог: реализация на medium допустима, экономику считать по конвейеру; эффорт-карта Claude-субагентов; пределы эффорта и эмпирика кэша Codex при resume. - Проверка: - git diff предыдущей версии .scratch/headless-xray-proxy/token-ledger.md. Co-Authored-By: Claude Fable 5 --- .scratch/headless-xray-proxy/token-ledger.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.scratch/headless-xray-proxy/token-ledger.md b/.scratch/headless-xray-proxy/token-ledger.md index fae540c..10cfb49 100644 --- a/.scratch/headless-xray-proxy/token-ledger.md +++ b/.scratch/headless-xray-proxy/token-ledger.md @@ -83,3 +83,11 @@ Claude-доля — 61k суммарно (TASK-ревью + перепровер контекстом дешевле по лимитам, чем кажется по сырому input. - Сравнивать этапы честнее по «свежий input + output (включая reasoning)», а не по валовому input. +- Итог эксперимента (решение пользователя 2026-07-21, детали — в + claude-subagent-playbook): реализация по отревьюированному тикету → + medium допустима, но экономию считать по конвейеру целиком (04-medium + дороже 06-high из-за триажа); Claude-субагентам effort задавать явно — + xhigh постановки/планы, high слепое ревью (ниже не опускать), medium + перепроверки; высокий effort не страхует от выдумки внешних фактов + (казус формата `.dgst`); смена effort у Codex при resume кэш не + сбрасывает (~93% кэша при high→medium). From 1e7b9d3f3d58348883e548f69630553c0a0c1795 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Tue, 21 Jul 2026 03:43:37 -0400 Subject: [PATCH 03/10] =?UTF-8?q?docs(xray-proxy):=20=D0=BE=D0=B1=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=BB=D1=91=D0=BD=20handoff=20=D0=BA=D0=BE=D0=BD?= =?UTF-8?q?=D0=B2=D0=B5=D0=B9=D0=B5=D1=80=D0=B0=20=D0=BF=D0=BE=D1=81=D0=BB?= =?UTF-8?q?=D0=B5=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=2004?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - следующая сессия должна стартовать задачу 05 без восстановления контекста из транскриптов. - Что: - создан handoff после 04: состояние конвейера, решения по эффорту от 2026-07-21, план задачи 05 с ручными приёмками, грабли механики; - удалён отработанный handoff после 03. - Проверка: - cat .scratch/handoffs/20260721-0343-xray-proxy-after-04.md. Co-Authored-By: Claude Fable 5 --- .../20260720-0835-xray-proxy-after-03.md | 80 ------------------- .../20260721-0343-xray-proxy-after-04.md | 74 +++++++++++++++++ 2 files changed, 74 insertions(+), 80 deletions(-) delete mode 100644 .scratch/handoffs/20260720-0835-xray-proxy-after-03.md create mode 100644 .scratch/handoffs/20260721-0343-xray-proxy-after-04.md diff --git a/.scratch/handoffs/20260720-0835-xray-proxy-after-03.md b/.scratch/handoffs/20260720-0835-xray-proxy-after-03.md deleted file mode 100644 index bcb2bba..0000000 --- a/.scratch/handoffs/20260720-0835-xray-proxy-after-03.md +++ /dev/null @@ -1,80 +0,0 @@ -# Handoff: конвейер xray-proxy после задачи 03 - -Дата: 2026-07-20. Для свежего агента, продолжающего работу в этом -репозитории с пользователем Дмитрием. Заменяет handoff «after-06». - -## Состояние проекта - -- Спека: `docs/specs/2026-07-15-headless-xray-proxy-design.md` (обновлена - задачей 03: модель идентичности процессов, lifetime-flock, три исхода - проверки state). -- Тикеты: `.scratch/headless-xray-proxy/issues/01..06`. 01, 02, 03, 06 — - done; 04 — ready-for-agent (следующая); 05 — замыкающая (blocked by - 03+04, включает ручную приёмку пользователем на целевых системах). -- Последний коммит работ: `5ace0cf` (feat 03, +890/−100). -- Тесты сейчас: `python3 -m unittest discover -s tests` → **49 OK** (~60 с); - `bash tests/test_proxy_launchers.sh` → **8 PASS**. -- Резолюции конвейера по 03 — в шапке тикета 03; расход токенов по этапам — - `.scratch/headless-xray-proxy/token-ledger.md`. - -## Решение пользователя по эксперименту (2026-07-20) - -Реализацию 04 гнать на `model_reasoning_effort="medium"` — добираем -статистику эксперимента (память `codex-effort-calibration`). Контекст -решения: 03 на medium дала 4 MAJOR от слепого CODE-ревью против 1 MAJOR у -06 на high (все — гонки конкурентности/восстановления; конвейер выловил и -починил, обе перепроверки FIXES_CONFIRMED). Оговорка: 03 богаче на гонки, -чем 06, чистым A/B не считать. Ревью постановок и кода, диагнозы — high. - -## Как вести работу (проверено прогонами 06 и 03) - -Скилл `claude-subagent-playbook`: постановка → spec-ревью (Codex, high; -для 03 это дало NEEDS_REWORK с 7 MAJOR и спасло реализацию — не -пропускать) → реализация Codex (medium) → саморевью (resume, medium) → -два слепых ревью (Claude TASK-субагент + Codex CODE свежий тред, high) → -триаж тредом исполнителя (resume, medium) → узкие перепроверки теми же -ревьюерами → приёмка оркестратором (обе сьюты локально) → коммит по -`conventional-commits`. - -Критично: - -- **thread_id Codex забирать сразу** из первой строки stdout-JSONL. - Рестарты сессии дважды убивали фоновые процессы/сторожа; тред - реанимируется `codex exec resume ` без потерь. -- **Синтаксис resume отличается**: `codex exec resume ` принимает - ТОЛЬКО `-c`-оверрайды (`-c 'sandbox_mode="workspace-write"'`, - `-c 'model="gpt-5.6-sol"'`, `-c 'model_reasoning_effort="…"'`), флаги - `-s`/`-m`/`-o` дают exit 2. Отчёты просить писать в файл через промпт. -- **Usage при убитом ходе теряется** (нет `turn.completed`); цифры - resume-хода покрывают тред по масштабу — фиксировать с оговоркой. -- Токен-леджер дополнять строкой на каждый этап; методика — в шапке - леджера. -- Один писатель в дереве; ревьюеров ограничивать read-only мандатом, после - ревью сверять `git status`. -- Чужие untracked-артефакты (`zed/.config/*`, `agents/.agents/skills/*`, - `scripts/setup-xray-proxy.sh`) не трогать и не коммитить. - -## Решения пользователя (не пересматривать без него) - -Память: `prefers-kiss`, `consultant-multi-distro`, -`codex-effort-calibration`. KISS с различением essential/accidental; -целевые системы: Ubuntu, РЕД ОС, возможно Astra/Alt (Python 3.8+ -stdlib-only, переносимость); строгий отказ парсера и безопасный kill не -упрощать; секреты (VLESS/UUID/ключи) — никогда в stdout/stderr/argv/ -state/репозиторий. - -## Suggested skills - -- `claude-subagent-playbook` — конвейер задач 04/05. -- `conventional-commits` — обязателен для каждого коммита. -- `resolving-merge-conflicts` — если merge с origin принесёт конфликты. - -## Ближайший шаг - -Задача 04: свежий обменный каталог (`mktemp -d`), spec-ревью постановки -`.scratch/headless-xray-proxy/issues/04-*.md` (Codex, high) → правка -постановки по находкам → реализация (**medium**) → далее по конвейеру. -После 04 — итоговое сравнение эксперимента и, при решении пользователя, -запись выводов в -`~/.claude/skills/claude-subagent-playbook/references/codex-exec.md`. -Затем 05 с ручной приёмкой пользователя на целевых системах. diff --git a/.scratch/handoffs/20260721-0343-xray-proxy-after-04.md b/.scratch/handoffs/20260721-0343-xray-proxy-after-04.md new file mode 100644 index 0000000..d0781e3 --- /dev/null +++ b/.scratch/handoffs/20260721-0343-xray-proxy-after-04.md @@ -0,0 +1,74 @@ +# Handoff: headless-xray-proxy — после задачи 04, перед 05 + +Дата: 2026-07-21. Одноразовый документ (правило AGENTS.md): следующая +сессия читает, продолжает, по завершении вехи удаляет и пишет новый. + +## Состояние конвейера + +- Порядок прогона: 06 (done, baseline high) → 03 (done) → 04 (done) → + **следующая — 05**. +- 04 закрыта полным конвейером и закоммичена: + `0432c4a feat(xray-proxy): добавлена проверяемая установка Xray командой install`. + Резолюция и детали — в + `.scratch/headless-xray-proxy/issues/04-verified-xray-installation.md` + (Status: done). Тесты после 04: **79 OK + 8 PASS** + (`PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests`, + `bash tests/test_proxy_launchers.sh`). +- Эксперимент «реализация на medium» завершён и зафиксирован (см. ниже); + затраты по этапам — `.scratch/headless-xray-proxy/token-ledger.md` + (итоги 03/04, сводка 06/03/04, наблюдения). + +## Решения пользователя 2026-07-21 (обязательны для 05) + +Зафиксированы в `~/.claude/skills/claude-subagent-playbook/` +(SKILL.md — эффорт-карта Claude-субагентов; references/codex-exec.md — +уточнение к правилу эффорта 2026-07-19; references/lessons.md — эмпирика +прогонов 03/04) и в памяти `codex-effort-calibration.md`. Кратко: + +- Codex: реализация по отревьюированному тикету → medium допустима; + экономию эффорта считать по конвейеру целиком. +- Claude-субагентам effort задавать ЯВНО при спавне: xhigh — ревью + постановок/планов; high — слепое TASK-ревью (ниже не опускать); + medium/default — узкие перепроверки. Внутри одного агента effort между + резюмами не менять. +- Высокий effort не страхует от выдумки внешних фактов (казус `.dgst` в + 04) — форматы артефактов/версии проверять первоисточником или фикстурой. + +## Следующий шаг: задача 05 + +`.scratch/headless-xray-proxy/issues/05-package-delivery-and-acceptance.md` +— доставка пакета и приёмка. Особенность: включает РУЧНЫЕ приёмочные +прогоны пользователя на Ubuntu / РЕД ОС / Astra / Alt (по доступности) — +планируй конвейер с точками ожидания пользователя. Старт — по команде +пользователя («поехали по 5»): постановка → Codex spec-review (high) → +переписывание тикета оркестратором → реализация (medium, если типовая) → +саморевью (resume, medium) → два слепых ревью (Opus TASK high + Codex +CODE high свежим тредом) → триаж (resume) → узкие перепроверки (medium) → +приёмка → conventional commit (русский, Зачем/Что/Проверка). + +## Механика и грабли (проверено в 03/04) + +- Codex-вызовы, шаблоны промптов, правила resume — playbook + references/codex-exec.md. Критично: `codex exec resume ` принимает + ТОЛЬКО `-c`-оверрайды; thread_id забирать из первой строки stdout-JSONL + сразу после старта; usage убитого хода теряется (нет `turn.completed`). +- Смена `model_reasoning_effort` при resume кэш Codex-треда не сбрасывает. +- Леджер: Codex — `grep '"usage"' stdout*.jsonl` (последняя строка хода), + Claude-субагенты — `subagent_tokens` из task-notification; сравнивать по + «свежий input + output». +- Scratch прогонов 03/04: `/tmp/xray-lifecycle-OZHc`, `/tmp/xray-04-H156` + (архив, для 05 создать свой каталог). +- Секреты: реальные VLESS/UUID/ключи — никогда в репо/выводы; чужие + untracked-артефакты (`zed/.config/*`, `agents/.agents/skills/*`, + `scripts/setup-xray-proxy.sh`) не трогать и не коммитить; git add — только + адресно или `-u`. + +## Suggested skills + +- `codex-subagent-playbook` — оптика делегирования; конвейер и механика + Codex — в `~/.claude/skills/claude-subagent-playbook/` (SKILL.md + + references), читать перед стартом 05. +- `conventional-commits` — для коммитов (русский, совершенный вид, + Зачем/Что/Проверка). +- `handoff` — по завершении 05 обновить этот документ (удалить, создать + новый по той же схеме). From 811fc28b7cf6f0f25038b52e65b3dbf1667c7213 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Tue, 21 Jul 2026 03:47:02 -0400 Subject: [PATCH 04/10] =?UTF-8?q?docs(claude-subagent-playbook):=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=B8=D1=82?= =?UTF-8?q?=D0=BE=D0=B3=D0=B8=20=D1=8D=D0=BA=D1=81=D0=BF=D0=B5=D1=80=D0=B8?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=20xray-proxy=2003/04?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - зафиксировать решения пользователя 2026-07-21 и эмпирику effort/кэша после двух боевых прогонов. - Что: - явная карта effort для Claude-субагентов: xhigh — постановки/планы, high — слепое ревью, medium — перепроверки (SKILL.md); - уточнение критерия выбора effort по цене тихой ошибки этапа и эмпирика смены effort при resume (codex-exec.md); - сводка прогонов 03–04 с казусами: гонки конкурентности, выдумка формата .dgst, экономика конвейера (lessons.md). - Проверка: - git diff HEAD~1 --stat. --- .../skills/claude-subagent-playbook/SKILL.md | 12 ++++++++ .../references/codex-exec.md | 16 ++++++++++ .../references/lessons.md | 30 +++++++++++++++++++ 3 files changed, 58 insertions(+) diff --git a/agents/.agents/skills/claude-subagent-playbook/SKILL.md b/agents/.agents/skills/claude-subagent-playbook/SKILL.md index b27f293..7c6b886 100644 --- a/agents/.agents/skills/claude-subagent-playbook/SKILL.md +++ b/agents/.agents/skills/claude-subagent-playbook/SKILL.md @@ -81,6 +81,18 @@ description: >- глубины — повышай effort; не хватает качества суждения — повышай модель; после двух слабых результатов подряд — свежий более сильный агент вместо переноса неудачного контекста. +- **Effort Claude-субагентов тоже задавай явно** (решение пользователя + 2026-07-21), по цене *тихой* ошибки этапа, не по сложности: `xhigh` — + ревью постановок и планов (дефект каскадирует: отравляет реализацию, оба + ревью и триаж — казус CODE-1 в xray-proxy 04); `high` — слепое ревью + (последняя линия обороны своей линзы, пропуск тихий); `medium`/default — + узкие перепроверки и механика (за ними приёмка оркестратора, ошибка + громкая). Ревью ниже high не опускать даже при двух ревьюерах: линзы + разные, избыточность — не дублирование. Внутри одного агента effort между + резюмами не менять (у Claude смена thinking-конфига сбрасывает + messages-ярус кэша; про effort не задокументировано — не рисковать). + Ограничение любого эффорта: от уверенной выдумки внешних фактов (форматы + артефактов, версии) не страхует — факты проверять отдельно. ## Конвейер diff --git a/agents/.agents/skills/claude-subagent-playbook/references/codex-exec.md b/agents/.agents/skills/claude-subagent-playbook/references/codex-exec.md index 619ee4d..3279197 100644 --- a/agents/.agents/skills/claude-subagent-playbook/references/codex-exec.md +++ b/agents/.agents/skills/claude-subagent-playbook/references/codex-exec.md @@ -33,6 +33,22 @@ рискованная реализация, ревью кода, диагноз; `xhigh` — не дефолтить: расход удваивается, а выигрыш мал — только по явному запросу пользователя. Оверрайды проверены под `--strict-config`. + Уточнение по эксперименту xray-proxy 03/04 (решение пользователя + 2026-07-21): реализация по отревьюированному самодостаточному тикету на + `medium` допустима и для нетривиальных задач — конвейер (саморевью + два + слепых ревью + триаж) ловит просадку (03: 4 MAJOR-гонки найдены и + починены; 04: 1 «чистый» MAJOR — на уровне high-baseline 06). Но экономию + считай по всему конвейеру, не по этапу: в 04 medium вышел дороже + high-baseline (~822k+135k против ~735k+97k свежих) — сэкономленное на + реализации съел триаж находок. Критерий выбора — цена *тихой* ошибки + этапа: ревью/постановка/диагноз остаются high (там модель — последняя + линия обороны). И предел: high на ревью постановки не страхует от + уверенной выдумки внешних фактов (04: несуществующий однострочный формат + `.dgst`) — форматы артефактов и версии проверяй реальным запросом или + фикстурой из первоисточника. Попутная эмпирика: смена + `model_reasoning_effort` при `resume` кэш треда НЕ сбрасывает (доля кэша + держалась ~93–94% при high→medium в 06/03/04) — дорогое ревью на high и + дешёвая перепроверка на medium в одном треде работают как задумано. - **Запуск — собственным фоновым Bash оркестратора** (`run_in_background`): фоновый процесс детачится, живёт сквозь ходы и будит оркестратора по выходу. Потолок 10 минут — только у фронтграундного Bash; фоновому таймаут diff --git a/agents/.agents/skills/claude-subagent-playbook/references/lessons.md b/agents/.agents/skills/claude-subagent-playbook/references/lessons.md index ac61cff..45e4caa 100644 --- a/agents/.agents/skills/claude-subagent-playbook/references/lessons.md +++ b/agents/.agents/skills/claude-subagent-playbook/references/lessons.md @@ -231,3 +231,33 @@ git (`git show HEAD:path`) + повторным наложением право пользователя не узкое место, менять нечего; потенциальная полоса — bulk-механика /coordinator-loop и узкие починки, если лимиты станут узкими. Проверка — один A/B на реальной узкой починке. + +## Прогоны 2026-07-20/21: headless-xray-proxy, задачи 03–04 — эксперимент «реализация на medium» + +- Дизайн: реализация Codex на `medium` (03 — жизненный цикл процессов, + 04 — проверяемая установка), остальное по правилам 2026-07-19; baseline — + задача 06 на high (1 MAJOR + 1 NIT от слепых ревью). +- Качество: 03 → 4 MAJOR (все — гонки конкурентности/восстановления), + 04 → 2 MAJOR, из них один (CODE-1) — дефект ПОСТАНОВКИ, не исполнителя. + Конвейер всё выловил и починил; «чистых» MAJOR реализации в 04 — один, + на уровне baseline. +- Экономика: 03 дешевле baseline (~636k+77k против ~735k+97k свежих + Codex-токенов), 04 — ДОРОЖЕ (~822k+135k): экономию реализации съел + триаж семи находок. Урок: экономию эффорта считать по конвейеру целиком. +- Казус CODE-1: spec-review на high уверенно выдумал несуществующий + однострочный формат `SHA256=` в `.dgst` (реальный — четыре записи + `MD5=`/`SHA1=`/`SHA2-256=`/`SHA2-512=`); дефект уехал в тикет и код, + пойман только слепым CODE-ревью, стоил поправки тикета до триажа. + Урок: внешние факты (форматы артефактов, версии релизов) эффортом не + лечатся — проверять реальным скачиванием/фикстурой из первоисточника. +- Эмпирика кэша Codex: смена `model_reasoning_effort` high→medium при + `resume` кэш треда не сбрасывает (~93–94% кэша во всех трёх задачах). +- Казусы харнеса: рестарт сессии дважды убивал фоновый codex — тред + спасался по сохранённому thread_id; `codex exec resume` принимает + ТОЛЬКО `-c`-оверрайды (`-s`/`-m`/`-o` → exit 2); usage убитого хода + теряется (нет `turn.completed`) — фиксировать с оговоркой из resume-хода. +- Решения пользователя 2026-07-21 (поглощено: codex-exec.md — эффорт, + SKILL.md — эффорт-карта Claude-субагентов): реализация по + отревьюированному тикету → medium допустим; Claude-субагентам effort + задавать явно — xhigh постановки/планы, high слепое ревью, medium + перепроверки; ревью ниже high не опускать. From fd8210db717fd1243e9bb0273910209d84e01f04 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Tue, 21 Jul 2026 07:52:58 -0400 Subject: [PATCH 05/10] =?UTF-8?q?test(xray-proxy):=20=D0=B4=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=BF=D0=BE=D0=BB=D0=BD?= =?UTF-8?q?=D0=B0=D1=8F=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0?= =?UTF-8?q?=20Python=203.8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - требовалось подтвердить нижнюю версию Python и исключить переполнение /tmp тестовыми данными. - Что: - добавлен полный прогон под системным Python и CPython 3.8 через uv. - исправлены переносимость тестового Xray, уборка временных файлов и документация задачи 05. - добавлен список локальных навыков, исключаемых из Git. - Проверка: - bash tests/test_xray_proxy_full.sh. --- .../20260721-0343-xray-proxy-after-04.md | 74 ---------- ...0721-0743-xray-proxy-05-ready-for-human.md | 62 +++++++++ .../05-package-delivery-and-acceptance.md | 129 +++++++++++++----- .scratch/headless-xray-proxy/token-ledger.md | 6 + README.md | 11 +- agents/.agents/skills/.gitignore | 23 ++++ tests/test_install.sh | 32 ++--- tests/test_xray_proxy_full.sh | 17 +++ tests/test_xray_proxy_managed.py | 4 + xray-proxy/README.md | 3 +- 10 files changed, 234 insertions(+), 127 deletions(-) delete mode 100644 .scratch/handoffs/20260721-0343-xray-proxy-after-04.md create mode 100644 .scratch/handoffs/20260721-0743-xray-proxy-05-ready-for-human.md create mode 100644 agents/.agents/skills/.gitignore create mode 100755 tests/test_xray_proxy_full.sh diff --git a/.scratch/handoffs/20260721-0343-xray-proxy-after-04.md b/.scratch/handoffs/20260721-0343-xray-proxy-after-04.md deleted file mode 100644 index d0781e3..0000000 --- a/.scratch/handoffs/20260721-0343-xray-proxy-after-04.md +++ /dev/null @@ -1,74 +0,0 @@ -# Handoff: headless-xray-proxy — после задачи 04, перед 05 - -Дата: 2026-07-21. Одноразовый документ (правило AGENTS.md): следующая -сессия читает, продолжает, по завершении вехи удаляет и пишет новый. - -## Состояние конвейера - -- Порядок прогона: 06 (done, baseline high) → 03 (done) → 04 (done) → - **следующая — 05**. -- 04 закрыта полным конвейером и закоммичена: - `0432c4a feat(xray-proxy): добавлена проверяемая установка Xray командой install`. - Резолюция и детали — в - `.scratch/headless-xray-proxy/issues/04-verified-xray-installation.md` - (Status: done). Тесты после 04: **79 OK + 8 PASS** - (`PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests`, - `bash tests/test_proxy_launchers.sh`). -- Эксперимент «реализация на medium» завершён и зафиксирован (см. ниже); - затраты по этапам — `.scratch/headless-xray-proxy/token-ledger.md` - (итоги 03/04, сводка 06/03/04, наблюдения). - -## Решения пользователя 2026-07-21 (обязательны для 05) - -Зафиксированы в `~/.claude/skills/claude-subagent-playbook/` -(SKILL.md — эффорт-карта Claude-субагентов; references/codex-exec.md — -уточнение к правилу эффорта 2026-07-19; references/lessons.md — эмпирика -прогонов 03/04) и в памяти `codex-effort-calibration.md`. Кратко: - -- Codex: реализация по отревьюированному тикету → medium допустима; - экономию эффорта считать по конвейеру целиком. -- Claude-субагентам effort задавать ЯВНО при спавне: xhigh — ревью - постановок/планов; high — слепое TASK-ревью (ниже не опускать); - medium/default — узкие перепроверки. Внутри одного агента effort между - резюмами не менять. -- Высокий effort не страхует от выдумки внешних фактов (казус `.dgst` в - 04) — форматы артефактов/версии проверять первоисточником или фикстурой. - -## Следующий шаг: задача 05 - -`.scratch/headless-xray-proxy/issues/05-package-delivery-and-acceptance.md` -— доставка пакета и приёмка. Особенность: включает РУЧНЫЕ приёмочные -прогоны пользователя на Ubuntu / РЕД ОС / Astra / Alt (по доступности) — -планируй конвейер с точками ожидания пользователя. Старт — по команде -пользователя («поехали по 5»): постановка → Codex spec-review (high) → -переписывание тикета оркестратором → реализация (medium, если типовая) → -саморевью (resume, medium) → два слепых ревью (Opus TASK high + Codex -CODE high свежим тредом) → триаж (resume) → узкие перепроверки (medium) → -приёмка → conventional commit (русский, Зачем/Что/Проверка). - -## Механика и грабли (проверено в 03/04) - -- Codex-вызовы, шаблоны промптов, правила resume — playbook - references/codex-exec.md. Критично: `codex exec resume ` принимает - ТОЛЬКО `-c`-оверрайды; thread_id забирать из первой строки stdout-JSONL - сразу после старта; usage убитого хода теряется (нет `turn.completed`). -- Смена `model_reasoning_effort` при resume кэш Codex-треда не сбрасывает. -- Леджер: Codex — `grep '"usage"' stdout*.jsonl` (последняя строка хода), - Claude-субагенты — `subagent_tokens` из task-notification; сравнивать по - «свежий input + output». -- Scratch прогонов 03/04: `/tmp/xray-lifecycle-OZHc`, `/tmp/xray-04-H156` - (архив, для 05 создать свой каталог). -- Секреты: реальные VLESS/UUID/ключи — никогда в репо/выводы; чужие - untracked-артефакты (`zed/.config/*`, `agents/.agents/skills/*`, - `scripts/setup-xray-proxy.sh`) не трогать и не коммитить; git add — только - адресно или `-u`. - -## Suggested skills - -- `codex-subagent-playbook` — оптика делегирования; конвейер и механика - Codex — в `~/.claude/skills/claude-subagent-playbook/` (SKILL.md + - references), читать перед стартом 05. -- `conventional-commits` — для коммитов (русский, совершенный вид, - Зачем/Что/Проверка). -- `handoff` — по завершении 05 обновить этот документ (удалить, создать - новый по той же схеме). diff --git a/.scratch/handoffs/20260721-0743-xray-proxy-05-ready-for-human.md b/.scratch/handoffs/20260721-0743-xray-proxy-05-ready-for-human.md new file mode 100644 index 0000000..a3ad669 --- /dev/null +++ b/.scratch/handoffs/20260721-0743-xray-proxy-05-ready-for-human.md @@ -0,0 +1,62 @@ +# Handoff: headless-xray-proxy — задача 05 готова к ручной приёмке + +Дата: 2026-07-21 07:43 EDT. Заменяет прежние handoff задачи 05. + +## Состояние + +Тикет `.scratch/headless-xray-proxy/issues/05-package-delivery-and-acceptance.md` +переведён в `ready-for-human`. Автоматическая часть выполнена; продуктовый CLI +менять не потребовалось. + +## Что изменено + +- Корневой `README.md` описывает готовый управляемый Xray и отдельное + подключение через `stow xray-proxy`. +- `xray-proxy/README.md` явно фиксирует отсутствие systemd, автозапуска и + автоматического перехода между прокси. +- `tests/test_xray_proxy_full.sh` запускает полный набор под системным Python и + CPython 3.8 через `uv`, а также Bash-тесты и проверку установки. +- Поддельный Xray в `tests/test_xray_proxy_managed.py` сохраняет отдельный + исполняемый файл и получает доступ к библиотекам исходного Python. Это + устраняет ложные падения переносимой сборки `uv`, не ломая тест удалённого + исполняемого файла. +- `tests/test_install.sh` создаёт тестовые деревья только из отслеживаемых + Git-файлов внутри одного временного каталога и удаляет его при выходе. + +## Проверка + +`bash tests/test_xray_proxy_full.sh` завершился с кодом 0: + +- системный Python 3.14: 79 тестов, `OK`; +- Bash-интеграция: 8 проверок, `PASS`; +- установка: `All install tests passed`, остатков `dotfiles-install-test.*` в + `/tmp` нет; +- CPython 3.8.20 через `uv`: 79 тестов, `OK`. + +Двухосевое ревью относительно `92cf339`: + +- Standards: 3 документационных замечания исправлены, повторная проверка PASS; +- Spec: 2 документационных замечания исправлены, повторная проверка PASS; +- запахов кода и незапрошенного расширения области не найдено. + +## Что осталось пользователю + +Выполнить ручные команды из раздела `## Comments` тикета 05: + +1. На доступной Ubuntu с Python 3.10 — установка, жизненный цикл, + `xray run -test`, HTTP и SOCKS через один локальный вход. +2. На РЕД ОС, Astra Linux или Alt Linux по доступности — тот же сценарий после + проверки версии Python. + +В отчёт не копировать VLESS-ссылку, UUID, ключи, `shortId`, `path` и +сгенерированный JSON. Достаточно версии системы/Python, кодов возврата и итога +HTTP/SOCKS. Недоступную систему отметить как неподтверждённую. + +## После ручного результата + +1. Отметить только фактически выполненные пункты тикета 05. +2. Обновить статус исходного дизайна и заявления о поддержке. +3. Обновить или удалить этот handoff. +4. По навыку `implement` выполнить итоговую проверку и conventional commit. + +До ручной приёмки коммита задачи 05 нет. diff --git a/.scratch/headless-xray-proxy/issues/05-package-delivery-and-acceptance.md b/.scratch/headless-xray-proxy/issues/05-package-delivery-and-acceptance.md index 8f39488..c62c922 100644 --- a/.scratch/headless-xray-proxy/issues/05-package-delivery-and-acceptance.md +++ b/.scratch/headless-xray-proxy/issues/05-package-delivery-and-acceptance.md @@ -1,36 +1,103 @@ # 05 — Доставка пакета и итоговая приёмка -**What to build:** Завершённая прокси-подсистема устанавливается отдельно или -вместе с dotfiles, снабжена безопасными шаблонами и понятной инструкцией, а её -заявленные сценарии подтверждены автоматическими и доступными системными -проверками. +**What to build:** Поставка `xray-proxy` проверена целиком: корневой обзор +соответствует готовым возможностям, автоматические проверки воспроизводимы, а +ручная приёмка на доступных системах подготовлена для пользователя. -**Blocked by:** 03 — Безопасный жизненный цикл управляемого процесса; 04 — -Проверяемая пользовательская установка Xray. +**Blocked by:** 03 — безопасный жизненный цикл; 04 — проверяемая установка. -**Status:** ready-for-agent +**Status:** ready-for-human -- [ ] Самостоятельный stow-совместимый пакет доставляет CLI, Bash-модуль и - вымышленные шаблоны машинного INI и VLESS-профиля; реальные пользовательские - настройки, UUID, ссылки и ключи не входят в репозиторий. -- [ ] Пакет можно подключить отдельно через stow, эквивалентные ручные симлинки - или копирование; общий установщик использует тот же пакет без отдельной - реализации и сохраняет повторяемость установки. -- [ ] Основная инструкция объясняет внешний Hiddify и управляемый Xray, - подготовку двух файлов настройки, все команды управления, `proxy_on/off`, - `pclaude/pcodex`, `NO_PROXY`, ограничения CIDR и диагностику. -- [ ] Инструкция фиксирует две поддержанные семьи VLESS, Xray 26.3.27, - современную проверку сертификата и отсутствие systemd, автозапуска и - автоматического перехода между типами прокси. -- [ ] Корневой обзор кратко описывает подсистему, показывает подключение пакета - и направляет к полной инструкции, не дублируя её. -- [ ] Полный автоматический прогон подтверждает Python 3.8-совместимый синтаксис, - внешний Hiddify-сценарий, обе семьи VLESS, Xray 26.3.27, установку, - жизненный цикл, Bash-интеграцию и отсутствие утечки секретов. -- [ ] На доступной Ubuntu с Python 3.10 проверены установка, `xray run -test` и - работа HTTP и SOCKS через один локальный вход для Xray 26.3.27. -- [ ] На целевой удалённой системе выполнен тот же пользовательский сценарий; - версия Python в РЕД ОС проверена до заявления поддержки, а недоступная среда - явно записана как неподтверждённая вместо неподкреплённого обещания. -- [ ] После приёмки статус исходной спецификации и документация соответствуют - фактически подтверждённому объёму, включая все оставшиеся ограничения. +## Границы задачи + +Тикеты 03/04/06 уже доставили CLI, Bash-модуль, установку и жизненный цикл. +Задача 05 их проверяет, но не переписывает. Источники истины: + +- [`xray-proxy/README.md`](../../../xray-proxy/README.md) — пользовательские + команды, настройка и ограничения; +- [исходный дизайн](../../../docs/specs/2026-07-15-headless-xray-proxy-design.md) + — границы и принятые решения. + +- [x] Корневой `README.md` кратко описывает внешний Hiddify и управляемый Xray, + показывает `stow xray-proxy` и ссылается на полную инструкцию. +- [x] Пакет подключается через stow, ручные ссылки или копирование; общий + установщик использует тот же пакет. +- [x] Инструкция покрывает две семьи VLESS, Xray 26.3.27, отсутствие systemd, + автозапуска и автоматического перехода между прокси. +- [x] Тест + `ManagedXrayCliTest.test_exact_boolean_version_alpn_and_extra_branches` + подтверждает, что устаревшие `allowInsecure`/`insecure` не попадают в + TLS-конфиг, а истинное значение даёт предупреждение. + +## Секретные данные + +- [x] В отслеживаемых файлах, тестовых данных, аргументах, выводе и отчёте нет + настоящих VLESS-ссылок, UUID, ключей, `shortId` и `path`. Автоматические тесты + используют только вымышленные контрольные значения. Настоящий профиль и + сгенерированный JSON остаются в закрытых пользовательских файлах и не + копируются в отчёт. + +## Автоматическая проверка + +- [x] `tests/test_xray_proxy_full.sh` запускает весь Python-набор под системным + `python3` и CPython 3.8 через `uv`, затем Bash-тесты и проверку установки. +- [x] Тестовый Xray остаётся отдельным исполняемым файлом и получает доступ к + библиотекам исходного Python. Это устраняет ложные падения переносимой сборки + `uv`, не ослабляя проверки личности процесса. +- [x] `tests/test_install.sh` собирает небольшие тестовые деревья только из + отслеживаемых Git-файлов и удаляет общий временный каталог при выходе. + +## Приёмка пользователем + +Исполнитель готовит команды и останавливается. Результат отмечает пользователь; +недоступная система остаётся неподтверждённой. + +- [ ] Ubuntu с Python 3.10: установка, жизненный цикл, `xray run -test`, HTTP и + SOCKS через один локальный вход Xray 26.3.27. +- [ ] РЕД ОС, Astra Linux или Alt Linux по доступности: тот же сценарий после + проверки фактической версии Python. + +## После приёмки + +- [ ] Статус исходного дизайна и заявления о поддержке приведены в соответствие + только с фактически выполненными прогонами. + +## Comments + +### 2026-07-21 — автоматическая проверка + +`bash tests/test_xray_proxy_full.sh` завершился с кодом 0: + +- системный Python 3.14: 79 тестов, `OK`, 57,276 с; +- Bash-интеграция: 8 проверок, `PASS`; `shellcheck` отсутствует и явно пропущен; +- установка: `All install tests passed`; в `/tmp` не осталось каталогов + `dotfiles-install-test.*`; +- CPython 3.8.20 через `uv`: 79 тестов, `OK`, 51,412 с. + +### 2026-07-21 — команды ручной приёмки + +В первой панели tmux, после подготовки закрытого `default.url` и машинного +конфига с `type = xray`: + +```bash +python3 --version +cd ~/dotfiles +stow xray-proxy +source ~/.config/xray-proxy/shell.bash +xray-proxy install +xray-proxy start +``` + +Во второй панели, пока `start` работает: + +```bash +xray-proxy status +xray run -test -c "${XDG_STATE_HOME:-$HOME/.local/state}/xray-proxy/client.json" +curl --proxy http://127.0.0.1:12334 https://api.ipify.org +curl --proxy socks5h://127.0.0.1:12334 https://api.ipify.org +xray-proxy stop +``` + +Если настроен другой локальный адрес или порт, те же значения подставляются в +обе команды `curl`. В отчёт записываются только версии, коды возврата и итог +проверки HTTP/SOCKS. diff --git a/.scratch/headless-xray-proxy/token-ledger.md b/.scratch/headless-xray-proxy/token-ledger.md index 10cfb49..c197b15 100644 --- a/.scratch/headless-xray-proxy/token-ledger.md +++ b/.scratch/headless-xray-proxy/token-ledger.md @@ -77,6 +77,12 @@ Claude-доля — 61k суммарно (TASK-ревью + перепровер | 03 | medium | ~636k + 77k | 80.5k | 4 (гонки) | | 04 | medium | ~822k + 135k | 61k | 2 (1 — вина постановки) | +## Задача 05 — доставка пакета и приёмка + +| Этап | Агент / модель | Effort | Токены | Прочее | +|---|---|---|---|---| +| 05: ревью постановки, попытки 1–2 (убиты) | Codex gpt-5.6-sol | high | расход токенов потерян (нет `turn.completed`) | обе умерли на `Quota exceeded (os error 122)`: набор `test_install.sh` заполнял `/tmp` тестовыми деревьями по ~1,8 ГБ; потеряно ~2 и ~10 мин работы, сеансы невосстановимы из-за сбоя журнала | + ## Наблюдения - Кэш у Codex покрывает ~93% входа ревью-этапа: длинные треды с большим diff --git a/README.md b/README.md index 4dd8c00..b2e3a6e 100644 --- a/README.md +++ b/README.md @@ -98,11 +98,12 @@ export ZAI_API_KEY="..." # https://z.ai ### Прокси (`xray-proxy`) Linux/Bash-команды `proxy_on/off`, `pclaude`, `pcodex` и `myip` находятся в -самостоятельном пакете [`xray-proxy`](xray-proxy/README.md). Он читает строгий -машинный INI, проверяет доступность явно выбранного внешнего прокси и не -запускает команду при ошибке. Без конфига сохраняется внешний Hiddify -`hproxy.local:12334`. Управление процессом Xray будет добавлено отдельно; -`type = xray` пока даёт безопасный отказ. +самостоятельном пакете [`xray-proxy`](xray-proxy/README.md). Он поддерживает +явно выбранный внешний Hiddify или управляемый пользовательский Xray 26.3.27 с +профилями VLESS TCP/RAW + REALITY и XHTTP + TLS. Для отдельного подключения +выполните `stow xray-proxy`. Команды `xray-proxy install/start/stop/status` +работают без systemd, автозапуска и автоматического перехода между прокси. Без +конфига сохраняется внешний Hiddify `hproxy.local:12334`. Имя `hproxy.local` — прежний алиас хоста с внешним прокси. Windows и VS Code Server продолжают использовать его напрямую. diff --git a/agents/.agents/skills/.gitignore b/agents/.agents/skills/.gitignore new file mode 100644 index 0000000..e5b9030 --- /dev/null +++ b/agents/.agents/skills/.gitignore @@ -0,0 +1,23 @@ +# Matt Pocock skills +writing-great-skills/ +wayfinder/ +triage/ +to-tickets/ +to-spec/ +teach/ +tdd/ +setup-matt-pocock-skills/ +resolving-merge-conflicts/ +research/ +prototype/ +improve-codebase-architecture/ +implement/ +handoff/ +grilling/ +grill-with-docs/ +grill-me/ +domain-modeling/ +diagnosing-bugs/ +codebase-design/ +code-review/ +ask-matt/ diff --git a/tests/test_install.sh b/tests/test_install.sh index 8d6c2da..7a1b56c 100755 --- a/tests/test_install.sh +++ b/tests/test_install.sh @@ -3,6 +3,11 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# Один корневой временный каталог на прогон: тестовые деревья копируют заметную +# часть репозитория и без уборки переполняют /tmp за несколько прогонов. +TEST_TMP_ROOT="$(mktemp -d /tmp/dotfiles-install-test.XXXXXX)" +trap 'rm -rf "$TEST_TMP_ROOT"' EXIT + fail() { echo "FAIL: $*" >&2 exit 1 @@ -21,26 +26,21 @@ assert_not_exists() { setup_fixture() { local fixture_root repo_root bin_dir - fixture_root="$(mktemp -d /tmp/dotfiles-install-test.XXXXXX)" + fixture_root="$(mktemp -d "$TEST_TMP_ROOT/fixture.XXXXXX")" repo_root="$fixture_root/repo" bin_dir="$fixture_root/bin" mkdir -p "$repo_root" "$bin_dir" - cp -a \ - "$REPO_ROOT/install.sh" \ - "$REPO_ROOT/bash" \ - "$REPO_ROOT/tmux" \ - "$REPO_ROOT/vim" \ - "$REPO_ROOT/zed" \ - "$REPO_ROOT/vscode" \ - "$REPO_ROOT/agents" \ - "$REPO_ROOT/opencode" \ - "$REPO_ROOT/codex" \ - "$REPO_ROOT/gemini" \ - "$REPO_ROOT/claude" \ - "$REPO_ROOT/xray-proxy" \ - "$REPO_ROOT/scripts" \ - "$repo_root/" + # Список файлов соответствует свежему клону: только отслеживаемые Git-файлы + # (новые файлы видны после git add). Содержимое берётся из текущего рабочего + # дерева. Локальные данные инструментов внутри пакетов (vscode/.vscode-server, + # zed/.config/opencode и т.п.) весят гигабайты, переполняют /tmp и ломают + # stow конфликтами, поэтому в тестовое дерево не попадает. + git -C "$REPO_ROOT" ls-files -z --cached -- \ + install.sh bash tmux vim zed vscode agents opencode codex gemini \ + claude xray-proxy scripts \ + | tar -C "$REPO_ROOT" --null -T - -cf - \ + | tar -xf - -C "$repo_root" cat > "$bin_dir/fake-git" <<'EOF' #!/usr/bin/env bash diff --git a/tests/test_xray_proxy_full.sh b/tests/test_xray_proxy_full.sh new file mode 100755 index 0000000..4f2ca36 --- /dev/null +++ b/tests/test_xray_proxy_full.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +if ! command -v uv >/dev/null 2>&1; then + echo "Ошибка: для полной проверки требуется uv" >&2 + exit 1 +fi + +export PYTHONDONTWRITEBYTECODE=1 + +python3 -m unittest discover -s tests +bash tests/test_proxy_launchers.sh +bash tests/test_install.sh +uv run --python 3.8 --no-project python -m unittest discover -s tests diff --git a/tests/test_xray_proxy_managed.py b/tests/test_xray_proxy_managed.py index e431d5b..d3bdf81 100644 --- a/tests/test_xray_proxy_managed.py +++ b/tests/test_xray_proxy_managed.py @@ -166,6 +166,10 @@ class ManagedXrayCliTest(unittest.TestCase): self.bin_dir.mkdir() self.log = self.root / "xray.log" self.xray = self.bin_dir / "xray" + python_prefix = Path(sys.executable).resolve().parent.parent + python_lib = python_prefix / "lib" + if python_lib.is_dir(): + (self.root / "lib").symlink_to(python_lib, target_is_directory=True) shutil.copy2(sys.executable, self.xray) self.xray.chmod(0o755) for operand in ("version", "run"): diff --git a/xray-proxy/README.md b/xray-proxy/README.md index b151419..3e73d8b 100644 --- a/xray-proxy/README.md +++ b/xray-proxy/README.md @@ -132,7 +132,8 @@ PID не принимается. `run` никогда не запускает Xr отдельную панель tmux. После готовности один loopback-порт принимает HTTP и SOCKS. `stop` ограниченно ждёт завершения супервизора и Xray, очищает служебные файлы и возвращает исходный `start` в терминал. Команда `stop` работает и после -явного переключения машинного типа на `external`. +явного переключения машинного типа на `external`. CLI не создаёт systemd-службу, +не запускает Xray автоматически и не переключается между типами прокси. `run` передаёт восемь proxy-переменных только дочернему процессу и сохраняет аргументы и код возврата. `proxy_on` атомарно применяет те же восемь переменных From e5323284a8959d89233f7e5a7e14181d93ce3707 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Tue, 21 Jul 2026 08:29:52 -0400 Subject: [PATCH 06/10] =?UTF-8?q?fix(install):=20=D0=B8=D1=81=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=BE=20=D0=B2=D0=BB=D0=B0=D0=B4?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=B0=D1=82=D0=B0=D0=BB=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=D0=BC=20.config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Зачем: - GNU Stow сворачивал весь ~/.config в пакет zed и помещал рабочие настройки в репозиторий. Что: - добавлена ограниченная миграция старой ссылки с возвратом данных в домашний каталог; - предотвращено повторное сворачивание ~/.config на свежей установке; - добавлены проверки сохранности профиля, повторного запуска и чужих ссылок. Проверка: - bash tests/test_install.sh - bash -n install.sh tests/test_install.sh - git diff --check --- ...0721-0743-xray-proxy-05-ready-for-human.md | 86 ++++++------- install.sh | 77 ++++++++++++ tests/test_install.sh | 116 +++++++++++++++++- 3 files changed, 231 insertions(+), 48 deletions(-) diff --git a/.scratch/handoffs/20260721-0743-xray-proxy-05-ready-for-human.md b/.scratch/handoffs/20260721-0743-xray-proxy-05-ready-for-human.md index a3ad669..5264541 100644 --- a/.scratch/handoffs/20260721-0743-xray-proxy-05-ready-for-human.md +++ b/.scratch/handoffs/20260721-0743-xray-proxy-05-ready-for-human.md @@ -1,62 +1,54 @@ -# Handoff: headless-xray-proxy — задача 05 готова к ручной приёмке +# Handoff: владение `~/.config` исправлено, задача 05 готова к приёмке -Дата: 2026-07-21 07:43 EDT. Заменяет прежние handoff задачи 05. +Обновлён: 2026-07-21 08:18 EDT. -## Состояние +## Состояние задачи 05 +Автоматическая часть `headless-xray-proxy` зафиксирована коммитом `fd8210d`. Тикет `.scratch/headless-xray-proxy/issues/05-package-delivery-and-acceptance.md` -переведён в `ready-for-human`. Автоматическая часть выполнена; продуктовый CLI -менять не потребовалось. +находится в `ready-for-human`; результаты тестов и команды ручной приёмки уже +записаны там. Дальше можно продолжать ручную приёмку по этому тикету. -## Что изменено +## Исправленный дефект stow -- Корневой `README.md` описывает готовый управляемый Xray и отдельное - подключение через `stow xray-proxy`. -- `xray-proxy/README.md` явно фиксирует отсутствие systemd, автозапуска и - автоматического перехода между прокси. -- `tests/test_xray_proxy_full.sh` запускает полный набор под системным Python и - CPython 3.8 через `uv`, а также Bash-тесты и проверку установки. -- Поддельный Xray в `tests/test_xray_proxy_managed.py` сохраняет отдельный - исполняемый файл и получает доступ к библиотекам исходного Python. Это - устраняет ложные падения переносимой сборки `uv`, не ломая тест удалённого - исполняемого файла. -- `tests/test_install.sh` создаёт тестовые деревья только из отслеживаемых - Git-файлов внутри одного временного каталога и удаляет его при выходе. +Корневая причина была в сворачивании GNU Stow: в пустом домашнем каталоге пакет +`zed` создавал общую ссылку `~/.config -> ~/dotfiles/zed/.config`. После этого +рабочие настройки приложений и закрытый профиль Xray физически попадали внутрь +репозитория. -## Проверка +`install.sh` теперь: -`bash tests/test_xray_proxy_full.sh` завершился с кодом 0: +- заранее создаёт настоящий каталог `~/.config`, поэтому свежая установка + создаёт только ссылку `~/.config/zed`; +- распознаёт старую ссылку лишь при точном совпадении с + `$DOTFILES_DIR/zed/.config`; +- оставляет управляемый каталог `zed` в пакете, а остальные верхнеуровневые + элементы переносит в настоящий `~/.config`; +- до первого переноса проверяет, что смог полностью перечислить данные; +- при штатной ошибке переноса возвращает уже перемещённые элементы назад; +- не раскрывает произвольные ссылки `~/.config`. -- системный Python 3.14: 79 тестов, `OK`; -- Bash-интеграция: 8 проверок, `PASS`; -- установка: `All install tests passed`, остатков `dotfiles-install-test.*` в - `/tmp` нет; -- CPython 3.8.20 через `uv`: 79 тестов, `OK`. +В `tests/test_install.sh` добавлены проверки свежей установки, миграции с +вымышленным секретом, сохранения прав `600`, отсутствия секрета в выводе, +повторного запуска и неприкосновенности произвольной ссылки. -Двухосевое ревью относительно `92cf339`: +## Проверка и живое состояние -- Standards: 3 документационных замечания исправлены, повторная проверка PASS; -- Spec: 2 документационных замечания исправлены, повторная проверка PASS; -- запахов кода и незапрошенного расширения области не найдено. +Полный набор `bash tests/test_xray_proxy_full.sh` прошёл на системном Python и +Python 3.8 через `uv`: по 79 Python-тестов, 8 проверок командной оболочки и тесты +установщика. Синтаксис сценариев командной оболочки и `git diff --check` также +прошли. -## Что осталось пользователю +Миграция применена к текущему домашнему каталогу: -Выполнить ручные команды из раздела `## Comments` тикета 05: +- `~/.config` — настоящий каталог; +- `~/.config/zed` указывает на пакет Zed; +- в `zed/.config` остался только каталог `zed`; +- рабочие каталоги приложений находятся в `~/.config`; +- `~/.config/xray-proxy/default.url` — обычный файл вне репозитория, сохранил + права `600` и одну непустую строку. Содержимое профиля не выводилось. -1. На доступной Ubuntu с Python 3.10 — установка, жизненный цикл, - `xray run -test`, HTTP и SOCKS через один локальный вход. -2. На РЕД ОС, Astra Linux или Alt Linux по доступности — тот же сценарий после - проверки версии Python. +## Текущее состояние Git -В отчёт не копировать VLESS-ссылку, UUID, ключи, `shortId`, `path` и -сгенерированный JSON. Достаточно версии системы/Python, кодов возврата и итога -HTTP/SOCKS. Недоступную систему отметить как неподтверждённую. - -## После ручного результата - -1. Отметить только фактически выполненные пункты тикета 05. -2. Обновить статус исходного дизайна и заявления о поддержке. -3. Обновить или удалить этот handoff. -4. По навыку `implement` выполнить итоговую проверку и conventional commit. - -До ручной приёмки коммита задачи 05 нет. +Не включать в коммиты посторонние неотслеживаемые +`agents/.agents/.skill-lock.json` и `scripts/setup-xray-proxy.sh`. diff --git a/install.sh b/install.sh index 10701f1..4b57e51 100755 --- a/install.sh +++ b/install.sh @@ -169,6 +169,77 @@ migrate_claude_stow_symlink() { fi } +# Миграция: раскрыть старую stow-ссылку ~/.config -> zed/.config. +# В пакете zed остаётся только управляемый каталог zed; остальные настройки +# возвращаются в домашний каталог и больше не попадают в репозиторий. +migrate_zed_config_stow_symlink() { + local config_path="$HOME/.config" + local zed_config="$DOTFILES_DIR/zed/.config" + local config_real="" + local zed_config_real="" + local replacement="" + local items_file="" + local item="" + local base="" + local moved_items=() + + [ -L "$config_path" ] || return 0 + + config_real="$(readlink -f "$config_path" 2>/dev/null || true)" + zed_config_real="$(readlink -f "$zed_config" 2>/dev/null || true)" + if [ -z "$zed_config_real" ] || [ "$config_real" != "$zed_config_real" ]; then + return 0 + fi + + info "Миграция: ~/.config больше не должен принадлежать пакету zed..." + replacement="$(mktemp -d "$HOME/.config.migrate.XXXXXX")" + chmod --reference="$zed_config_real" "$replacement" + + items_file="$(mktemp "$HOME/.config.migrate-items.XXXXXX")" + if ! find "$zed_config_real" -mindepth 1 -maxdepth 1 ! -name zed -print0 > "$items_file"; then + error "Не удалось получить полный список старого ~/.config" + rm -f -- "$items_file" + rmdir "$replacement" 2>/dev/null || true + return 1 + fi + + while IFS= read -r -d '' item; do + base="${item##*/}" + if ! mv -- "$item" "$replacement/$base"; then + error "Не удалось перенести ~/.config/$base" + for base in "${moved_items[@]}"; do + mv -- "$replacement/$base" "$zed_config_real/$base" 2>/dev/null || true + done + rm -f -- "$items_file" + rmdir "$replacement" 2>/dev/null || true + return 1 + fi + moved_items+=("$base") + done < "$items_file" + rm -f -- "$items_file" + + if ! rm -- "$config_path"; then + error "Не удалось удалить старую ссылку ~/.config" + for base in "${moved_items[@]}"; do + mv -- "$replacement/$base" "$zed_config_real/$base" 2>/dev/null || true + done + rmdir "$replacement" 2>/dev/null || true + return 1 + fi + + if ! mv -- "$replacement" "$config_path"; then + error "Не удалось создать новый каталог ~/.config" + ln -s "$zed_config_real" "$config_path" 2>/dev/null || true + for base in "${moved_items[@]}"; do + mv -- "$replacement/$base" "$zed_config_real/$base" 2>/dev/null || true + done + rmdir "$replacement" 2>/dev/null || true + return 1 + fi + + info "Настройки перенесены из пакета zed в ~/.config" +} + # Настроить boot command для hproxy.local в /etc/wsl.conf. setup_wsl_hproxy() { [ -n "${WSL_DISTRO_NAME:-}" ] || return 0 @@ -244,6 +315,12 @@ for pkg in "${PACKAGES[@]}"; do fi done +migrate_zed_config_stow_symlink + +if [ ! -L "$HOME/.config" ]; then + mkdir -p "$HOME/.config" +fi + if [ "${#AVAILABLE_PACKAGES[@]}" -gt 0 ]; then stow_package "${AVAILABLE_PACKAGES[@]}" fi diff --git a/tests/test_install.sh b/tests/test_install.sh index 7a1b56c..eb16bb7 100755 --- a/tests/test_install.sh +++ b/tests/test_install.sh @@ -94,7 +94,7 @@ test_fresh_install_uses_home_target() { home_dir="$fixture/home" mkdir -p "$home_dir" - run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >/tmp/test-install.out + run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >"$fixture/install.out" [ -L "$home_dir/.bash_custom" ] || fail "Expected .bash_custom symlink in HOME" [ -x "$home_dir/.local/bin/xray-proxy" ] || fail "Expected executable xray-proxy CLI in HOME" @@ -105,6 +105,116 @@ test_fresh_install_uses_home_target() { assert_not_exists "$fixture/.bash_custom" } +test_fresh_install_does_not_fold_config_into_zed() { + local fixture repo_root home_dir target expected + fixture="$(setup_fixture)" + repo_root="$fixture/repo" + home_dir="$fixture/home" + mkdir -p "$home_dir" + + # Неполный клон: zed остаётся единственным stow-пакетом с каталогом .config. + mv "$repo_root/xray-proxy" "$fixture/xray-proxy-disabled" + + run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >"$fixture/install.out" + + [ -d "$home_dir/.config" ] || fail "Expected ~/.config directory" + [ ! -L "$home_dir/.config" ] || fail "Expected ~/.config to stay a real directory" + [ -L "$home_dir/.config/zed" ] || fail "Expected ~/.config/zed symlink" + target="$(readlink -f "$home_dir/.config/zed")" + expected="$(readlink -f "$repo_root/zed/.config/zed")" + [ "$target" = "$expected" ] || fail "~/.config/zed points to $target, expected $expected" +} + +test_migrates_legacy_zed_config_symlink() { + local fixture repo_root home_dir legacy_config output mode + fixture="$(setup_fixture)" + repo_root="$fixture/repo" + home_dir="$fixture/home" + legacy_config="$repo_root/zed/.config" + mkdir -p "$home_dir" "$legacy_config/btop" "$legacy_config/xray-proxy" + printf '%s\n' 'btop settings' > "$legacy_config/btop/btop.conf" + printf '%s\n' 'VLESS_PROFILE_SENTINEL' > "$legacy_config/xray-proxy/default.url" + chmod 600 "$legacy_config/xray-proxy/default.url" + ln -s "$legacy_config" "$home_dir/.config" + + if ! output="$( + run_install "$repo_root" "$home_dir" \ + env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never + )"; then + fail "Expected legacy ~/.config migration to complete" + fi + + [ -d "$home_dir/.config" ] || fail "Expected migrated ~/.config directory" + [ ! -L "$home_dir/.config" ] || fail "Expected migrated ~/.config to stop being a symlink" + [ -L "$home_dir/.config/zed" ] || fail "Expected managed ~/.config/zed symlink" + [ -d "$home_dir/.config/btop" ] || fail "Expected btop config to be migrated" + [ ! -L "$home_dir/.config/btop" ] || fail "Expected btop config outside the repository" + [ -f "$home_dir/.config/xray-proxy/default.url" ] || fail "Expected profile to be migrated" + [ ! -L "$home_dir/.config/xray-proxy/default.url" ] || fail "Expected profile outside the repository" + mode="$(stat -c '%a' "$home_dir/.config/xray-proxy/default.url")" + [ "$mode" = "600" ] || fail "Expected profile mode 600, got $mode" + assert_file_contains "$home_dir/.config/xray-proxy/default.url" "VLESS_PROFILE_SENTINEL" + assert_not_exists "$legacy_config/btop" + assert_not_exists "$legacy_config/xray-proxy" + if printf '%s' "$output" | grep -Fq 'VLESS_PROFILE_SENTINEL'; then + fail "Secret profile leaked into install output" + fi + + run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >"$fixture/install.out" + assert_file_contains "$home_dir/.config/xray-proxy/default.url" "VLESS_PROFILE_SENTINEL" +} + +test_stops_legacy_migration_when_listing_fails() { + local fixture repo_root home_dir legacy_config target expected + fixture="$(setup_fixture)" + repo_root="$fixture/repo" + home_dir="$fixture/home" + legacy_config="$repo_root/zed/.config" + mkdir -p "$home_dir" "$legacy_config/xray-proxy" + printf '%s\n' 'VLESS_PROFILE_SENTINEL' > "$legacy_config/xray-proxy/default.url" + ln -s "$legacy_config" "$home_dir/.config" + + cat > "$fixture/bin/find" <<'EOF' +#!/usr/bin/env bash +exit 7 +EOF + chmod +x "$fixture/bin/find" + + if run_install "$repo_root" "$home_dir" \ + env PATH="$fixture/bin:$PATH" WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never \ + >"$fixture/install.out" 2>&1; then + fail "Expected migration to stop when legacy config listing fails" + fi + + [ -L "$home_dir/.config" ] || fail "Expected legacy ~/.config symlink after listing failure" + target="$(readlink -f "$home_dir/.config")" + expected="$(readlink -f "$legacy_config")" + [ "$target" = "$expected" ] || fail "Legacy ~/.config target changed after listing failure" + assert_file_contains "$legacy_config/xray-proxy/default.url" "VLESS_PROFILE_SENTINEL" +} + +test_does_not_migrate_unrelated_config_symlink() { + local fixture repo_root home_dir unrelated_config target expected + fixture="$(setup_fixture)" + repo_root="$fixture/repo" + home_dir="$fixture/home" + unrelated_config="$fixture/unrelated-config" + mkdir -p "$home_dir" "$unrelated_config" + printf '%s\n' 'keep me' > "$unrelated_config/local.conf" + ln -s "$unrelated_config" "$home_dir/.config" + + # Stow вправе сообщить о конфликте, но установщик не должен раскрывать + # произвольную ссылку и переносить чужие данные. + run_install "$repo_root" "$home_dir" \ + env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >"$fixture/install.out" 2>&1 || true + + [ -L "$home_dir/.config" ] || fail "Expected unrelated ~/.config symlink to be preserved" + target="$(readlink -f "$home_dir/.config")" + expected="$(readlink -f "$unrelated_config")" + [ "$target" = "$expected" ] || fail "Unrelated ~/.config symlink target changed" + assert_file_contains "$unrelated_config/local.conf" "keep me" +} + test_settings_local_is_symlinked_to_repo() { local fixture repo_root home_dir fixture="$(setup_fixture)" @@ -385,6 +495,10 @@ main() { command -v stow >/dev/null 2>&1 || fail "stow is required for tests" test_fresh_install_uses_home_target + test_fresh_install_does_not_fold_config_into_zed + test_migrates_legacy_zed_config_symlink + test_stops_legacy_migration_when_listing_fails + test_does_not_migrate_unrelated_config_symlink test_settings_local_is_symlinked_to_repo test_settings_local_backup_on_existing_file test_settings_local_dir_is_skipped From 6e431d875e974eb06875152e696bc4b732fd9345 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Tue, 21 Jul 2026 09:31:21 -0400 Subject: [PATCH 07/10] =?UTF-8?q?refactor(proxy-client):=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B8=D0=BC=D0=B5=D0=BD=D0=BE=D0=B2=D0=B0=D0=BD=20?= =?UTF-8?q?=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D1=84=D0=B5=D0=B9=D1=81=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=BA=D1=81=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - прежнее имя выпячивало Xray, хотя клиент поддерживает также внешний прокси, а два каталога конфигурации были неочевидны. - Что: - пакет, CLI, конфигурация, состояние, тесты и документация переименованы в proxy-client. - машинный конфиг, профиль и два явных примера собраны в одном пользовательском каталоге. - свежая установка защищена от сворачивания каталога конфигурации внутрь репозитория. - Проверка: - bash tests/test_proxy_client_full.sh. - bash tests/test_install.sh. - ручные start, status и xray run -test; незавершённая сетевая приёмка записана в handoff. --- ...21-0743-proxy-client-05-ready-for-human.md | 87 +++++++++++++++++++ ...0721-0743-xray-proxy-05-ready-for-human.md | 54 ------------ .../01-external-proxy-and-portable-shell.md | 0 .../issues/02-managed-xray-happy-path.md | 0 .../issues/03-safe-process-lifecycle.md | 0 .../issues/04-verified-xray-installation.md | 4 +- .../05-package-delivery-and-acceptance.md | 45 ++++++---- .../issues/06-simplification-pass.md | 10 +-- .../token-ledger.md | 2 +- README.md | 13 +-- bash/.bash_custom | 2 +- ...n.md => 2026-07-15-proxy-client-design.md} | 63 ++++++++------ install.sh | 6 +- .../proxy-client/examples/config.external | 0 .../.config/proxy-client/examples/config.xray | 6 ++ .../proxy-client}/examples/default.url | 0 .../.config/proxy-client}/shell.bash | 12 +-- .../.local/bin/proxy-client | 50 ++++++----- .../.stow-local-ignore | 0 {xray-proxy => proxy-client}/README.md | 47 +++++----- tests/test_install.sh | 38 ++++---- ...est_xray_proxy.py => test_proxy_client.py} | 28 +++--- ...roxy_full.sh => test_proxy_client_full.sh} | 0 ...anaged.py => test_proxy_client_managed.py} | 64 +++++++------- tests/test_proxy_launchers.sh | 6 +- 25 files changed, 310 insertions(+), 227 deletions(-) create mode 100644 .scratch/handoffs/20260721-0743-proxy-client-05-ready-for-human.md delete mode 100644 .scratch/handoffs/20260721-0743-xray-proxy-05-ready-for-human.md rename .scratch/{headless-xray-proxy => proxy-client}/issues/01-external-proxy-and-portable-shell.md (100%) rename .scratch/{headless-xray-proxy => proxy-client}/issues/02-managed-xray-happy-path.md (100%) rename .scratch/{headless-xray-proxy => proxy-client}/issues/03-safe-process-lifecycle.md (100%) rename .scratch/{headless-xray-proxy => proxy-client}/issues/04-verified-xray-installation.md (98%) rename .scratch/{headless-xray-proxy => proxy-client}/issues/05-package-delivery-and-acceptance.md (70%) rename .scratch/{headless-xray-proxy => proxy-client}/issues/06-simplification-pass.md (97%) rename .scratch/{headless-xray-proxy => proxy-client}/token-ledger.md (99%) rename docs/specs/{2026-07-15-headless-xray-proxy-design.md => 2026-07-15-proxy-client-design.md} (91%) rename xray-proxy/.config/xray-proxy/examples/config => proxy-client/.config/proxy-client/examples/config.external (100%) create mode 100644 proxy-client/.config/proxy-client/examples/config.xray rename {xray-proxy/.config/xray-proxy => proxy-client/.config/proxy-client}/examples/default.url (100%) rename {xray-proxy/.config/xray-proxy => proxy-client/.config/proxy-client}/shell.bash (80%) rename xray-proxy/.local/bin/xray-proxy => proxy-client/.local/bin/proxy-client (97%) rename {xray-proxy => proxy-client}/.stow-local-ignore (100%) rename {xray-proxy => proxy-client}/README.md (87%) rename tests/{test_xray_proxy.py => test_proxy_client.py} (98%) rename tests/{test_xray_proxy_full.sh => test_proxy_client_full.sh} (100%) rename tests/{test_xray_proxy_managed.py => test_proxy_client_managed.py} (95%) diff --git a/.scratch/handoffs/20260721-0743-proxy-client-05-ready-for-human.md b/.scratch/handoffs/20260721-0743-proxy-client-05-ready-for-human.md new file mode 100644 index 0000000..a62ba66 --- /dev/null +++ b/.scratch/handoffs/20260721-0743-proxy-client-05-ready-for-human.md @@ -0,0 +1,87 @@ +# Handoff: `proxy-client` переименован, сетевой маршрут требует диагностики + +Обновлён: 2026-07-21 09:23 EDT. + +## Состояние задачи 05 + +Автоматическая часть готова. Публичное имя изменено с `xray-proxy` на +`proxy-client`, а ручная приёмка дошла до реального сетевого запроса. Xray +запускается и принимает локальные соединения, но удалённый маршрут пока не +работает. Статус тикета остаётся `ready-for-human`. + +Тикет: `.scratch/proxy-client/issues/05-package-delivery-and-acceptance.md`. + +## Переименование + +- stow-пакет: `proxy-client/`; +- CLI: `~/.local/bin/proxy-client`; +- единый пользовательский каталог: `~/.config/proxy-client/`; +- служебный каталог: `~/.local/state/proxy-client/`; +- машинный конфиг и закрытый профиль теперь лежат рядом как `config` и + `default.url`; +- примеры называются `config.external` и `config.xray`; +- имена тестов, спецификации, задач, README и Bash-модуля приведены к + `proxy-client`; +- `Xray`, бинарник `xray` и значение `type = xray` сохранены как названия + конкретного движка. + +Поддержка старой раскладки в `install.sh` намеренно не добавлена: публичное имя +изменено до завершения разработки и выпуска. Текущий HOME уже приведён к новой +структуре. Свежая установка заранее создаёт настоящий +`~/.config/proxy-client`, поэтому Stow не может свернуть его в ссылку на +репозиторий. + +## Автоматическая проверка + +`bash tests/test_proxy_client_full.sh` завершился с кодом 0 после +переименования: + +- системный Python 3.14: 79 тестов, `OK`, 61,034 с; +- Bash-интеграция: 8 проверок, `PASS`; `shellcheck` не установлен; +- установка и миграция: `All install tests passed`; +- CPython 3.8 через `uv`: 79 тестов, `OK`, 52,446 с. + +Дополнительно прошли синтаксическая проверка shell-файлов и `git diff --check`. + +## Текущий HOME + +Во время разработки текущий HOME приведён к новой структуре: + +- `~/.config/proxy-client` — настоящий каталог; +- `config` и `default.url` — обычные файлы с правами `600`; +- профиль содержит одну непустую строку, его содержимое не выводилось; +- `shell.bash`, `examples` и CLI указывают в новый stow-пакет; +- старые `~/.config/xray-proxy`, `~/.config/proxy/config`, + `~/.local/bin/xray-proxy` и `~/.local/state/xray-proxy` отсутствуют. + +## Ручная приёмка + +Подтверждено: + +- установлен Xray 26.3.27; +- `proxy-client start` успешно проверяет версию, строит JSON и запускает Xray; +- `proxy-client status` видит управляемый процесс на `127.0.0.1:12334`; +- `xray run -test` сообщает `Configuration OK`; +- после проверки процесс штатно остановлен командой `proxy-client stop`. + +Не прошло: + +- встроенное определение внешнего IP; +- HTTP через `127.0.0.1:12334`; +- SOCKS5h через тот же порт. + +Оба запроса `curl` завершились кодом 35: TLS-соединение неожиданно закрылось во +время рукопожатия. Внешний IP и содержимое профиля в вывод не попадали. + +## Следующий шаг + +Диагностировать сетевой маршрут XHTTP/TLS: сравнить профиль с рабочим клиентом, +проверить доступность удалённого узла и выяснить, связан ли EOF с преобразованием +профиля, параметрами XHTTP/TLS или удалённой стороной. До этого не отмечать +ручную HTTP/SOCKS-приёмку выполненной. + +## Git + +Переименование и текущий handoff ещё не закоммичены. Не включать посторонние +неотслеживаемые `agents/.agents/.skill-lock.json` и +`scripts/setup-xray-proxy.sh`. diff --git a/.scratch/handoffs/20260721-0743-xray-proxy-05-ready-for-human.md b/.scratch/handoffs/20260721-0743-xray-proxy-05-ready-for-human.md deleted file mode 100644 index 5264541..0000000 --- a/.scratch/handoffs/20260721-0743-xray-proxy-05-ready-for-human.md +++ /dev/null @@ -1,54 +0,0 @@ -# Handoff: владение `~/.config` исправлено, задача 05 готова к приёмке - -Обновлён: 2026-07-21 08:18 EDT. - -## Состояние задачи 05 - -Автоматическая часть `headless-xray-proxy` зафиксирована коммитом `fd8210d`. -Тикет `.scratch/headless-xray-proxy/issues/05-package-delivery-and-acceptance.md` -находится в `ready-for-human`; результаты тестов и команды ручной приёмки уже -записаны там. Дальше можно продолжать ручную приёмку по этому тикету. - -## Исправленный дефект stow - -Корневая причина была в сворачивании GNU Stow: в пустом домашнем каталоге пакет -`zed` создавал общую ссылку `~/.config -> ~/dotfiles/zed/.config`. После этого -рабочие настройки приложений и закрытый профиль Xray физически попадали внутрь -репозитория. - -`install.sh` теперь: - -- заранее создаёт настоящий каталог `~/.config`, поэтому свежая установка - создаёт только ссылку `~/.config/zed`; -- распознаёт старую ссылку лишь при точном совпадении с - `$DOTFILES_DIR/zed/.config`; -- оставляет управляемый каталог `zed` в пакете, а остальные верхнеуровневые - элементы переносит в настоящий `~/.config`; -- до первого переноса проверяет, что смог полностью перечислить данные; -- при штатной ошибке переноса возвращает уже перемещённые элементы назад; -- не раскрывает произвольные ссылки `~/.config`. - -В `tests/test_install.sh` добавлены проверки свежей установки, миграции с -вымышленным секретом, сохранения прав `600`, отсутствия секрета в выводе, -повторного запуска и неприкосновенности произвольной ссылки. - -## Проверка и живое состояние - -Полный набор `bash tests/test_xray_proxy_full.sh` прошёл на системном Python и -Python 3.8 через `uv`: по 79 Python-тестов, 8 проверок командной оболочки и тесты -установщика. Синтаксис сценариев командной оболочки и `git diff --check` также -прошли. - -Миграция применена к текущему домашнему каталогу: - -- `~/.config` — настоящий каталог; -- `~/.config/zed` указывает на пакет Zed; -- в `zed/.config` остался только каталог `zed`; -- рабочие каталоги приложений находятся в `~/.config`; -- `~/.config/xray-proxy/default.url` — обычный файл вне репозитория, сохранил - права `600` и одну непустую строку. Содержимое профиля не выводилось. - -## Текущее состояние Git - -Не включать в коммиты посторонние неотслеживаемые -`agents/.agents/.skill-lock.json` и `scripts/setup-xray-proxy.sh`. diff --git a/.scratch/headless-xray-proxy/issues/01-external-proxy-and-portable-shell.md b/.scratch/proxy-client/issues/01-external-proxy-and-portable-shell.md similarity index 100% rename from .scratch/headless-xray-proxy/issues/01-external-proxy-and-portable-shell.md rename to .scratch/proxy-client/issues/01-external-proxy-and-portable-shell.md diff --git a/.scratch/headless-xray-proxy/issues/02-managed-xray-happy-path.md b/.scratch/proxy-client/issues/02-managed-xray-happy-path.md similarity index 100% rename from .scratch/headless-xray-proxy/issues/02-managed-xray-happy-path.md rename to .scratch/proxy-client/issues/02-managed-xray-happy-path.md diff --git a/.scratch/headless-xray-proxy/issues/03-safe-process-lifecycle.md b/.scratch/proxy-client/issues/03-safe-process-lifecycle.md similarity index 100% rename from .scratch/headless-xray-proxy/issues/03-safe-process-lifecycle.md rename to .scratch/proxy-client/issues/03-safe-process-lifecycle.md diff --git a/.scratch/headless-xray-proxy/issues/04-verified-xray-installation.md b/.scratch/proxy-client/issues/04-verified-xray-installation.md similarity index 98% rename from .scratch/headless-xray-proxy/issues/04-verified-xray-installation.md rename to .scratch/proxy-client/issues/04-verified-xray-installation.md index dfc4593..6034a93 100644 --- a/.scratch/headless-xray-proxy/issues/04-verified-xray-installation.md +++ b/.scratch/proxy-client/issues/04-verified-xray-installation.md @@ -93,7 +93,7 @@ Ubuntu / РЕД ОС / Astra / Alt. и state не тронуты; - `shutil.which`-проверка: отсутствие каталога в PATH и конкурирующий более ранний `xray` в PATH. -- [x] `xray-proxy/README.md` обновлён: одна поддержанная версия 26.3.27 +- [x] `proxy-client/README.md` обновлён: одна поддержанная версия 26.3.27 (упоминания 25.12.8 удалены), использование `install [--replace]`, маппинг x86_64/aarch64, SHA-256-проверка, поведение PATH, отказ при живом managed-экземпляре. @@ -141,4 +141,4 @@ TASK: 3 NIT) → триаж (FIXED 7, REJECTED 0) → узкие перепро `allowInsecure` — 25.12.8, опция удалена в 26.2.6; CLI их не ставит. Тесты: 79 OK (baseline 49, +30) + 8 PASS shell-проверок. Затраты — в -`.scratch/headless-xray-proxy/token-ledger.md`. +`.scratch/proxy-client/token-ledger.md`. diff --git a/.scratch/headless-xray-proxy/issues/05-package-delivery-and-acceptance.md b/.scratch/proxy-client/issues/05-package-delivery-and-acceptance.md similarity index 70% rename from .scratch/headless-xray-proxy/issues/05-package-delivery-and-acceptance.md rename to .scratch/proxy-client/issues/05-package-delivery-and-acceptance.md index c62c922..d46a1dc 100644 --- a/.scratch/headless-xray-proxy/issues/05-package-delivery-and-acceptance.md +++ b/.scratch/proxy-client/issues/05-package-delivery-and-acceptance.md @@ -1,6 +1,6 @@ # 05 — Доставка пакета и итоговая приёмка -**What to build:** Поставка `xray-proxy` проверена целиком: корневой обзор +**What to build:** Поставка `proxy-client` проверена целиком: корневой обзор соответствует готовым возможностям, автоматические проверки воспроизводимы, а ручная приёмка на доступных системах подготовлена для пользователя. @@ -13,13 +13,13 @@ Тикеты 03/04/06 уже доставили CLI, Bash-модуль, установку и жизненный цикл. Задача 05 их проверяет, но не переписывает. Источники истины: -- [`xray-proxy/README.md`](../../../xray-proxy/README.md) — пользовательские +- [`proxy-client/README.md`](../../../proxy-client/README.md) — пользовательские команды, настройка и ограничения; -- [исходный дизайн](../../../docs/specs/2026-07-15-headless-xray-proxy-design.md) +- [исходный дизайн](../../../docs/specs/2026-07-15-proxy-client-design.md) — границы и принятые решения. - [x] Корневой `README.md` кратко описывает внешний Hiddify и управляемый Xray, - показывает `stow xray-proxy` и ссылается на полную инструкцию. + показывает `stow proxy-client` и ссылается на полную инструкцию. - [x] Пакет подключается через stow, ручные ссылки или копирование; общий установщик использует тот же пакет. - [x] Инструкция покрывает две семьи VLESS, Xray 26.3.27, отсутствие systemd, @@ -39,7 +39,7 @@ ## Автоматическая проверка -- [x] `tests/test_xray_proxy_full.sh` запускает весь Python-набор под системным +- [x] `tests/test_proxy_client_full.sh` запускает весь Python-набор под системным `python3` и CPython 3.8 через `uv`, затем Bash-тесты и проверку установки. - [x] Тестовый Xray остаётся отдельным исполняемым файлом и получает доступ к библиотекам исходного Python. Это устраняет ложные падения переносимой сборки @@ -66,13 +66,13 @@ ### 2026-07-21 — автоматическая проверка -`bash tests/test_xray_proxy_full.sh` завершился с кодом 0: +`bash tests/test_proxy_client_full.sh` завершился с кодом 0: -- системный Python 3.14: 79 тестов, `OK`, 57,276 с; +- системный Python 3.14: 79 тестов, `OK`, 61,034 с; - Bash-интеграция: 8 проверок, `PASS`; `shellcheck` отсутствует и явно пропущен; - установка: `All install tests passed`; в `/tmp` не осталось каталогов `dotfiles-install-test.*`; -- CPython 3.8.20 через `uv`: 79 тестов, `OK`, 51,412 с. +- CPython 3.8 через `uv`: 79 тестов, `OK`, 52,446 с. ### 2026-07-21 — команды ручной приёмки @@ -82,22 +82,37 @@ ```bash python3 --version cd ~/dotfiles -stow xray-proxy -source ~/.config/xray-proxy/shell.bash -xray-proxy install -xray-proxy start +stow proxy-client +source ~/.config/proxy-client/shell.bash +proxy-client install +proxy-client start ``` Во второй панели, пока `start` работает: ```bash -xray-proxy status -xray run -test -c "${XDG_STATE_HOME:-$HOME/.local/state}/xray-proxy/client.json" +proxy-client status +xray run -test -c "${XDG_STATE_HOME:-$HOME/.local/state}/proxy-client/client.json" curl --proxy http://127.0.0.1:12334 https://api.ipify.org curl --proxy socks5h://127.0.0.1:12334 https://api.ipify.org -xray-proxy stop +proxy-client stop ``` Если настроен другой локальный адрес или порт, те же значения подставляются в обе команды `curl`. В отчёт записываются только версии, коды возврата и итог проверки HTTP/SOCKS. + +### 2026-07-21 — переименование и частичная ручная приёмка + +Публичный интерфейс переименован в `proxy-client`, а машинный конфиг и закрытый +профиль объединены в `~/.config/proxy-client/`. В ходе разработки текущий HOME +приведён к новой структуре; старые пользовательские пути отсутствуют, права +файлов `600` сохранены. Поддержка прежнего имени в установщик не добавлялась, +поскольку выпуск ещё не состоялся. + +Xray 26.3.27 установился и запустился, `status` подтвердил процесс на +`127.0.0.1:12334`, а `xray run -test` завершился сообщением +`Configuration OK`. HTTP и SOCKS5h через локальный вход не прошли: оба запроса +`curl` завершились кодом 35 с неожиданным закрытием TLS-соединения во время +рукопожатия. После проверки Xray штатно остановлен. Пункты ручной приёмки пока +не отмечены. diff --git a/.scratch/headless-xray-proxy/issues/06-simplification-pass.md b/.scratch/proxy-client/issues/06-simplification-pass.md similarity index 97% rename from .scratch/headless-xray-proxy/issues/06-simplification-pass.md rename to .scratch/proxy-client/issues/06-simplification-pass.md index 8766a60..b13a81d 100644 --- a/.scratch/headless-xray-proxy/issues/06-simplification-pass.md +++ b/.scratch/proxy-client/issues/06-simplification-pass.md @@ -22,9 +22,9 @@ Non-gating замечания ревью приняты как риски мод 1. Этот файл целиком, затем `/tmp/xray-simplify-Zeo5/06-simplify/sweep.md` — там точный line-level blast radius каждого пункта (секция 2 — срезы A/B, секция 3 — SIMP-N). Номера строк — ориентир, сверяй с фактическим кодом. -2. `docs/specs/2026-07-15-headless-xray-proxy-design.md` — исходная спека. -3. `xray-proxy/.local/bin/xray-proxy`, `xray-proxy/.config/xray-proxy/shell.bash`, - `tests/test_xray_proxy.py`, `tests/test_xray_proxy_managed.py`. +2. `docs/specs/2026-07-15-proxy-client-design.md` — исходная спека. +3. `proxy-client/.local/bin/proxy-client`, `proxy-client/.config/proxy-client/shell.bash`, + `tests/test_proxy_client.py`, `tests/test_proxy_client_managed.py`. Команды проверки (обе обязательны): @@ -88,11 +88,11 @@ Non-gating замечания ревью приняты как риски мод - [x] `write_private_json`: простая запись файла с правами 0600 внутри уже-0700 каталога; O_EXCL/fsync/`os.replace`/PID-суффиксы удалены. -- [x] Валидация `PROXY_PREFLIGHT_TIMEOUT` и `XRAY_PROXY_STOP_TIMEOUT` — один +- [x] Валидация `PROXY_PREFLIGHT_TIMEOUT` и `PROXY_CLIENT_STOP_TIMEOUT` — один guard «`float()` успешен и > 0, иначе понятная ошибка»; `math` не импортируется; тесты экстремальных значений (nan/inf/1e-7/1e308) удалены. Для `PROXY_PREFLIGHT_TIMEOUT` оставить один тест «плохое - значение → понятный отказ»; для `XRAY_PROXY_STOP_TIMEOUT` отдельного + значение → понятный отказ»; для `PROXY_CLIENT_STOP_TIMEOUT` отдельного теста нет и добавлять его не нужно — достаточно guard'а. - [x] `check_reachable` использует `socket.create_connection(timeout=...)` без SIGALRM/setitimer-машинерии. Принятый риск (записан здесь): diff --git a/.scratch/headless-xray-proxy/token-ledger.md b/.scratch/proxy-client/token-ledger.md similarity index 99% rename from .scratch/headless-xray-proxy/token-ledger.md rename to .scratch/proxy-client/token-ledger.md index c197b15..fd8f688 100644 --- a/.scratch/headless-xray-proxy/token-ledger.md +++ b/.scratch/proxy-client/token-ledger.md @@ -1,4 +1,4 @@ -# Токен-леджер проекта headless-xray-proxy +# Токен-леджер проекта proxy-client Затраты по этапам конвейера, для калибровки эффорта и планирования лимитов (5-часовые / недельные окна подписок). Прямое сравнение корректно внутри diff --git a/README.md b/README.md index b2e3a6e..6cfee0b 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Chezmoi использует отдельный source dir `chezmoi/` внутр | Пакет | Что делает | |-------|------------| | `bash` | Кастомный prompt, общие функции, подгрузка секретов | -| `xray-proxy` | Переносимый CLI и Bash-команды для явного запуска через прокси | +| `proxy-client` | Переносимый CLI и Bash-команды для явного запуска через прокси | | `tmux` | Два префикса (`Ctrl+b`, `Ctrl+Space`), vi-режим, split по `\|` / `-` | | `vim` | Минимальный .vimrc без плагинов, 2 пробела, leader = `Space` | | `zed` | Конфигурация Zed редактора | @@ -95,13 +95,14 @@ export ZAI_API_KEY="..." # https://z.ai Файл подгружается при старте bash и VS Code Server. Не трекается в git. -### Прокси (`xray-proxy`) +### Прокси (`proxy-client`) Linux/Bash-команды `proxy_on/off`, `pclaude`, `pcodex` и `myip` находятся в -самостоятельном пакете [`xray-proxy`](xray-proxy/README.md). Он поддерживает +самостоятельном пакете [`proxy-client`](proxy-client/README.md). Он поддерживает явно выбранный внешний Hiddify или управляемый пользовательский Xray 26.3.27 с профилями VLESS TCP/RAW + REALITY и XHTTP + TLS. Для отдельного подключения -выполните `stow xray-proxy`. Команды `xray-proxy install/start/stop/status` +сначала выполните `mkdir -p ~/.config/proxy-client`, затем +`stow proxy-client`. Команды `proxy-client install/start/stop/status` работают без systemd, автозапуска и автоматического перехода между прокси. Без конфига сохраняется внешний Hiddify `hproxy.local:12334`. @@ -145,7 +146,7 @@ echo '127.0.0.1 hproxy.local' | sudo tee -a /etc/hosts **Windows pwsh** — `proxy_on` / `proxy_off` / `myip` работают аналогично bash-версиям. -**`pclaude` / `pcodex`** — явный запуск Claude/Codex CLI через прокси (bash и pwsh). Bash-версия делегирует проверку и запуск `xray-proxy run`; Windows-версия сохраняет прежний адрес `hproxy.local:12334`. Таймаут — `PROXY_PREFLIGHT_TIMEOUT`, по умолчанию 2 c. Если прокси недоступен, клиент не запускается. Proxy-переменные видны только дочернему процессу и не остаются в сессии. Голые `claude`/`codex` не перекрываются и идут напрямую — для прокси нужен явный `p`-префикс. +**`pclaude` / `pcodex`** — явный запуск Claude/Codex CLI через прокси (bash и pwsh). Bash-версия делегирует проверку и запуск `proxy-client run`; Windows-версия сохраняет прежний адрес `hproxy.local:12334`. Таймаут — `PROXY_PREFLIGHT_TIMEOUT`, по умолчанию 2 c. Если прокси недоступен, клиент не запускается. Proxy-переменные видны только дочернему процессу и не остаются в сессии. Голые `claude`/`codex` не перекрываются и идут напрямую — для прокси нужен явный `p`-префикс. **Codex GUI на Windows** — chezmoi создает Start Menu ярлык `Codex Proxy`, который запускает Codex Windows App с proxy-переменными: @@ -188,7 +189,7 @@ NO_PROXY=hproxy.local,localhost,127.0.0.1,::1,[::1],.concept-software.ru ## 📖 Справочник -### Bash-функции (`~/.config/xray-proxy/shell.bash`) +### Bash-функции (`~/.config/proxy-client/shell.bash`) | Функция | Описание | |---------|----------| diff --git a/bash/.bash_custom b/bash/.bash_custom index 092c279..137e06a 100644 --- a/bash/.bash_custom +++ b/bash/.bash_custom @@ -16,7 +16,7 @@ esac # Файлы из этого репозитория (stow кладет их в $HOME). [ -f ~/.bash_functions ] && source ~/.bash_functions -[ -f ~/.config/xray-proxy/shell.bash ] && source ~/.config/xray-proxy/shell.bash +[ -f ~/.config/proxy-client/shell.bash ] && source ~/.config/proxy-client/shell.bash [ -f ~/.bash_prompt ] && source ~/.bash_prompt # Локальные секреты (не хранить в git/stow). diff --git a/docs/specs/2026-07-15-headless-xray-proxy-design.md b/docs/specs/2026-07-15-proxy-client-design.md similarity index 91% rename from docs/specs/2026-07-15-headless-xray-proxy-design.md rename to docs/specs/2026-07-15-proxy-client-design.md index 05c6829..0345263 100644 --- a/docs/specs/2026-07-15-headless-xray-proxy-design.md +++ b/docs/specs/2026-07-15-proxy-client-design.md @@ -1,8 +1,8 @@ -# Headless Xray-прокси для агентских CLI +# `proxy-client` для агентских CLI Дата: 2026-07-15 -Статус: дизайн утверждён, реализация не начата +Статус: реализация готова к ручной приёмке; публичное имя уточнено 2026-07-21 ## Проблема @@ -93,9 +93,12 @@ Bash нужен только там, где необходимо изменит ### Границы компонентов -`xray-proxy` — один исполняемый файл на Python 3.8+ без внешних библиотек. Он +`proxy-client` — один исполняемый файл на Python 3.8+ без внешних библиотек. Он отвечает за установку Xray, запуск и остановку процесса, диагностику, преобразование VLESS и запуск произвольной дочерней команды через прокси. +Нейтральное публичное имя описывает клиентскую прокси-подсистему целиком: +внешний прокси и управляемый Xray. Название `xray` остаётся у конкретного +движка и значения `type = xray`, где оно действительно важно. Переносимый `shell.bash` — единственный владелец `proxy_on`, `proxy_off`, `pclaude`, `pcodex`, `myip` и внутренних proxy-помощников. При реализации они @@ -105,18 +108,18 @@ Bash нужен только там, где необходимо изменит машинный конфиг самостоятельно: - `pclaude` и `pcodex` делегируют запуск форме - `xray-proxy run -- COMMAND ARG...`, где `--` отделяет параметры клиента; -- `proxy_on` получает от `xray-proxy env` проверенный набор proxy-переменных и + `proxy-client run -- COMMAND ARG...`, где `--` отделяет параметры клиента; +- `proxy_on` получает от `proxy-client env` проверенный набор proxy-переменных и применяет его к текущей Bash-сессии; - `proxy_off` очищает верхний и нижний регистр proxy-переменных. -Общий `bash/.bash_functions` и модуль Xray не зависят друг от друга. В составе +Общий `bash/.bash_functions` и модуль клиента не зависят друг от друга. В составе dotfiles `.bash_custom` подключает их двумя независимыми строками. Без -остальных dotfiles пользователь добавляет только один `source` модуля Xray в +остальных dotfiles пользователь добавляет только один `source` модуля клиента в `.bashrc`. Для неинтерактивных сценариев и других оболочек остаётся прямой -`xray-proxy run`. +`proxy-client run`. -`xray-proxy env` сначала выполняет ту же проверку доступности, что и `run`, а +`proxy-client env` сначала выполняет ту же проверку доступности, что и `run`, а затем печатает восемь строк `KEY=value`: HTTP, HTTPS, ALL и NO_PROXY в верхнем и нижнем регистре. `shell.bash` сначала целиком получает успешный ответ, затем разбирает строки по первому `=`, принимает только известные ключи и лишь после @@ -131,17 +134,17 @@ dotfiles `.bash_custom` подключает их двумя независим набора переменных, fail-fast перед `pclaude`/`pcodex` и изоляции окружения дочернего процесса. Автоматический запрос внешнего IP из `proxy_on` и `proxy_off` удаляется: он замедляет переключение и смешивает изменение -окружения с диагностикой. Для проверки остаются `myip` и `xray-proxy status`. +окружения с диагностикой. Для проверки остаются `myip` и `proxy-client status`. ### Два независимых файла настройки -Машинный файл `~/.config/proxy/config` хранит несекретное описание активного -прокси: явный тип `xray` или `external`, отображаемое имя, адрес, порт и -добавку к `NO_PROXY`. Формат — INI из стандартной библиотеки Python. Если файла -нет, прокси-подсистема сохраняет прежний внешний адрес +Машинный файл `~/.config/proxy-client/config` хранит несекретное описание +активного прокси: явный тип `xray` или `external`, отображаемое имя, адрес, порт +и добавку к `NO_PROXY`. Формат — INI из стандартной библиотеки Python. Если +файла нет, прокси-подсистема сохраняет прежний внешний адрес `hproxy.local:12334` ради обратной совместимости. -Секретный `~/.config/xray-proxy/default.url` содержит одну VLESS-ссылку. +Секретный `~/.config/proxy-client/default.url` содержит одну VLESS-ссылку. Команда запуска принимает другой файл через `-f`. Профили можно хранить в нескольких файлах, но активным у пользователя бывает только один. CLI следует за симлинком, но проверяет права конечного файла: доступ группы или @@ -156,8 +159,8 @@ CLI следует за симлинком, но проверяет права ### Явная семантика прокси Тип `xray` означает, что `pclaude` и `pcodex` требуют запущенный процесс, -которым управляет `xray-proxy`. Если его нет, агент не запускается, а сообщение -предлагает выполнить `xray-proxy start`. +которым управляет `proxy-client`. Если его нет, агент не запускается, а сообщение +предлагает выполнить `proxy-client start`. Тип `external` означает внешний процесс, например Hiddify. Проверяется только доступность явно заданного адреса и порта. Ни один тип не переходит на другой @@ -264,7 +267,7 @@ Hiddify Manager может добавлять `allowInsecure=true` и `insecure= ### Установка Xray -`xray-proxy install` — отдельная явная операция. Она устанавливает Xray в +`proxy-client install` — отдельная явная операция. Она устанавливает Xray в `~/.local/bin`, скачивает официальный архив и файл контрольной суммы, затем проверяет SHA-256. Поддерживается только зафиксированный релиз 26.3.27; параметра версии нет. Официальные релизы публикуют необходимые файлы `.dgst`: @@ -301,7 +304,7 @@ Start-lock берётся до проверки назначения и удер из содержимого VLESS-ссылки. `start` никогда не скачивает бинарник. Если `xray` отсутствует в `PATH`, команда -завершается с подсказкой выполнить `xray-proxy install`. +завершается с подсказкой выполнить `proxy-client install`. ### Жизненный цикл @@ -317,7 +320,7 @@ CLI предоставляет `install`, `start`, `stop`, `status`, `env` и `r достаточно истории tmux. Супервизор на всё время работы удерживает неблокирующую эксклюзивную блокировку -`fcntl.flock` в служебном каталоге `~/.local/state/xray-proxy/`. Второй `start` +`fcntl.flock` в служебном каталоге `~/.local/state/proxy-client/`. Второй `start` отказывает до запуска процессов и изменения state или сгенерированного конфига. `state.json` нужен для восстановления после сбоя, но не служит блокировкой. @@ -364,12 +367,14 @@ UUID, ключи, short ID и другие уникальные секретны ### Доставка -Компоненты образуют отдельный stow-совместимый каталог `xray-proxy/`. Он -доставляет CLI, Bash-модуль и два шаблона: машинного INI и файла VLESS-ссылки. +Компоненты образуют отдельный stow-совместимый каталог `proxy-client/`. Он +доставляет CLI, Bash-модуль и три шаблона: машинного INI для внешнего прокси, +машинного INI для Xray и файла VLESS-ссылки. Реальные пользовательские файлы не управляются stow и не входят в Git. -CLI раскладывается как `~/.local/bin/xray-proxy`, а модуль — как -`~/.config/xray-proxy/shell.bash`. Шаблоны лежат рядом с модулем в подкаталоге -`examples/` и не подменяют настоящие пользовательские файлы. +CLI раскладывается как `~/.local/bin/proxy-client`. Машинный конфиг, профиль, +модуль `shell.bash` и каталог `examples/` находятся вместе в +`~/.config/proxy-client/`. Шаблоны не подменяют настоящие пользовательские +файлы. В корне этого каталога находится самостоятельный `README.md`. Он является основной инструкцией клиента и исключается из раскладки stow. README остаётся @@ -391,6 +396,12 @@ README репозитория только кратко описывает по Python 3.11, что исключило бы штатный Python 3.10 в Ubuntu 22.04. - Bash оставлен отдельным тонким модулем, потому что дочерний Python-процесс не может изменить окружение родительской shell-сессии. +- Выбрано публичное имя `proxy-client`, потому что подсистема обслуживает и + внешний прокси, и управляемый Xray. `xray-proxy` отклонено как имя, + выпячивающее одну реализацию, а `proxyctl` — как менее понятное пользователю. +- Все пользовательские файлы собраны в `~/.config/proxy-client/`. Разделение + между общим машинным конфигом и Xray-профилем остаётся логическим, но не + требует помнить два каталога. - Машинный прокси и VLESS-ссылка разделены: первый файл описывает локальную политику shell, второй содержит секрет и меняется независимо. - Выбран один порт, потому что SOCKS-вход Xray совместим с HTTP и не требует @@ -459,7 +470,7 @@ README репозитория только кратко описывает по ## Влияние на документацию -Основная инструкция создаётся как `xray-proxy/README.md` одновременно с +Основная инструкция находится в `proxy-client/README.md` рядом с реализацией. Она должна описывать: - различие внешнего Hiddify и управляемого Xray; diff --git a/install.sh b/install.sh index 4b57e51..2168346 100755 --- a/install.sh +++ b/install.sh @@ -302,7 +302,7 @@ if ! command -v "$STOW_BIN" &>/dev/null; then exit 1 fi -PACKAGES=(bash tmux vim zed vscode agents codex gemini xray-proxy) +PACKAGES=(bash tmux vim zed vscode agents codex gemini proxy-client) AVAILABLE_PACKAGES=() info "Применяю stow пакеты..." @@ -320,6 +320,10 @@ migrate_zed_config_stow_symlink if [ ! -L "$HOME/.config" ]; then mkdir -p "$HOME/.config" fi +if [ -d "$HOME/.config" ] && [ ! -L "$HOME/.config" ] \ + && [ ! -L "$HOME/.config/proxy-client" ]; then + mkdir -p "$HOME/.config/proxy-client" +fi if [ "${#AVAILABLE_PACKAGES[@]}" -gt 0 ]; then stow_package "${AVAILABLE_PACKAGES[@]}" diff --git a/xray-proxy/.config/xray-proxy/examples/config b/proxy-client/.config/proxy-client/examples/config.external similarity index 100% rename from xray-proxy/.config/xray-proxy/examples/config rename to proxy-client/.config/proxy-client/examples/config.external diff --git a/proxy-client/.config/proxy-client/examples/config.xray b/proxy-client/.config/proxy-client/examples/config.xray new file mode 100644 index 0000000..55e9cc9 --- /dev/null +++ b/proxy-client/.config/proxy-client/examples/config.xray @@ -0,0 +1,6 @@ +[proxy] +type = xray +name = Local Xray +address = 127.0.0.1 +port = 12334 +no_proxy_extra = .example.internal,git.example.internal diff --git a/xray-proxy/.config/xray-proxy/examples/default.url b/proxy-client/.config/proxy-client/examples/default.url similarity index 100% rename from xray-proxy/.config/xray-proxy/examples/default.url rename to proxy-client/.config/proxy-client/examples/default.url diff --git a/xray-proxy/.config/xray-proxy/shell.bash b/proxy-client/.config/proxy-client/shell.bash similarity index 80% rename from xray-proxy/.config/xray-proxy/shell.bash rename to proxy-client/.config/proxy-client/shell.bash index a88a78d..63a6fcd 100644 --- a/xray-proxy/.config/xray-proxy/shell.bash +++ b/proxy-client/.config/proxy-client/shell.bash @@ -1,8 +1,8 @@ -# Переносимый Bash-интерфейс xray-proxy. Файл можно подключать отдельно от -# остальных dotfiles: source ~/.config/xray-proxy/shell.bash +# Переносимый Bash-интерфейс proxy-client. Файл можно подключать отдельно от +# остальных dotfiles: source ~/.config/proxy-client/shell.bash _proxy_cli() { - "${XRAY_PROXY_BIN:-xray-proxy}" "$@" + "${PROXY_CLIENT_BIN:-proxy-client}" "$@" } _proxy_apply_env_file() { @@ -15,7 +15,7 @@ _proxy_apply_env_file() { while IFS= read -r line || [ -n "$line" ]; do if [[ "$line" != *=* ]]; then - echo "Ошибка: xray-proxy env вернул строку без '='" >&2 + echo "Ошибка: proxy-client env вернул строку без '='" >&2 return 1 fi key="${line%%=*}" @@ -23,7 +23,7 @@ _proxy_apply_env_file() { case "$key" in HTTP_PROXY|HTTPS_PROXY|ALL_PROXY|NO_PROXY|http_proxy|https_proxy|all_proxy|no_proxy) ;; *) - echo "Ошибка: xray-proxy env вернул неизвестный ключ: $key" >&2 + echo "Ошибка: proxy-client env вернул неизвестный ключ: $key" >&2 return 1 ;; esac @@ -42,7 +42,7 @@ proxy_on() { local output_file old_umask old_umask="$(umask)" umask 077 - if ! output_file="$(mktemp "${TMPDIR:-/tmp}/xray-proxy-env.XXXXXX")"; then + if ! output_file="$(mktemp "${TMPDIR:-/tmp}/proxy-client-env.XXXXXX")"; then umask "$old_umask" echo "Ошибка: не удалось создать временный файл для proxy-env" >&2 return 1 diff --git a/xray-proxy/.local/bin/xray-proxy b/proxy-client/.local/bin/proxy-client similarity index 97% rename from xray-proxy/.local/bin/xray-proxy rename to proxy-client/.local/bin/proxy-client index c13ee01..2103ac2 100755 --- a/xray-proxy/.local/bin/xray-proxy +++ b/proxy-client/.local/bin/proxy-client @@ -3,7 +3,7 @@ import sys if sys.version_info < (3, 8): sys.stderr.write( - "Ошибка: xray-proxy требует Python 3.8 или новее; обнаружен Python {}.{}.\n".format( + "Ошибка: proxy-client требует Python 3.8 или новее; обнаружен Python {}.{}.\n".format( sys.version_info[0], sys.version_info[1] ) ) @@ -125,22 +125,22 @@ class VlessProfile: def config_path(): config_home = os.environ.get("XDG_CONFIG_HOME") if config_home: - return Path(config_home).expanduser() / "proxy/config" - return Path.home() / ".config/proxy/config" + return Path(config_home).expanduser() / "proxy-client/config" + return Path.home() / ".config/proxy-client/config" -def xray_config_home(): +def proxy_client_config_home(): config_home = os.environ.get("XDG_CONFIG_HOME") if config_home: - return Path(config_home).expanduser() / "xray-proxy" - return Path.home() / ".config/xray-proxy" + return Path(config_home).expanduser() / "proxy-client" + return Path.home() / ".config/proxy-client" def state_home(): state_root = os.environ.get("XDG_STATE_HOME") if state_root: - return Path(state_root).expanduser() / "xray-proxy" - return Path.home() / ".local/state/xray-proxy" + return Path(state_root).expanduser() / "proxy-client" + return Path.home() / ".local/state/proxy-client" def state_path(): @@ -574,7 +574,7 @@ def build_xray_config(profile, local_address, local_port): def resolve_xray(): found = shutil.which("xray") if found is None: - raise ProxyError("xray не найден в PATH; сначала выполните xray-proxy install") + raise ProxyError("xray не найден в PATH; сначала выполните proxy-client install") try: return str(Path(found).resolve(strict=True)) except OSError as error: @@ -765,18 +765,18 @@ def require_install_safe_managed_state(): return except OSError: raise ProxyError( - "состояние managed-режима не удалось проверить; сначала выполните xray-proxy stop" + "состояние managed-режима не удалось проверить; сначала выполните proxy-client stop" ) try: state = read_managed_state() outcome = managed_state_outcome(state) except ProxyError: raise ProxyError( - "состояние managed-режима не подтверждено; сначала выполните xray-proxy stop" + "состояние managed-режима не подтверждено; сначала выполните proxy-client stop" ) if outcome != "stale": raise ProxyError( - "managed Xray работает или не подтверждён; сначала выполните xray-proxy stop" + "managed Xray работает или не подтверждён; сначала выполните proxy-client stop" ) @@ -785,7 +785,7 @@ def acquire_install_lock(): return acquire_start_lock() except StartLockBusyError: raise ProxyError( - "другая операция удерживает блокировку; сначала выполните xray-proxy stop" + "другая операция удерживает блокировку; сначала выполните proxy-client stop" ) @@ -991,7 +991,7 @@ def read_managed_state(): raise ProxyError("состояние управляемого Xray не подтверждено") value = json.loads(path.read_text(encoding="utf-8")) except FileNotFoundError: - raise ProxyError("управляемый Xray не запущен; выполните xray-proxy start") + raise ProxyError("управляемый Xray не запущен; выполните proxy-client start") except ProxyError: raise except (OSError, ValueError, TypeError): @@ -1123,7 +1123,7 @@ def verify_managed_state(config=None, require_port=True): finally: lock.close() if outcome == "orphan": - raise ProxyError("обнаружен Xray-сирота; выполните xray-proxy stop") + raise ProxyError("обнаружен Xray-сирота; выполните proxy-client stop") if outcome != "healthy": raise unverifiable_state_error() if config is not None and ( @@ -1200,7 +1200,7 @@ def diagnose_ip(config): connection.request( "GET", "http://ifconfig.me/ip", - headers={"Host": "ifconfig.me", "User-Agent": "xray-proxy/1"}, + headers={"Host": "ifconfig.me", "User-Agent": "proxy-client/1"}, ) response = connection.getresponse() address = response.read(256).decode("ascii", "replace").strip() @@ -1259,7 +1259,7 @@ def command_env(config): def command_run(config, remainder): if not remainder or remainder[0] != "--" or len(remainder) == 1: - raise ProxyError("использование: xray-proxy run -- COMMAND ARG...") + raise ProxyError("использование: proxy-client run -- COMMAND ARG...") command = remainder[1:] if shutil.which(command[0]) is None: sys.stderr.write("Ошибка: команда не найдена в PATH: {}\n".format(command[0])) @@ -1319,7 +1319,7 @@ def command_start_locked(config, profile_file, address): if outcome == "stale": cleanup_stale_files() elif outcome == "orphan": - raise ProxyError("обнаружен Xray-сирота; сначала выполните xray-proxy stop") + raise ProxyError("обнаружен Xray-сирота; сначала выполните proxy-client stop") elif outcome == "healthy": raise ProxyError("управляемый Xray уже запущен") else: @@ -1327,7 +1327,11 @@ def command_start_locked(config, profile_file, address): check_port_available(address, config.port) - profile_path = Path(profile_file) if profile_file else xray_config_home() / "default.url" + profile_path = ( + Path(profile_file) + if profile_file + else proxy_client_config_home() / "default.url" + ) raw_profile = read_profile(profile_path) executable = resolve_xray() version = xray_version(executable) @@ -1432,13 +1436,13 @@ def command_start_locked(config, profile_file, address): def stop_timeout(): - raw = os.environ.get("XRAY_PROXY_STOP_TIMEOUT", "5") + raw = os.environ.get("PROXY_CLIENT_STOP_TIMEOUT", "5") try: value = float(raw) if not value > 0: raise ValueError except ValueError: - raise ProxyError("XRAY_PROXY_STOP_TIMEOUT должен быть положительным числом секунд") + raise ProxyError("PROXY_CLIENT_STOP_TIMEOUT должен быть положительным числом секунд") return value @@ -1498,7 +1502,7 @@ def command_stop(): def build_parser(): parser = argparse.ArgumentParser( - prog="xray-proxy", + prog="proxy-client", description="Безопасный запуск команд через явно настроенный прокси.", ) subparsers = parser.add_subparsers(dest="subcommand") @@ -1521,7 +1525,7 @@ def main(): arguments = parser.parse_args() if arguments.subcommand is None: parser.print_usage(sys.stderr) - sys.stderr.write("xray-proxy: ошибка: укажите явную подкоманду\n") + sys.stderr.write("proxy-client: ошибка: укажите явную подкоманду\n") return 2 try: diff --git a/xray-proxy/.stow-local-ignore b/proxy-client/.stow-local-ignore similarity index 100% rename from xray-proxy/.stow-local-ignore rename to proxy-client/.stow-local-ignore diff --git a/xray-proxy/README.md b/proxy-client/README.md similarity index 87% rename from xray-proxy/README.md rename to proxy-client/README.md index 3e73d8b..f29b9a7 100644 --- a/xray-proxy/README.md +++ b/proxy-client/README.md @@ -1,4 +1,4 @@ -# xray-proxy +# proxy-client Переносимый Linux/Bash-интерфейс для безопасного запуска команд через явно настроенный внешний прокси или через управляемый пользовательский Xray. @@ -13,8 +13,9 @@ CLI требует Python 3.8+ и использует только станда ```bash cd ~/dotfiles -stow xray-proxy -source ~/.config/xray-proxy/shell.bash +mkdir -p ~/.config/proxy-client +stow proxy-client +source ~/.config/proxy-client/shell.bash ``` Полный `./install.sh` выполняет тот же `stow` вместе с остальными пакетами, а @@ -23,11 +24,11 @@ source ~/.config/xray-proxy/shell.bash Без остальных dotfiles можно создать две ссылки: ```bash -mkdir -p ~/.local/bin ~/.config/xray-proxy -ln -s /path/to/xray-proxy/.local/bin/xray-proxy ~/.local/bin/xray-proxy -ln -s /path/to/xray-proxy/.config/xray-proxy/shell.bash \ - ~/.config/xray-proxy/shell.bash -printf '%s\n' 'source ~/.config/xray-proxy/shell.bash' >> ~/.bashrc +mkdir -p ~/.local/bin ~/.config/proxy-client +ln -s /path/to/proxy-client/.local/bin/proxy-client ~/.local/bin/proxy-client +ln -s /path/to/proxy-client/.config/proxy-client/shell.bash \ + ~/.config/proxy-client/shell.bash +printf '%s\n' 'source ~/.config/proxy-client/shell.bash' >> ~/.bashrc ``` Вместо ссылок файлы можно скопировать в те же пути. Исполняемому файлу нужен @@ -35,11 +36,13 @@ printf '%s\n' 'source ~/.config/xray-proxy/shell.bash' >> ~/.bashrc ## Машинный конфиг -Скопируйте пример и отредактируйте его: +Скопируйте пример нужного режима и отредактируйте его: ```bash -mkdir -p ~/.config/proxy -cp ~/.config/xray-proxy/examples/config ~/.config/proxy/config +mkdir -p ~/.config/proxy-client +cp ~/.config/proxy-client/examples/config.xray ~/.config/proxy-client/config +# или для внешнего Hiddify: +cp ~/.config/proxy-client/examples/config.external ~/.config/proxy-client/config ``` Формат строгий: одна секция `[proxy]`, пять обязательных ключей без повторов и @@ -78,23 +81,23 @@ no_proxy_extra = .example.internal,git.example.internal его одной настоящей VLESS-ссылкой и закройте права конечного файла: ```bash -cp ~/.config/xray-proxy/examples/default.url ~/.config/xray-proxy/default.url -chmod 600 ~/.config/xray-proxy/default.url +cp ~/.config/proxy-client/examples/default.url ~/.config/proxy-client/default.url +chmod 600 ~/.config/proxy-client/default.url ``` Файл должен содержать ровно одну непустую строку. Можно передать другой файл -через `xray-proxy start -f /path/to/profile.url`. Если это симлинк, проверяются +через `proxy-client start -f /path/to/profile.url`. Если это симлинк, проверяются права конечного файла. ## Команды ```bash -xray-proxy install [--replace] -xray-proxy status -xray-proxy start [-f PROFILE] -xray-proxy stop -xray-proxy env -xray-proxy run -- COMMAND ARG... +proxy-client install [--replace] +proxy-client status +proxy-client start [-f PROFILE] +proxy-client stop +proxy-client env +proxy-client run -- COMMAND ARG... proxy_on proxy_off pclaude [ARG...] @@ -120,7 +123,7 @@ myip но проверенная установка сохраняется. Родительский симлинк принимается, только если разрешённый каталог остаётся внутри `HOME`. Во время живого или неподтверждённого managed-экземпляра установка запрещена: сначала выполните -`xray-proxy stop`. +`proxy-client stop`. Для `external` команды `status`, `env` и `run` проверяют настроенный адрес и порт. Для `xray` они дополнительно требуют закрытое состояние именно того @@ -167,6 +170,6 @@ CLI такие версии не поддерживает и не устанав Сводка скрывает UUID и ключи. Содержимое XHTTP `path` не показывается — только его длина. Сгенерированный JSON и состояние лежат в закрытом каталоге -`$XDG_STATE_HOME/xray-proxy` либо `~/.local/state/xray-proxy` и удаляются после +`$XDG_STATE_HOME/proxy-client` либо `~/.local/state/proxy-client` и удаляются после штатной остановки. Сбой диагностики внешнего IP через локальный HTTP-прокси показывается как предупреждение и не останавливает уже готовый Xray. diff --git a/tests/test_install.sh b/tests/test_install.sh index eb16bb7..2f3250e 100755 --- a/tests/test_install.sh +++ b/tests/test_install.sh @@ -38,7 +38,7 @@ setup_fixture() { # stow конфликтами, поэтому в тестовое дерево не попадает. git -C "$REPO_ROOT" ls-files -z --cached -- \ install.sh bash tmux vim zed vscode agents opencode codex gemini \ - claude xray-proxy scripts \ + claude proxy-client scripts \ | tar -C "$REPO_ROOT" --null -T - -cf - \ | tar -xf - -C "$repo_root" @@ -97,8 +97,13 @@ test_fresh_install_uses_home_target() { run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >"$fixture/install.out" [ -L "$home_dir/.bash_custom" ] || fail "Expected .bash_custom symlink in HOME" - [ -x "$home_dir/.local/bin/xray-proxy" ] || fail "Expected executable xray-proxy CLI in HOME" - [ -f "$home_dir/.config/xray-proxy/shell.bash" ] || fail "Expected xray-proxy shell module" + [ -x "$home_dir/.local/bin/proxy-client" ] || fail "Expected executable proxy-client CLI in HOME" + [ -d "$home_dir/.config/proxy-client" ] || fail "Expected proxy-client config directory" + [ ! -L "$home_dir/.config/proxy-client" ] || fail "Expected proxy-client config to stay outside the repository" + [ -f "$home_dir/.config/proxy-client/shell.bash" ] || fail "Expected proxy-client shell module" + [ -f "$home_dir/.config/proxy-client/examples/config.external" ] || fail "Expected external proxy config example" + [ -f "$home_dir/.config/proxy-client/examples/config.xray" ] || fail "Expected managed Xray config example" + assert_not_exists "$home_dir/.config/proxy-client/examples/config" [ ! -e "$home_dir/README.md" ] || fail "Package README must not be stowed into HOME" [ -L "$home_dir/.claude/skills" ] || fail "Expected ~/.claude/skills symlink" assert_file_contains "$home_dir/.bashrc" "[ -f ~/.bash_custom ] && source ~/.bash_custom" @@ -113,7 +118,7 @@ test_fresh_install_does_not_fold_config_into_zed() { mkdir -p "$home_dir" # Неполный клон: zed остаётся единственным stow-пакетом с каталогом .config. - mv "$repo_root/xray-proxy" "$fixture/xray-proxy-disabled" + mv "$repo_root/proxy-client" "$fixture/proxy-client-disabled" run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >"$fixture/install.out" @@ -131,10 +136,10 @@ test_migrates_legacy_zed_config_symlink() { repo_root="$fixture/repo" home_dir="$fixture/home" legacy_config="$repo_root/zed/.config" - mkdir -p "$home_dir" "$legacy_config/btop" "$legacy_config/xray-proxy" + mkdir -p "$home_dir" "$legacy_config/btop" "$legacy_config/proxy-client" printf '%s\n' 'btop settings' > "$legacy_config/btop/btop.conf" - printf '%s\n' 'VLESS_PROFILE_SENTINEL' > "$legacy_config/xray-proxy/default.url" - chmod 600 "$legacy_config/xray-proxy/default.url" + printf '%s\n' 'VLESS_PROFILE_SENTINEL' > "$legacy_config/proxy-client/default.url" + chmod 600 "$legacy_config/proxy-client/default.url" ln -s "$legacy_config" "$home_dir/.config" if ! output="$( @@ -149,19 +154,19 @@ test_migrates_legacy_zed_config_symlink() { [ -L "$home_dir/.config/zed" ] || fail "Expected managed ~/.config/zed symlink" [ -d "$home_dir/.config/btop" ] || fail "Expected btop config to be migrated" [ ! -L "$home_dir/.config/btop" ] || fail "Expected btop config outside the repository" - [ -f "$home_dir/.config/xray-proxy/default.url" ] || fail "Expected profile to be migrated" - [ ! -L "$home_dir/.config/xray-proxy/default.url" ] || fail "Expected profile outside the repository" - mode="$(stat -c '%a' "$home_dir/.config/xray-proxy/default.url")" + [ -f "$home_dir/.config/proxy-client/default.url" ] || fail "Expected profile to be migrated" + [ ! -L "$home_dir/.config/proxy-client/default.url" ] || fail "Expected profile outside the repository" + mode="$(stat -c '%a' "$home_dir/.config/proxy-client/default.url")" [ "$mode" = "600" ] || fail "Expected profile mode 600, got $mode" - assert_file_contains "$home_dir/.config/xray-proxy/default.url" "VLESS_PROFILE_SENTINEL" + assert_file_contains "$home_dir/.config/proxy-client/default.url" "VLESS_PROFILE_SENTINEL" assert_not_exists "$legacy_config/btop" - assert_not_exists "$legacy_config/xray-proxy" + assert_not_exists "$legacy_config/proxy-client" if printf '%s' "$output" | grep -Fq 'VLESS_PROFILE_SENTINEL'; then fail "Secret profile leaked into install output" fi run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >"$fixture/install.out" - assert_file_contains "$home_dir/.config/xray-proxy/default.url" "VLESS_PROFILE_SENTINEL" + assert_file_contains "$home_dir/.config/proxy-client/default.url" "VLESS_PROFILE_SENTINEL" } test_stops_legacy_migration_when_listing_fails() { @@ -170,8 +175,8 @@ test_stops_legacy_migration_when_listing_fails() { repo_root="$fixture/repo" home_dir="$fixture/home" legacy_config="$repo_root/zed/.config" - mkdir -p "$home_dir" "$legacy_config/xray-proxy" - printf '%s\n' 'VLESS_PROFILE_SENTINEL' > "$legacy_config/xray-proxy/default.url" + mkdir -p "$home_dir" "$legacy_config/proxy-client" + printf '%s\n' 'VLESS_PROFILE_SENTINEL' > "$legacy_config/proxy-client/default.url" ln -s "$legacy_config" "$home_dir/.config" cat > "$fixture/bin/find" <<'EOF' @@ -190,7 +195,7 @@ EOF target="$(readlink -f "$home_dir/.config")" expected="$(readlink -f "$legacy_config")" [ "$target" = "$expected" ] || fail "Legacy ~/.config target changed after listing failure" - assert_file_contains "$legacy_config/xray-proxy/default.url" "VLESS_PROFILE_SENTINEL" + assert_file_contains "$legacy_config/proxy-client/default.url" "VLESS_PROFILE_SENTINEL" } test_does_not_migrate_unrelated_config_symlink() { @@ -213,6 +218,7 @@ test_does_not_migrate_unrelated_config_symlink() { expected="$(readlink -f "$unrelated_config")" [ "$target" = "$expected" ] || fail "Unrelated ~/.config symlink target changed" assert_file_contains "$unrelated_config/local.conf" "keep me" + assert_not_exists "$unrelated_config/proxy-client" } test_settings_local_is_symlinked_to_repo() { diff --git a/tests/test_xray_proxy.py b/tests/test_proxy_client.py similarity index 98% rename from tests/test_xray_proxy.py rename to tests/test_proxy_client.py index 96c509a..cd0939d 100644 --- a/tests/test_xray_proxy.py +++ b/tests/test_proxy_client.py @@ -16,7 +16,7 @@ from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] -CLI = REPO_ROOT / "xray-proxy/.local/bin/xray-proxy" +CLI = REPO_ROOT / "proxy-client/.local/bin/proxy-client" class FakeUrlResponse: @@ -71,7 +71,7 @@ SUCCESSFUL_SOCKET_SETUP = checking_socket_setup() REFUSED_SOCKET_SETUP = checking_socket_setup("refused") -class XrayProxyCliTest(unittest.TestCase): +class ProxyClientCliTest(unittest.TestCase): def setUp(self): self.temp_dir = tempfile.TemporaryDirectory() self.config_home = Path(self.temp_dir.name) @@ -90,7 +90,7 @@ class XrayProxyCliTest(unittest.TestCase): address="127.0.0.1", no_proxy_extra=".example.test", ): - config_dir = self.config_home / "proxy" + config_dir = self.config_home / "proxy-client" config_dir.mkdir(exist_ok=True) (config_dir / "config").write_text( "[proxy]\n" @@ -105,7 +105,7 @@ class XrayProxyCliTest(unittest.TestCase): ) def write_raw_config(self, content): - config_dir = self.config_home / "proxy" + config_dir = self.config_home / "proxy-client" config_dir.mkdir(exist_ok=True) path = config_dir / "config" if isinstance(content, bytes): @@ -167,7 +167,7 @@ class XrayProxyCliTest(unittest.TestCase): self.assertEqual(2, result.returncode) self.assertEqual("", result.stdout) self.assertIn("укажите явную подкоманду", result.stderr) - self.assertFalse((self.config_home / "proxy").exists()) + self.assertFalse((self.config_home / "proxy-client").exists()) def test_old_python_version_gets_clear_error_before_main_program(self): code = ( @@ -269,7 +269,7 @@ class XrayProxyCliTest(unittest.TestCase): self.assertEqual(7654321, wait_for_starttime(child)) def test_broken_config_symlink_is_not_treated_as_missing_config(self): - config_dir = self.config_home / "proxy" + config_dir = self.config_home / "proxy-client" config_dir.mkdir() (config_dir / "config").symlink_to(self.config_home / "missing-config") result = self.run_cli("start") @@ -651,7 +651,7 @@ class XrayInstallTest(unittest.TestCase): def tree_snapshot_without_install_lock(self): snapshot = self.tree_snapshot() - for path in ("state/xray-proxy/lock", "state/xray-proxy", "state"): + for path in ("state/proxy-client/lock", "state/proxy-client", "state"): snapshot.pop(path, None) return snapshot @@ -662,7 +662,7 @@ class XrayInstallTest(unittest.TestCase): ) def assert_install_lock_released(self): - lock_path = self.state_home / "xray-proxy/lock" + lock_path = self.state_home / "proxy-client/lock" with lock_path.open("r+", encoding="ascii") as lock: fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) @@ -692,8 +692,8 @@ class XrayInstallTest(unittest.TestCase): "home/.local/bin", "home/.local/bin/xray", "state", - "state/xray-proxy", - "state/xray-proxy/lock", + "state/proxy-client", + "state/proxy-client/lock", }, set(self.tree_snapshot()), ) @@ -983,7 +983,7 @@ class XrayInstallTest(unittest.TestCase): downloader = mock.Mock(side_effect=AssertionError("network used")) guard = mock.Mock( side_effect=self.module["ProxyError"]( - "managed Xray работает; сначала выполните xray-proxy stop" + "managed Xray работает; сначала выполните proxy-client stop" ) ) globals_ = self.module["command_install"].__globals__ @@ -1129,14 +1129,14 @@ class XrayInstallTest(unittest.TestCase): self.assertEqual(sentinel, self.destination.read_bytes()) self.assert_no_staging() - lock_path = self.state_home / "xray-proxy/lock" + lock_path = self.state_home / "proxy-client/lock" with lock_path.open("r+", encoding="ascii") as lock: fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) self.assertEqual(before, self.tree_snapshot_without_install_lock()) def test_start_lock_is_held_during_download_and_through_replace(self): downloader, _calls = self.downloader() - lock_path = self.state_home / "xray-proxy/lock" + lock_path = self.state_home / "proxy-client/lock" def assert_lock_held(): with lock_path.open("r+", encoding="ascii") as competing: @@ -1213,7 +1213,7 @@ class XrayInstallTest(unittest.TestCase): def test_install_dispatches_before_machine_config_loading(self): self.write_destination() - config = self.root / "config/proxy" + config = self.root / "config/proxy-client" config.mkdir(parents=True) (config / "config").write_text("not an ini\n", encoding="ascii") environment = os.environ.copy() diff --git a/tests/test_xray_proxy_full.sh b/tests/test_proxy_client_full.sh similarity index 100% rename from tests/test_xray_proxy_full.sh rename to tests/test_proxy_client_full.sh diff --git a/tests/test_xray_proxy_managed.py b/tests/test_proxy_client_managed.py similarity index 95% rename from tests/test_xray_proxy_managed.py rename to tests/test_proxy_client_managed.py index d3bdf81..fae2ce8 100644 --- a/tests/test_xray_proxy_managed.py +++ b/tests/test_proxy_client_managed.py @@ -15,7 +15,7 @@ from urllib.parse import quote REPO_ROOT = Path(__file__).resolve().parents[1] -CLI = REPO_ROOT / "xray-proxy/.local/bin/xray-proxy" +CLI = REPO_ROOT / "proxy-client/.local/bin/proxy-client" UUID = "11111111-2222-4333-8444-555555555555" PBK = "PUBLIC_KEY_SENTINEL_7f8a" SID = "SHORT_ID_SENTINEL_9b3c" @@ -185,7 +185,7 @@ class ManagedXrayCliTest(unittest.TestCase): "FAKE_XRAY_LOG": str(self.log), "FAKE_XRAY_VERSION": "Xray 26.3.27 (fake)", "PROXY_PREFLIGHT_TIMEOUT": "0.5", - "XRAY_PROXY_STOP_TIMEOUT": "2", + "PROXY_CLIENT_STOP_TIMEOUT": "2", } ) self.port = self.free_port() @@ -201,7 +201,7 @@ class ManagedXrayCliTest(unittest.TestCase): return listener.getsockname()[1] def write_machine_config(self, proxy_type): - directory = self.config_home / "proxy" + directory = self.config_home / "proxy-client" directory.mkdir(parents=True, exist_ok=True) (directory / "config").write_text( "[proxy]\n" @@ -217,7 +217,7 @@ class ManagedXrayCliTest(unittest.TestCase): if explicit: path = self.root / "chosen.url" else: - path = self.config_home / "xray-proxy/default.url" + path = self.config_home / "proxy-client/default.url" path.parent.mkdir(parents=True, exist_ok=True) path.write_text(url + "\n", encoding="utf-8") path.chmod(0o600) @@ -257,7 +257,7 @@ class ManagedXrayCliTest(unittest.TestCase): ) def wait_for_state(self, process): - state = self.state_home / "xray-proxy/state.json" + state = self.state_home / "proxy-client/state.json" deadline = time.monotonic() + 5 while time.monotonic() < deadline: if state.exists(): @@ -276,7 +276,7 @@ class ManagedXrayCliTest(unittest.TestCase): return [json.loads(line) for line in self.log.read_text().splitlines()] def managed_state_path(self): - return self.state_home / "xray-proxy/state.json" + return self.state_home / "proxy-client/state.json" def read_state(self): return json.loads(self.managed_state_path().read_text(encoding="utf-8")) @@ -358,8 +358,8 @@ class ManagedXrayCliTest(unittest.TestCase): self.assertIn("ip-probe", kinds) for entry in entries: self.assertEqual(str(self.xray.resolve()), entry["executable"]) - self.assertFalse((self.state_home / "xray-proxy/state.json").exists()) - self.assertFalse((self.state_home / "xray-proxy/client.json").exists()) + self.assertFalse((self.state_home / "proxy-client/state.json").exists()) + self.assertFalse((self.state_home / "proxy-client/client.json").exists()) self.assert_no_secrets(stdout + stderr + status.stdout + env.stdout, profile_text) self.log.unlink() self.port = self.free_port() @@ -395,9 +395,9 @@ class ManagedXrayCliTest(unittest.TestCase): profile_text = self.tcp_profile() self.write_profile(profile_text) process = self.start() - state_path = self.state_home / "xray-proxy/state.json" - config_path = self.state_home / "xray-proxy/client.json" - lock_path = self.state_home / "xray-proxy/lock" + state_path = self.state_home / "proxy-client/state.json" + config_path = self.state_home / "proxy-client/client.json" + lock_path = self.state_home / "proxy-client/lock" state = json.loads(state_path.read_text(encoding="utf-8")) config = json.loads(config_path.read_text(encoding="utf-8")) self.assertEqual(0o600, state_path.stat().st_mode & 0o777) @@ -456,7 +456,7 @@ class ManagedXrayCliTest(unittest.TestCase): def test_held_lock_rejects_start_without_changing_managed_files(self): profile_text = self.tcp_profile() self.write_profile(profile_text) - state_dir = self.state_home / "xray-proxy" + state_dir = self.state_home / "proxy-client" state_dir.mkdir(parents=True, mode=0o700) state_path = state_dir / "state.json" config_path = state_dir / "client.json" @@ -496,7 +496,7 @@ class ManagedXrayCliTest(unittest.TestCase): self.stop_and_collect(process) def test_stale_cleanup_waits_for_start_lock_and_preserves_files(self): - state_dir = self.state_home / "xray-proxy" + state_dir = self.state_home / "proxy-client" state_dir.mkdir(parents=True, mode=0o700) state = { "supervisor_pid": 999999998, @@ -586,7 +586,7 @@ class ManagedXrayCliTest(unittest.TestCase): self.assertNotEqual(0, status.returncode) self.assertIn("устаревшее", status.stderr) self.assertFalse(self.managed_state_path().exists()) - self.assertFalse((self.state_home / "xray-proxy/client.json").exists()) + self.assertFalse((self.state_home / "proxy-client/client.json").exists()) self.assertTrue(Path("/proc/{}".format(state["xray_pid"])).exists()) os.kill(state["xray_pid"], signal.SIGKILL) @@ -594,7 +594,7 @@ class ManagedXrayCliTest(unittest.TestCase): self.assert_no_secrets(stdout + stderr, profile_text) def test_dead_pids_allow_stale_state_and_config_cleanup(self): - state_dir = self.state_home / "xray-proxy" + state_dir = self.state_home / "proxy-client" state_dir.mkdir(parents=True, mode=0o700) state = { "supervisor_pid": 999999998, @@ -638,7 +638,7 @@ class ManagedXrayCliTest(unittest.TestCase): len([entry for entry in self.log_entries() if entry["kind"] == "run"]), ) - lock_path = self.state_home / "xray-proxy/lock" + lock_path = self.state_home / "proxy-client/lock" with lock_path.open("r+", encoding="ascii") as lock: with self.assertRaises(OSError): fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) @@ -662,7 +662,7 @@ class ManagedXrayCliTest(unittest.TestCase): self.assertEqual(0, stopped.returncode, stopped.stderr) self.assertIn("сирот", stopped.stdout) self.assertFalse(self.managed_state_path().exists()) - self.assertFalse((self.state_home / "xray-proxy/client.json").exists()) + self.assertFalse((self.state_home / "proxy-client/client.json").exists()) stdout, stderr = process.communicate(timeout=2) self.assert_no_secrets(stdout + stderr + stopped.stdout, profile_text) @@ -677,7 +677,7 @@ class ManagedXrayCliTest(unittest.TestCase): def test_unverifiable_live_child_preserves_state_and_config(self): profile_text = self.tcp_profile() self.write_profile(profile_text) - self.environment["XRAY_PROXY_STOP_TIMEOUT"] = "0.2" + self.environment["PROXY_CLIENT_STOP_TIMEOUT"] = "0.2" process = self.start() state = self.read_state() self.xray.unlink() @@ -685,7 +685,7 @@ class ManagedXrayCliTest(unittest.TestCase): os.kill(state["supervisor_pid"], signal.SIGTERM) process.wait(timeout=3) state_exists = self.managed_state_path().exists() - config_path = self.state_home / "xray-proxy/client.json" + config_path = self.state_home / "proxy-client/client.json" config_exists = config_path.exists() child_alive = Path("/proc/{}".format(state["xray_pid"])).exists() if child_alive: @@ -715,11 +715,11 @@ class ManagedXrayCliTest(unittest.TestCase): process.wait(timeout=2) stopped = self.run_cli( - "stop", environment={"XRAY_PROXY_STOP_TIMEOUT": "0.2"} + "stop", environment={"PROXY_CLIENT_STOP_TIMEOUT": "0.2"} ) self.assertEqual(0, stopped.returncode, stopped.stderr) self.assertFalse(self.managed_state_path().exists()) - self.assertFalse((self.state_home / "xray-proxy/client.json").exists()) + self.assertFalse((self.state_home / "proxy-client/client.json").exists()) signals = [ entry["signum"] for entry in self.log_entries() @@ -745,7 +745,7 @@ class ManagedXrayCliTest(unittest.TestCase): self.assertIn("занят", result.stderr) self.assertEqual([], self.log_entries()) self.assertFalse(self.managed_state_path().exists()) - self.assertFalse((self.state_home / "xray-proxy/client.json").exists()) + self.assertFalse((self.state_home / "proxy-client/client.json").exists()) self.assert_no_secrets(result.stdout + result.stderr, profile_text) def test_versions_and_profile_failures_stop_before_test_or_run_without_leaks(self): @@ -930,8 +930,8 @@ class ManagedXrayCliTest(unittest.TestCase): self.write_profile(profile_text) process = self.start() stdout, stderr = self.stop_and_collect(process) - self.assertFalse((self.state_home / "xray-proxy/state.json").exists()) - self.assertFalse((self.state_home / "xray-proxy/client.json").exists()) + self.assertFalse((self.state_home / "proxy-client/state.json").exists()) + self.assertFalse((self.state_home / "proxy-client/client.json").exists()) self.assert_no_secrets(stdout + stderr, profile_text) self.log.unlink() self.port = self.free_port() @@ -969,12 +969,12 @@ class ManagedXrayCliTest(unittest.TestCase): self.assertNotEqual(0, result.returncode) self.assertEqual(["version", "test"], [entry["kind"] for entry in self.log_entries()]) self.assert_no_secrets(result.stdout + result.stderr, profile_text) - self.assertFalse((self.state_home / "xray-proxy/client.json").exists()) + self.assertFalse((self.state_home / "proxy-client/client.json").exists()) def test_xray_start_requires_loopback_before_selecting_binary(self): profile_text = self.tcp_profile() self.write_profile(profile_text) - config_path = self.config_home / "proxy/config" + config_path = self.config_home / "proxy-client/config" config_path.write_text( config_path.read_text(encoding="utf-8").replace( "address = 127.0.0.1", "address = 192.0.2.10" @@ -991,7 +991,7 @@ class ManagedXrayCliTest(unittest.TestCase): listener = socket.socket() listener.bind(("127.0.0.1", self.port)) listener.listen(1) - state_dir = self.state_home / "xray-proxy" + state_dir = self.state_home / "proxy-client" state_dir.mkdir(parents=True) state = { "supervisor_pid": os.getpid(), @@ -1021,7 +1021,7 @@ class ManagedXrayCliTest(unittest.TestCase): listener = socket.socket() listener.bind(("127.0.0.1", foreign_port)) listener.listen(1) - config_path = self.config_home / "proxy/config" + config_path = self.config_home / "proxy-client/config" config_path.write_text( config_path.read_text(encoding="utf-8").replace( "port = {}".format(managed_port), "port = {}".format(foreign_port) @@ -1056,7 +1056,7 @@ class ManagedXrayCliTest(unittest.TestCase): self.write_profile(profile_text) process = self.start() state = json.loads( - (self.state_home / "xray-proxy/state.json").read_text( + (self.state_home / "proxy-client/state.json").read_text( encoding="utf-8" ) ) @@ -1077,12 +1077,12 @@ class ManagedXrayCliTest(unittest.TestCase): def test_absolute_relative_and_path_symlink_cli_invocations_are_manageable(self): proxy_bin = self.root / "proxy-bin" proxy_bin.mkdir() - (proxy_bin / "xray-proxy").symlink_to(CLI) + (proxy_bin / "proxy-client").symlink_to(CLI) invocations = ( ([str(CLI)], self.root, self.environment), ([os.path.relpath(str(CLI), str(self.root))], self.root, self.environment), ( - ["xray-proxy"], + ["proxy-client"], self.root, dict( self.environment, @@ -1106,7 +1106,7 @@ class ManagedXrayCliTest(unittest.TestCase): stderr=subprocess.PIPE, env=environment, ) - state_path = self.state_home / "xray-proxy/state.json" + state_path = self.state_home / "proxy-client/state.json" deadline = time.monotonic() + 3 while time.monotonic() < deadline and not state_path.exists(): if process.poll() is not None: diff --git a/tests/test_proxy_launchers.sh b/tests/test_proxy_launchers.sh index 97b1e2d..abb3171 100755 --- a/tests/test_proxy_launchers.sh +++ b/tests/test_proxy_launchers.sh @@ -4,7 +4,7 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" FUNCTIONS_FILE="$REPO_ROOT/bash/.bash_functions" CUSTOM_FILE="$REPO_ROOT/bash/.bash_custom" -SHELL_MODULE="$REPO_ROOT/xray-proxy/.config/xray-proxy/shell.bash" +SHELL_MODULE="$REPO_ROOT/proxy-client/.config/proxy-client/shell.bash" pass_count=0 @@ -52,10 +52,10 @@ assert_five_proxy_commands() { # Обычная .bash_custom независимо подключает общие функции и proxy-модуль. ( fixture="$(mktemp -d /tmp/proxy-bash-custom-test.XXXXXX)" - mkdir -p "$fixture/.config/xray-proxy" + mkdir -p "$fixture/.config/proxy-client" ln -s "$CUSTOM_FILE" "$fixture/.bash_custom" ln -s "$FUNCTIONS_FILE" "$fixture/.bash_functions" - ln -s "$SHELL_MODULE" "$fixture/.config/xray-proxy/shell.bash" + ln -s "$SHELL_MODULE" "$fixture/.config/proxy-client/shell.bash" output="$( HOME="$fixture" bash --noprofile --norc -ic ' source "$HOME/.bash_custom" From bca90c1d21f17da982bd9f8bc645c668775b8e83 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Tue, 21 Jul 2026 10:30:53 -0400 Subject: [PATCH 08/10] =?UTF-8?q?docs(proxy-client):=20=D0=B7=D0=B0=D1=84?= =?UTF-8?q?=D0=B8=D0=BA=D1=81=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B0=20?= =?UTF-8?q?=D1=83=D1=81=D0=BF=D0=B5=D1=88=D0=BD=D0=B0=D1=8F=20H3-=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=D1=91=D0=BC=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - нужны фактические результаты ручной приёмки на рабочем профиле. - Что: - подтверждены HTTP CONNECT и SOCKS5h на Ubuntu 26.04 с Xray 26.3.27. - записаны результаты проверки H3 под повторной нагрузкой и после простоя. - сохранён открытым вопрос проверки на других семействах Linux. - Проверка: - 240 последовательных запросов без ошибок, две загрузки по 10 МБ и повторные запросы после 45 секунд простоя. - git diff --cached --check. --- ...21-0743-proxy-client-05-ready-for-human.md | 43 +++++++++++-------- .../05-package-delivery-and-acceptance.md | 29 ++++++++++++- 2 files changed, 51 insertions(+), 21 deletions(-) diff --git a/.scratch/handoffs/20260721-0743-proxy-client-05-ready-for-human.md b/.scratch/handoffs/20260721-0743-proxy-client-05-ready-for-human.md index a62ba66..865b97a 100644 --- a/.scratch/handoffs/20260721-0743-proxy-client-05-ready-for-human.md +++ b/.scratch/handoffs/20260721-0743-proxy-client-05-ready-for-human.md @@ -1,13 +1,14 @@ -# Handoff: `proxy-client` переименован, сетевой маршрут требует диагностики +# Handoff: `proxy-client` переименован, Ubuntu-приёмка пройдена -Обновлён: 2026-07-21 09:23 EDT. +Обновлён: 2026-07-21 10:08 EDT. ## Состояние задачи 05 Автоматическая часть готова. Публичное имя изменено с `xray-proxy` на -`proxy-client`, а ручная приёмка дошла до реального сетевого запроса. Xray -запускается и принимает локальные соединения, но удалённый маршрут пока не -работает. Статус тикета остаётся `ready-for-human`. +`proxy-client`. Ручная приёмка на Ubuntu 26.04 с системным Python 3.14 пройдена +на подтверждённо рабочем профиле XHTTP/TLS с ALPN `h3`. Статус тикета остаётся +`ready-for-human`, пока не решено, нужна ли проверка на РЕД ОС, Astra Linux или +Alt Linux. Тикет: `.scratch/proxy-client/issues/05-package-delivery-and-acceptance.md`. @@ -62,26 +63,30 @@ - `proxy-client start` успешно проверяет версию, строит JSON и запускает Xray; - `proxy-client status` видит управляемый процесс на `127.0.0.1:12334`; - `xray run -test` сообщает `Configuration OK`; +- встроенное определение внешнего IP работает; +- HTTP CONNECT и SOCKS5h через `127.0.0.1:12334` получают HTTP 200; - после проверки процесс штатно остановлен командой `proxy-client stop`. -Не прошло: +Первый профиль не работал и в исходном клиенте. Его дополнительная диагностика +на Xray 26.3.27 и 25.12.8 не выявила расхождений преобразования; оба клиента +получали от удалённой стороны неверный VLESS-ответ. Понижение версии не помогло. +После замены строки на рабочую H3-профиль сразу прошёл полный сценарий на +26.3.27. Содержимое профилей в Git не попадало, временные копии удалены. -- встроенное определение внешнего IP; -- HTTP через `127.0.0.1:12334`; -- SOCKS5h через тот же порт. - -Оба запроса `curl` завершились кодом 35: TLS-соединение неожиданно закрылось во -время рукопожатия. Внешний IP и содержимое профиля в вывод не попадали. +Короткая проверка стабильности H3 также прошла: 120 циклов дали 240 новых HTTP +CONNECT- и SOCKS5h-соединений без ошибок; две одновременные загрузки по 10 МБ +завершились полностью; после 45 секунд простоя оба способа снова получили HTTP +200. Известный сбой первых соединений из XTLS/Xray-core#3673 не воспроизвёлся. +Это не заменяет длительное наблюдение под обычной нагрузкой. ## Следующий шаг -Диагностировать сетевой маршрут XHTTP/TLS: сравнить профиль с рабочим клиентом, -проверить доступность удалённого узла и выяснить, связан ли EOF с преобразованием -профиля, параметрами XHTTP/TLS или удалённой стороной. До этого не отмечать -ручную HTTP/SOCKS-приёмку выполненной. +Решить, обязательна ли ручная проверка на доступной системе из семейства РЕД ОС, +Astra Linux или Alt Linux. Если нет, привести статус исходного дизайна и +заявления о поддержке в соответствие с фактически пройденной Ubuntu-приёмкой. ## Git -Переименование и текущий handoff ещё не закоммичены. Не включать посторонние -неотслеживаемые `agents/.agents/.skill-lock.json` и -`scripts/setup-xray-proxy.sh`. +Переименование зафиксировано коммитом `6e431d8`. Обновления тикета и handoff после +успешной H3-приёмки ещё не закоммичены. Не включать посторонние неотслеживаемые +`agents/.agents/.skill-lock.json` и `scripts/setup-xray-proxy.sh`. diff --git a/.scratch/proxy-client/issues/05-package-delivery-and-acceptance.md b/.scratch/proxy-client/issues/05-package-delivery-and-acceptance.md index d46a1dc..207f1cd 100644 --- a/.scratch/proxy-client/issues/05-package-delivery-and-acceptance.md +++ b/.scratch/proxy-client/issues/05-package-delivery-and-acceptance.md @@ -52,8 +52,9 @@ Исполнитель готовит команды и останавливается. Результат отмечает пользователь; недоступная система остаётся неподтверждённой. -- [ ] Ubuntu с Python 3.10: установка, жизненный цикл, `xray run -test`, HTTP и - SOCKS через один локальный вход Xray 26.3.27. +- [x] Ubuntu 26.04 с системным Python 3.14: установка, жизненный цикл, + `xray run -test`, HTTP и SOCKS через один локальный вход Xray 26.3.27. + Минимальный CPython 3.8 отдельно покрыт автоматическим прогоном через `uv`. - [ ] РЕД ОС, Astra Linux или Alt Linux по доступности: тот же сценарий после проверки фактической версии Python. @@ -116,3 +117,27 @@ Xray 26.3.27 установился и запустился, `status` подтв `curl` завершились кодом 35 с неожиданным закрытием TLS-соединения во время рукопожатия. После проверки Xray штатно остановлен. Пункты ручной приёмки пока не отмечены. + +### 2026-07-21 — успешная приёмка на Ubuntu + +Первая строка оказалась нерабочей и в исходном клиенте. После замены на +подтверждённо рабочий профиль XHTTP/TLS с ALPN `h3` тот же Xray 26.3.27 прошёл +полный сценарий на Ubuntu 26.04 с системным Python 3.14: встроенная проверка +внешнего IP, `status`, `xray run -test`, HTTP CONNECT и SOCKS5h. Оба запроса +`curl` получили HTTP 200. Содержимое профиля в журнал и Git не попадало. + +Диагностическое понижение до Xray 25.12.8 не исправляло старый профиль, поэтому +поддержка второй версии не нужна. Ошибка была связана с прежней строкой или её +удалённой стороной, а не с преобразованием `proxy-client`. + +Короткая проверка стабильности H3 на Xray 26.3.27 также прошла: + +- 120 циклов, в каждом новое HTTP CONNECT- и SOCKS5h-соединение: 240 запросов, + 0 ошибок; +- две одновременные загрузки по 10 МБ через HTTP и SOCKS завершились полностью; +- после 45 секунд без трафика оба новых запроса получили HTTP 200. + +Таким образом, не воспроизвёлся сбой из +[XTLS/Xray-core#3673](https://github.com/XTLS/Xray-core/issues/3673), при котором +H3 переставал принимать новые соединения уже после первых запросов. Этот +короткий прогон не заменяет длительное наблюдение под обычной нагрузкой. From 9c2e134fc19eed1322fb461bd5c48b0407deaa89 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Tue, 21 Jul 2026 11:15:27 -0400 Subject: [PATCH 09/10] =?UTF-8?q?fix(install):=20=D0=BF=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=D0=BE=D1=82=D0=B2=D1=80=D0=B0=D1=89=D0=B5=D0=BD=D0=BE=20=D1=81?= =?UTF-8?q?=D0=B2=D0=BE=D1=80=D0=B0=D1=87=D0=B8=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=BA=D0=B0=D1=82=D0=B0=D0=BB=D0=BE=D0=B3=D0=B0=20.lo?= =?UTF-8?q?cal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - свежая установка могла превратить ~/.local в ссылку на пакет и записать Xray в репозиторий. - Что: - перед Stow теперь гарантируются настоящие ~/.local/bin и ~/.config/proxy-client без раскрытия посторонних ссылок. - добавлены регрессионные проверки свежего HOME и посторонней ссылки ~/.local. - README перестроены вокруг установки, выбора режима и ежедневного использования. - Проверка: - bash tests/test_install.sh. - bash tests/test_proxy_launchers.sh. - bash -n install.sh tests/test_install.sh. - git diff --cached --check. --- ...21-0743-proxy-client-05-ready-for-human.md | 14 +- README.md | 67 +-- install.sh | 8 + proxy-client/README.md | 426 ++++++++++++------ tests/test_install.sh | 27 ++ 5 files changed, 341 insertions(+), 201 deletions(-) diff --git a/.scratch/handoffs/20260721-0743-proxy-client-05-ready-for-human.md b/.scratch/handoffs/20260721-0743-proxy-client-05-ready-for-human.md index 865b97a..5eb67d1 100644 --- a/.scratch/handoffs/20260721-0743-proxy-client-05-ready-for-human.md +++ b/.scratch/handoffs/20260721-0743-proxy-client-05-ready-for-human.md @@ -1,6 +1,6 @@ # Handoff: `proxy-client` переименован, Ubuntu-приёмка пройдена -Обновлён: 2026-07-21 10:08 EDT. +Обновлён: 2026-07-21 11:13 EDT. ## Состояние задачи 05 @@ -79,6 +79,14 @@ CONNECT- и SOCKS5h-соединений без ошибок; две однов 200. Известный сбой первых соединений из XTLS/Xray-core#3673 не воспроизвёлся. Это не заменяет длительное наблюдение под обычной нагрузкой. +## Документация и установка + +Корневой README сокращён до обзора, а `proxy-client/README.md` перестроен по +пути читателя: установка, выбор режима, настройка, ежедневная работа, +диагностика и справочник. Установщик заранее создаёт настоящие `~/.local/bin` и +`~/.config/proxy-client`, чтобы Stow не сворачивал изменяемые каталоги в ссылки +на репозиторий. Это покрыто свежим HOME и посторонней ссылкой `~/.local`. + ## Следующий шаг Решить, обязательна ли ручная проверка на доступной системе из семейства РЕД ОС, @@ -87,6 +95,6 @@ Astra Linux или Alt Linux. Если нет, привести статус и ## Git -Переименование зафиксировано коммитом `6e431d8`. Обновления тикета и handoff после -успешной H3-приёмки ещё не закоммичены. Не включать посторонние неотслеживаемые +Переименование зафиксировано коммитом `6e431d8`, успешная H3-приёмка — +`bca90c1`. Не включать посторонние неотслеживаемые `agents/.agents/.skill-lock.json` и `scripts/setup-xray-proxy.sh`. diff --git a/README.md b/README.md index 6cfee0b..e136d18 100644 --- a/README.md +++ b/README.md @@ -97,67 +97,14 @@ export ZAI_API_KEY="..." # https://z.ai ### Прокси (`proxy-client`) -Linux/Bash-команды `proxy_on/off`, `pclaude`, `pcodex` и `myip` находятся в -самостоятельном пакете [`proxy-client`](proxy-client/README.md). Он поддерживает -явно выбранный внешний Hiddify или управляемый пользовательский Xray 26.3.27 с -профилями VLESS TCP/RAW + REALITY и XHTTP + TLS. Для отдельного подключения -сначала выполните `mkdir -p ~/.config/proxy-client`, затем -`stow proxy-client`. Команды `proxy-client install/start/stop/status` -работают без systemd, автозапуска и автоматического перехода между прокси. Без -конфига сохраняется внешний Hiddify `hproxy.local:12334`. +Linux/Bash-интерфейс `proxy-client` поддерживает внешний прокси и управляемый +пользовательский Xray. Без машинного конфига используется прежний Hiddify на +`hproxy.local:12334`; автоматического запуска Xray и переключения между режимами +нет. -Имя `hproxy.local` — прежний алиас хоста с внешним прокси. Windows и VS Code -Server продолжают использовать его напрямую. - -**Порты:** HTTP/HTTPS — `hproxy.local:12334`, SOCKS5 — `socks5://hproxy.local:12334`. - -**WSL NAT mode** (IP шлюза меняется при каждом запуске): - -```bash -# Через install.sh (интерактивно, ответить "y" на вопрос про wsl.conf) -./install.sh - -# Или вручную: -sudo tee -a /etc/wsl.conf <<'EOF' - -[boot] -command = "/path/to/dotfiles/scripts/setup-hproxy.sh" -EOF -``` - -После изменения `wsl.conf` — `wsl --shutdown`. Обновить без перезапуска: `sudo scripts/setup-hproxy.sh` или `update_hproxy`. - -**WSL mirrored mode** (`networkingMode=mirrored`) — имя резолвится из WSL через DNS/hosts хоста. Достаточно записи на стороне Windows: - -``` -# C:\Windows\System32\drivers\etc\hosts -192.168.1.100 hproxy.local -``` - -Проверка: `getent hosts hproxy.local` (запись в Linux `/etc/hosts` не обязательна). - -**Native Linux** — прокси на localhost, нужна запись в `/etc/hosts`: - -```bash -echo '127.0.0.1 hproxy.local' | sudo tee -a /etc/hosts -``` - -**VS Code Server** — `vscode/.vscode-server/server-env-setup` автоматически подхватывает `hproxy.local` и выставляет все proxy-переменные (HTTP_PROXY, HTTPS_PROXY, ALL_PROXY, NO_PROXY и lowercase-варианты). Если `hproxy.local` не резолвится — прокси не включается, запуск не блокируется. - -**Windows pwsh** — `proxy_on` / `proxy_off` / `myip` работают аналогично bash-версиям. - -**`pclaude` / `pcodex`** — явный запуск Claude/Codex CLI через прокси (bash и pwsh). Bash-версия делегирует проверку и запуск `proxy-client run`; Windows-версия сохраняет прежний адрес `hproxy.local:12334`. Таймаут — `PROXY_PREFLIGHT_TIMEOUT`, по умолчанию 2 c. Если прокси недоступен, клиент не запускается. Proxy-переменные видны только дочернему процессу и не остаются в сессии. Голые `claude`/`codex` не перекрываются и идут напрямую — для прокси нужен явный `p`-префикс. - -**Codex GUI на Windows** — chezmoi создает Start Menu ярлык `Codex Proxy`, который запускает Codex Windows App с proxy-переменными: - -```powershell -HTTP_PROXY=http://hproxy.local:12334 -HTTPS_PROXY=http://hproxy.local:12334 -ALL_PROXY=socks5://hproxy.local:12334 -NO_PROXY=hproxy.local,localhost,127.0.0.1,::1,[::1],.concept-software.ru -``` - -Ярлык вызывает `~/.local/bin/start-codex-proxy.ps1`. Скрипт также добавляет эти переменные в `WSLENV` с флагом `/u`: это штатный мост Windows → WSL, чтобы дочерние WSL-сессии, запущенные из Codex GUI, получили тот же proxy environment. +[Установка, выбор режима, WSL и ежедневное использование](proxy-client/README.md) +описаны рядом с пакетом. Windows PowerShell и ярлык `Codex Proxy` сохраняют +прежнюю интеграцию с `hproxy.local:12334`. ### Git baseline diff --git a/install.sh b/install.sh index 2168346..770e2b7 100755 --- a/install.sh +++ b/install.sh @@ -317,6 +317,14 @@ done migrate_zed_config_stow_symlink +if [ ! -L "$HOME/.local" ]; then + mkdir -p "$HOME/.local" +fi +if [ -d "$HOME/.local" ] && [ ! -L "$HOME/.local" ] \ + && [ ! -L "$HOME/.local/bin" ]; then + mkdir -p "$HOME/.local/bin" +fi + if [ ! -L "$HOME/.config" ]; then mkdir -p "$HOME/.config" fi diff --git a/proxy-client/README.md b/proxy-client/README.md index f29b9a7..6c26514 100644 --- a/proxy-client/README.md +++ b/proxy-client/README.md @@ -1,175 +1,325 @@ # proxy-client -Переносимый Linux/Bash-интерфейс для безопасного запуска команд через явно -настроенный внешний прокси или через управляемый пользовательский Xray. +`proxy-client` — переносимый Linux/Bash-интерфейс для запуска команд через +внешний прокси или через управляемый пользовательский Xray. Он добавляет команды +`proxy_on`, `proxy_off`, `pclaude` и `pcodex`, но не подменяет обычные `claude` +и `codex`. -CLI требует Python 3.8+ и использует только стандартную библиотеку. Он ничего не -устанавливает и не запускает без явной подкоманды. `start` использует первый -`xray` из `PATH`; скачивания и установки при запуске нет. +CLI требует Python 3.8+ и использует только стандартную библиотеку. Он не +запускает Xray и не переключает режим прокси без явной команды пользователя. -## Подключение +## Установка -В составе dotfiles: +### В составе dotfiles + +Полная установка подключает пакет и автоматически загружает Bash-функции в +новых интерактивных сессиях: ```bash cd ~/dotfiles -mkdir -p ~/.config/proxy-client +./install.sh +``` + +Установщик заранее создаёт `~/.local/bin` и `~/.config/proxy-client`, чтобы Stow +размещал ссылки внутри них, а не превращал сами каталоги в ссылки на +репозиторий. + +После установки откройте новый Bash или выполните: + +```bash +source ~/.bash_custom +``` + +### Только пакет `proxy-client` + +```bash +cd ~/dotfiles +mkdir -p ~/.local/bin ~/.config/proxy-client stow proxy-client source ~/.config/proxy-client/shell.bash ``` -Полный `./install.sh` выполняет тот же `stow` вместе с остальными пакетами, а -`.bash_custom` подключает модуль автоматически. +Если Bash-пакет dotfiles не установлен, добавьте подключение модуля в +`~/.bashrc`: -Без остальных dotfiles можно создать две ссылки: +```bash +printf '%s\n' 'source ~/.config/proxy-client/shell.bash' >> ~/.bashrc +``` + +## Как выбирается прокси + +Режим определяется только машинным файлом +`~/.config/proxy-client/config`: + +| Машинный конфиг | Используется | Что запускать | +|-----------------|-------------|---------------| +| Файла нет | Hiddify на `hproxy.local:12334` | Ничего | +| `type = external` | Указанный внешний прокси | Ничего | +| `type = xray` | Управляемый локальный Xray | `proxy-client start` | + +Доступность процесса не меняет выбранный режим. Если в конфиге указан Xray, но +он не запущен, `status`, `env`, `run`, `proxy_on`, `pclaude` и `pcodex` +завершатся с ошибкой. Автоматического перехода на Hiddify или другой прокси нет. + +Текущий выбор и доступность проверяет команда: + +```bash +proxy-client status +``` + +## Настройка режима + +### Hiddify в Windows, команды в WSL + +Это режим по умолчанию. Если Hiddify доступен из WSL как +`hproxy.local:12334`, создавать `config` не нужно: + +```bash +getent hosts hproxy.local +proxy-client status +pclaude --version +``` + +В WSL NAT mode IP Windows-шлюза меняется при перезапуске. Интерактивный +`./install.sh` предлагает добавить обновление `hproxy.local` в `/etc/wsl.conf`. +То же можно настроить вручную: + +```ini +[boot] +command = "/path/to/dotfiles/scripts/setup-hproxy.sh" +``` + +После изменения выполните `wsl --shutdown` из Windows. Обновить адрес без +перезапуска WSL можно так: + +```bash +sudo ~/dotfiles/scripts/setup-hproxy.sh +# или после загрузки Bash-функций: +update_hproxy +``` + +В NAT mode Hiddify должен слушать адрес, доступный через Windows-шлюз, а не +только `127.0.0.1`. + +В [WSL mirrored mode](https://learn.microsoft.com/windows/wsl/networking#mirrored-mode-networking) +(`networkingMode=mirrored`) Windows-сервисы доступны из WSL через `127.0.0.1`. +Чтобы сохранить адрес по умолчанию, добавьте алиас в Windows hosts: + +```text +# C:\Windows\System32\drivers\etc\hosts +127.0.0.1 hproxy.local +``` + +Альтернатива — создать `config.external` с `address = 127.0.0.1`. В обоих +случаях проверьте результат через `getent hosts hproxy.local` или +`proxy-client status`. + +### Другой внешний прокси + +Скопируйте пример и измените адрес, порт и локальные исключения: + +```bash +cp ~/.config/proxy-client/examples/config.external \ + ~/.config/proxy-client/config +``` + +```ini +[proxy] +type = external +name = Office proxy +address = proxy.example.internal +port = 12334 +no_proxy_extra = .example.internal,git.example.internal +``` + +Для внешнего прокси на той же Linux-машине можно указать `127.0.0.1` напрямую. + +### Локальный Xray + +Создайте машинный конфиг: + +```bash +cp ~/.config/proxy-client/examples/config.xray \ + ~/.config/proxy-client/config +``` + +Закрытый профиль должен содержать ровно одну VLESS-ссылку: + +```bash +cp ~/.config/proxy-client/examples/default.url \ + ~/.config/proxy-client/default.url +$EDITOR ~/.config/proxy-client/default.url +chmod 600 ~/.config/proxy-client/default.url +``` + +Установите проверенную версию Xray без root: + +```bash +proxy-client install +``` + +Запустите Xray в отдельной панели tmux: + +```bash +proxy-client start +``` + +Другой закрытый профиль можно выбрать без замены `default.url`: + +```bash +proxy-client start -f ~/.config/proxy-client/work.url +``` + +Для переключения профиля сначала выполните `proxy-client stop`. Все файлы с +настоящими VLESS-ссылками должны иметь права `600` и не должны попадать в Git. + +## Ежедневное использование + +| Задача | Команда | Область действия | +|--------|---------|------------------| +| Запустить Claude через прокси | `pclaude [ARG...]` | Только дочерний процесс | +| Запустить Codex через прокси | `pcodex [ARG...]` | Только дочерний процесс | +| Запустить другую команду | `proxy-client run -- COMMAND ARG...` | Только дочерний процесс | +| Включить прокси | `proxy_on` | Текущая Bash-сессия | +| Выключить прокси | `proxy_off` | Текущая Bash-сессия | +| Проверить прокси | `proxy-client status` | Без изменения окружения | +| Получить proxy-переменные | `proxy-client env` | Машинный вывод в stdout | +| Показать внешний IP | `myip` | С учётом текущего окружения | + +`run`, `pclaude` и `pcodex` передают дочернему процессу восемь вариантов +`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY` и `NO_PROXY`, включая имена в нижнем +регистре. После завершения команды переменные не остаются в оболочке. + +`proxy_on` применяет те же переменные к текущей Bash-сессии только после +успешной проверки прокси. `proxy_off` удаляет их. Обычные `claude` и `codex` не +обёрнуты и не выполняют предварительную проверку, но после `proxy_on` наследуют +прокси-переменные, как и любой другой дочерний процесс. + +### Жизненный цикл Xray + +```bash +proxy-client install [--replace] +proxy-client start [-f PROFILE] +proxy-client status +proxy-client stop +``` + +`start` сначала проверяет версию и конфигурацию Xray, затем остаётся на переднем +плане. Systemd, фоновый автозапуск и скрытый запуск из `run` не используются. +Один loopback-порт принимает HTTP CONNECT и SOCKS5. + +`stop` завершает управляемые процессы и удаляет созданные служебные файлы. Он +работает и после изменения машинного типа на `external`. + +## Диагностика + +Начните с проверки выбранного режима: + +```bash +proxy-client status +``` + +Для внешнего Hiddify в WSL проверьте имя и порт: + +```bash +getent hosts hproxy.local +curl --proxy http://hproxy.local:12334 https://example.com +curl --proxy socks5h://hproxy.local:12334 https://example.com +``` + +Для запущенного Xray можно дополнительно проверить созданный JSON: + +```bash +xray run -test -c \ + "${XDG_STATE_HOME:-$HOME/.local/state}/proxy-client/client.json" +``` + +Если выбран `type = xray`, но процесс не запущен, запустите его отдельно — +`proxy_on` и `run` этого не делают. Профиль XHTTP с ALPN `h3` требует доступного +UDP-маршрута до сервера; автоматического перехода на H2 нет. + +Таймаут предварительной проверки по умолчанию равен двум секундам. Его можно +изменить положительным числом: + +```bash +export PROXY_PREFLIGHT_TIMEOUT=5 +``` + +## Справочник + +### Ручное подключение без Stow + +Можно создать ссылки на CLI, Bash-модуль и примеры либо скопировать те же файлы: ```bash mkdir -p ~/.local/bin ~/.config/proxy-client ln -s /path/to/proxy-client/.local/bin/proxy-client ~/.local/bin/proxy-client ln -s /path/to/proxy-client/.config/proxy-client/shell.bash \ ~/.config/proxy-client/shell.bash -printf '%s\n' 'source ~/.config/proxy-client/shell.bash' >> ~/.bashrc +ln -s /path/to/proxy-client/.config/proxy-client/examples \ + ~/.config/proxy-client/examples ``` -Вместо ссылок файлы можно скопировать в те же пути. Исполняемому файлу нужен -режим `chmod 755`. Каталог `~/.local/bin` должен входить в `PATH`. +Исполняемому файлу нужен режим `755`, а `~/.local/bin` должен входить в `PATH`. -## Машинный конфиг +### Машинный конфиг -Скопируйте пример нужного режима и отредактируйте его: +Формат строгий: одна секция `[proxy]` и пять обязательных ключей без повторов и +неизвестных полей: -```bash -mkdir -p ~/.config/proxy-client -cp ~/.config/proxy-client/examples/config.xray ~/.config/proxy-client/config -# или для внешнего Hiddify: -cp ~/.config/proxy-client/examples/config.external ~/.config/proxy-client/config -``` +- `type` — `external` или `xray`; +- `name` — имя для диагностики; +- `address` — IPv4, IPv6 без квадратных скобок или ASCII DNS-имя без схемы и + порта; +- `port` — TCP-порт локального входа; +- `no_proxy_extra` — дополнительные IP и домены через запятую, значение может + быть пустым. -Формат строгий: одна секция `[proxy]`, пять обязательных ключей без повторов и -неизвестных полей. - -```ini -[proxy] -type = external -name = Hiddify -address = hproxy.local -port = 12334 -no_proxy_extra = .example.internal,git.example.internal -``` - -`address` — IPv4, IPv6 без квадратных скобок или ASCII DNS-имя без схемы и -порта. `no_proxy_extra` — список через запятую; значение может быть пустым. Поддержка CIDR в `NO_PROXY` различается между программами, поэтому надёжнее -указывать точные IP и домены. +указывать точные IP и домены. Без машинного файла внешний режим дополнительно +включает `.concept-software.ru` в `NO_PROXY`. Для `type = xray` адрес обязан +быть адресом обратной петли, например `127.0.0.1` или `::1`. -Если файла нет, используется прежний внешний Hiddify на -`hproxy.local:12334`, включая `.concept-software.ru` в `NO_PROXY`. Это не -переключает тип прокси автоматически. +### Поддерживаемые профили -Для управляемого Xray задайте явный тип, loopback IP и нужный локальный порт: - -```ini -[proxy] -type = xray -name = Local Xray -address = 127.0.0.1 -port = 12334 -no_proxy_extra = .example.internal,git.example.internal -``` - -Секретный профиль хранится отдельно. Скопируйте синтетический пример, замените -его одной настоящей VLESS-ссылкой и закройте права конечного файла: - -```bash -cp ~/.config/proxy-client/examples/default.url ~/.config/proxy-client/default.url -chmod 600 ~/.config/proxy-client/default.url -``` - -Файл должен содержать ровно одну непустую строку. Можно передать другой файл -через `proxy-client start -f /path/to/profile.url`. Если это симлинк, проверяются -права конечного файла. - -## Команды - -```bash -proxy-client install [--replace] -proxy-client status -proxy-client start [-f PROFILE] -proxy-client stop -proxy-client env -proxy-client run -- COMMAND ARG... -proxy_on -proxy_off -pclaude [ARG...] -pcodex [ARG...] -myip -``` - -`install` без root устанавливает единственную поддержанную версию Xray -`26.3.27` в `~/.local/bin/xray`. Для `x86_64`/`amd64` используется официальный -`Xray-linux-64.zip`, для `aarch64`/`arm64` — `Xray-linux-arm64-v8a.zip`. -Остальные архитектуры отклоняются. CLI скачивает рядом файл `.dgst`, проверяет -единственную запись `SHA2-256= <64 lowercase hex>` в официальном -четырёхстрочном формате, извлекает `xray` в закрытый временный каталог внутри -`~/.local/bin`, проверяет `xray version` и только затем атомарно меняет -назначение. Дубликаты, uppercase, пропущенные и лишние записи отклоняются. -Архив и извлечённый бинарник ограничены 64 MiB, `.dgst` — 4 KiB; объявленный -`Content-Length` должен точно совпадать с фактически прочитанным размером. - -Существующая корректная версия `26.3.27` даёт успешный no-op без сети. Другой, -битый, неисполняемый файл или симлинк не заменяется без `--replace`; каталог не -заменяется никогда. Если после установки `PATH` выбирает не этот файл, команда -завершается с ошибкой и предлагает поставить `~/.local/bin` в начало `PATH`, -но проверенная установка сохраняется. Родительский симлинк принимается, только -если разрешённый каталог остаётся внутри `HOME`. Во время живого или -неподтверждённого managed-экземпляра установка запрещена: сначала выполните -`proxy-client stop`. - -Для `external` команды `status`, `env` и `run` проверяют настроенный адрес и -порт. Для `xray` они дополнительно требуют закрытое состояние именно того -супервизора и дочернего Xray, который запустил CLI. Один открытый порт или чужой -PID не принимается. `run` никогда не запускает Xray скрыто. - -`start` поддерживает только Xray `26.3.27`, сначала выполняет -`xray version` и `xray run -test`, затем остаётся на переднем плане. Используйте -отдельную панель tmux. После готовности один loopback-порт принимает HTTP и -SOCKS. `stop` ограниченно ждёт завершения супервизора и Xray, очищает служебные -файлы и возвращает исходный `start` в терминал. Команда `stop` работает и после -явного переключения машинного типа на `external`. CLI не создаёт systemd-службу, -не запускает Xray автоматически и не переключается между типами прокси. - -`run` передаёт восемь proxy-переменных только дочернему процессу и сохраняет -аргументы и код возврата. `proxy_on` атомарно применяет те же восемь переменных -к текущей Bash-сессии; `proxy_off` удаляет их. - -Таймаут проверки задаётся положительным числом секунд: - -```bash -export PROXY_PREFLIGHT_TIMEOUT=2 -``` - -Значение по умолчанию — 2 секунды. `env` при успехе печатает только восемь строк -`KEY=value`; для обычной диагностики используйте `status` или `myip`. - -## Поддерживаемые профили - -Поддерживаются только VLESS TCP/RAW с REALITY и VLESS XHTTP с TLS. Неизвестные, +Поддерживаются VLESS TCP/RAW с REALITY и VLESS XHTTP с TLS. Неизвестные, повторные и несовместимые query-параметры отклоняются. XHTTP поддерживает ALPN `http/1.1`, `h2` и `h3`; `extra` должен быть JSON-объектом без повторных ключей. Xray 26.3.27 использует `pcs` и `vcn`, а устаревшие `allowInsecure` и `insecure` не переносит. Истинное legacy-значение даёт предупреждение, но -обычная проверка сертификата остаётся включённой. Автоматического перехода -между версиями или типами прокси нет. +обычная проверка сертификата остаётся включённой. -Для справки при ручном конфигурировании: последняя версия Xray, где -`allowInsecure` ещё применяется, — +Последняя версия Xray, где `allowInsecure` ещё применяется, — [25.12.8](https://github.com/XTLS/Xray-core/releases/tag/v25.12.8); опция удалена в [26.2.6](https://github.com/XTLS/Xray-core/releases/tag/v26.2.6). -CLI такие версии не поддерживает и не устанавливает. +`proxy-client` эти версии не поддерживает и не устанавливает. -Сводка скрывает UUID и ключи. Содержимое XHTTP `path` не показывается — только -его длина. Сгенерированный JSON и состояние лежат в закрытом каталоге -`$XDG_STATE_HOME/proxy-client` либо `~/.local/state/proxy-client` и удаляются после -штатной остановки. Сбой диагностики внешнего IP через локальный HTTP-прокси -показывается как предупреждение и не останавливает уже готовый Xray. +### Проверяемая установка Xray + +`proxy-client install` устанавливает Xray 26.3.27 в `~/.local/bin/xray`. Для +`x86_64`/`amd64` используется `Xray-linux-64.zip`, для `aarch64`/`arm64` — +`Xray-linux-arm64-v8a.zip`; остальные архитектуры отклоняются. + +CLI скачивает официальный архив и файл `.dgst`, проверяет SHA-256, ограничения +размера и фактическую версию бинарника, а затем атомарно заменяет назначение. +Корректная уже установленная версия даёт no-op без сети. Другой файл или симлинк +не заменяется без `--replace`; каталог не заменяется никогда. + +`start` использует первый `xray` из `PATH` и принимает только версию 26.3.27. +Если `PATH` выбирает другой бинарник, команда сообщает об ошибке. Во время +живого или неподтверждённого управляемого процесса установка запрещена. + +### Файлы и безопасность + +| Путь | Назначение | +|------|------------| +| `~/.config/proxy-client/config` | Несекретный машинный выбор режима | +| `~/.config/proxy-client/default.url` | Закрытая VLESS-ссылка, права `600` | +| `~/.config/proxy-client/examples/` | Синтетические примеры без секретов | +| `$XDG_STATE_HOME/proxy-client/` или `~/.local/state/proxy-client/` | Состояние и сгенерированный JSON | + +Сводка скрывает UUID и ключи, а для XHTTP показывает только длину `path`. +Служебный каталог закрыт для других пользователей и очищается после штатной +остановки Xray. Сбой определения внешнего IP показывается как предупреждение и +не останавливает уже готовый процесс. diff --git a/tests/test_install.sh b/tests/test_install.sh index 2f3250e..c412699 100755 --- a/tests/test_install.sh +++ b/tests/test_install.sh @@ -97,6 +97,10 @@ test_fresh_install_uses_home_target() { run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >"$fixture/install.out" [ -L "$home_dir/.bash_custom" ] || fail "Expected .bash_custom symlink in HOME" + [ -d "$home_dir/.local" ] || fail "Expected ~/.local directory" + [ ! -L "$home_dir/.local" ] || fail "Expected ~/.local to stay a real directory" + [ -d "$home_dir/.local/bin" ] || fail "Expected ~/.local/bin directory" + [ ! -L "$home_dir/.local/bin" ] || fail "Expected ~/.local/bin to stay a real directory" [ -x "$home_dir/.local/bin/proxy-client" ] || fail "Expected executable proxy-client CLI in HOME" [ -d "$home_dir/.config/proxy-client" ] || fail "Expected proxy-client config directory" [ ! -L "$home_dir/.config/proxy-client" ] || fail "Expected proxy-client config to stay outside the repository" @@ -221,6 +225,28 @@ test_does_not_migrate_unrelated_config_symlink() { assert_not_exists "$unrelated_config/proxy-client" } +test_does_not_follow_unrelated_local_symlink() { + local fixture repo_root home_dir unrelated_local target expected + fixture="$(setup_fixture)" + repo_root="$fixture/repo" + home_dir="$fixture/home" + unrelated_local="$fixture/unrelated-local" + mkdir -p "$home_dir" "$unrelated_local" + printf '%s\n' 'keep me' > "$unrelated_local/local.conf" + ln -s "$unrelated_local" "$home_dir/.local" + + # Установщик не должен создавать bin через произвольную ссылку ~/.local. + run_install "$repo_root" "$home_dir" \ + env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >"$fixture/install.out" 2>&1 || true + + [ -L "$home_dir/.local" ] || fail "Expected unrelated ~/.local symlink to be preserved" + target="$(readlink -f "$home_dir/.local")" + expected="$(readlink -f "$unrelated_local")" + [ "$target" = "$expected" ] || fail "Unrelated ~/.local target changed" + assert_file_contains "$unrelated_local/local.conf" "keep me" + assert_not_exists "$unrelated_local/bin" +} + test_settings_local_is_symlinked_to_repo() { local fixture repo_root home_dir fixture="$(setup_fixture)" @@ -505,6 +531,7 @@ main() { test_migrates_legacy_zed_config_symlink test_stops_legacy_migration_when_listing_fails test_does_not_migrate_unrelated_config_symlink + test_does_not_follow_unrelated_local_symlink test_settings_local_is_symlinked_to_repo test_settings_local_backup_on_existing_file test_settings_local_dir_is_skipped From fb655f0dc83ae1eed33b177ae31b0883260639a0 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Tue, 21 Jul 2026 11:21:37 -0400 Subject: [PATCH 10/10] =?UTF-8?q?chore(scratch):=20=D0=BE=D1=87=D0=B8?= =?UTF-8?q?=D1=89=D0=B5=D0=BD=20=D1=80=D0=B0=D0=B1=D0=BE=D1=87=D0=B8=D0=B9?= =?UTF-8?q?=20=D0=BA=D0=B0=D1=82=D0=B0=D0=BB=D0=BE=D0=B3=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B4=20=D1=81=D0=BB=D0=B8=D1=8F=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - рабочие задачи и handoff не должны попадать в итоговое состояние master. - Что: - удалены временные материалы разработки proxy-client. - добавлен .scratch/.gitkeep для сохранения пустого рабочего каталога. - Проверка: - git diff --cached --check. - find .scratch -mindepth 1 -print. --- .scratch/.gitkeep | 0 ...21-0743-proxy-client-05-ready-for-human.md | 100 ---------- .../01-external-proxy-and-portable-shell.md | 70 ------- .../issues/02-managed-xray-happy-path.md | 74 ------- .../issues/03-safe-process-lifecycle.md | 132 ------------- .../issues/04-verified-xray-installation.md | 144 -------------- .../05-package-delivery-and-acceptance.md | 143 -------------- .../issues/06-simplification-pass.md | 180 ------------------ .scratch/proxy-client/token-ledger.md | 99 ---------- 9 files changed, 942 deletions(-) create mode 100644 .scratch/.gitkeep delete mode 100644 .scratch/handoffs/20260721-0743-proxy-client-05-ready-for-human.md delete mode 100644 .scratch/proxy-client/issues/01-external-proxy-and-portable-shell.md delete mode 100644 .scratch/proxy-client/issues/02-managed-xray-happy-path.md delete mode 100644 .scratch/proxy-client/issues/03-safe-process-lifecycle.md delete mode 100644 .scratch/proxy-client/issues/04-verified-xray-installation.md delete mode 100644 .scratch/proxy-client/issues/05-package-delivery-and-acceptance.md delete mode 100644 .scratch/proxy-client/issues/06-simplification-pass.md delete mode 100644 .scratch/proxy-client/token-ledger.md diff --git a/.scratch/.gitkeep b/.scratch/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.scratch/handoffs/20260721-0743-proxy-client-05-ready-for-human.md b/.scratch/handoffs/20260721-0743-proxy-client-05-ready-for-human.md deleted file mode 100644 index 5eb67d1..0000000 --- a/.scratch/handoffs/20260721-0743-proxy-client-05-ready-for-human.md +++ /dev/null @@ -1,100 +0,0 @@ -# Handoff: `proxy-client` переименован, Ubuntu-приёмка пройдена - -Обновлён: 2026-07-21 11:13 EDT. - -## Состояние задачи 05 - -Автоматическая часть готова. Публичное имя изменено с `xray-proxy` на -`proxy-client`. Ручная приёмка на Ubuntu 26.04 с системным Python 3.14 пройдена -на подтверждённо рабочем профиле XHTTP/TLS с ALPN `h3`. Статус тикета остаётся -`ready-for-human`, пока не решено, нужна ли проверка на РЕД ОС, Astra Linux или -Alt Linux. - -Тикет: `.scratch/proxy-client/issues/05-package-delivery-and-acceptance.md`. - -## Переименование - -- stow-пакет: `proxy-client/`; -- CLI: `~/.local/bin/proxy-client`; -- единый пользовательский каталог: `~/.config/proxy-client/`; -- служебный каталог: `~/.local/state/proxy-client/`; -- машинный конфиг и закрытый профиль теперь лежат рядом как `config` и - `default.url`; -- примеры называются `config.external` и `config.xray`; -- имена тестов, спецификации, задач, README и Bash-модуля приведены к - `proxy-client`; -- `Xray`, бинарник `xray` и значение `type = xray` сохранены как названия - конкретного движка. - -Поддержка старой раскладки в `install.sh` намеренно не добавлена: публичное имя -изменено до завершения разработки и выпуска. Текущий HOME уже приведён к новой -структуре. Свежая установка заранее создаёт настоящий -`~/.config/proxy-client`, поэтому Stow не может свернуть его в ссылку на -репозиторий. - -## Автоматическая проверка - -`bash tests/test_proxy_client_full.sh` завершился с кодом 0 после -переименования: - -- системный Python 3.14: 79 тестов, `OK`, 61,034 с; -- Bash-интеграция: 8 проверок, `PASS`; `shellcheck` не установлен; -- установка и миграция: `All install tests passed`; -- CPython 3.8 через `uv`: 79 тестов, `OK`, 52,446 с. - -Дополнительно прошли синтаксическая проверка shell-файлов и `git diff --check`. - -## Текущий HOME - -Во время разработки текущий HOME приведён к новой структуре: - -- `~/.config/proxy-client` — настоящий каталог; -- `config` и `default.url` — обычные файлы с правами `600`; -- профиль содержит одну непустую строку, его содержимое не выводилось; -- `shell.bash`, `examples` и CLI указывают в новый stow-пакет; -- старые `~/.config/xray-proxy`, `~/.config/proxy/config`, - `~/.local/bin/xray-proxy` и `~/.local/state/xray-proxy` отсутствуют. - -## Ручная приёмка - -Подтверждено: - -- установлен Xray 26.3.27; -- `proxy-client start` успешно проверяет версию, строит JSON и запускает Xray; -- `proxy-client status` видит управляемый процесс на `127.0.0.1:12334`; -- `xray run -test` сообщает `Configuration OK`; -- встроенное определение внешнего IP работает; -- HTTP CONNECT и SOCKS5h через `127.0.0.1:12334` получают HTTP 200; -- после проверки процесс штатно остановлен командой `proxy-client stop`. - -Первый профиль не работал и в исходном клиенте. Его дополнительная диагностика -на Xray 26.3.27 и 25.12.8 не выявила расхождений преобразования; оба клиента -получали от удалённой стороны неверный VLESS-ответ. Понижение версии не помогло. -После замены строки на рабочую H3-профиль сразу прошёл полный сценарий на -26.3.27. Содержимое профилей в Git не попадало, временные копии удалены. - -Короткая проверка стабильности H3 также прошла: 120 циклов дали 240 новых HTTP -CONNECT- и SOCKS5h-соединений без ошибок; две одновременные загрузки по 10 МБ -завершились полностью; после 45 секунд простоя оба способа снова получили HTTP -200. Известный сбой первых соединений из XTLS/Xray-core#3673 не воспроизвёлся. -Это не заменяет длительное наблюдение под обычной нагрузкой. - -## Документация и установка - -Корневой README сокращён до обзора, а `proxy-client/README.md` перестроен по -пути читателя: установка, выбор режима, настройка, ежедневная работа, -диагностика и справочник. Установщик заранее создаёт настоящие `~/.local/bin` и -`~/.config/proxy-client`, чтобы Stow не сворачивал изменяемые каталоги в ссылки -на репозиторий. Это покрыто свежим HOME и посторонней ссылкой `~/.local`. - -## Следующий шаг - -Решить, обязательна ли ручная проверка на доступной системе из семейства РЕД ОС, -Astra Linux или Alt Linux. Если нет, привести статус исходного дизайна и -заявления о поддержке в соответствие с фактически пройденной Ubuntu-приёмкой. - -## Git - -Переименование зафиксировано коммитом `6e431d8`, успешная H3-приёмка — -`bca90c1`. Не включать посторонние неотслеживаемые -`agents/.agents/.skill-lock.json` и `scripts/setup-xray-proxy.sh`. diff --git a/.scratch/proxy-client/issues/01-external-proxy-and-portable-shell.md b/.scratch/proxy-client/issues/01-external-proxy-and-portable-shell.md deleted file mode 100644 index a6fd35f..0000000 --- a/.scratch/proxy-client/issues/01-external-proxy-and-portable-shell.md +++ /dev/null @@ -1,70 +0,0 @@ -# 01 — Внешний прокси и переносимый Bash-интерфейс - -**What to build:** Самостоятельный пользовательский пакет прокси, через который -можно безопасно запускать команды с уже работающим внешним прокси, например -Hiddify. Привычные Bash-команды переходят на единый CLI-контракт, но прежний -сценарий без машинного конфига продолжает работать. - -**Blocked by:** None — can start immediately. - -**Status:** done - -Часть требований пересмотрена в 06 — см. 06-simplification-pass.md. - -- [x] CLI работает на Python 3.8+ без сторонних библиотек, ничего не устанавливает - и не запускает без явной подкоманды и выдаёт понятную ошибку на более старом - Python. -- [x] Машинный INI явно различает типы `external` и `xray`, проверяет имя, адрес, - порт и добавку к `NO_PROXY`; отсутствие файла сохраняет внешний прокси - `hproxy.local:12334` без неявного переключения на другой тип. -- [x] Канонический машинный INI содержит единственную секцию `[proxy]` и ровно - по одному ключу `type`, `name`, `address`, `port`, `no_proxy_extra`: - - ```ini - [proxy] - type = external - name = Hiddify - address = hproxy.local - port = 12334 - no_proxy_extra = .example.internal,git.example.internal - ``` - - Все ключи обязательны при наличии файла; `no_proxy_extra` может быть пустым. - Неизвестные секции и ключи, повторы, пропуски, многострочные или содержащие - NUL значения, пустые имя или адрес и порт вне диапазона 1–65535 отклоняются. - Добавки к `NO_PROXY` задаются через запятую, окружающие пробелы удаляются, - пустые элементы отклоняются. -- [x] `address` принимает IPv4, IPv6 без квадратных скобок либо ASCII DNS-имя - из корректных непустых меток; схема URL, встроенный порт, скобки, zone ID, - пробелы, `/`, `@` и другие URL-разделители отклоняются. При формировании - proxy-URL CLI сам заключает IPv6-литерал в квадратные скобки. -- [x] Для типа `external` команды `status`, `env` и `run -- COMMAND ARG...` - проверяют только настроенный адрес и порт; недоступный прокси запрещает запуск - дочерней команды с понятным сообщением. -- [x] До реализации управляемого процесса конфиг с `type=xray` обрабатывается - безопасным отказом: `status`, `env`, `run` и `start` не считают один открытый - порт доказательством управляемого Xray и не запускают дочернюю команду. -- [x] `env` при успехе печатает только восемь разрешённых proxy-переменных в двух - регистрах, а при ошибке не печатает частичный результат; базовый `NO_PROXY` - включает loopback и узел прокси, затем дополняется машинной настройкой. -- [x] Документированный `PROXY_PREFLIGHT_TIMEOUT` сохраняется как положительное - число секунд с прежним значением по умолчанию `2` для проверок доступности из - `status`, `env` и `run`; ошибочное значение даёт понятный отказ. -- [x] `run` без искажений сохраняет аргументы и код возврата команды, передаёт - proxy-переменные только дочернему процессу и возвращает отдельную понятную - ошибку, если команда отсутствует в `PATH`. -- [x] Переносимый Bash-модуль становится единственным владельцем `proxy_on`, - `proxy_off`, `pclaude`, `pcodex`, `myip` и внутренних proxy-помощников; - WSL-функция обновления `hproxy.local` остаётся независимой. -- [x] Обычный запуск dotfiles подключает общий Bash-файл и переносимый - proxy-модуль независимо: после установки доступны все пять proxy-команд и - `update_hproxy`; подключение одного переносимого модуля без остальных - dotfiles также предоставляет все пять proxy-команд. -- [x] Bash-модуль принимает вывод `env` атомарно, проверяет белый список и - наличие каждого из восьми ключей ровно один раз, не использует `eval` и при - любом неверном выводе оставляет прежние восемь переменных без изменений; - `proxy_on` и `proxy_off` больше не делают автоматический запрос внешнего IP. -- [x] Автоматические тесты подтверждают прежний внешний Hiddify-сценарий, - fail-fast, оба регистра переменных, настраиваемый `NO_PROXY`, отсутствие - протечки окружения, обычное и самостоятельное подключение Bash-модуля, - безопасный отказ для `type=xray` и атомарный разбор полного вывода `env`. diff --git a/.scratch/proxy-client/issues/02-managed-xray-happy-path.md b/.scratch/proxy-client/issues/02-managed-xray-happy-path.md deleted file mode 100644 index 91b5fb6..0000000 --- a/.scratch/proxy-client/issues/02-managed-xray-happy-path.md +++ /dev/null @@ -1,74 +0,0 @@ -# 02 — Управляемый Xray: полный штатный путь - -**What to build:** Пользователь с уже установленным поддерживаемым Xray кладёт -одну VLESS-ссылку в закрытый файл и получает полный штатный сценарий локального -прокси: проверку профиля, запуск на переднем плане, диагностику, запуск команд -через прокси и штатную остановку. - -**Blocked by:** 01 — Внешний прокси и переносимый Bash-интерфейс. - -**Status:** done - -Часть требований пересмотрена в 06 — см. 06-simplification-pass.md. - -- [x] `start` разрешён только при явном типе `xray`, требует loopback-адрес и - согласованный локальный порт, никогда не скачивает бинарник и выбирает первый - `xray` из `PATH`. Один разрешённый путь используется для `version`, - `run -test` и основного процесса и показывается перед запуском. -- [x] Успешный `xray version` разбирается только по первой строке в форме - `Xray VERSION` с концом строки либо ASCII-пробелом после полного токена - версии. Принимаются только `25.12.8` и `26.3.27`; ведущий мусор, похожий - префикс вроде `26.3.270`, пустой, неверный или завершившийся ошибкой вывод - запрещает преобразование и запуск. Запрошенная или указанная в профиле версия - не подменяет фактическую версию выбранного бинарника. -- [x] Профиль берётся из предусмотренного файла по умолчанию либо из явно - переданного `-f`, содержит ровно одну VLESS-ссылку, а конечный файл после - разрешения симлинка недоступен группе и остальным пользователям. -- [x] Строгий преобразователь поддерживает TCP/RAW с REALITY и XHTTP с TLS, - сохраняет все предусмотренные спецификацией поля, отклоняет повторы, - любой неизвестный query-параметр, отсутствующие обязательные поля и - неподдерживаемые сочетания до запуска Xray. -- [x] Для Xray 26.3.27 корректно обрабатываются `pcs`/`vcn`, а устаревшие - `allowInsecure`/`insecure` не переносятся и дают предупреждение; для явно - выбранного Xray 25.12.8 сохраняется совместимое insecure-поведение, а новые - поля отклоняются без автоматического отката версии. -- [x] `allowInsecure` и `insecure` принимают только точные значения `true` или - `false`; при одновременном присутствии значения должны совпадать. Для - 25.12.8 только согласованное истинное значение включает `allowInsecure` с - предупреждением, а для 26.3.27 поле никогда не попадает в JSON и истинное - значение предупреждает об обычной проверке сертификата. -- [x] Созданный клиентский JSON слушает только loopback, предоставляет HTTP и - SOCKS на одном порту, отключает журнал доступа и проходит `xray run -test` - до запуска основного процесса. -- [x] `start` остаётся на переднем плане как супервизор одного дочернего Xray; - после запуска процесс жив и порт принимает соединения, а сбой отдельной - проверки внешнего IP остаётся предупреждением. -- [x] Штатный `start` создаёт закрытое состояние с PID супервизора и дочернего - Xray, разрешённым путём к бинарнику, портом и безопасным отпечатком профиля. - `status`, `env`, `run` и `stop` требуют это состояние, живой супервизор, - ожидаемый дочерний процесс и настроенный порт; один чужой listener или PID - никогда не считается управляемым Xray и не затрагивается. -- [x] `status`, `env`, `run` и `stop` обеспечивают штатный путь управляемого - Xray; `run` никогда не запускает его скрыто, а `stop` может завершить - управляемый процесс после переключения машинного типа на `external`. - Успешный `stop` посылает сигнал супервизору, ограниченно ждёт завершения - супервизора и Xray, очищает штатное состояние и возвращает исходный - foreground-вызов `start` в терминал. -- [x] Диагностика внешнего IP действительно делает запрос через настроенный - локальный HTTP-прокси. Её сбой явно показан как предупреждение и не отменяет - уже успешные проверки процесса и порта. -- [x] Безопасная сводка показывает только полезные несекретные свойства - профиля; XHTTP `path` показывается только как скрытое значение с длиной, а - UUID, ключи, short ID и другие секреты — без фрагментов исходного значения. - Полная VLESS-ссылка и уникальные секреты отсутствуют в stdout, stderr и argv - дочерних процессов при успехе, любом отказе разбора и сбое `run -test`. -- [x] Модульные и интеграционные тесты покрывают обе семьи ссылок, варианты - ALPN, типичную ссылку Hiddify XHTTP/TLS, обе поддержанные версии Xray и - понятные отказы на неподдерживаемом вводе. -- [x] Чёрный ящик для каждой поддержанной версии доказывает полный порядок: - профиль по умолчанию и `-f`, `version`, успешный `run -test` до основного - запуска, живое управляемое состояние и порт, `status`, `env`, дочернюю - команду через `run`, проксированную IP-диагностику, `stop` и ограниченное - завершение исходного `start`. Через один настроенный локальный порт реально - завершаются отдельный HTTP-proxy-запрос и отдельный SOCKS-proxy-запрос. - Подмена порта или состояния чужим процессом даёт безопасный отказ. diff --git a/.scratch/proxy-client/issues/03-safe-process-lifecycle.md b/.scratch/proxy-client/issues/03-safe-process-lifecycle.md deleted file mode 100644 index 9433100..0000000 --- a/.scratch/proxy-client/issues/03-safe-process-lifecycle.md +++ /dev/null @@ -1,132 +0,0 @@ -# 03 — Безопасный жизненный цикл управляемого процесса - -**What to build:** Управляемый Xray предсказуемо переживает повторные команды, -сигналы, устаревшее состояние и аварийное исчезновение супервизора, не -затрагивая чужие процессы и не оставляя секретных данных вне закрытого -служебного каталога. - -**Blocked by:** 02 — Управляемый Xray: полный штатный путь; 06 — Упрощение. - -**Status:** done - -**Резолюция (2026-07-20):** реализовано и принято. Дельта: CLI 976→1235, -тесты 437→521 + 799→1112; сьюты: 49 unittest OK + 8 shell PASS (baseline -34/8). Конвейер: spec-ревью постановки (NEEDS_REWORK, 7 MAJOR + 3 MINOR — -постановка переписана до реализации) → реализация на medium → саморевью -(3 находки, починены) → слепые ревью: TASK 1 MINOR + 2 NIT, CODE 4 MAJOR + -1 MINOR (гонки идентичности/очистки) → триаж: FIXED 8, REJECTED 0 → обе -перепроверки FIXES_CONFIRMED. - -## Модель идентичности процессов - -Идентичность определяется отдельно для ребёнка-Xray и для супервизора; -«обход argv в поисках любого совпадения» (`verify_managed_state`) заменяется -этим механизмом. - -- **Общее для обоих:** PID + время старта. Время старта — поле 22 - (`starttime`) из `/proc/PID/stat`, целое число тиков с момента загрузки; - хранится в state как есть и сравнивается на точное равенство. Парсер - обязан сначала изолировать поле `comm` по последней `)` (comm может - содержать пробелы и скобки) и только потом индексировать поля; любая - ошибка чтения/разбора трактуется как несовпадение (fail-closed). -- **Ребёнок Xray:** канонический путь `/proc/PID/exe` (realpath) совпадает - с сохранённым `xray_path`. -- **Супервизор:** его exe — интерпретатор Python, поэтому проверяется - иначе: в state сохраняется канонический путь скрипта CLI - (`supervisor_path`, realpath на момент старта); при проверке операнд - скрипта из `/proc/PID/cmdline` резолвится (относительные пути — через - `/proc/PID/cwd`) и сравнивается с `supervisor_path`. Поддерживаемые формы - запуска (относительный путь, PATH, симлинк) не ломаются — сравнение - идёт по realpath. -- **Границы загрузки:** в state сохраняется `boot_id` - (`/proc/sys/kernel/random/boot_id`). Несовпадение с текущим означает - устаревшее состояние: записанные PID трактуются как мёртвые, сигналы не - посылаются. -- **Гонка check-then-kill:** непосредственно перед каждым `os.kill` - идентичность перечитывается заново; несовпадение — отказ без сигнала. - Остаточная гонка «процесс умер между перечиткой и kill» принята как риск - модели доверенных пользователей (pidfd не используем: Python 3.8+, - переносимость). - -## Чекбоксы - -- [x] Взаимное исключение: супервизор на всё время жизни держит - неблокирующий эксклюзивный `fcntl.flock` на lock-файле в служебном - каталоге. Второй одновременный `start` завершается понятным отказом, не - запуская процессов и не трогая чужие state/конфиг. `state.json` остаётся - данными для восстановления после сбоя, а не механизмом блокировки. - Последовательные повторный `start` и смена профиля при живом экземпляре - по-прежнему отвергаются. -- [x] Схема state (права 0600, каталоги 0700) — ровно эти ключи: - `supervisor_pid`, `xray_pid`, `xray_path`, `config_path`, `address`, - `port` (существующие) + `supervisor_path`, `supervisor_starttime`, - `xray_starttime`, `boot_id` (новые). Ссылка VLESS и её секреты в state - не попадают. -- [x] `status` и `stop` применяют модель идентичности выше; чужой, - устаревший или переиспользованный PID не признаётся управляемым - процессом (в пределах принятой остаточной гонки). -- [x] Три исхода проверки состояния, у каждого — своё поведение: - 1. **Здоровая пара** (супервизор и ребёнок живы, идентичность - подтверждена): `stop` сигналит супервизору, как сейчас. - 2. **Сирота** (супервизор исчез, ребёнок жив и подтверждён по - PID + starttime + boot_id + exe): `stop` сигналит ребёнку напрямую и - удаляет state и сгенерированный конфиг; доступность порта для этого - НЕ требуется. `status` — отдельная ненулевая диагностика «сирота»; - `env` и `run` — отказ. - 3. **Непроверяемое/устаревшее состояние:** если записанный PID жив, но - идентичность не подтверждена — никаких сигналов, state сохраняется, - диагностика с инструкцией ручного удаления; если записанные PID - мертвы (или boot_id не совпал) — безопасная очистка устаревших - файлов разрешена. -- [x] Конфликт локального порта: `start` при уже занятом порте завершается - ненулевым кодом, диагностика называет порт занятым/недоступным, чужой - слушатель не затронут, свои сгенерированные файлы убраны. Инспекция - владения слушателем (/proc/net/tcp) запрещена. -- [x] Секреты: полная VLESS-ссылка никуда не персистится; UUID, ключи, - секретоподобные значения отсутствуют в stdout, stderr, argv, - `state.json` и lock-файле. Единственное допустимое место — `client.json` - с правами 0600 внутри каталога 0700; он удаляется при штатном завершении - и при `stop` сироты. -- [x] Автоматические тесты (без root, на обычной dev-машине): - - блокировка: удержанный извне flock → `start` отказывает, ничего не - запустив и не изменив; - - переиспользованный PID моделируется правкой сохранённого `starttime` - у живого процесса (реальную гонку PID не воспроизводим); отдельно — - мёртвый PID, неверный исполняемый файл, несовпавший `boot_id`; - - SIGKILL супервизора → `stop` добивает подтверждённого сироту и чистит - state/конфиг; - - занятый порт (заранее открытый сокет) → контракт из чекбокса выше; - - юнит-тест парсера `/proc/PID/stat` на синтетических `comm` с - пробелами и `)`; - - фикстура фейкового Xray адаптирована под проверку `/proc/PID/exe` - (ребёнок должен быть настоящим исполняемым файлом — например, - `sys.executable` как «бинарник xray» со скриптами-операндами; детали - на усмотрение исполнителя); - - все сохраняемые ниже поведения остаются зелёными. - -## Сохранить, не переписывать - -Уже реализовано и покрыто тестами в 02/06 — не переделывать, только не -сломать: проброс SIGINT/SIGTERM/SIGHUP ребёнку; очистка state и конфига при -штатном завершении; права 0600/0700; последовательные отказы (повторный -start, смена профиля, несовпадение машинного адреса/порта со state); -loopback-only; секреты вне stdout/stderr/argv; foreground-супервизор; -fail-fast `pclaude`/`pcodex`; внешний путь без машинного конфига. - -Запрещено восстанавливать срезанное в 06: владение слушателем через -`/proc/net/tcp`, `profile_fingerprint`, инспекцию zombie-состояний, -SIGALRM-машинерию, поддержку второй версии Xray. - -## Не трогать - -- `bash/.bash_functions`, установщик, README — вне объёма. -- Никаких коммитов. - -## Отчёт исполнителя - -1. Изменённые файлы со строкой «зачем» на каждый. -2. Команды тестов и счётчики (`python3 -m unittest discover -s tests`, - `bash tests/test_proxy_launchers.sh`; baseline: 34 OK / 8 PASS — новых - станет больше, указать числа). -3. Чекбоксы: закрыт / не закрыт и почему. -4. Принятые по ходу решения и сомнения, если есть. diff --git a/.scratch/proxy-client/issues/04-verified-xray-installation.md b/.scratch/proxy-client/issues/04-verified-xray-installation.md deleted file mode 100644 index 6034a93..0000000 --- a/.scratch/proxy-client/issues/04-verified-xray-installation.md +++ /dev/null @@ -1,144 +0,0 @@ -# 04 — Проверяемая пользовательская установка Xray - -**What to build:** Пользователь без root устанавливает официальный Xray -одной явной командой `install` и получает проверенный бинарник -зафиксированной версии, без скрытой замены существующей установки, без -записи за пределами пользовательских каталогов и без риска для живого -managed-экземпляра. - -**Prerequisites:** 01, 03, 06 — все done. Наследуемые решения: одна -поддержанная версия Xray 26.3.27 (06); идентичность managed-процессов по -`/proc/PID/exe` и start-lock (03); Python 3.8+ stdlib-only, переносимость -Ubuntu / РЕД ОС / Astra / Alt. - -**Status:** done - -## Зафиксированный протокол установки - -- **Только версия 26.3.27** — та же, что принимает `VERSION_LINE`. - Никакого параметра версии: будущий апгрейд — отдельный тикет, меняющий - пин скачивания, ворота рантайма, фикстуры и документацию вместе. -- **Источник:** `https://github.com/XTLS/Xray-core/releases/download/v26.3.27/`. - Маппинг архитектур (чистая функция, по `platform.machine()`): - `x86_64`/`amd64` → `Xray-linux-64.zip`; `aarch64`/`arm64` → - `Xray-linux-arm64-v8a.zip`; прочее — понятный отказ. -- **Проверка:** скачивается `<архив>.dgst`. Фактический формат официальных - файлов — четыре записи `MD5=`, `SHA1=`, `SHA2-256=`, `SHA2-512=` - (исправлено 2026-07-20 по находке CODE-ревью: однострочного `SHA256=` - в релизах нет). Принимается ровно одна запись `SHA2-256= <64 hex в - нижнем регистре>`; дубликаты, uppercase, отсутствие записи или мусор — - отказ. Хэш архива считается `hashlib.sha256`; несовпадение или - неожиданный формат — отказ до распаковки, установленный бинарник не - тронут. -- **Границы размеров:** скачивание и распаковка ограничены - зафиксированными лимитами (архив, `.dgst`, извлекаемый бинарник); - фактически прочитанные байты считаются с limit+1; несоответствие - объявленному `Content-Length` или превышение лимита — отказ без - исчерпания диска/памяти. -- **Инструменты:** `urllib.request` для скачивания, `zipfile` для - извлечения члена `xray`. Явно исключены: tar, GPG-подписи, curl, wget, - пакетные менеджеры, sudo, systemd, системные каталоги. -- **Атомарность:** приватный staging-каталог создаётся ВНУТРИ - `~/.local/bin` (одна файловая система — `os.replace` без EXDEV); - скачивание и распаковка туда; кандидату ставится режим 0755; кандидат - проходит проверку `version` (те же ворота 26.3.27); затем один - `os.replace` в `~/.local/bin/xray`. До этого вызова существующий файл - назначения не изменяется. Staging убирается при любой ошибке и - прерывании (best-effort); fsync/power-loss-гарантии не требуются. - -## Чекбоксы - -- [x] Команда `install [--replace]` добавлена в парсер и диспетчеризуется - ДО `load_config()`: установка не требует машинного конфига и - VLESS-профиля. `start` и запуск без подкоманды по-прежнему никогда не - начинают скачивание. -- [x] Защита managed-режима: до любого скачивания и изменения назначения - `install` берёт существующий start-lock и держит его до конца - `os.replace`. Lock занят — немедленный отказ с подсказкой «сначала - stop». При взятом lock допускаются только отсутствующий state или - доказуемо устаревший (по классификации 03 — stale); живой, сиротский - или непроверяемый state — отказ. -- [x] Поведение при существующем назначении `~/.local/bin/xray`: - - отсутствует → установка; - - обычный исполняемый файл, отвечающий `26.3.27` → no-op без сети - (успех, сообщение «уже установлено»); - - `--replace` → принудительная переустановка с полной проверкой; - - иной файл (не отвечает 26.3.27, неисполняемый, битый) или симлинк → - отказ без `--replace`; с `--replace` заменяется сам путь, симлинк не - разыменовывается; - - каталог → всегда понятный отказ. -- [x] После установки: проверка `shutil.which("xray")` — резолвится ли в - установленный `~/.local/bin/xray`. Нет — ненулевой код с подсказкой - добавить `~/.local/bin` в начало `PATH`; готовая установка при этом НЕ - откатывается. -- [x] Тестовый шов: чистая функция «архитектура → имя ассета» и - подменяемый загрузчик (callable), который тесты заменяют in-process. - Пользовательского оверрайда базового URL нет. -- [x] Автоматические тесты (без root, без сети; временные HOME/PATH/state): - - маппинг обеих архитектур (patch значения архитектуры) и отказ на - неподдержанной; - - корректный `.dgst` → установка; повреждённый хэш и неожиданный формат - `.dgst` → отказ до распаковки, назначение не тронуто; - - фикстуры: локальный ZIP с фейковым исполняемым, печатающим - `Xray 26.3.27 ...`, и парные валидный/битый `.dgst`; - - существующая корректная установка → no-op без обращения к загрузчику; - `--replace` → переустановка; симлинк/каталог/битый файл — по таблице - выше; - - атомарность: инъецированная ошибка после скачивания / при распаковке / - при проверке `version` → назначение не изменено, staging убран; - - запись только в пределах staging/назначения и lock-файла: сверка - полного дерева временной ФС до/после; - - защита managed: при живом managed-экземпляре (реальная фикстура 03) - `install --replace` отказывает с подсказкой «сначала stop», бинарник - и state не тронуты; - - `shutil.which`-проверка: отсутствие каталога в PATH и конкурирующий - более ранний `xray` в PATH. -- [x] `proxy-client/README.md` обновлён: одна поддержанная версия 26.3.27 - (упоминания 25.12.8 удалены), использование `install [--replace]`, - маппинг x86_64/aarch64, SHA-256-проверка, поведение PATH, отказ при - живом managed-экземпляре. - -## Сохранить, не трогать - -- Существующее поведение `resolve_xray`, ворот `VERSION_LINE`, - managed-режима и всех 49 зелёных тестов + 8 shell-проверок. -- Секреты: install не касается профилей и не выводит их; общее правило - «VLESS/UUID/ключи никогда в stdout/stderr/argv» остаётся. -- `bash/.bash_functions`, установщик dotfiles — вне объёма. -- Никаких коммитов. - -## Отчёт исполнителя - -1. Изменённые файлы со строкой «зачем» на каждый. -2. Команды тестов и счётчики (baseline: 49 OK / 8 PASS — новых станет - больше, указать числа). -3. Чекбоксы: закрыт / не закрыт и почему. -4. Принятые по ходу решения и сомнения, если есть. - -## Резолюция (2026-07-20) - -Конвейер пройден целиком: постановка → Codex spec-review (high, 5 MAJOR + -3 MINOR + 1 NIT, переписана) → реализация (Codex, medium — эксперимент) → -саморевью (found 6, fixed 6) → два слепых ревью (CODE: 2 MAJOR + 2 MINOR; -TASK: 3 NIT) → триаж (FIXED 7, REJECTED 0) → узкие перепроверки -(оба FIXES_CONFIRMED) → приёмка оркестратором. - -Ключевое: - -- Команда `install [--replace]`: пин 26.3.27, маппинг архитектур чистой - функцией, `urllib.request` + `zipfile`, staging внутри `~/.local/bin`, - один `os.replace`, `version`-ворота кандидата, guard по start-lock. -- CODE-1 (MAJOR) оказался дефектом постановки: spec-review зафиксировал - несуществующий однострочный формат `SHA256=`; реальные `.dgst` — четыре - записи. Протокол в тикете исправлен (см. выше), парсер строгий: ровно - одна `SHA2-256= <64 hex в нижнем регистре>`. -- Границы размеров: 64 MiB архив/бинарник, 4 KiB `.dgst`, счёт limit+1, - сверка Content-Length; offline-покрытие реального `download_file` через - подмену `urllib.request.urlopen`. -- `StartLockBusyError` вместо парсинга текста ошибки; снапшоты дерева ФС - в тестах отказных путей; PATH-тест конкурирующего бинарника. -- README дополнён (по просьбе пользователя): последняя версия с - `allowInsecure` — 25.12.8, опция удалена в 26.2.6; CLI их не ставит. - -Тесты: 79 OK (baseline 49, +30) + 8 PASS shell-проверок. Затраты — в -`.scratch/proxy-client/token-ledger.md`. diff --git a/.scratch/proxy-client/issues/05-package-delivery-and-acceptance.md b/.scratch/proxy-client/issues/05-package-delivery-and-acceptance.md deleted file mode 100644 index 207f1cd..0000000 --- a/.scratch/proxy-client/issues/05-package-delivery-and-acceptance.md +++ /dev/null @@ -1,143 +0,0 @@ -# 05 — Доставка пакета и итоговая приёмка - -**What to build:** Поставка `proxy-client` проверена целиком: корневой обзор -соответствует готовым возможностям, автоматические проверки воспроизводимы, а -ручная приёмка на доступных системах подготовлена для пользователя. - -**Blocked by:** 03 — безопасный жизненный цикл; 04 — проверяемая установка. - -**Status:** ready-for-human - -## Границы задачи - -Тикеты 03/04/06 уже доставили CLI, Bash-модуль, установку и жизненный цикл. -Задача 05 их проверяет, но не переписывает. Источники истины: - -- [`proxy-client/README.md`](../../../proxy-client/README.md) — пользовательские - команды, настройка и ограничения; -- [исходный дизайн](../../../docs/specs/2026-07-15-proxy-client-design.md) - — границы и принятые решения. - -- [x] Корневой `README.md` кратко описывает внешний Hiddify и управляемый Xray, - показывает `stow proxy-client` и ссылается на полную инструкцию. -- [x] Пакет подключается через stow, ручные ссылки или копирование; общий - установщик использует тот же пакет. -- [x] Инструкция покрывает две семьи VLESS, Xray 26.3.27, отсутствие systemd, - автозапуска и автоматического перехода между прокси. -- [x] Тест - `ManagedXrayCliTest.test_exact_boolean_version_alpn_and_extra_branches` - подтверждает, что устаревшие `allowInsecure`/`insecure` не попадают в - TLS-конфиг, а истинное значение даёт предупреждение. - -## Секретные данные - -- [x] В отслеживаемых файлах, тестовых данных, аргументах, выводе и отчёте нет - настоящих VLESS-ссылок, UUID, ключей, `shortId` и `path`. Автоматические тесты - используют только вымышленные контрольные значения. Настоящий профиль и - сгенерированный JSON остаются в закрытых пользовательских файлах и не - копируются в отчёт. - -## Автоматическая проверка - -- [x] `tests/test_proxy_client_full.sh` запускает весь Python-набор под системным - `python3` и CPython 3.8 через `uv`, затем Bash-тесты и проверку установки. -- [x] Тестовый Xray остаётся отдельным исполняемым файлом и получает доступ к - библиотекам исходного Python. Это устраняет ложные падения переносимой сборки - `uv`, не ослабляя проверки личности процесса. -- [x] `tests/test_install.sh` собирает небольшие тестовые деревья только из - отслеживаемых Git-файлов и удаляет общий временный каталог при выходе. - -## Приёмка пользователем - -Исполнитель готовит команды и останавливается. Результат отмечает пользователь; -недоступная система остаётся неподтверждённой. - -- [x] Ubuntu 26.04 с системным Python 3.14: установка, жизненный цикл, - `xray run -test`, HTTP и SOCKS через один локальный вход Xray 26.3.27. - Минимальный CPython 3.8 отдельно покрыт автоматическим прогоном через `uv`. -- [ ] РЕД ОС, Astra Linux или Alt Linux по доступности: тот же сценарий после - проверки фактической версии Python. - -## После приёмки - -- [ ] Статус исходного дизайна и заявления о поддержке приведены в соответствие - только с фактически выполненными прогонами. - -## Comments - -### 2026-07-21 — автоматическая проверка - -`bash tests/test_proxy_client_full.sh` завершился с кодом 0: - -- системный Python 3.14: 79 тестов, `OK`, 61,034 с; -- Bash-интеграция: 8 проверок, `PASS`; `shellcheck` отсутствует и явно пропущен; -- установка: `All install tests passed`; в `/tmp` не осталось каталогов - `dotfiles-install-test.*`; -- CPython 3.8 через `uv`: 79 тестов, `OK`, 52,446 с. - -### 2026-07-21 — команды ручной приёмки - -В первой панели tmux, после подготовки закрытого `default.url` и машинного -конфига с `type = xray`: - -```bash -python3 --version -cd ~/dotfiles -stow proxy-client -source ~/.config/proxy-client/shell.bash -proxy-client install -proxy-client start -``` - -Во второй панели, пока `start` работает: - -```bash -proxy-client status -xray run -test -c "${XDG_STATE_HOME:-$HOME/.local/state}/proxy-client/client.json" -curl --proxy http://127.0.0.1:12334 https://api.ipify.org -curl --proxy socks5h://127.0.0.1:12334 https://api.ipify.org -proxy-client stop -``` - -Если настроен другой локальный адрес или порт, те же значения подставляются в -обе команды `curl`. В отчёт записываются только версии, коды возврата и итог -проверки HTTP/SOCKS. - -### 2026-07-21 — переименование и частичная ручная приёмка - -Публичный интерфейс переименован в `proxy-client`, а машинный конфиг и закрытый -профиль объединены в `~/.config/proxy-client/`. В ходе разработки текущий HOME -приведён к новой структуре; старые пользовательские пути отсутствуют, права -файлов `600` сохранены. Поддержка прежнего имени в установщик не добавлялась, -поскольку выпуск ещё не состоялся. - -Xray 26.3.27 установился и запустился, `status` подтвердил процесс на -`127.0.0.1:12334`, а `xray run -test` завершился сообщением -`Configuration OK`. HTTP и SOCKS5h через локальный вход не прошли: оба запроса -`curl` завершились кодом 35 с неожиданным закрытием TLS-соединения во время -рукопожатия. После проверки Xray штатно остановлен. Пункты ручной приёмки пока -не отмечены. - -### 2026-07-21 — успешная приёмка на Ubuntu - -Первая строка оказалась нерабочей и в исходном клиенте. После замены на -подтверждённо рабочий профиль XHTTP/TLS с ALPN `h3` тот же Xray 26.3.27 прошёл -полный сценарий на Ubuntu 26.04 с системным Python 3.14: встроенная проверка -внешнего IP, `status`, `xray run -test`, HTTP CONNECT и SOCKS5h. Оба запроса -`curl` получили HTTP 200. Содержимое профиля в журнал и Git не попадало. - -Диагностическое понижение до Xray 25.12.8 не исправляло старый профиль, поэтому -поддержка второй версии не нужна. Ошибка была связана с прежней строкой или её -удалённой стороной, а не с преобразованием `proxy-client`. - -Короткая проверка стабильности H3 на Xray 26.3.27 также прошла: - -- 120 циклов, в каждом новое HTTP CONNECT- и SOCKS5h-соединение: 240 запросов, - 0 ошибок; -- две одновременные загрузки по 10 МБ через HTTP и SOCKS завершились полностью; -- после 45 секунд без трафика оба новых запроса получили HTTP 200. - -Таким образом, не воспроизвёлся сбой из -[XTLS/Xray-core#3673](https://github.com/XTLS/Xray-core/issues/3673), при котором -H3 переставал принимать новые соединения уже после первых запросов. Этот -короткий прогон не заменяет длительное наблюдение под обычной нагрузкой. diff --git a/.scratch/proxy-client/issues/06-simplification-pass.md b/.scratch/proxy-client/issues/06-simplification-pass.md deleted file mode 100644 index b13a81d..0000000 --- a/.scratch/proxy-client/issues/06-simplification-pass.md +++ /dev/null @@ -1,180 +0,0 @@ -# 06 — Упрощение: срез accidental-сложности спеки и кода - -**What to build:** Один проход упрощения по готовым issue 01–02 и спеке: -убрать поддержку второй версии Xray, избыточные защитные проверки вне -принятой модели угроз («доверенные пользователи машины», единственный -пользователь редактирует свои файлы сам) и церемонии, не нужные личному -foreground-инструменту. Поведенческие контракты из раздела «Не трогать» -сохраняются без изменений. - -**Blocked by:** 02 — Управляемый Xray: полный штатный путь. - -**Status:** done - -**Резолюция (2026-07-20):** реализовано и принято. Дельта: CLI 1123→976, -shell.bash 116→91, тесты 1357→1236 строк; сьюты: 34 unittest OK + 8 shell -PASS. Ревью: 1 MAJOR (scoped IPv6 в упрощённом валидаторе — починен с -регрессионным тестом под Python 3.8), 1 NIT отклонён аргументированно. -Non-gating замечания ревью приняты как риски модели угроз. - -**Сначала прочитать:** - -1. Этот файл целиком, затем `/tmp/xray-simplify-Zeo5/06-simplify/sweep.md` — - там точный line-level blast radius каждого пункта (секция 2 — срезы A/B, - секция 3 — SIMP-N). Номера строк — ориентир, сверяй с фактическим кодом. -2. `docs/specs/2026-07-15-proxy-client-design.md` — исходная спека. -3. `proxy-client/.local/bin/proxy-client`, `proxy-client/.config/proxy-client/shell.bash`, - `tests/test_proxy_client.py`, `tests/test_proxy_client_managed.py`. - -Команды проверки (обе обязательны): - -- `python3 -m unittest discover -s tests` — baseline до изменений: 38 - тестов, OK, ~60 с; -- `bash tests/test_proxy_launchers.sh` — baseline: 9 проверок PASS. - -После изменений тестов станет меньше (удаляются тесты срезанных фич); все -оставшиеся зелёные, новые счётчики указать в отчёте. - -## A. Срез dual-version: остаётся только Xray 26.3.27 - -- [x] `VERSION_LINE` принимает только `26.3.27`; параметр `version` убран из - сигнатур `parse_vless`, `build_xray_config`, `safe_summary`, `warn_legacy` - и их вызовов; `xray_version` остаётся воротами «поддержанная версия или - отказ» и по-прежнему показывает фактическую версию в сводке. -- [x] Ветка эмиссии `allowInsecure` для 25.12.8 и запрет `pcs`/`vcn` для - 25.12.8 удалены. `parse_boolean_fields` (валидация точных `true`/`false`, - требование согласованности обоих полей, предупреждение при истинном - значении) сохраняется; `allowInsecure` никогда не попадает в JSON. -- [x] Тестовая ось версий схлопнута: lifecycle-тест гоняет один прогон - 26.3.27 (обе локации профиля — default и `-f` — сохраняются); - `test_old_version_preserves_explicit_legacy_insecure` и все - 25.12.8-строки табличных тестов удалены. - -## B. Срез проверки владения сокетом через /proc/net/tcp - -- [x] `process_owns_listener`, `process_socket_inodes`, `decode_proc_address` - и их вызовы (в `verify_managed_state` и readiness-цикле `command_start`) - удалены. Сохраняются: сверка PID/идентичности процесса, сверка - адрес/порт машинного конфига со state и `check_endpoint`-проверка - доступности порта. -- [x] `test_verified_child_must_own_configured_listener` удалён вместе с - веткой `FAKE_XRAY_LISTEN_PORT` в фейковом xray; - `test_substituted_machine_port_fails_closed...` сохраняется и зелёный. -- [x] Диагностические сообщения, упоминающие проверку владения слушателем - (в `verify_managed_state` и readiness-цикле `command_start`), обновлены: - после среза они описывают фактические проверки (идентичность процесса, - сверка адрес/порт, доступность порта), а не владение сокетом. - -## C. Диета входной валидации (файлы пишет сам пользователь) - -- [x] `validate_address`: сначала `ipaddress.ip_address`, иначе минимальная - проверка «непустое ASCII-имя без пробелов, схемы `://`, `@`, `/` и - квадратных скобок». RFC-строгость по меткам DNS, лимит 253 и - сопутствующие тесты удалены. Оборачивание IPv6 в `[]` при сборке URL - сохраняется. -- [x] `load_config`: сохраняется отказ на неизвестной секции/ключе и на - отсутствии обязательного ключа (ловит реальные опечатки) и диапазон - порта. Удалены: скан NUL, guard UTF-8-декодирования, отдельный запрет - `[DEFAULT]`, запрет CR/многострочных значений, case-sensitive - `optionxform`; соответствующие подкейсы тестов удалены. -- [x] `read_profile`: сохраняются разрешение симлинка, проверка прав - (group/other → ошибка с подсказкой `chmod 600`) и правило «ровно одна - непустая строка». Удалены скан NUL и guard UTF-8. -- [x] `parse_query`: сохраняются отказ на неизвестном и на повторном - query-параметре. Удалены regex на сырые `%`-последовательности и - посимвольный запрет управляющих символов. - -## D. Диета церемоний - -- [x] `write_private_json`: простая запись файла с правами 0600 внутри - уже-0700 каталога; O_EXCL/fsync/`os.replace`/PID-суффиксы удалены. -- [x] Валидация `PROXY_PREFLIGHT_TIMEOUT` и `PROXY_CLIENT_STOP_TIMEOUT` — один - guard «`float()` успешен и > 0, иначе понятная ошибка»; `math` не - импортируется; тесты экстремальных значений (nan/inf/1e-7/1e308) - удалены. Для `PROXY_PREFLIGHT_TIMEOUT` оставить один тест «плохое - значение → понятный отказ»; для `PROXY_CLIENT_STOP_TIMEOUT` отдельного - теста нет и добавлять его не нужно — достаточно guard'а. -- [x] `check_reachable` использует `socket.create_connection(timeout=...)` - без SIGALRM/setitimer-машинерии. Принятый риск (записан здесь): - зависший DNS-резолв на external-пути не ограничен таймаутом — для - личного инструмента пользователь прерывает вручную. -- [x] `extra` и `alpn` разбираются один раз в `parse_vless`, результат - сохраняется в профиле и переиспользуется в `build_xray_config` без - повторного разбора. `parse_json_object` сохраняет оба отказа — - на повторах ключей и на JSON-константах (`reject_constant` остаётся): - существующий тест требует отказ на уровне разбора с диагностикой про - `extra` и без запуска `run -test`, и это дешевле, чем переносить - ответственность на сериализацию. -- [x] `process_alive` — только `os.kill(pid, 0)`; чтение - `/proc/PID/stat` ради zombie-состояния удалено. -- [x] Отпечаток профиля (`profile_fingerprint`) удалён из state и кода: - он не участвует ни в одном решении. Смена профиля по-прежнему - блокируется существованием state. Вместе с ним обновлены `STATE_KEYS` и - все тестовые фикстуры state (включая точную сверку набора ключей в - managed-тестах): устаревший ключ в фикстуре не должен ронять сверку - ключей раньше, чем тест дойдёт до проверяемой идентичности процесса. - -## E. shell.bash: средняя форма приёма `env` - -- [x] `_proxy_apply_env_file`: при ненулевом коде возврата CLI окружение не - меняется вовсе; успешный вывод разбирается построчно по первому `=`; - применяются (через `printf -v`, без `eval`) только восемь известных - ключей, прочие строки — ошибка без частичного экспорта. Удалены сканы - NUL/CR, отдельный детектор повторов и проверка «ровно восемь строк». - Свойство «нет частичного изменения окружения при ошибке» сохраняется и - остаётся покрыто тестом. -- [x] `tests/test_proxy_launchers.sh` приведён к средней форме: проверки - повторов/CR/NUL/счётчика строк, соответствующие удалённой машинерии, - убраны или переписаны; проверки «ненулевой код возврата CLI → окружение - не тронуто», «неизвестная строка → ошибка без частичного экспорта» и - владения функциями остаются зелёными. - -## F. Спека и соседние issue приведены к новому объёму - -- [x] Из спеки удалены: ветка 25.12.8 (все места из секции 2 sweep-файла), - флаги `-v`/`--debug` (не реализованы), упоминание отпечатка профиля в - state; описания валидации/атомарности смягчены до фактического - поведения после этого issue. Формулировка идентичности процессов - («PID + время старта + исполняемый файл») в спеке не меняется. -- [x] Issue 03: добавлен явный мандат «идентичность = PID + время старта - (`/proc/PID/stat`) + исполняемый файл ребёнка / путь скрипта - супервизора; текущий argv-обход заменить этим механизмом»; убраны - унаследованные упоминания срезанных фич, если есть; строка Blocked by - дополнена: «06 — Упрощение». -- [x] Issue 05: приёмка сведена к одной версии Xray 26.3.27; упоминания - 25.12.8 удалены. Пункты про РЕД ОС сохранены (решение пользователя - 2026-07-20: целевые системы шире Ubuntu — РЕД ОС, возможно Astra Linux - и Alt Linux; инструмент могут использовать коллеги). -- [x] В спеке в раздел ручной проверки добавлены Astra Linux и Alt Linux - как кандидаты «по мере доступности» с тем же правилом, что для РЕД ОС: - недоступная среда явно записывается как неподтверждённая, поддержка не - заявляется без фактической проверки версии Python и прогона. -- [x] В issue 01 и 02 добавлена одна строка после Status: «Часть - требований пересмотрена в 06 — см. 06-simplification-pass.md»; чекбоксы - 01 и 02 не редактируются (issue 01 описывает строгую приёмку `env`, - которую этот issue смягчает, — без пометки он противоречил бы коду). - -## G. Не трогать (границы) - -- Строгий отказ на неизвестном/повторном query-параметре VLESS. -- Точечный `stop` только по проверенному PID; никакого pkill. -- Foreground-супервизор, проброс сигналов, очистка state. -- Изоляция окружения `run` (proxy-переменные только у ребёнка), сохранение - argv и кода возврата, `--`-разделитель. -- Fail-fast `pclaude`/`pcodex`, обратная совместимость: без машинного - конфига — внешний `hproxy.local:12334`. -- Проверка прав файла профиля (chmod 600) и loopback-only вход. -- Авто-диагностика внешнего IP на старте (несмертельная) — остаётся. -- Секреты: полная VLESS-ссылка, UUID, ключи не появляются в stdout/stderr/ - argv — все существующие тесты на это остаются зелёными. -- `bash/.bash_functions`, установщик, README — не входят в этот issue. -- Никаких коммитов. - -## Отчёт исполнителя - -1. Изменённые файлы со строкой «зачем» на каждый. -2. Команда тестов и счётчик (все зелёные, указать новое число). -3. Чекбоксы: закрыт / не закрыт и почему. -4. Итоговая дельта `wc -l` по CLI, shell.bash и обоим тест-файлам - против baseline (1123 / 116 / 467 / 890). -5. Сомнения, если есть. diff --git a/.scratch/proxy-client/token-ledger.md b/.scratch/proxy-client/token-ledger.md deleted file mode 100644 index fd8f688..0000000 --- a/.scratch/proxy-client/token-ledger.md +++ /dev/null @@ -1,99 +0,0 @@ -# Токен-леджер проекта proxy-client - -Затраты по этапам конвейера, для калибровки эффорта и планирования лимитов -(5-часовые / недельные окна подписок). Прямое сравнение корректно внутри -провайдера; между Claude и Codex метрики разные (у Claude-субагентов -харнесс даёт один суммарный счётчик, у Codex — раздельные input/cached/ -output/reasoning), кросс-сравнение — ориентировочное. - -## Задача 06 — упрощение (baseline: реализация на HIGH) - -| Этап | Агент / модель | Effort | Токены | Прочее | -|---|---|---|---|---| -| KISS-обход спеки и кода | Claude Opus (субагент) | default | 100 025 суммарно | 14 tool uses, 4.5 мин | -| Ревью постановки | Codex gpt-5.6-sol | high | in 1 263 557 (кэш 1 174 784 → свежих ~89k), out 16 862 (reasoning 12 817) | 82 команды | -| Реализация | Codex gpt-5.6-sol | high | in 6 680 053 (кэш 6 492 928 → свежих ~187k), out 29 064 (reasoning 8 280) | 84 команды, −331 строка | -| Саморевью | Codex (resume) | medium | инкремент: свежих in ~155k, out ~4.5k | 8 команд, found 2 fixed 2 | -| Слепое ревью «по задаче» | Claude Opus (субагент) | default | 75 216 суммарно | 26 tool uses, 4.3 мин; 1 NIT | -| Слепое ревью «по коду» | Codex gpt-5.6-sol (свежий тред) | high | in 2 336 162 (кэш 2 106 112 → свежих ~230k), out 12 434 (reasoning 7 033) | 48 команд; 1 MAJOR | -| Триаж/починка | Codex (resume) | medium | инкремент: свежих in ~26k, out ~7.3k | 18 команд; FIXED 1, REJECTED 1 | -| Перепроверка CODE-1 | Codex-ревьюер (resume) | medium | инкремент: свежих in ~137k (вкл. оборванный первый заход), out ~1.8k | FIXES_CONFIRMED | - -Итог 06 (high-baseline): свежий input ~735k + output ~97k на весь конвейер -(обход, ревью постановки, реализация, саморевью, 2 слепых ревью, триаж, -перепроверка); Claude-доля — 175k суммарно (обход 100k + ревью 75k). - -## Задача 03 / 04 (эксперимент: реализация на MEDIUM) - -_Заполняется по ходу; сравнивать построчно с 06._ - -| Этап | Агент / модель | Effort | Токены | Прочее | -|---|---|---|---|---| -| 03: ревью постановки | Codex gpt-5.6-sol | high | in 1 126 384 (кэш 1 044 480 → свежих ~82k), out 15 830 (reasoning 9 567) | NEEDS_REWORK: 7 MAJOR + 3 MINOR | -| 03: реализация | Codex gpt-5.6-sol | **medium** | in 5 925 714 (кэш 5 678 080 → свежих ~248k), out 29 512 (reasoning 11 251)* | 88+6 команд; +657/−89; 44 OK + 8 PASS; процесс убит рестартом сессии перед отчётом, дописан через resume | - -| 03: саморевью | Codex (resume) | medium | инкремент: свежих in ~53k, out ~11.6k (reasoning ~7k) | 38 команд; found 3 fixed 3; 45 OK + 8 PASS | -| 03: слепое ревью «по задаче» | Claude Opus (субагент) | default | 73 131 суммарно | 21 tool uses, 6.3 мин; 1 MINOR + 2 NIT | -| 03: слепое ревью «по коду» | Codex gpt-5.6-sol (свежий тред) | high | in 1 103 725 (кэш 1 019 136 → свежих ~85k), out 12 797 (reasoning 8 908) | 28 команд; 4 MAJOR + 1 MINOR | -| 03: триаж/починка | Codex (resume) | medium | инкремент: свежих in ~55.5k, out ~16.9k (reasoning ~7.8k) | 46 команд; FIXED 8, REJECTED 0; 49 OK + 8 PASS | -| 03: перепроверка TASK-1..3 | Claude Opus (резюм субагента) | default | инкремент ~7.4k (суммарно 80 560) | 8 tool uses, 1.7 мин; FIXES_CONFIRMED | -| 03: перепроверка CODE-1..5 | Codex-ревьюер (resume) | medium | инкремент: свежих in ~112k, out ~3.2k | 20 команд; FIXES_CONFIRMED | - -| 04: ревью постановки | Codex gpt-5.6-sol | high | in 2 469 937 (кэш 2 352 896 → свежих ~117k), out 17 051 (reasoning 10 128) | 54 команды; NEEDS_REWORK: 5 MAJOR + 3 MINOR + 1 NIT | -| 04: реализация | Codex gpt-5.6-sol | **medium** | in 2 941 682 (кэш 2 828 288 → свежих ~113k), out 18 521 (reasoning 5 278) | 50 команд; +~600 строк; 63 OK (+14) + 8 PASS | -| 04: саморевью | Codex (resume) | medium | инкремент: свежих in ~50k, out ~17.2k (reasoning ~9.2k) | 38 команд; found 6 fixed 6; 72 OK + 8 PASS | -| 04: слепое ревью «по задаче» | Claude Opus (субагент) | default | 53 463 суммарно | 18 tool uses, 5.7 мин; 3 NIT | -| 04: слепое ревью «по коду» | Codex gpt-5.6-sol (свежий тред) | high | in 1 774 005 (кэш 1 676 288 → свежих ~98k), out 10 402 (reasoning 5 481) | 46 команд; 2 MAJOR + 2 MINOR (CODE-1 — дефект постановки: реальный формат .dgst) | -| 04: триаж/починка | Codex (resume) | medium | инкремент: свежих in ~237k, out ~58.9k (reasoning ~24k) | FIXED 7, REJECTED 0; 79 OK (+30 к baseline) + 8 PASS | -| 04: перепроверка TASK-1..3 | Claude Opus (резюм субагента) | default | инкремент ~7.7k (суммарно 61 135) | 6 tool uses, 1.7 мин; FIXES_CONFIRMED | -| 04: перепроверка CODE-1..4 | Codex-ревьюер (resume) | medium | инкремент: свежих in ~207k, out ~12.5k (reasoning ~6.3k) | FIXES_CONFIRMED | - -Итог 03 (реализация на MEDIUM): Codex — свежий input ~636k + output ~77k -(ревью постановки 82k/16k, реализация ~248k/29.5k, саморевью 53k/11.6k, -CODE-ревью 85k/12.8k, триаж 55.5k/16.9k, перепроверка 112k/3.2k); -Claude-доля — 80.5k суммарно (TASK-ревью + перепроверка). Против 06 -(high-baseline: ~735k + 97k Codex, 175k Claude) — дешевле, но качество -ниже: слепые ревью нашли 4 MAJOR (у 06 — 1), все — гонки -конкурентности/восстановления; конвейер их выловил и починил. - -\* usage первого (убитого) хода не был зафиксирован (нет `turn.completed`); цифры — из resume-хода, по масштабу покрывают весь тред. - -Итог 04 (реализация на MEDIUM): Codex — свежий input ~822k + output ~135k -(ревью постановки 117k/17k, реализация 113k/18.5k, саморевью 50k/17.2k, -CODE-ревью 98k/10.4k, триаж 237k/58.9k, перепроверка 207k/12.5k); -Claude-доля — 61k суммарно (TASK-ревью + перепроверка). Слепые ревью -нашли 2 MAJOR + 2 MINOR + 3 NIT, причём CODE-1 (MAJOR) — дефект -постановки (spec-review на high выдумал однострочный `SHA256=`), т.е. -на счету реализации-medium фактически 1 MAJOR (границы размеров). -Примечательно: 04-medium вышла ДОРОЖЕ 06-high по свежим токенам -(~822k+135k против ~735k+97k) — экономию реализации съели дорогой триаж -(7 находок, включая переделку под исправленный протокол) и перепроверки. - -Сводка эксперимента (свежий input + output Codex; MAJOR от слепых ревью): - -| Задача | Effort реализации | Codex токены | Claude | MAJOR | -|---|---|---|---|---| -| 06 | high (baseline) | ~735k + 97k | 175k | 1 | -| 03 | medium | ~636k + 77k | 80.5k | 4 (гонки) | -| 04 | medium | ~822k + 135k | 61k | 2 (1 — вина постановки) | - -## Задача 05 — доставка пакета и приёмка - -| Этап | Агент / модель | Effort | Токены | Прочее | -|---|---|---|---|---| -| 05: ревью постановки, попытки 1–2 (убиты) | Codex gpt-5.6-sol | high | расход токенов потерян (нет `turn.completed`) | обе умерли на `Quota exceeded (os error 122)`: набор `test_install.sh` заполнял `/tmp` тестовыми деревьями по ~1,8 ГБ; потеряно ~2 и ~10 мин работы, сеансы невосстановимы из-за сбоя журнала | - -## Наблюдения - -- Кэш у Codex покрывает ~93% входа ревью-этапа: длинные треды с большим - контекстом дешевле по лимитам, чем кажется по сырому input. -- Сравнивать этапы честнее по «свежий input + output (включая reasoning)», - а не по валовому input. -- Итог эксперимента (решение пользователя 2026-07-21, детали — в - claude-subagent-playbook): реализация по отревьюированному тикету → - medium допустима, но экономию считать по конвейеру целиком (04-medium - дороже 06-high из-за триажа); Claude-субагентам effort задавать явно — - xhigh постановки/планы, high слепое ревью (ниже не опускать), medium - перепроверки; высокий effort не страхует от выдумки внешних фактов - (казус формата `.dgst`); смена effort у Codex при resume кэш не - сбрасывает (~93% кэша при high→medium).