diff --git a/.gitattributes b/.gitattributes index 393d4c6..fc3b7e0 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ *.sh text eol=lf docker-entrypoint.sh text eol=lf +*.bat text eol=crlf diff --git a/.github/workflows/build-portable-windows.yml b/.github/workflows/build-portable-windows.yml index 1099335..a21c5b6 100644 --- a/.github/workflows/build-portable-windows.yml +++ b/.github/workflows/build-portable-windows.yml @@ -18,6 +18,8 @@ on: paths: - 'contextcortex.spec' - 'scripts/build_portable_windows.ps1' + - 'scripts/portable/**' + - 'main.py' - 'app/services/database/portable_postgres.py' - '.github/workflows/build-portable-windows.yml' @@ -77,6 +79,36 @@ jobs: if (-not (Test-Path "$bundle/start-contextcortex.bat")) { throw "Missing start-contextcortex.bat" } Write-Host "All portable bundle artifacts verified!" -ForegroundColor Green + - name: Runtime Smoke Test (start exe, bootstrap portable PostgreSQL, check /ready) + shell: pwsh + run: | + $bundle = Resolve-Path "dist/contextcortex-windows-x64" + # Keep test data out of the bundle so it is not zipped + $env:DATA_DIR = Join-Path $env:RUNNER_TEMP "cc-smoke-data" + $env:HOST = "127.0.0.1" + $env:PORT = "3999" + $env:PORTABLE_POSTGRES_ENABLED = "true" + $env:PORTABLE_PG_PORT = "54399" + $env:VECTOR_STORE_PROVIDER = "pgvector" + $env:EMBEDDING_PROVIDER = "api" + $env:EMBEDDING_MODEL = "text-embedding-3-small" + $log = Join-Path $env:RUNNER_TEMP "cc-smoke.log" + $proc = Start-Process -FilePath "$bundle/contextcortex.exe" -WorkingDirectory $bundle -PassThru -NoNewWindow -RedirectStandardOutput $log -RedirectStandardError "$log.err" + $ready = $false + for ($i = 0; $i -lt 90; $i++) { + Start-Sleep -Seconds 2 + if ($proc.HasExited) { break } + try { + $r = Invoke-RestMethod -Uri "http://127.0.0.1:3999/ready" -TimeoutSec 3 + if ($r.status -eq "ready" -and $r.database.type -eq "postgresql") { $ready = $true; break } + } catch { } + } + if (-not $proc.HasExited) { Stop-Process -Id $proc.Id -Force } + Get-Process postgres -ErrorAction SilentlyContinue | Stop-Process -Force + Get-Content $log, "$log.err" -ErrorAction SilentlyContinue | Select-Object -Last 60 + if (-not $ready) { throw "Portable bundle did not become ready with portable PostgreSQL" } + Write-Host "Portable bundle started and reported ready on PostgreSQL + pgvector." -ForegroundColor Green + - name: Create Portable Zip Archive shell: pwsh run: | diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index c26cd0a..95b99a2 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -2,7 +2,7 @@ > **Note:** This document is automatically generated and verified against the live test suite by `scripts/generate_requirements.py` and `tests/backend/test_requirements_sync.py`. -**Test Verification Baseline:** **1179 Automated Tests** (819 Pytest Backend + 310 Vitest Frontend + 50 Playwright E2E). +**Test Verification Baseline:** **1180 Automated Tests** (820 Pytest Backend + 310 Vitest Frontend + 50 Playwright E2E). --- @@ -1298,11 +1298,12 @@ and leaves the prior indexed state intact without data loss._ - `test_poller_worker_cycle` - `test_poller_worker_disabled_interval` -#### `tests/test_portable_postgres.py` (5 tests) +#### `tests/test_portable_postgres.py` (6 tests) - `test_find_free_port_returns_valid_port` - _Verifies that find_free_port returns an available TCP port on localhost._ - `test_portable_postgres_availability_detection` - _Verifies that PortablePostgresManager accurately detects when PostgreSQL binaries exist._ - `test_portable_postgres_initialize_cluster_writes_config` - _Verifies that initialize_cluster runs initdb and appends user-space tuning to postgresql.conf._ - `test_portable_postgres_start_and_stop_lifecycle` - _Verifies that start() launches pg_ctl, provisions database + vector extension, sets env vars, and stop() halts the cluster._ +- `test_pg_ctl_start_does_not_capture_output_pipes` - _Verifies that pg_ctl start writes to a file instead of a pipe (an inherited pipe blocks start() forever on Windows)._ - `test_bootstrap_skips_when_database_url_already_set` - _Verifies that bootstrap_portable_postgres_if_needed respects an existing DATABASE_URL._ #### `tests/test_postgres_unified_connection.py` (5 tests) diff --git a/app/services/database/portable_postgres.py b/app/services/database/portable_postgres.py index 71d8302..4da8f62 100644 --- a/app/services/database/portable_postgres.py +++ b/app/services/database/portable_postgres.py @@ -203,8 +203,29 @@ def start(self) -> str: "-w", "start", ] - res = subprocess.run(cmd, capture_output=True, text=True, check=False) - if res.returncode != 0: + # pg_ctl starts postgres through a child shell that inherits pg_ctl's + # stdout/stderr handles. On Windows, capture_output=True then blocks + # forever: the pipes stay open for as long as the server runs. Send + # pg_ctl's output to a file so that no pipe is inherited. + out_file = self.log_file.parent / "pg_ctl.out" + try: + with open(out_file, "w", encoding="utf-8", errors="ignore") as out: + rc = subprocess.run( + cmd, + stdin=subprocess.DEVNULL, + stdout=out, + stderr=subprocess.STDOUT, + check=False, + timeout=120, + ).returncode + except subprocess.TimeoutExpired: + rc = -1 + if rc != 0: + pg_ctl_out = "" + try: + pg_ctl_out = out_file.read_text(encoding="utf-8", errors="ignore")[-1500:] + except Exception: + pass log_tail = "" if self.log_file.exists(): try: @@ -212,8 +233,8 @@ def start(self) -> str: except Exception: pass raise RuntimeError( - f"Portable PostgreSQL failed to start (exit {res.returncode}): " - f"{res.stderr or res.stdout}\nLog tail:\n{log_tail}" + f"Portable PostgreSQL failed to start (exit {rc}): " + f"{pg_ctl_out}\nLog tail:\n{log_tail}" ) self._started_by_us = True atexit.register(self.stop) diff --git a/app/services/litellm_service.py b/app/services/litellm_service.py index 4e0cdae..3855336 100644 --- a/app/services/litellm_service.py +++ b/app/services/litellm_service.py @@ -89,7 +89,7 @@ async def discover_models( headers = {**get_llm_extra_headers(), "Authorization": f"Bearer {resolved_api_key}"} try: - async with httpx.AsyncClient(timeout=6.0) as client: + async with httpx.AsyncClient(timeout=15.0) as client: response = await client.get(endpoint, headers=headers) if response.status_code != 200: diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 0b549de..ccbf5df 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -55,12 +55,28 @@ For Windows environments without Docker or administrator privileges, ContextCort .\scripts\build_portable_windows.ps1 ``` -2. Copy `dist\contextcortex-windows-x64` to your target Windows workstation, set `OPENAI_API_URL` and `OPENAI_API_KEY` in `.env`, and run: + You can also download the `contextcortex-windows-x64-portable` artifact from the **Build Portable Windows x64 Bundle** GitHub Actions workflow. + +2. Copy `dist\contextcortex-windows-x64` to your target Windows workstation. + +3. Copy `.env.example` to `.env`. Set these values **before the first start**: + - `OPENAI_API_URL` and `OPENAI_API_KEY`. + - `EMBEDDING_MODEL`: an embedding model that your gateway supplies. The bundle has no local embedding engine. The first start sets the vector table dimension from this model. + - `LLM_EXTRA_HEADERS` (optional): a JSON object of headers that your gateway requires. Do not put quotes around the JSON. + +4. If your network uses private CAs or a TLS inspection proxy, put the CA files (`.crt` or `.pem`) in `.\certs\`. + +5. Run: ```powershell .\start-contextcortex.bat ``` -On first launch, the portable bundle automatically initializes a local PostgreSQL 16 + `pgvector` cluster under `.\data\pgdata`, loads any corporate CA certificates placed in `.\certs\*.crt`, and starts the web dashboard and MCP endpoints on `http://127.0.0.1:3000`. +On first start, the bundle does these steps: +- It makes a local PostgreSQL 16 + `pgvector` cluster in `.\data\pgdata`. +- It adds the CA files in `.\certs\` to the public root CAs and uses this bundle for HTTPS and Git. +- It starts the web dashboard and the MCP endpoints on `http://127.0.0.1:3000`. + +All data stays in `.\data\`. To use a different folder, set `DATA_DIR` in `.env`. The launcher works with Windows PowerShell 5.1 and with PowerShell 7. ### Windows Portable Database & Vector Store Sub-Configurations diff --git a/main.py b/main.py index 31ebf6c..9474983 100644 --- a/main.py +++ b/main.py @@ -9,9 +9,36 @@ import json import logging import os +import sys import threading -from fastapi import FastAPI + +def _apply_frozen_bundle_defaults() -> None: + """ + Sets data path defaults for the frozen (PyInstaller) Windows portable bundle. + Without them, module defaults resolve inside the read-only _internal folder + or to container paths such as /app/data and /docs (C:\\app\\data, C:\\docs). + Must run before any app.* module is imported, because those modules read + the paths at import time. Explicit environment values always win. + """ + if not getattr(sys, "frozen", False): + return + exe_dir = os.path.dirname(os.path.abspath(sys.executable)) + data_dir = os.path.abspath(os.getenv("DATA_DIR") or os.path.join(exe_dir, "data")) + os.environ["DATA_DIR"] = data_dir + os.environ.setdefault("VAULT_PATH", os.path.join(data_dir, "docs")) + os.environ.setdefault("CACHE_DB_PATH", os.path.join(data_dir, "index_cache.db")) + os.environ.setdefault("VECTOR_STORE_STORAGE_PATH", os.path.join(data_dir, "vector_storage")) + os.environ.setdefault("TMP_REPOS_DIR", os.path.join(data_dir, "tmp_repos")) + # The portable bundle does not include FastEmbed/ONNX, so embeddings must use the API gateway. + os.environ.setdefault("EMBEDDING_PROVIDER", "api") + os.environ.setdefault("HOST", "127.0.0.1") + os.makedirs(os.environ["VAULT_PATH"], exist_ok=True) + + +_apply_frozen_bundle_defaults() + +from fastapi import FastAPI # noqa: E402 from fastapi.responses import FileResponse, JSONResponse, RedirectResponse from fastapi.staticfiles import StaticFiles import uvicorn diff --git a/scripts/build_portable_windows.ps1 b/scripts/build_portable_windows.ps1 index 5a741b3..7a6dc8f 100644 --- a/scripts/build_portable_windows.ps1 +++ b/scripts/build_portable_windows.ps1 @@ -108,92 +108,12 @@ Pop-Location Copy-Item -Path (Join-Path $ExtractedPg "lib\vector.dll") -Destination (Join-Path $TargetPg "lib\vector.dll") -Force Copy-Item -Path (Join-Path $ExtractedPg "share\extension\vector*") -Destination (Join-Path $TargetPg "share\extension\") -Force -# 6. Create Default Corporate .env.example and Launcher Scripts -$EnvTemplate = @" -# ============================================================================== -# ContextCortex Portable Edition (Windows x64 + User-Space PostgreSQL/pgvector) -# ============================================================================== -HOST=127.0.0.1 -PORT=3000 - -# Portable PostgreSQL + pgvector (automatically initialized in .\data\pgdata) -PORTABLE_POSTGRES_ENABLED=true -PORTABLE_PG_PORT=54329 -VECTOR_STORE=pgvector -VECTOR_STORE_PROVIDER=pgvector - -# OpenAI-Compatible AI Gateway (LiteLLM, vLLM, Ollama, OpenAI, or internal proxy) -# Models (Embedding, Chat, Vision OCR) are discovered & selected in the Admin UI via /v1/models -OPENAI_API_URL=https://litellm.corp.example.com/v1 -OPENAI_API_KEY=your-corporate-api-key - -# Optional custom corporate headers (JSON format) -# LLM_EXTRA_HEADERS={"X-User-Agent":"contextcortex"} - -# Optional Private Corporate CA Bundle path (.pem or .crt) -# SSL_CERT_FILE=.\certs\corporate-ca-bundle.crt -# REQUESTS_CA_BUNDLE=.\certs\corporate-ca-bundle.crt -"@ -Set-Content -Path (Join-Path $BundleDir ".env.example") -Value $EnvTemplate -Encoding UTF8 - -$LauncherPs1 = @" -`$ErrorActionPreference = "Stop" -`$ScriptDir = Split-Path -Parent `$MyInvocation.MyCommand.Path -Set-Location `$ScriptDir - -# Load .env file if present -`$EnvFile = Join-Path `$ScriptDir ".env" -if (-not (Test-Path `$EnvFile)) { - Copy-Item (Join-Path `$ScriptDir ".env.example") `$EnvFile - Write-Host "Created default .env from .env.example. Edit .env to set your OPENAI_API_URL and OPENAI_API_KEY." -ForegroundColor Yellow -} - -Get-Content `$EnvFile | ForEach-Object { - `$line = `$_.Trim() - if (`$line -and -not `$line.StartsWith("#") -and `$line.Contains("=")) { - `$parts = `$line.Split("=", 2) - `$key = `$parts[0].Trim() - `$val = `$parts[1].Trim() - [System.Environment]::SetEnvironmentVariable(`$key, `$val, "Process") - } +# 6. Copy the .env template and launcher scripts (kept as real files in scripts\portable\) +$PortableSrc = Join-Path $RootDir "scripts\portable" +foreach ($f in @(".env.example", "start-contextcortex.ps1", "start-contextcortex.bat")) { + Copy-Item -Path (Join-Path $PortableSrc $f) -Destination (Join-Path $BundleDir $f) -Force } -# Configure custom CA certificate bundle if placed in .\certs\ -`$CertsDir = Join-Path `$ScriptDir "certs" -if (Test-Path `$CertsDir) { - `$CertFiles = Get-ChildItem -Path `$CertsDir -Filter "*.crt" - if (`$CertFiles.Count -gt 0 -and -not `$env:SSL_CERT_FILE) { - `$CombinedBundle = Join-Path `$ScriptDir "data\ca-bundle.crt" - New-Item -ItemType Directory -Path (Join-Path `$ScriptDir "data") -Force | Out-Null - `$Content = "" - foreach (`$c in `$CertFiles) { - `$Content += (Get-Content `$c.FullName -Raw) + "`n" - } - Set-Content -Path `$CombinedBundle -Value `$Content -Encoding ASCII - `$env:SSL_CERT_FILE = `$CombinedBundle - `$env:REQUESTS_CA_BUNDLE = `$CombinedBundle - `$env:GIT_SSL_CAINFO = `$CombinedBundle - Write-Host "Loaded corporate CA certificates from .\certs\" -ForegroundColor Green - } -} - -Write-Host "======================================================================" -ForegroundColor Cyan -Write-Host "Starting ContextCortex Portable (PostgreSQL + pgvector + Tree-sitter)" -ForegroundColor Cyan -Write-Host "Admin UI: http://127.0.0.1:`$(`$env:PORT ?? 3000)/admin/" -ForegroundColor Green -Write-Host "MCP SSE URL: http://127.0.0.1:`$(`$env:PORT ?? 3000)/sse" -ForegroundColor Green -Write-Host "MCP HTTP URL: http://127.0.0.1:`$(`$env:PORT ?? 3000)/mcp" -ForegroundColor Green -Write-Host "======================================================================" -ForegroundColor Cyan - -& (Join-Path `$ScriptDir "contextcortex.exe") -"@ -Set-Content -Path (Join-Path $BundleDir "start-contextcortex.ps1") -Value $LauncherPs1 -Encoding UTF8 - -$LauncherBat = @" -@echo off -powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0start-contextcortex.ps1" -"@ -Set-Content -Path (Join-Path $BundleDir "start-contextcortex.bat") -Value $LauncherBat -Encoding ASCII - New-Item -ItemType Directory -Path (Join-Path $BundleDir "certs") -Force | Out-Null New-Item -ItemType Directory -Path (Join-Path $BundleDir "data") -Force | Out-Null diff --git a/scripts/portable/.env.example b/scripts/portable/.env.example new file mode 100644 index 0000000..8c86d86 --- /dev/null +++ b/scripts/portable/.env.example @@ -0,0 +1,38 @@ +# ============================================================================== +# ContextCortex Portable Edition (Windows x64 + User-Space PostgreSQL/pgvector) +# start-contextcortex.bat copies this file to .env on first start. +# Values are read as-is. Do not put quotes around JSON values. +# ============================================================================== +HOST=127.0.0.1 +PORT=3000 + +# Portable PostgreSQL + pgvector (initialized automatically in .\data\pgdata) +PORTABLE_POSTGRES_ENABLED=true +PORTABLE_PG_PORT=54329 +VECTOR_STORE=pgvector +VECTOR_STORE_PROVIDER=pgvector + +# Data folder (default: .\data next to contextcortex.exe) +# DATA_DIR=D:\ContextCortexData + +# OpenAI-compatible AI gateway (LiteLLM, vLLM, Ollama, OpenAI, or internal proxy). +# Use the base URL exactly as the gateway documents it (for example .../v1 or .../api/v3). +OPENAI_API_URL=https://litellm.corp.example.com/v1 +OPENAI_API_KEY=your-corporate-api-key + +# Embeddings. The portable bundle has no local embedding engine, so use an API model. +# Set the model BEFORE the first start: the vector table dimension is fixed on first start. +EMBEDDING_PROVIDER=api +EMBEDDING_MODEL=text-embedding-3-small + +# Optional chat and vision OCR models (you can also select them in the Admin UI) +# CHAT_MODEL=gpt-4o-mini +# VISION_OCR_MODEL=gpt-4o-mini + +# Optional extra HTTP headers on every gateway request (JSON object, no outer quotes) +# LLM_EXTRA_HEADERS={"X-User-Agent":"contextcortex","X-User-Name":"jdoe"} + +# Corporate CA certificates: put the .crt or .pem files in .\certs\ +# The launcher adds them to the public root CAs and sets SSL_CERT_FILE, +# REQUESTS_CA_BUNDLE, and GIT_SSL_CAINFO. Set SSL_CERT_FILE here only to +# use your own complete bundle instead. diff --git a/scripts/portable/start-contextcortex.bat b/scripts/portable/start-contextcortex.bat new file mode 100644 index 0000000..197c9ea --- /dev/null +++ b/scripts/portable/start-contextcortex.bat @@ -0,0 +1,3 @@ +@echo off +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0start-contextcortex.ps1" +if errorlevel 1 pause diff --git a/scripts/portable/start-contextcortex.ps1 b/scripts/portable/start-contextcortex.ps1 new file mode 100644 index 0000000..cc31b7d --- /dev/null +++ b/scripts/portable/start-contextcortex.ps1 @@ -0,0 +1,65 @@ +# ============================================================================== +# ContextCortex Portable launcher (Windows x64) +# Compatible with Windows PowerShell 5.1 and PowerShell 7+. +# ============================================================================== +$ErrorActionPreference = "Stop" +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +Set-Location $ScriptDir + +# Create .env from the template on first start +$EnvFile = Join-Path $ScriptDir ".env" +if (-not (Test-Path $EnvFile)) { + Copy-Item (Join-Path $ScriptDir ".env.example") $EnvFile + Write-Host "Created .env from .env.example. Edit .env to set OPENAI_API_URL, OPENAI_API_KEY, and LLM_EXTRA_HEADERS." -ForegroundColor Yellow +} + +# Load .env into the process environment +foreach ($rawLine in Get-Content $EnvFile) { + $line = $rawLine.Trim() + if (-not $line -or $line.StartsWith("#") -or -not $line.Contains("=")) { continue } + $parts = $line.Split("=", 2) + $key = $parts[0].Trim() + $val = $parts[1].Trim() + if ($val.Length -ge 2 -and (($val.StartsWith('"') -and $val.EndsWith('"')) -or ($val.StartsWith("'") -and $val.EndsWith("'")))) { + $val = $val.Substring(1, $val.Length - 2) + } + [System.Environment]::SetEnvironmentVariable($key, $val, "Process") +} + +# Build a CA bundle: the public roots shipped with the app + every .crt/.pem in .\certs\ +# (corporate root, subordinate, and TLS-inspection proxy CAs). The public roots stay in +# the bundle, so public HTTPS endpoints continue to work. +$CertsDir = Join-Path $ScriptDir "certs" +if (-not $env:SSL_CERT_FILE -and (Test-Path $CertsDir)) { + $CertFiles = @(Get-ChildItem -Path $CertsDir -File | Where-Object { $_.Extension -in ".crt", ".pem" }) + if ($CertFiles.Count -gt 0) { + $DataDir = Join-Path $ScriptDir "data" + New-Item -ItemType Directory -Path $DataDir -Force | Out-Null + $CombinedBundle = Join-Path $DataDir "ca-bundle.crt" + $Builder = New-Object System.Text.StringBuilder + $PublicRoots = Join-Path $ScriptDir "_internal\certifi\cacert.pem" + if (Test-Path $PublicRoots) { + [void]$Builder.AppendLine((Get-Content $PublicRoots -Raw)) + } + foreach ($c in $CertFiles) { + [void]$Builder.AppendLine((Get-Content $c.FullName -Raw)) + } + Set-Content -Path $CombinedBundle -Value $Builder.ToString() -Encoding ASCII + $env:SSL_CERT_FILE = $CombinedBundle + $env:REQUESTS_CA_BUNDLE = $CombinedBundle + $env:GIT_SSL_CAINFO = $CombinedBundle + Write-Host "Loaded $($CertFiles.Count) CA certificate file(s) from .\certs\" -ForegroundColor Green + } +} + +$Port = if ($env:PORT) { $env:PORT } else { "3000" } +Write-Host "======================================================================" -ForegroundColor Cyan +Write-Host "Starting ContextCortex Portable (PostgreSQL + pgvector + Tree-sitter)" -ForegroundColor Cyan +Write-Host "Admin UI: http://127.0.0.1:$Port/admin/" -ForegroundColor Green +Write-Host "MCP SSE URL: http://127.0.0.1:$Port/sse" -ForegroundColor Green +Write-Host "MCP HTTP URL: http://127.0.0.1:$Port/mcp" -ForegroundColor Green +Write-Host "Press Ctrl+C to stop. PostgreSQL stops with the app." -ForegroundColor Cyan +Write-Host "======================================================================" -ForegroundColor Cyan + +& (Join-Path $ScriptDir "contextcortex.exe") +exit $LASTEXITCODE diff --git a/tests/test_portable_postgres.py b/tests/test_portable_postgres.py index 428a502..e18d9bf 100644 --- a/tests/test_portable_postgres.py +++ b/tests/test_portable_postgres.py @@ -149,6 +149,45 @@ def execute(self, query, params=None): os.environ.pop("VECTOR_STORE_PROVIDER", None) +def test_pg_ctl_start_does_not_capture_output_pipes(tmp_path, monkeypatch): + """Verifies that pg_ctl start writes to a file instead of a pipe (an inherited pipe blocks start() forever on Windows).""" + valid_bin = tmp_path / "pgsql" / "bin" + _create_fake_pg_bins(valid_bin) + data_dir = tmp_path / "data" / "pgdata" + data_dir.mkdir(parents=True) + (data_dir / "PG_VERSION").write_text("16\n", encoding="utf-8") + + start_kwargs = {} + + def fake_run(cmd, **kwargs): + res = MagicMock() + if "status" in cmd: + res.returncode = 3 + elif "start" in cmd: + start_kwargs.update(kwargs) + res.returncode = 0 + else: + res.returncode = 0 + return res + + monkeypatch.setattr(subprocess, "run", fake_run) + mgr = PortablePostgresManager(pg_bin_dir=valid_bin, data_dir=data_dir, port=54332) + monkeypatch.setattr(mgr, "_ensure_database_and_extension", lambda: None) + monkeypatch.delenv("DATABASE_URL", raising=False) + + mgr.start() + + assert not start_kwargs.get("capture_output") + assert start_kwargs.get("stdout") not in (None, subprocess.PIPE) + assert start_kwargs.get("stderr") not in (None, subprocess.PIPE) + assert start_kwargs.get("stdin") == subprocess.DEVNULL + assert start_kwargs.get("timeout") + mgr._started_by_us = False + os.environ.pop("DATABASE_URL", None) + os.environ.pop("VECTOR_STORE", None) + os.environ.pop("VECTOR_STORE_PROVIDER", None) + + def test_bootstrap_skips_when_database_url_already_set(monkeypatch): """Verifies that bootstrap_portable_postgres_if_needed respects an existing DATABASE_URL.""" monkeypatch.setenv("DATABASE_URL", "postgresql://custom:5432/db")