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.
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:
-
File paths must be in WSL (POSIX) form. Any path you pass in
DATABASE_URLorsslcert/sslkey/sslrootcertquery parameters must use the/mnt/c/...prefix that WSL exposes, not the WindowsC:\...form. Example:sslcert=/mnt/c/Users/yourname/certs/client.crt -
PostgreSQL must be reachable from inside WSL. If PostgreSQL is running on the Windows host, set
DATABASE_URLto point at the Windows host IP or$(hostname).localfrom inside WSL. If PostgreSQL is installed inside WSL itself (recommended),localhostworks as normal.
Everything else — hook registration, python3 -m mcp_server.doctor,
scripts/setup_db.py, scripts/setup.py — works without modification.
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.
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"libpq rejects a private key that is world-readable. Set the mode before starting Cortex:
chmod 600 /path/to/client.keyCortex 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.
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@host→user:***@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.
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 .
.devcontainer/devcontainer.json— pins theghcr.io/anthropics/devcontainer-features/claude-codefeature and theghcr.io/devcontainers/features/nodefeature (Node 22), setsDISABLE_AUTOUPDATER=1, and runspython scripts/setup_db.py && python -m mcp_server.doctoraspostCreateCommand— the container is not considered ready untildoctorexits 0..devcontainer/docker-compose.yml— two services:app: builds.devcontainer/Dockerfile, mounts the repo at/workspace, talks todbviaDATABASE_URL.db:pgvector/pgvector:pg16— the same imagebenchmarks/reproduce.shalready uses for its own ephemeral PostgreSQL instances — withvector/pg_trgmcreated by.devcontainer/initdb/01-extensions.sqlat first boot (the same DDL asmcp_server/infrastructure/pg_schema.py::EXTENSIONS_DDLanddocker/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 thecodebaseextra, and prewarms the embedding model (all-MiniLM-L6-v2) and the FlashRank cross-encoder reranker at build time, cached underHF_HOME=/opt/model-cache/huggingface/XDG_CACHE_HOME=/opt/model-cache— a durable image path, never/tmp(seemcp_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).
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 againstghcr.io/v2/anthropics/devcontainer-features/ claude-code/manifests/1.0.5): the feature exposes no version knob for the CLI it installs. Itsinstall.shruns an unpinnednpm install -g @anthropic-ai/claude-coderegardless of which feature tag you pin — so pinning:1.0.5fixes the install script (and thus guards against a future script-level regression), but not which@anthropic-ai/claude-coderelease 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-runpostCreateCommand(mcp_server.doctordoes not check the CLI version) to confirm the rest of the stack is still healthy.ghcr.io/devcontainers/features/node:2.1.0withversion: "22"— pinned explicitly rather than accepting theclaude-codefeature's best-effort Node 18.x auto-install fallback (install.sh, only triggers when no Node is already present).
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.
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.doctorThe DATABASE_URL check and the PG connection check both probe the host
from your DSN.
Cortex downloads two model artifacts on first use, both from Hugging Face:
- sentence-transformers embedding model (
all-MiniLM-L6-v2, ~100MB), fetched viahuggingface_hub/requestsinsidemcp_server/infrastructure/embedding_engine.py(_ensure_model), and pre-cached once byscripts/setup.py(cache_embedding_model, step 5). - FlashRank cross-encoder reranker (
ms-marco-MiniLM-L-12-v2, ~34MB ONNX), fetched by theflashranklibrary itself fromhttps://huggingface.co/prithivida/flashrank/resolve/main/{model}.zip(mcp_server/core/reranker.py, module docstring; verified by reading the installedflashrank==0.2.10package, 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.
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.
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.crtIf 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.
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_dir — reranker_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.
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:
- Pre-warm the cache at build time. Run the same pre-caching step
scripts/setup.pyperforms (cache_embedding_model) and the FlashRankRanker(...)construction inside your container/VM image build, while the build host still has network access, then ship the resulting$HF_HOMEand$XDG_CACHE_HOME/flashrankdirectories baked into the image. This is the approach tracked for the official devcontainer in #118. - 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 setHF_ENDPOINTto that mirror's base URL — this is ahuggingface_hubconvention, not a Cortex-specific mechanism; Cortex's downloads are ordinaryhuggingface_hub/flashrankHTTP 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.
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.
--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.
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.
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.