Skip to content

Latest commit

 

History

History
386 lines (309 loc) · 18.6 KB

File metadata and controls

386 lines (309 loc) · 18.6 KB

Cortex — Deployment Scenarios

Scenarios that have caused friction for Discord users: running under WSL, connecting with TLS client-certificate authentication instead of a password, and downloading model weights behind a corporate proxy / inspection CA.


WSL (Windows Subsystem for Linux)

Cortex runs as a Linux process under WSL — no Windows-specific code paths are active. The win32 branches in scripts/setup.py (ANSI colour suppression, service-start hints) are gated on sys.platform == "win32" and are inert inside WSL.

Two things to get right:

  1. File paths must be in WSL (POSIX) form. Any path you pass in DATABASE_URL or sslcert/sslkey/sslrootcert query parameters must use the /mnt/c/... prefix that WSL exposes, not the Windows C:\... form. Example:

    sslcert=/mnt/c/Users/yourname/certs/client.crt
    
  2. PostgreSQL must be reachable from inside WSL. If PostgreSQL is running on the Windows host, set DATABASE_URL to point at the Windows host IP or $(hostname).local from inside WSL. If PostgreSQL is installed inside WSL itself (recommended), localhost works as normal.

Everything else — hook registration, python3 -m mcp_server.doctor, scripts/setup_db.py, scripts/setup.py — works without modification.


Client-certificate authentication (no password)

Cortex passes DATABASE_URL directly to libpq via psycopg.connect(url) (mcp_server/infrastructure/pg_store.py, line 133) and to psycopg_pool.ConnectionPool(conninfo=url, ...). This means every standard libpq TLS parameter works as a query parameter in the DSN — no password required.

Example DSN

DATABASE_URL="postgresql://USER@HOST:5432/cortex?sslmode=verify-full&sslcert=/path/to/client.crt&sslkey=/path/to/client.key&sslrootcert=/path/to/ca.crt"

Set this in your environment before starting Claude Code (or before running scripts/setup_db.py):

export DATABASE_URL="postgresql://myuser@db.example.com:5432/cortex?sslmode=verify-full&sslcert=/etc/certs/client.crt&sslkey=/etc/certs/client.key&sslrootcert=/etc/certs/ca.crt"

Required: key-file permissions

libpq rejects a private key that is world-readable. Set the mode before starting Cortex:

chmod 600 /path/to/client.key

No password field needed

Cortex never requires a password field in DATABASE_URL. Authentication is delegated entirely to libpq, so pg_hba.conf cert auth (or scram-sha-256 over TLS, or peer auth for local sockets) all work without any Cortex-side changes.

Secret redaction in logs and doctor output

python3 -m mcp_server.doctor and internal log lines pass DATABASE_URL through mcp_server.shared.redaction.redact_url before printing. That function masks only:

  • the userinfo password (user:secret@hostuser:***@host)
  • the ?password= and ?pgpassword= query parameters

TLS parameters (sslcert, sslkey, sslrootcert, sslmode) are not treated as secrets and are preserved verbatim in log output. A cert-based DSN that contains no password field is printed unchanged.


