Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
*.sh text eol=lf
docker-entrypoint.sh text eol=lf
*.bat text eol=crlf
32 changes: 32 additions & 0 deletions .github/workflows/build-portable-windows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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: |
Expand Down
5 changes: 3 additions & 2 deletions REQUIREMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---

Expand Down Expand Up @@ -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)
Expand Down
29 changes: 25 additions & 4 deletions app/services/database/portable_postgres.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,17 +203,38 @@ 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:
log_tail = self.log_file.read_text(encoding="utf-8", errors="ignore")[-1500:]
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)
Expand Down
2 changes: 1 addition & 1 deletion app/services/litellm_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
20 changes: 18 additions & 2 deletions docs/guide/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
29 changes: 28 additions & 1 deletion main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
88 changes: 4 additions & 84 deletions scripts/build_portable_windows.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
38 changes: 38 additions & 0 deletions scripts/portable/.env.example
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions scripts/portable/start-contextcortex.bat
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
@echo off
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0start-contextcortex.ps1"
if errorlevel 1 pause
65 changes: 65 additions & 0 deletions scripts/portable/start-contextcortex.ps1
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading