From 049c4d13380f014f4298a1b73628754cdda003d4 Mon Sep 17 00:00:00 2001 From: yilsem Date: Tue, 13 Oct 2026 17:22:28 +0300 Subject: [PATCH] fix(installer): preserve healthy Python runtime and Codex connections --- .gitea/workflows/ci.yml | 2 + apps/desktop/assets/codex-bridge/bridge.py | 2 +- apps/desktop/assets/codex-bridge/kopru.py | 2 +- apps/desktop/electron/codex-link-config.ts | 2 +- apps/desktop/package.json | 2 +- package-lock.json | 2 +- scripts/install.ps1 | 46 ++++++++++++ tests/test_install_ps1_reuse_healthy_venv.py | 77 ++++++++++++++++++++ 8 files changed, 130 insertions(+), 5 deletions(-) create mode 100644 tests/test_install_ps1_reuse_healthy_venv.py diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 45eaba6..c1a3285 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -35,6 +35,8 @@ jobs: "$RUNNER_TEMP/ci-venv/bin/python" -m pytest tests/tools/test_protected_instruction_unattended.py tests/tools/test_file_write_safety.py tests/tools/test_computer_use_approval_isolation.py tests/cli/test_cli_approval_ui.py tests/cli/test_single_query_session_finalize.py -q -p no:cacheprovider -rf - name: Codex bridge regression tests run: '"$RUNNER_TEMP/ci-venv/bin/python" -m unittest discover -s apps/desktop/assets/codex-bridge' + - name: Managed Python update preservation + run: '"$RUNNER_TEMP/ci-venv/bin/python" -m pytest tests/test_install_ps1_reuse_healthy_venv.py tests/test_install_ps1_python_fallback_venv.py -q -p no:cacheprovider -rf' - name: Project scoped and bounded search regressions run: '"$RUNNER_TEMP/ci-venv/bin/python" -m pytest tests/tools/test_search_project_scope.py tests/tools/test_search_files_cpu_windows.py tests/tools/test_search_zero_match_and_multipath.py tests/tools/test_search_giant_line_containment.py tests/tools/test_search_budget_truncation.py -q -p no:cacheprovider -rf' - name: Native watch and speech mux pytest regressions diff --git a/apps/desktop/assets/codex-bridge/bridge.py b/apps/desktop/assets/codex-bridge/bridge.py index e1c6006..dd30357 100644 --- a/apps/desktop/assets/codex-bridge/bridge.py +++ b/apps/desktop/assets/codex-bridge/bridge.py @@ -35,7 +35,7 @@ import gecis_context import terminal_stream HERE = os.path.dirname(os.path.abspath(__file__)) -SURUM = "17" # AITURK IDE'nin bekledigi kopru surumu (codex-link-config.ts CODEX_BRIDGE_VERSION) +SURUM = "18" # AITURK IDE'nin bekledigi kopru surumu (codex-link-config.ts CODEX_BRIDGE_VERSION) try: with open(os.path.abspath(__file__), "rb") as _f: KOD = hashlib.sha256(_f.read()).hexdigest()[:8] diff --git a/apps/desktop/assets/codex-bridge/kopru.py b/apps/desktop/assets/codex-bridge/kopru.py index 49c29f0..f0f26cb 100644 --- a/apps/desktop/assets/codex-bridge/kopru.py +++ b/apps/desktop/assets/codex-bridge/kopru.py @@ -40,7 +40,7 @@ from bekci_watch import MetadataFilter # Installed only by owned native launcher, never an HTTP control endpoint. WATCH_OWNER = None -SURUM = "17" +SURUM = "18" HERE = os.path.dirname(os.path.abspath(__file__)) try: with open(os.path.abspath(__file__), "rb") as _f: diff --git a/apps/desktop/electron/codex-link-config.ts b/apps/desktop/electron/codex-link-config.ts index f6a13ba..9571270 100644 --- a/apps/desktop/electron/codex-link-config.ts +++ b/apps/desktop/electron/codex-link-config.ts @@ -27,7 +27,7 @@ export const CODEX_LINK_REFRESH_URL = `${CODEX_LINK_ORIGIN}/aiturk/codex-baglama export const CODEX_LINK_MARKER = '# AITURK IDE Codex Baglama' // Must equal SURUM in assets/codex-bridge/bridge.py; a running bridge that reports // another version is restarted so an updated IDE never keeps an old bridge alive. -export const CODEX_BRIDGE_VERSION = '17' +export const CODEX_BRIDGE_VERSION = '18' export const CODEX_LINK_LINE = `openai_base_url = "${CODEX_LINK_ORIGIN}/backend-api/codex" ${CODEX_LINK_MARKER}` export const CODEX_LINK_RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run' diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 08b89fd..8dbc6ed 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -2,7 +2,7 @@ "name": "aiturk-ide", "productName": "AITURK IDE", "private": true, - "version": "1.0.0-beta.36", + "version": "1.0.0-beta.37", "description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.", "author": "AITURK / TurkServis", "repository": { diff --git a/package-lock.json b/package-lock.json index a14ba37..237c50e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,7 +65,7 @@ }, "apps/desktop": { "name": "aiturk-ide", - "version": "1.0.0-beta.36", + "version": "1.0.0-beta.37", "license": "MIT", "dependencies": { "@assistant-ui/core": "0.2.23", diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 05684f5..e3261cb 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -2550,6 +2550,39 @@ function Install-Repository { Write-Success "Repository ready" } +function Test-ReusableManagedVenv { + param([string]$VenvPath, [string]$BasePython, [string]$Version) + $pythonExe = Join-Path $VenvPath "Scripts\python.exe" + if (-not (Test-Path -LiteralPath $pythonExe -PathType Leaf)) { return $false } + $process = New-Object System.Diagnostics.Process + try { + $info = New-Object System.Diagnostics.ProcessStartInfo + $info.FileName = $pythonExe + $info.Arguments = '-I -c "import json,sys,ssl,ctypes,venv,encodings; print(json.dumps(dict(version=str(sys.version_info.major)+''.''+str(sys.version_info.minor),prefix=sys.prefix,base=sys._base_executable)))"' + $info.UseShellExecute = $false + $info.CreateNoWindow = $true + $info.RedirectStandardOutput = $true + $info.RedirectStandardError = $true + $process.StartInfo = $info + if (-not $process.Start()) { return $false } + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + if (-not $process.WaitForExit(15000)) { + try { $process.Kill() } catch { } + return $false + } + if ($process.ExitCode -ne 0) { return $false } + $health = $stdout.Result | ConvertFrom-Json + return ($health.version -eq $Version -and + [string]::Equals([IO.Path]::GetFullPath($health.prefix), [IO.Path]::GetFullPath($VenvPath), [StringComparison]::OrdinalIgnoreCase) -and + [string]::Equals([IO.Path]::GetFullPath($health.base), [IO.Path]::GetFullPath($BasePython), [StringComparison]::OrdinalIgnoreCase)) + } catch { + return $false + } finally { + $process.Dispose() + } +} + function Install-Venv { if ($NoVenv) { Write-Info "Skipping virtual environment (-NoVenv)" @@ -2567,6 +2600,19 @@ function Install-Venv { throw "Hermes-managed Python is unavailable. Run install.ps1 -Stage python first." } + # Routine source updates do not require replacing a healthy interpreter. + # Recreating it kills detached Codex bridges sharing this venv and leaves + # every subscription model disconnected until bootstrap finishes. Verify + # version, provenance and stdlib first; the dependency stage still performs + # the locked sync and import checks. Broken/mismatched runtimes retain the + # transactional repair path below. + $existingVenvPath = Join-Path $InstallDir "venv" + if (Test-ReusableManagedVenv -VenvPath $existingVenvPath -BasePython $resolvedPython.Path -Version $resolvedPython.Version) { + $env:UV_PYTHON = Join-Path $existingVenvPath "Scripts\python.exe" + Write-Success "Reusing healthy managed virtual environment; running Codex bridges preserved" + return + } + Write-Info "Creating virtual environment with Python $($resolvedPython.Version)..." Push-Location $InstallDir diff --git a/tests/test_install_ps1_reuse_healthy_venv.py b/tests/test_install_ps1_reuse_healthy_venv.py new file mode 100644 index 0000000..2f88ad4 --- /dev/null +++ b/tests/test_install_ps1_reuse_healthy_venv.py @@ -0,0 +1,77 @@ +"""Run the actual installer functions against a real disposable Python venv.""" +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +SOURCE = Path(__file__).resolve().parents[1] / 'scripts/install.ps1' +PS = shutil.which('powershell') or shutil.which('pwsh') +pytestmark = pytest.mark.skipif(os.name != 'nt' or not PS, reason='Windows installer runtime test') + + +def function(source, name): + start = source.index('function ' + name + ' {') + opening = source.index('{', start) + depth = 0 + for index in range(opening, len(source)): + if source[index] == '{': depth += 1 + elif source[index] == '}': depth -= 1 + if depth == 0: return source[start:index + 1] + raise AssertionError(name) + + +@pytest.fixture(scope='module') +def runtime(tmp_path_factory): + root = tmp_path_factory.mktemp('installer-reuse') + subprocess.run([sys.executable, '-m', 'venv', '--without-pip', str(root / 'venv')], check=True) + return root + + +def run(runtime, body): + text = SOURCE.read_text(encoding='utf-8-sig') + script = runtime / 'probe.ps1' + prefix = "$ErrorActionPreference = 'Stop'\n" + prefix += function(text, 'Test-ReusableManagedVenv') + '\n' + prefix += function(text, 'Install-Venv') + '\n' + prefix += "$InstallDir = '" + str(runtime).replace("'", "''") + "'\n" + prefix += "$BasePython = '" + sys._base_executable.replace("'", "''") + "'\n" + prefix += "$Version = '" + str(sys.version_info.major) + '.' + str(sys.version_info.minor) + "'\n" + script.write_text(prefix + body, encoding='utf-8-sig') + result = subprocess.run([PS, '-NoProfile', '-File', str(script)], capture_output=True, text=True, timeout=40) + assert result.returncode == 0, result.stdout + result.stderr + return result.stdout + + +def test_healthy_update_keeps_live_python_and_never_reaches_kill_sweep(runtime): + python = runtime / 'venv/Scripts/python.exe' + live = subprocess.Popen([str(python), '-c', 'import time;time.sleep(90)']) + try: + output = run(runtime, r''' +function Resolve-AvailablePythonVersion { return @{Path=$BasePython;Version=$Version} } +function Write-Success { param($Message); Write-Output $Message } +function Write-Info { throw 'Unexpected venv recreation/kill path' } +function taskkill { throw 'Live process must not be killed' } +Install-Venv +''') + assert 'Reusing healthy' in output + assert live.poll() is None + assert python.is_file() + finally: + live.terminate();live.wait(timeout=10) + + +def test_wrong_version_or_foreign_interpreter_does_not_reuse(runtime): + output = run(runtime, r''' +$venvPath = Join-Path $InstallDir 'venv' +$results = @( + (Test-ReusableManagedVenv $venvPath $BasePython '0.0'), + (Test-ReusableManagedVenv $venvPath (Join-Path $InstallDir 'other-python.exe') $Version), + (Test-ReusableManagedVenv (Join-Path $InstallDir 'missing') $BasePython $Version) +) +ConvertTo-Json -Compress -InputObject $results +''') + assert json.loads(output) == [False, False, False]