From b45d44963bb9b5fd7c9e4d021eafa07283f087d0 Mon Sep 17 00:00:00 2001 From: Roman Date: Tue, 29 Sep 2026 03:24:27 +0800 Subject: [PATCH 01/11] fix(codex): disable unsupported hosted tools in client profile --- AGENTS.md | 1 + README.en.md | 2 +- README.md | 2 +- docs/manual-setup.md | 9 +++++++++ docs/troubleshooting.md | 1 + scripts/macos/install.sh | 8 ++++++++ scripts/windows/setup.ps1 | 8 ++++++++ tests/static/profile_contract.py | 7 +++++++ tests/static/validate.ps1 | 4 ++++ 9 files changed, 40 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f21c752..3fe8b7e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,4 +8,5 @@ - Validate script syntax, generated TOML/JSON, idempotency, secret handling, and uninstall boundaries before publishing. - Codex uses the user-level profile-v2 file and `/v1/codex` with WebSocket enabled. Publish installers only after authenticated server catalog and HTTP/WebSocket release checks; HTTP fallback keeps the same profile URL. +- Keep unsupported hosted Codex tools (`web_search`, multi-agent namespace, goals, apps and browser use) disabled in this profile until the server can execute and bill them safely; local file and shell tools must remain available. - Managed launchers fetch and validate fresh key-scoped catalogs into private per-launch snapshots; never fall back to stale/bundled lists or accept executable server settings. Claude's explicit base URL is `/v1/claude-code`. Keep ordinary keys, preserve unrelated configuration, and document managed-policy/explicit-override boundaries. diff --git a/README.en.md b/README.en.md index f02a9e7..4f086a0 100644 --- a/README.en.md +++ b/README.en.md @@ -6,7 +6,7 @@ Public, auditable one-click setup for routing local Codex CLI and Claude Code se ## Release compatibility -This version configures `https://neuroapi.host/v1/codex` with `supports_websockets = true`, and `https://neuroapi.host/v1/claude-code` for Claude Code. Publish or distribute it **only after the server profiles are deployed** and authenticated `/v1/codex/models`, HTTP/WebSocket `/v1/codex/responses`, `/v1/claude-code/client-settings`, and Claude Messages/count_tokens checks pass. Local implementation is not production evidence. Setup deliberately does not call the API to validate credentials; launchers fetch the current catalog before starting a client. +This version configures `https://neuroapi.host/v1/codex` with `supports_websockets = true`, and `https://neuroapi.host/v1/claude-code` for Claude Code. The Codex profile disables hosted web search, multi-agent, goals, apps, and browser use because the current client includes these tools even in simple local tasks, while NeuroAPI does not guarantee their upstream execution. Local shell and file tools remain available. Publish or distribute it **only after the server profiles are deployed** and authenticated `/v1/codex/models`, HTTP/WebSocket `/v1/codex/responses`, `/v1/claude-code/client-settings`, and Claude Messages/count_tokens checks pass. Local implementation is not production evidence. Setup deliberately does not call the API to validate credentials; launchers fetch the current catalog before starting a client. An existing `CODEX_HOME` selects the profile directory without being modified. Keep its value consistent for setup, launch and uninstall. diff --git a/README.md b/README.md index 85b8958..f55eda7 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ NeuroAPI — российский AI API-сервис: единый доступ ## Совместимость версии -Эта версия создаёт профиль Codex с `https://neuroapi.host/v1/codex` и `supports_websockets = true`, а профиль Claude Code — с `https://neuroapi.host/v1/claude-code`. Выпускайте и распространяйте установщик **только после публикации серверных профилей**: должны пройти авторизованные проверки `GET /v1/codex/models`, HTTP/WebSocket `/v1/codex/responses`, `GET /v1/claude-code/client-settings` и Claude Messages/count_tokens. Подготовленный код не доказывает доступность этих адресов в production; setup намеренно не вызывает API для проверки ключа. +Эта версия создаёт профиль Codex с `https://neuroapi.host/v1/codex` и `supports_websockets = true`, а профиль Claude Code — с `https://neuroapi.host/v1/claude-code`. Codex-профиль отключает hosted web search, multi-agent, goals, apps и browser use: эти инструменты новейший клиент отправляет даже в простых задачах, а NeuroAPI пока не гарантирует их провайдерское исполнение. Локальные команды, чтение и редактирование файлов работают. Выпускайте и распространяйте установщик **только после публикации серверных профилей**: должны пройти авторизованные проверки `GET /v1/codex/models`, HTTP/WebSocket `/v1/codex/responses`, `GET /v1/claude-code/client-settings` и Claude Messages/count_tokens. Подготовленный код не доказывает доступность этих адресов в production; setup намеренно не вызывает API для проверки ключа. Уже заданный `CODEX_HOME` учитывается для профиля и не изменяется; сохраняйте одинаковое значение при установке, запуске и удалении. diff --git a/docs/manual-setup.md b/docs/manual-setup.md index c1060bb..797e7af 100644 --- a/docs/manual-setup.md +++ b/docs/manual-setup.md @@ -15,6 +15,13 @@ ```toml model_provider = "neuroapi" +web_search = "disabled" + +[features] +multi_agent = false +goals = false +apps = false +browser_use = false [model_providers.neuroapi] name = "NeuroAPI" @@ -30,6 +37,8 @@ refresh_interval_ms = 300000 На Windows `command` — `powershell.exe`, а helper и DPAPI secret передаются отдельными элементами `args`. +Codex 0.158.0 по умолчанию добавляет к каждому запросу hosted `web_search` и namespace-инструмент для multi-agent, даже при локальном чтении файла. NeuroAPI не объявляет эти инструменты как поддерживаемые для Codex-профиля: они требуют отдельного провайдерского контракта и тарификации. Профиль отключает только эти возможности, а чтение, правка и запуск команд остаются доступны. Возвращать их вручную в профиле можно лишь после отдельной проверки поддержки сервером. + Запуск: `codex-neuroapi`. Перед каждым запуском launcher получает `/v1/codex/models` с обычным ключом NeuroAPI, проверяет ответ и передаёт приватный файл через `model_catalog_json` вместе с доступной моделью по умолчанию. Файл удаляется после завершения клиента. Проверка: `/debug-config` и `/model`. Прямой `codex --profile neuroapi-host` пропускает этот механизм: command-auth discovery может подмешать встроенные модели. При ручной настройке без launcher можно задать собственный проверенный `model_catalog_json`; поддерживать его актуальность тогда нужно самостоятельно. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index ef2cedf..afbee32 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -15,6 +15,7 @@ macOS: используйте полный путь `~/.local/bin/codex-neuroapi - base URL `https://neuroapi.host/v1/codex`; - `wire_api = "responses"`; - `supports_websockets = true` (или `false` для диагностики HTTP/SSE). +- `web_search = "disabled"` и `[features]` с `multi_agent = false`, `goals = false`, `apps = false`, `browser_use = false`: без них Codex 0.158.0 может включить неподдерживаемые hosted/namespace-инструменты в обычный запрос к файлу и получить `effective_request_unsupported`. Если `~/.codex/neuroapi-host.config.toml` существовал до установки без ownership-marker, setup должен отказать, а не перезаписать его. diff --git a/scripts/macos/install.sh b/scripts/macos/install.sh index 39bb1dc..7e3b92c 100755 --- a/scripts/macos/install.sh +++ b/scripts/macos/install.sh @@ -66,6 +66,14 @@ ESCAPED_HELPER_PATH="$(toml_escape "$HELPER_PATH")" cat >"$PROFILE_PATH" < Date: Tue, 29 Sep 2026 06:44:18 +0800 Subject: [PATCH 02/11] feat(agents): install and verify current Codex and Claude clients --- README.en.md | 8 +- README.md | 10 +- scripts/macos/catalog-validator.js | 3 + scripts/macos/common.sh | 44 +++++ scripts/macos/get-neuroapi-key.sh | 9 + scripts/macos/install.sh | 227 ++++++++++++++++++---- scripts/macos/launch-managed.sh | 31 +-- scripts/macos/uninstall.sh | 11 +- scripts/windows/common.ps1 | 291 ++++++++++++++++++++++++++++ scripts/windows/managed-catalog.ps1 | 16 +- scripts/windows/setup.ps1 | 31 ++- scripts/windows/uninstall.ps1 | 8 +- tests/macos/catalog_smoke.py | 17 +- tests/macos/smoke.sh | 135 ++++++++++++- tests/windows/first-run.ps1 | 161 +++++++++++++++ tests/windows/managed-catalog.ps1 | 13 +- 16 files changed, 931 insertions(+), 84 deletions(-) create mode 100644 tests/windows/first-run.ps1 diff --git a/README.en.md b/README.en.md index 4f086a0..b1776f4 100644 --- a/README.en.md +++ b/README.en.md @@ -6,7 +6,7 @@ Public, auditable one-click setup for routing local Codex CLI and Claude Code se ## Release compatibility -This version configures `https://neuroapi.host/v1/codex` with `supports_websockets = true`, and `https://neuroapi.host/v1/claude-code` for Claude Code. The Codex profile disables hosted web search, multi-agent, goals, apps, and browser use because the current client includes these tools even in simple local tasks, while NeuroAPI does not guarantee their upstream execution. Local shell and file tools remain available. Publish or distribute it **only after the server profiles are deployed** and authenticated `/v1/codex/models`, HTTP/WebSocket `/v1/codex/responses`, `/v1/claude-code/client-settings`, and Claude Messages/count_tokens checks pass. Local implementation is not production evidence. Setup deliberately does not call the API to validate credentials; launchers fetch the current catalog before starting a client. +This version configures `https://neuroapi.host/v1/codex` with `supports_websockets = true`, and `https://neuroapi.host/v1/claude-code` for Claude Code. The Codex profile disables hosted web search, multi-agent, goals, apps, and browser use because the current client includes these tools even in simple local tasks, while NeuroAPI does not guarantee their upstream execution. Local shell and file tools remain available. Setup checks both key-scoped catalogs without a paid generation. Verify HTTP/WebSocket Responses and Claude Messages/count_tokens after each server release. An existing `CODEX_HOME` selects the profile directory without being modified. Keep its value consistent for setup, launch and uninstall. @@ -14,7 +14,7 @@ Use a current Codex release whose `--help` describes `--profile` as loading `/dev/null || true)" + native="$HOME/.local/bin/$client" + if (( $# == 3 )); then + if [[ -n "$resolved" && -x "$resolved" ]] && client_version_at_least "$resolved" "$@"; then + printf '%s\n' "$resolved" + return 0 + fi + if [[ -x "$native" ]] && client_version_at_least "$native" "$@"; then + printf '%s\n' "$native" + return 0 + fi + fi + if [[ -n "$resolved" && -x "$resolved" ]]; then + printf '%s\n' "$resolved" + return 0 + fi + # Native Codex and Claude installers use ~/.local/bin; GUI-launched + # terminals do not always inherit that directory in PATH immediately. + if [[ -x "$native" ]]; then + printf '%s\n' "$native" + return 0 + fi + return 1 +} + +client_version_at_least() { + local binary="$1" minimum_major="$2" minimum_minor="$3" minimum_patch="$4" + local version major minor patch suffix + version="$("$binary" --version 2>/dev/null)" || return 1 + [[ "$version" =~ (^|[[:space:]])([0-9]+)\.([0-9]+)\.([0-9]+)([^[:space:]]*) ]] || return 1 + suffix="${BASH_REMATCH[5]}" + [[ -z "$suffix" ]] || return 1 + major=$((10#${BASH_REMATCH[2]})) + minor=$((10#${BASH_REMATCH[3]})) + patch=$((10#${BASH_REMATCH[4]})) + (( major > minimum_major || + (major == minimum_major && minor > minimum_minor) || + (major == minimum_major && minor == minimum_minor && patch >= minimum_patch) )) +} + security_bin() { if is_test_mode && [[ -n "${NEUROAPI_AGENTS_SECURITY_BIN:-}" ]]; then printf '%s\n' "$NEUROAPI_AGENTS_SECURITY_BIN" diff --git a/scripts/macos/get-neuroapi-key.sh b/scripts/macos/get-neuroapi-key.sh index 067c248..3b5559d 100755 --- a/scripts/macos/get-neuroapi-key.sh +++ b/scripts/macos/get-neuroapi-key.sh @@ -2,6 +2,15 @@ set -euo pipefail KEYCHAIN_SERVICE='host.neuroapi.agents.api-key' +SCRIPT_DIR="$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)" +SERVICE_POINTER="$SCRIPT_DIR/../config/keychain-service" +if [[ -e "$SERVICE_POINTER" ]]; then + IFS= read -r KEYCHAIN_SERVICE <"$SERVICE_POINTER" + if [[ ! "$KEYCHAIN_SERVICE" =~ ^host\.neuroapi\.agents\.api-key\.[a-f0-9]{32}$ ]]; then + printf 'Invalid NeuroAPI Keychain service pointer.\n' >&2 + exit 1 + fi +fi if [[ "${NEUROAPI_AGENTS_TEST_MODE:-0}" == '1' ]] && [[ -n "${NEUROAPI_AGENTS_SECURITY_BIN:-}" ]]; then diff --git a/scripts/macos/install.sh b/scripts/macos/install.sh index 7e3b92c..bb3f1d5 100755 --- a/scripts/macos/install.sh +++ b/scripts/macos/install.sh @@ -1,5 +1,7 @@ #!/bin/bash +set +x set -euo pipefail +umask 077 SCRIPT_DIR="$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)" # shellcheck source=scripts/macos/common.sh @@ -23,6 +25,15 @@ SECURITY_BIN="$(security_bin)" CURRENT_USER="$(current_user)" HELPER_PATH="$STATE_ROOT/bin/get-neuroapi-key.sh" CLAUDE_SETTINGS_PATH="$STATE_ROOT/config/claude-settings.json" +SERVICE_POINTER="$STATE_ROOT/config/keychain-service" +PREVIOUS_SERVICE="$KEYCHAIN_SERVICE" +if [[ -e "$SERVICE_POINTER" ]]; then + IFS= read -r PREVIOUS_SERVICE <"$SERVICE_POINTER" + if [[ ! "$PREVIOUS_SERVICE" =~ ^host\.neuroapi\.agents\.api-key\.[a-f0-9]{32}$ ]]; then + printf 'Invalid existing NeuroAPI Keychain service pointer.\n' >&2 + exit 1 + fi +fi if [[ -e "$PROFILE_PATH" ]] && ! marker_is_owned "$PROFILE_MARKER_PATH"; then printf 'Refusing to overwrite an unowned Codex profile: %s\n' "$PROFILE_PATH" >&2 @@ -31,15 +42,103 @@ fi if "$SECURITY_BIN" find-generic-password \ -a "$CURRENT_USER" \ - -s "$KEYCHAIN_SERVICE" >/dev/null 2>&1 && + -s "$PREVIOUS_SERVICE" >/dev/null 2>&1 && ! marker_is_owned "$(keychain_marker_path)"; then printf 'Refusing to overwrite an unowned Keychain item for service %s.\n' \ - "$KEYCHAIN_SERVICE" >&2 + "$PREVIOUS_SERVICE" >&2 exit 1 fi -mkdir -p "$STATE_ROOT/bin" "$STATE_ROOT/config" "$PROFILE_CONFIG_ROOT" "$LAUNCHER_ROOT" -write_marker "$(state_marker_path)" +install_native_client() { + local client="$1" url interpreter installer + case "$client" in + codex) url='https://chatgpt.com/codex/install.sh'; interpreter='/bin/sh' ;; + claude) url='https://claude.ai/install.sh'; interpreter='/bin/bash' ;; + *) return 1 ;; + esac + installer="$(mktemp "${TMPDIR:-/tmp}/neuroapi-${client}-install.XXXXXX")" + if ! /usr/bin/curl --disable --fail --silent --show-error --location \ + --proto '=https' --proto-redir '=https' --max-redirs 3 \ + --connect-timeout 10 --max-time 60 --max-filesize 4194304 \ + --output "$installer" "$url"; then + rm -f -- "$installer" + printf 'Не удалось загрузить официальный установщик %s.\n' "$client" >&2 + return 1 + fi + if ! "$interpreter" "$installer"; then + rm -f -- "$installer" + printf 'Установка %s завершилась с ошибкой.\n' "$client" >&2 + return 1 + fi + rm -f -- "$installer" +} + +if ! is_test_mode; then + for client in codex claude; do + case "$client" in + codex) minimum=(0 158 0) ;; + claude) minimum=(2 1 284) ;; + esac + client_bin="$(resolve_client_bin "$client" "${minimum[@]}" || true)" + if [[ -n "$client_bin" ]] && client_version_at_least "$client_bin" "${minimum[@]}"; then + continue + fi + printf 'Устанавливаю актуальный %s из официального источника.\n' "$client" + install_native_client "$client" + client_bin="$(resolve_client_bin "$client" "${minimum[@]}" || true)" + if [[ -z "$client_bin" ]] || ! client_version_at_least "$client_bin" "${minimum[@]}"; then + printf 'Не удалось найти совместимый %s после установки. Проверьте PATH и повторите настройку.\n' "$client" >&2 + exit 1 + fi + done +fi + +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 +STATE_ROOT_WAS_PRESENT=0 +[[ -d "$STATE_ROOT" ]] && STATE_ROOT_WAS_PRESENT=1 +PROMOTE_TARGETS=() +PROMOTE_BACKUPS=() +cleanup_stage() { + local status=$? i + trap - EXIT + set +e + if [[ "$status" -ne 0 && "$COMMIT_STARTED" == '1' ]]; then + for ((i=${#PROMOTE_TARGETS[@]}-1; i>=0; i--)); do + rm -f -- "${PROMOTE_TARGETS[i]}.new.$$" + if [[ "${PROMOTE_BACKUPS[i]}" == 'present' ]]; then + cp -p -- "$STAGE_ROOT/rollback/$i" "${PROMOTE_TARGETS[i]}" + else + rm -f -- "${PROMOTE_TARGETS[i]}" + fi + done + if [[ "$STATE_ROOT_WAS_PRESENT" == '0' ]]; then + rm -rf -- "$STATE_ROOT" + elif ! marker_is_owned "$(state_marker_path)"; then + rmdir -- "$STATE_ROOT/bin" "$STATE_ROOT/config" 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 + fi + rm -rf -- "$STAGE_ROOT" + exit "$status" +} +trap cleanup_stage EXIT +mkdir -p "$STAGE_ROOT/bin" "$STAGE_ROOT/config" +cp "$SCRIPT_DIR/get-neuroapi-key.sh" "$SCRIPT_DIR/launch-managed.sh" \ + "$SCRIPT_DIR/common.sh" "$SCRIPT_DIR/catalog-validator.js" "$STAGE_ROOT/bin/" +printf '%s\n' "$CANDIDATE_SERVICE" >"$STAGE_ROOT/config/keychain-service" +chmod 700 "$STAGE_ROOT/bin/get-neuroapi-key.sh" "$STAGE_ROOT/bin/launch-managed.sh" +chmod 600 "$STAGE_ROOT/bin/common.sh" "$STAGE_ROOT/bin/catalog-validator.js" \ + "$STAGE_ROOT/config/keychain-service" + +if "$SECURITY_BIN" find-generic-password -a "$CURRENT_USER" -s "$CANDIDATE_SERVICE" >/dev/null 2>&1; then + printf 'Keychain service collision; repeat setup.\n' >&2 + exit 1 +fi if ! is_test_mode; then printf '\nNeuroAPI will ask macOS Keychain to store the API key for user %s.\n' \ @@ -47,23 +146,26 @@ if ! is_test_mode; then printf 'Paste the key into the Keychain password prompt that follows.\n\n' fi "$SECURITY_BIN" add-generic-password \ - -U \ -a "$CURRENT_USER" \ - -s "$KEYCHAIN_SERVICE" \ + -s "$CANDIDATE_SERVICE" \ -l 'NeuroAPI API key for local agents' \ -j 'Used by the auditable NeuroAPI Codex CLI and Claude Code helpers' \ -w -write_marker "$(keychain_marker_path)" +CANDIDATE_OWNED=1 -cp "$SCRIPT_DIR/get-neuroapi-key.sh" "$HELPER_PATH" -chmod 700 "$HELPER_PATH" -cp "$SCRIPT_DIR/launch-managed.sh" "$STATE_ROOT/bin/launch-managed.sh" -cp "$SCRIPT_DIR/catalog-validator.js" "$STATE_ROOT/bin/catalog-validator.js" -chmod 700 "$STATE_ROOT/bin/launch-managed.sh" -chmod 600 "$STATE_ROOT/bin/catalog-validator.js" +# Verify the candidate through the exact launcher logic before changing any +# working profile, helper, launcher, or previously stored Keychain item. +if ! is_test_mode || [[ "${NEUROAPI_AGENTS_TEST_PREFLIGHT:-0}" == '1' ]]; then + for client in codex claude; do + if ! "$STAGE_ROOT/bin/launch-managed.sh" "$client" --verify; then + printf 'Проверка каталога %s не прошла. Существующие настройки и ключ сохранены.\n' "$client" >&2 + exit 1 + fi + done +fi ESCAPED_HELPER_PATH="$(toml_escape "$HELPER_PATH")" -cat >"$PROFILE_PATH" <"$STAGE_ROOT/profile.toml" <"$LAUNCHER_ROOT/$client-neuroapi" + } >"$STAGE_ROOT/launchers/$client-neuroapi" +done +chmod 700 "$STAGE_ROOT/launchers/codex-neuroapi" "$STAGE_ROOT/launchers/claude-neuroapi" +write_marker "$STAGE_ROOT/state-marker" +write_marker "$STAGE_ROOT/profile-marker" +write_marker "$STAGE_ROOT/keychain-marker" +write_marker "$STAGE_ROOT/codex-launcher-marker" +write_marker "$STAGE_ROOT/claude-launcher-marker" + +PROMOTE_TARGETS=( + "$HELPER_PATH" + "$STATE_ROOT/bin/launch-managed.sh" + "$STATE_ROOT/bin/common.sh" + "$STATE_ROOT/bin/catalog-validator.js" + "$PROFILE_PATH" + "$PROFILE_MARKER_PATH" + "$CLAUDE_SETTINGS_PATH" + "$LAUNCHER_ROOT/codex-neuroapi" + "$LAUNCHER_ROOT/claude-neuroapi" + "$(launcher_marker_path 'codex-neuroapi')" + "$(launcher_marker_path 'claude-neuroapi')" + "$(state_marker_path)" + "$(keychain_marker_path)" + "$SERVICE_POINTER" +) +PROMOTE_SOURCES=( + "$STAGE_ROOT/bin/get-neuroapi-key.sh" + "$STAGE_ROOT/bin/launch-managed.sh" + "$STAGE_ROOT/bin/common.sh" + "$STAGE_ROOT/bin/catalog-validator.js" + "$STAGE_ROOT/profile.toml" + "$STAGE_ROOT/profile-marker" + "$STAGED_CLAUDE_SETTINGS" + "$STAGE_ROOT/launchers/codex-neuroapi" + "$STAGE_ROOT/launchers/claude-neuroapi" + "$STAGE_ROOT/codex-launcher-marker" + "$STAGE_ROOT/claude-launcher-marker" + "$STAGE_ROOT/state-marker" + "$STAGE_ROOT/keychain-marker" + "$STAGE_ROOT/config/keychain-service" +) +for ((i=0; i<${#PROMOTE_TARGETS[@]}; i++)); do + if [[ -L "${PROMOTE_TARGETS[i]}" ]]; then + printf 'Refusing to replace a symlink: %s\n' "${PROMOTE_TARGETS[i]}" >&2 + exit 1 + fi + if [[ -e "${PROMOTE_TARGETS[i]}" ]]; then + cp -p -- "${PROMOTE_TARGETS[i]}" "$STAGE_ROOT/rollback/$i" + PROMOTE_BACKUPS+=(present) + else + PROMOTE_BACKUPS+=(missing) + fi done -chmod 700 "$LAUNCHER_ROOT/codex-neuroapi" "$LAUNCHER_ROOT/claude-neuroapi" -write_marker "$(launcher_marker_path 'codex-neuroapi')" -write_marker "$(launcher_marker_path 'claude-neuroapi')" +COMMIT_STARTED=1 +mkdir -p "$STATE_ROOT/bin" "$STATE_ROOT/config" "$PROFILE_CONFIG_ROOT" "$LAUNCHER_ROOT" +for ((i=0; i<${#PROMOTE_TARGETS[@]}; i++)); do + target="${PROMOTE_TARGETS[i]}" + 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" +done +CANDIDATE_OWNED=0 +if [[ "$PREVIOUS_SERVICE" != "$CANDIDATE_SERVICE" ]] && + "$SECURITY_BIN" find-generic-password -a "$CURRENT_USER" -s "$PREVIOUS_SERVICE" >/dev/null 2>&1; then + "$SECURITY_BIN" delete-generic-password -a "$CURRENT_USER" -s "$PREVIOUS_SERVICE" >/dev/null 2>&1 || true +fi printf '\nNeuroAPI setup is complete.\n' printf 'Codex launcher: %s/codex-neuroapi\n' "$LAUNCHER_ROOT" @@ -118,9 +285,3 @@ printf 'Claude launcher: %s/claude-neuroapi\n' "$LAUNCHER_ROOT" if [[ ":$PATH:" != *":$LAUNCHER_ROOT:"* ]]; then printf 'Your PATH does not include %s. Run the launchers by full path or add that directory yourself.\n' "$LAUNCHER_ROOT" fi -if ! command -v codex >/dev/null 2>&1; then - printf 'Warning: Codex CLI is not installed or is not on PATH.\n' >&2 -fi -if ! command -v claude >/dev/null 2>&1; then - printf 'Warning: Claude Code is not installed or is not on PATH.\n' >&2 -fi diff --git a/scripts/macos/launch-managed.sh b/scripts/macos/launch-managed.sh index 3f37b0f..ffe0341 100755 --- a/scripts/macos/launch-managed.sh +++ b/scripts/macos/launch-managed.sh @@ -5,14 +5,22 @@ set -euo pipefail umask 077 SCRIPT_DIR="$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)" +# shellcheck source=scripts/macos/common.sh +. "$SCRIPT_DIR/common.sh" CLIENT="${1:-}" shift || true case "$CLIENT" in - codex) MIN_MAJOR=0; MIN_MINOR=147; MIN_PATCH=0; ENDPOINT='https://neuroapi.host/v1/codex/models' ;; - claude) MIN_MAJOR=2; MIN_MINOR=1; MIN_PATCH=280; ENDPOINT='https://neuroapi.host/v1/claude-code/client-settings' ;; + codex) MIN_MAJOR=0; MIN_MINOR=158; MIN_PATCH=0; ENDPOINT='https://neuroapi.host/v1/codex/models' ;; + claude) MIN_MAJOR=2; MIN_MINOR=1; MIN_PATCH=284; ENDPOINT='https://neuroapi.host/v1/claude-code/client-settings' ;; *) printf 'Неизвестный клиент NeuroAPI.\n' >&2; exit 1 ;; esac +VERIFY_ONLY=0 +if [[ "${1:-}" == '--verify' ]]; then + VERIFY_ONLY=1 + shift +fi + fail() { printf '%s\n' "$1" >&2; exit 1; } if [[ "$CLIENT" == 'claude' ]]; then HOST_MANAGED="${CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST:-}" @@ -23,16 +31,10 @@ if [[ "$CLIENT" == 'claude' ]]; then fail 'Провайдер Claude Code управляется приложением-хостом. Запустите NeuroAPI из самостоятельного терминала.' ;; esac fi -if ! VERSION_TEXT="$("$CLIENT" --version 2>/dev/null)" || - [[ ! "$VERSION_TEXT" =~ ([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then +if ! CLIENT_BIN="$(resolve_client_bin "$CLIENT" "$MIN_MAJOR" "$MIN_MINOR" "$MIN_PATCH")"; then fail "Не удалось проверить версию $CLIENT. Обновите клиент и повторите запуск." fi -CLIENT_MAJOR=$((10#${BASH_REMATCH[1]})) -CLIENT_MINOR=$((10#${BASH_REMATCH[2]})) -CLIENT_PATCH=$((10#${BASH_REMATCH[3]})) -if (( CLIENT_MAJOR < MIN_MAJOR || - (CLIENT_MAJOR == MIN_MAJOR && CLIENT_MINOR < MIN_MINOR) || - (CLIENT_MAJOR == MIN_MAJOR && CLIENT_MINOR == MIN_MINOR && CLIENT_PATCH < MIN_PATCH) )); then +if ! client_version_at_least "$CLIENT_BIN" "$MIN_MAJOR" "$MIN_MINOR" "$MIN_PATCH"; then fail "Требуется $CLIENT версии $MIN_MAJOR.$MIN_MINOR.$MIN_PATCH или новее." fi @@ -87,6 +89,11 @@ if ! printf 'header = "Authorization: Bearer %s"\n' "$credential" | fi unset credential +if (( VERIFY_ONLY )); then + printf 'Каталог %s проверен.\n' "$CLIENT" + exit 0 +fi + if [[ "$CLIENT" == 'codex' ]]; then IFS= read -r DEFAULT_MODEL <"$SNAPSHOT/model.txt" CATALOG_PATH="$SNAPSHOT/models.json" @@ -94,7 +101,7 @@ if [[ "$CLIENT" == 'codex' ]]; then CATALOG_PATH="${CATALOG_PATH//\"/\\\"}" # User arguments follow generated defaults intentionally, so explicit --model # and ordinary CLI overrides retain their documented meaning. - launch_child codex --profile neuroapi-host -c "model_catalog_json=\"$CATALOG_PATH\"" -c "model=\"$DEFAULT_MODEL\"" "$@" + launch_child "$CLIENT_BIN" --profile neuroapi-host -c "model_catalog_json=\"$CATALOG_PATH\"" -c "model=\"$DEFAULT_MODEL\"" "$@" else run_claude() { # Remove inherited selectors and credentials only in this child. The @@ -105,7 +112,7 @@ else unset "$variable" ;; esac done - exec claude --settings "$SNAPSHOT/settings.json" "$@" + exec "$CLIENT_BIN" --settings "$SNAPSHOT/settings.json" "$@" } launch_child run_claude "$@" fi diff --git a/scripts/macos/uninstall.sh b/scripts/macos/uninstall.sh index 6a4dd45..04e3fd5 100755 --- a/scripts/macos/uninstall.sh +++ b/scripts/macos/uninstall.sh @@ -17,6 +17,15 @@ LAUNCHER_ROOT="$(launcher_root)" SECURITY_BIN="$(security_bin)" CURRENT_USER="$(current_user)" KEYCHAIN_ITEM_IS_OWNED=0 +SERVICE_POINTER="$STATE_ROOT/config/keychain-service" +SERVICE_TO_DELETE="$KEYCHAIN_SERVICE" +if [[ -e "$SERVICE_POINTER" ]]; then + IFS= read -r SERVICE_TO_DELETE <"$SERVICE_POINTER" + if [[ ! "$SERVICE_TO_DELETE" =~ ^host\.neuroapi\.agents\.api-key\.[a-f0-9]{32}$ ]]; then + printf 'Invalid NeuroAPI Keychain service pointer; refusing to remove it.\n' >&2 + exit 1 + fi +fi if ! is_test_mode; then printf 'This removes the NeuroAPI launchers and the API key from macOS Keychain.\n' @@ -68,7 +77,7 @@ fi if [[ "$KEYCHAIN_ITEM_IS_OWNED" == '1' ]]; then if ! "$SECURITY_BIN" delete-generic-password \ -a "$CURRENT_USER" \ - -s "$KEYCHAIN_SERVICE" >/dev/null 2>&1; then + -s "$SERVICE_TO_DELETE" >/dev/null 2>&1; then printf 'No installer-owned NeuroAPI Keychain item was found, or it was already removed.\n' fi else diff --git a/scripts/windows/common.ps1 b/scripts/windows/common.ps1 index 4a6d6c9..c4184df 100644 --- a/scripts/windows/common.ps1 +++ b/scripts/windows/common.ps1 @@ -4,6 +4,7 @@ $ErrorActionPreference = 'Stop' $script:OwnerMarkerText = 'neuroapi-agents:v1' $script:ProfileFileName = 'neuroapi-host.config.toml' $script:ProfileMarkerFileName = '.neuroapi-host.config.toml.neuroapi-agents-owned' +$script:PathMarkerFileName = '.neuroapi-agents-path-added' function Get-DefaultStateRoot { if ([string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) { @@ -64,6 +65,11 @@ function Get-ProfileMarkerPath { return [System.IO.Path]::Combine($CodexHome, $script:ProfileMarkerFileName) } +function Get-PathMarkerPath { + param([Parameter(Mandatory = $true)][string]$StateRoot) + return [System.IO.Path]::Combine($StateRoot, $script:PathMarkerFileName) +} + function Test-OwnerMarker { param([Parameter(Mandatory = $true)][string]$Path) if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { @@ -124,6 +130,30 @@ function Add-UserPathEntry { $parts += $Entry [Environment]::SetEnvironmentVariable('Path', ($parts -join ';'), 'User') } + return (-not $alreadyPresent) +} + +function Add-OwnedUserPathEntry { + param([string]$Entry, [string]$StateRoot) + $marker = Get-PathMarkerPath -StateRoot $StateRoot + $owned = @() + if (Test-Path -LiteralPath $marker -PathType Leaf) { + $owned = @(Get-Content -LiteralPath $marker | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + } + $added = Add-UserPathEntry -Entry $Entry + if ($added -and $owned -notcontains $Entry) { + $owned += $Entry + Write-Utf8NoBom -Path $marker -Content ($owned -join "`n") + } +} + +function Remove-OwnedUserPathEntries { + param([string]$StateRoot) + $marker = Get-PathMarkerPath -StateRoot $StateRoot + if (-not (Test-Path -LiteralPath $marker -PathType Leaf)) { return } + foreach ($entry in @(Get-Content -LiteralPath $marker)) { + if (-not [string]::IsNullOrWhiteSpace($entry)) { Remove-UserPathEntry -Entry $entry } + } } function Remove-UserPathEntry { @@ -149,3 +179,264 @@ function Remove-UserPathEntry { } [Environment]::SetEnvironmentVariable('Path', (@($kept) -join ';'), 'User') } + +function Get-NeuroAPIHTTPSContent { + param( + [Parameter(Mandatory = $true)][string]$Url, + [Parameter(Mandatory = $true)][string[]]$AllowedHosts, + [Parameter(Mandatory = $true)][long]$MaxBytes, + [string]$Bearer, + [string]$OutputPath, + [int]$TimeoutSeconds = 30 + ) + Add-Type -AssemblyName System.Net.Http + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + $handler = New-Object Net.Http.HttpClientHandler + $handler.AllowAutoRedirect = $false + $handler.UseCookies = $false + $http = New-Object Net.Http.HttpClient($handler) + $http.Timeout = [TimeSpan]::FromSeconds($TimeoutSeconds) + $deadline = New-Object Threading.CancellationTokenSource + $deadline.CancelAfter([TimeSpan]::FromSeconds($TimeoutSeconds)) + $current = [Uri]$Url + try { + for ($redirect = 0; $redirect -le 5; $redirect++) { + if ($current.Scheme -cne 'https' -or $current.Port -ne 443 -or + $AllowedHosts -notcontains $current.Host -or $current.UserInfo.Length -gt 0) { + throw 'Untrusted download destination' + } + $request = New-Object Net.Http.HttpRequestMessage([Net.Http.HttpMethod]::Get, $current) + $response = $null + try { + $request.Headers.UserAgent.ParseAdd('NeuroAPI-Agents-Installer/1') + if (-not [string]::IsNullOrEmpty($Bearer)) { + $request.Headers.Authorization = New-Object Net.Http.Headers.AuthenticationHeaderValue('Bearer', $Bearer) + } + $response = $http.SendAsync($request, [Net.Http.HttpCompletionOption]::ResponseHeadersRead, $deadline.Token).GetAwaiter().GetResult() + $status = [int]$response.StatusCode + if ($status -in @(301, 302, 303, 307, 308)) { + if ($null -eq $response.Headers.Location -or $redirect -eq 5) { throw 'Invalid redirect' } + $current = New-Object Uri($current, $response.Headers.Location) + continue + } + if ($status -ne 200) { return [pscustomobject]@{ Status = $status; Bytes = $null } } + if ($null -ne $response.Content.Headers.ContentLength -and + $response.Content.Headers.ContentLength -gt $MaxBytes) { throw 'Download too large' } + $inputStream = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult() + $outputStream = if ([string]::IsNullOrWhiteSpace($OutputPath)) { + New-Object IO.MemoryStream + } else { + New-Object IO.FileStream($OutputPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) + } + try { + $chunk = New-Object byte[] 65536 + $total = [long]0 + while (($count = $inputStream.ReadAsync($chunk, 0, $chunk.Length, $deadline.Token).GetAwaiter().GetResult()) -gt 0) { + $total += $count + if ($total -gt $MaxBytes) { throw 'Download too large' } + $outputStream.Write($chunk, 0, $count) + } + $bytes = if ($outputStream -is [IO.MemoryStream]) { $outputStream.ToArray() } else { $null } + return [pscustomobject]@{ Status = 200; Bytes = $bytes } + } finally { + $outputStream.Dispose() + $inputStream.Dispose() + } + } finally { + if ($null -ne $response) { $response.Dispose() } + $request.Dispose() + } + } + } finally { + $deadline.Dispose() + $http.Dispose() + $handler.Dispose() + } +} + +function Assert-NeuroAPIKeyCatalogs { + param([Parameter(Mandatory = $true)][Security.SecureString]$SecureKey) + $pointer = [IntPtr]::Zero + $credential = $null + try { + $pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureKey) + $credential = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer) + if ([string]::IsNullOrWhiteSpace($credential) -or $credential.IndexOfAny([char[]]"`r`n") -ge 0) { + throw 'Неверный формат ключа NeuroAPI.' + } + foreach ($client in @('codex', 'claude')) { + $endpoint = if ($client -eq 'codex') { 'https://neuroapi.host/v1/codex/models' } else { 'https://neuroapi.host/v1/claude-code/client-settings' } + try { + $result = Get-NeuroAPIHTTPSContent -Url $endpoint -AllowedHosts @('neuroapi.host') -MaxBytes (2 * 1024 * 1024) -Bearer $credential -TimeoutSeconds 20 + } catch { + throw 'Не удалось проверить ключ: каталог NeuroAPI временно недоступен. Попробуйте позже.' + } + if ($result.Status -in @(401, 403)) { throw 'Ключ NeuroAPI отклонен. Проверьте ключ и его доступ к моделям.' } + if ($result.Status -ne 200) { throw 'Не удалось проверить ключ: каталог NeuroAPI временно недоступен. Попробуйте позже.' } + try { + $raw = (New-Object Text.UTF8Encoding($false, $true)).GetString($result.Bytes) + if ($raw.IndexOf($credential, [StringComparison]::Ordinal) -ge 0) { throw 'Credential reflected' } + $json = $raw | ConvertFrom-Json -ErrorAction Stop + Assert-NeuroAPINoCredential -Value $json -Credential $credential -Depth 0 + ConvertFrom-NeuroAPICatalog -Client $client -Json $raw -HelperCommand 'local-credential-helper' | Out-Null + } catch { + throw 'Не удалось проверить ключ: каталог NeuroAPI вернул некорректный ответ.' + } + } + } finally { + if ($pointer -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer) } + $credential = $null + } +} + +function Get-NeuroAPIClientExecutable { + param([ValidateSet('codex', 'claude')][string]$Client, [string]$StateRoot) + $nativeCodex = Join-Path $StateRoot 'native-codex/codex.exe' + $candidate = if ($Client -eq 'codex') { $nativeCodex } else { Join-Path $env:USERPROFILE '.local/bin/claude.exe' } + if (Test-Path -LiteralPath $candidate -PathType Leaf) { return $candidate } + $command = Get-Command $Client -CommandType Application, ExternalScript -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($null -ne $command) { return $command.Source } + return $null +} + +function Assert-NeuroAPIClientReady { + param([ValidateSet('codex', 'claude')][string]$Client, [string]$Command) + $minimum = if ($Client -eq 'codex') { [version]'0.158.0' } else { [version]'2.1.284' } + if ([string]::IsNullOrWhiteSpace($Command)) { throw "Не найден $Client. Повторите установку NeuroAPI." } + try { + $global:LASTEXITCODE = 0 + $output = @(& $Command --version 2>&1) -join ' ' + if ($LASTEXITCODE -ne 0 -or $output -notmatch '(?&1) -join ' ' diff --git a/scripts/windows/setup.ps1 b/scripts/windows/setup.ps1 index 9526929..0704ce6 100644 --- a/scripts/windows/setup.ps1 +++ b/scripts/windows/setup.ps1 @@ -25,6 +25,12 @@ $CodexHome = Get-FullPath -Path $CodexHome $defaultStateRoot = Get-FullPath -Path (Get-DefaultStateRoot) $defaultCodexHome = Get-FullPath -Path (Get-DefaultCodexHome) +if ($TestMode -and ( + [string]::Equals($StateRoot, $defaultStateRoot, [System.StringComparison]::OrdinalIgnoreCase) -or + [string]::Equals($CodexHome, $defaultCodexHome, [System.StringComparison]::OrdinalIgnoreCase) +)) { + throw 'Test mode requires isolated StateRoot and CodexHome paths.' +} if (-not $TestMode) { if (-not [string]::Equals($StateRoot, $defaultStateRoot, [System.StringComparison]::OrdinalIgnoreCase)) { throw 'A custom StateRoot is allowed only in test mode.' @@ -54,6 +60,12 @@ if ($secureKey.Length -eq 0) { throw 'The NeuroAPI API key cannot be empty.' } +if (-not $TestMode) { + # Both checks are read-only. A rejected key or broken client leaves existing + # credentials and configuration untouched. + Assert-NeuroAPIKeyCatalogs -SecureKey $secureKey +} + $binRoot = [System.IO.Path]::Combine($StateRoot, 'bin') $configRoot = [System.IO.Path]::Combine($StateRoot, 'config') $secretRoot = [System.IO.Path]::Combine($StateRoot, 'secret') @@ -61,6 +73,14 @@ $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') +if (-not $TestMode) { + Ensure-Directory -Path $StateRoot + Set-NeuroAPIPrivateDirectory -Path $StateRoot + Write-OwnerMarker -Path (Get-StateMarkerPath -StateRoot $StateRoot) + Ensure-Directory -Path $binRoot + $installedClients = @(Ensure-NeuroAPIClients -StateRoot $StateRoot) +} + Ensure-Directory -Path $StateRoot Set-NeuroAPIPrivateDirectory -Path $StateRoot Write-OwnerMarker -Path (Get-StateMarkerPath -StateRoot $StateRoot) @@ -135,7 +155,10 @@ Write-Utf8NoBom -Path ([System.IO.Path]::Combine($binRoot, 'codex-neuroapi.cmd') Write-Utf8NoBom -Path ([System.IO.Path]::Combine($binRoot, 'claude-neuroapi.cmd')) -Content $claudeLauncher if (-not $TestMode -and -not $NoPathUpdate) { - Add-UserPathEntry -Entry $binRoot + Add-OwnedUserPathEntry -Entry $binRoot -StateRoot $StateRoot + if ($installedClients -contains 'claude') { + Add-OwnedUserPathEntry -Entry (Join-Path $env:USERPROFILE '.local/bin') -StateRoot $StateRoot + } } Write-Host '' @@ -143,9 +166,3 @@ Write-Host 'NeuroAPI setup is complete.' Write-Host "Codex launcher: $binRoot\codex-neuroapi.cmd" Write-Host "Claude launcher: $binRoot\claude-neuroapi.cmd" Write-Host 'Open a new terminal, then run codex-neuroapi or claude-neuroapi.' -if (-not (Get-Command codex -ErrorAction SilentlyContinue)) { - Write-Warning 'Codex CLI is not installed or is not on PATH.' -} -if (-not (Get-Command claude -ErrorAction SilentlyContinue)) { - Write-Warning 'Claude Code is not installed or is not on PATH.' -} diff --git a/scripts/windows/uninstall.ps1 b/scripts/windows/uninstall.ps1 index 251c440..6ad81fc 100644 --- a/scripts/windows/uninstall.ps1 +++ b/scripts/windows/uninstall.ps1 @@ -24,6 +24,12 @@ $CodexHome = Get-FullPath -Path $CodexHome $defaultStateRoot = Get-FullPath -Path (Get-DefaultStateRoot) $defaultCodexHome = Get-FullPath -Path (Get-DefaultCodexHome) +if ($TestMode -and ( + [string]::Equals($StateRoot, $defaultStateRoot, [System.StringComparison]::OrdinalIgnoreCase) -or + [string]::Equals($CodexHome, $defaultCodexHome, [System.StringComparison]::OrdinalIgnoreCase) +)) { + throw 'Test mode requires isolated StateRoot and CodexHome paths.' +} if (-not $TestMode) { if (-not [string]::Equals($StateRoot, $defaultStateRoot, [System.StringComparison]::OrdinalIgnoreCase)) { throw 'A custom StateRoot is allowed only in test mode.' @@ -59,7 +65,7 @@ if (Test-Path -LiteralPath $profilePath -PathType Leaf) { $binRoot = [System.IO.Path]::Combine($StateRoot, 'bin') if (-not $TestMode -and -not $NoPathUpdate) { - Remove-UserPathEntry -Entry $binRoot + Remove-OwnedUserPathEntries -StateRoot $StateRoot } if (Test-Path -LiteralPath $StateRoot -PathType Container) { diff --git a/tests/macos/catalog_smoke.py b/tests/macos/catalog_smoke.py index 37f0f46..7ce7d2e 100644 --- a/tests/macos/catalog_smoke.py +++ b/tests/macos/catalog_smoke.py @@ -63,7 +63,7 @@ def claude_catalog(slug="claude-opus-5-5"): client_source = '''#!/usr/bin/env python3 import json, os, pathlib, stat, sys, time if sys.argv[1:] == ['--version']: - print(os.environ.get('TEST_VERSION', 'codex-cli 0.147.0' if pathlib.Path(sys.argv[0]).name == 'codex' else '2.1.280 (Claude Code)')) + print(os.environ.get('TEST_VERSION', 'codex-cli 0.158.0' if pathlib.Path(sys.argv[0]).name == 'codex' else '2.1.284 (Claude Code)')) sys.exit(0) a = sys.argv[1:] if pathlib.Path(sys.argv[0]).name == 'codex': @@ -77,6 +77,7 @@ def claude_catalog(slug="claude-opus-5-5"): path = pathlib.Path(a[a.index('--settings') + 1]) payload = json.loads(path.read_text()) assert payload['env']['ANTHROPIC_BASE_URL'] == 'https://neuroapi.host/v1/claude-code' + assert payload['env']['CLAUDE_CODE_MAX_OUTPUT_TOKENS'] == '4096' assert 'apiKeyHelper' in payload and 'test-neuroapi-token' not in path.read_text() neutralized = ['ANTHROPIC_SMALL_FAST_MODEL', 'CLAUDE_CODE_SUBAGENT_MODEL', 'ANTHROPIC_DEFAULT_MODEL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN', @@ -141,6 +142,16 @@ def run(payload, client="codex", success=True, user_args=None, **extra): return marker.exists() first = run(catalog()) + verify_payload = root / 'verify-catalog.json' + verify_payload.write_text(json.dumps(catalog())) + verify_scratch = root / 'verify-private' + verify_scratch.mkdir() + verified = subprocess.run([str(launchers / 'codex-neuroapi'), '--verify'], + env=dict(base_env, TMPDIR=str(verify_scratch), TEST_PAYLOAD=str(verify_payload), + TEST_FETCH_MARKER=str(root / 'verify-fetch')), + text=True, capture_output=True, timeout=30) + assert verified.returncode == 0 and verified.stdout == 'Каталог codex проверен.\n', verified.stderr + assert not list(verify_scratch.iterdir()), 'Verify left a private snapshot' second = run(catalog("changed-model")) assert second["payload"]["models"][0]["slug"] == "changed-model" assert first["path"] != second["path"] @@ -178,8 +189,8 @@ def run(payload, client="codex", success=True, user_args=None, **extra): run(json.dumps(echoed).replace("test-neuroapi-token", "test-neuroapi-\\u0074oken"), success=False) run(catalog(), success=False, TEST_STATUS="302") run(catalog(), success=False, TEST_FETCH_FAIL="403") - assert not run(catalog(), success=False, TEST_VERSION="codex-cli 0.146.9") - assert not run(claude_catalog(), client="claude", success=False, TEST_VERSION="2.1.279 (Claude Code)") + assert not run(catalog(), success=False, TEST_VERSION="codex-cli 0.157.9") + assert not run(claude_catalog(), client="claude", success=False, TEST_VERSION="2.1.283 (Claude Code)") for invalid in [dict(claude_catalog(), apiKeyHelper="bad"), dict(claude_catalog(), availableModels=[]), dict(claude_catalog(), env={"ANTHROPIC_AUTH_TOKEN":"evil"}), dict(claude_catalog(), fallbackModel=["other"]), dict(claude_catalog(), model="missing")]: diff --git a/tests/macos/smoke.sh b/tests/macos/smoke.sh index 66c9a3e..7aad2f0 100755 --- a/tests/macos/smoke.sh +++ b/tests/macos/smoke.sh @@ -12,7 +12,7 @@ TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/neuroapi-agents-test.XXXXXX")" [[ "$(resolve_codex_config_root '' '/tmp/user')" == '/tmp/user/.codex' ]] MOCK_SECURITY="$TMP_ROOT/security" SECURITY_LOG="$TMP_ROOT/security.log" -MOCK_KEYCHAIN_STATE="$TMP_ROOT/keychain-present" +MOCK_KEYCHAIN_STATE="$TMP_ROOT/keychain" cleanup() { rm -rf -- "$TMP_ROOT" @@ -39,22 +39,34 @@ cat >"$MOCK_SECURITY" <<'EOF' #!/bin/bash set -euo pipefail printf '%s\n' "$*" >>"$NEUROAPI_AGENTS_SECURITY_LOG" +service='' +for ((i=1; i<=$#; i++)); do + if [[ "${!i}" == '-s' ]]; then + j=$((i+1)) + service="${!j}" + break + fi +done +[[ "$service" =~ ^host\.neuroapi\.agents\.api-key(\.[a-f0-9]{32})?$ ]] || exit 1 +mkdir -p -- "$NEUROAPI_AGENTS_MOCK_KEYCHAIN_STATE" +item="$NEUROAPI_AGENTS_MOCK_KEYCHAIN_STATE/$service" case "$1" in add-generic-password) [[ "${!#}" == '-w' ]] || { printf 'Expected -w to be the final argument.\n' >&2 exit 1 } - : >"$NEUROAPI_AGENTS_MOCK_KEYCHAIN_STATE" + [[ ! -e "$item" ]] || exit 1 + printf '%s\n' "${NEUROAPI_AGENTS_MOCK_NEXT_TOKEN:-test-neuroapi-token}" >"$item" ;; find-generic-password) - [[ -f "$NEUROAPI_AGENTS_MOCK_KEYCHAIN_STATE" ]] || exit 44 + [[ -f "$item" ]] || exit 44 if [[ "${!#}" == '-w' ]]; then - printf 'test-neuroapi-token\n' + cat -- "$item" fi ;; delete-generic-password) - rm -f -- "$NEUROAPI_AGENTS_MOCK_KEYCHAIN_STATE" + rm -f -- "$item" ;; *) printf 'Unexpected security command: %s\n' "$*" >&2 @@ -110,6 +122,113 @@ fi cp "$NEUROAPI_AGENTS_CODEX_HOME/neuroapi-host.config.toml" "$TMP_ROOT/profile-before.toml" /bin/bash "$REPO_ROOT/scripts/macos/install.sh" >/dev/null 2>/dev/null cmp "$TMP_ROOT/profile-before.toml" "$NEUROAPI_AGENTS_CODEX_HOME/neuroapi-host.config.toml" + +# A failed authenticated preflight must leave the previous working key and +# launcher configuration untouched. A successful retry changes only the key. +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}" +EOF +cat >"$MOCK_CLIENT_BIN/claude" <<'EOF' +#!/bin/bash +[[ "$1" == '--version' ]] || exit 1 +printf '2.1.284 (Claude Code)\n' +EOF +chmod 700 "$MOCK_CLIENT_BIN/codex" "$MOCK_CLIENT_BIN/claude" +if MOCK_CODEX_VERSION='codex-cli 0.158.0-alpha' \ + client_version_at_least "$MOCK_CLIENT_BIN/codex" 0 158 0; then + printf 'Prerelease Codex version was accepted.\n' >&2 + exit 1 +fi +MOCK_CURL="$TMP_ROOT/mock-curl" +cat >"$MOCK_CURL" <<'EOF' +#!/bin/bash +set -euo pipefail +config="$(cat)" +[[ "$config" == *'Bearer '* ]] || exit 1 +[[ "$config" != *'bad-rotation'* ]] || exit 22 +case "${!#}" in + https://neuroapi.host/v1/codex/models) cat "$NEUROAPI_TEST_CODEX_CATALOG" ;; + https://neuroapi.host/v1/claude-code/client-settings) cat "$NEUROAPI_TEST_CLAUDE_CATALOG" ;; + *) exit 1 ;; +esac +printf '\n200' +EOF +chmod 700 "$MOCK_CURL" +export NEUROAPI_TEST_CODEX_CATALOG="$TMP_ROOT/codex-catalog.json" +export NEUROAPI_TEST_CLAUDE_CATALOG="$TMP_ROOT/claude-catalog.json" +"$PYTHON_BIN" - "$NEUROAPI_TEST_CODEX_CATALOG" "$NEUROAPI_TEST_CLAUDE_CATALOG" <<'PY' +import json, pathlib, sys +model = dict(slug='gpt-6-sol', display_name='GPT-6 Sol', description='Test model', + supported_reasoning_levels=[], shell_type='shell_command', visibility='list', + supported_in_api=True, priority=0, base_instructions='test', + model_messages={'instructions_template': 'test'}, + supports_reasoning_summary_parameter=False, support_verbosity=False, + supports_parallel_tool_calls=False, truncation_policy={'mode': 'tokens', 'limit': 100}, + context_window=10000, max_context_window=10000, auto_compact_token_limit=8000, + effective_context_window_percent=90, experimental_supported_tools=[], + input_modalities=['text'], supports_search_tool=False, use_responses_lite=False, + input_token_limit=8000, output_token_limit=2000) +pathlib.Path(sys.argv[1]).write_text(json.dumps({'models': [model], 'default_model': model['slug']})) +pathlib.Path(sys.argv[2]).write_text(json.dumps({ + 'model': 'claude-opus-5-5', 'availableModels': ['claude-opus-5-5'], + 'enforceAvailableModels': True, 'fallbackModel': [], + 'modelPicker': {'options': [{'model': 'claude-opus-5-5', 'label': 'Opus'}], + 'replaceBuiltInOptions': True}, + 'env': {'ANTHROPIC_DEFAULT_OPUS_MODEL': 'claude-opus-5-5'}})) +PY +export NEUROAPI_AGENTS_TEST_PREFLIGHT=1 +export NEUROAPI_AGENTS_CURL_BIN="$MOCK_CURL" +rotation_path="$NEUROAPI_AGENTS_STATE_ROOT/config/keychain-service" +old_service="$(<"$rotation_path")" +cp "$rotation_path" "$TMP_ROOT/pointer-before" +cp "$NEUROAPI_AGENTS_STATE_ROOT/config/claude-settings.json" "$TMP_ROOT/claude-before.json" +cp "$NEUROAPI_AGENTS_BIN_ROOT/codex-neuroapi" "$TMP_ROOT/codex-before" +if PATH="$MOCK_CLIENT_BIN:$PATH" NEUROAPI_AGENTS_MOCK_NEXT_TOKEN=bad-rotation \ + /bin/bash "$REPO_ROOT/scripts/macos/install.sh" >"$TMP_ROOT/rotation.out" 2>"$TMP_ROOT/rotation.err"; then + printf 'Invalid replacement key passed preflight.\n' >&2 + exit 1 +fi +cmp "$TMP_ROOT/pointer-before" "$rotation_path" +cmp "$TMP_ROOT/profile-before.toml" "$NEUROAPI_AGENTS_CODEX_HOME/neuroapi-host.config.toml" +cmp "$TMP_ROOT/claude-before.json" "$NEUROAPI_AGENTS_STATE_ROOT/config/claude-settings.json" +cmp "$TMP_ROOT/codex-before" "$NEUROAPI_AGENTS_BIN_ROOT/codex-neuroapi" +[[ "$("$NEUROAPI_AGENTS_STATE_ROOT/bin/get-neuroapi-key.sh")" == 'test-neuroapi-token' ]] +[[ -f "$MOCK_KEYCHAIN_STATE/$old_service" ]] +[[ "$(find "$MOCK_KEYCHAIN_STATE" -type f | wc -l | tr -d ' ')" == '1' ]] +if grep -R -Fq 'bad-rotation' "$NEUROAPI_AGENTS_STATE_ROOT" "$NEUROAPI_AGENTS_CODEX_HOME" "$TMP_ROOT/rotation.out" "$TMP_ROOT/rotation.err"; then + printf 'Rejected replacement key leaked.\n' >&2 + exit 1 +fi +for failure_point in claude-settings.json keychain-service; do + if PATH="$MOCK_CLIENT_BIN:$PATH" NEUROAPI_AGENTS_MOCK_NEXT_TOKEN=good-rotation \ + NEUROAPI_AGENTS_TEST_FAIL_COMMIT_AT="$failure_point" \ + /bin/bash "$REPO_ROOT/scripts/macos/install.sh" >"$TMP_ROOT/rotation.out" 2>"$TMP_ROOT/rotation.err"; then + printf 'Injected installer failure at %s was ignored.\n' "$failure_point" >&2 + exit 1 + fi + cmp "$TMP_ROOT/pointer-before" "$rotation_path" + cmp "$TMP_ROOT/profile-before.toml" "$NEUROAPI_AGENTS_CODEX_HOME/neuroapi-host.config.toml" + cmp "$TMP_ROOT/claude-before.json" "$NEUROAPI_AGENTS_STATE_ROOT/config/claude-settings.json" + cmp "$TMP_ROOT/codex-before" "$NEUROAPI_AGENTS_BIN_ROOT/codex-neuroapi" + [[ "$("$NEUROAPI_AGENTS_STATE_ROOT/bin/get-neuroapi-key.sh")" == 'test-neuroapi-token' ]] + [[ -f "$MOCK_KEYCHAIN_STATE/$old_service" ]] + [[ "$(find "$MOCK_KEYCHAIN_STATE" -type f | wc -l | tr -d ' ')" == '1' ]] +done +PATH="$MOCK_CLIENT_BIN:$PATH" NEUROAPI_AGENTS_MOCK_NEXT_TOKEN=good-rotation \ + /bin/bash "$REPO_ROOT/scripts/macos/install.sh" >"$TMP_ROOT/rotation.out" 2>"$TMP_ROOT/rotation.err" +[[ "$("$NEUROAPI_AGENTS_STATE_ROOT/bin/get-neuroapi-key.sh")" == 'good-rotation' ]] +[[ "$(<"$rotation_path")" != "$old_service" ]] +[[ ! -e "$MOCK_KEYCHAIN_STATE/$old_service" ]] +cmp "$TMP_ROOT/profile-before.toml" "$NEUROAPI_AGENTS_CODEX_HOME/neuroapi-host.config.toml" +if grep -R -Fq 'good-rotation' "$NEUROAPI_AGENTS_STATE_ROOT" "$NEUROAPI_AGENTS_CODEX_HOME" "$TMP_ROOT/rotation.out" "$TMP_ROOT/rotation.err"; then + printf 'Replacement key leaked.\n' >&2 + exit 1 +fi +unset NEUROAPI_AGENTS_TEST_PREFLIGHT NEUROAPI_AGENTS_CURL_BIN /bin/bash "$REPO_ROOT/scripts/macos/uninstall.sh" >/dev/null [[ ! -e "$NEUROAPI_AGENTS_STATE_ROOT" ]] @@ -119,13 +238,13 @@ cmp "$TMP_ROOT/profile-before.toml" "$NEUROAPI_AGENTS_CODEX_HOME/neuroapi-host.c [[ ! -e "$NEUROAPI_AGENTS_BIN_ROOT/claude-neuroapi" ]] grep -Fq 'delete-generic-password' "$SECURITY_LOG" -printf 'pre-existing-secret\n' >"$MOCK_KEYCHAIN_STATE" +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" \ /bin/bash "$REPO_ROOT/scripts/macos/uninstall.sh" >/dev/null delete_count_after="$(grep -c 'delete-generic-password' "$SECURITY_LOG")" [[ "$delete_count_after" == "$delete_count_before" ]] -[[ -f "$MOCK_KEYCHAIN_STATE" ]] +[[ -f "$MOCK_KEYCHAIN_STATE/host.neuroapi.agents.api-key" ]] UNOWNED_KEYCHAIN_STATE="$TMP_ROOT/unowned-keychain-state" if NEUROAPI_AGENTS_STATE_ROOT="$UNOWNED_KEYCHAIN_STATE" \ @@ -133,7 +252,7 @@ if NEUROAPI_AGENTS_STATE_ROOT="$UNOWNED_KEYCHAIN_STATE" \ printf 'Setup overwrote an unowned Keychain item.\n' >&2 exit 1 fi -[[ -f "$MOCK_KEYCHAIN_STATE" ]] +[[ -f "$MOCK_KEYCHAIN_STATE/host.neuroapi.agents.api-key" ]] UNOWNED_STATE="$TMP_ROOT/unowned-state" mkdir -p "$UNOWNED_STATE" diff --git a/tests/windows/first-run.ps1 b/tests/windows/first-run.ps1 new file mode 100644 index 0000000..677002c --- /dev/null +++ b/tests/windows/first-run.ps1 @@ -0,0 +1,161 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Assert-True { param([bool]$Condition, [string]$Message) if (-not $Condition) { throw $Message } } + +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +. (Join-Path $repoRoot 'scripts/windows/common.ps1') +. (Join-Path $repoRoot 'scripts/windows/managed-catalog.ps1') +$realCodexInstaller = ${function:Install-NeuroAPICodex} +$realClientExecutable = ${function:Get-NeuroAPIClientExecutable} +$tempRoot = Join-Path ([IO.Path]::GetTempPath()) ('neuroapi-first-run-' + [Guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $tempRoot | Out-Null +try { + $current = Join-Path $tempRoot 'current.ps1' + $old = Join-Path $tempRoot 'old.ps1' + Set-Content -LiteralPath $current -Value "'codex-cli 0.158.0'" -Encoding Ascii + Set-Content -LiteralPath $old -Value "'codex-cli 0.120.0'" -Encoding Ascii + Assert-NeuroAPIClientReady -Client codex -Command $current + $rejected = $false + try { Assert-NeuroAPIClientReady -Client codex -Command $old } catch { $rejected = $_.Exception.Message -match '0.158.0' } + Assert-True $rejected 'An obsolete client was accepted without an actionable version.' + + $script:KnownClients = @{ codex = $null; claude = $null } + $script:Installs = @{ codex = 0; claude = 0 } + function Get-NeuroAPIClientExecutable { param([string]$Client, [string]$StateRoot) return $script:KnownClients[$Client] } + function Assert-NeuroAPIClientReady { param([string]$Client, [string]$Command) if ($Command -eq 'old') { throw 'Outdated client' }; if (-not $Command) { throw 'Missing client' } } + function Install-NeuroAPICodex { param([string]$StateRoot) $script:Installs.codex++; $script:KnownClients.codex = 'new-codex' } + function Install-NeuroAPIClaude { param([string]$StateRoot) $script:Installs.claude++; $script:KnownClients.claude = 'new-claude' } + function Read-Host { param([string]$Prompt) return '' } + + $installed = @(Ensure-NeuroAPIClients -StateRoot $tempRoot) + Assert-True ($installed.Count -eq 2 -and $script:Installs.codex -eq 1 -and $script:Installs.claude -eq 1) 'Both missing clients were not installed exactly once.' + $again = @(Ensure-NeuroAPIClients -StateRoot $tempRoot) + Assert-True ($again.Count -eq 0 -and $script:Installs.codex -eq 1 -and $script:Installs.claude -eq 1) 'Existing clients were modified on rerun.' + $script:KnownClients.codex = 'old' + $updated = @(Ensure-NeuroAPIClients -StateRoot $tempRoot) + Assert-True ($updated.Count -eq 1 -and $updated[0] -eq 'codex' -and $script:Installs.codex -eq 2 -and $script:Installs.claude -eq 1) 'An old client was not replaced by an owned current copy.' + + $script:PathEntries = @('C:\preexisting') + function Add-UserPathEntry { param([string]$Entry) if ($script:PathEntries -contains $Entry) { return $false }; $script:PathEntries += $Entry; return $true } + function Remove-UserPathEntry { param([string]$Entry) $script:PathEntries = @($script:PathEntries | Where-Object { $_ -ne $Entry }) } + Add-OwnedUserPathEntry -Entry 'C:\preexisting' -StateRoot $tempRoot + Add-OwnedUserPathEntry -Entry 'C:\new' -StateRoot $tempRoot + Add-OwnedUserPathEntry -Entry 'C:\new' -StateRoot $tempRoot + Remove-OwnedUserPathEntries -StateRoot $tempRoot + Assert-True ($script:PathEntries.Count -eq 1 -and $script:PathEntries[0] -eq 'C:\preexisting') 'Uninstall removed a PATH entry it did not add.' + + $secure = ConvertTo-SecureString -String 'dummy-secret' -AsPlainText -Force + $script:CatalogStatus = 200 + $script:CatalogCalls = 0 + $script:CatalogBodies = @{ + codex = (@{ models = @(@{ + slug = 'gpt-6-sol'; display_name = 'GPT-6 Sol'; description = 'Test'; base_instructions = 'Test' + 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 = 'Test' }; truncation_policy = @{ mode = 'tokens'; limit = 10000 } + experimental_supported_tools = @(); input_modalities = @('text') + }); default_model = 'gpt-6-sol' } | ConvertTo-Json -Depth 8 -Compress) + claude = (@{ + model = 'claude-opus-5-5'; availableModels = @('claude-opus-5-5'); enforceAvailableModels = $true; fallbackModel = @() + modelPicker = @{ options = @(@{ model = 'claude-opus-5-5'; label = 'Opus 5.5' }); replaceBuiltInOptions = $true } + env = @{ ANTHROPIC_DEFAULT_OPUS_MODEL = 'claude-opus-5-5' } + } | ConvertTo-Json -Depth 8 -Compress) + } + function Get-NeuroAPIHTTPSContent { + param([string]$Url, [string[]]$AllowedHosts, [long]$MaxBytes, [string]$Bearer, [string]$OutputPath, [int]$TimeoutSeconds) + if ($Bearer -cne 'dummy-secret') { throw 'Wrong test credential' } + $script:CatalogCalls++ + $client = if ($Url -like '*/codex/*') { 'codex' } else { 'claude' } + return [pscustomobject]@{ Status = $script:CatalogStatus; Bytes = [Text.Encoding]::UTF8.GetBytes($script:CatalogBodies[$client]) } + } + Assert-NeuroAPIKeyCatalogs -SecureKey $secure + Assert-True ($script:CatalogCalls -eq 2) 'Preflight did not check both catalogs.' + $validCodexBody = $script:CatalogBodies.codex + $script:CatalogBodies.codex = '{}' + $message = '' + try { Assert-NeuroAPIKeyCatalogs -SecureKey $secure } catch { $message = $_.Exception.Message } + Assert-True ($message -match 'некорректный' -and $message -notmatch 'dummy-secret') 'A 200 response with an empty Codex catalog was accepted.' + $script:CatalogBodies.codex = $validCodexBody + $script:CatalogBodies.claude = '{}' + $message = '' + try { Assert-NeuroAPIKeyCatalogs -SecureKey $secure } catch { $message = $_.Exception.Message } + Assert-True ($message -match 'некорректный' -and $message -notmatch 'dummy-secret') 'A 200 response with an empty catalog was accepted.' + $script:CatalogBodies.claude = (@{ + model = 'claude-opus-5-5'; availableModels = @('claude-opus-5-5'); enforceAvailableModels = $true; fallbackModel = @() + modelPicker = @{ options = @(@{ model = 'claude-opus-5-5' }); replaceBuiltInOptions = $true } + env = @{} + } | ConvertTo-Json -Depth 8 -Compress) + Assert-NeuroAPIKeyCatalogs -SecureKey $secure + $script:CatalogStatus = 401 + $message = '' + try { Assert-NeuroAPIKeyCatalogs -SecureKey $secure } catch { $message = $_.Exception.Message } + Assert-True ($message -match 'отклонен' -and $message -notmatch 'dummy-secret') 'Invalid key did not fail safely.' + $script:CatalogStatus = 503 + $message = '' + try { Assert-NeuroAPIKeyCatalogs -SecureKey $secure } catch { $message = $_.Exception.Message } + Assert-True ($message -match 'недоступен' -and $message -notmatch 'dummy-secret') 'Unavailable catalog did not fail safely.' + $secure.Dispose() + + # Exercise native bundle extraction with a tiny local ZIP. The test never + # runs a vendor installer or downloads a binary. + Set-Item function:Install-NeuroAPICodex $realCodexInstaller + Add-Type -AssemblyName System.IO.Compression.FileSystem + $bundleRoot = Join-Path $tempRoot 'bundle-source' + $installRoot = Join-Path $tempRoot 'bundle-install' + New-Item -ItemType Directory -Path (Join-Path $bundleRoot 'codex-resources') -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $installRoot 'bin') -Force | Out-Null + $assetName = 'codex-x86_64-pc-windows-msvc.exe.zip' + Set-Content -LiteralPath (Join-Path $bundleRoot $assetName.Replace('.zip', '')) -Value 'test executable' -Encoding Ascii + Set-Content -LiteralPath (Join-Path $bundleRoot 'codex-resources/manifest.json') -Value '{}' -Encoding Ascii + $bundleZip = Join-Path $tempRoot 'bundle.zip' + [IO.Compression.ZipFile]::CreateFromDirectory($bundleRoot, $bundleZip) + $script:BundleZip = $bundleZip + $script:BundleDigest = (Get-FileHash -LiteralPath $bundleZip -Algorithm SHA256).Hash.ToLowerInvariant() + function Get-NeuroAPIHTTPSContent { + param([string]$Url, [string[]]$AllowedHosts, [long]$MaxBytes, [string]$Bearer, [string]$OutputPath, [int]$TimeoutSeconds) + if ($Url -like 'https://api.github.com/*') { + $asset = @{ name = 'codex-x86_64-pc-windows-msvc.exe.zip'; size = (Get-Item $script:BundleZip).Length; digest = ('sha256:' + $script:BundleDigest); browser_download_url = 'https://github.com/openai/codex/releases/download/rust-v0.158.0/codex-x86_64-pc-windows-msvc.exe.zip' } + return [pscustomobject]@{ Status = 200; Bytes = [Text.Encoding]::UTF8.GetBytes((@{ assets = @($asset) } | ConvertTo-Json -Depth 5)) } + } + Copy-Item -LiteralPath $script:BundleZip -Destination $OutputPath + return [pscustomobject]@{ Status = 200; Bytes = $null } + } + Install-NeuroAPICodex -StateRoot $installRoot + Assert-True (Test-Path -LiteralPath (Join-Path $installRoot 'native-codex/codex.exe')) 'Native Codex binary was not extracted.' + Assert-True (Test-Path -LiteralPath (Join-Path $installRoot 'native-codex/codex-resources/manifest.json')) 'Codex support files were not extracted.' + Assert-True (Test-Path -LiteralPath (Join-Path $installRoot 'bin/codex.cmd')) 'Codex command launcher was not created.' + $nativePath = Join-Path $installRoot 'native-codex/codex.exe' + $beforeUpdate = Get-Content -LiteralPath $nativePath -Raw + $script:BundleDigest = ('0' * 64) + $updateRejected = $false + try { Install-NeuroAPICodex -StateRoot $installRoot } catch { $updateRejected = $true } + Assert-True ($updateRejected -and (Get-Content -LiteralPath $nativePath -Raw) -ceq $beforeUpdate) 'A failed update changed the installed Codex bundle.' + $script:BundleDigest = (Get-FileHash -LiteralPath $bundleZip -Algorithm SHA256).Hash.ToLowerInvariant() + $wrapperPath = Join-Path $installRoot 'bin/codex.cmd' + $wrapperBefore = Get-Content -LiteralPath $wrapperPath -Raw + Set-Content -LiteralPath $wrapperPath -Value 'other launcher' -Encoding Ascii + $conflictRejected = $false + try { Install-NeuroAPICodex -StateRoot $installRoot } catch { $conflictRejected = $true } + Assert-True ($conflictRejected -and (Get-Content -LiteralPath $nativePath -Raw) -ceq $beforeUpdate) 'A launcher conflict did not restore the previous Codex bundle.' + Assert-True ((Get-Content -LiteralPath $wrapperPath -Raw) -match 'other launcher') 'A launcher conflict overwrote the existing file.' + Write-Utf8NoBom -Path $wrapperPath -Content $wrapperBefore + Set-Content -LiteralPath (Join-Path $bundleRoot $assetName.Replace('.zip', '')) -Value 'updated executable' -Encoding Ascii + Remove-Item -LiteralPath $bundleZip + [IO.Compression.ZipFile]::CreateFromDirectory($bundleRoot, $bundleZip) + $script:BundleDigest = (Get-FileHash -LiteralPath $bundleZip -Algorithm SHA256).Hash.ToLowerInvariant() + Install-NeuroAPICodex -StateRoot $installRoot + Assert-True ((Get-Content -LiteralPath $nativePath -Raw) -match 'updated executable') 'A verified owned Codex bundle was not updated.' + Assert-True (@(Get-ChildItem -LiteralPath $installRoot -Filter 'codex-backup-*').Count -eq 0) 'Successful update left an obsolete backup.' + Set-Item function:Get-NeuroAPIClientExecutable $realClientExecutable + function Get-Command { param([string]$Name, $CommandType, $ErrorAction) return $null } + $resolved = Get-NeuroAPIClientExecutable -Client codex -StateRoot $installRoot + Remove-Item function:Get-Command + Assert-True ($resolved -eq (Join-Path $installRoot 'native-codex/codex.exe')) 'Managed Codex must use the native binary, not the CMD wrapper.' + Write-Host 'Windows first-run tests passed.' +} finally { + Remove-Item -LiteralPath $tempRoot -Recurse -Force +} diff --git a/tests/windows/managed-catalog.ps1 b/tests/windows/managed-catalog.ps1 index 56295e2..5dc3e42 100644 --- a/tests/windows/managed-catalog.ps1 +++ b/tests/windows/managed-catalog.ps1 @@ -49,6 +49,7 @@ Assert-Catalog ($claude.apiKeyHelper -ceq 'local-helper') 'Server replaced crede Assert-Catalog (-not $claude.Contains('hooks')) 'Server hooks were copied.' Assert-Catalog ($claude.env.ANTHROPIC_CUSTOM_HEADERS -ceq '' -and $claude.env.ANTHROPIC_AUTH_TOKEN -ceq '') 'Merged settings can override credentials.' Assert-Catalog ($claude.env.ANTHROPIC_MODEL -ceq $claude.model) 'Merged model override can bypass default.' +Assert-Catalog ($claude.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS -ceq '4096') 'Claude output reservation cap missing.' Assert-Catalog (-not $claude.env.Contains('UNSAFE_ENV')) 'Unreviewed environment was copied.' Assert-Catalog (-not $claude.env.Contains('ANTHROPIC_DEFAULT_FABLE_MODEL')) 'Missing Fable was invented.' Assert-Catalog ($claude.availableModels.Count -eq 5 -and $claude.modelPicker.options.Count -eq 3) 'Compatibility aliases must remain available but hidden.' @@ -165,11 +166,11 @@ function Get-TestClientVersion { $script:versionText } foreach ($versionCase in @( - @{ Client = 'codex'; Text = 'codex-cli 0.147.0'; Valid = $true }, - @{ Client = 'codex'; Text = 'codex-cli 0.146.9'; Valid = $false }, - @{ Client = 'codex'; Text = 'codex-cli 0.147.0-alpha.1'; Valid = $false }, - @{ Client = 'claude'; Text = '2.1.280 (Claude Code)'; Valid = $true }, - @{ Client = 'claude'; Text = '2.1.279 (Claude Code)'; Valid = $false }, + @{ Client = 'codex'; Text = 'codex-cli 0.158.0'; Valid = $true }, + @{ Client = 'codex'; Text = 'codex-cli 0.157.9'; Valid = $false }, + @{ Client = 'codex'; Text = 'codex-cli 0.158.0-alpha.1'; Valid = $false }, + @{ Client = 'claude'; Text = '2.1.284 (Claude Code)'; Valid = $true }, + @{ Client = 'claude'; Text = '2.1.283 (Claude Code)'; Valid = $false }, @{ Client = 'claude'; Text = 'unknown'; Valid = $false } )) { $script:versionExit = 0 @@ -181,7 +182,7 @@ foreach ($versionCase in @( } } $script:versionExit = 1 -$script:versionText = 'codex-cli 0.147.0' +$script:versionText = 'codex-cli 0.158.0' Assert-CatalogFailure { Assert-NeuroAPIClientVersion -Client codex -Command 'Get-TestClientVersion' } $tempRoot = Join-Path ([IO.Path]::GetTempPath()) ('neuroapi-managed-test-' + [Guid]::NewGuid().ToString('N')) From 48ac5260423902d55149f6fef2f78a35de07286d Mon Sep 17 00:00:00 2001 From: Roman Date: Tue, 29 Sep 2026 06:46:07 +0800 Subject: [PATCH 03/11] fix(agents): encode Windows PowerShell scripts for Windows PowerShell 5 --- scripts/windows/common.ps1 | 2 +- tests/windows/first-run.ps1 | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/windows/common.ps1 b/scripts/windows/common.ps1 index c4184df..9691096 100644 --- a/scripts/windows/common.ps1 +++ b/scripts/windows/common.ps1 @@ -1,4 +1,4 @@ -Set-StrictMode -Version Latest +Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $script:OwnerMarkerText = 'neuroapi-agents:v1' diff --git a/tests/windows/first-run.ps1 b/tests/windows/first-run.ps1 index 677002c..3bc771e 100644 --- a/tests/windows/first-run.ps1 +++ b/tests/windows/first-run.ps1 @@ -1,4 +1,4 @@ -Set-StrictMode -Version Latest +Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' function Assert-True { param([bool]$Condition, [string]$Message) if (-not $Condition) { throw $Message } } From 1461b5da25138047ddc090fb073af3ba0b168d6e Mon Sep 17 00:00:00 2001 From: Roman Date: Tue, 29 Sep 2026 06:51:20 +0800 Subject: [PATCH 04/11] test(agents): verify official clients on Windows runner --- .github/workflows/validate.yml | 3 +++ tests/windows/native-clients.ps1 | 25 +++++++++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 tests/windows/native-clients.ps1 diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 560f6a4..c8b2bab 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -40,6 +40,9 @@ jobs: - name: Run isolated installer smoke shell: powershell run: .\tests\windows\smoke.ps1 + - name: Install and launch official clients + shell: powershell + run: .\tests\windows\native-clients.ps1 macos: name: macOS Keychain-command smoke diff --git a/tests/windows/native-clients.ps1 b/tests/windows/native-clients.ps1 new file mode 100644 index 0000000..5d09f04 --- /dev/null +++ b/tests/windows/native-clients.ps1 @@ -0,0 +1,25 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +. (Join-Path $repoRoot 'scripts/windows/common.ps1') + +$stateRoot = Join-Path ([IO.Path]::GetTempPath()) ('neuroapi-native-clients-' + [Guid]::NewGuid().ToString('N')) +try { + Ensure-Directory -Path (Join-Path $stateRoot 'bin') + Install-NeuroAPICodex -StateRoot $stateRoot + $codex = Get-NeuroAPIClientExecutable -Client codex -StateRoot $stateRoot + if ($codex -cne (Join-Path $stateRoot 'native-codex/codex.exe')) { + throw 'The native Codex executable was not selected.' + } + Assert-NeuroAPIClientReady -Client codex -Command $codex + + Install-NeuroAPIClaude -StateRoot $stateRoot | Out-Host + $claude = Get-NeuroAPIClientExecutable -Client claude -StateRoot $stateRoot + Assert-NeuroAPIClientReady -Client claude -Command $claude + Write-Host 'Official native Codex and Claude clients installed and launched.' +} finally { + if (Test-Path -LiteralPath $stateRoot) { + Remove-Item -LiteralPath $stateRoot -Recurse -Force + } +} From 6cac814ac28f59eddc8ebfde2a2e04fc87831c12 Mon Sep 17 00:00:00 2001 From: Roman Date: Tue, 29 Sep 2026 07:27:07 +0800 Subject: [PATCH 05/11] fix(agents): make Windows first run non-interactive --- .github/workflows/validate.yml | 3 +++ README.en.md | 6 ++++-- README.md | 9 ++++++--- docs/manual-setup.md | 6 +++--- docs/troubleshooting.md | 5 +++-- scripts/windows/common.ps1 | 4 +--- tests/windows/first-run.ps1 | 2 +- 7 files changed, 21 insertions(+), 14 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index c8b2bab..512b84e 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -40,6 +40,9 @@ jobs: - name: Run isolated installer smoke shell: powershell run: .\tests\windows\smoke.ps1 + - name: Verify first-run client installation and upgrades + shell: powershell + run: .\tests\windows\first-run.ps1 - name: Install and launch official clients shell: powershell run: .\tests\windows\native-clients.ps1 diff --git a/README.en.md b/README.en.md index b1776f4..4b986ce 100644 --- a/README.en.md +++ b/README.en.md @@ -1,6 +1,6 @@ # NeuroAPI for Codex CLI and Claude Code -Public, auditable one-click setup for routing local Codex CLI and Claude Code sessions through [NeuroAPI](https://neuroapi.host) on Windows and macOS. +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. [Русская версия](README.md) @@ -16,6 +16,8 @@ Use a current Codex release whose `--help` describes `--profile` as loading ``. +Нужен Claude Code 2.1.284 или новее. Существующий `~/.claude/settings.json` не меняется. Launcher получает `/v1/claude-code/client-settings`, проверяет разрешённые поля данных, добавляет локальный `apiKeyHelper` и передаёт приватный JSON через `claude --settings `. ```json { @@ -95,7 +95,7 @@ Setup не меняет `.zprofile`, `.zshrc`, `.bash_profile` или систе Конкретные IDs выбирает сервер из опубликованных моделей с учётом ключа, тарифа и совместимости протокола. В установщике больше нет фиксированной основной модели. Ошибка загрузки, пустой список, неподходящая версия клиента или неверная схема останавливают запуск с понятным сообщением: старый список не используется. -Серверный порядок по умолчанию на 26.09.2026: `claude-opus-5-5`, `claude-sonnet-5`, `claude-haiku-4-5`, `claude-fable-5-1`; для Codex — `gpt-6-sol`, `gpt-6-astra`, `gpt-6-luna`. После публикации Opus 5.5 доступные ключу настройки содержат `model: "claude-opus-5-5"` и `ANTHROPIC_DEFAULT_OPUS_MODEL: "claude-opus-5-5"`. До публикации выбирается первая доступная рекомендация. Opus 5 и 4.8 остаются только совместимыми служебными вариантами вне рекомендуемого меню. Сохранённая администратором настройка каталога имеет приоритет над этим порядком. +Рекомендуемый серверный порядок: `claude-opus-5-5`, `claude-sonnet-5-5`, `claude-sonnet-5`, `claude-fable-5-1`; для Codex — `gpt-6-sol`, `gpt-6-astra`, `gpt-6-luna`. Список зависит от опубликованных моделей, совместимого маршрута и прав ключа. Сохранённая администратором настройка каталога имеет приоритет над этим порядком. Специальный ключ не нужен. Можно подключаться вручную через обычный API: сервер распознаёт известные заголовки Codex/Claude на `/v1/models`. Однако клиент должен сам запросить каталог, а встроенные варианты могут сохраниться. Управляемые launchers — дополнительный способ получить заданное меню, а не условие доступа к API. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index afbee32..bcee774 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,5 +1,7 @@ # Решение проблем +Эти инструкции относятся к терминальным `codex-neuroapi` и `claude-neuroapi`. Запуск тех же моделей в Codex Desktop или вкладке Code приложения Claude Desktop требует отдельной настройки и проверки GUI. + ## `codex-neuroapi` или `claude-neuroapi` не найдены Windows: откройте новый терминал после установки. Проверьте наличие `%LOCALAPPDATA%\NeuroAPIAgents\bin` в пользовательском `PATH`. @@ -58,8 +60,7 @@ macOS может показать системный запрос доступа Убедитесь, что файл скачан из `neurogen-dev/NeuroAPI`. Выполните: ```bash -chmod +x setup-macos.command -./setup-macos.command +bash setup-macos.command ``` Если Gatekeeper всё ещё блокирует запуск, откройте файл через Finder → правый клик → Open. Не отключайте Gatekeeper глобально. diff --git a/scripts/windows/common.ps1 b/scripts/windows/common.ps1 index 9691096..2daa2f3 100644 --- a/scripts/windows/common.ps1 +++ b/scripts/windows/common.ps1 @@ -424,9 +424,7 @@ function Ensure-NeuroAPIClients { catch { $install += $client } } if ($install.Count -gt 0) { - $answer = Read-Host ('Установить или обновить Codex CLI и Claude Code из официальных источников? (' + ($install -join ', ') + ') [Y/n]') - if ($answer -match '^(n|no|н|нет)$') { throw 'Установите отсутствующие клиенты и повторите настройку NeuroAPI.' } - if (-not [string]::IsNullOrWhiteSpace($answer) -and $answer -notmatch '^(y|yes|д|да)$') { throw 'Установка клиентов отменена.' } + Write-Host ('Устанавливаю или обновляю из официальных источников: ' + ($install -join ', ') + '.') foreach ($client in $install) { try { if ($client -eq 'codex') { Install-NeuroAPICodex -StateRoot $StateRoot } diff --git a/tests/windows/first-run.ps1 b/tests/windows/first-run.ps1 index 3bc771e..af1c804 100644 --- a/tests/windows/first-run.ps1 +++ b/tests/windows/first-run.ps1 @@ -26,7 +26,7 @@ try { function Assert-NeuroAPIClientReady { param([string]$Client, [string]$Command) if ($Command -eq 'old') { throw 'Outdated client' }; if (-not $Command) { throw 'Missing client' } } function Install-NeuroAPICodex { param([string]$StateRoot) $script:Installs.codex++; $script:KnownClients.codex = 'new-codex' } function Install-NeuroAPIClaude { param([string]$StateRoot) $script:Installs.claude++; $script:KnownClients.claude = 'new-claude' } - function Read-Host { param([string]$Prompt) return '' } + function Read-Host { param([string]$Prompt) throw 'Native client installation must not request another confirmation.' } $installed = @(Ensure-NeuroAPIClients -StateRoot $tempRoot) Assert-True ($installed.Count -eq 2 -and $script:Installs.codex -eq 1 -and $script:Installs.claude -eq 1) 'Both missing clients were not installed exactly once.' From c2da5c725de960d4394dc3d9bbed7117713246cf Mon Sep 17 00:00:00 2001 From: Roman Date: Tue, 29 Sep 2026 07:33:08 +0800 Subject: [PATCH 06/11] fix(agents): validate Claude background model fallback --- README.en.md | 2 ++ README.md | 2 ++ docs/manual-setup.md | 2 ++ scripts/macos/catalog-validator.js | 9 ++++++++- scripts/windows/common.ps1 | 5 ++++- scripts/windows/managed-catalog.ps1 | 11 +++++++++-- tests/macos/catalog_smoke.py | 14 ++++++++++++++ tests/windows/first-run.ps1 | 11 +++++++++++ tests/windows/managed-catalog.ps1 | 12 +++++++++++- 9 files changed, 63 insertions(+), 5 deletions(-) diff --git a/README.en.md b/README.en.md index 4b986ce..49ecb23 100644 --- a/README.en.md +++ b/README.en.md @@ -55,6 +55,8 @@ Each launcher fetches a fresh catalog scoped to the ordinary NeuroAPI key. Codex Recommended server selection: GPT-6 Sol, Astra and Luna for Codex; **Opus 5.5** (`claude-opus-5-5`, preferred), Sonnet 5.5, Sonnet 5 and Fable 5.1 for Claude Code. An entry appears only when its published model, tariff and compatible upstream route are available to the key. Existing administrator catalog settings override source defaults. +Claude Code also uses the `haiku` alias for background work. If no recommended Haiku is available, the server maps that alias to an eligible Sonnet or the default model. These requests are billed for the actual selected model and may cost more than Haiku. + ## More - [Security model](docs/security.md) diff --git a/README.md b/README.md index 23eb74a..bac6bc5 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,8 @@ Claude Code: Рекомендуемый серверный набор: Codex — GPT-6 Sol, Astra и Luna; Claude Code — **Opus 5.5** (`claude-opus-5-5`, приоритетный), Sonnet 5.5, Sonnet 5 и Fable 5.1. Модель появляется в меню только после публикации на сервисе и появления совместимого маршрута для тарифа и ключа. Если администратор сохранил собственный список, он имеет приоритет над рекомендуемым набором. +Claude Code обращается к alias `haiku` и в фоновых задачах. Если доступной рекомендованной Haiku нет, сервер назначает для этого alias доступную Sonnet или основную модель. Такие запросы тарифицируются по фактически выбранной модели; фоновая работа может стоить дороже Haiku. + ## Удаление и замена ключа - Чтобы заменить ключ, повторно запустите setup-файл — защищённое значение обновится. diff --git a/docs/manual-setup.md b/docs/manual-setup.md index 8fdc84c..61c3d2d 100644 --- a/docs/manual-setup.md +++ b/docs/manual-setup.md @@ -97,6 +97,8 @@ Setup не меняет `.zprofile`, `.zshrc`, `.bash_profile` или систе Рекомендуемый серверный порядок: `claude-opus-5-5`, `claude-sonnet-5-5`, `claude-sonnet-5`, `claude-fable-5-1`; для Codex — `gpt-6-sol`, `gpt-6-astra`, `gpt-6-luna`. Список зависит от опубликованных моделей, совместимого маршрута и прав ключа. Сохранённая администратором настройка каталога имеет приоритет над этим порядком. +`ANTHROPIC_DEFAULT_HAIKU_MODEL` используется Claude Code также для фоновых запросов. При отсутствии рекомендованной Haiku сервер назначает доступную модель Sonnet либо основную модель из меню; установщик принимает такое назначение только внутри опубликованного allowlist и рекомендованного меню. Списание идёт по фактической модели и может быть выше стоимости Haiku. + Специальный ключ не нужен. Можно подключаться вручную через обычный API: сервер распознаёт известные заголовки Codex/Claude на `/v1/models`. Однако клиент должен сам запросить каталог, а встроенные варианты могут сохраниться. Управляемые launchers — дополнительный способ получить заданное меню, а не условие доступа к API. ## Официальные контракты diff --git a/scripts/macos/catalog-validator.js b/scripts/macos/catalog-validator.js index 21d2f72..bf9cce3 100644 --- a/scripts/macos/catalog-validator.js +++ b/scripts/macos/catalog-validator.js @@ -87,7 +87,14 @@ function validateClaude(data) { Object.keys(data.env).forEach(function (key) { requireValue(modelID(data.env[key]) && seen[data.env[key]]) var family = key.slice('ANTHROPIC_DEFAULT_'.length, -'_MODEL'.length).toLowerCase() - requireValue(new RegExp('^claude-' + family + '(?:[-.]|$)').test(data.env[key])) + if (family === 'haiku') { + // Claude Code also uses the Haiku alias for background calls. The + // server may route it to an eligible recommended Sonnet or default. + requireValue(options[data.env[key]] === true) + requireValue(/^claude-(?:haiku|sonnet|opus|fable)(?:[-.]|$)/.test(data.env[key])) + } else { + requireValue(new RegExp('^claude-' + family + '(?:[-.]|$)').test(data.env[key])) + } }) } function run(args) { diff --git a/scripts/windows/common.ps1 b/scripts/windows/common.ps1 index 2daa2f3..937d525 100644 --- a/scripts/windows/common.ps1 +++ b/scripts/windows/common.ps1 @@ -345,7 +345,10 @@ function Install-NeuroAPICodex { if ($archive.Entries.Count -gt 128) { throw 'Пакет Codex содержит слишком много файлов.' } $total = [long]0 foreach ($entry in $archive.Entries) { - $name = $entry.FullName + # Windows-created ZIPs can use backslashes. Normalize before + # validating so either separator is subject to the same + # traversal and extraction-root checks. + $name = $entry.FullName.Replace('\', '/') if ($name -cnotmatch '^[A-Za-z0-9._/-]+$' -or $name -match '(^|/)\.\.(/|$)' -or $name.StartsWith('/') -or $name.Contains('//')) { throw 'Пакет Codex содержит небезопасный путь.' } if ($name.EndsWith('/')) { continue } diff --git a/scripts/windows/managed-catalog.ps1 b/scripts/windows/managed-catalog.ps1 index 88753ff..86001d7 100644 --- a/scripts/windows/managed-catalog.ps1 +++ b/scripts/windows/managed-catalog.ps1 @@ -266,8 +266,15 @@ function ConvertFrom-NeuroAPICatalog { $key = 'ANTHROPIC_DEFAULT_' + $family + '_MODEL' $value = Get-NeuroAPIProperty $environment $key if ($null -ne $value) { - if (-not (Test-NeuroAPIModelId $value) -or -not $allowed.Contains($value) -or - $value -notmatch ('^claude-' + $family.ToLowerInvariant() + '(?:[.\-]|$)')) { throw 'Invalid model family' } + if (-not (Test-NeuroAPIModelId $value) -or -not $allowed.Contains($value)) { throw 'Invalid model family' } + if ($family -eq 'HAIKU') { + # Claude Code uses this alias for background calls. The + # target must be a recommended Claude model, not a hidden + # compatibility-only entry from availableModels. + if (-not $seen.Contains($value) -or $value -notmatch '^claude-(haiku|sonnet|opus|fable)(?:[.\-]|$)') { throw 'Invalid background model' } + } elseif ($value -notmatch ('^claude-' + $family.ToLowerInvariant() + '(?:[.\-]|$)')) { + throw 'Invalid model family' + } $safeEnv[$key] = $value } } diff --git a/tests/macos/catalog_smoke.py b/tests/macos/catalog_smoke.py index 7ce7d2e..28d913a 100644 --- a/tests/macos/catalog_smoke.py +++ b/tests/macos/catalog_smoke.py @@ -157,6 +157,16 @@ def run(payload, client="codex", success=True, user_args=None, **extra): assert first["path"] != second["path"] run(catalog(), user_args=['--model', 'explicit-user-model']) run(claude_catalog(), client="claude") + sonnet_background = claude_catalog() + sonnet_background['availableModels'].append('claude-sonnet-5-5') + sonnet_background['modelPicker']['options'].append({'model': 'claude-sonnet-5-5'}) + sonnet_background['env']['ANTHROPIC_DEFAULT_SONNET_MODEL'] = 'claude-sonnet-5-5' + sonnet_background['env']['ANTHROPIC_DEFAULT_HAIKU_MODEL'] = 'claude-sonnet-5-5' + sonnet_result = run(sonnet_background, client="claude") + assert sonnet_result['payload']['env']['ANTHROPIC_DEFAULT_HAIKU_MODEL'] == 'claude-sonnet-5-5' + default_background = claude_catalog() + default_background['env']['ANTHROPIC_DEFAULT_HAIKU_MODEL'] = default_background['model'] + run(default_background, client="claude") security_log = Path(os.environ['NEUROAPI_AGENTS_SECURITY_LOG']) for host_managed in ['1', 'true', 'yes', 'on', ' TRUE ', '\tOn\n', ' YeS ', ' 1 ']: before = security_log.read_bytes() @@ -171,6 +181,10 @@ def run(payload, client="codex", success=True, user_args=None, **extra): wrong_family = claude_catalog() wrong_family['env']['ANTHROPIC_DEFAULT_FABLE_MODEL'] = wrong_family['model'] run(wrong_family, client="claude", success=False) + hidden_background = claude_catalog() + hidden_background['availableModels'].append('claude-sonnet-5-5') + hidden_background['env']['ANTHROPIC_DEFAULT_HAIKU_MODEL'] = 'claude-sonnet-5-5' + run(hidden_background, client="claude", success=False) hidden_default = dict(hidden, model='claude-opus-4.8') run(hidden_default, client="claude", success=False) hidden_codex = catalog() diff --git a/tests/windows/first-run.ps1 b/tests/windows/first-run.ps1 index af1c804..72c9a9e 100644 --- a/tests/windows/first-run.ps1 +++ b/tests/windows/first-run.ps1 @@ -130,6 +130,17 @@ try { Assert-True (Test-Path -LiteralPath (Join-Path $installRoot 'bin/codex.cmd')) 'Codex command launcher was not created.' $nativePath = Join-Path $installRoot 'native-codex/codex.exe' $beforeUpdate = Get-Content -LiteralPath $nativePath -Raw + $unsafeZip = Join-Path $tempRoot 'unsafe-bundle.zip' + Copy-Item -LiteralPath $bundleZip -Destination $unsafeZip + $unsafeArchive = [IO.Compression.ZipFile]::Open($unsafeZip, [IO.Compression.ZipArchiveMode]::Update) + try { [void]$unsafeArchive.CreateEntry('..\escape.txt') } finally { $unsafeArchive.Dispose() } + $script:BundleZip = $unsafeZip + $script:BundleDigest = (Get-FileHash -LiteralPath $unsafeZip -Algorithm SHA256).Hash.ToLowerInvariant() + $unsafeRejected = $false + try { Install-NeuroAPICodex -StateRoot $installRoot } catch { $unsafeRejected = $true } + Assert-True ($unsafeRejected -and -not (Test-Path -LiteralPath (Join-Path $tempRoot 'escape.txt'))) 'Backslash traversal ZIP entry was accepted.' + Assert-True ((Get-Content -LiteralPath $nativePath -Raw) -ceq $beforeUpdate) 'Unsafe ZIP changed the installed Codex bundle.' + $script:BundleZip = $bundleZip $script:BundleDigest = ('0' * 64) $updateRejected = $false try { Install-NeuroAPICodex -StateRoot $installRoot } catch { $updateRejected = $true } diff --git a/tests/windows/managed-catalog.ps1 b/tests/windows/managed-catalog.ps1 index 5dc3e42..aae7a33 100644 --- a/tests/windows/managed-catalog.ps1 +++ b/tests/windows/managed-catalog.ps1 @@ -54,6 +54,14 @@ Assert-Catalog (-not $claude.env.Contains('UNSAFE_ENV')) 'Unreviewed environment Assert-Catalog (-not $claude.env.Contains('ANTHROPIC_DEFAULT_FABLE_MODEL')) 'Missing Fable was invented.' Assert-Catalog ($claude.availableModels.Count -eq 5 -and $claude.modelPicker.options.Count -eq 3) 'Compatibility aliases must remain available but hidden.' Assert-Catalog ($claude.fallbackModel.Count -eq 0) 'Inherited fallback was not disabled.' +$sonnetBackground = New-TestClaudeCatalog | ConvertFrom-Json +$sonnetBackground.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = 'claude-sonnet-5' +$sonnetSettings = ConvertFrom-NeuroAPICatalog claude ($sonnetBackground | ConvertTo-Json -Depth 8 -Compress) 'local-helper' +Assert-Catalog ($sonnetSettings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL -ceq 'claude-sonnet-5') 'Recommended Sonnet background fallback was rejected.' +$opusBackground = New-TestClaudeCatalog | ConvertFrom-Json +$opusBackground.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = 'claude-opus-5-5' +$opusSettings = ConvertFrom-NeuroAPICatalog claude ($opusBackground | ConvertTo-Json -Depth 8 -Compress) 'local-helper' +Assert-Catalog ($opusSettings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL -ceq 'claude-opus-5-5') 'Recommended default background fallback was rejected.' foreach ($bad in @('{', '{}', '{"models":[],"default_model":"model-one"}', '{"models":[{"slug":"model-one","display_name":"one"}],"default_model":"missing"}', '{"models":[{"slug":"bad model","display_name":"one"}],"default_model":"bad model"}')) { Assert-CatalogFailure { ConvertFrom-NeuroAPICatalog codex $bad '' } } @@ -80,7 +88,9 @@ foreach ($mutation in @( { param($x) $x.model = 'claude-opus-5' }, { param($x) $x.fallbackModel = @('not-allowed') }, { param($x) $x.modelPicker.options[0].model = 'not-allowed' }, - { param($x) $x.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = 'claude-sonnet-5' }, + { param($x) $x.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = 'claude-opus-5' }, + { param($x) $x.env | Add-Member -NotePropertyName ANTHROPIC_DEFAULT_FABLE_MODEL -NotePropertyValue 'claude-sonnet-5' }, + { param($x) $x.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = 'gpt-6-sol'; $x.availableModels += 'gpt-6-sol'; $x.modelPicker.options += @{ model = 'gpt-6-sol' } }, { param($x) $x.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = '' } )) { $payload = New-TestClaudeCatalog | ConvertFrom-Json From 99d8fdb292ba2e221e725d42f4fb98e4165e82d6 Mon Sep 17 00:00:00 2001 From: Roman Date: Tue, 29 Sep 2026 07:41:54 +0800 Subject: [PATCH 07/11] fix(agents): preserve Windows credential on failed update --- scripts/windows/setup.ps1 | 148 ++++++++++++++++++++++++++++++-------- tests/windows/smoke.ps1 | 37 ++++++++++ 2 files changed, 155 insertions(+), 30 deletions(-) diff --git a/scripts/windows/setup.ps1 b/scripts/windows/setup.ps1 index 0704ce6..6dce35f 100644 --- a/scripts/windows/setup.ps1 +++ b/scripts/windows/setup.ps1 @@ -50,7 +50,8 @@ if ((Test-Path -LiteralPath $profilePath -PathType Leaf) -and } $secureKey = if ($TestMode) { - ConvertTo-SecureString -String 'test-neuroapi-token' -AsPlainText -Force + $testToken = if ([string]::IsNullOrEmpty($env:NEUROAPI_AGENTS_TEST_TOKEN)) { 'test-neuroapi-token' } else { $env:NEUROAPI_AGENTS_TEST_TOKEN } + ConvertTo-SecureString -String $testToken -AsPlainText -Force } else { Write-Host '' Write-Host 'NeuroAPI will store the key with Windows DPAPI for this user on this computer.' @@ -89,17 +90,28 @@ Ensure-Directory -Path $configRoot Ensure-Directory -Path $secretRoot Ensure-Directory -Path $CodexHome -$ciphertext = ConvertFrom-SecureString -SecureString $secureKey -Write-Utf8NoBom -Path $secretPath -Content $ciphertext -$secureKey.Clear() +$transactionId = [Guid]::NewGuid().ToString('N') +$stageRoot = Join-Path $StateRoot ('setup-stage-' + $transactionId) +$promoted = New-Object System.Collections.ArrayList +$pendingFiles = New-Object System.Collections.ArrayList +$backupFiles = New-Object System.Collections.ArrayList +$rollbackFailed = $false +try { + Ensure-Directory -Path $stageRoot + Set-NeuroAPIPrivateDirectory -Path $stageRoot + Ensure-Directory -Path (Join-Path $stageRoot 'bin') + $ciphertext = ConvertFrom-SecureString -SecureString $secureKey + Write-Utf8NoBom -Path (Join-Path $stageRoot 'api-key.dpapi') -Content $ciphertext + $ciphertext = $null + $secureKey.Clear() -foreach ($scriptName in @('get-neuroapi-key.ps1', 'common.ps1', 'managed-catalog.ps1', 'launch-neuroapi.ps1')) { - Copy-Item -LiteralPath (Join-Path $PSScriptRoot $scriptName) -Destination (Join-Path $binRoot $scriptName) -Force -} + foreach ($scriptName in @('get-neuroapi-key.ps1', 'common.ps1', 'managed-catalog.ps1', 'launch-neuroapi.ps1')) { + Copy-Item -LiteralPath (Join-Path $PSScriptRoot $scriptName) -Destination (Join-Path (Join-Path $stageRoot 'bin') $scriptName) + } -$tomlHelperPath = ConvertTo-TomlBasicString -Value $helperPath -$tomlSecretPath = ConvertTo-TomlBasicString -Value $secretPath -$profile = @" + $tomlHelperPath = ConvertTo-TomlBasicString -Value $helperPath + $tomlSecretPath = ConvertTo-TomlBasicString -Value $secretPath + $profile = @" # Managed by the NeuroAPI Agents installer. model_provider = "neuroapi" web_search = "disabled" @@ -123,41 +135,117 @@ args = ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass" timeout_ms = 5000 refresh_interval_ms = 300000 "@ -Write-Utf8NoBom -Path $profilePath -Content $profile -Write-OwnerMarker -Path $profileMarkerPath - -$helperCommand = 'powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "' + - $helperPath + '" -SecretPath "' + $secretPath + '"' -$claudeSettings = [ordered]@{ - '$schema' = 'https://json.schemastore.org/claude-code-settings.json' - apiKeyHelper = $helperCommand - env = [ordered]@{ - ANTHROPIC_BASE_URL = 'https://neuroapi.host/v1/claude-code' - } -} | ConvertTo-Json -Depth 5 -Write-Utf8NoBom -Path $claudeSettingsPath -Content $claudeSettings + Write-Utf8NoBom -Path (Join-Path $stageRoot 'profile.toml') -Content $profile + Write-OwnerMarker -Path (Join-Path $stageRoot 'profile-marker') -$codexLauncher = @" + $helperCommand = 'powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "' + + $helperPath + '" -SecretPath "' + $secretPath + '"' + $claudeSettings = [ordered]@{ + '$schema' = 'https://json.schemastore.org/claude-code-settings.json' + apiKeyHelper = $helperCommand + env = [ordered]@{ + ANTHROPIC_BASE_URL = 'https://neuroapi.host/v1/claude-code' + } + } | ConvertTo-Json -Depth 5 + Write-Utf8NoBom -Path (Join-Path $stageRoot 'claude-settings.json') -Content $claudeSettings + + $codexLauncher = @" @echo off setlocal set "PSModulePath=" powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0launch-neuroapi.ps1" codex %* exit /b %errorlevel% "@ -$claudeLauncher = @" + $claudeLauncher = @" @echo off setlocal set "PSModulePath=" powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0launch-neuroapi.ps1" claude %* exit /b %errorlevel% "@ -Write-Utf8NoBom -Path ([System.IO.Path]::Combine($binRoot, 'codex-neuroapi.cmd')) -Content $codexLauncher -Write-Utf8NoBom -Path ([System.IO.Path]::Combine($binRoot, 'claude-neuroapi.cmd')) -Content $claudeLauncher + Write-Utf8NoBom -Path (Join-Path $stageRoot 'codex-neuroapi.cmd') -Content $codexLauncher + Write-Utf8NoBom -Path (Join-Path $stageRoot 'claude-neuroapi.cmd') -Content $claudeLauncher + + # The credential is promoted last. Every file replacement keeps an + # encrypted/installer-owned backup until the entire update succeeds. + $files = @( + [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/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' }, + [pscustomobject]@{ Source = (Join-Path $stageRoot 'claude-settings.json'); Target = $claudeSettingsPath; Name = 'claude-settings.json' }, + [pscustomobject]@{ Source = (Join-Path $stageRoot 'codex-neuroapi.cmd'); Target = (Join-Path $binRoot 'codex-neuroapi.cmd'); Name = 'codex-neuroapi.cmd' }, + [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' } + ) + foreach ($file in $files) { + $target = $file.Target + $pending = $target + '.new.' + $transactionId + $backup = $target + '.backup.' + $transactionId + if ((Test-Path -LiteralPath $pending) -or (Test-Path -LiteralPath $backup)) { throw 'Setup transaction path is occupied.' } + $existed = Test-Path -LiteralPath $target + if ($existed) { + $item = Get-Item -LiteralPath $target -Force + if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { + throw 'Refusing to replace a directory or linked setup file.' + } + } + Copy-Item -LiteralPath $file.Source -Destination $pending -ErrorAction Stop + [void]$pendingFiles.Add($pending) + if ($existed) { + [IO.File]::Replace($pending, $target, $backup) + [void]$backupFiles.Add($backup) + } else { + [IO.File]::Move($pending, $target) + } + [void]$promoted.Add([pscustomobject]@{ Target = $target; Backup = $backup; Existed = $existed }) + if ($TestMode -and $env:NEUROAPI_AGENTS_TEST_FAIL_AFTER -ceq $file.Name) { + throw 'Simulated setup transaction failure.' + } + } +} catch { + $originalError = $_ + for ($i = $promoted.Count - 1; $i -ge 0; $i--) { + $entry = $promoted[$i] + try { + if ($entry.Existed) { + if (Test-Path -LiteralPath $entry.Target) { + [IO.File]::Replace($entry.Backup, $entry.Target, $null) + } else { + [IO.File]::Move($entry.Backup, $entry.Target) + } + } elseif (Test-Path -LiteralPath $entry.Target) { + [IO.File]::Delete($entry.Target) + } + } catch { + $rollbackFailed = $true + } + } + if ($rollbackFailed) { throw 'Не удалось полностью восстановить прежнюю настройку NeuroAPI; сохраните резервные DPAPI-файлы и обратитесь в поддержку.' } + throw $originalError +} finally { + $secureKey.Clear() + foreach ($pending in $pendingFiles) { + if (Test-Path -LiteralPath $pending) { Remove-Item -LiteralPath $pending -Force -ErrorAction SilentlyContinue } + } + if (-not $rollbackFailed) { + foreach ($backup in $backupFiles) { + if (Test-Path -LiteralPath $backup) { Remove-Item -LiteralPath $backup -Force -ErrorAction SilentlyContinue } + } + } + if (Test-Path -LiteralPath $stageRoot) { Remove-Item -LiteralPath $stageRoot -Recurse -Force -ErrorAction SilentlyContinue } +} if (-not $TestMode -and -not $NoPathUpdate) { - Add-OwnedUserPathEntry -Entry $binRoot -StateRoot $StateRoot - if ($installedClients -contains 'claude') { - Add-OwnedUserPathEntry -Entry (Join-Path $env:USERPROFILE '.local/bin') -StateRoot $StateRoot + try { + Add-OwnedUserPathEntry -Entry $binRoot -StateRoot $StateRoot + if ($installedClients -contains 'claude') { + Add-OwnedUserPathEntry -Entry (Join-Path $env:USERPROFILE '.local/bin') -StateRoot $StateRoot + } + } catch { + Write-Warning 'Клиенты настроены, но PATH не обновлён. Запускайте команды по полному пути, указанному ниже.' } } diff --git a/tests/windows/smoke.ps1 b/tests/windows/smoke.ps1 index b9b0e6c..a0cda4c 100644 --- a/tests/windows/smoke.ps1 +++ b/tests/windows/smoke.ps1 @@ -117,6 +117,43 @@ try { Assert-True ((Get-Content -LiteralPath $profilePath -Raw) -ceq $profileBefore) 'Reinstall changed the generated Codex profile.' + # A failed key rotation must restore every installer-owned file, including + # the exact previous DPAPI ciphertext, even after the new key was written. + $tracked = @($secretPath, $profilePath, $profileMarker, $settingsPath) + foreach ($file in @('get-neuroapi-key.ps1', 'common.ps1', 'managed-catalog.ps1', 'launch-neuroapi.ps1', + 'codex-neuroapi.cmd', 'claude-neuroapi.cmd')) { + $tracked += (Join-Path (Join-Path $stateRoot 'bin') $file) + } + $beforeHashes = @{} + foreach ($path in $tracked) { $beforeHashes[$path] = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash } + $env:NEUROAPI_AGENTS_TEST_TOKEN = 'rotated-test-token' + foreach ($failurePoint in @('claude-settings.json', 'api-key.dpapi')) { + $env:NEUROAPI_AGENTS_TEST_FAIL_AFTER = $failurePoint + $rotationFailed = $false + try { + & "$repoRoot\scripts\windows\setup.ps1" ` + -TestMode -StateRoot $stateRoot -CodexHome $codexHome -NoPathUpdate | Out-Null + } catch { + $rotationFailed = $_.Exception.Message -match 'Simulated setup transaction failure' + } + Assert-True $rotationFailed "Injected failure after $failurePoint was ignored." + foreach ($path in $tracked) { + Assert-True ((Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash -ceq $beforeHashes[$path]) "Failed rotation changed $path." + } + $restoredKey = & powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $helperPath -SecretPath $secretPath + Assert-True ($restoredKey -ceq 'test-neuroapi-token') 'Failed rotation lost the previous DPAPI key.' + $residue = @(Get-ChildItem -LiteralPath $stateRoot, $codexHome -Recurse -Force | Where-Object { + $_.Name -match '^(setup-stage-)|\.(new|backup)\.[a-f0-9]{32}$' + }) + Assert-True ($residue.Count -eq 0) 'Failed rotation left stage or backup files.' + } + $env:NEUROAPI_AGENTS_TEST_FAIL_AFTER = $null + & "$repoRoot\scripts\windows\setup.ps1" ` + -TestMode -StateRoot $stateRoot -CodexHome $codexHome -NoPathUpdate | Out-Null + $rotatedKey = & powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $helperPath -SecretPath $secretPath + Assert-True ($rotatedKey -ceq 'rotated-test-token') 'Successful retry did not install the replacement key.' + $env:NEUROAPI_AGENTS_TEST_TOKEN = $null + & "$repoRoot\scripts\windows\uninstall.ps1" ` -TestMode ` -StateRoot $stateRoot ` From bc63279434c625e40fa28be7dcf804b3d2a1785b Mon Sep 17 00:00:00 2001 From: Roman Date: Tue, 29 Sep 2026 07:43:21 +0800 Subject: [PATCH 08/11] fix(agents): preserve Windows PowerShell encoding and profile test --- scripts/windows/setup.ps1 | 2 +- tests/static/windows-profile.ps1 | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/windows/setup.ps1 b/scripts/windows/setup.ps1 index 6dce35f..f792bf0 100644 --- a/scripts/windows/setup.ps1 +++ b/scripts/windows/setup.ps1 @@ -1,4 +1,4 @@ -[CmdletBinding()] +[CmdletBinding()] param( [switch]$TestMode, [string]$StateRoot, diff --git a/tests/static/windows-profile.ps1 b/tests/static/windows-profile.ps1 index 4d89c1b..2ff281a 100644 --- a/tests/static/windows-profile.ps1 +++ b/tests/static/windows-profile.ps1 @@ -15,7 +15,7 @@ if ((Resolve-CodexConfigRoot -ConfiguredRoot '' -UserRoot 'C:\User') -cne $expec # Render only the data template. Never execute setup, a helper or DPAPI here. $source = Get-Content -LiteralPath (Join-Path $repoRoot 'scripts/windows/setup.ps1') -Raw -$match = [regex]::Match($source, '(?ms)^\$profile = @"\r?\n(.*?)^"@') +$match = [regex]::Match($source, '(?ms)^[ \t]*\$profile = @"\r?\n(.*?)^"@') if (-not $match.Success) { throw 'Windows profile template was not found.' } $helperPath = 'C:\Test user\NeuroAPI\bin\get-neuroapi-key.ps1' $secretPath = 'C:\Test user\NeuroAPI\secret\api-key.dpapi' From 78b3e68cb49334d92e55fc77dc761ae6affb7659 Mon Sep 17 00:00:00 2001 From: Roman Date: Tue, 29 Sep 2026 07:44:57 +0800 Subject: [PATCH 09/11] test(agents): expose rollback failure cause in Windows smoke --- tests/windows/smoke.ps1 | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/windows/smoke.ps1 b/tests/windows/smoke.ps1 index a0cda4c..692c0c9 100644 --- a/tests/windows/smoke.ps1 +++ b/tests/windows/smoke.ps1 @@ -130,13 +130,15 @@ try { foreach ($failurePoint in @('claude-settings.json', 'api-key.dpapi')) { $env:NEUROAPI_AGENTS_TEST_FAIL_AFTER = $failurePoint $rotationFailed = $false + $rotationError = '' try { & "$repoRoot\scripts\windows\setup.ps1" ` -TestMode -StateRoot $stateRoot -CodexHome $codexHome -NoPathUpdate | Out-Null } catch { - $rotationFailed = $_.Exception.Message -match 'Simulated setup transaction failure' + $rotationError = $_.Exception.Message + $rotationFailed = $rotationError -match 'Simulated setup transaction failure' } - Assert-True $rotationFailed "Injected failure after $failurePoint was ignored." + Assert-True $rotationFailed "Injected failure after $failurePoint did not restore cleanly: $rotationError" foreach ($path in $tracked) { Assert-True ((Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash -ceq $beforeHashes[$path]) "Failed rotation changed $path." } From a208093739dc5156eb67c35b126a63e36accb7b7 Mon Sep 17 00:00:00 2001 From: Roman Date: Tue, 29 Sep 2026 07:46:26 +0800 Subject: [PATCH 10/11] test(agents): diagnose Windows rollback failure --- scripts/windows/setup.ps1 | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/windows/setup.ps1 b/scripts/windows/setup.ps1 index f792bf0..2950aed 100644 --- a/scripts/windows/setup.ps1 +++ b/scripts/windows/setup.ps1 @@ -96,6 +96,7 @@ $promoted = New-Object System.Collections.ArrayList $pendingFiles = New-Object System.Collections.ArrayList $backupFiles = New-Object System.Collections.ArrayList $rollbackFailed = $false +$rollbackDiagnostics = New-Object System.Collections.ArrayList try { Ensure-Directory -Path $stageRoot Set-NeuroAPIPrivateDirectory -Path $stageRoot @@ -221,9 +222,13 @@ exit /b %errorlevel% } } catch { $rollbackFailed = $true + [void]$rollbackDiagnostics.Add($_.Exception.GetType().Name + ': ' + $_.Exception.Message) } } - if ($rollbackFailed) { throw 'Не удалось полностью восстановить прежнюю настройку NeuroAPI; сохраните резервные DPAPI-файлы и обратитесь в поддержку.' } + if ($rollbackFailed) { + if ($TestMode) { throw ('Setup rollback failed: ' + ($rollbackDiagnostics -join ' | ')) } + throw 'Не удалось полностью восстановить прежнюю настройку NeuroAPI; сохраните резервные DPAPI-файлы и обратитесь в поддержку.' + } throw $originalError } finally { $secureKey.Clear() From 5865adee04b9e42d33d6f8a29791110c37211368 Mon Sep 17 00:00:00 2001 From: Roman Date: Tue, 29 Sep 2026 07:48:05 +0800 Subject: [PATCH 11/11] fix(agents): restore Windows setup backup with valid replace path --- scripts/windows/setup.ps1 | 10 +++++++++- tests/windows/smoke.ps1 | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/scripts/windows/setup.ps1 b/scripts/windows/setup.ps1 index 2950aed..f4de0c9 100644 --- a/scripts/windows/setup.ps1 +++ b/scripts/windows/setup.ps1 @@ -213,7 +213,11 @@ exit /b %errorlevel% try { if ($entry.Existed) { if (Test-Path -LiteralPath $entry.Target) { - [IO.File]::Replace($entry.Backup, $entry.Target, $null) + # Windows PowerShell 5/.NET Framework requires a concrete + # destination backup path here; null is rejected. + $discard = $entry.Target + '.discard.' + $transactionId + [IO.File]::Replace($entry.Backup, $entry.Target, $discard) + [IO.File]::Delete($discard) } else { [IO.File]::Move($entry.Backup, $entry.Target) } @@ -239,6 +243,10 @@ exit /b %errorlevel% foreach ($backup in $backupFiles) { if (Test-Path -LiteralPath $backup) { Remove-Item -LiteralPath $backup -Force -ErrorAction SilentlyContinue } } + foreach ($entry in $promoted) { + $discard = $entry.Target + '.discard.' + $transactionId + if (Test-Path -LiteralPath $discard) { Remove-Item -LiteralPath $discard -Force -ErrorAction SilentlyContinue } + } } if (Test-Path -LiteralPath $stageRoot) { Remove-Item -LiteralPath $stageRoot -Recurse -Force -ErrorAction SilentlyContinue } } diff --git a/tests/windows/smoke.ps1 b/tests/windows/smoke.ps1 index 692c0c9..7da03e6 100644 --- a/tests/windows/smoke.ps1 +++ b/tests/windows/smoke.ps1 @@ -145,7 +145,7 @@ try { $restoredKey = & powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $helperPath -SecretPath $secretPath Assert-True ($restoredKey -ceq 'test-neuroapi-token') 'Failed rotation lost the previous DPAPI key.' $residue = @(Get-ChildItem -LiteralPath $stateRoot, $codexHome -Recurse -Force | Where-Object { - $_.Name -match '^(setup-stage-)|\.(new|backup)\.[a-f0-9]{32}$' + $_.Name -match '^(setup-stage-)|\.(new|backup|discard)\.[a-f0-9]{32}$' }) Assert-True ($residue.Count -eq 0) 'Failed rotation left stage or backup files.' }