diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 560f6a4..512b84e 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -40,6 +40,12 @@ 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 macos: name: macOS Keychain-command smoke 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..49ecb23 100644 --- a/README.en.md +++ b/README.en.md @@ -1,12 +1,12 @@ # 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) ## 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. 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,9 @@ 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 { @@ -86,7 +95,9 @@ 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`. Список зависит от опубликованных моделей, совместимого маршрута и прав ключа. Сохранённая администратором настройка каталога имеет приоритет над этим порядком. + +`ANTHROPIC_DEFAULT_HAIKU_MODEL` используется Claude Code также для фоновых запросов. При отсутствии рекомендованной Haiku сервер назначает доступную модель Sonnet либо основную модель из меню; установщик принимает такое назначение только внутри опубликованного allowlist и рекомендованного меню. Списание идёт по фактической модели и может быть выше стоимости Haiku. Специальный ключ не нужен. Можно подключаться вручную через обычный API: сервер распознаёт известные заголовки Codex/Claude на `/v1/models`. Однако клиент должен сам запросить каталог, а встроенные варианты могут сохраниться. Управляемые launchers — дополнительный способ получить заданное меню, а не условие доступа к API. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index ef2cedf..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`. @@ -15,6 +17,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 должен отказать, а не перезаписать его. @@ -57,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/macos/catalog-validator.js b/scripts/macos/catalog-validator.js index 83cc3b4..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) { @@ -116,6 +123,9 @@ function run(args) { }) data.env.ANTHROPIC_BASE_URL = 'https://neuroapi.host/v1/claude-code' data.env.ANTHROPIC_MODEL = data.model + // Keep first-request quota reservations bounded. Claude Code can continue + // generation in another turn when a response reaches this limit. + data.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = '4096' data.apiKeyHelper = "'" + args[2].replace(/'/g, "'\\''") + "'" write(args[1] + '/settings.json', JSON.stringify(data)) } diff --git a/scripts/macos/common.sh b/scripts/macos/common.sh index aca5366..65a504f 100755 --- a/scripts/macos/common.sh +++ b/scripts/macos/common.sh @@ -46,6 +46,50 @@ launcher_root() { fi } +resolve_client_bin() { + local client="$1" + shift + local resolved native + resolved="$(command -v "$client" 2>/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 39bb1dc..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,25 +146,36 @@ 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" -chmod 700 "$LAUNCHER_ROOT/codex-neuroapi" "$LAUNCHER_ROOT/claude-neuroapi" -write_marker "$(launcher_marker_path 'codex-neuroapi')" -write_marker "$(launcher_marker_path 'claude-neuroapi')" +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 + +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" @@ -110,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..937d525 100644 --- a/scripts/windows/common.ps1 +++ b/scripts/windows/common.ps1 @@ -1,9 +1,10 @@ -Set-StrictMode -Version Latest +Set-StrictMode -Version Latest $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,265 @@ 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 27b2e2c..f4de0c9 100644 --- a/scripts/windows/setup.ps1 +++ b/scripts/windows/setup.ps1 @@ -1,4 +1,4 @@ -[CmdletBinding()] +[CmdletBinding()] param( [switch]$TestMode, [string]$StateRoot, @@ -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.' @@ -44,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.' @@ -54,6 +61,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 +74,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) @@ -69,19 +90,39 @@ 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 +$rollbackDiagnostics = New-Object System.Collections.ArrayList +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" + +# These Codex-hosted tools are not part of the NeuroAPI Responses contract. +[features] +multi_agent = false +goals = false +apps = false +browser_use = false [model_providers.neuroapi] name = "NeuroAPI" @@ -95,39 +136,130 @@ 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) { + # 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) + } + } elseif (Test-Path -LiteralPath $entry.Target) { + [IO.File]::Delete($entry.Target) + } + } catch { + $rollbackFailed = $true + [void]$rollbackDiagnostics.Add($_.Exception.GetType().Name + ': ' + $_.Exception.Message) + } + } + if ($rollbackFailed) { + if ($TestMode) { throw ('Setup rollback failed: ' + ($rollbackDiagnostics -join ' | ')) } + 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 } + } + 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 } +} if (-not $TestMode -and -not $NoPathUpdate) { - Add-UserPathEntry -Entry $binRoot + 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 не обновлён. Запускайте команды по полному пути, указанному ниже.' + } } Write-Host '' @@ -135,9 +267,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..28d913a 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,11 +142,31 @@ 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"] 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() @@ -160,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() @@ -178,8 +203,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/static/profile_contract.py b/tests/static/profile_contract.py index 56bbda6..79c8868 100644 --- a/tests/static/profile_contract.py +++ b/tests/static/profile_contract.py @@ -8,6 +8,13 @@ profile = tomllib.loads(raw) assert "model" not in profile # The authenticated startup catalog supplies it. assert profile["model_provider"] == "neuroapi" +assert profile["web_search"] == "disabled" +assert profile["features"] == { + "multi_agent": False, + "goals": False, + "apps": False, + "browser_use": False, +} provider = profile["model_providers"]["neuroapi"] assert provider["base_url"] == "https://neuroapi.host/v1/codex" assert provider["wire_api"] == "responses" diff --git a/tests/static/validate.ps1 b/tests/static/validate.ps1 index 0d44009..e646350 100644 --- a/tests/static/validate.ps1 +++ b/tests/static/validate.ps1 @@ -89,6 +89,10 @@ foreach ($setup in @($windowsSetup, $macSetup)) { 'Codex must use the profile-specific API and catalog.' Assert-Contains $setup 'supports_websockets = true' ` 'Codex must enable the supported Responses WebSocket transport.' + Assert-Contains $setup 'web_search = "disabled"' ` + 'Codex must omit unsupported hosted web search from ordinary requests.' + Assert-Contains $setup 'multi_agent = false' ` + 'Codex must omit unsupported namespace tools from ordinary requests.' Assert-Contains $setup '[model_providers.neuroapi.auth]' ` 'Codex must retain command-backed authentication.' Assert-NotMatches $setup '(?i)experimental_bearer_token|env_key|http_headers' ` 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' diff --git a/tests/windows/first-run.ps1 b/tests/windows/first-run.ps1 new file mode 100644 index 0000000..72c9a9e --- /dev/null +++ b/tests/windows/first-run.ps1 @@ -0,0 +1,172 @@ +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) 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.' + $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 + $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 } + 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..aae7a33 100644 --- a/tests/windows/managed-catalog.ps1 +++ b/tests/windows/managed-catalog.ps1 @@ -49,10 +49,19 @@ 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.' 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 '' } } @@ -79,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 @@ -165,11 +176,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 +192,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')) 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 + } +} diff --git a/tests/windows/smoke.ps1 b/tests/windows/smoke.ps1 index b9b0e6c..7da03e6 100644 --- a/tests/windows/smoke.ps1 +++ b/tests/windows/smoke.ps1 @@ -117,6 +117,45 @@ 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 + $rotationError = '' + try { + & "$repoRoot\scripts\windows\setup.ps1" ` + -TestMode -StateRoot $stateRoot -CodexHome $codexHome -NoPathUpdate | Out-Null + } catch { + $rotationError = $_.Exception.Message + $rotationFailed = $rotationError -match 'Simulated setup transaction failure' + } + 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." + } + $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|discard)\.[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 `