From d497040b8f5febd5cd61179fa8a88810199a0707 Mon Sep 17 00:00:00 2001 From: Roman Date: Sat, 26 Sep 2026 19:44:43 +0800 Subject: [PATCH 1/3] feat(codex): install dedicated API profile with WebSocket support --- .github/workflows/validate.yml | 3 +++ AGENTS.md | 2 ++ README.en.md | 10 +++++++- README.md | 10 +++++++- docs/manual-setup.md | 13 +++++++++-- docs/troubleshooting.md | 15 ++++++++++-- scripts/macos/common.sh | 12 +++++++++- scripts/macos/install.sh | 7 +++--- scripts/windows/common.ps1 | 17 +++++++++++--- scripts/windows/setup.ps1 | 3 ++- tests/macos/smoke.sh | 35 +++++++++++++++++++++++----- tests/static/README.md | 14 ++++++++++++ tests/static/profile_contract.py | 34 ++++++++++++++++++++++++++++ tests/static/validate.ps1 | 21 +++++++++++++++-- tests/static/windows-profile.ps1 | 39 ++++++++++++++++++++++++++++++++ tests/windows/smoke.ps1 | 12 ++++++++-- 16 files changed, 223 insertions(+), 24 deletions(-) create mode 100644 tests/static/profile_contract.py create mode 100644 tests/static/windows-profile.ps1 diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index d86d265..560f6a4 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -22,6 +22,9 @@ jobs: - name: Validate repository contract shell: pwsh run: ./tests/static/validate.ps1 + - name: Validate Windows profile template without credentials + shell: pwsh + run: ./tests/static/windows-profile.ps1 - name: ShellCheck run: | shellcheck setup-macos.command uninstall-macos.command scripts/macos/*.sh tests/macos/*.sh diff --git a/AGENTS.md b/AGENTS.md index a735d48..b1b11a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,3 +6,5 @@ - Do not overwrite unrelated Codex, Claude Code, shell, or PATH configuration. Update and uninstall only files marked as installer-owned. - Keep Windows PowerShell 5.1 compatibility and macOS system Bash compatibility. - 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. diff --git a/README.en.md b/README.en.md index ca58a55..10eab3c 100644 --- a/README.en.md +++ b/README.en.md @@ -4,6 +4,14 @@ Public, auditable one-click setup for routing local Codex CLI and Claude Code se [Русская версия](README.md) +## Release compatibility + +This version configures `https://neuroapi.host/v1/codex` with `supports_websockets = true`. Publish or distribute it **only after the server profile is deployed** and authenticated `/v1/codex/models` plus HTTP/WebSocket `/v1/codex/responses` checks pass. Local implementation is not production evidence. Setup deliberately does not call the API to validate credentials. + +An existing `CODEX_HOME` selects the profile directory without being modified. Keep its value consistent for setup, launch and uninstall. + +Use a current Codex release whose `--help` describes `--profile` as loading `.config.toml`. Update older clients that expect `[profiles.name]` in the main configuration. For WebSocket troubleshooting, temporarily set `supports_websockets = false` in the generated profile, keeping `/v1/codex` and its credential helper. + ## Quick start Install [Codex CLI](https://developers.openai.com/codex/cli/) and/or [Claude Code](https://code.claude.com/docs/en/installation), then create a key in the [NeuroAPI dashboard](https://neuroapi.host/login?redirect=/dashboard/tokens). @@ -37,7 +45,7 @@ This prevents accidental plaintext disclosure. It does not protect a key from ma ## Verification -- In `codex-neuroapi`, run `/debug-config` and confirm the `neuroapi-host` profile and `https://neuroapi.host/v1`. +- In `codex-neuroapi`, run `/debug-config` and confirm the `neuroapi-host` profile and `https://neuroapi.host/v1/codex`. - In `claude-neuroapi`, run `/status` and confirm `https://neuroapi.host` plus `apiKeyHelper`. - Check current model IDs and pricing at [neuroapi.host/price](https://neuroapi.host/price). diff --git a/README.md b/README.md index 193a587..5c598fe 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,14 @@ NeuroAPI — российский AI API-сервис: единый доступ [English version](README.en.md) +## Совместимость версии + +Эта версия создаёт профиль Codex с `https://neuroapi.host/v1/codex` и `supports_websockets = true`. Выпускайте и распространяйте установщик **только после публикации серверного профиля**: должны работать авторизованный `GET /v1/codex/models` в формате Codex и `/v1/codex/responses` по HTTP и WebSocket. Подготовленный код не доказывает доступность этих адресов в production; setup намеренно не вызывает API для проверки ключа. + +Уже заданный `CODEX_HOME` учитывается для профиля и не изменяется; сохраняйте одинаковое значение при установке, запуске и удалении. + +Нужен актуальный Codex с отдельными profile-файлами: `codex --help` должен описывать `--profile` как загрузку `.config.toml`. Старые версии с `[profiles.name]` в общем конфиге обновите перед установкой. Для диагностики WebSocket можно временно поставить `supports_websockets = false` в созданном профиле, сохранив `/v1/codex` и credential helper. Подробнее — [решение проблем](docs/troubleshooting.md). + ## Установка в один запуск Сначала установите сам [Codex CLI](https://developers.openai.com/codex/cli/) и/или [Claude Code](https://code.claude.com/docs/en/installation), затем создайте API-ключ в [кабинете NeuroAPI](https://neuroapi.host/login?redirect=/dashboard/tokens). @@ -95,7 +103,7 @@ Codex CLI: 1. Запустите `codex-neuroapi`. 2. Выполните `/debug-config`. -3. Проверьте профиль `neuroapi-host`, provider `neuroapi` и `https://neuroapi.host/v1`. +3. Проверьте профиль `neuroapi-host`, provider `neuroapi` и `https://neuroapi.host/v1/codex`. Claude Code: diff --git a/docs/manual-setup.md b/docs/manual-setup.md index edb9307..6e48f39 100644 --- a/docs/manual-setup.md +++ b/docs/manual-setup.md @@ -4,11 +4,13 @@ ## Codex CLI -Создаётся отдельный user-level profile: +Нужен актуальный Codex с profile-v2: `codex --help` описывает `--profile` как загрузку `.config.toml`. Создаётся отдельный user-level profile: - Windows: `%USERPROFILE%\.codex\neuroapi-host.config.toml`; - macOS: `~/.codex/neuroapi-host.config.toml`. +Если `CODEX_HOME` уже задан, обе платформы используют `/neuroapi-host.config.toml` вместо стандартного пути. Setup не изменяет `CODEX_HOME`; используйте одинаковое значение при установке, запуске и удалении. Доступ к ключу по-прежнему идёт через прежний helper. + Основная конфигурация: ```toml @@ -17,8 +19,9 @@ model_provider = "neuroapi" [model_providers.neuroapi] name = "NeuroAPI" -base_url = "https://neuroapi.host/v1" +base_url = "https://neuroapi.host/v1/codex" wire_api = "responses" +supports_websockets = true [model_providers.neuroapi.auth] command = "/absolute/path/to/installer-owned-helper" @@ -32,6 +35,12 @@ refresh_interval_ms = 300000 Project `.codex/config.toml` не подходит для provider/auth redirect: актуальный Codex игнорирует там `model_provider` и `model_providers` по соображениям безопасности. +## HTTP/SSE для диагностики + +Если соединение WebSocket блокируется вашей сетью, в секции `[model_providers.neuroapi]` созданного профиля замените `supports_websockets = true` на `supports_websockets = false`. Base URL остаётся `https://neuroapi.host/v1/codex`, auth helper и key storage не меняются. При повторном запуске setup управляемый профиль снова получит настройку по умолчанию `true`. + +Если сервер ещё не предоставляет `/v1/codex`, не распространяйте эту версию установщика: сначала требуется согласованный серверный выпуск. Возврат к общему `/v1` не решает несовпадение формата каталога при command-backed auth. + ## Claude Code Существующий `~/.claude/settings.json` не меняется. Launcher передаёт отдельный installer-owned JSON через `claude --settings `. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index ef02659..29f03bd 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -12,11 +12,22 @@ macOS: используйте полный путь `~/.local/bin/codex-neuroapi - profile `neuroapi-host`; - provider `neuroapi`; -- base URL `https://neuroapi.host/v1`; -- `wire_api = "responses"`. +- base URL `https://neuroapi.host/v1/codex`; +- `wire_api = "responses"`; +- `supports_websockets = true` (или `false` для диагностики HTTP/SSE). Если `~/.codex/neuroapi-host.config.toml` существовал до установки без ownership-marker, setup должен отказать, а не перезаписать его. +## WebSocket не подключается + +В `[model_providers.neuroapi]` файла `neuroapi-host.config.toml` временно установите `supports_websockets = false`, оставив base URL `/v1/codex`. Перезапустите `codex-neuroapi` и проверьте короткий запрос по HTTP/SSE. Не переносите ключ в TOML и не меняйте helper. Повторная установка восстановит `true`. + +`404` на `/v1/codex/models` или `/v1/codex/responses` может означать, что серверное обновление ещё не опубликовано. Эта версия установщика должна распространяться только после проверки обоих адресов и WebSocket на сервере. Один fallback на HTTP не создаёт отсутствующий endpoint. + +## Codex сообщает, что профиль не найден + +Обновите Codex CLI. В `codex --help` описание `--profile` должно указывать на отдельный `.config.toml`, а не на старую таблицу `[profiles.name]`. Проверьте профиль и ownership-marker в путях из [ручной настройки](manual-setup.md). Если у вас задан `CODEX_HOME`, установщик и uninstaller используют его для Codex-профиля, не меняя переменную. Запускайте setup, launcher и uninstall с одинаковым значением: изменение переменной не переносит ранее установленный профиль. + ## Claude Code использует другой URL или credential Запустите именно `claude-neuroapi`, затем `/status`. Launcher передаёт isolated settings через `--settings`, имеющий приоритет над user/project settings для совпадающих ключей. diff --git a/scripts/macos/common.sh b/scripts/macos/common.sh index d46b04e..aca5366 100755 --- a/scripts/macos/common.sh +++ b/scripts/macos/common.sh @@ -20,11 +20,21 @@ state_root() { fi } +resolve_codex_config_root() { + local configured_root="$1" + local user_root="$2" + if [[ -n "$configured_root" ]]; then + printf '%s\n' "$configured_root" + else + printf '%s\n' "$user_root/.codex" + fi +} + codex_home() { if is_test_mode && [[ -n "${NEUROAPI_AGENTS_CODEX_HOME:-}" ]]; then printf '%s\n' "$NEUROAPI_AGENTS_CODEX_HOME" else - printf '%s\n' "$HOME/.codex" + resolve_codex_config_root "${CODEX_HOME:-}" "$HOME" fi } diff --git a/scripts/macos/install.sh b/scripts/macos/install.sh index c51b42f..038b403 100755 --- a/scripts/macos/install.sh +++ b/scripts/macos/install.sh @@ -15,7 +15,7 @@ assert_launcher_owned_or_missing 'codex-neuroapi' assert_launcher_owned_or_missing 'claude-neuroapi' STATE_ROOT="$(state_root)" -CODEX_HOME="$(codex_home)" +PROFILE_CONFIG_ROOT="$(codex_home)" LAUNCHER_ROOT="$(launcher_root)" PROFILE_PATH="$(profile_path)" PROFILE_MARKER_PATH="$(profile_marker_path)" @@ -38,7 +38,7 @@ if "$SECURITY_BIN" find-generic-password \ exit 1 fi -mkdir -p "$STATE_ROOT/bin" "$STATE_ROOT/config" "$CODEX_HOME" "$LAUNCHER_ROOT" +mkdir -p "$STATE_ROOT/bin" "$STATE_ROOT/config" "$PROFILE_CONFIG_ROOT" "$LAUNCHER_ROOT" write_marker "$(state_marker_path)" if ! is_test_mode; then @@ -66,8 +66,9 @@ model_provider = "neuroapi" [model_providers.neuroapi] name = "NeuroAPI" -base_url = "https://neuroapi.host/v1" +base_url = "https://neuroapi.host/v1/codex" wire_api = "responses" +supports_websockets = true [model_providers.neuroapi.auth] command = "$ESCAPED_HELPER_PATH" diff --git a/scripts/windows/common.ps1 b/scripts/windows/common.ps1 index db76eda..4a6d6c9 100644 --- a/scripts/windows/common.ps1 +++ b/scripts/windows/common.ps1 @@ -12,11 +12,22 @@ function Get-DefaultStateRoot { return [System.IO.Path]::Combine($env:LOCALAPPDATA, 'NeuroAPIAgents') } -function Get-DefaultCodexHome { - if ([string]::IsNullOrWhiteSpace($env:USERPROFILE)) { +function Resolve-CodexConfigRoot { + param( + [AllowNull()][string]$ConfiguredRoot, + [AllowNull()][string]$UserRoot + ) + if (-not [string]::IsNullOrWhiteSpace($ConfiguredRoot)) { + return $ConfiguredRoot + } + if ([string]::IsNullOrWhiteSpace($UserRoot)) { throw 'USERPROFILE is not available.' } - return [System.IO.Path]::Combine($env:USERPROFILE, '.codex') + return [System.IO.Path]::Combine($UserRoot, '.codex') +} + +function Get-DefaultCodexHome { + return Resolve-CodexConfigRoot -ConfiguredRoot $env:CODEX_HOME -UserRoot $env:USERPROFILE } function Get-FullPath { diff --git a/scripts/windows/setup.ps1 b/scripts/windows/setup.ps1 index 5afef22..956416e 100644 --- a/scripts/windows/setup.ps1 +++ b/scripts/windows/setup.ps1 @@ -82,8 +82,9 @@ model_provider = "neuroapi" [model_providers.neuroapi] name = "NeuroAPI" -base_url = "https://neuroapi.host/v1" +base_url = "https://neuroapi.host/v1/codex" wire_api = "responses" +supports_websockets = true [model_providers.neuroapi.auth] command = "powershell.exe" diff --git a/tests/macos/smoke.sh b/tests/macos/smoke.sh index 0952a0f..c46401c 100755 --- a/tests/macos/smoke.sh +++ b/tests/macos/smoke.sh @@ -2,7 +2,14 @@ set -euo pipefail REPO_ROOT="$(CDPATH='' cd -- "$(dirname -- "$0")/../.." && pwd)" +PYTHON_BIN="${NEUROAPI_AGENTS_TEST_PYTHON:-python3}" +"$PYTHON_BIN" -c 'import tomllib' TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/neuroapi-agents-test.XXXXXX")" +# Resolve synthetic values through the same pure helper used by setup/uninstall. +# shellcheck source=scripts/macos/common.sh +. "$REPO_ROOT/scripts/macos/common.sh" +[[ "$(resolve_codex_config_root '/tmp/custom profile' '/tmp/user')" == '/tmp/custom profile' ]] +[[ "$(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" @@ -57,8 +64,6 @@ esac EOF chmod 700 "$MOCK_SECURITY" -export HOME="$TMP_ROOT/home" -export USER='neuroapi-test' export NEUROAPI_AGENTS_TEST_MODE=1 export NEUROAPI_AGENTS_STATE_ROOT="$TMP_ROOT/state" export NEUROAPI_AGENTS_CODEX_HOME="$TMP_ROOT/codex" @@ -66,7 +71,7 @@ export NEUROAPI_AGENTS_BIN_ROOT="$TMP_ROOT/bin" export NEUROAPI_AGENTS_SECURITY_BIN="$MOCK_SECURITY" export NEUROAPI_AGENTS_SECURITY_LOG="$SECURITY_LOG" export NEUROAPI_AGENTS_MOCK_KEYCHAIN_STATE="$MOCK_KEYCHAIN_STATE" -mkdir -p "$HOME" "$NEUROAPI_AGENTS_CODEX_HOME" +mkdir -p "$NEUROAPI_AGENTS_CODEX_HOME" printf 'keep\n' >"$NEUROAPI_AGENTS_CODEX_HOME/user-owned.txt" /bin/bash -x "$REPO_ROOT/scripts/macos/install.sh" \ @@ -91,11 +96,29 @@ fi helper_output="$("$NEUROAPI_AGENTS_STATE_ROOT/bin/get-neuroapi-key.sh")" [[ "$helper_output" == 'test-neuroapi-token' ]] -python3 -c 'import json,pathlib,sys; json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))' \ +"$PYTHON_BIN" -c 'import json,pathlib,sys; json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))' \ "$NEUROAPI_AGENTS_STATE_ROOT/config/claude-settings.json" -python3 -c 'import pathlib,sys,tomllib; tomllib.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))' \ - "$NEUROAPI_AGENTS_CODEX_HOME/neuroapi-host.config.toml" +"$PYTHON_BIN" "$REPO_ROOT/tests/static/profile_contract.py" \ + "$NEUROAPI_AGENTS_CODEX_HOME/neuroapi-host.config.toml" macos \ + "$NEUROAPI_AGENTS_STATE_ROOT/bin/get-neuroapi-key.sh" +if grep -R -Fq 'test-neuroapi-token' "$NEUROAPI_AGENTS_CODEX_HOME" "$NEUROAPI_AGENTS_STATE_ROOT" "$NEUROAPI_AGENTS_BIN_ROOT"; then + printf 'Generated configuration or launchers exposed the dummy token.\n' >&2 + exit 1 +fi +mkdir -p "$TMP_ROOT/mock-bin" +cat >"$TMP_ROOT/mock-bin/codex" <<'EOF' +#!/bin/bash +set -euo pipefail +printf '%s\0' "$@" >"$NEUROAPI_AGENTS_TEST_ARGV" +EOF +chmod 700 "$TMP_ROOT/mock-bin/codex" +NEUROAPI_AGENTS_TEST_ARGV="$TMP_ROOT/codex-argv" PATH="$TMP_ROOT/mock-bin:$PATH" \ + "$NEUROAPI_AGENTS_BIN_ROOT/codex-neuroapi" 'prompt with spaces' +"$PYTHON_BIN" -c 'import pathlib,sys; assert pathlib.Path(sys.argv[1]).read_bytes().split(b"\0") == [b"--profile", b"neuroapi-host", b"prompt with spaces", b""]' "$TMP_ROOT/codex-argv" + +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" /bin/bash "$REPO_ROOT/scripts/macos/uninstall.sh" >/dev/null [[ ! -e "$NEUROAPI_AGENTS_STATE_ROOT" ]] diff --git a/tests/static/README.md b/tests/static/README.md index fca40aa..f197bad 100644 --- a/tests/static/README.md +++ b/tests/static/README.md @@ -3,9 +3,23 @@ `validate.ps1` enforces the public installer contract: - required files exist; +- both Codex templates use `/v1/codex`, Responses and WebSocket with command-backed auth; - setup wrappers do not accept or forward key arguments; - Windows uses masked input and DPAPI; - macOS delegates secret entry to Keychain `security -w`; - tracked text contains no common real-secret shapes or remote-download-to-shell installer pattern. Platform smoke tests use only the literal dummy value `test-neuroapi-token`. + +`profile_contract.py` parses the TOML emitted by the isolated platform smoke tests. +It checks the exact endpoint, model, transport and helper arguments; credentials +must not appear in the profile. Both smoke tests also check reinstall stability. +The macOS test uses mock Keychain plus dedicated temporary paths without changing +`HOME` or `CODEX_HOME`. Windows DPAPI and launcher execution require Windows; a +PowerShell parser pass on another OS is not a Windows runtime smoke pass. + +TOML validation requires Python 3.11+. For macOS machines whose `python3` is +older, use `NEUROAPI_AGENTS_TEST_PYTHON=/path/to/python3.12 /bin/bash tests/macos/smoke.sh`. +`windows-profile.ps1` renders the Windows profile template with synthetic paths +and parses it on any OS; it does not execute setup, helpers or DPAPI and does not +replace the native Windows smoke test. diff --git a/tests/static/profile_contract.py b/tests/static/profile_contract.py new file mode 100644 index 0000000..223e418 --- /dev/null +++ b/tests/static/profile_contract.py @@ -0,0 +1,34 @@ +"""Validate generated profiles without invoking Codex or credential helpers.""" +import pathlib +import sys +import tomllib + +profile_path, platform, helper_path, *secret_paths = sys.argv[1:] +raw = pathlib.Path(profile_path).read_text(encoding="utf-8") +profile = tomllib.loads(raw) +assert profile["model"] == "gpt-5.6-sol" +assert profile["model_provider"] == "neuroapi" +provider = profile["model_providers"]["neuroapi"] +assert provider["base_url"] == "https://neuroapi.host/v1/codex" +assert provider["wire_api"] == "responses" +assert provider["supports_websockets"] is True +assert set(provider) == {"name", "base_url", "wire_api", "supports_websockets", "auth"} +assert "test-neuroapi-token" not in raw +assert profile_path.endswith("neuroapi-host.config.toml") +auth = provider["auth"] +assert auth["timeout_ms"] == 5000 +assert auth["refresh_interval_ms"] == 300000 +if platform == "macos": + assert auth["command"] == helper_path + assert set(auth) == {"command", "timeout_ms", "refresh_interval_ms"} +elif platform == "windows": + assert len(secret_paths) == 1 + assert auth["command"] == "powershell.exe" + assert auth["args"] == [ + "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", + "-File", helper_path, "-SecretPath", secret_paths[0], + ] + assert set(auth) == {"command", "args", "timeout_ms", "refresh_interval_ms"} +else: + raise AssertionError("Unknown platform") +print("Generated Codex profile contract passed.") diff --git a/tests/static/validate.ps1 b/tests/static/validate.ps1 index 70d711d..e0afcfc 100644 --- a/tests/static/validate.ps1 +++ b/tests/static/validate.ps1 @@ -47,7 +47,8 @@ $requiredFiles = @( 'scripts/macos/uninstall.sh', 'docs/security.md', 'docs/manual-setup.md', - 'docs/troubleshooting.md' + 'docs/troubleshooting.md', + 'tests/static/profile_contract.py' ) foreach ($file in $requiredFiles) { if (-not (Test-Path -LiteralPath (Join-Path $repoRoot $file) -PathType Leaf)) { @@ -83,6 +84,22 @@ Assert-NotMatches $macSetup '(?i)(api_key|token|secret)=' ` Assert-NotMatches $macSetup '\|\s*"\$SECURITY_BIN"\s+add-generic-password' ` 'macOS setup must not pipe a key into the Keychain command.' +foreach ($setup in @($windowsSetup, $macSetup)) { + Assert-Contains $setup 'base_url = "https://neuroapi.host/v1/codex"' ` + '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 '[model_providers.neuroapi.auth]' ` + 'Codex must retain command-backed authentication.' + Assert-NotMatches $setup '(?i)experimental_bearer_token|env_key|http_headers' ` + 'Setup must not introduce a second credential source.' +} +Assert-NotMatches $macSetup '(?m)^CODEX_HOME=' ` + 'Setup must not repurpose the Codex environment variable.' +$macSmoke = Read-RepoFile 'tests/macos/smoke.sh' +Assert-NotMatches $macSmoke '(?m)^(export\s+)?(HOME|CODEX_HOME)=' ` + 'Tests must use dedicated installer test paths, not override user home variables.' + $macWrapper = Read-RepoFile 'setup-macos.command' Assert-NotMatches $macWrapper '"\$@"' ` 'The macOS setup wrapper must not forward command-line arguments.' @@ -108,7 +125,7 @@ $denyPatterns = @( ) $textExtensions = @( - '.ps1', '.sh', '.command', '.bat', '.md', '.json', '.toml', '.txt', '.yml', '.yaml' + '.py', '.ps1', '.sh', '.command', '.bat', '.md', '.json', '.toml', '.txt', '.yml', '.yaml' ) $textFiles = Get-ChildItem -LiteralPath $repoRoot -Recurse -File | Where-Object { diff --git a/tests/static/windows-profile.ps1 b/tests/static/windows-profile.ps1 new file mode 100644 index 0000000..f7d5f7c --- /dev/null +++ b/tests/static/windows-profile.ps1 @@ -0,0 +1,39 @@ +param([string]$PythonExecutable = 'python3') +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path +. (Join-Path $repoRoot 'scripts/windows/common.ps1') +if ((Resolve-CodexConfigRoot -ConfiguredRoot 'C:\Custom profile' -UserRoot $null) -cne 'C:\Custom profile') { + throw 'Configured CODEX_HOME was not preserved.' +} +$expectedDefault = [System.IO.Path]::Combine('C:\User', '.codex') +if ((Resolve-CodexConfigRoot -ConfiguredRoot '' -UserRoot 'C:\User') -cne $expectedDefault) { + throw 'Default Codex profile root was not resolved.' +} + + +# 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(.*?)^"@') +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' +$template = $match.Groups[1].Value +$withoutKnownVariables = $template.Replace('$tomlHelperPath', '').Replace('$tomlSecretPath', '') +if ($withoutKnownVariables.Contains('$') -or $withoutKnownVariables.Contains('`')) { + throw 'Unexpected executable interpolation in the profile template.' +} +$profile = $template.Replace('$tomlHelperPath', (ConvertTo-TomlBasicString -Value $helperPath)).Replace( + '$tomlSecretPath', (ConvertTo-TomlBasicString -Value $secretPath) +) +$tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('neuroapi-profile-' + [Guid]::NewGuid().ToString('N')) +try { + New-Item -ItemType Directory -Path $tempRoot | Out-Null + $profilePath = Join-Path $tempRoot 'neuroapi-host.config.toml' + Write-Utf8NoBom -Path $profilePath -Content $profile + & $PythonExecutable (Join-Path $PSScriptRoot 'profile_contract.py') $profilePath windows $helperPath $secretPath + if ($LASTEXITCODE -ne 0) { throw 'Windows profile contract failed.' } +} finally { + if (Test-Path -LiteralPath $tempRoot) { Remove-Item -LiteralPath $tempRoot -Recurse -Force } +} diff --git a/tests/windows/smoke.ps1 b/tests/windows/smoke.ps1 index c9b4790..82089e7 100644 --- a/tests/windows/smoke.ps1 +++ b/tests/windows/smoke.ps1 @@ -45,6 +45,12 @@ try { Assert-True (Test-Path -LiteralPath $secretPath) 'DPAPI secret was not created.' Assert-True (Test-Path -LiteralPath $helperPath) 'Credential helper was not installed.' Assert-True (Test-Path -LiteralPath $settingsPath) 'Claude settings were not created.' + $profileBefore = Get-Content -LiteralPath $profilePath -Raw + Assert-True ($profileBefore -match '(?m)^base_url = "https://neuroapi.host/v1/codex"\r?$') 'Wrong Codex base URL.' + Assert-True ($profileBefore -match '(?m)^supports_websockets = true\r?$') 'Codex WebSocket support is missing.' + Assert-True ($profileBefore -notmatch 'test-neuroapi-token|experimental_bearer_token|env_key|http_headers') 'Credential leaked into Codex configuration.' + Assert-True ((Get-Content -LiteralPath $settingsPath -Raw) -notmatch 'test-neuroapi-token') 'Credential leaked into Claude configuration.' + Assert-True ( (Get-Content -LiteralPath $secretPath -Raw) -notmatch 'test-neuroapi-token' ) 'The key was stored in plaintext.' @@ -84,8 +90,8 @@ try { Write-Warning 'Skipping TOML parse because local Python is older than 3.11.' } else { Assert-True ($LASTEXITCODE -eq 0) 'Unable to determine the local Python version.' - & $pythonExecutable @pythonPrefix -c 'import sys,tomllib; tomllib.load(open(sys.argv[1],''rb''))' $profilePath - Assert-True ($LASTEXITCODE -eq 0) 'Generated Codex profile is not valid TOML.' + & $pythonExecutable @pythonPrefix "$repoRoot/tests/static/profile_contract.py" $profilePath windows $helperPath $secretPath + Assert-True ($LASTEXITCODE -eq 0) 'Generated Codex profile violates the profile contract.' } } @@ -95,6 +101,8 @@ try { -CodexHome $codexHome ` -NoPathUpdate | Out-Null + Assert-True ((Get-Content -LiteralPath $profilePath -Raw) -ceq $profileBefore) 'Reinstall changed the generated Codex profile.' + & "$repoRoot\scripts\windows\uninstall.ps1" ` -TestMode ` -StateRoot $stateRoot ` From 2f840b9180fabda7dcfe48ce1c972a2860603d54 Mon Sep 17 00:00:00 2001 From: Roman Date: Sat, 26 Sep 2026 21:24:46 +0800 Subject: [PATCH 2/3] feat(agents): load curated models securely before client launch --- AGENTS.md | 1 + README.en.md | 6 +- README.md | 10 +- docs/manual-setup.md | 20 +- docs/security.md | 10 +- docs/troubleshooting.md | 10 +- scripts/macos/catalog-validator.js | 122 ++++++++++ scripts/macos/install.sh | 28 +-- scripts/macos/launch-managed.sh | 111 +++++++++ scripts/windows/launch-neuroapi.ps1 | 23 ++ scripts/windows/managed-catalog.ps1 | 359 ++++++++++++++++++++++++++++ scripts/windows/setup.ps1 | 20 +- tests/macos/catalog_smoke.py | 213 +++++++++++++++++ tests/macos/smoke.sh | 11 +- tests/static/profile_contract.py | 2 +- tests/static/validate.ps1 | 2 +- tests/static/windows-profile.ps1 | 1 + tests/windows/managed-catalog.ps1 | 297 +++++++++++++++++++++++ tests/windows/smoke.ps1 | 19 +- 19 files changed, 1208 insertions(+), 57 deletions(-) create mode 100644 scripts/macos/catalog-validator.js create mode 100755 scripts/macos/launch-managed.sh create mode 100644 scripts/windows/launch-neuroapi.ps1 create mode 100644 scripts/windows/managed-catalog.ps1 create mode 100644 tests/macos/catalog_smoke.py create mode 100644 tests/windows/managed-catalog.ps1 diff --git a/AGENTS.md b/AGENTS.md index b1b11a9..f21c752 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,3 +8,4 @@ - 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. +- 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 10eab3c..c5b40c4 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`. Publish or distribute it **only after the server profile is deployed** and authenticated `/v1/codex/models` plus HTTP/WebSocket `/v1/codex/responses` checks pass. Local implementation is not production evidence. Setup deliberately does not call the API to validate credentials. +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. An existing `CODEX_HOME` selects the profile directory without being modified. Keep its value consistent for setup, launch and uninstall. @@ -46,10 +46,10 @@ This prevents accidental plaintext disclosure. It does not protect a key from ma ## Verification - In `codex-neuroapi`, run `/debug-config` and confirm the `neuroapi-host` profile and `https://neuroapi.host/v1/codex`. -- In `claude-neuroapi`, run `/status` and confirm `https://neuroapi.host` plus `apiKeyHelper`. +- In `claude-neuroapi`, run `/status` and confirm `https://neuroapi.host/v1/claude-code` plus `apiKeyHelper`. - Check current model IDs and pricing at [neuroapi.host/price](https://neuroapi.host/price). -The installer defaults are examples and may drift with provider catalogs. +Each launcher fetches a fresh catalog scoped to the ordinary NeuroAPI key. Codex uses a private `model_catalog_json`; Claude receives a configured picker. There are no hardcoded default models. Codex 0.147.0+ and Claude Code 2.1.280+ are required. Invalid, empty or unavailable catalogs stop launch instead of restoring stale lists. Organization policies and deliberate CLI overrides retain their documented precedence; these launchers do not support host-managed provider mode. ## More diff --git a/README.md b/README.md index 5c598fe..2cca045 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ NeuroAPI — российский AI API-сервис: единый доступ ## Совместимость версии -Эта версия создаёт профиль Codex с `https://neuroapi.host/v1/codex` и `supports_websockets = true`. Выпускайте и распространяйте установщик **только после публикации серверного профиля**: должны работать авторизованный `GET /v1/codex/models` в формате Codex и `/v1/codex/responses` по HTTP и WebSocket. Подготовленный код не доказывает доступность этих адресов в production; setup намеренно не вызывает 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_HOME` учитывается для профиля и не изменяется; сохраняйте одинаковое значение при установке, запуске и удалении. @@ -68,7 +68,7 @@ chmod +x setup-macos.command | Получает ключ | command-backed auth helper | `apiKeyHelper` / Keychain helper | | Существующие конфиги | не перезаписываются | не перезаписываются | -Установщик не вызывает API и не отправляет ключ в сеть. Сеть используется уже Codex CLI или Claude Code при ваших запросах к `https://neuroapi.host`. +Установщик не вызывает API и не отправляет ключ в сеть. При запуске `codex-neuroapi` или `claude-neuroapi` запускатель получает актуальный каталог с `https://neuroapi.host`, затем клиент использует API при ваших запросах. ## Почему ключ не лежит в конфиге @@ -76,7 +76,7 @@ chmod +x setup-macos.command - ключ не сохраняется в `.env`, TOML, JSON или репозитории; - Windows шифрует значение через DPAPI без отдельного сохранённого master key; - macOS сохраняет значение штатной командой Keychain с интерактивным `-w`; -- helpers печатают только токен в stdout в момент, когда его запрашивает клиент. +- helpers печатают только токен в stdout по запросу launcher или клиента. Это защищает от случайной публикации ключа, но не от вредоносной программы, уже работающей от имени того же пользователя. Полная модель угроз: [docs/security.md](docs/security.md). @@ -109,9 +109,9 @@ Claude Code: 1. Запустите `claude-neuroapi`. 2. Выполните `/status`. -3. Проверьте base URL `https://neuroapi.host` и credential source `apiKeyHelper`. +3. Проверьте base URL `https://neuroapi.host/v1/claude-code` и credential source `apiKeyHelper`. -Примеры используют `gpt-5.6-sol` и `claude-sonnet-4-5`. ID моделей меняются: при `model not found` возьмите точное имя из [каталога NeuroAPI](https://neuroapi.host/price) или `GET /v1/models`. +При каждом запуске launcher получает актуальный список для вашего обычного ключа NeuroAPI. Codex использует отдельный каталог, Claude Code — настроенное меню; фиксированных моделей в установщике нет. Нужны Codex 0.147.0+ и Claude Code 2.1.280+. При ошибке обновления или пустом списке запуск останавливается, не возвращаясь к старым моделям. Подробности и ограничения managed-политик: [ручная настройка](docs/manual-setup.md). ## Удаление и замена ключа diff --git a/docs/manual-setup.md b/docs/manual-setup.md index 6e48f39..c5bf9f0 100644 --- a/docs/manual-setup.md +++ b/docs/manual-setup.md @@ -4,7 +4,7 @@ ## Codex CLI -Нужен актуальный Codex с profile-v2: `codex --help` описывает `--profile` как загрузку `.config.toml`. Создаётся отдельный user-level profile: +Нужен Codex 0.147.0 или новее с profile-v2. Создаётся отдельный user-level profile: - Windows: `%USERPROFILE%\.codex\neuroapi-host.config.toml`; - macOS: `~/.codex/neuroapi-host.config.toml`. @@ -14,7 +14,6 @@ Основная конфигурация: ```toml -model = "gpt-5.6-sol" model_provider = "neuroapi" [model_providers.neuroapi] @@ -31,7 +30,9 @@ refresh_interval_ms = 300000 На Windows `command` — `powershell.exe`, а helper и DPAPI secret передаются отдельными элементами `args`. -Запуск: `codex --profile neuroapi-host`. Проверка: `/debug-config`. +Запуск: `codex-neuroapi`. Перед каждым запуском launcher получает `/v1/codex/models` с обычным ключом NeuroAPI, проверяет ответ и передаёт приватный файл через `model_catalog_json` вместе с доступной моделью по умолчанию. Файл удаляется после завершения клиента. Проверка: `/debug-config` и `/model`. + +Прямой `codex --profile neuroapi-host` пропускает этот механизм: command-auth discovery может подмешать встроенные модели. При ручной настройке без launcher можно задать собственный проверенный `model_catalog_json`; поддерживать его актуальность тогда нужно самостоятельно. Project `.codex/config.toml` не подходит для provider/auth redirect: актуальный Codex игнорирует там `model_provider` и `model_providers` по соображениям безопасности. @@ -43,20 +44,21 @@ Project `.codex/config.toml` не подходит для provider/auth redirect ## Claude Code -Существующий `~/.claude/settings.json` не меняется. Launcher передаёт отдельный installer-owned JSON через `claude --settings `. +Нужен Claude Code 2.1.280 или новее. Существующий `~/.claude/settings.json` не меняется. Launcher получает `/v1/claude-code/client-settings`, проверяет разрешённые поля данных, добавляет локальный `apiKeyHelper` и передаёт приватный JSON через `claude --settings `. ```json { "$schema": "https://json.schemastore.org/claude-code-settings.json", "apiKeyHelper": "/absolute/path/to/installer-owned-helper", "env": { - "ANTHROPIC_BASE_URL": "https://neuroapi.host", - "ANTHROPIC_MODEL": "claude-sonnet-4-5" + "ANTHROPIC_BASE_URL": "https://neuroapi.host/v1/claude-code" } } ``` -Проверка: `/status`. +Это локальная основа настроек, а не полный runtime-файл. Сервер добавляет доступную `model`, `availableModels`, семейные defaults, пустой `fallbackModel` и `modelPicker.options` с `replaceBuiltInOptions: true`. Совместимые fallback-модели могут быть разрешены отдельно от меню. Проверка: `/status` и `/model`. + +Настройки организации могут иметь более высокий приоритет; режим host-managed provider для этих launchers не поддерживается. Явные пользовательские аргументы остаются явными переопределениями. Меню не заменяет серверные ограничения ключа. ## Пути Windows @@ -82,7 +84,9 @@ Setup не меняет `.zprofile`, `.zshrc`, `.bash_profile` или систе ## Модели -`gpt-5.6-sol` и `claude-sonnet-4-5` — текущие defaults этого репозитория, а не бессрочная гарантия каталога. Точный доступ зависит от опубликованных моделей и вашего ключа. Проверяйте [каталог](https://neuroapi.host/price) или `GET https://neuroapi.host/v1/models` со своим ключом. +Конкретные IDs выбирает сервер из опубликованных моделей с учётом ключа, тарифа и совместимости протокола. В установщике больше нет фиксированной основной модели. Ошибка загрузки, пустой список, неподходящая версия клиента или неверная схема останавливают запуск с понятным сообщением: старый список не используется. + +Специальный ключ не нужен. Можно подключаться вручную через обычный API: сервер распознаёт известные заголовки Codex/Claude на `/v1/models`. Однако клиент должен сам запросить каталог, а встроенные варианты могут сохраниться. Управляемые launchers — дополнительный способ получить заданное меню, а не условие доступа к API. ## Официальные контракты diff --git a/docs/security.md b/docs/security.md index f023213..af1cc5c 100644 --- a/docs/security.md +++ b/docs/security.md @@ -10,7 +10,7 @@ Windows: 1. `Read-Host -AsSecureString` получает ключ без обычной строки в setup-скрипте. 2. `ConvertFrom-SecureString` без `-Key` создаёт DPAPI-ciphertext для текущего пользователя и компьютера. -3. Codex/Claude вызывают короткий helper. +3. Launcher и Codex/Claude вызывают короткий helper для своих авторизованных запросов. 4. Helper расшифровывает значение в своём процессе, печатает только токен в stdout и очищает BSTR. macOS: @@ -43,10 +43,12 @@ macOS: ## Сетевое поведение -Setup и uninstall не вызывают NeuroAPI и не валидируют ключ по сети. После установки запросы отправляют официальные клиенты: +Setup и uninstall не вызывают NeuroAPI. Каждый launcher сначала получает актуальный каталог через HTTPS с сохранённым ключом: фиксированный адрес, запрет redirects, ограничение времени и размера ответа. Ключ остаётся в памяти/анонимных pipes, не передаётся в argv и не сохраняется в каталоге. Отражение ключа в ответе проверяется до записи, включая JSON escaping. Произвольные hooks, команды и настройки сервера не принимаются. -- Codex custom provider: `https://neuroapi.host/v1`; -- Claude Code gateway: `https://neuroapi.host`. +- Codex custom provider: `https://neuroapi.host/v1/codex`; +- Claude Code gateway: `https://neuroapi.host/v1/claude-code`. + +Проверенные данные записываются в приватный отдельный каталог на время одного запуска, затем удаляются. Параллельные запуски не делят изменяемый snapshot. Ошибка загрузки не включает старый каталог. Чужие provider/auth overrides нейтрализуются только в дочернем процессе и его локальных settings; настройки пользователя на диске не переписываются. Host-managed provider mode отклоняется, а не обходится. ## Границы файлов diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 29f03bd..ef2cedf 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -42,7 +42,15 @@ macOS может показать системный запрос доступа ## `model not found` -Defaults установщика могут отстать от живого каталога. Проверьте [модели и цены](https://neuroapi.host/price) либо `GET /v1/models`, затем обновите репозиторий. Не заменяйте модель на случайное имя: Codex дополнительно требует Responses-совместимость. +Перезапустите launcher: он получает свежий список, доступный ключу. Проверьте явный `--model` и сохранённую модель старой сессии. Если список пуст или серверные endpoints ещё не опубликованы, launcher остановится; это не повод подставлять случайный ID. Codex дополнительно требует Responses-совместимость. + +## Каталог не загружается или список пуст + +Проверьте ключ, соединение и ограничения моделей/тарифа. Для этой версии нужны `/v1/codex/models` и `/v1/claude-code/client-settings`; обновление установщика само по себе не создаёт их на сервере. Ответы `401`, `403`, redirects, слишком большой/неправильный JSON и пустой список не заменяются локальным старым каталогом. Серверное обновление должно быть выпущено до распространения установщика. + +## В меню всё ещё есть другие модели + +Запуск напрямую через `codex`/`claude` обходит managed launcher. Обычное discovery может дополнять встроенный список. Даже в управляемом режиме Claude сохраняет строки Default/текущей модели, а корпоративные настройки и явные CLI-переопределения могут иметь приоритет. Launcher не обходит host-managed provider mode: для него нужна настройка со стороны управляющего приложения. ## macOS блокирует `.command` diff --git a/scripts/macos/catalog-validator.js b/scripts/macos/catalog-validator.js new file mode 100644 index 0000000..83cc3b4 --- /dev/null +++ b/scripts/macos/catalog-validator.js @@ -0,0 +1,122 @@ +// JXA/Foundation is part of macOS; no Python or Node dependency at runtime. +ObjC.import('Foundation') + +function requireValue(condition) { + if (!condition) throw new Error('Invalid model catalog') +} +function object(value) { return value !== null && typeof value === 'object' && !Array.isArray(value) } +function keys(value, allowed) { + requireValue(object(value)) + requireValue(Object.keys(value).every(function (key) { return allowed.indexOf(key) !== -1 })) +} +function text(value, limit) { + return typeof value === 'string' && value.length <= limit && !/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/.test(value) +} +function modelID(value) { return typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/.test(value) } +function integer(value, min, max) { return Number.isSafeInteger(value) && value >= min && value <= max } +function readBounded(handle, limit) { + var data = $.NSMutableData.data + while (true) { + var chunk = handle.readDataOfLength(65536) + if (Number(chunk.length) === 0) break + requireValue(Number(data.length) + Number(chunk.length) <= limit) + data.appendData(chunk) + } + var decoded = $.NSString.alloc.initWithDataEncoding(data, $.NSUTF8StringEncoding) + requireValue(!decoded.isNil()) + return ObjC.unwrap(decoded) +} +function write(path, value) { + requireValue($(value).dataUsingEncoding($.NSUTF8StringEncoding).writeToFileAtomically($(path), true)) +} +function validateCodex(data) { + keys(data, ['models', 'default_model']) + requireValue(Array.isArray(data.models) && data.models.length > 0 && data.models.length <= 128) + var seen = Object.create(null) + var strings = ['display_name', 'description', 'base_instructions'] + var bools = ['supported_in_api', 'supports_reasoning_summary_parameter', 'support_verbosity', 'supports_parallel_tool_calls', 'supports_search_tool', 'use_responses_lite'] + var counts = ['priority', 'context_window', 'max_context_window', 'auto_compact_token_limit', 'effective_context_window_percent', 'input_token_limit', 'output_token_limit'] + var allowed = ['slug', 'supported_reasoning_levels', 'shell_type', 'visibility', 'model_messages', 'truncation_policy', 'experimental_supported_tools', 'input_modalities'].concat(strings, bools, counts) + data.models.forEach(function (model) { + keys(model, allowed) + requireValue(modelID(model.slug) && !seen[model.slug]) + seen[model.slug] = model + strings.forEach(function (key) { requireValue(text(model[key], 65536)) }) + bools.forEach(function (key) { requireValue(typeof model[key] === 'boolean') }) + counts.forEach(function (key) { requireValue(integer(model[key], 0, 1000000000)) }) + requireValue(model.context_window > 0 && model.max_context_window >= model.context_window) + requireValue(model.effective_context_window_percent > 0 && model.effective_context_window_percent <= 100) + requireValue(['shell_command', 'default', 'local', 'unified_exec', 'disabled'].indexOf(model.shell_type) !== -1) + requireValue(['list', 'hide', 'hidden'].indexOf(model.visibility) !== -1) + requireValue(Array.isArray(model.supported_reasoning_levels) && model.supported_reasoning_levels.length <= 16) + model.supported_reasoning_levels.forEach(function (level) { + keys(level, ['effort', 'description']) + requireValue(['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'].indexOf(level.effort) !== -1 && text(level.description, 4096)) + }) + keys(model.model_messages, ['instructions_template']) + requireValue(text(model.model_messages.instructions_template, 65536)) + keys(model.truncation_policy, ['mode', 'limit']) + requireValue(['bytes', 'tokens'].indexOf(model.truncation_policy.mode) !== -1 && integer(model.truncation_policy.limit, 1, 1000000000)) + requireValue(Array.isArray(model.experimental_supported_tools) && model.experimental_supported_tools.length <= 64) + model.experimental_supported_tools.forEach(function (tool) { requireValue(modelID(tool)) }) + requireValue(Array.isArray(model.input_modalities) && model.input_modalities.length > 0 && model.input_modalities.length <= 4) + model.input_modalities.forEach(function (mode) { requireValue(['text', 'image', 'audio', 'video'].indexOf(mode) !== -1) }) + }) + requireValue(modelID(data.default_model) && seen[data.default_model]) + requireValue(seen[data.default_model].visibility === 'list' && seen[data.default_model].supported_in_api === true) +} +function validateClaude(data) { + keys(data, ['model', 'availableModels', 'enforceAvailableModels', 'modelPicker', 'env', 'fallbackModel']) + requireValue(Array.isArray(data.availableModels) && data.availableModels.length > 0 && data.availableModels.length <= 256) + var seen = Object.create(null) + data.availableModels.forEach(function (model) { requireValue(modelID(model) && !seen[model]); seen[model] = true }) + requireValue(modelID(data.model) && seen[data.model] && data.enforceAvailableModels === true) + requireValue(Array.isArray(data.fallbackModel) && data.fallbackModel.length === 0) + keys(data.modelPicker, ['options', 'replaceBuiltInOptions']) + requireValue(data.modelPicker.replaceBuiltInOptions === true && Array.isArray(data.modelPicker.options) && data.modelPicker.options.length > 0 && data.modelPicker.options.length <= 128) + var options = Object.create(null) + data.modelPicker.options.forEach(function (option) { + keys(option, ['model', 'label', 'description']) + requireValue(modelID(option.model) && seen[option.model] && !options[option.model]) + options[option.model] = true + if (option.label !== undefined) requireValue(text(option.label, 512)) + if (option.description !== undefined) requireValue(text(option.description, 4096)) + }) + requireValue(options[data.model] === true) + keys(data.env, ['ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL', 'ANTHROPIC_DEFAULT_FABLE_MODEL']) + 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])) + }) +} +function run(args) { + requireValue(args.length === 3 && ['codex', 'claude'].indexOf(args[0]) !== -1) + var raw = readBounded($.NSFileHandle.fileHandleWithStandardInput, 4194310) + var secretHandle = $.NSFileHandle.fileHandleForReadingAtPath('/dev/fd/3') + requireValue(!secretHandle.isNil()) + var secret = readBounded(secretHandle, 4096) + requireValue(secret.length > 0 && raw.indexOf(secret) === -1) + requireValue(raw.slice(-4) === '\n200') + var data = JSON.parse(raw.slice(0, -4)) + requireValue(JSON.stringify(data).indexOf(secret) === -1) + if (args[0] === 'codex') { + validateCodex(data) + write(args[1] + '/models.json', JSON.stringify({models: data.models})) + write(args[1] + '/model.txt', data.default_model + '\n') + } else { + validateClaude(data) + // Claude reapplies lower-priority settings.env after inheriting the process + // environment. Neutralize foreign auth/provider values in both layers. + ;['ANTHROPIC_SMALL_FAST_MODEL', 'CLAUDE_CODE_SUBAGENT_MODEL', 'ANTHROPIC_DEFAULT_MODEL', + 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN', + 'ANTHROPIC_CUSTOM_HEADERS', 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', + 'CLAUDE_CODE_USE_FOUNDRY', 'CLAUDE_CODE_USE_ANTHROPIC_AWS', 'CLAUDE_CODE_USE_MANTLE'].forEach(function (key) { + data.env[key] = '' + }) + data.env.ANTHROPIC_BASE_URL = 'https://neuroapi.host/v1/claude-code' + data.env.ANTHROPIC_MODEL = data.model + data.apiKeyHelper = "'" + args[2].replace(/'/g, "'\\''") + "'" + write(args[1] + '/settings.json', JSON.stringify(data)) + } +} diff --git a/scripts/macos/install.sh b/scripts/macos/install.sh index 038b403..39bb1dc 100755 --- a/scripts/macos/install.sh +++ b/scripts/macos/install.sh @@ -57,11 +57,14 @@ write_marker "$(keychain_marker_path)" 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" ESCAPED_HELPER_PATH="$(toml_escape "$HELPER_PATH")" cat >"$PROFILE_PATH" <"$LAUNCHER_ROOT/codex-neuroapi" <<'EOF' -#!/bin/bash -set -euo pipefail -exec codex --profile neuroapi-host "$@" -EOF - -cat >"$LAUNCHER_ROOT/claude-neuroapi" <"$LAUNCHER_ROOT/$client-neuroapi" +done chmod 700 "$LAUNCHER_ROOT/codex-neuroapi" "$LAUNCHER_ROOT/claude-neuroapi" write_marker "$(launcher_marker_path 'codex-neuroapi')" diff --git a/scripts/macos/launch-managed.sh b/scripts/macos/launch-managed.sh new file mode 100755 index 0000000..3f37b0f --- /dev/null +++ b/scripts/macos/launch-managed.sh @@ -0,0 +1,111 @@ +#!/bin/bash +# Do not trace credentials even when the caller enabled shell tracing. +set +x +set -euo pipefail +umask 077 + +SCRIPT_DIR="$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)" +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' ;; + *) printf 'Неизвестный клиент NeuroAPI.\n' >&2; exit 1 ;; +esac + +fail() { printf '%s\n' "$1" >&2; exit 1; } +if [[ "$CLIENT" == 'claude' ]]; then + HOST_MANAGED="${CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST:-}" + HOST_MANAGED="${HOST_MANAGED#"${HOST_MANAGED%%[![:space:]]*}"}" + HOST_MANAGED="${HOST_MANAGED%"${HOST_MANAGED##*[![:space:]]}"}" + case "$HOST_MANAGED" in + 1|[Tt][Rr][Uu][Ee]|[Yy][Ee][Ss]|[Oo][Nn]) + fail 'Провайдер Claude Code управляется приложением-хостом. Запустите NeuroAPI из самостоятельного терминала.' ;; + esac +fi +if ! VERSION_TEXT="$("$CLIENT" --version 2>/dev/null)" || + [[ ! "$VERSION_TEXT" =~ ([0-9]+)\.([0-9]+)\.([0-9]+) ]]; 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 + fail "Требуется $CLIENT версии $MIN_MAJOR.$MIN_MINOR.$MIN_PATCH или новее." +fi + +SNAPSHOT="$(mktemp -d "${TMPDIR:-/tmp}/neuroapi-models.XXXXXX")" +CHILD_PID='' +cleanup() { rm -rf -- "$SNAPSHOT"; } +stop_child() { + local exit_status="$1" + trap '' INT TERM HUP + if [[ -n "$CHILD_PID" ]]; then + kill -TERM "$CHILD_PID" 2>/dev/null || true + wait "$CHILD_PID" 2>/dev/null || true + fi + exit "$exit_status" +} +launch_child() { + local result=0 + "$@" <&0 & + CHILD_PID=$! + wait "$CHILD_PID" || result=$? + CHILD_PID='' + return "$result" +} +trap cleanup EXIT +trap 'stop_child 130' INT +trap 'stop_child 143' TERM +trap 'stop_child 129' HUP + +CURL_BIN='/usr/bin/curl' +if [[ "${NEUROAPI_AGENTS_TEST_MODE:-0}" == '1' && -n "${NEUROAPI_AGENTS_CURL_BIN:-}" ]]; then + CURL_BIN="$NEUROAPI_AGENTS_CURL_BIN" +fi +# The credential stays in shell memory and anonymous pipes. Never export it, +# include it in argv, or put it in a curl config file. +if ! credential="$("$SCRIPT_DIR/get-neuroapi-key.sh" 2>/dev/null)"; then + fail 'Не удалось получить ключ NeuroAPI из Keychain.' +fi +if [[ ${#credential} -gt 4096 || ! "$credential" =~ ^[A-Za-z0-9._~-]+$ ]]; then + unset credential + fail 'Ключ NeuroAPI имеет неподдерживаемый формат. Повторите настройку.' +fi +if ! printf 'header = "Authorization: Bearer %s"\n' "$credential" | + "$CURL_BIN" --disable --config - --silent --fail --proto '=https' \ + --proto-redir '=https' --max-redirs 0 --connect-timeout 5 --max-time 20 \ + --max-filesize 4194304 --header 'Accept: application/json' \ + --write-out '\n%{http_code}' "$ENDPOINT" 2>/dev/null | + /usr/bin/osascript -l JavaScript "$SCRIPT_DIR/catalog-validator.js" \ + "$CLIENT" "$SNAPSHOT" "$SCRIPT_DIR/get-neuroapi-key.sh" \ + 3< <(printf '%s' "$credential") >/dev/null 2>/dev/null; then + unset credential + fail 'Не удалось загрузить актуальные модели NeuroAPI. Проверьте доступ ключа и соединение; старый список не используется.' +fi +unset credential + +if [[ "$CLIENT" == 'codex' ]]; then + IFS= read -r DEFAULT_MODEL <"$SNAPSHOT/model.txt" + CATALOG_PATH="$SNAPSHOT/models.json" + CATALOG_PATH="${CATALOG_PATH//\\/\\\\}" + 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\"" "$@" +else + run_claude() { + # Remove inherited selectors and credentials only in this child. The + # fetched settings and local helper must use the same scoped key. + for variable in $(compgen -e); do + case "$variable" in + ANTHROPIC_MODEL|ANTHROPIC_DEFAULT_*|ANTHROPIC_SMALL_FAST_*|CLAUDE_CODE_SUBAGENT_MODEL|ANTHROPIC_AUTH_TOKEN|ANTHROPIC_API_KEY|ANTHROPIC_BASE_URL|ANTHROPIC_CUSTOM_HEADERS|CLAUDE_CODE_OAUTH_TOKEN|CLAUDE_CODE_USE_BEDROCK|CLAUDE_CODE_USE_VERTEX|CLAUDE_CODE_USE_FOUNDRY|CLAUDE_CODE_USE_ANTHROPIC_AWS|CLAUDE_CODE_USE_MANTLE) + unset "$variable" ;; + esac + done + exec claude --settings "$SNAPSHOT/settings.json" "$@" + } + launch_child run_claude "$@" +fi diff --git a/scripts/windows/launch-neuroapi.ps1 b/scripts/windows/launch-neuroapi.ps1 new file mode 100644 index 0000000..8618339 --- /dev/null +++ b/scripts/windows/launch-neuroapi.ps1 @@ -0,0 +1,23 @@ +# No parameter binder: retain arbitrary client flags as data, including --model. +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +. "$PSScriptRoot\common.ps1" +. "$PSScriptRoot\managed-catalog.ps1" +try { + if ($args.Count -lt 1 -or $args[0] -cnotin @('codex', 'claude')) { throw 'Неизвестный клиент NeuroAPI.' } + $clientName = [string]$args[0] + $forwarded = @() + if ($args.Count -gt 1) { $forwarded = @($args[1..($args.Count - 1)]) } + Invoke-NeuroAPIManagedClient -Client $clientName -StateRoot (Split-Path -Parent $PSScriptRoot) -ClientArguments $forwarded + exit $script:NeuroAPIChildExitCode +} catch { + $message = 'Запуск NeuroAPI отменен. Проверьте подключение, ключ и актуальность клиента. Повторите установку при повреждении файлов.' + if ($_.Exception.Message -ceq 'Провайдер Claude Code управляется организацией. Используйте настройки организации; запуск NeuroAPI отменен.') { + $message = $_.Exception.Message + } + if ($_.Exception.Message -match '^Обновите (codex|claude) до версии [0-9.]+ или новее\. Запуск отменен\.$') { + $message = $_.Exception.Message + } + [Console]::Error.WriteLine($message) + exit 1 +} diff --git a/scripts/windows/managed-catalog.ps1 b/scripts/windows/managed-catalog.ps1 new file mode 100644 index 0000000..7aeb650 --- /dev/null +++ b/scripts/windows/managed-catalog.ps1 @@ -0,0 +1,359 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Set-NeuroAPIPrivateDirectory { + param([Parameter(Mandatory = $true)][string]$Path) + if ((Get-Item -LiteralPath $Path -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) { + throw 'Каталог NeuroAPI не должен быть ссылкой. Повторите установку.' + } + $identity = [Security.Principal.WindowsIdentity]::GetCurrent().User + $acl = New-Object Security.AccessControl.DirectorySecurity + $acl.SetOwner($identity) + $acl.SetAccessRuleProtection($true, $false) + $rule = New-Object Security.AccessControl.FileSystemAccessRule( + $identity, 'FullControl', 'ContainerInherit, ObjectInherit', 'None', 'Allow' + ) + $acl.AddAccessRule($rule) + Set-Acl -LiteralPath $Path -AclObject $acl +} + +function New-NeuroAPILaunchDirectory { + param([Parameter(Mandatory = $true)][string]$StateRoot) + $path = Join-Path $StateRoot ('launch-' + [Guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $path -ErrorAction Stop | Out-Null + try { + Set-NeuroAPIPrivateDirectory -Path $path + return $path + } catch { + Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue + throw 'Не удалось создать приватный каталог запуска NeuroAPI.' + } +} + +function Read-NeuroAPIProtectedCredential { + param([Parameter(Mandatory = $true)][string]$SecretPath) + $secure = $null + $pointer = [IntPtr]::Zero + try { + $cipher = [IO.File]::ReadAllText($SecretPath) + $secure = ConvertTo-SecureString -String $cipher + $pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure) + $value = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer) + if ([string]::IsNullOrWhiteSpace($value) -or $value.IndexOfAny([char[]]"`r`n") -ge 0) { + throw 'Invalid credential' + } + return $value + } catch { + throw 'Не удалось прочитать защищенный ключ NeuroAPI. Повторите установку.' + } finally { + if ($pointer -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer) } + if ($null -ne $secure) { $secure.Dispose() } + $value = $null + } +} + +function New-NeuroAPICatalogClient { + Add-Type -AssemblyName System.Net.Http + $handler = New-Object Net.Http.HttpClientHandler + $handler.AllowAutoRedirect = $false + $handler.UseCookies = $false + $client = New-Object Net.Http.HttpClient($handler) + $client.Timeout = [TimeSpan]::FromSeconds(30) + return $client +} + +function Get-NeuroAPICatalogJson { + param([ValidateSet('codex', 'claude')][string]$Client, [string]$SecretPath) + $endpoint = if ($Client -eq 'codex') { + 'https://neuroapi.host/v1/codex/models' + } else { + 'https://neuroapi.host/v1/claude-code/client-settings' + } + $http = $null; $request = $null; $response = $null; $stream = $null; $buffer = $null; $timeout = $null + $credential = $null + try { + $http = New-NeuroAPICatalogClient + $timeout = New-Object Threading.CancellationTokenSource + $timeout.CancelAfter(30000) + $request = New-Object Net.Http.HttpRequestMessage([Net.Http.HttpMethod]::Get, $endpoint) + $credential = Read-NeuroAPIProtectedCredential -SecretPath $SecretPath + $request.Headers.Authorization = New-Object Net.Http.Headers.AuthenticationHeaderValue('Bearer', $credential) + $request.Headers.Accept.ParseAdd('application/json') + $response = $http.SendAsync($request, [Net.Http.HttpCompletionOption]::ResponseHeadersRead, $timeout.Token).GetAwaiter().GetResult() + if ([int]$response.StatusCode -ne 200) { throw 'Catalog unavailable' } + $limit = 2 * 1024 * 1024 + if ($null -ne $response.Content.Headers.ContentLength -and $response.Content.Headers.ContentLength -gt $limit) { + throw 'Catalog too large' + } + $stream = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult() + $buffer = New-Object IO.MemoryStream + $chunk = New-Object byte[] 8192 + while ($true) { + $read = $stream.ReadAsync($chunk, 0, $chunk.Length, $timeout.Token).GetAwaiter().GetResult() + if ($read -eq 0) { break } + if ($buffer.Length + $read -gt $limit) { throw 'Catalog too large' } + $buffer.Write($chunk, 0, $read) + } + $utf8 = New-Object Text.UTF8Encoding($false, $true) + $json = $utf8.GetString($buffer.ToArray()) + if ($json.IndexOf($credential, [StringComparison]::Ordinal) -ge 0) { throw 'Credential reflected' } + $decoded = $json | ConvertFrom-Json -ErrorAction Stop + Assert-NeuroAPINoCredential -Value $decoded -Credential $credential -Depth 0 + return $json + } catch { + # Never print the underlying HTTP exception, request headers or body. + throw 'Не удалось обновить каталог NeuroAPI. Проверьте доступ к сервису и ключ; запуск отменен.' + } finally { + $credential = $null + foreach ($resource in @($buffer, $stream, $response, $request, $timeout, $http)) { + if ($null -ne $resource) { $resource.Dispose() } + } + } +} + +function Test-NeuroAPIModelId { + param($Value) + return ($Value -is [string] -and $Value -cmatch '^[A-Za-z0-9][A-Za-z0-9._:/\-]{0,255}$') +} + +function Get-NeuroAPIProperty { + param($Object, [string]$Name) + if ($null -eq $Object) { return $null } + $property = $Object.PSObject.Properties[$Name] + if ($null -eq $property) { return $null } + return ,$property.Value +} + +function Assert-NeuroAPINoCredential { + param($Value, [string]$Credential, [int]$Depth) + if ($Depth -gt 64) { throw 'Catalog nesting too deep' } + if ($Value -is [string]) { + if ($Value.IndexOf($Credential, [StringComparison]::Ordinal) -ge 0) { throw 'Credential reflected' } + } elseif ($Value -is [array]) { + foreach ($item in $Value) { Assert-NeuroAPINoCredential $item $Credential ($Depth + 1) } + } elseif ($Value -is [pscustomobject]) { + foreach ($property in $Value.PSObject.Properties) { + if ($property.Name.IndexOf($Credential, [StringComparison]::Ordinal) -ge 0) { throw 'Credential reflected' } + Assert-NeuroAPINoCredential $property.Value $Credential ($Depth + 1) + } + } +} + +function Assert-NeuroAPIKeys { + param($Object, [string[]]$Allowed) + if ($Object -isnot [pscustomobject]) { throw 'Invalid object' } + foreach ($property in $Object.PSObject.Properties) { + if ($Allowed -cnotcontains $property.Name) { throw 'Unexpected property' } + } +} + +function Test-NeuroAPIText { + param($Value, [int]$Limit) + return ($Value -is [string] -and $Value.Length -le $Limit -and $Value -notmatch '[\x00-\x08\x0b\x0c\x0e-\x1f]') +} + +function Test-NeuroAPIInteger { + param($Value, [long]$Minimum, [long]$Maximum) + return (($Value -is [int] -or $Value -is [long] -or $Value -is [double] -or $Value -is [decimal]) -and + $Value -ge $Minimum -and $Value -le $Maximum -and [Math]::Floor([double]$Value) -eq $Value) +} + +function Assert-NeuroAPICodexModel { + param($Model) + $strings = @('display_name', 'description', 'base_instructions') + $booleans = @('supported_in_api', 'supports_reasoning_summary_parameter', 'support_verbosity', 'supports_parallel_tool_calls', 'supports_search_tool', 'use_responses_lite') + $counts = @('priority', 'context_window', 'max_context_window', 'auto_compact_token_limit', 'effective_context_window_percent', 'input_token_limit', 'output_token_limit') + Assert-NeuroAPIKeys $Model (@('slug', 'supported_reasoning_levels', 'shell_type', 'visibility', 'model_messages', 'truncation_policy', 'experimental_supported_tools', 'input_modalities') + $strings + $booleans + $counts) + foreach ($field in $strings) { if (-not (Test-NeuroAPIText (Get-NeuroAPIProperty $Model $field) 65536)) { throw 'Invalid model text' } } + foreach ($field in $booleans) { if ((Get-NeuroAPIProperty $Model $field) -isnot [bool]) { throw 'Invalid model flag' } } + foreach ($field in $counts) { if (-not (Test-NeuroAPIInteger (Get-NeuroAPIProperty $Model $field) 0 1000000000)) { throw 'Invalid model count' } } + if ($Model.context_window -le 0 -or $Model.max_context_window -lt $Model.context_window -or $Model.effective_context_window_percent -le 0 -or $Model.effective_context_window_percent -gt 100) { throw 'Invalid context window' } + if (@('shell_command', 'default', 'local', 'unified_exec', 'disabled') -cnotcontains $Model.shell_type -or @('list', 'hide', 'hidden') -cnotcontains $Model.visibility) { throw 'Invalid model mode' } + $levels = Get-NeuroAPIProperty $Model 'supported_reasoning_levels' + if ($levels -isnot [array] -or $levels.Count -gt 16) { throw 'Invalid reasoning levels' } + foreach ($level in $levels) { + Assert-NeuroAPIKeys $level @('effort', 'description') + if (@('none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra') -cnotcontains $level.effort -or -not (Test-NeuroAPIText $level.description 4096)) { throw 'Invalid reasoning level' } + } + Assert-NeuroAPIKeys $Model.model_messages @('instructions_template') + if (-not (Test-NeuroAPIText $Model.model_messages.instructions_template 65536)) { throw 'Invalid model instructions' } + Assert-NeuroAPIKeys $Model.truncation_policy @('mode', 'limit') + if (@('bytes', 'tokens') -cnotcontains $Model.truncation_policy.mode -or -not (Test-NeuroAPIInteger $Model.truncation_policy.limit 1 1000000000)) { throw 'Invalid truncation policy' } + $tools = Get-NeuroAPIProperty $Model 'experimental_supported_tools' + if ($tools -isnot [array] -or $tools.Count -gt 64) { throw 'Invalid tools' } + foreach ($tool in $tools) { if (-not (Test-NeuroAPIModelId $tool)) { throw 'Invalid tool' } } + $modalities = Get-NeuroAPIProperty $Model 'input_modalities' + if ($modalities -isnot [array] -or $modalities.Count -lt 1 -or $modalities.Count -gt 4) { throw 'Invalid modalities' } + foreach ($modality in $modalities) { if (@('text', 'image', 'audio', 'video') -cnotcontains $modality) { throw 'Invalid modality' } } +} + +function Get-NeuroAPIClaudeEnvironmentOverrides { + # Exact provider/auth selectors only; unrelated feature/tool switches remain. + return @('ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_CUSTOM_HEADERS', 'CLAUDE_CODE_OAUTH_TOKEN', + 'CLAUDE_CODE_USE_ANTHROPIC_AWS', 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY', 'CLAUDE_CODE_USE_MANTLE', + 'ANTHROPIC_AWS_BASE_URL', 'ANTHROPIC_MANTLE_BASE_URL', 'ANTHROPIC_DEFAULT_MODEL', + 'ANTHROPIC_SMALL_FAST_MODEL', 'CLAUDE_CODE_SUBAGENT_MODEL') +} + +function ConvertFrom-NeuroAPICatalog { + param([ValidateSet('codex', 'claude')][string]$Client, [string]$Json, [string]$HelperCommand) + try { + $data = $Json | ConvertFrom-Json -ErrorAction Stop + if ($null -eq $data -or $data -is [array] -or $data -is [string]) { throw 'Invalid object' } + if ($Client -eq 'codex') { + Assert-NeuroAPIKeys $data @('models', 'default_model') + $models = Get-NeuroAPIProperty $data 'models' + $default = Get-NeuroAPIProperty $data 'default_model' + if ($models -isnot [array] -or $models.Count -lt 1 -or $models.Count -gt 128 -or -not (Test-NeuroAPIModelId $default)) { + throw 'Invalid catalog' + } + $ids = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::Ordinal) + foreach ($item in $models) { + $slug = Get-NeuroAPIProperty $item 'slug' + if (-not (Test-NeuroAPIModelId $slug) -or -not $ids.Add($slug)) { throw 'Invalid model' } + Assert-NeuroAPICodexModel $item + } + if (-not $ids.Contains($default)) { throw 'Default model missing' } + $defaultInfo = @($models | Where-Object { $_.slug -ceq $default })[0] + if ($defaultInfo.visibility -cne 'list' -or -not $defaultInfo.supported_in_api) { throw 'Default model unavailable' } + return [pscustomobject]@{ DefaultModel = $default; Content = @{ models = @($models) } } + } + Assert-NeuroAPIKeys $data @('model', 'availableModels', 'enforceAvailableModels', 'modelPicker', 'env', 'fallbackModel') + $ids = Get-NeuroAPIProperty $data 'availableModels' + $model = Get-NeuroAPIProperty $data 'model' + $enforced = Get-NeuroAPIProperty $data 'enforceAvailableModels' + $picker = Get-NeuroAPIProperty $data 'modelPicker' + Assert-NeuroAPIKeys $picker @('options', 'replaceBuiltInOptions') + $options = Get-NeuroAPIProperty $picker 'options' + $replace = Get-NeuroAPIProperty $picker 'replaceBuiltInOptions' + $fallback = Get-NeuroAPIProperty $data 'fallbackModel' + if ($ids -isnot [array] -or $ids.Count -lt 1 -or $ids.Count -gt 256 -or + $enforced -isnot [bool] -or -not $enforced -or $replace -isnot [bool] -or -not $replace -or + $fallback -isnot [array] -or $fallback.Count -ne 0 -or $options -isnot [array] -or $options.Count -lt 1 -or $options.Count -gt 128) { + throw 'Invalid Claude catalog' + } + $allowed = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::Ordinal) + foreach ($id in $ids) { + if (-not (Test-NeuroAPIModelId $id) -or -not $allowed.Add($id)) { throw 'Invalid model' } + } + if (-not (Test-NeuroAPIModelId $model) -or -not $allowed.Contains($model)) { throw 'Default model missing' } + $seen = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::Ordinal) + $safeOptions = foreach ($option in $options) { + Assert-NeuroAPIKeys $option @('model', 'label', 'description') + $id = Get-NeuroAPIProperty $option 'model' + if (-not (Test-NeuroAPIModelId $id) -or -not $allowed.Contains($id) -or -not $seen.Add($id)) { throw 'Invalid option' } + $safe = [ordered]@{ model = $id } + foreach ($field in @('label', 'description')) { + $value = Get-NeuroAPIProperty $option $field + if ($null -ne $value) { + $limit = if ($field -eq 'label') { 512 } else { 4096 } + if (-not (Test-NeuroAPIText $value $limit)) { throw 'Invalid option text' } + $safe[$field] = $value + } + } + $safe + } + if (-not $seen.Contains($model)) { throw 'Default model hidden from picker' } + $safeEnv = [ordered]@{ ANTHROPIC_BASE_URL = 'https://neuroapi.host/v1/claude-code'; ANTHROPIC_MODEL = $model } + foreach ($name in (Get-NeuroAPIClaudeEnvironmentOverrides)) { $safeEnv[$name] = '' } + $environment = Get-NeuroAPIProperty $data 'env' + Assert-NeuroAPIKeys $environment @('ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL', 'ANTHROPIC_DEFAULT_FABLE_MODEL') + foreach ($family in @('SONNET', 'OPUS', 'HAIKU', 'FABLE')) { + $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' } + $safeEnv[$key] = $value + } + } + return [ordered]@{ + model = $model + availableModels = @($ids) + enforceAvailableModels = $true + fallbackModel = @() + modelPicker = @{ options = @($safeOptions); replaceBuiltInOptions = $true } + env = $safeEnv + apiKeyHelper = $HelperCommand + } + } catch { + throw 'Каталог моделей NeuroAPI некорректен или пуст. Запуск отменен; сохраненный каталог не используется.' + } +} + +function Get-NeuroAPIClientCommand { + param([string]$Client) + $command = Get-Command $Client -CommandType Application, ExternalScript -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($null -eq $command) { throw 'Установите Codex CLI или Claude Code и откройте новый терминал.' } + return $command.Source +} + +function Assert-NeuroAPIClientVersion { + param([string]$Client, [string]$Command) + $minimum = if ($Client -eq 'codex') { [version]'0.147.0' } else { [version]'2.1.280' } + try { + $global:LASTEXITCODE = 0 + $output = @(& $Command --version 2>&1) -join ' ' + if ($LASTEXITCODE -ne 0 -or $output -notmatch '(?&2 exit 1 fi -mkdir -p "$TMP_ROOT/mock-bin" -cat >"$TMP_ROOT/mock-bin/codex" <<'EOF' -#!/bin/bash -set -euo pipefail -printf '%s\0' "$@" >"$NEUROAPI_AGENTS_TEST_ARGV" -EOF -chmod 700 "$TMP_ROOT/mock-bin/codex" -NEUROAPI_AGENTS_TEST_ARGV="$TMP_ROOT/codex-argv" PATH="$TMP_ROOT/mock-bin:$PATH" \ - "$NEUROAPI_AGENTS_BIN_ROOT/codex-neuroapi" 'prompt with spaces' -"$PYTHON_BIN" -c 'import pathlib,sys; assert pathlib.Path(sys.argv[1]).read_bytes().split(b"\0") == [b"--profile", b"neuroapi-host", b"prompt with spaces", b""]' "$TMP_ROOT/codex-argv" +"$PYTHON_BIN" "$REPO_ROOT/tests/macos/catalog_smoke.py" "$NEUROAPI_AGENTS_STATE_ROOT" "$NEUROAPI_AGENTS_BIN_ROOT" 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 diff --git a/tests/static/profile_contract.py b/tests/static/profile_contract.py index 223e418..56bbda6 100644 --- a/tests/static/profile_contract.py +++ b/tests/static/profile_contract.py @@ -6,7 +6,7 @@ profile_path, platform, helper_path, *secret_paths = sys.argv[1:] raw = pathlib.Path(profile_path).read_text(encoding="utf-8") profile = tomllib.loads(raw) -assert profile["model"] == "gpt-5.6-sol" +assert "model" not in profile # The authenticated startup catalog supplies it. assert profile["model_provider"] == "neuroapi" provider = profile["model_providers"]["neuroapi"] assert provider["base_url"] == "https://neuroapi.host/v1/codex" diff --git a/tests/static/validate.ps1 b/tests/static/validate.ps1 index e0afcfc..0d44009 100644 --- a/tests/static/validate.ps1 +++ b/tests/static/validate.ps1 @@ -125,7 +125,7 @@ $denyPatterns = @( ) $textExtensions = @( - '.py', '.ps1', '.sh', '.command', '.bat', '.md', '.json', '.toml', '.txt', '.yml', '.yaml' + '.py', '.js', '.ps1', '.sh', '.command', '.bat', '.md', '.json', '.toml', '.txt', '.yml', '.yaml' ) $textFiles = Get-ChildItem -LiteralPath $repoRoot -Recurse -File | Where-Object { diff --git a/tests/static/windows-profile.ps1 b/tests/static/windows-profile.ps1 index f7d5f7c..4d89c1b 100644 --- a/tests/static/windows-profile.ps1 +++ b/tests/static/windows-profile.ps1 @@ -24,6 +24,7 @@ $withoutKnownVariables = $template.Replace('$tomlHelperPath', '').Replace('$toml if ($withoutKnownVariables.Contains('$') -or $withoutKnownVariables.Contains('`')) { throw 'Unexpected executable interpolation in the profile template.' } +if ($template -match '(?m)^model =') { throw 'Bundled Codex model default is forbidden.' } $profile = $template.Replace('$tomlHelperPath', (ConvertTo-TomlBasicString -Value $helperPath)).Replace( '$tomlSecretPath', (ConvertTo-TomlBasicString -Value $secretPath) ) diff --git a/tests/windows/managed-catalog.ps1 b/tests/windows/managed-catalog.ps1 new file mode 100644 index 0000000..9e9f672 --- /dev/null +++ b/tests/windows/managed-catalog.ps1 @@ -0,0 +1,297 @@ +# These deterministic tests run on PowerShell 5.1 (Windows) and pwsh (other OS). +# Credential/network/process seams are mocked; no real keys or provider calls. +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +. (Join-Path $repoRoot 'scripts/windows/common.ps1') +. (Join-Path $repoRoot 'scripts/windows/managed-catalog.ps1') + +function Assert-Catalog { + param([bool]$Condition, [string]$Message) + if (-not $Condition) { throw $Message } +} +function Assert-CatalogFailure { + param([scriptblock]$Action) + $failed = $false + try { & $Action | Out-Null } catch { + $failed = $true + Assert-Catalog ($_.Exception.Message -notmatch 'test-neuroapi-token|private-provider-body') 'Error exposed sensitive data.' + } + Assert-Catalog $failed 'Expected fail-closed rejection.' +} +function New-TestCodexCatalog { + param([string]$Id) + return @{ models = @(@{ + slug = $Id; display_name = 'Test model'; description = 'Description'; base_instructions = 'Test instructions' + supported_in_api = $true; supports_reasoning_summary_parameter = $true; support_verbosity = $false + supports_parallel_tool_calls = $true; supports_search_tool = $false; use_responses_lite = $false + priority = 0; context_window = 128000; max_context_window = 128000; auto_compact_token_limit = 100000 + effective_context_window_percent = 95; input_token_limit = 128000; output_token_limit = 16000 + supported_reasoning_levels = @(@{ effort = 'medium'; description = 'Medium' }); shell_type = 'shell_command'; visibility = 'list' + model_messages = @{ instructions_template = 'Instructions' }; truncation_policy = @{ mode = 'tokens'; limit = 10000 } + experimental_supported_tools = @(); input_modalities = @('text') + }); default_model = $Id } | ConvertTo-Json -Depth 8 -Compress +} +function New-TestClaudeCatalog { + return @{ + model = 'claude-sonnet-test'; availableModels = @('claude-sonnet-test', 'claude-haiku-test', 'claude-opus-5', 'claude-opus-4.8') + enforceAvailableModels = $true; fallbackModel = @() + modelPicker = @{ options = @(@{ model = 'claude-sonnet-test'; label = 'Sonnet' }, @{ model = 'claude-haiku-test' }); replaceBuiltInOptions = $true } + env = @{ ANTHROPIC_DEFAULT_SONNET_MODEL = 'claude-sonnet-test'; ANTHROPIC_DEFAULT_HAIKU_MODEL = 'claude-haiku-test' } + } | ConvertTo-Json -Depth 8 -Compress +} + +$codex = ConvertFrom-NeuroAPICatalog codex (New-TestCodexCatalog 'model-one') '' +Assert-Catalog ($codex.DefaultModel -ceq 'model-one') 'Default model was not preserved.' +Assert-Catalog ($codex.Content.models[0].base_instructions -ceq 'Test instructions') 'Full ModelInfo was lost.' +$claude = ConvertFrom-NeuroAPICatalog claude (New-TestClaudeCatalog) 'local-helper' +Assert-Catalog ($claude.apiKeyHelper -ceq 'local-helper') 'Server replaced credential command.' +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 (-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 4 -and $claude.modelPicker.options.Count -eq 2) 'Compatibility aliases must remain available but hidden.' +Assert-Catalog ($claude.fallbackModel.Count -eq 0) 'Inherited fallback was not disabled.' +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 '' } +} +foreach ($mutation in @( + { param($x) $x.models[0].visibility = 'hide' }, + { param($x) $x.models[0].supported_in_api = $false }, + { param($x) $x.models[0].PSObject.Properties.Remove('context_window') }, + { param($x) $x.models[0].context_window = -1 }, + { param($x) $x.models[0].supported_reasoning_levels = @(@{ effort = 'wrong'; description = '' }) }, + { param($x) $x.models[0] | Add-Member -NotePropertyName hooks -NotePropertyValue @{} } +)) { + $payload = New-TestCodexCatalog 'model-one' | ConvertFrom-Json + & $mutation $payload + $invalid = $payload | ConvertTo-Json -Depth 8 -Compress + Assert-CatalogFailure { ConvertFrom-NeuroAPICatalog codex $invalid '' } +} +foreach ($mutation in @( + { param($x) $x | Add-Member -NotePropertyName hooks -NotePropertyValue @{ dangerous = 'ignore' } }, + { param($x) $x | Add-Member -NotePropertyName apiKeyHelper -NotePropertyValue 'untrusted command' }, + { param($x) $x.env | Add-Member -NotePropertyName UNSAFE_ENV -NotePropertyValue 'ignore' }, + { param($x) $x.availableModels = @() }, + { param($x) $x.enforceAvailableModels = $false }, + { param($x) $x.model = 'not-allowed' }, + { 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-test' }, + { param($x) $x.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = '' } +)) { + $payload = New-TestClaudeCatalog | ConvertFrom-Json + & $mutation $payload + $invalid = $payload | ConvertTo-Json -Depth 8 -Compress + Assert-CatalogFailure { ConvertFrom-NeuroAPICatalog claude $invalid 'local-helper' } +} + +Add-Type -AssemblyName System.Net.Http +if (-not ('NeuroAPITestHttpHandler' -as [type])) { + $references = @([Net.Http.HttpClient].Assembly.Location) + if ($PSVersionTable.PSEdition -eq 'Core') { $references += [Net.HttpStatusCode].Assembly.Location } + Add-Type -ReferencedAssemblies $references -TypeDefinition @' +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +public sealed class NeuroAPITestHttpHandler : HttpMessageHandler { + public int Status = 200; + public string Body = "{}"; + public string Uri; + public string Authorization; + public bool Fail; + public bool MisleadingLength; + public int Calls; + protected override Task SendAsync(HttpRequestMessage request, CancellationToken token) { + Calls++; + Uri = request.RequestUri.ToString(); + Authorization = request.Headers.Authorization.ToString(); + if (Fail) throw new HttpRequestException("private-provider-body test-neuroapi-token"); + var response = new HttpResponseMessage((HttpStatusCode)Status); + response.Content = new StringContent(Body); + if (MisleadingLength) response.Content.Headers.ContentLength = 1; + response.Headers.Location = new Uri("https://invalid.example/redirect"); + return Task.FromResult(response); + } +} +'@ +} +$originalClientFactory = ${function:New-NeuroAPICatalogClient} +function New-NeuroAPICatalogClient { return New-Object Net.Http.HttpClient($script:mockHttp) } +function Read-NeuroAPIProtectedCredential { param([string]$SecretPath) return 'test-neuroapi-token' } +foreach ($client in @('codex', 'claude')) { + $script:mockHttp = New-Object NeuroAPITestHttpHandler + $script:mockHttp.Body = New-TestCodexCatalog 'model-network' + $wire = Get-NeuroAPICatalogJson $client 'unused-secret-path' + Assert-Catalog ($wire -ceq $script:mockHttp.Body) 'Catalog response changed.' + Assert-Catalog ($script:mockHttp.Authorization -ceq 'Bearer test-neuroapi-token') 'Authorization missing.' + $expected = if ($client -eq 'codex') { 'https://neuroapi.host/v1/codex/models' } else { 'https://neuroapi.host/v1/claude-code/client-settings' } + Assert-Catalog ($script:mockHttp.Uri -ceq $expected) 'Wrong catalog endpoint.' +} +foreach ($status in @(301, 302, 401, 403, 429, 503)) { + $script:mockHttp = New-Object NeuroAPITestHttpHandler + $script:mockHttp.Status = $status + $script:mockHttp.Body = 'private-provider-body test-neuroapi-token' + Assert-CatalogFailure { Get-NeuroAPICatalogJson codex 'unused' } + Assert-Catalog ($script:mockHttp.Calls -eq 1) 'Redirect or retry occurred.' +} +$script:mockHttp = New-Object NeuroAPITestHttpHandler +$script:mockHttp.Fail = $true +Assert-CatalogFailure { Get-NeuroAPICatalogJson codex 'unused' } +$script:mockHttp = New-Object NeuroAPITestHttpHandler +$script:mockHttp.Body = 'x' * (2 * 1024 * 1024 + 1) +Assert-CatalogFailure { Get-NeuroAPICatalogJson codex 'unused' } +$script:mockHttp = New-Object NeuroAPITestHttpHandler +$script:mockHttp.Body = 'x' * (2 * 1024 * 1024 + 1) +$script:mockHttp.MisleadingLength = $true +Assert-CatalogFailure { Get-NeuroAPICatalogJson codex 'unused' } +foreach ($echoBody in @('{"display_name":"test-neuroapi-token"}', '{"display_name":"\u0074est-neuroapi-token"}', '{"\u0074est-neuroapi-token":"value"}')) { + $script:mockHttp = New-Object NeuroAPITestHttpHandler + $script:mockHttp.Body = $echoBody + Assert-CatalogFailure { Get-NeuroAPICatalogJson codex 'unused' } +} +# Ensure the actual factory (not the mocked handler) disables redirects/cookies. +$factorySource = $originalClientFactory.ToString() +Assert-Catalog ($factorySource.Contains('$handler.AllowAutoRedirect = $false')) 'Redirects are enabled.' +Assert-Catalog ($factorySource.Contains('$handler.UseCookies = $false')) 'Cookies are enabled.' + +# Exercise the actual version gate without installing or launching a client. +function Get-TestClientVersion { + $global:LASTEXITCODE = $script:versionExit + $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 = 'claude'; Text = 'unknown'; Valid = $false } +)) { + $script:versionExit = 0 + $script:versionText = $versionCase.Text + if ($versionCase.Valid) { + Assert-NeuroAPIClientVersion -Client $versionCase.Client -Command 'Get-TestClientVersion' + } else { + Assert-CatalogFailure { Assert-NeuroAPIClientVersion -Client $versionCase.Client -Command 'Get-TestClientVersion' } + } +} +$script:versionExit = 1 +$script:versionText = 'codex-cli 0.147.0' +Assert-CatalogFailure { Assert-NeuroAPIClientVersion -Client codex -Command 'Get-TestClientVersion' } + +$tempRoot = Join-Path ([IO.Path]::GetTempPath()) ('neuroapi-managed-test-' + [Guid]::NewGuid().ToString('N')) +$savedFast = $env:ANTHROPIC_SMALL_FAST_MODEL +$savedFable = $env:ANTHROPIC_DEFAULT_FABLE_MODEL +$savedHostProvider = $env:CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST +$savedCustomHeaders = $env:ANTHROPIC_CUSTOM_HEADERS +$savedAwsProvider = $env:CLAUDE_CODE_USE_ANTHROPIC_AWS +$savedToolFlag = $env:CLAUDE_CODE_USE_TEST_TOOL +try { + Ensure-Directory $tempRoot + Write-OwnerMarker (Get-StateMarkerPath $tempRoot) + # ACL/DPAPI require native Windows; test their integration in smoke.ps1. + function Set-NeuroAPIPrivateDirectory { param([string]$Path) } + function Get-NeuroAPIClientCommand { param([string]$Client) return 'mock-client' } + function Assert-NeuroAPIClientVersion { param([string]$Client, [string]$Command) } + $script:fetchCount = 0 + $script:childCount = 0 + $script:snapshots = @() + $script:catalogJson = New-TestCodexCatalog 'model-first' + function Get-NeuroAPICatalogJson { param([string]$Client, [string]$SecretPath) $script:fetchCount++; return $script:catalogJson } + function Invoke-NeuroAPIChild { + param([string]$Command, [string[]]$Arguments) + $script:childCount++ + Assert-Catalog (($Arguments -join ' ') -notmatch 'test-neuroapi-token') 'Token leaked to argv.' + if ($Arguments[0] -eq '--profile') { + Assert-Catalog ($Arguments[1] -ceq 'neuroapi-host') 'Named profile lost.' + $entry = $Arguments[3].Substring('model_catalog_json='.Length) + $script:snapshots += $entry + $catalogFile = Get-Content -LiteralPath $entry -Raw | ConvertFrom-Json + Assert-Catalog ($catalogFile.models[0].slug -ceq $script:expectedModel) 'Catalog was stale.' + Assert-Catalog ($Arguments[5] -ceq ("model='" + $script:expectedModel + "'")) 'Default model absent.' + Assert-Catalog ($Arguments[6] -ceq '--model' -and $Arguments[7] -ceq 'deliberate-model') 'User flags changed.' + Assert-Catalog ($Arguments[8] -ceq 'prompt with spaces') 'Prompt argument split.' + } else { + $entry = $Arguments[1] + $script:snapshots += $entry + $settings = Get-Content -LiteralPath $entry -Raw | ConvertFrom-Json + Assert-Catalog ($settings.apiKeyHelper -match 'get-neuroapi-key.ps1') 'Local helper absent.' + Assert-Catalog ($settings.model -ceq 'claude-sonnet-test') 'Managed model missing.' + Assert-Catalog ($settings.env.ANTHROPIC_CUSTOM_HEADERS -ceq '' -and $settings.env.CLAUDE_CODE_USE_ANTHROPIC_AWS -ceq '') 'Merged settings can override endpoint or auth.' + Assert-Catalog ($settings.env.ANTHROPIC_MODEL -ceq 'claude-sonnet-test') 'Merged settings can override model.' + Assert-Catalog ([string]::IsNullOrEmpty($env:ANTHROPIC_CUSTOM_HEADERS) -and [string]::IsNullOrEmpty($env:CLAUDE_CODE_USE_ANTHROPIC_AWS)) 'Inherited auth/provider override survived.' + Assert-Catalog ($env:CLAUDE_CODE_USE_TEST_TOOL -ceq 'keep') 'Unrelated tool switch was cleared.' + Assert-Catalog ([string]::IsNullOrEmpty($env:ANTHROPIC_SMALL_FAST_MODEL)) 'Inherited fast model overrides catalog.' + Assert-Catalog ([string]::IsNullOrEmpty($env:ANTHROPIC_DEFAULT_FABLE_MODEL)) 'Missing family inherited.' + Assert-Catalog ($env:ANTHROPIC_DEFAULT_HAIKU_MODEL -ceq 'claude-haiku-test') 'Concrete family missing.' + Assert-Catalog ($env:ANTHROPIC_BASE_URL -ceq 'https://neuroapi.host/v1/claude-code') 'Claude endpoint missing.' + } + $script:NeuroAPIChildExitCode = 37 + } + foreach ($model in @('model-first', 'model-second')) { + $script:catalogJson = New-TestCodexCatalog $model + $script:expectedModel = $model + Invoke-NeuroAPIManagedClient codex $tempRoot @('--model', 'deliberate-model', 'prompt with spaces') + Assert-Catalog ($script:NeuroAPIChildExitCode -eq 37) 'Child exit code changed.' + } + Assert-Catalog ($script:fetchCount -eq 2 -and $script:childCount -eq 2) 'Every launch must refresh.' + Assert-Catalog ($script:snapshots[0] -cne $script:snapshots[1]) 'Concurrent launches share a mutable snapshot.' + $script:catalogJson = '{invalid}' + Assert-CatalogFailure { Invoke-NeuroAPIManagedClient codex $tempRoot @() } + Assert-Catalog ($script:childCount -eq 2) 'Malformed catalog launched child.' + $script:catalogJson = New-TestClaudeCatalog + foreach ($hostManagedValue in @('1', 'true', 'yes', 'on', ' true ', ' YES ', 'On')) { + $env:CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST = $hostManagedValue + $beforeHostBlock = $script:fetchCount + Assert-CatalogFailure { Invoke-NeuroAPIManagedClient claude $tempRoot @() } + Assert-Catalog ($script:fetchCount -eq $beforeHostBlock) 'Host-managed policy was bypassed.' + } + $env:CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST = $null + $env:ANTHROPIC_CUSTOM_HEADERS = 'Authorization: foreign-value' + $env:CLAUDE_CODE_USE_ANTHROPIC_AWS = '1' + $env:CLAUDE_CODE_USE_TEST_TOOL = 'keep' + $env:ANTHROPIC_SMALL_FAST_MODEL = 'old-fast-model' + $env:ANTHROPIC_DEFAULT_FABLE_MODEL = 'old-fable' + Invoke-NeuroAPIManagedClient claude $tempRoot @('--print', 'hello') + Assert-Catalog ($env:ANTHROPIC_SMALL_FAST_MODEL -ceq 'old-fast-model') 'Parent environment changed.' + Assert-Catalog ($env:ANTHROPIC_DEFAULT_FABLE_MODEL -ceq 'old-fable') 'Parent family changed.' + # Exercise the actual launcher wrapper in an isolated child host: arbitrary + # flags must remain data and the command's exit code must survive the wrapper. + $wrapperRoot = Join-Path $tempRoot 'wrapper' + $wrapperBin = Join-Path $wrapperRoot 'bin' + Ensure-Directory $wrapperBin + Copy-Item (Join-Path $repoRoot 'scripts/windows/launch-neuroapi.ps1') (Join-Path $wrapperBin 'launch-neuroapi.ps1') + Write-Utf8NoBom (Join-Path $wrapperBin 'common.ps1') '' + $mockLibrary = @' +function Invoke-NeuroAPIManagedClient { + param([string]$Client, [string]$StateRoot, [string[]]$ClientArguments) + [IO.File]::WriteAllText((Join-Path $StateRoot 'args.json'), (@{client=$Client;arguments=@($ClientArguments)} | ConvertTo-Json)) + $script:NeuroAPIChildExitCode = 37 +} +'@ + Write-Utf8NoBom (Join-Path $wrapperBin 'managed-catalog.ps1') $mockLibrary + $hostExecutable = (Get-Process -Id $PID).Path + & $hostExecutable -NoLogo -NoProfile -ExecutionPolicy Bypass -File (Join-Path $wrapperBin 'launch-neuroapi.ps1') codex --model 'model-explicit' 'prompt with spaces' + Assert-Catalog ($LASTEXITCODE -eq 37) 'Launcher did not forward the exit code.' + $forwarded = Get-Content (Join-Path $wrapperRoot 'args.json') -Raw | ConvertFrom-Json + Assert-Catalog ($forwarded.client -ceq 'codex' -and $forwarded.arguments.Count -eq 3) 'Launcher consumed a client flag.' + Assert-Catalog ($forwarded.arguments[0] -ceq '--model' -and $forwarded.arguments[2] -ceq 'prompt with spaces') 'Launcher changed arguments.' + Remove-Item -LiteralPath $wrapperRoot -Recurse -Force + foreach ($snapshot in $script:snapshots) { Assert-Catalog (-not (Test-Path -LiteralPath $snapshot)) 'Launch snapshot was retained.' } + Assert-Catalog (@(Get-ChildItem -LiteralPath $tempRoot -Directory).Count -eq 0) 'Launch directory was retained.' +} finally { + $env:ANTHROPIC_SMALL_FAST_MODEL = $savedFast + $env:ANTHROPIC_DEFAULT_FABLE_MODEL = $savedFable + $env:CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST = $savedHostProvider + $env:ANTHROPIC_CUSTOM_HEADERS = $savedCustomHeaders + $env:CLAUDE_CODE_USE_ANTHROPIC_AWS = $savedAwsProvider + $env:CLAUDE_CODE_USE_TEST_TOOL = $savedToolFlag + if (Test-Path -LiteralPath $tempRoot) { Remove-Item -LiteralPath $tempRoot -Recurse -Force } +} +$global:LASTEXITCODE = 0 +Write-Host 'Windows managed catalog mock tests passed (native DPAPI/ACL require Windows smoke).' diff --git a/tests/windows/smoke.ps1 b/tests/windows/smoke.ps1 index 82089e7..b9b0e6c 100644 --- a/tests/windows/smoke.ps1 +++ b/tests/windows/smoke.ps1 @@ -64,9 +64,23 @@ try { -SecretPath $secretPath Assert-True ($helperOutput -ceq 'test-neuroapi-token') 'Credential helper returned the wrong value.' + . (Join-Path $repoRoot 'scripts/windows/managed-catalog.ps1') + $privateSnapshot = New-NeuroAPILaunchDirectory -StateRoot $stateRoot + $snapshotAcl = Get-Acl -LiteralPath $privateSnapshot + Assert-True $snapshotAcl.AreAccessRulesProtected 'Launch snapshot inherited a public ACL.' + $currentSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value + foreach ($rule in $snapshotAcl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])) { + Assert-True ($rule.IdentityReference.Value -ceq $currentSid) 'Launch snapshot grants another account access.' + } + Remove-Item -LiteralPath $privateSnapshot -Force + $settings = Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json - Assert-True ($settings.env.ANTHROPIC_BASE_URL -ceq 'https://neuroapi.host') 'Wrong Claude base URL.' - Assert-True ($settings.env.ANTHROPIC_MODEL -ceq 'claude-sonnet-4-5') 'Wrong Claude model.' + Assert-True ($settings.env.ANTHROPIC_BASE_URL -ceq 'https://neuroapi.host/v1/claude-code') 'Wrong Claude base URL.' + Assert-True (-not ($settings.env.PSObject.Properties.Name -contains 'ANTHROPIC_MODEL')) 'A bundled model must not override fresh catalog defaults.' + Assert-True ($profileBefore -notmatch '(?m)^model =') 'Codex default must come from the fresh catalog.' + foreach ($launcherFile in @('common.ps1', 'managed-catalog.ps1', 'launch-neuroapi.ps1')) { + Assert-True (Test-Path -LiteralPath (Join-Path $stateRoot ('bin/' + $launcherFile))) 'Managed launcher dependency missing.' + } Assert-True (-not ($settings.PSObject.Properties.Name -contains 'ANTHROPIC_AUTH_TOKEN')) 'Secret leaked into settings.' Assert-True ($settings.apiKeyHelper -match 'get-neuroapi-key\.ps1') 'Claude helper is not configured.' $claudeHelperOutput = & cmd.exe /d /s /c $settings.apiKeyHelper @@ -145,6 +159,7 @@ try { (Get-Content -LiteralPath $profilePath -Raw) -match 'user-owned' ) 'Setup changed an unowned Codex profile.' + & (Join-Path $PSScriptRoot 'managed-catalog.ps1') Write-Host 'Windows installer smoke test passed.' } finally { From 8ae367e0afc2b1e04987bbb2ad9ee8270fdcaa2a Mon Sep 17 00:00:00 2001 From: Roman Date: Sat, 26 Sep 2026 21:41:18 +0800 Subject: [PATCH 3/3] chore(agents): refresh installer examples for Claude Opus 5.5 --- README.en.md | 2 ++ README.md | 2 ++ docs/manual-setup.md | 2 ++ tests/macos/catalog_smoke.py | 5 ++--- tests/windows/managed-catalog.ps1 | 16 ++++++++-------- 5 files changed, 16 insertions(+), 11 deletions(-) diff --git a/README.en.md b/README.en.md index c5b40c4..f02a9e7 100644 --- a/README.en.md +++ b/README.en.md @@ -51,6 +51,8 @@ This prevents accidental plaintext disclosure. It does not protect a key from ma Each launcher fetches a fresh catalog scoped to the ordinary NeuroAPI key. Codex uses a private `model_catalog_json`; Claude receives a configured picker. There are no hardcoded default models. Codex 0.147.0+ and Claude Code 2.1.280+ are required. Invalid, empty or unavailable catalogs stop launch instead of restoring stale lists. Organization policies and deliberate CLI overrides retain their documented precedence; these launchers do not support host-managed provider mode. +Recommended server selection as of 2026-09-26: GPT-6 Sol, Astra and Luna for Codex; **Opus 5.5** (`claude-opus-5-5`, preferred), Sonnet 5, Haiku 4.5 and Fable 5.1 for Claude Code. Opus 5.5 becomes the default after publication and key eligibility; otherwise the next available recommendation is selected. Official ID: [Anthropic](https://www.anthropic.com/claude/opus). + ## More - [Security model](docs/security.md) diff --git a/README.md b/README.md index 2cca045..85b8958 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,8 @@ Claude Code: При каждом запуске launcher получает актуальный список для вашего обычного ключа NeuroAPI. Codex использует отдельный каталог, Claude Code — настроенное меню; фиксированных моделей в установщике нет. Нужны Codex 0.147.0+ и Claude Code 2.1.280+. При ошибке обновления или пустом списке запуск останавливается, не возвращаясь к старым моделям. Подробности и ограничения managed-политик: [ручная настройка](docs/manual-setup.md). +Рекомендуемый серверный набор на 26.09.2026: Codex — GPT-6 Sol, Astra и Luna; Claude Code — **Opus 5.5** (`claude-opus-5-5`, приоритетный), Sonnet 5, Haiku 4.5 и Fable 5.1. Opus 5.5 выбирается после публикации модели на сервисе и появления доступа у ключа; до этого используется следующая доступная рекомендация. Официальный ID: [Anthropic](https://www.anthropic.com/claude/opus). + ## Удаление и замена ключа - Чтобы заменить ключ, повторно запустите setup-файл — защищённое значение обновится. diff --git a/docs/manual-setup.md b/docs/manual-setup.md index c5bf9f0..c1060bb 100644 --- a/docs/manual-setup.md +++ b/docs/manual-setup.md @@ -86,6 +86,8 @@ 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 остаются только совместимыми служебными вариантами вне рекомендуемого меню. Сохранённая администратором настройка каталога имеет приоритет над этим порядком. + Специальный ключ не нужен. Можно подключаться вручную через обычный API: сервер распознаёт известные заголовки Codex/Claude на `/v1/models`. Однако клиент должен сам запросить каталог, а встроенные варианты могут сохраниться. Управляемые launchers — дополнительный способ получить заданное меню, а не условие доступа к API. ## Официальные контракты diff --git a/tests/macos/catalog_smoke.py b/tests/macos/catalog_smoke.py index a8aab15..37f0f46 100644 --- a/tests/macos/catalog_smoke.py +++ b/tests/macos/catalog_smoke.py @@ -28,10 +28,10 @@ def catalog(slug="test-model"): return {"models": [model(slug)], "default_model": slug} -def claude_catalog(slug="claude-sonnet-test"): +def claude_catalog(slug="claude-opus-5-5"): return dict(model=slug, availableModels=[slug], enforceAvailableModels=True, fallbackModel=[], modelPicker={"options": [{"model": slug, "label": "Test"}], "replaceBuiltInOptions": True}, - env={"ANTHROPIC_DEFAULT_SONNET_MODEL": slug}) + env={"ANTHROPIC_DEFAULT_OPUS_MODEL": slug}) with tempfile.TemporaryDirectory(prefix="neuroapi-catalog-test-") as temp: @@ -156,7 +156,6 @@ def run(payload, client="codex", success=True, user_args=None, **extra): run(claude_catalog(), client="claude", CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST=host_unmanaged) hidden = claude_catalog() hidden['availableModels'].append('claude-opus-4.8') - hidden['env']['ANTHROPIC_DEFAULT_OPUS_MODEL'] = 'claude-opus-4.8' run(hidden, client="claude") wrong_family = claude_catalog() wrong_family['env']['ANTHROPIC_DEFAULT_FABLE_MODEL'] = wrong_family['model'] diff --git a/tests/windows/managed-catalog.ps1 b/tests/windows/managed-catalog.ps1 index 9e9f672..56295e2 100644 --- a/tests/windows/managed-catalog.ps1 +++ b/tests/windows/managed-catalog.ps1 @@ -34,10 +34,10 @@ function New-TestCodexCatalog { } function New-TestClaudeCatalog { return @{ - model = 'claude-sonnet-test'; availableModels = @('claude-sonnet-test', 'claude-haiku-test', 'claude-opus-5', 'claude-opus-4.8') + model = 'claude-opus-5-5'; availableModels = @('claude-opus-5-5', 'claude-sonnet-5', 'claude-haiku-4-5', 'claude-opus-5', 'claude-opus-4.8') enforceAvailableModels = $true; fallbackModel = @() - modelPicker = @{ options = @(@{ model = 'claude-sonnet-test'; label = 'Sonnet' }, @{ model = 'claude-haiku-test' }); replaceBuiltInOptions = $true } - env = @{ ANTHROPIC_DEFAULT_SONNET_MODEL = 'claude-sonnet-test'; ANTHROPIC_DEFAULT_HAIKU_MODEL = 'claude-haiku-test' } + modelPicker = @{ options = @(@{ model = 'claude-opus-5-5'; label = 'Opus 5.5' }, @{ model = 'claude-sonnet-5'; label = 'Sonnet 5' }, @{ model = 'claude-haiku-4-5' }); replaceBuiltInOptions = $true } + env = @{ ANTHROPIC_DEFAULT_OPUS_MODEL = 'claude-opus-5-5'; ANTHROPIC_DEFAULT_SONNET_MODEL = 'claude-sonnet-5'; ANTHROPIC_DEFAULT_HAIKU_MODEL = 'claude-haiku-4-5' } } | ConvertTo-Json -Depth 8 -Compress } @@ -51,7 +51,7 @@ Assert-Catalog ($claude.env.ANTHROPIC_CUSTOM_HEADERS -ceq '' -and $claude.env.AN Assert-Catalog ($claude.env.ANTHROPIC_MODEL -ceq $claude.model) 'Merged model override can bypass default.' 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 4 -and $claude.modelPicker.options.Count -eq 2) 'Compatibility aliases must remain available but hidden.' +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.' 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 +79,7 @@ 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-test' }, + { param($x) $x.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = 'claude-sonnet-5' }, { param($x) $x.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = '' } )) { $payload = New-TestClaudeCatalog | ConvertFrom-Json @@ -221,14 +221,14 @@ try { $script:snapshots += $entry $settings = Get-Content -LiteralPath $entry -Raw | ConvertFrom-Json Assert-Catalog ($settings.apiKeyHelper -match 'get-neuroapi-key.ps1') 'Local helper absent.' - Assert-Catalog ($settings.model -ceq 'claude-sonnet-test') 'Managed model missing.' + Assert-Catalog ($settings.model -ceq 'claude-opus-5-5') 'Managed model missing.' Assert-Catalog ($settings.env.ANTHROPIC_CUSTOM_HEADERS -ceq '' -and $settings.env.CLAUDE_CODE_USE_ANTHROPIC_AWS -ceq '') 'Merged settings can override endpoint or auth.' - Assert-Catalog ($settings.env.ANTHROPIC_MODEL -ceq 'claude-sonnet-test') 'Merged settings can override model.' + Assert-Catalog ($settings.env.ANTHROPIC_MODEL -ceq 'claude-opus-5-5') 'Merged settings can override model.' Assert-Catalog ([string]::IsNullOrEmpty($env:ANTHROPIC_CUSTOM_HEADERS) -and [string]::IsNullOrEmpty($env:CLAUDE_CODE_USE_ANTHROPIC_AWS)) 'Inherited auth/provider override survived.' Assert-Catalog ($env:CLAUDE_CODE_USE_TEST_TOOL -ceq 'keep') 'Unrelated tool switch was cleared.' Assert-Catalog ([string]::IsNullOrEmpty($env:ANTHROPIC_SMALL_FAST_MODEL)) 'Inherited fast model overrides catalog.' Assert-Catalog ([string]::IsNullOrEmpty($env:ANTHROPIC_DEFAULT_FABLE_MODEL)) 'Missing family inherited.' - Assert-Catalog ($env:ANTHROPIC_DEFAULT_HAIKU_MODEL -ceq 'claude-haiku-test') 'Concrete family missing.' + Assert-Catalog ($env:ANTHROPIC_DEFAULT_HAIKU_MODEL -ceq 'claude-haiku-4-5') 'Concrete family missing.' Assert-Catalog ($env:ANTHROPIC_BASE_URL -ceq 'https://neuroapi.host/v1/claude-code') 'Claude endpoint missing.' } $script:NeuroAPIChildExitCode = 37