Dev container (VS Code / Claude Code — issue #118)

One command, no local Python/Postgres/model setup: open the repo in a container that already has Claude Code, PostgreSQL+pgvector, and Cortex's embedding/reranker models ready to go.

Open in VS Code → "Dev Containers: Reopen in Container"
# or, headless:
devcontainer up --workspace-folder .

What's included

  • .devcontainer/devcontainer.json — pins the ghcr.io/anthropics/devcontainer-features/claude-code feature and the ghcr.io/devcontainers/features/node feature (Node 22), sets DISABLE_AUTOUPDATER=1, and runs python scripts/setup_db.py && python -m mcp_server.doctor as postCreateCommand — the container is not considered ready until doctor exits 0.
  • .devcontainer/docker-compose.yml — two services:
    • app: builds .devcontainer/Dockerfile, mounts the repo at /workspace, talks to db via DATABASE_URL.
    • db: pgvector/pgvector:pg16 — the same image benchmarks/reproduce.sh already uses for its own ephemeral PostgreSQL instances — with vector/pg_trgm created by .devcontainer/initdb/01-extensions.sql at first boot (the same DDL as mcp_server/infrastructure/pg_schema.py::EXTENSIONS_DDL and docker/entrypoint.sh's single-container runtime image).
  • .devcontainer/Dockerfile — reuses the production build recipe from ../Dockerfile's builder stage (CPU-only torch wheel, pip install .[postgresql]), adds the codebase extra, and prewarms the embedding model (all-MiniLM-L6-v2) and the FlashRank cross-encoder reranker at build time, cached under HF_HOME=/opt/model-cache/huggingface / XDG_CACHE_HOME=/opt/model-cache — a durable image path, never /tmp (see mcp_server/core/reranker.py's module docstring for the exact 2026-07-11 incident this avoids: FlashRank's own default cache directory IS /tmp, and losing that cache mid-process causes recall to silently fall back to first-stage-only scores with no error logged).

Version pinning

Every dependency this container introduces is pinned to a value verified against this repo's own lockfile or the upstream registry, not guessed:

  • torch==2.11.0, sentence-transformers==5.4.1, flashrank==0.2.10# source: uv.lock (this repo).
  • ghcr.io/anthropics/devcontainer-features/claude-code:1.0.5 — the feature's own manifest at this tag reports "options": {} (verified 2026-07-14 against ghcr.io/v2/anthropics/devcontainer-features/ claude-code/manifests/1.0.5): the feature exposes no version knob for the CLI it installs. Its install.sh runs an unpinned npm install -g @anthropic-ai/claude-code regardless of which feature tag you pin — so pinning :1.0.5 fixes the install script (and thus guards against a future script-level regression), but not which @anthropic-ai/claude-code release ends up in the image. This is the same install-script-vs-package-release gap referenced by this issue. There is currently no upstream mechanism to pin the CLI release itself through this feature; rebuild the container to pick up a newer CLI, and re-run postCreateCommand (mcp_server.doctor does not check the CLI version) to confirm the rest of the stack is still healthy.
  • ghcr.io/devcontainers/features/node:2.1.0 with version: "22" — pinned explicitly rather than accepting the claude-code feature's best-effort Node 18.x auto-install fallback (install.sh, only triggers when no Node is already present).

Validation

postCreateCommand runs python scripts/setup_db.py (idempotent — creates the database/extensions/schema if absent, no-ops otherwise; the same script the plugin's SessionStart hook already uses) followed by python -m mcp_server.doctor, which must exit 0: Python version, PG driver import, DATABASE_URL reachability, pgvector/pg_trgm extensions, schema presence, and the POOL_INTERACTIVE_MAX invariant.

Verified locally (2026-07-14, docker compose up -d from a checkout under $HOME, both services healthy): \dx inside db lists vector 0.8.4 and pg_trgm 1.6; python scripts/setup_db.py returns {"status": "ready", ...}; python -m mcp_server.doctor exits 0 with every required check green (the only warning is the optional codebase-pipeline capability, expected without the separate Rust component). The FlashRank ONNX model's on-disk mtime inside the running container matches the image's build time, not the container's start time — confirming the model was baked into the image layer and not downloaded at first use. Caveat specific to this verification, not to the devcontainer itself: the first attempt, from a checkout under /tmp, hit a Docker Desktop bind-mount quirk on this machine (a single-file mount at a path absent from the base image materialized as an empty directory instead of the file — .devcontainer/initdb/ mounts a directory for exactly this reason, see that directory's file header). Re-running the identical compose file from a $HOME-rooted checkout worked without any file changes, isolating the quirk to /tmp bind mounts in this environment, not to the compose/Dockerfile content.


Remote PostgreSQL

Any host reachable from the machine running Cortex works in DATABASE_URL. Both the runtime (mcp_server/infrastructure/pg_store.py) and the hook bootstrap (scripts/setup_db.py) read DATABASE_URL from the environment and connect to whatever host the DSN specifies.

One caveat with the convenience installer: scripts/setup.py derives the host and port from DATABASE_URL via urllib.parse and passes them to pg_isready -h HOST -p PORT. This means the installer correctly probes the remote host rather than localhost, as long as DATABASE_URL is set before running the script. If DATABASE_URL is unset, the installer falls back to localhost:5432.

Verify a remote connection with:

python3 -m mcp_server.doctor

The DATABASE_URL check and the PG connection check both probe the host from your DSN.


Corporate proxy / custom CA (model downloads)

Cortex downloads two model artifacts on first use, both from Hugging Face:

  • sentence-transformers embedding model (all-MiniLM-L6-v2, ~100MB), fetched via huggingface_hub/requests inside mcp_server/infrastructure/embedding_engine.py (_ensure_model), and pre-cached once by scripts/setup.py (cache_embedding_model, step 5).
  • FlashRank cross-encoder reranker (ms-marco-MiniLM-L-12-v2, ~34MB ONNX), fetched by the flashrank library itself from https://huggingface.co/prithivida/flashrank/resolve/main/{model}.zip (mcp_server/core/reranker.py, module docstring; verified by reading the installed flashrank==0.2.10 package, not assumed).

Neither of these paths has Cortex-specific proxy/CA handling — there is none to add. Both sentence-transformers/huggingface_hub and flashrank use Python's requests under the hood, which honors the standard environment variables natively. scripts/setup.py passes the pre-caching step through subprocess.run(..., env={**os.environ, ...}) (see cache_embedding_model), so anything exported in your shell before running the installer reaches the download.

Proxy variables (read by requests/urllib3, and by huggingface_hub)

export HTTPS_PROXY="http://proxy.corp.example.com:8080"
export HTTP_PROXY="http://proxy.corp.example.com:8080"
export NO_PROXY="localhost,127.0.0.1,.corp.example.com"

These are read once per process at first HTTP(S) request. Set them before running scripts/setup.py, and before starting Claude Code / the Cortex MCP server if the reranker or embedding model has not been cached yet.

The central trap: which CA variable Python actually reads

If your proxy terminates TLS and re-signs traffic with a corporate root CA (common with inspection proxies), the variable that matters for Cortex's downloads is a Python/requests variable, not a Node one:

Variable Honored by Relevant to Cortex?
REQUESTS_CA_BUNDLE Python requests (and therefore huggingface_hub, sentence-transformers, flashrank) Yes — this is the one to set.
SSL_CERT_FILE Python's ssl module directly (used by libraries that bypass requests) Yes, as a second/fallback path — set alongside REQUESTS_CA_BUNDLE for defense in depth.
NODE_EXTRA_CA_CERTS Node.js's TLS stack No. This only affects Claude Code's own Node process (npm registry TLS, Claude Code's network calls). It has zero effect on Cortex's Python model downloads. Setting only this variable and expecting model downloads to trust the corporate CA is the classic mistake this section exists to head off.
export REQUESTS_CA_BUNDLE=/etc/ssl/certs/corp-ca-bundle.crt
export SSL_CERT_FILE=/etc/ssl/certs/corp-ca-bundle.crt

If you already set NODE_EXTRA_CA_CERTS for Claude Code itself, keep it — it is still required for Claude Code's own TLS — but it is a separate, additional variable, not a substitute for REQUESTS_CA_BUNDLE/SSL_CERT_FILE.

Cache location: never let it land in /tmp

Cortex has already been bitten by exactly this failure mode once, in the reranker: FlashRank 0.2.10's own default cache_dir is /tmp (flashrank/Config.py, default_cache_dir = "/tmp"). macOS periodically purges /tmp, which silently disabled reranking for the remainder of a process's life and went unnoticed through 6 benchmark runs (fixed in bb1c581f, "fix(reranker): durable cache_dir + non-silent failure + bench fail-fast", 2026-07-11). The fix was to pass an explicit, durable cache_dirreranker_cache_dir() in mcp_server/core/reranker.py, which resolves to ~/.cache/flashrank and honors $XDG_CACHE_HOME via mcp_server.shared.platform.cache_dir() — instead of relying on the library default.

The sentence-transformers embedding model does not have this same Cortex-side override: embedding_engine.py calls SentenceTransformer(...) without a cache_folder argument, so it defers entirely to huggingface_hub's own cache resolution — $HF_HOME if set, else the library default ~/.cache/huggingface. That default is durable (not /tmp) on every platform huggingface_hub supports, so there is no equivalent bug here today, but the lesson still applies operationally: if you redirect the cache, redirect it somewhere persistent.

# Optional: put both model caches under one durable, shared location
export HF_HOME=/opt/cortex-cache/huggingface
export XDG_CACHE_HOME=/opt/cortex-cache        # FlashRank reads this via
                                                 # mcp_server.shared.platform.cache_dir()

Do not point either variable at /tmp or any other path a cleanup job, container restart, or OS policy can purge — you will reproduce the exact silent-failure pattern from the FlashRank incident, except for the embedding model instead of the reranker.

Air-gapped / no outbound internet

If the deployment environment has no route to huggingface.co at all (a stricter case than a proxy — no egress permitted, even through a proxy), neither HTTPS_PROXY nor the CA variables above help; the download itself must not happen at runtime. Two options:

  1. Pre-warm the cache at build time. Run the same pre-caching step scripts/setup.py performs (cache_embedding_model) and the FlashRank Ranker(...) construction inside your container/VM image build, while the build host still has network access, then ship the resulting $HF_HOME and $XDG_CACHE_HOME/flashrank directories baked into the image. This is the approach tracked for the official devcontainer in #118.
  2. Mirror Hugging Face internally. Point HF_HOME's resolution at an internal Hugging Face Hub mirror (or a static file mirror serving the same paths) reachable from the air-gapped network, and set HF_ENDPOINT to that mirror's base URL — this is a huggingface_hub convention, not a Cortex-specific mechanism; Cortex's downloads are ordinary huggingface_hub/flashrank HTTP calls and inherit whatever endpoint those libraries are configured to use.

See also the TLS client-certificate section above for a related but distinct concern — that section covers CA/cert configuration for Cortex's own PostgreSQL connection, not model downloads. The proxy/CA variables in this section apply only to the outbound HTTPS calls sentence-transformers/huggingface_hub/flashrank make; they have no effect on DATABASE_URL's sslcert/sslkey/sslrootcert parameters, and vice versa.


Two headless CI regimes — pick one per job, never both

Running claude -p (headless, no interactive session) in your own CI is a different decision from running Cortex's own test suite (.github/workflows/ci.yml, which never invokes the Claude CLI at all). This section is for teams that drive Claude Code itself inside a pipeline — linting with an agent, an authoring/review bot, a scheduled task — and need to decide whether Cortex's memory is available to it. The two regimes are mutually exclusive by construction; do not expect both properties from a single job.

Regime A — claude -p --bare: reproducible, no Cortex

--bare loads no project/user settings, no hooks, and no MCP servers — it forces ANTHROPIC_API_KEY billing and starts from a blank configuration slate every invocation (see mcp_server/handlers/consolidation/claude_cli.py, module docstring, "Solo mode" discussion, for the verified behavior Cortex's own headless wiki-authoring drain relies on when contrasting --bare against --safe-mode). Because MCP servers never load in this mode, Cortex is not present: no SessionStart context injection, no remember/recall, no consolidation. The job's output depends only on the prompt, the pinned CLI version, and whatever files are in the checkout — nothing else.

Use this regime when the job's correctness argument is "this must produce the same result on every run" — e.g. a CI check that a diff violates no lint rule, or a report-generation step being asserted against a golden file. Reproducibility is the whole point; adding Cortex's memory would make the job's output depend on accumulated state from prior runs, which defeats that argument.

Regime B — containerized, pinned versions, with memory

Run the Claude CLI (pinned version) and Cortex (pinned plugin/package version, PostgreSQL reachable) normally inside a container — project/user settings, hooks, and MCP servers all load as they would locally. SessionStart injects hot/anchored memories, remember/recall work, and the autonomous wiki cycle can consolidate across scheduled runs. This gives the pipeline continuity: a nightly review-agent job can accumulate "we already flagged this pattern" across weeks of runs instead of starting blank every time.

The trade-off is exactly the one this repo already documents for release benchmarks (see CLAUDE.md, Build & Test): a job with persistent state behind it is not byte-for-byte reproducible run-to-run, because the DB state differs. Do not use this regime to gate anything that must be a clean, repeatable pass/fail (that is what Regime A, or benchmarks/reproduce.sh's isolated ephemeral container, is for) — use it for jobs whose value is the accumulated memory.

The rule

One job, one regime. A job that needs both a clean reproducible verdict and persistent Cortex memory is asking for a contradiction — --bare strips the MCP/hooks layer that memory depends on, and enabling that layer reintroduces the run-to-run state dependency --bare exists to remove. If a pipeline needs both properties, split it into two jobs instead of one.