From 27f76a95131999eb90d3d196633dff473cf76c2c Mon Sep 17 00:00:00 2001 From: Roman Date: Wed, 30 Sep 2026 03:52:49 +0800 Subject: [PATCH] feat(clients): configure Codex Desktop with guarded user settings --- .github/workflows/validate.yml | 3 + README.en.md | 13 ++- README.md | 16 +++- docs/manual-setup.md | 8 ++ docs/troubleshooting.md | 8 +- scripts/macos/common.sh | 8 ++ scripts/macos/desktop-config.js | 66 +++++++++++++++ scripts/macos/install.sh | 122 ++++++++++++++++++++++++++- scripts/macos/launch-managed.sh | 10 +++ scripts/macos/uninstall.sh | 38 +++++++++ scripts/windows/desktop-config.ps1 | 125 ++++++++++++++++++++++++++++ scripts/windows/setup.ps1 | 128 ++++++++++++++++++++++++++++- scripts/windows/uninstall.ps1 | 47 +++++++++++ setup-windows.bat | 2 +- tests/macos/smoke.sh | 95 ++++++++++++++++++++- tests/windows/desktop-config.ps1 | 69 ++++++++++++++++ tests/windows/smoke.ps1 | 92 +++++++++++++++++++++ 17 files changed, 835 insertions(+), 15 deletions(-) create mode 100644 scripts/macos/desktop-config.js create mode 100644 scripts/windows/desktop-config.ps1 create mode 100644 tests/windows/desktop-config.ps1 diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 512b84e..baf1ee4 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -37,6 +37,9 @@ jobs: - name: Validate PowerShell syntax shell: powershell run: .\tests\windows\syntax.ps1 + - name: Validate Codex Desktop configuration + shell: powershell + run: .\tests\windows\desktop-config.ps1 - name: Run isolated installer smoke shell: powershell run: .\tests\windows\smoke.ps1 diff --git a/README.en.md b/README.en.md index 49ecb23..e5cfe8b 100644 --- a/README.en.md +++ b/README.en.md @@ -1,6 +1,6 @@ # NeuroAPI for Codex CLI and Claude Code -Public, auditable guided setup for routing local Codex CLI and Claude Code sessions through [NeuroAPI](https://neuroapi.host) on Windows and macOS. It configures the terminal launchers `codex-neuroapi` and `claude-neuroapi`; it does not configure or validate the Claude Desktop Code or Codex Desktop GUIs. +Public, auditable guided setup for routing local Codex CLI and Claude Code sessions through [NeuroAPI](https://neuroapi.host) on Windows and macOS. It configures the terminal launchers `codex-neuroapi` and `claude-neuroapi`. With separate consent, it also configures **Codex Desktop** in the user-level `~/.codex/config.toml`. See the [Claude Desktop guide](https://neuroapi.host/docs/claude-desktop) for that application's own setup. [Русская версия](README.md) @@ -23,16 +23,18 @@ Windows: 1. [Download the agents ZIP](https://github.com/neurogen-dev/NeuroAPI/archive/refs/heads/agents.zip). 2. Extract it and double-click `setup-windows.bat`. 3. Paste the key into the masked PowerShell prompt. -4. Open a new terminal and run `codex-neuroapi` or `claude-neuroapi`. +4. If you use Codex Desktop, choose the optional Desktop setup and restart the app. Your original user config is backed up. +5. Open a new terminal and run `codex-neuroapi` or `claude-neuroapi`. macOS: 1. Download and extract the same ZIP. 2. In the extracted directory, run `bash setup-macos.command`. 3. Paste the key into the macOS Keychain prompt. -4. Run `~/.local/bin/codex-neuroapi` or `~/.local/bin/claude-neuroapi`. +4. If you use Codex Desktop, choose the optional Desktop setup and restart the app. Your original user config is backed up. +5. Run `~/.local/bin/codex-neuroapi` or `~/.local/bin/claude-neuroapi`. -The setup does not require administrator privileges, does not use `sudo`, and does not overwrite existing Codex, Claude Code, or shell configuration. +The setup does not require administrator privileges or `sudo`. Codex Desktop integration edits the user config only after consent, with a guarded backup and conflict checks. ## Secret model @@ -49,6 +51,7 @@ This prevents accidental plaintext disclosure. It does not protect a key from ma - In `codex-neuroapi`, run `/debug-config` and confirm the `neuroapi-host` profile and `https://neuroapi.host/v1/codex`. - In `claude-neuroapi`, run `/status` and confirm `https://neuroapi.host/v1/claude-code` plus `apiKeyHelper`. +- For Codex Desktop, check a new local task, `https://codex.neuroapi.host/v1` in the user config, and the request in your NeuroAPI usage logs. Setup uses HTTP/SSE for this GUI integration. - Check current model IDs and pricing at [neuroapi.host/price](https://neuroapi.host/price). Each launcher fetches a fresh catalog scoped to the ordinary NeuroAPI key. Codex uses a private `model_catalog_json`; Claude receives a configured picker. There are no hardcoded default models. Tested minimum versions are Codex 0.158.0 and Claude Code 2.1.284. The Claude launcher limits initial output to 4096 tokens to bound quota reservation. Invalid, empty or unavailable catalogs stop launch instead of restoring stale lists. Organization policies and deliberate CLI overrides retain their documented precedence; these launchers do not support host-managed provider mode. @@ -64,4 +67,6 @@ Claude Code also uses the `haiku` alias for background work. If no recommended H - [Troubleshooting](docs/troubleshooting.md) - [Codex guide](https://neuroapi.host/codex-api) - [Claude Code guide](https://neuroapi.host/claude-code) +- [Codex Desktop guide](https://neuroapi.host/docs/codex-desktop) +- [Claude Desktop Code guide](https://neuroapi.host/docs/claude-desktop) - [NeuroAPI documentation](https://neuroapi.host/docs/getting-started) diff --git a/README.md b/README.md index bac6bc5..e892fea 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Открытые установщики для подключения [Codex CLI](https://developers.openai.com/codex/cli/) и [Claude Code](https://code.claude.com/docs/en/overview) к [NeuroAPI](https://neuroapi.host) на Windows и macOS. -Пакет настраивает **клиенты в терминале** через команды `codex-neuroapi` и `claude-neuroapi`. Настройки Claude Desktop Code и Codex Desktop этим установщиком не изменяются; работу этих GUI с NeuroAPI он не подтверждает. +Пакет настраивает **клиенты в терминале** через команды `codex-neuroapi` и `claude-neuroapi`. При отдельном согласии он также подключает **Codex Desktop** через пользовательский `~/.codex/config.toml`. Claude Desktop настраивается в самом приложении по [отдельной инструкции](https://neuroapi.host/docs/claude-desktop). NeuroAPI — российский AI API-сервис: единый доступ к моделям OpenAI, Anthropic Claude, Google Gemini, DeepSeek, генерации изображений и видео с оплатой в рублях. Проект работает от российского ООО, инфраструктура сервиса размещена в РФ. Актуальные модели и цены всегда проверяйте в [живом каталоге](https://neuroapi.host/price). @@ -31,7 +31,8 @@ NeuroAPI — российский AI API-сервис: единый доступ 1. [Скачайте ZIP с установщиками](https://github.com/neurogen-dev/NeuroAPI/archive/refs/heads/agents.zip) и распакуйте его. 2. Дважды щёлкните `setup-windows.bat`. 3. Вставьте API-ключ в скрытый запрос PowerShell. -4. Откройте новый терминал и запустите: +4. Если используете Codex Desktop, ответьте «да» на отдельный вопрос установщика и перезапустите приложение. Установщик создаст резервную копию вашего пользовательского конфига. +5. Откройте новый терминал и запустите: ```powershell codex-neuroapi @@ -51,7 +52,8 @@ bash setup-macos.command ``` 4. Вставьте API-ключ в защищённый запрос macOS Keychain. -5. Запустите: +5. Если используете Codex Desktop, ответьте «да» на отдельный вопрос установщика и перезапустите приложение. Установщик создаст резервную копию вашего пользовательского конфига. +6. Запустите: ```bash ~/.local/bin/codex-neuroapi @@ -69,7 +71,8 @@ bash setup-macos.command | Codex | отдельный `~/.codex/neuroapi-host.config.toml` | отдельный `~/.codex/neuroapi-host.config.toml` | | Claude Code | отдельный installer-owned JSON через `--settings` | отдельный installer-owned JSON через `--settings` | | Получает ключ | command-backed auth helper | `apiKeyHelper` / Keychain helper | -| Существующие конфиги | не перезаписываются | не перезаписываются | +| Codex Desktop по согласию | пользовательский `config.toml`, DPAPI helper, каталог доступных моделей | пользовательский `config.toml`, Keychain helper, каталог доступных моделей | +| Существующий `config.toml` | резервная копия и атомарное изменение только по согласию; конфликт останавливает установку | резервная копия и атомарное изменение только по согласию; конфликт останавливает установку | Установщик отправляет ключ только в NeuroAPI для проверки доступных моделей; платной генерации при настройке нет. При запуске `codex-neuroapi` или `claude-neuroapi` запускатель вновь получает актуальный каталог с `https://neuroapi.host`, затем клиент использует API при ваших запросах. @@ -89,12 +92,14 @@ Windows: - `%LOCALAPPDATA%\NeuroAPIAgents\` — helper, Claude settings, DPAPI-ciphertext и launchers; - `%USERPROFILE%\.codex\neuroapi-host.config.toml` — отдельный профиль Codex; +- `%USERPROFILE%\.codex\config.toml` — только при согласии на Codex Desktop; исходный файл хранится в installer-owned каталоге; - `%LOCALAPPDATA%\NeuroAPIAgents\bin` — одна запись в пользовательском `PATH`. macOS: - `~/.local/share/neuroapi-agents/` — helper и Claude settings; - `~/.codex/neuroapi-host.config.toml` — отдельный профиль Codex; +- `~/.codex/config.toml` — только при согласии на Codex Desktop; исходный файл хранится в installer-owned каталоге; - `~/.local/bin/codex-neuroapi` и `~/.local/bin/claude-neuroapi`; - Keychain item `host.neuroapi.agents.api-key`. @@ -127,6 +132,7 @@ Claude Code обращается к alias `haiku` и в фоновых зада - macOS: `./uninstall-macos.command`. Uninstaller удаляет только installer-owned файлы и локально сохранённый ключ. Удалённый ключ через этот пакет восстановить нельзя. +Если после установки вы изменили `~/.codex/config.toml`, удаление остановится до удаления helper и ключа: это сохраняет работоспособность вашей конфигурации. Сначала вручную разберите изменения и повторите удаление. ## Документация @@ -135,6 +141,8 @@ Uninstaller удаляет только installer-owned файлы и локал - [Решение проблем](docs/troubleshooting.md) - [Codex через NeuroAPI](https://neuroapi.host/codex-api) - [Claude Code через NeuroAPI](https://neuroapi.host/claude-code) +- [Codex Desktop](https://neuroapi.host/docs/codex-desktop) +- [Claude Desktop Code](https://neuroapi.host/docs/claude-desktop) - [OpenAI-совместимый API](https://neuroapi.host/openai-compatible-api) - [Модели и цены](https://neuroapi.host/price) diff --git a/docs/manual-setup.md b/docs/manual-setup.md index 61c3d2d..7ee343a 100644 --- a/docs/manual-setup.md +++ b/docs/manual-setup.md @@ -45,6 +45,12 @@ Codex 0.158.0 по умолчанию добавляет к каждому за Project `.codex/config.toml` не подходит для provider/auth redirect: актуальный Codex игнорирует там `model_provider` и `model_providers` по соображениям безопасности. +## Codex Desktop (по отдельному согласию) + +Установщик предлагает включить пользовательский Codex Desktop. В этом случае он сохраняет точную исходную копию `~/.codex/config.toml`, затем устанавливает в нём `model_provider = "neuroapi_agents"`, `model_catalog_json` с моделями, доступными введённому ключу, и подходящую модель по умолчанию. Провайдер использует `https://codex.neuroapi.host/v1`, Responses API, HTTP/SSE (`supports_websockets = false`) и тот же защищённый DPAPI/Keychain helper. Отдельный профиль CLI остаётся независимым. + +Если в исходном файле есть конфликтующий провайдер, необычная форма root-настроек, неверный TOML либо файл изменился во время установки, setup останавливается без перезаписи. Повторная установка сохраняет первоначальную копию. При удалении проверяется хеш конфигурации: если пользователь изменил файл после setup, uninstaller не удаляет helper и ключ, чтобы не сломать действующую настройку. После установки перезапустите Codex Desktop и проверьте новую локальную задачу; полная инструкция: [Codex Desktop](https://neuroapi.host/docs/codex-desktop). + ## HTTP/SSE для диагностики Если соединение WebSocket блокируется вашей сетью, в секции `[model_providers.neuroapi]` созданного профиля замените `supports_websockets = true` на `supports_websockets = false`. Base URL остаётся `https://neuroapi.host/v1/codex`, auth helper и key storage не меняются. При повторном запуске setup управляемый профиль снова получит настройку по умолчанию `true`. @@ -77,6 +83,7 @@ Project `.codex/config.toml` не подходит для provider/auth redirect - Claude settings: `%LOCALAPPDATA%\NeuroAPIAgents\config\claude-settings.json`; - launchers: `%LOCALAPPDATA%\NeuroAPIAgents\bin`; - Codex profile: `%USERPROFILE%\.codex\neuroapi-host.config.toml`. +- Codex Desktop (опционально): `%USERPROFILE%\.codex\config.toml`, резервная копия и каталог в `%LOCALAPPDATA%\NeuroAPIAgents\config`. Setup добавляет только launcher directory в пользовательский `PATH`. @@ -87,6 +94,7 @@ Setup добавляет только launcher directory в пользовате - Claude settings: `~/.local/share/neuroapi-agents/config/claude-settings.json`; - launchers: `~/.local/bin/codex-neuroapi`, `~/.local/bin/claude-neuroapi`; - Codex profile: `~/.codex/neuroapi-host.config.toml`; +- Codex Desktop (опционально): `~/.codex/config.toml`, резервная копия и каталог в `~/.local/share/neuroapi-agents/config`; - Keychain service: `host.neuroapi.agents.api-key`. Setup не меняет `.zprofile`, `.zshrc`, `.bash_profile` или системный `PATH`. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index bcee774..bed3861 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,6 +1,12 @@ # Решение проблем -Эти инструкции относятся к терминальным `codex-neuroapi` и `claude-neuroapi`. Запуск тех же моделей в Codex Desktop или вкладке Code приложения Claude Desktop требует отдельной настройки и проверки GUI. +Терминальные launchers и графические приложения читают разные настройки. Codex Desktop можно подключить отдельным согласием в установщике или [вручную](https://neuroapi.host/docs/codex-desktop); Claude Desktop настраивается в [официальной форме приложения](https://neuroapi.host/docs/claude-desktop). + +## Codex Desktop обращается к `chatgpt.com` + +Проверьте пользовательский `~/.codex/config.toml`: `model_provider = "neuroapi_agents"`, `base_url = "https://codex.neuroapi.host/v1"`, `supports_websockets = false`. Перезапустите приложение и создайте **новую локальную** задачу. Старые задачи и облачные функции могут сохранять прежний маршрут. Если вы пропустили предложение установщика, запустите setup заново и согласитесь на подключение Desktop. + +Если удаление останавливается из-за изменения `config.toml`, сначала сравните текущий файл с резервной копией в каталоге установщика. Это защищает ваши правки и сохраняет key helper до ручного разбора. ## `codex-neuroapi` или `claude-neuroapi` не найдены diff --git a/scripts/macos/common.sh b/scripts/macos/common.sh index 65a504f..0b8ae43 100755 --- a/scripts/macos/common.sh +++ b/scripts/macos/common.sh @@ -38,6 +38,14 @@ codex_home() { fi } +desktop_codex_home() { + if is_test_mode && [[ -n "${NEUROAPI_AGENTS_DESKTOP_CODEX_HOME:-}" ]]; then + printf '%s\n' "$NEUROAPI_AGENTS_DESKTOP_CODEX_HOME" + else + printf '%s\n' "$HOME/.codex" + fi +} + launcher_root() { if is_test_mode && [[ -n "${NEUROAPI_AGENTS_BIN_ROOT:-}" ]]; then printf '%s\n' "$NEUROAPI_AGENTS_BIN_ROOT" diff --git a/scripts/macos/desktop-config.js b/scripts/macos/desktop-config.js new file mode 100644 index 0000000..a50f44b --- /dev/null +++ b/scripts/macos/desktop-config.js @@ -0,0 +1,66 @@ +// macOS JXA: edit only the user-level Codex root settings owned by setup. +ObjC.import('Foundation') + +function fail(message) { throw new Error(message) } +function read(path) { + var handle = $.NSFileHandle.fileHandleForReadingAtPath($(path)) + if (handle.isNil()) fail('Cannot read Codex config') + var data = handle.readDataToEndOfFile + handle.closeFile + if (Number(data.length) > 4194304) fail('Codex config is too large') + var decoded = $.NSString.alloc.initWithDataEncoding(data, $.NSUTF8StringEncoding) + if (decoded.isNil()) fail('Codex config is not UTF-8') + return ObjC.unwrap(decoded) +} +function quote(value) { + return '"' + value.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"' +} +function rootSetting(source, key, value, newline) { + // Match only a real table header at the beginning of a line. + var table = /^[ \t]*\[[^\r\n]*\]/m.exec(source) + var boundary = table ? table.index : source.length + var root = source.slice(0, boundary) + var rest = source.slice(boundary) + var pattern = new RegExp('^([ \\t]*)' + key + '[ \\t]*=[ \\t]*(.*)$', 'gm') + var matches = [], match + while ((match = pattern.exec(root)) !== null) matches.push(match) + if (matches.length > 1) fail('Duplicate Codex root setting: ' + key) + if (matches.length === 1) { + if (!/^"(?:[^"\\]|\\.)*"(?:[ \t]*#.*)?\r?$/.test(matches[0][2])) + fail('Unsupported Codex root setting: ' + key) + root = root.slice(0, matches[0].index) + matches[0][1] + key + ' = ' + quote(value) + + root.slice(matches[0].index + matches[0][0].length) + } else { + root = key + ' = ' + quote(value) + newline + root + } + return root + rest +} +function run(args) { + if (args.length !== 5) fail('Invalid desktop config arguments') + var original = read(args[0]) + var model = read(args[1]).trim() + if (!/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/.test(model)) fail('Invalid default model') + if (original.indexOf('"""') !== -1 || original.indexOf("'''") !== -1) + fail('Multiline TOML requires manual Codex Desktop setup') + if (/^[ \t]*\[[ \t]*model_providers\.(?:"neuroapi_agents"|'neuroapi_agents'|neuroapi_agents)(?:\.|[ \t]*\])/m.test(original)) + fail('Codex provider ID is already in use') + if (original.indexOf('\u0000') !== -1) fail('Codex config contains NUL') + var newline = original.indexOf('\r\n') !== -1 ? '\r\n' : '\n' + var result = original + result = rootSetting(result, 'model_provider', 'neuroapi_agents', newline) + result = rootSetting(result, 'model_catalog_json', args[2], newline) + result = rootSetting(result, 'model', model, newline) + if (result.length && !/\n$/.test(result)) result += newline + result += ('\n# NeuroAPI Agents desktop provider. Owned by the installer.\n' + + '[model_providers.neuroapi_agents]\n' + + 'name = "NeuroAPI"\n' + + 'base_url = "https://codex.neuroapi.host/v1"\n' + + 'wire_api = "responses"\n' + + 'supports_websockets = false\n\n' + + '[model_providers.neuroapi_agents.auth]\n' + + 'command = ' + quote(args[3]) + '\n' + + 'timeout_ms = 5000\n' + + 'refresh_interval_ms = 300000\n').replace(/\n/g, newline) + if (!$(result).dataUsingEncoding($.NSUTF8StringEncoding).writeToFileAtomically($(args[4]), true)) + fail('Cannot stage Codex config') +} diff --git a/scripts/macos/install.sh b/scripts/macos/install.sh index bb3f1d5..9cbb365 100755 --- a/scripts/macos/install.sh +++ b/scripts/macos/install.sh @@ -21,6 +21,11 @@ PROFILE_CONFIG_ROOT="$(codex_home)" LAUNCHER_ROOT="$(launcher_root)" PROFILE_PATH="$(profile_path)" PROFILE_MARKER_PATH="$(profile_marker_path)" +DESKTOP_CONFIG_ROOT="$(desktop_codex_home)" +DESKTOP_CONFIG_PATH="$DESKTOP_CONFIG_ROOT/config.toml" +DESKTOP_ORIGINAL_PATH="$STATE_ROOT/config/codex-desktop-original.toml" +DESKTOP_HASH_PATH="$STATE_ROOT/config/codex-desktop-state" +DESKTOP_CATALOG_PATH="$STATE_ROOT/config/codex-desktop-models.json" SECURITY_BIN="$(security_bin)" CURRENT_USER="$(current_user)" HELPER_PATH="$STATE_ROOT/bin/get-neuroapi-key.sh" @@ -40,6 +45,39 @@ if [[ -e "$PROFILE_PATH" ]] && ! marker_is_owned "$PROFILE_MARKER_PATH"; then exit 1 fi +DESKTOP_OPT_IN=0 +if [[ -L "$DESKTOP_ORIGINAL_PATH" || -L "$DESKTOP_HASH_PATH" || -L "$DESKTOP_CATALOG_PATH" ]]; then + printf 'Refusing to use symlinked Codex Desktop ownership state.\n' >&2 + exit 1 +fi +if [[ -e "$DESKTOP_HASH_PATH" ]]; then + if [[ ! -f "$DESKTOP_ORIGINAL_PATH" || ! -f "$DESKTOP_CATALOG_PATH" || ! -f "$DESKTOP_CONFIG_PATH" ]]; then + printf 'Codex Desktop ownership state is incomplete. Existing settings were not changed.\n' >&2 + exit 1 + fi + IFS=' ' read -r desktop_state_version desktop_original_exists desktop_applied_hash <"$DESKTOP_HASH_PATH" + if [[ "$desktop_state_version" != 'v1' || ! "$desktop_original_exists" =~ ^[01]$ || + ! "$desktop_applied_hash" =~ ^[a-f0-9]{64}$ || + "$(/usr/bin/shasum -a 256 "$DESKTOP_CONFIG_PATH" | /usr/bin/awk '{print $1}')" != "$desktop_applied_hash" ]]; then + printf 'Codex Desktop config changed since setup. Resolve it manually before reinstalling.\n' >&2 + exit 1 + fi + DESKTOP_OPT_IN=1 +elif [[ -e "$DESKTOP_ORIGINAL_PATH" || -e "$DESKTOP_CATALOG_PATH" ]]; then + printf 'Codex Desktop ownership state is incomplete. Existing settings were not changed.\n' >&2 + exit 1 +elif is_test_mode; then + [[ "${NEUROAPI_AGENTS_TEST_DESKTOP_OPT_IN:-0}" == '1' ]] && DESKTOP_OPT_IN=1 +else + printf '\nПодключить также Codex Desktop к NeuroAPI? [y/N]: ' + IFS= read -r desktop_answer || desktop_answer='' + case "$desktop_answer" in [yY]|[yY][eE][sS]|[дД]|[дД][аА]) DESKTOP_OPT_IN=1 ;; esac +fi +if [[ "$DESKTOP_OPT_IN" == '1' && ( -L "$DESKTOP_CONFIG_ROOT" || -L "$DESKTOP_CONFIG_PATH" ) ]]; then + printf 'Refusing to use a symlinked Codex Desktop configuration path.\n' >&2 + exit 1 +fi + if "$SECURITY_BIN" find-generic-password \ -a "$CURRENT_USER" \ -s "$PREVIOUS_SERVICE" >/dev/null 2>&1 && @@ -97,8 +135,11 @@ STAGE_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/neuroapi-agents-stage.XXXXXX")" CANDIDATE_SERVICE="${KEYCHAIN_SERVICE}.$(LC_ALL=C /usr/bin/od -An -N16 -tx1 /dev/urandom | /usr/bin/tr -d ' \n')" CANDIDATE_OWNED=0 COMMIT_STARTED=0 +PROMOTE_COUNT=0 STATE_ROOT_WAS_PRESENT=0 [[ -d "$STATE_ROOT" ]] && STATE_ROOT_WAS_PRESENT=1 +DESKTOP_ROOT_WAS_PRESENT=0 +[[ -d "$DESKTOP_CONFIG_ROOT" ]] && DESKTOP_ROOT_WAS_PRESENT=1 PROMOTE_TARGETS=() PROMOTE_BACKUPS=() cleanup_stage() { @@ -106,8 +147,10 @@ cleanup_stage() { trap - EXIT set +e if [[ "$status" -ne 0 && "$COMMIT_STARTED" == '1' ]]; then - for ((i=${#PROMOTE_TARGETS[@]}-1; i>=0; i--)); do + for ((i=0; i<${#PROMOTE_TARGETS[@]}; i++)); do rm -f -- "${PROMOTE_TARGETS[i]}.new.$$" + done + for ((i=PROMOTE_COUNT-1; i>=0; i--)); do if [[ "${PROMOTE_BACKUPS[i]}" == 'present' ]]; then cp -p -- "$STAGE_ROOT/rollback/$i" "${PROMOTE_TARGETS[i]}" else @@ -119,6 +162,9 @@ cleanup_stage() { elif ! marker_is_owned "$(state_marker_path)"; then rmdir -- "$STATE_ROOT/bin" "$STATE_ROOT/config" 2>/dev/null || true fi + if [[ "$DESKTOP_OPT_IN" == '1' && "$DESKTOP_ROOT_WAS_PRESENT" == '0' ]]; then + rmdir -- "$DESKTOP_CONFIG_ROOT" 2>/dev/null || true + fi fi if [[ "$CANDIDATE_OWNED" == '1' ]]; then "$SECURITY_BIN" delete-generic-password -a "$CURRENT_USER" -s "$CANDIDATE_SERVICE" >/dev/null 2>&1 || true @@ -164,6 +210,58 @@ if ! is_test_mode || [[ "${NEUROAPI_AGENTS_TEST_PREFLIGHT:-0}" == '1' ]]; then done fi +if [[ "$DESKTOP_OPT_IN" == '1' ]]; then + mkdir -p "$STAGE_ROOT/desktop" + if [[ -e "$DESKTOP_HASH_PATH" ]]; then + cp -p -- "$DESKTOP_ORIGINAL_PATH" "$STAGE_ROOT/desktop/original.toml" + elif [[ -e "$DESKTOP_CONFIG_PATH" ]]; then + [[ -f "$DESKTOP_CONFIG_PATH" ]] || { printf 'Invalid Codex Desktop config.\n' >&2; exit 1; } + cp -p -- "$DESKTOP_CONFIG_PATH" "$STAGE_ROOT/desktop/original.toml" + else + : >"$STAGE_ROOT/desktop/original.toml" + fi + chmod 600 "$STAGE_ROOT/desktop/original.toml" + desktop_expected_exists=0 + desktop_expected_hash='' + if [[ -e "$DESKTOP_CONFIG_PATH" ]]; then + desktop_expected_exists=1 + desktop_expected_hash="$(/usr/bin/shasum -a 256 "$DESKTOP_CONFIG_PATH" | /usr/bin/awk '{print $1}')" + fi + if [[ -e "$DESKTOP_HASH_PATH" && "$desktop_expected_hash" != "$desktop_applied_hash" ]]; then + printf 'Codex Desktop config changed during setup; existing settings were preserved.\n' >&2 + exit 1 + fi + if ! "$STAGE_ROOT/bin/launch-managed.sh" codex --export-codex-catalog "$STAGE_ROOT/desktop"; then + printf 'Не удалось проверить модели Codex Desktop для этого ключа. Настройки сохранены.\n' >&2 + exit 1 + fi + /usr/bin/osascript -l JavaScript "$SCRIPT_DIR/desktop-config.js" \ + "$STAGE_ROOT/desktop/original.toml" "$STAGE_ROOT/desktop/model.txt" \ + "$DESKTOP_CATALOG_PATH" "$HELPER_PATH" "$STAGE_ROOT/desktop/config.toml" + chmod 600 "$STAGE_ROOT/desktop/config.toml" "$STAGE_ROOT/desktop/models.json" + desktop_original_exists=0 + [[ -e "$DESKTOP_CONFIG_PATH" && ! -e "$DESKTOP_HASH_PATH" ]] && desktop_original_exists=1 + if [[ -e "$DESKTOP_HASH_PATH" ]]; then + IFS=' ' read -r _ desktop_original_exists _ <"$DESKTOP_HASH_PATH" + fi + desktop_hash="$(/usr/bin/shasum -a 256 "$STAGE_ROOT/desktop/config.toml" | /usr/bin/awk '{print $1}')" + printf 'v1 %s %s\n' "$desktop_original_exists" "$desktop_hash" >"$STAGE_ROOT/desktop/state" + desktop_codex_bin="$(resolve_client_bin codex 0 158 0 || true)" + if [[ -z "$desktop_codex_bin" ]] || ! client_version_at_least "$desktop_codex_bin" 0 158 0; then + printf 'Для проверки Codex Desktop требуется Codex CLI 0.158.0 или новее.\n' >&2 + exit 1 + fi + mkdir -p "$STAGE_ROOT/desktop/validate" + /usr/bin/osascript -l JavaScript "$SCRIPT_DIR/desktop-config.js" \ + "$STAGE_ROOT/desktop/original.toml" "$STAGE_ROOT/desktop/model.txt" \ + "$STAGE_ROOT/desktop/models.json" "$STAGE_ROOT/bin/get-neuroapi-key.sh" \ + "$STAGE_ROOT/desktop/validate/config.toml" + if ! CODEX_HOME="$STAGE_ROOT/desktop/validate" "$desktop_codex_bin" features list >/dev/null; then + printf 'Codex отклонил настройки Desktop. Существующий конфиг сохранён.\n' >&2 + exit 1 + fi +fi + ESCAPED_HELPER_PATH="$(toml_escape "$HELPER_PATH")" cat >"$STAGE_ROOT/profile.toml" <&2 @@ -264,14 +368,30 @@ done COMMIT_STARTED=1 mkdir -p "$STATE_ROOT/bin" "$STATE_ROOT/config" "$PROFILE_CONFIG_ROOT" "$LAUNCHER_ROOT" +if [[ "$DESKTOP_OPT_IN" == '1' ]]; then + mkdir -p "$DESKTOP_CONFIG_ROOT" +fi for ((i=0; i<${#PROMOTE_TARGETS[@]}; i++)); do target="${PROMOTE_TARGETS[i]}" + if [[ "$DESKTOP_OPT_IN" == '1' && "$target" == "$DESKTOP_CONFIG_PATH" ]]; then + if [[ "$desktop_expected_exists" == '1' ]]; then + if [[ ! -f "$target" || -L "$target" || + "$(/usr/bin/shasum -a 256 "$target" | /usr/bin/awk '{print $1}')" != "$desktop_expected_hash" ]]; then + printf 'Codex Desktop config changed during setup; existing settings were preserved.\n' >&2 + exit 1 + fi + elif [[ -e "$target" || -L "$target" ]]; then + printf 'Codex Desktop config appeared during setup; existing settings were preserved.\n' >&2 + exit 1 + fi + fi if is_test_mode && [[ "${NEUROAPI_AGENTS_TEST_FAIL_COMMIT_AT:-}" == "$(basename -- "$target")" ]]; then printf 'Simulated installer commit failure.\n' >&2 exit 1 fi cp -p -- "${PROMOTE_SOURCES[i]}" "$target.new.$$" mv -f -- "$target.new.$$" "$target" + PROMOTE_COUNT=$((i + 1)) done CANDIDATE_OWNED=0 if [[ "$PREVIOUS_SERVICE" != "$CANDIDATE_SERVICE" ]] && diff --git a/scripts/macos/launch-managed.sh b/scripts/macos/launch-managed.sh index ffe0341..db244cc 100755 --- a/scripts/macos/launch-managed.sh +++ b/scripts/macos/launch-managed.sh @@ -16,9 +16,13 @@ case "$CLIENT" in esac VERIFY_ONLY=0 +EXPORT_CATALOG_ROOT='' if [[ "${1:-}" == '--verify' ]]; then VERIFY_ONLY=1 shift +elif [[ "${1:-}" == '--export-codex-catalog' && "$CLIENT" == 'codex' && -n "${2:-}" ]]; then + EXPORT_CATALOG_ROOT="$2" + shift 2 fi fail() { printf '%s\n' "$1" >&2; exit 1; } @@ -89,6 +93,12 @@ if ! printf 'header = "Authorization: Bearer %s"\n' "$credential" | fi unset credential +if [[ -n "$EXPORT_CATALOG_ROOT" ]]; then + cp -- "$SNAPSHOT/models.json" "$EXPORT_CATALOG_ROOT/models.json" + cp -- "$SNAPSHOT/model.txt" "$EXPORT_CATALOG_ROOT/model.txt" + exit 0 +fi + if (( VERIFY_ONLY )); then printf 'Каталог %s проверен.\n' "$CLIENT" exit 0 diff --git a/scripts/macos/uninstall.sh b/scripts/macos/uninstall.sh index 04e3fd5..c494f1d 100755 --- a/scripts/macos/uninstall.sh +++ b/scripts/macos/uninstall.sh @@ -13,6 +13,11 @@ fi STATE_ROOT="$(state_root)" PROFILE_PATH="$(profile_path)" PROFILE_MARKER_PATH="$(profile_marker_path)" +DESKTOP_CONFIG_ROOT="$(desktop_codex_home)" +DESKTOP_CONFIG_PATH="$DESKTOP_CONFIG_ROOT/config.toml" +DESKTOP_ORIGINAL_PATH="$STATE_ROOT/config/codex-desktop-original.toml" +DESKTOP_HASH_PATH="$STATE_ROOT/config/codex-desktop-state" +DESKTOP_CATALOG_PATH="$STATE_ROOT/config/codex-desktop-models.json" LAUNCHER_ROOT="$(launcher_root)" SECURITY_BIN="$(security_bin)" CURRENT_USER="$(current_user)" @@ -42,6 +47,39 @@ if [[ -d "$STATE_ROOT" ]] && ! marker_is_owned "$(state_marker_path)"; then exit 1 fi +# Do this before deleting the helper or Keychain secret: a changed Desktop +# config could still point at both and must remain usable for manual repair. +if [[ -L "$DESKTOP_ORIGINAL_PATH" || -L "$DESKTOP_HASH_PATH" || -L "$DESKTOP_CATALOG_PATH" ]]; then + printf 'Codex Desktop ownership state uses a symlink; uninstall stopped without changing credentials.\n' >&2 + exit 1 +fi +if [[ -e "$DESKTOP_HASH_PATH" ]]; then + if [[ -L "$DESKTOP_CONFIG_ROOT" || -L "$DESKTOP_CONFIG_PATH" ]]; then + printf 'Codex Desktop config uses a symlink; uninstall stopped without changing credentials.\n' >&2 + exit 1 + fi + if [[ ! -f "$DESKTOP_ORIGINAL_PATH" || ! -f "$DESKTOP_CATALOG_PATH" || ! -f "$DESKTOP_CONFIG_PATH" ]]; then + printf 'Codex Desktop ownership state is incomplete; uninstall stopped.\n' >&2 + exit 1 + fi + IFS=' ' read -r desktop_state_version desktop_original_exists desktop_applied_hash <"$DESKTOP_HASH_PATH" + if [[ "$desktop_state_version" != 'v1' || ! "$desktop_original_exists" =~ ^[01]$ || + ! "$desktop_applied_hash" =~ ^[a-f0-9]{64}$ || + "$(/usr/bin/shasum -a 256 "$DESKTOP_CONFIG_PATH" | /usr/bin/awk '{print $1}')" != "$desktop_applied_hash" ]]; then + printf 'Codex Desktop config changed since setup. Restore the saved config manually or remove the NeuroAPI provider, then retry uninstall. Credentials were kept.\n' >&2 + exit 1 + fi + if [[ "$desktop_original_exists" == '1' ]]; then + cp -p -- "$DESKTOP_ORIGINAL_PATH" "$DESKTOP_CONFIG_PATH.new.$$" + mv -f -- "$DESKTOP_CONFIG_PATH.new.$$" "$DESKTOP_CONFIG_PATH" + else + rm -f -- "$DESKTOP_CONFIG_PATH" + fi +elif [[ -e "$DESKTOP_ORIGINAL_PATH" || -e "$DESKTOP_CATALOG_PATH" ]]; then + printf 'Codex Desktop ownership state is incomplete; uninstall stopped.\n' >&2 + exit 1 +fi + if marker_is_owned "$(keychain_marker_path)"; then KEYCHAIN_ITEM_IS_OWNED=1 fi diff --git a/scripts/windows/desktop-config.ps1 b/scripts/windows/desktop-config.ps1 new file mode 100644 index 0000000..18af6ec --- /dev/null +++ b/scripts/windows/desktop-config.ps1 @@ -0,0 +1,125 @@ +Set-StrictMode -Version Latest + +$script:DesktopProviderId = 'neuroapi_agents' +$script:DesktopMetadataName = 'codex-desktop-state.json' +$script:DesktopOriginalName = 'codex-desktop-original.toml' +$script:DesktopCatalogName = 'codex-desktop-models.json' + +function Get-NeuroAPIDesktopHash { + param([AllowEmptyString()][string]$Text) + $bytes = [Text.Encoding]::UTF8.GetBytes($Text) + $sha = [Security.Cryptography.SHA256]::Create() + try { return [BitConverter]::ToString($sha.ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() } + finally { $sha.Dispose() } +} + +function Assert-NeuroAPIDesktopFile { + param([string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { return } + $item = Get-Item -LiteralPath $Path -Force + if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { + throw 'Codex Desktop configuration contains a directory or link. No changes were made.' + } +} + +function Get-NeuroAPIDesktopState { + param([string]$ConfigRoot) + $metadata = Join-Path $ConfigRoot $script:DesktopMetadataName + Assert-NeuroAPIDesktopFile -Path $metadata + if (-not (Test-Path -LiteralPath $metadata)) { return $null } + try { + $state = [IO.File]::ReadAllText($metadata) | ConvertFrom-Json -ErrorAction Stop + if ($state.version -ne 1 -or $state.provider -cne $script:DesktopProviderId -or + $state.applied_hash -cnotmatch '^[a-f0-9]{64}$' -or + $state.original_exists -isnot [bool]) { throw 'Invalid ownership state' } + if ($state.original_exists -and $state.original_hash -cnotmatch '^[a-f0-9]{64}$') { + throw 'Invalid original hash' + } + return $state + } catch { + throw 'Codex Desktop ownership state is invalid. No changes were made.' + } +} + +function New-NeuroAPIDesktopConfig { + param( + [AllowEmptyString()][string]$Original, + [string]$HelperPath, + [string]$SecretPath, + [string]$CatalogPath, + [string]$DefaultModel + ) + if (-not (Test-NeuroAPIModelId $DefaultModel)) { throw 'No valid Codex model is available for this key.' } + if ($Original -match '(?m)^\s*\[\s*model_providers\.\s*(?:neuroapi_agents|"neuroapi_agents"|''neuroapi_agents'')(?:\.|\])') { + throw 'The Codex provider ID is already in use. No changes were made.' + } + $lines = [regex]::Split($Original, '\r\n|\n|\r') + $firstTable = $lines.Count + for ($i = 0; $i -lt $lines.Count; $i++) { + if ($lines[$i] -match '^\s*\[') { $firstTable = $i; break } + } + [string[]]$header = @() + [string[]]$tail = @() + if ($firstTable -gt 0) { $header = @($lines[0..($firstTable - 1)]) } + if ($firstTable -lt $lines.Count) { $tail = @($lines[$firstTable..($lines.Count - 1)]) } + if (($header -join "`n") -match '("""|'''''')') { + throw 'Multiline root TOML cannot be edited safely. No changes were made.' + } + $values = [ordered]@{ + model_provider = (ConvertTo-TomlBasicString -Value $script:DesktopProviderId) + model_catalog_json = (ConvertTo-TomlBasicString -Value $CatalogPath) + model = (ConvertTo-TomlBasicString -Value $DefaultModel) + } + foreach ($key in @($values.Keys)) { + $found = @() + for ($i = 0; $i -lt $header.Count; $i++) { + if ($header[$i] -match ('^\s*' + $key + '\s*=')) { $found += $i } + } + if ($found.Count -gt 1) { throw "Duplicate Codex setting: $key" } + if ($found.Count -eq 1) { + # Reject unusual/multiline assignments rather than corrupting them. + if ($header[$found[0]] -notmatch ('^\s*' + $key + '\s*=\s*(?:"(?:[^"\\]|\\.)*"|''[^'']*'')(?\s*(?:#.*)?)$')) { + throw "Unsupported Codex setting syntax: $key" + } + $header[$found[0]] = $key + ' = ' + $values[$key] + $Matches.suffix + } else { + $header += ($key + ' = ' + $values[$key]) + } + } + $base = ((@($header) + @($tail)) -join "`n").TrimEnd("`r", "`n") + $tomlHelper = ConvertTo-TomlBasicString -Value $HelperPath + $tomlSecret = ConvertTo-TomlBasicString -Value $SecretPath + return ($base + "`n`n" + @" +# BEGIN NEUROAPI CODEX DESKTOP +[model_providers.neuroapi_agents] +name = "NeuroAPI" +base_url = "https://codex.neuroapi.host/v1" +wire_api = "responses" +supports_websockets = false + +[model_providers.neuroapi_agents.auth] +command = "powershell.exe" +args = ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", $tomlHelper, "-SecretPath", $tomlSecret] +timeout_ms = 5000 +refresh_interval_ms = 300000 +# END NEUROAPI CODEX DESKTOP +"@.TrimStart() + "`n") +} + +function Assert-NeuroAPIDesktopConfigWithCodex { + param([string]$Config, [string]$StageRoot, [string]$CodexBinary) + if (-not (Test-Path -LiteralPath $CodexBinary -PathType Leaf)) { + throw 'Codex CLI is required to validate the desktop configuration.' + } + $validationRoot = Join-Path $StageRoot 'codex-validate' + Ensure-Directory -Path $validationRoot + Write-Utf8NoBom -Path (Join-Path $validationRoot 'config.toml') -Content $Config + $previous = $env:CODEX_HOME + try { + $env:CODEX_HOME = $validationRoot + $output = & $CodexBinary features list 2>&1 + if ($LASTEXITCODE -ne 0) { throw 'Codex rejected the desktop configuration.' } + } finally { + $env:CODEX_HOME = $previous + } +} diff --git a/scripts/windows/setup.ps1 b/scripts/windows/setup.ps1 index f4de0c9..6acc0a6 100644 --- a/scripts/windows/setup.ps1 +++ b/scripts/windows/setup.ps1 @@ -3,11 +3,14 @@ param( [switch]$TestMode, [string]$StateRoot, [string]$CodexHome, - [switch]$NoPathUpdate + [switch]$NoPathUpdate, + [switch]$EnableCodexDesktop, + [string]$DesktopCatalogJson ) . "$PSScriptRoot\common.ps1" . "$PSScriptRoot\managed-catalog.ps1" +. "$PSScriptRoot\desktop-config.ps1" if ($env:OS -ne 'Windows_NT') { throw 'This installer must run on Windows.' @@ -39,6 +42,13 @@ if (-not $TestMode) { throw 'A custom CodexHome is allowed only in test mode.' } } +if (-not $TestMode -and -not $EnableCodexDesktop) { + $choice = Read-Host 'Настроить также Codex Desktop через NeuroAPI? Изменится ваш пользовательский config.toml. [y/N]' + $EnableCodexDesktop = $choice -match '^(?i:y|yes|да)$' +} +if (-not $TestMode -and -not [string]::IsNullOrEmpty($DesktopCatalogJson)) { + throw 'DesktopCatalogJson is available only in test mode.' +} Assert-StateRootIsOwnedOrEmpty -StateRoot $StateRoot @@ -73,6 +83,64 @@ $secretRoot = [System.IO.Path]::Combine($StateRoot, 'secret') $secretPath = [System.IO.Path]::Combine($secretRoot, 'api-key.dpapi') $helperPath = [System.IO.Path]::Combine($binRoot, 'get-neuroapi-key.ps1') $claudeSettingsPath = [System.IO.Path]::Combine($configRoot, 'claude-settings.json') +$desktopConfigPath = [System.IO.Path]::Combine($CodexHome, 'config.toml') +$desktopMetadataPath = Join-Path $configRoot $script:DesktopMetadataName +$desktopOriginalPath = Join-Path $configRoot $script:DesktopOriginalName +$desktopCatalogPath = Join-Path $configRoot $script:DesktopCatalogName +$desktopState = $null +$desktopOriginal = '' +$desktopOriginalExists = $false +$desktopOriginalSource = $null +$desktopCurrentExists = $false +$desktopCurrentHash = $null +if ($EnableCodexDesktop) { + foreach ($path in @($CodexHome, $configRoot, $desktopConfigPath, $desktopOriginalPath, $desktopCatalogPath)) { + if (Test-Path -LiteralPath $path) { + $item = Get-Item -LiteralPath $path -Force + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { + throw 'Codex Desktop configuration is linked. No changes were made.' + } + } + } + Assert-NeuroAPIDesktopFile -Path $desktopConfigPath + Assert-NeuroAPIDesktopFile -Path $desktopOriginalPath + Assert-NeuroAPIDesktopFile -Path $desktopCatalogPath + $desktopState = Get-NeuroAPIDesktopState -ConfigRoot $configRoot + $desktopCurrentExists = Test-Path -LiteralPath $desktopConfigPath -PathType Leaf + if ($desktopCurrentExists) { + $desktopCurrentHash = (Get-FileHash -LiteralPath $desktopConfigPath -Algorithm SHA256).Hash + } + if ($null -ne $desktopState) { + if (-not (Test-Path -LiteralPath $desktopConfigPath -PathType Leaf)) { + throw 'Codex Desktop configuration was removed after setup. No changes were made.' + } + if ((Get-FileHash -LiteralPath $desktopConfigPath -Algorithm SHA256).Hash.ToLowerInvariant() -cne $desktopState.applied_hash) { + throw 'Codex Desktop configuration changed after setup. Preserve your edits and configure manually.' + } + $desktopOriginalExists = $desktopState.original_exists + if ($desktopOriginalExists) { + if (-not (Test-Path -LiteralPath $desktopOriginalPath -PathType Leaf)) { + throw 'Codex Desktop backup is missing. No changes were made.' + } + $desktopOriginal = [IO.File]::ReadAllText($desktopOriginalPath) + $desktopOriginalSource = $desktopOriginalPath + if ((Get-FileHash -LiteralPath $desktopOriginalPath -Algorithm SHA256).Hash.ToLowerInvariant() -cne $desktopState.original_hash) { + throw 'Codex Desktop backup changed after setup. No changes were made.' + } + } elseif (Test-Path -LiteralPath $desktopOriginalPath) { + throw 'Codex Desktop backup state is inconsistent. No changes were made.' + } + } else { + if (Test-Path -LiteralPath $desktopOriginalPath) { + throw 'An unowned Codex Desktop backup exists. No changes were made.' + } + $desktopOriginalExists = Test-Path -LiteralPath $desktopConfigPath -PathType Leaf + if ($desktopOriginalExists) { + $desktopOriginal = [IO.File]::ReadAllText($desktopConfigPath) + $desktopOriginalSource = $desktopConfigPath + } + } +} if (-not $TestMode) { Ensure-Directory -Path $StateRoot @@ -106,7 +174,7 @@ try { $ciphertext = $null $secureKey.Clear() - foreach ($scriptName in @('get-neuroapi-key.ps1', 'common.ps1', 'managed-catalog.ps1', 'launch-neuroapi.ps1')) { + foreach ($scriptName in @('get-neuroapi-key.ps1', 'common.ps1', 'managed-catalog.ps1', 'desktop-config.ps1', 'launch-neuroapi.ps1')) { Copy-Item -LiteralPath (Join-Path $PSScriptRoot $scriptName) -Destination (Join-Path (Join-Path $stageRoot 'bin') $scriptName) } @@ -150,6 +218,45 @@ refresh_interval_ms = 300000 } | ConvertTo-Json -Depth 5 Write-Utf8NoBom -Path (Join-Path $stageRoot 'claude-settings.json') -Content $claudeSettings + if ($EnableCodexDesktop) { + $catalogJson = if ($TestMode) { $DesktopCatalogJson } else { + Get-NeuroAPICatalogJson -Client codex -SecretPath (Join-Path $stageRoot 'api-key.dpapi') + } + $catalog = ConvertFrom-NeuroAPICatalog -Client codex -Json $catalogJson -HelperCommand '' + $preferred = @($catalog.Content.models | Where-Object { + $_.slug -ceq 'gpt-6-sol' -and $_.visibility -ceq 'list' -and $_.supported_in_api + }) + $desktopModel = if ($preferred.Count -gt 0) { 'gpt-6-sol' } else { $catalog.DefaultModel } + $desktopContent = New-NeuroAPIDesktopConfig -Original $desktopOriginal ` + -HelperPath $helperPath -SecretPath $secretPath -CatalogPath $desktopCatalogPath ` + -DefaultModel $desktopModel + $catalogFile = @{ models = @($catalog.Content.models) } | ConvertTo-Json -Depth 32 + $stagedDesktopCatalog = Join-Path $stageRoot 'desktop-catalog.json' + Write-Utf8NoBom -Path $stagedDesktopCatalog -Content $catalogFile + $nativeCodex = Get-NeuroAPIClientExecutable -Client codex -StateRoot $StateRoot + if (-not $TestMode) { + Assert-NeuroAPIDesktopConfigWithCodex -Config $desktopOriginal -StageRoot $stageRoot -CodexBinary $nativeCodex + $validationContent = $desktopContent.Replace( + (ConvertTo-TomlBasicString -Value $desktopCatalogPath), + (ConvertTo-TomlBasicString -Value $stagedDesktopCatalog) + ) + Assert-NeuroAPIDesktopConfigWithCodex -Config $validationContent -StageRoot $stageRoot -CodexBinary $nativeCodex + } + Write-Utf8NoBom -Path (Join-Path $stageRoot 'desktop-config.toml') -Content $desktopContent + if ($desktopOriginalExists) { + Copy-Item -LiteralPath $desktopOriginalSource -Destination (Join-Path $stageRoot 'desktop-original.toml') -ErrorAction Stop + } + $desktopMetadata = [ordered]@{ + version = 1; provider = $script:DesktopProviderId + applied_hash = (Get-NeuroAPIDesktopHash -Text $desktopContent) + original_exists = $desktopOriginalExists + original_hash = $(if ($desktopOriginalExists) { + (Get-FileHash -LiteralPath (Join-Path $stageRoot 'desktop-original.toml') -Algorithm SHA256).Hash.ToLowerInvariant() + } else { $null }) + } | ConvertTo-Json -Compress + Write-Utf8NoBom -Path (Join-Path $stageRoot 'desktop-state.json') -Content $desktopMetadata + } + $codexLauncher = @" @echo off setlocal @@ -173,6 +280,7 @@ exit /b %errorlevel% [pscustomobject]@{ Source = (Join-Path $stageRoot 'bin/get-neuroapi-key.ps1'); Target = $helperPath; Name = 'get-neuroapi-key.ps1' }, [pscustomobject]@{ Source = (Join-Path $stageRoot 'bin/common.ps1'); Target = (Join-Path $binRoot 'common.ps1'); Name = 'common.ps1' }, [pscustomobject]@{ Source = (Join-Path $stageRoot 'bin/managed-catalog.ps1'); Target = (Join-Path $binRoot 'managed-catalog.ps1'); Name = 'managed-catalog.ps1' }, + [pscustomobject]@{ Source = (Join-Path $stageRoot 'bin/desktop-config.ps1'); Target = (Join-Path $binRoot 'desktop-config.ps1'); Name = 'desktop-config.ps1' }, [pscustomobject]@{ Source = (Join-Path $stageRoot 'bin/launch-neuroapi.ps1'); Target = (Join-Path $binRoot 'launch-neuroapi.ps1'); Name = 'launch-neuroapi.ps1' }, [pscustomobject]@{ Source = (Join-Path $stageRoot 'profile.toml'); Target = $profilePath; Name = 'neuroapi-host.config.toml' }, [pscustomobject]@{ Source = (Join-Path $stageRoot 'profile-marker'); Target = $profileMarkerPath; Name = 'profile-marker' }, @@ -181,8 +289,23 @@ exit /b %errorlevel% [pscustomobject]@{ Source = (Join-Path $stageRoot 'claude-neuroapi.cmd'); Target = (Join-Path $binRoot 'claude-neuroapi.cmd'); Name = 'claude-neuroapi.cmd' }, [pscustomobject]@{ Source = (Join-Path $stageRoot 'api-key.dpapi'); Target = $secretPath; Name = 'api-key.dpapi' } ) + if ($EnableCodexDesktop) { + $files += [pscustomobject]@{ Source = (Join-Path $stageRoot 'desktop-catalog.json'); Target = $desktopCatalogPath; Name = $script:DesktopCatalogName } + if ($desktopOriginalExists) { + $files += [pscustomobject]@{ Source = (Join-Path $stageRoot 'desktop-original.toml'); Target = $desktopOriginalPath; Name = $script:DesktopOriginalName } + } + $files += [pscustomobject]@{ Source = (Join-Path $stageRoot 'desktop-state.json'); Target = $desktopMetadataPath; Name = $script:DesktopMetadataName } + $files += [pscustomobject]@{ Source = (Join-Path $stageRoot 'desktop-config.toml'); Target = $desktopConfigPath; Name = 'config.toml' } + } foreach ($file in $files) { $target = $file.Target + if ($EnableCodexDesktop -and $file.Name -ceq 'config.toml') { + $stillExists = Test-Path -LiteralPath $target -PathType Leaf + if ($stillExists -ne $desktopCurrentExists -or + ($stillExists -and (Get-FileHash -LiteralPath $target -Algorithm SHA256).Hash -cne $desktopCurrentHash)) { + throw 'Codex Desktop configuration changed during setup. No user edits were overwritten.' + } + } $pending = $target + '.new.' + $transactionId $backup = $target + '.backup.' + $transactionId if ((Test-Path -LiteralPath $pending) -or (Test-Path -LiteralPath $backup)) { throw 'Setup transaction path is occupied.' } @@ -266,4 +389,5 @@ Write-Host '' Write-Host 'NeuroAPI setup is complete.' Write-Host "Codex launcher: $binRoot\codex-neuroapi.cmd" Write-Host "Claude launcher: $binRoot\claude-neuroapi.cmd" +if ($EnableCodexDesktop) { Write-Host 'Codex Desktop: пользовательская настройка активирована. Перезапустите приложение.' } Write-Host 'Open a new terminal, then run codex-neuroapi or claude-neuroapi.' diff --git a/scripts/windows/uninstall.ps1 b/scripts/windows/uninstall.ps1 index 6ad81fc..08b4b7c 100644 --- a/scripts/windows/uninstall.ps1 +++ b/scripts/windows/uninstall.ps1 @@ -7,6 +7,7 @@ param( ) . "$PSScriptRoot\common.ps1" +. "$PSScriptRoot\desktop-config.ps1" if ($env:OS -ne 'Windows_NT') { throw 'This uninstaller must run on Windows.' @@ -50,6 +51,52 @@ if ((Test-Path -LiteralPath $StateRoot -PathType Container) -and throw "Refusing to remove an unowned directory: $StateRoot" } +$configRoot = Join-Path $StateRoot 'config' +$desktopState = Get-NeuroAPIDesktopState -ConfigRoot $configRoot +$desktopConfigPath = Join-Path $CodexHome 'config.toml' +$desktopOriginalPath = Join-Path $configRoot $script:DesktopOriginalName +Assert-NeuroAPIDesktopFile -Path $desktopConfigPath +if ($null -ne $desktopState) { + Assert-NeuroAPIDesktopFile -Path $desktopOriginalPath + if (-not (Test-Path -LiteralPath $desktopConfigPath -PathType Leaf) -or + (Get-FileHash -LiteralPath $desktopConfigPath -Algorithm SHA256).Hash.ToLowerInvariant() -cne $desktopState.applied_hash) { + throw 'Codex Desktop config.toml changed after setup. Uninstall stopped; the protected key and helper remain available. Restore the config manually, then retry.' + } + if ($desktopState.original_exists -and -not (Test-Path -LiteralPath $desktopOriginalPath -PathType Leaf)) { + throw 'Codex Desktop backup is missing. Uninstall stopped; the protected key remains available.' + } + if ($desktopState.original_exists -and + (Get-FileHash -LiteralPath $desktopOriginalPath -Algorithm SHA256).Hash.ToLowerInvariant() -cne $desktopState.original_hash) { + throw 'Codex Desktop backup changed. Uninstall stopped; the protected key remains available.' + } + if (-not $desktopState.original_exists -and (Test-Path -LiteralPath $desktopOriginalPath)) { + throw 'Codex Desktop backup state is inconsistent. Uninstall stopped.' + } +} elseif (Test-Path -LiteralPath $desktopConfigPath -PathType Leaf) { + $current = [IO.File]::ReadAllText($desktopConfigPath) + if ($current.Contains('[model_providers.neuroapi_agents]')) { + throw 'Codex Desktop still uses the NeuroAPI provider, but installer ownership metadata is missing. Uninstall stopped to preserve its credential helper.' + } +} + +if ($null -ne $desktopState) { + # The complete hash check deliberately refuses to overwrite later user edits. + # Keep credentials until the desktop configuration has been restored. + if ($desktopState.original_exists) { + $temporary = $desktopConfigPath + '.neuroapi-restore-' + [Guid]::NewGuid().ToString('N') + $discard = $desktopConfigPath + '.neuroapi-discard-' + [Guid]::NewGuid().ToString('N') + try { + Copy-Item -LiteralPath $desktopOriginalPath -Destination $temporary -ErrorAction Stop + [IO.File]::Replace($temporary, $desktopConfigPath, $discard) + } finally { + if (Test-Path -LiteralPath $temporary) { Remove-Item -LiteralPath $temporary -Force } + if (Test-Path -LiteralPath $discard) { Remove-Item -LiteralPath $discard -Force } + } + } else { + [IO.File]::Delete($desktopConfigPath) + } +} + $profilePath = [System.IO.Path]::Combine($CodexHome, $script:ProfileFileName) $profileMarkerPath = Get-ProfileMarkerPath -CodexHome $CodexHome if (Test-Path -LiteralPath $profilePath -PathType Leaf) { diff --git a/setup-windows.bat b/setup-windows.bat index 068a044..e914959 100644 --- a/setup-windows.bat +++ b/setup-windows.bat @@ -1,6 +1,6 @@ @echo off setlocal EnableExtensions -title NeuroAPI setup for Codex CLI and Claude Code +title NeuroAPI setup for Codex and Claude Code set "PSModulePath=" powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\windows\setup.ps1" diff --git a/tests/macos/smoke.sh b/tests/macos/smoke.sh index 7aad2f0..f24dd7a 100755 --- a/tests/macos/smoke.sh +++ b/tests/macos/smoke.sh @@ -129,8 +129,13 @@ MOCK_CLIENT_BIN="$TMP_ROOT/mock-client-bin" mkdir -p "$MOCK_CLIENT_BIN" cat >"$MOCK_CLIENT_BIN/codex" <<'EOF' #!/bin/bash -[[ "$1" == '--version' ]] || exit 1 -printf '%s\n' "${MOCK_CODEX_VERSION:-codex-cli 0.158.0}" +if [[ "$1" == '--version' ]]; then + printf '%s\n' "${MOCK_CODEX_VERSION:-codex-cli 0.158.0}" +elif [[ "$1 $2" == 'features list' ]]; then + "${NEUROAPI_AGENTS_TEST_PYTHON:-python3}" -c 'import pathlib,sys,tomllib; tomllib.loads((pathlib.Path(sys.argv[1]) / "config.toml").read_text())' "$CODEX_HOME" +else + exit 1 +fi EOF cat >"$MOCK_CLIENT_BIN/claude" <<'EOF' #!/bin/bash @@ -228,16 +233,102 @@ if grep -R -Fq 'good-rotation' "$NEUROAPI_AGENTS_STATE_ROOT" "$NEUROAPI_AGENTS_C printf 'Replacement key leaked.\n' >&2 exit 1 fi + +# Desktop opt-in uses the real user-level config, independent of the CLI +# profile. Existing TOML must survive byte-for-byte after guarded uninstall. +export NEUROAPI_AGENTS_DESKTOP_CODEX_HOME="$TMP_ROOT/desktop-codex" +export NEUROAPI_AGENTS_TEST_DESKTOP_OPT_IN=1 +mkdir -p "$NEUROAPI_AGENTS_DESKTOP_CODEX_HOME" +cat >"$NEUROAPI_AGENTS_DESKTOP_CODEX_HOME/config.toml" <<'EOF' +# personal Codex settings +model = "gpt-6-astra" + +[features] +apps = false +EOF +cp "$NEUROAPI_AGENTS_DESKTOP_CODEX_HOME/config.toml" "$TMP_ROOT/desktop-original.toml" +if PATH="$MOCK_CLIENT_BIN:$PATH" NEUROAPI_AGENTS_TEST_FAIL_COMMIT_AT=config.toml \ + /bin/bash "$REPO_ROOT/scripts/macos/install.sh" >"$TMP_ROOT/desktop.out" 2>"$TMP_ROOT/desktop.err"; then + printf 'Injected Desktop commit failure was ignored.\n' >&2 + exit 1 +fi +cmp "$TMP_ROOT/desktop-original.toml" "$NEUROAPI_AGENTS_DESKTOP_CODEX_HOME/config.toml" +[[ ! -e "$NEUROAPI_AGENTS_STATE_ROOT/config/codex-desktop-state" ]] +PATH="$MOCK_CLIENT_BIN:$PATH" /bin/bash "$REPO_ROOT/scripts/macos/install.sh" \ + >"$TMP_ROOT/desktop.out" 2>"$TMP_ROOT/desktop.err" +desktop_config="$NEUROAPI_AGENTS_DESKTOP_CODEX_HOME/config.toml" +desktop_catalog="$NEUROAPI_AGENTS_STATE_ROOT/config/codex-desktop-models.json" +grep -Fq 'model_provider = "neuroapi_agents"' "$desktop_config" +grep -Fq 'base_url = "https://codex.neuroapi.host/v1"' "$desktop_config" +grep -Fq 'supports_websockets = false' "$desktop_config" +grep -Fq 'model = "gpt-6-sol"' "$desktop_config" +[[ -f "$desktop_catalog" ]] +"$PYTHON_BIN" -c 'import json,pathlib,sys,tomllib; cfg=tomllib.loads(pathlib.Path(sys.argv[1]).read_text()); catalog=json.loads(pathlib.Path(sys.argv[2]).read_text()); assert cfg["model"]==catalog["models"][0]["slug"]' "$desktop_config" "$desktop_catalog" +if grep -R -Fq 'good-rotation' "$desktop_config" "$desktop_catalog"; then + printf 'Desktop configuration exposed the dummy token.\n' >&2 + exit 1 +fi +cp "$desktop_config" "$TMP_ROOT/desktop-installed.toml" +PATH="$MOCK_CLIENT_BIN:$PATH" /bin/bash "$REPO_ROOT/scripts/macos/install.sh" \ + >"$TMP_ROOT/desktop.out" 2>"$TMP_ROOT/desktop.err" +cmp "$TMP_ROOT/desktop-installed.toml" "$desktop_config" +printf '\n# user changed this later\n' >>"$desktop_config" +if /bin/bash "$REPO_ROOT/scripts/macos/uninstall.sh" >"$TMP_ROOT/desktop.out" 2>"$TMP_ROOT/desktop.err"; then + printf 'Uninstall deleted credentials for a modified Desktop config.\n' >&2 + exit 1 +fi +[[ -f "$NEUROAPI_AGENTS_STATE_ROOT/bin/get-neuroapi-key.sh" ]] +[[ "$("$NEUROAPI_AGENTS_STATE_ROOT/bin/get-neuroapi-key.sh")" == 'good-rotation' ]] +cp "$TMP_ROOT/desktop-installed.toml" "$desktop_config" +unset NEUROAPI_AGENTS_TEST_DESKTOP_OPT_IN unset NEUROAPI_AGENTS_TEST_PREFLIGHT NEUROAPI_AGENTS_CURL_BIN /bin/bash "$REPO_ROOT/scripts/macos/uninstall.sh" >/dev/null [[ ! -e "$NEUROAPI_AGENTS_STATE_ROOT" ]] +cmp "$TMP_ROOT/desktop-original.toml" "$desktop_config" [[ ! -e "$NEUROAPI_AGENTS_CODEX_HOME/neuroapi-host.config.toml" ]] [[ -e "$NEUROAPI_AGENTS_CODEX_HOME/user-owned.txt" ]] [[ ! -e "$NEUROAPI_AGENTS_BIN_ROOT/codex-neuroapi" ]] [[ ! -e "$NEUROAPI_AGENTS_BIN_ROOT/claude-neuroapi" ]] grep -Fq 'delete-generic-password' "$SECURITY_LOG" +# Fresh Desktop config, reserved provider name, malformed TOML, and symlink +# refusal are tested independently of the existing-config flow above. +export NEUROAPI_AGENTS_DESKTOP_CODEX_HOME="$TMP_ROOT/desktop-fresh" +export NEUROAPI_AGENTS_TEST_DESKTOP_OPT_IN=1 +export NEUROAPI_AGENTS_CURL_BIN="$MOCK_CURL" +mkdir -p "$NEUROAPI_AGENTS_DESKTOP_CODEX_HOME" +cat >"$NEUROAPI_AGENTS_DESKTOP_CODEX_HOME/config.toml" <<'EOF' +[model_providers.neuroapi_agents] +name = "user owned" +EOF +cp "$NEUROAPI_AGENTS_DESKTOP_CODEX_HOME/config.toml" "$TMP_ROOT/desktop-conflict.toml" +if PATH="$MOCK_CLIENT_BIN:$PATH" /bin/bash "$REPO_ROOT/scripts/macos/install.sh" >/dev/null 2>/dev/null; then + printf 'Setup overwrote a user-owned Desktop provider.\n' >&2 + exit 1 +fi +cmp "$TMP_ROOT/desktop-conflict.toml" "$NEUROAPI_AGENTS_DESKTOP_CODEX_HOME/config.toml" +printf 'broken = [\n' >"$NEUROAPI_AGENTS_DESKTOP_CODEX_HOME/config.toml" +if PATH="$MOCK_CLIENT_BIN:$PATH" /bin/bash "$REPO_ROOT/scripts/macos/install.sh" >/dev/null 2>/dev/null; then + printf 'Setup accepted malformed Desktop TOML.\n' >&2 + exit 1 +fi +[[ "$(<"$NEUROAPI_AGENTS_DESKTOP_CODEX_HOME/config.toml")" == 'broken = [' ]] +mv "$NEUROAPI_AGENTS_DESKTOP_CODEX_HOME/config.toml" "$TMP_ROOT/malformed-desktop.toml" +ln -s "$TMP_ROOT/malformed-desktop.toml" "$NEUROAPI_AGENTS_DESKTOP_CODEX_HOME/config.toml" +if PATH="$MOCK_CLIENT_BIN:$PATH" /bin/bash "$REPO_ROOT/scripts/macos/install.sh" >/dev/null 2>/dev/null; then + printf 'Setup accepted a symlinked Desktop config.\n' >&2 + exit 1 +fi +[[ "$(<"$TMP_ROOT/malformed-desktop.toml")" == 'broken = [' ]] +unlink "$NEUROAPI_AGENTS_DESKTOP_CODEX_HOME/config.toml" +PATH="$MOCK_CLIENT_BIN:$PATH" /bin/bash "$REPO_ROOT/scripts/macos/install.sh" >/dev/null 2>"$TMP_ROOT/install.err" +[[ -f "$NEUROAPI_AGENTS_DESKTOP_CODEX_HOME/config.toml" ]] +grep -Fq 'model = "gpt-6-sol"' "$NEUROAPI_AGENTS_DESKTOP_CODEX_HOME/config.toml" +/bin/bash "$REPO_ROOT/scripts/macos/uninstall.sh" >/dev/null +[[ ! -e "$NEUROAPI_AGENTS_DESKTOP_CODEX_HOME/config.toml" ]] +unset NEUROAPI_AGENTS_TEST_DESKTOP_OPT_IN NEUROAPI_AGENTS_CURL_BIN + printf 'pre-existing-secret\n' >"$MOCK_KEYCHAIN_STATE/host.neuroapi.agents.api-key" delete_count_before="$(grep -c 'delete-generic-password' "$SECURITY_LOG")" NEUROAPI_AGENTS_STATE_ROOT="$TMP_ROOT/no-installer-state" \ diff --git a/tests/windows/desktop-config.ps1 b/tests/windows/desktop-config.ps1 new file mode 100644 index 0000000..e0aaaa8 --- /dev/null +++ b/tests/windows/desktop-config.ps1 @@ -0,0 +1,69 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +. (Join-Path $repoRoot 'scripts/windows/common.ps1') +. (Join-Path $repoRoot 'scripts/windows/managed-catalog.ps1') +. (Join-Path $repoRoot 'scripts/windows/desktop-config.ps1') + +function Assert-Desktop { + param([bool]$Condition, [string]$Message) + if (-not $Condition) { throw $Message } +} +function Assert-DesktopFailure { + param([scriptblock]$Action) + $failed = $false + try { & $Action | Out-Null } catch { $failed = $true } + Assert-Desktop $failed 'Expected a fail-closed desktop configuration error.' +} + +$options = @{ + HelperPath = 'C:\Users\Admin\AppData\Local\NeuroAPIAgents\bin\get-neuroapi-key.ps1' + SecretPath = 'C:\Users\Admin\AppData\Local\NeuroAPIAgents\secret\api-key.dpapi' + CatalogPath = 'C:\Users\Admin\AppData\Local\NeuroAPIAgents\config\codex-desktop-models.json' + DefaultModel = 'gpt-6-sol' +} +$fresh = New-NeuroAPIDesktopConfig -Original '' @options +Assert-Desktop ($fresh -match '(?m)^model_provider = "neuroapi_agents"$') 'Fresh config has no provider.' +Assert-Desktop ($fresh -match '(?m)^model = "gpt-6-sol"$') 'Fresh config has no supported default.' +Assert-Desktop ($fresh -match '(?m)^base_url = "https://codex.neuroapi.host/v1"$') 'Desktop subdomain is wrong.' +Assert-Desktop ($fresh -match '(?m)^supports_websockets = false$') 'Unproven WebSocket path was enabled.' +Assert-Desktop ($fresh -notmatch 'sk-|experimental_bearer_token') 'A token was embedded in desktop config.' +Assert-Desktop ($fresh -match '# BEGIN NEUROAPI CODEX DESKTOP' -and $fresh -match '# END NEUROAPI CODEX DESKTOP') 'Ownership markers missing.' + +$existing = @' +# User comment +model = "old-model" # keep elsewhere +model_provider = "other" +[features] +multi_agent = true +[mcp_servers.demo] +url = "https://example.test/mcp" +'@ +$updated = New-NeuroAPIDesktopConfig -Original $existing @options +Assert-Desktop ($updated -match '(?m)^multi_agent = true$') 'User feature was changed.' +Assert-Desktop ($updated -match '(?m)^url = "https://example.test/mcp"$') 'User MCP was changed.' +Assert-Desktop (([regex]::Matches($updated, '(?m)^model_provider\s*=')).Count -eq 1) 'Provider root key was duplicated.' +Assert-Desktop (([regex]::Matches($updated, '(?m)^model\s*=')).Count -eq 1) 'Model root key was duplicated.' +Assert-Desktop ($updated -match '(?s)^.*model_catalog_json = .*\[features\]') 'Catalog root key landed inside a table.' + +Assert-DesktopFailure { New-NeuroAPIDesktopConfig -Original "[model_providers.neuroapi_agents]`nname = 'mine'" @options } +Assert-DesktopFailure { New-NeuroAPIDesktopConfig -Original '[model_providers."neuroapi_agents"]' @options } +$literal = New-NeuroAPIDesktopConfig -Original "model = 'literal'" @options +Assert-Desktop ($literal -match '(?m)^model = "gpt-6-sol"$') 'Literal TOML strings were not handled.' +Assert-DesktopFailure { New-NeuroAPIDesktopConfig -Original 'model = """multiline"""' @options } +Assert-DesktopFailure { New-NeuroAPIDesktopConfig -Original "model = `"one`"`nmodel = `"two`"" @options } +Assert-DesktopFailure { New-NeuroAPIDesktopConfig -Original '' -HelperPath $options.HelperPath -SecretPath $options.SecretPath -CatalogPath $options.CatalogPath -DefaultModel 'bad model' } + +$temp = Join-Path ([IO.Path]::GetTempPath()) ('neuroapi-desktop-' + [Guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $temp | Out-Null +try { + $statePath = Join-Path $temp 'codex-desktop-state.json' + [IO.File]::WriteAllText($statePath, ('{"version":1,"provider":"neuroapi_agents","applied_hash":"' + (Get-NeuroAPIDesktopHash -Text $fresh) + '","original_exists":false}')) + $state = Get-NeuroAPIDesktopState -ConfigRoot $temp + Assert-Desktop ($state.applied_hash -ceq (Get-NeuroAPIDesktopHash -Text $fresh)) 'Owned config digest mismatch.' + [IO.File]::WriteAllText($statePath, '{}') + Assert-DesktopFailure { Get-NeuroAPIDesktopState -ConfigRoot $temp } +} finally { + Remove-Item -LiteralPath $temp -Recurse -Force +} +Write-Host 'Codex Desktop config tests passed.' diff --git a/tests/windows/smoke.ps1 b/tests/windows/smoke.ps1 index 7da03e6..f68612d 100644 --- a/tests/windows/smoke.ps1 +++ b/tests/windows/smoke.ps1 @@ -11,6 +11,20 @@ function Assert-True { } } +function New-DesktopTestCatalog { + param([string]$Model) + return @{ models = @(@{ + slug = $Model; display_name = 'Test model'; description = 'Description'; base_instructions = 'Instructions' + supported_in_api = $true; supports_reasoning_summary_parameter = $true; support_verbosity = $false + supports_parallel_tool_calls = $true; supports_search_tool = $false; use_responses_lite = $false + priority = 0; context_window = 128000; max_context_window = 128000; auto_compact_token_limit = 100000 + effective_context_window_percent = 95; input_token_limit = 128000; output_token_limit = 16000 + supported_reasoning_levels = @(@{ effort = 'medium'; description = 'Medium' }); shell_type = 'shell_command'; visibility = 'list' + model_messages = @{ instructions_template = 'Instructions' }; truncation_policy = @{ mode = 'tokens'; limit = 10000 } + experimental_supported_tools = @(); input_modalities = @('text') + }); default_model = $Model } | ConvertTo-Json -Depth 8 -Compress +} + $repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) $tempRoot = [System.IO.Path]::Combine( [System.IO.Path]::GetTempPath(), @@ -156,6 +170,59 @@ try { Assert-True ($rotatedKey -ceq 'rotated-test-token') 'Successful retry did not install the replacement key.' $env:NEUROAPI_AGENTS_TEST_TOKEN = $null + $desktopCatalog = New-DesktopTestCatalog 'gpt-6-sol' + & "$repoRoot\scripts\windows\setup.ps1" ` + -TestMode -StateRoot $stateRoot -CodexHome $codexHome -NoPathUpdate ` + -EnableCodexDesktop -DesktopCatalogJson $desktopCatalog | Out-Null + $desktopConfigPath = Join-Path $codexHome 'config.toml' + $desktopStatePath = Join-Path $stateRoot 'config/codex-desktop-state.json' + $desktopCatalogPath = Join-Path $stateRoot 'config/codex-desktop-models.json' + Assert-True (Test-Path -LiteralPath $desktopConfigPath) 'Desktop config was not created.' + Assert-True (Test-Path -LiteralPath $desktopStatePath) 'Desktop ownership metadata is missing.' + Assert-True (Test-Path -LiteralPath $desktopCatalogPath) 'Desktop model catalog is missing.' + $desktopBefore = [IO.File]::ReadAllText($desktopConfigPath) + Assert-True ($desktopBefore -match '(?m)^model = "gpt-6-sol"$') 'Entitled default model was not selected.' + Assert-True ($desktopBefore -match 'https://codex.neuroapi.host/v1') 'Desktop URL is incorrect.' + Assert-True ($desktopBefore -notmatch 'test-neuroapi-token') 'Desktop config contains a plaintext key.' + & "$repoRoot\scripts\windows\setup.ps1" ` + -TestMode -StateRoot $stateRoot -CodexHome $codexHome -NoPathUpdate ` + -EnableCodexDesktop -DesktopCatalogJson $desktopCatalog | Out-Null + Assert-True ([IO.File]::ReadAllText($desktopConfigPath) -ceq $desktopBefore) 'Desktop rerun changed a stable config.' + + $desktopKeyBefore = (Get-FileHash -LiteralPath $secretPath -Algorithm SHA256).Hash + $emptyCatalogRefused = $false + try { + & "$repoRoot\scripts\windows\setup.ps1" ` + -TestMode -StateRoot $stateRoot -CodexHome $codexHome -NoPathUpdate ` + -EnableCodexDesktop -DesktopCatalogJson '{}' | Out-Null + } catch { $emptyCatalogRefused = $true } + Assert-True $emptyCatalogRefused 'Desktop setup accepted a key with no usable model catalog.' + Assert-True ([IO.File]::ReadAllText($desktopConfigPath) -ceq $desktopBefore) 'Invalid catalog changed Desktop config.' + Assert-True ((Get-FileHash -LiteralPath $secretPath -Algorithm SHA256).Hash -ceq $desktopKeyBefore) 'Invalid catalog changed protected key.' + $env:NEUROAPI_AGENTS_TEST_TOKEN = 'attempted-rotation' + $env:NEUROAPI_AGENTS_TEST_FAIL_AFTER = 'config.toml' + $desktopFailed = $false + try { + & "$repoRoot\scripts\windows\setup.ps1" ` + -TestMode -StateRoot $stateRoot -CodexHome $codexHome -NoPathUpdate ` + -EnableCodexDesktop -DesktopCatalogJson $desktopCatalog | Out-Null + } catch { $desktopFailed = $_.Exception.Message -match 'Simulated setup transaction failure' } + Assert-True $desktopFailed 'Desktop rollback injection did not fail.' + Assert-True ([IO.File]::ReadAllText($desktopConfigPath) -ceq $desktopBefore) 'Desktop rollback changed config.' + Assert-True ((Get-FileHash -LiteralPath $secretPath -Algorithm SHA256).Hash -ceq $desktopKeyBefore) 'Desktop rollback changed protected key.' + $env:NEUROAPI_AGENTS_TEST_FAIL_AFTER = $null + $env:NEUROAPI_AGENTS_TEST_TOKEN = $null + + [IO.File]::AppendAllText($desktopConfigPath, "`n# user edit`n") + $desktopUninstallRefused = $false + try { + & "$repoRoot\scripts\windows\uninstall.ps1" ` + -TestMode -StateRoot $stateRoot -CodexHome $codexHome -NoPathUpdate | Out-Null + } catch { $desktopUninstallRefused = $_.Exception.Message -match 'changed after setup' } + Assert-True $desktopUninstallRefused 'Uninstall overwrote a later user edit.' + Assert-True (Test-Path -LiteralPath $secretPath) 'Uninstall removed the key while Desktop still depends on it.' + [IO.File]::WriteAllText($desktopConfigPath, $desktopBefore, (New-Object Text.UTF8Encoding($false))) + & "$repoRoot\scripts\windows\uninstall.ps1" ` -TestMode ` -StateRoot $stateRoot ` @@ -166,6 +233,31 @@ try { Assert-True (-not (Test-Path -LiteralPath $profilePath)) 'Codex profile remains after uninstall.' Assert-True (Test-Path -LiteralPath $sentinel) 'Uninstall removed an unrelated file.' + $originalDesktop = "# existing user setup`nmodel = `"old-model`"`n[features]`nmulti_agent = true`n" + [IO.File]::WriteAllText($desktopConfigPath, $originalDesktop, (New-Object Text.UTF8Encoding($false))) + $fallbackCatalog = New-DesktopTestCatalog 'model-one' + & "$repoRoot\scripts\windows\setup.ps1" ` + -TestMode -StateRoot $stateRoot -CodexHome $codexHome -NoPathUpdate ` + -EnableCodexDesktop -DesktopCatalogJson $fallbackCatalog | Out-Null + $existingUpdated = [IO.File]::ReadAllText($desktopConfigPath) + Assert-True ($existingUpdated -match '(?m)^model = "model-one"$') 'An unavailable gpt-6-sol was selected.' + Assert-True ($existingUpdated -match '(?m)^multi_agent = true$') 'User feature was overwritten.' + & "$repoRoot\scripts\windows\uninstall.ps1" ` + -TestMode -StateRoot $stateRoot -CodexHome $codexHome -NoPathUpdate | Out-Null + Assert-True ([IO.File]::ReadAllText($desktopConfigPath) -ceq $originalDesktop) 'Uninstall did not restore the user config.' + + $conflict = "[model_providers.neuroapi_agents]`nname = `"Mine`"`n" + [IO.File]::WriteAllText($desktopConfigPath, $conflict, (New-Object Text.UTF8Encoding($false))) + $conflictRefused = $false + try { + & "$repoRoot\scripts\windows\setup.ps1" ` + -TestMode -StateRoot $stateRoot -CodexHome $codexHome -NoPathUpdate ` + -EnableCodexDesktop -DesktopCatalogJson $fallbackCatalog | Out-Null + } catch { $conflictRefused = $true } + Assert-True $conflictRefused 'Installer overwrote a user-owned provider.' + Assert-True ([IO.File]::ReadAllText($desktopConfigPath) -ceq $conflict) 'Provider conflict changed user config.' + Remove-Item -LiteralPath $desktopConfigPath -Force + $unownedState = [System.IO.Path]::Combine($tempRoot, 'unowned-state') New-Item -ItemType Directory -Path $unownedState -Force | Out-Null Set-Content -LiteralPath ([System.IO.Path]::Combine($unownedState, 'keep.txt')) -Value 'keep'