diff --git a/.env.example b/.env.example index c4f5a3ec..34617726 100644 --- a/.env.example +++ b/.env.example @@ -1,53 +1,38 @@ -# OpenCodex local/dev + container env. Copy to `.env` (gitignored). -# Never commit secrets, tokens, or Authentik client secrets. -# -# Live place lock (do not change from this checkout): -# systemd unit: opencodex-proxy -# version: 1.5.1 -# source SHA: c88648fa87001d04beedc8df853bee7700253422 -# health: http://:10100/healthz -# The immutable GHCR digest lives only on the live host `.env` as OPENCODEX_IMAGE. +# OpenCodex local/dev + container environment. Copy to .env (gitignored). +# Never commit secrets, tokens, OAuth client secrets, or production host data. -# --- Local / compose.dev path (repo-root compose.yml) --- +# --- Local / compose path --- # Generate a throwaway token file, then point at it: # mkdir -p deploy/container/.secrets # umask 077 && python3 -c 'import secrets; print(secrets.token_hex(16))' > deploy/container/.secrets/api-token OPENCODEX_API_TOKEN_FILE=deploy/container/.secrets/api-token OPENCODEX_STATE_DIR=./.tmp/opencodex-state OPENCODEX_GIT_SHA= -OPENCODEX_VERSION=1.5.1 +OPENCODEX_VERSION= -# --- Production compose.example.yml (digest-pinned; not used by local compose) --- -# OPENCODEX_IMAGE=ghcr.io/groeponline/opencodex:1.5.1@sha256: -# OPENCODEX_BIND_IP= # host Tailscale IPv4; required only for prod compose +# Optional production image reference. Pin an immutable digest in the private +# deployment system; do not encode a live host or digest in this example. +# OPENCODEX_IMAGE=ghcr.io/groeponline/opencodex:@sha256: +# OPENCODEX_BIND_IP= -# --- Cloudflare Access (live public-host gate; optional locally) --- +# --- Optional edge authentication --- CF_ACCESS_TEAM_DOMAIN= CF_ACCESS_AUD= CF_ACCESS_ALLOWED_HOSTS= -# --- Authentik OIDC (ChefGroep Auth product consumer) --- -# Public issuer APPLY DONE 2026-09-18 (discovery/JWKS 200, authorize 302). -# Not DNS HOLD. The proxy verifies Authentik ID tokens and can run -# GET /oauth/login → /oauth/callback when the secret file is set. -# Cloudflare Access remains the live public-host gate until -# deploy/oidc/CUTOVER-CHECKLIST.md is executed. client_secret stays -# file-only (never git). -# Redirect contract: deploy/oidc/authentik-ocx-client.placeholder.json -OIDC_ISSUER=https://auth.chefgroep.online/application/o/ocx/ -OIDC_CLIENT_ID=chefgroep-ocx-oidc +# --- Authentik/OIDC product consumer --- +# Set deployment-specific values outside this repository. The client secret is +# always file-delivered. +OIDC_ISSUER=https://id.example.com/application/o/opencodex/ +OIDC_CLIENT_ID=opencodex OIDC_CLIENT_SECRET_FILE= OIDC_ALLOWED_HOSTS= -# Local default. Production redirect is https://ocx.chefgroep.online/oauth/callback OIDC_REDIRECT_URI=http://127.0.0.1:10100/oauth/callback -# --- Fleet Azure Foundry keys (host env file only; never commit values) --- -# See deploy/container/model-catalog.example.json and docs/models.md. -# AZURE_OPENAI_KEY_OPENAICHEF= -# AZURE_OPENAI_KEY_OPENAICHEF_SE= -# AZURE_OPENAI_KEY_AZURE_FOUNDRY_US= +# --- Example provider credential --- +# EXAMPLE_OPENAI_API_KEY= -# --- Healthz smoke (scripts/healthz-smoke.sh) --- +# --- Healthz smoke --- # OPENCODEX_HEALTH_URL=http://127.0.0.1:10100/healthz -# OPENCODEX_SMOKE_EXPECT_SHA=c88648fa87001d04beedc8df853bee7700253422 -# OPENCODEX_SMOKE_EXPECT_VERSION=1.5.1 +# OPENCODEX_SMOKE_EXPECT_SHA= +# OPENCODEX_SMOKE_EXPECT_VERSION= diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b65aa468..217643c5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,10 +1,9 @@ name: Legacy deploy route retired -# `chef-control-az-01` is permanently retired. The previous Docker/GHCR -# workflow ran on a runner located there, so it must never be selected by a tag -# push or manual release dispatch. OCX now runs as a package release under the -# system service on bc-scan-2; its source-owned deployment contract is tracked -# separately and must be introduced with its own target and rollback evidence. +# The previous production Docker/GHCR deployment route is permanently retired. +# It must never be selected by a tag push or manual release dispatch. Production +# runtime cutover is owned by a private deployment contract with an explicit +# target, immutable artifact identity, health proof and rollback evidence. # # Keeping this small, explicit workflow provides a clear audit result for an # accidental legacy dispatch without retaining credentials, host paths, or any @@ -22,6 +21,6 @@ jobs: steps: - name: Refuse retired deployment route run: | - echo "::error::The chef-control-az-01 deployment route is permanently retired." - echo "::error::Do not deploy this workflow; use the separately verified bc-scan-2 package deployment contract." + echo "::error::The previous production deployment route is permanently retired." + echo "::error::Do not deploy this workflow; use the separately verified private production deployment contract." exit 1 diff --git a/.github/workflows/publish-on-tag.yml b/.github/workflows/publish-on-tag.yml index 418f19be..b8a008e0 100644 --- a/.github/workflows/publish-on-tag.yml +++ b/.github/workflows/publish-on-tag.yml @@ -2,7 +2,7 @@ name: Publish on tag # Tag a commit on main with `vX.Y.Z` and this workflow publishes # `@groeponline/opencodex` to npm. Publication does not deploy a runtime: the -# former chef-control-az-01 deploy route is permanently retired. +# former production deploy route is permanently retired. # # Publishing prefers npm Trusted Publishing (OIDC, no token). The Trusted Publisher # on npmjs.com is registered for the `Release` workflow, so OIDC is only accepted @@ -132,5 +132,5 @@ jobs: gh release create "v${VERSION}" --title "v${VERSION}" --notes "$notes" # Publication intentionally has no runtime side effect. The old Azure deploy - # route is retired and a bc-scan-2 package deployment contract must provide + # route is retired and a private production deployment contract must provide # its own target, immutable artifact, health, and rollback verification. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6b32180d..2c5a3b55 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,7 +29,7 @@ on: required: false type: string deploy: - description: "Legacy deploy.yml targets a permanently retired host and must remain false. Runtime cutover uses a separately verified bc-scan-2 package deployment contract." + description: "Legacy deploy.yml targets a permanently retired host and must remain false. Runtime cutover uses a separately verified private production deployment contract." required: false type: boolean default: false @@ -65,8 +65,8 @@ jobs: DEPLOY: ${{ inputs.deploy }} run: | if [ "$DEPLOY" = "true" ]; then - echo "::error::deploy=true is disabled because deploy.yml targets retired chef-control-az-01." - echo "::error::Use the separately verified bc-scan-2 package deployment contract." + echo "::error::deploy=true is disabled because deploy.yml targets retired production host." + echo "::error::Use the separately verified private production deployment contract." exit 1 fi @@ -651,6 +651,6 @@ jobs: echo "- runtime cutover: not dispatched; legacy Azure deployment is retired" >> "$GITHUB_STEP_SUMMARY" exit 0 fi - echo "::error::deploy=true is disabled because deploy.yml targets retired chef-control-az-01." - echo "::error::Use the separately verified bc-scan-2 package deployment contract." + echo "::error::deploy=true is disabled because deploy.yml targets retired production host." + echo "::error::Use the separately verified private production deployment contract." exit 1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b1b50467..0b84bd69 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,8 +34,9 @@ Bun runtime for end users, but contributor commands such as `bun install`, `bun The production proxy path (Compose + systemd + `:10100/healthz`) has a complete local/dev mirror: repo-root `compose.yml`, `.devcontainer/`, `.env.example`, and -`bash scripts/healthz-smoke.sh`. Authentik OIDC canary: -`bash scripts/oidc-authorize-canary.sh`. See +`bash scripts/healthz-smoke.sh`. Authentik OIDC canary requires explicit, +deployment-owned values, for example: +`OIDC_ISSUER=https://id.example.com/application/o/opencodex/ OIDC_CLIENT_ID=opencodex bash scripts/oidc-authorize-canary.sh`. See [`deploy/container/README.md`](./deploy/container/README.md) and [`deploy/oidc/CUTOVER-CHECKLIST.md`](./deploy/oidc/CUTOVER-CHECKLIST.md). diff --git a/RELEASE_PROCESS.md b/RELEASE_PROCESS.md index de273a06..be08f43f 100644 --- a/RELEASE_PROCESS.md +++ b/RELEASE_PROCESS.md @@ -70,7 +70,7 @@ created by that token, so `container.yml` is dispatched explicitly on `refs/tags Runtime cutover is intentionally not part of release publication. The former Azure deploy route is permanently retired; leave the `deploy` input at its default `false`. Deploying the -bc-scan-2 package service is a separate operation with its own immutable artifact, health and +private production service is a separate operation with its own immutable artifact, health and rollback evidence. ## Post-release diff --git a/deploy/container/README.md b/deploy/container/README.md index 1befc869..bd46dd77 100644 --- a/deploy/container/README.md +++ b/deploy/container/README.md @@ -1,41 +1,20 @@ # OpenCodex container + systemd path -This directory is the production compose/unit contract. Local/dev uses the same -proxy/app path (`:10100/healthz`) without touching the live place lock. - -| Path | Role | -| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- | -| [`compose.example.yml`](./compose.example.yml) | Digest-pinned Compose reference for `opencodex-proxy.service`; not the live bc-scan-2 deployment. | -| [`opencodex-proxy.service`](./opencodex-proxy.service) | Non-serving systemd oneshot that runs `docker compose up/down` in that directory. | -| [`../../compose.yml`](../../compose.yml) | Local/dev Compose. Builds from this repo's `Dockerfile`, loopback `:10100` only. | -| [`../../.env.example`](../../.env.example) | Env template. No secrets. | -| [`../../.devcontainer/`](../../.devcontainer/) | Coding container (Bun 1.4.0). | -| [`../oidc/authentik-ocx-client.placeholder.json`](../oidc/authentik-ocx-client.placeholder.json) | Authentik OIDC client contract (`chefgroep-ocx-oidc`; issuer APPLY DONE 2026-09-18). | -| [`../oidc/CUTOVER-CHECKLIST.md`](../oidc/CUTOVER-CHECKLIST.md) | Authorize canary and CoS / Cloudflare Access cutover checklist. | -| [`../../scripts/oidc-authorize-canary.sh`](../../scripts/oidc-authorize-canary.sh) | Secret-free discovery/JWKS (and optional `/oauth/login`) canary. | -| [`../../scripts/healthz-smoke.sh`](../../scripts/healthz-smoke.sh) | Local/dev `/healthz` identity smoke matching `:10100/healthz`. | - -## Live place lock - -Do **not** retarget, redeploy, or rewrite the live pin from this repository -change. Reported live identity: - -| Field | Value | -| ---------- | --------------------------------------------------------- | -| Unit | `opencodex-proxy.service` | -| Unit state | Non-serving; a separate npm process owns port `10100` | -| Version | `1.5.1` | -| Source SHA | `c88648fa87001d04beedc8df853bee7700253422` (tag `v1.5.1`) | -| Health | `GET /healthz` on the host Tailscale IPv4, port `10100` | - -The live bc-scan-2 service runs the published npm package from -`/home/joep/.opencodex/releases/current`; this unit file does not embed a -version and is not the live process owner. Do not restart it until the npm -package procedure is documented or the unit is reconciled. `.github/workflows/deploy.yml` -is retired fail-closed and no longer retargets or rolls back any host. A -source-owned bc-scan-2 package deployment contract remains a separate operation. - -## Local/dev (complete proxy/app path) +This directory contains generic deployment examples for the OpenCodex proxy. +It deliberately does not describe any ChefGroep production host, private +network address, release-tree path, credential location or live cutover state. + +| Path | Role | +| --- | --- | +| [`compose.example.yml`](./compose.example.yml) | Digest-pinned Compose reference. | +| [`opencodex-proxy.service`](./opencodex-proxy.service) | Example systemd wrapper for the Compose deployment. | +| [`../../compose.yml`](../../compose.yml) | Local/dev Compose using this repository's Dockerfile. | +| [`../../.env.example`](../../.env.example) | Secret-free environment template. | +| [`model-catalog.example.json`](./model-catalog.example.json) | Generic, key-free provider catalog example. | +| [`../oidc/CUTOVER-CHECKLIST.md`](../oidc/CUTOVER-CHECKLIST.md) | Generic OIDC cutover checklist. | +| [`../../scripts/healthz-smoke.sh`](../../scripts/healthz-smoke.sh) | `/healthz` identity smoke. | + +## Local/dev ```bash cp .env.example .env @@ -46,7 +25,7 @@ docker compose up -d --build bash scripts/healthz-smoke.sh ``` -Source-only (no Docker), same health contract: +Source-only: ```bash bun install --frozen-lockfile @@ -54,62 +33,29 @@ bun run start bash scripts/healthz-smoke.sh ``` -`scripts/healthz-smoke.sh` defaults to `http://127.0.0.1:10100/healthz` and -requires `status=ok`, `service=opencodex`, numeric `pid`/`port`, and a non-empty -`gitSha`. Set `OPENCODEX_SMOKE_EXPECT_SHA` / `OPENCODEX_SMOKE_EXPECT_VERSION` to -bind those fields the way the production health gate binds the release tag. - -## Authentik OIDC consumer - -Public ChefGroep Auth issuer is **APPLY DONE 2026-09-18** at -`https://auth.chefgroep.online/application/o/ocx/` (discovery/JWKS 200, -authorize 302). The issuer is **not** DNS HOLD. Live `client_id` is -`chefgroep-ocx-oidc` (Infra smoke + Cloudflare Access IdP). `client_secret` -stays `null` / file-only (`OIDC_CLIENT_SECRET_FILE`). - -The consumer contract lists redirect URIs for local loopback and -`https://ocx.chefgroep.online`. Compose forwards `OIDC_*` when set. The proxy -verifies Authentik ID tokens (JWKS) and runs `GET /oauth/login` → -`/oauth/callback` (authorization-code + PKCE) when the secret file is present. -**Cloudflare Access remains the live public-host dashboard gate** until -operators execute [`../oidc/CUTOVER-CHECKLIST.md`](../oidc/CUTOVER-CHECKLIST.md). - -- Do not put a client secret in git. Use `OIDC_CLIENT_SECRET_FILE`. -- Do not register this client in ChefFactory catalogs (Factory owns catalog). -- Do not apply Cloudflare DNS from this repository. - -```bash -bash scripts/oidc-authorize-canary.sh -# optional, against a running local proxy with OIDC_* set: -OPENCODEX_OIDC_CANARY_URL=http://127.0.0.1:10100 bash scripts/oidc-authorize-canary.sh -``` +`scripts/healthz-smoke.sh` defaults to +`http://127.0.0.1:10100/healthz` and requires `status=ok`, +`service=opencodex`, numeric `pid`/`port`, and a non-empty `gitSha`. +Use `OPENCODEX_SMOKE_EXPECT_SHA` and +`OPENCODEX_SMOKE_EXPECT_VERSION` when a deployment gate must bind an exact +artifact. -## Fleet model catalog +## Authentication -Runtime providers are not part of the image pin. The key-free default is -[`model-catalog.example.json`](./model-catalog.example.json), described in -[`../../docs/models.md`](../../docs/models.md). +Remote data-plane binds must use a generated client admission key or the +service-token mechanism described by the product. Do not distribute a host +service credential to clients. -| Check | Rule | -| --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Providers | `azure-us` (`openaichef`, eastus), `azure-se` (`openaichef-se`, swedencentral), `azure-foundry-us` (`azure-foundry-us`, eastus) | -| Wire | `openai-chat` against `https://.cognitiveservices.azure.com/openai/v1` | -| Keys | Host env only: `AZURE_OPENAI_KEY_OPENAICHEF`, `AZURE_OPENAI_KEY_OPENAICHEF_SE`, `AZURE_OPENAI_KEY_AZURE_FOUNDRY_US`. Never commit values. | -| Removed | `jort-7512-resource`, AWS/Bedrock hosts, `chef-control-az-01` as a model upstream, stopped llama.cpp/weg54 inference | -| Apply | Edit the host `~/.opencodex/config.json`, keep mode `0600`, then defer restart until the npm procedure is documented or the unit is reconciled. Do not retarget the binary pin from this file. | -| Check | `ocx config validate` before restart. `GET /v1/models` on the bind address must list `azure-us/*`, `azure-se/*`, and `azure-foundry-us/fw-deepseek-v4-pro`. | +OIDC deployments provide issuer, client-id, redirect and client-secret-file +settings through the deployment environment. Client secrets never belong in +source control. See [the generic cutover checklist](../oidc/CUTOVER-CHECKLIST.md). -Copying the example over a live file drops every other provider. Merge it into -the existing `providers` map instead. +## Production boundary -## Honest blockers +Publishing a package or image does not deploy a production runtime. Production +host placement, private addresses, release paths, credentials, current version +and rollback evidence belong to the operator's private deployment repository or +configuration system. -1. Authentik issuer public apply is done (2026-09-18). The consumer is wired, - but Cloudflare Access remains the live public-host gate until the cutover - checklist is executed. Client secret is not in git. -2. This change documents the `1.5.1` / `c88648fa8` live release and does not - move it. Do not deploy this PR to bc-scan-2. -3. The former Azure `deploy.yml` route is retired fail-closed; do not treat a - merge here as a live cutover. -4. Public `ocx.chefgroep.online` Cloudflare is already applied; this repo does - not mutate DNS or ChefFactory catalogs. +The public repository intentionally fails closed instead of carrying a live +ChefGroep deployment target. diff --git a/deploy/container/model-catalog.example.json b/deploy/container/model-catalog.example.json index ced93222..a7bff725 100644 --- a/deploy/container/model-catalog.example.json +++ b/deploy/container/model-catalog.example.json @@ -1,67 +1,17 @@ { "port": 10100, - "defaultProvider": "azure-us", + "defaultProvider": "example-openai", "providers": { - "azure-us": { + "example-openai": { "adapter": "openai-chat", - "baseUrl": "https://openaichef.cognitiveservices.azure.com/openai/v1", + "baseUrl": "https://api.example.com/v1", "authMode": "key", - "apiKey": "$AZURE_OPENAI_KEY_OPENAICHEF", + "apiKey": "$EXAMPLE_OPENAI_API_KEY", "liveModels": false, "contextWindow": 128000, - "defaultModel": "deepseek-v4-flash-0731", - "models": [ - "grok-4-6", - "DeepSeek-V4-1-Flash", - "fw-glm-5-3-flash", - "fw-kimi-k3", - "deepseek-v4-flash-0731", - "kimi-k2-7-code", - "fw-glm-5-3-dz" - ], - "selectedModels": [ - "grok-4-6", - "DeepSeek-V4-1-Flash", - "fw-glm-5-3-flash", - "fw-kimi-k3", - "deepseek-v4-flash-0731", - "kimi-k2-7-code", - "fw-glm-5-3-dz" - ] - }, - "azure-se": { - "adapter": "openai-chat", - "baseUrl": "https://openaichef-se.cognitiveservices.azure.com/openai/v1", - "authMode": "key", - "apiKey": "$AZURE_OPENAI_KEY_OPENAICHEF_SE", - "liveModels": false, - "contextWindow": 128000, - "defaultModel": "deepseek-v4-flash-dz", - "models": [ - "grok-4-6", - "DeepSeek-V4-1-Flash", - "grok-4-20-non-reasoning", - "kimi-k2-6", - "deepseek-v4-flash-dz" - ], - "selectedModels": [ - "grok-4-6", - "DeepSeek-V4-1-Flash", - "grok-4-20-non-reasoning", - "kimi-k2-6", - "deepseek-v4-flash-dz" - ] - }, - "azure-foundry-us": { - "adapter": "openai-chat", - "baseUrl": "https://azure-foundry-us.cognitiveservices.azure.com/openai/v1", - "authMode": "key", - "apiKey": "$AZURE_OPENAI_KEY_AZURE_FOUNDRY_US", - "liveModels": false, - "contextWindow": 128000, - "defaultModel": "fw-deepseek-v4-pro", - "models": ["fw-deepseek-v4-pro"], - "selectedModels": ["fw-deepseek-v4-pro"] + "defaultModel": "example-chat", + "models": ["example-chat"], + "selectedModels": ["example-chat"] } } } diff --git a/deploy/container/opencodex-proxy.service b/deploy/container/opencodex-proxy.service index 6a175da2..d5f53d9e 100644 --- a/deploy/container/opencodex-proxy.service +++ b/deploy/container/opencodex-proxy.service @@ -1,11 +1,6 @@ [Unit] Description=OpenCodex proxy (Docker Compose) Documentation=https://github.com/GroepOnline/opencodex -# Live place lock (docs only — this unit does not embed a version or digest): -# version 1.5.1 / source SHA c88648fa87001d04beedc8df853bee7700253422 -# image pin is OPENCODEX_IMAGE in EnvironmentFile (immutable digest) -# health: GET :10100/healthz on the host Tailscale IPv4 -# Do not retarget the pin from a docs or local/dev change. After=docker.service network-online.target tailscaled.service Requires=docker.service Wants=network-online.target tailscaled.service @@ -13,8 +8,8 @@ Wants=network-online.target tailscaled.service [Service] Type=oneshot RemainAfterExit=yes -WorkingDirectory=/opt/chef/deploy/opencodex -EnvironmentFile=-/opt/chef/deploy/opencodex/.env +WorkingDirectory=/opt/opencodex +EnvironmentFile=-/opt/opencodex/.env ExecStart=/usr/bin/docker compose up -d --remove-orphans ExecStop=/usr/bin/docker compose down TimeoutStartSec=120 diff --git a/deploy/oidc/CUTOVER-CHECKLIST.md b/deploy/oidc/CUTOVER-CHECKLIST.md index 4b1262e1..aea4ba99 100644 --- a/deploy/oidc/CUTOVER-CHECKLIST.md +++ b/deploy/oidc/CUTOVER-CHECKLIST.md @@ -1,116 +1,67 @@ -# Authentik OIDC canary and CoS / Cloudflare cutover +# OIDC canary and edge cutover checklist -This is the operator checklist for the product Authentik consumer -(`chefgroep-ocx-oidc`). It does **not** move the live place lock and it -does **not** deploy to `bc-scan-2`. `/healthz` stays unauthenticated on -`:10100`. There is no `/health` contract — that path is a JSON 404. - -**Dual-run (2026-09-20):** Cloudflare Access remains the live public-host -dashboard gate on `ocx.chefgroep.online`. Product OIDC is wired in the -consumer and host env, but is **not** the public gate. Rollback = keep -Access enabled and keep the previous release tree. +This checklist describes the product-level OIDC acceptance contract. It is +intentionally deployment-neutral: production hostnames, private addresses, +client identifiers, current versions and live cutover evidence belong in the +operator's private deployment repository. ## Planes -| Plane | Who it authenticates | Live today | Product path | -| ------------------------------------- | --------------------------------------------------------------------------------------------------------- | -------------------------------------------- | -------------------- | -| Cloudflare Access | Browser users at the edge (`CF_Authorization` / `cf-access-jwt-assertion`) | **Yes** — public GUI gate | Keep until cutover | -| Authentik OIDC (`chefgroep-ocx-oidc`) | Browser users at the proxy (`GET /oauth/login` → flow cookie → `/oauth/callback`, then `ocx_oidc` cookie) | Consumer wired; **not** the live public gate | Canary, then cutover | -| Service API token | Data-plane `/v1/*` | Unchanged | Unchanged | - -Authentik is also the Cloudflare Access IdP. Cutting over means the **proxy** -verifies ChefGroep Auth tokens itself so Access can later be removed from the -hostname. It is not a second identity provider. - -## Observed live state (read-only, refreshed 2026-09-24) - -- [x] Issuer `https://auth.chefgroep.online/application/o/ocx/` discovery returns 200. JWKS URI in that document is live. Introspection is `https://auth.chefgroep.online/application/o/introspect/` (same origin). -- [x] Client id is `chefgroep-ocx-oidc`. Client type in Authentik is `confidential`. Secret exists only in `OIDC_CLIENT_SECRET_FILE` on the host (never git). -- [x] Authentik redirect URIs that match this consumer (`STRICT`): - `http://127.0.0.1:10100/oauth/callback`, - `http://localhost:10100/oauth/callback`, - `https://ocx.chefgroep.online/oauth/callback`. - Extra registered URIs (not used by this code; **do not change from this repo**): - `/oauth2/callback`, `/api/auth/callback`, `/auth/callback`, `/oidc/callback`, - and the Cloudflare Access callback. -- [x] Host `.env` names set (values not recorded): `OIDC_ISSUER`, `OIDC_CLIENT_ID`, - `OIDC_CLIENT_SECRET_FILE`, `OIDC_REDIRECT_URI`, `OIDC_ALLOWED_HOSTS`, plus - `CF_ACCESS_TEAM_DOMAIN`, `CF_ACCESS_AUD`, `CF_ACCESS_ALLOWED_HOSTS`. -- [x] Actual running artifact is npm package **1.5.1** from - `/home/joep/.opencodex/releases/c88648fa87001d04beedc8df853bee7700253422` - (`start-service.sh` → `src/cli/index.ts start --port 10100`). The unit-file - comment and runtime health identity cite tag `v1.5.1` / source SHA - `c88648fa87001d04beedc8df853bee7700253422`. -- [x] `GET http://100.65.83.86:10100/healthz` returns `status=ok`, - `service=opencodex`, `version=1.5.1`, and source SHA `c88648fa8…`. - Bind is the Tailscale IPv4, not 127.0.0.1. Public - `https://ocx.chefgroep.online/` is still a Cloudflare Access 302. -- [ ] systemd `opencodex-proxy.service` is **not** healthy: `ActiveState=activating`, - `NRestarts` in the thousands, because PID `3197646` already holds - `:10100` and `ocx start` refuses a duplicate. The orphan process is the - live listener. Do not treat the unit as the source of truth until an - operator reconciles that (out of scope for this PR; no live write). - -## Preconditions (CoS) - -- [x] Issuer `https://auth.chefgroep.online/application/o/ocx/` discovery and JWKS return 200 (APPLY DONE 2026-09-18; not DNS HOLD). -- [x] Client id is `chefgroep-ocx-oidc`. Client secret exists only in `OIDC_CLIENT_SECRET_FILE` on the host (never git, never ChefFactory catalog). -- [x] Redirect URIs required by `deploy/oidc/authentik-ocx-client.placeholder.json` are present. Extra unused URIs remain; leave them. -- [x] Live package version is `1.5.1`. Running tree SHA is `c88648fa8`. This checklist does not retarget the pin. -- [x] `GET http://100.65.83.86:10100/healthz` still returns `status=ok`, `service=opencodex`. - -## Authorize canary (no live cutover) - -Run from a laptop or the host. Do not put the client secret on the command line. - -```bash -# 1) Issuer still live (no secrets) -bash scripts/oidc-authorize-canary.sh - -# 2) Optional: against a running local/dev proxy with OIDC_* set -OPENCODEX_OIDC_CANARY_URL=http://127.0.0.1:10100 bash scripts/oidc-authorize-canary.sh -``` - -Then, only on a **non-production** bind or a loopback tunnel: - -- [ ] `GET /oauth/login` returns 302 to `https://auth.chefgroep.online/application/o/authorize/` with `client_id=chefgroep-ocx-oidc`, `state`, `nonce`, `code_challenge_method=S256`, and an HttpOnly `ocx_oidc_flow` cookie. -- [ ] Completing login (human browser) lands on `/oauth/callback` then the `return_to` path with an `ocx_oidc` cookie. No token in the URL. -- [ ] `GET /api/usage` from that browser succeeds without `ocx_admin_*`. -- [ ] `GET /healthz` still works without cookies or tokens. -- [ ] `GET /v1/models` still requires the data-plane token (OIDC must not admit the data plane). -- [ ] A forged host or missing/invalid ID token still returns 401 on `/api/*`. -- [ ] Logout (`GET /oauth/logout`) and IdP introspection revoke dashboard access on the next request. - -Human browser login cannot be proven from this repository: ChefAuth has only -the bootstrap admin. Unit/integration tests cover the mocked issuer/JWKS -paths above. If any live canary box fails, stop. Cloudflare Access stays in front. - -## Cloudflare Access dual-run - -While Access is still the live gate: - -- [x] Keep `CF_ACCESS_TEAM_DOMAIN`, `CF_ACCESS_AUD`, and `CF_ACCESS_ALLOWED_HOSTS=ocx.chefgroep.online`. -- [x] `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET_FILE`, `OIDC_REDIRECT_URI`, and `OIDC_ALLOWED_HOSTS` names are set on the host env file only. -- [x] Access login still opens the dashboard (public `/` → Cloudflare Access 302). -- [ ] Confirm `/oauth/login` can complete behind Access (Access may already have authenticated the browser; the product callback must still succeed). Human login required. -- [x] Confirm `/healthz` on Tailscale `:10100` is unchanged (`status=ok`, `service=opencodex`). - -## Cutover (only after canary + dual-run) - -Do this in a later release that **intentionally** moves the live pin. Not this PR. - -- [ ] Product OIDC canary stayed green for the agreed soak window. -- [ ] Dual-run showed Access and Authentik both admitting the GUI, and neither admitting `/v1/*`. -- [ ] Change `OIDC_REDIRECT_URI` / Authentik redirect to the public callback only if local loopback URIs should drop. -- [ ] Remove the Cloudflare Access application from `ocx.chefgroep.online` (or bypass it) **after** OIDC-only admission is proven. -- [ ] Unset `CF_ACCESS_*` on the host only after Access is gone. -- [ ] Re-check `/healthz` on the Tailscale IPv4 and laptop tunnel loopback `:10100`. -- [ ] Keep a rollback: restore `CF_ACCESS_*`, re-enable the Access application, restart `opencodex-proxy` onto the previous release tree (`b59c2115` / 1.4.2 remains the on-disk previous release). Access stays the public gate if product OIDC is rolled back. +| Plane | Purpose | +| --- | --- | +| Edge authentication | Optional outer browser gate during migration/dual-run. | +| Product OIDC | Browser identity consumed directly by the OpenCodex proxy. | +| Service API token / client keys | Data-plane `/v1/*` admission. OIDC must not grant this plane. | +| Admin token / product session | Management-plane admission. | + +## Preconditions + +- [ ] OIDC issuer discovery returns 200. +- [ ] JWKS URI from discovery returns 200 and matches the configured issuer. +- [ ] The client is confidential when a client-secret file is configured. +- [ ] Redirect URIs exactly match the deployment's loopback/public callbacks. +- [ ] Client secret exists only in the secret/runtime plane. +- [ ] `GET /healthz` proves the exact intended runtime artifact. +- [ ] Data-plane admission remains independent from browser/OIDC admission. + +## Authorize canary + +Against a non-production bind or explicitly approved canary: + +- [ ] `GET /oauth/login` returns 302 to the configured issuer's authorization endpoint. +- [ ] Request contains `state`, `nonce`, `code_challenge`, and `code_challenge_method=S256`. +- [ ] Completing login returns to `/oauth/callback` and establishes an HttpOnly product session. +- [ ] No authorization code, access token or ID token remains in the final URL. +- [ ] An authenticated browser can use the intended management surface. +- [ ] `GET /healthz` still works according to the deployment health policy. +- [ ] `GET /v1/models` still requires data-plane credentials. +- [ ] Forged host, issuer, state, nonce or ID token fails closed. +- [ ] Logout and issuer-side revocation remove dashboard access. + +The repository includes `scripts/oidc-authorize-canary.sh` for +secret-free discovery/JWKS and optional login-start checks. + +## Edge dual-run + +If an outer access gateway is used during migration: + +- [ ] Keep the existing edge gate until product OIDC has completed canary and soak. +- [ ] Verify both layers do not accidentally widen `/v1/*` admission. +- [ ] Verify rollback before removing the edge gate. +- [ ] Remove old edge-specific environment only after OIDC-only access is proven. + +## Cutover + +- [ ] Canary stayed green for the agreed soak window. +- [ ] Exact runtime artifact and rollback artifact are recorded privately. +- [ ] Public callback is proven end-to-end. +- [ ] Logout, expiry and denied-user cases are proven. +- [ ] Edge policy changes are applied by the repository/system that owns them. +- [ ] Post-cutover health and authenticated management/data-plane probes pass. ## Never from this repository -- Do not apply Cloudflare DNS. -- Do not register `chefgroep-ocx-oidc` in ChefFactory catalogs. -- Do not commit a client secret. -- Do not treat merge of the consumer PR as a live cutover. -- Do not edit Authentik redirect URIs from here. Report mismatches only. +- Do not apply production DNS or edge policy. +- Do not commit client secrets, service tokens or browser credentials. +- Do not encode a private host/IP/release path as the product deployment target. +- Do not treat a merge, package publish or image publish as production cutover. diff --git a/deploy/oidc/authentik-ocx-client.placeholder.json b/deploy/oidc/authentik-ocx-client.placeholder.json index 802b6f17..00d980d7 100644 --- a/deploy/oidc/authentik-ocx-client.placeholder.json +++ b/deploy/oidc/authentik-ocx-client.placeholder.json @@ -1,31 +1,31 @@ { - "status": "consumer-wired", - "authority": "GroepOnline/chefgroep-auth", - "catalog_owner": "ChefFactory (do not register this client from opencodex)", + "status": "example-only", + "authority": "deployment-owned", + "catalog_owner": "deployment-owned", "application": { - "slug": "ocx", + "slug": "opencodex", "name": "OpenCodex", "client_type": "confidential", - "client_id": "chefgroep-ocx-oidc", + "client_id": "opencodex", "client_secret": null, - "issuer": "https://auth.chefgroep.online/application/o/ocx/", + "issuer": "https://id.example.com/application/o/opencodex/", "token_endpoint_auth_method": "client_secret_basic", "grant_types": ["authorization_code", "refresh_token"], "scopes": ["openid", "profile", "email"], "redirect_uris": [ "http://127.0.0.1:10100/oauth/callback", "http://localhost:10100/oauth/callback", - "https://ocx.chefgroep.online/oauth/callback" + "https://opencodex.example.com/oauth/callback" ], "post_logout_redirect_uris": [ "http://127.0.0.1:10100/", "http://localhost:10100/", - "https://ocx.chefgroep.online/" + "https://opencodex.example.com/" ] }, "consumer": { - "discovery": "https://auth.chefgroep.online/application/o/ocx/.well-known/openid-configuration", - "jwks": "https://auth.chefgroep.online/application/o/ocx/jwks/", + "discovery": "https://id.example.com/application/o/opencodex/.well-known/openid-configuration", + "jwks": "https://id.example.com/application/o/opencodex/jwks/", "authorize": "GET /oauth/login", "callback": "GET /oauth/callback", "logout": "GET /oauth/logout", @@ -33,10 +33,9 @@ "secret_delivery": "OIDC_CLIENT_SECRET_FILE" }, "notes": [ - "Public issuer APPLY DONE 2026-09-18: discovery/JWKS 200, authorize 302 at https://auth.chefgroep.online/application/o/ocx/. This is not DNS HOLD.", - "Live client_id is chefgroep-ocx-oidc (Infra smoke + Cloudflare Access IdP). client_secret stays null / file-only.", - "This file is the consumer contract only. Do not register this client in ChefFactory catalogs from this repository.", - "The proxy verifies Authentik ID tokens (JWKS) and runs authorization-code + PKCE when OIDC_CLIENT_SECRET_FILE is set. Cloudflare Access remains the live public-host dashboard gate until operators execute deploy/oidc/CUTOVER-CHECKLIST.md.", - "Do not apply Cloudflare DNS from this repository." + "This file is a product example, not a live registration.", + "The proxy verifies OIDC ID tokens and uses authorization-code plus PKCE when OIDC_CLIENT_SECRET_FILE is configured.", + "Production issuer, client id, redirects, edge policy, and cutover evidence belong to the deployment owner.", + "Do not put a client secret in this repository." ] } diff --git a/docs-site/src/content/docs/guides/access-vs-authentik.md b/docs-site/src/content/docs/guides/access-vs-authentik.md index 3845dd6c..a10b78f2 100644 --- a/docs-site/src/content/docs/guides/access-vs-authentik.md +++ b/docs-site/src/content/docs/guides/access-vs-authentik.md @@ -1,54 +1,57 @@ --- -title: Cloudflare Access vs Authentik OIDC -description: How the OpenCodex dashboard uses Cloudflare Access today and Authentik OIDC as the product consumer. +title: Edge Access vs OIDC +description: How OpenCodex can combine an outer access gateway with product-level OIDC. --- -OpenCodex has two human-dashboard gates. They are not interchangeable today. +OpenCodex can use an outer access gateway and product-level OIDC for the +human dashboard. These are separate trust boundaries. -| Gate | What the proxy checks | Live on `ocx.chefgroep.online` | When to use | -| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------- | ------------------------------------------ | -| **Cloudflare Access** | `CF_ACCESS_TEAM_DOMAIN` + `CF_ACCESS_AUD` JWT (`cf-access-jwt-assertion` or `CF_Authorization`) | **Yes** — current public GUI gate | Production public host until cutover | -| **Authentik OIDC** | Issuer `https://auth.chefgroep.online/application/o/ocx/`, client `chefgroep-ocx-oidc`, JWKS ID-token verify, `GET /oauth/login` → flow cookie → `/oauth/callback` | Consumer wired; **not** the live public gate | Local canary and the later Access cutover | -| **Admin token / GUI session** | `OPENCODEX_ADMIN_AUTH_TOKEN` or loopback-minted session | Unchanged | Loopback and Tailscale without Access/OIDC | -| **Service API token** | `OPENCODEX_API_AUTH_TOKEN` | Unchanged | Data-plane `/v1/*` only | +| Gate | What the proxy checks | Typical use | +| --- | --- | --- | +| **Cloudflare Access (edge gateway example)** | `CF_ACCESS_TEAM_DOMAIN` + `CF_ACCESS_AUD` JWT (`cf-access-jwt-assertion` or `CF_Authorization`) | Optional Cloudflare-specific outer browser gate during migration or defense in depth | +| **Product OIDC** | Configured issuer/client, JWKS ID-token verification, `GET /oauth/login` → flow cookie → `/oauth/callback` | Browser identity enforced directly by OpenCodex | +| **Admin token / GUI session** | `OPENCODEX_ADMIN_AUTH_TOKEN` or loopback-minted session | Local/admin management access | +| **Service API token / client key** | OpenCodex data-plane admission credential | `/v1/*` only | -Authentik is also the Cloudflare Access identity provider. Product OIDC means -the **proxy** verifies ChefGroep Auth tokens itself (`GET /oauth/login` → -browser-bound flow cookie → `/oauth/callback`, then an `ocx_oidc` cookie or a -Bearer ID token). Logout and IdP introspection revoke the next dashboard -request. An OIDC browser session never admits `/v1/*`. It does not register a -second client in ChefFactory. +On deployments that require data-plane authentication, an OIDC browser +session does not admit `/v1/*` by itself. Loopback deployments may admit +`/v1/*` without an additional data-plane credential. Edge-gateway and OIDC +configuration must not silently widen data-plane admission. -`GET /healthz` on `:10100` stays unauthenticated on both planes. +`GET /healthz` remains governed by the deployment's health policy and should +not depend on browser login. -## Environment (no secrets) +## Environment Copy [`.env.example`](https://github.com/GroepOnline/opencodex/blob/main/.env.example). -Never set `OIDC_CLIENT_SECRET` in the environment or in git. +Never set `OIDC_CLIENT_SECRET` directly in the environment or commit it. ```bash -OIDC_ISSUER=https://auth.chefgroep.online/application/o/ocx/ -OIDC_CLIENT_ID=chefgroep-ocx-oidc +OIDC_ISSUER=https://id.example.com/application/o/opencodex/ +OIDC_CLIENT_ID=opencodex OIDC_CLIENT_SECRET_FILE=/path/to/oidc-client-secret OIDC_REDIRECT_URI=http://127.0.0.1:10100/oauth/callback -OIDC_ALLOWED_HOSTS=ocx.chefgroep.online +OIDC_ALLOWED_HOSTS=opencodex.example.com ``` -Token verify needs issuer + client id. The browser authorize flow also needs -the secret file and a redirect URI from -`deploy/oidc/authentik-ocx-client.placeholder.json`. +Token verification needs issuer + client ID. The browser authorization flow +also needs the secret file and a redirect URI registered by the deployment +owner. ## Canary ```bash +export OIDC_ISSUER=https://id.example.com/application/o/opencodex/ +export OIDC_CLIENT_ID=opencodex bash scripts/oidc-authorize-canary.sh OPENCODEX_OIDC_CANARY_URL=http://127.0.0.1:10100 bash scripts/oidc-authorize-canary.sh ``` -The operator checklist for dual-run and Cloudflare Access cutover is -[`deploy/oidc/CUTOVER-CHECKLIST.md`](https://github.com/GroepOnline/opencodex/blob/main/deploy/oidc/CUTOVER-CHECKLIST.md). -Merging the consumer does not move the live `1.5.1` place lock and is not a -cutover. +Use the issuer and client ID owned by your deployment; the values above are +examples. See the +[`deploy/oidc/CUTOVER-CHECKLIST.md`](https://github.com/GroepOnline/opencodex/blob/main/deploy/oidc/CUTOVER-CHECKLIST.md) +for the generic dual-run and cutover contract. Merging product code is not a +production cutover. ## See also diff --git a/docs/convergence/STATE_FORENSICS.md b/docs/convergence/STATE_FORENSICS.md index 7d7267db..a48e8abe 100644 --- a/docs/convergence/STATE_FORENSICS.md +++ b/docs/convergence/STATE_FORENSICS.md @@ -1,336 +1,41 @@ -# OCX production state forensics - -Lane C (state authority). Captured 2026-08-23 by read-only observation of -`chef-control-az-01` plus local git history on `origin/main` @ `4a589932`. -No files were written, moved, deleted, chmod'd, restarted, or restored on the -host. Secret values (API keys, OAuth tokens, cookies, admin/service tokens) -were never printed; this report uses names, ids, lengths, env-ref flags, and -content hashes only. - -The question is not "where is the old data". It is: which persistent states -have ever held production authority for OCX, and can we prove nothing was -lost? - -Verdict: **we cannot prove completeness**. The live Azure store is internally -consistent against every *reachable* legacy copy of the same files. The prior -host is offline, and the live store has no `auth.json`. Those two facts leave -an unclosed hole. - -## 1. Authority map (what actually holds state) - -OCX has no database. Authority is a directory of JSON/JSONL plus a systemd -`EnvironmentFile` of provider key *names*. The unit -`opencodex-proxy.service` (`Active: running`, bun pid 413295, advertised -`1.2.1`) binds `100.109.39.86:10100` and has -`ReadWritePaths=/home/chef/.opencodex /var/lib/chef /opt/chef/services/opencodex`. -`OPENCODEX_HOME` is unset, so `src/config.ts` `resolveConfigDir()` uses -`~/.opencodex`. - -| Role | Path | What it is | Held authority? | -| --- | --- | --- | --- | -| **CURRENT store** | `/home/chef/.opencodex/` | Live JSON/JSONL. `config.json` 89213 B, mtime 2026-08-22 10:10Z. | **Yes — sole live authority today.** | -| **CURRENT Codex injection** | `/home/chef/.codex/` | Regenerated on every proxy start (mtime 2026-08-23 02:55Z, same second as pid). No `auth.json`. Journal `originalConfig` is empty. | Derived, not a user store. | -| **Staged snapshot** | `/etc/chef/opencodex/` | root:chef. `config.json` 70641 B, mtime 2026-08-22 06:34Z, plus `service.env` (15 names), `admin-api-token`, `service-api-token`. | Frozen copy of the live store taken ~4 h earlier the same day. Not read by the running unit except `EnvironmentFile` + `LoadCredential`. | -| **Secrets file** | `/etc/chef/opencodex/service.env` | 15 key *names* only (see below). | Credential *source* for env-ref providers. | -| Code backup 2026-08-22 | `/opt/chef/services/.opencodex-backup-20260822T095647Z` | Full git checkout, `@groeponline/opencodex@1.1.1`. Zero state files. | Never. | -| Pre-git install 2026-08-22 | `/opt/chef/services/opencodex.pre-git-20260822T133750Z` | npm layout, `@groeponline/opencodex@1.2.1`. Zero state files. | Never. | -| Live runtime checkout | `/opt/chef/services/opencodex` | SHA `71c3cad1`, branch `live-v1.2.1`, version 1.2.1. Zero state files. | Code only. | -| Deploy checkout | `/home/chef/opencodex-psp` | SHA `4a589932`, version 1.2.2. No `.opencodex/`. Zero state files. | Code only (and not the running path — V1). | -| Binary seal | `/home/chef/.opencodex/backups/native-binary-seal-20260731T000839Z` | `ocx.npm-bin` + `opencodex.target`. 20 K, no config. | Never. | -| `/var/lib/chef/backups` | vault + authentik tarballs only | Enumerated. No `opencodex` / `.opencodex` / `config.json`. | Never for OCX. | -| `/var/lib/chef/rollback` | systemd-unit snapshots for vault/authentik | Enumerated. No OCX unit. | Never for OCX. | -| **PRIOR host** | `chef-control-01` (`100.115.43.1`) | Tailscale **offline**, last seen `2026-08-22T01:31:44Z`. SSH/ping timeout. | **Unknown — unreachable.** Previously the named production host (`deploy.yml` still says so). | - -`service.env` key names (values not read into this document): -`OPENCODEX_API_AUTH_TOKEN`, `CURSOR_USER_API_KEY`, `CLINE_API_KEY`, -`GROQ_API_KEY`, `NVIDIA_API_KEY`, `OPENCODE_GO_API_KEY`, `ZAI_API_KEY`, -`ZAI_PLATFORM_API_KEY`, `OPENROUTER_API_KEY`, `DEEPSEEK_API_KEY`, -`OPENCODE_API_KEY`, `ZAI_CODING_PLAN_API_KEY`, `CF_ACCESS_TEAM_DOMAIN`, -`CF_ACCESS_AUD`, `CF_ACCESS_ALLOWED_HOSTS`. - -There is no OCX timer. Host timers backup Authentik and Vault, not -`~/.opencodex`. Cron has no OCX job. `config.json` has no `schemaVersion` / -`schema_version`. `storageCleanupPolicy` is unset. - -## 2. Lineage - -``` - chef-control-01 (PRIOR authority) - ~/.opencodex + ?auth.json + ?codex-accounts.json - last seen 2026-08-22T01:31:44Z OFFLINE - | - | 2026-08-03/04 host move - | evidence: kimi-device-id 2026-08-03, - | admin-api-token.bak-pre-control-20260804 - | (sha != live token — token was rotated), - | usage.jsonl starts 2026-08-03 (32 rows) - | SETUP.md still describes joep/sofie/control-01 - v - chef-control-az-01 (CURRENT authority) - /home/chef/.opencodex/ - config.json openaiProviderTierVersion=2 - NO auth.json NO codex-accounts.json - NO config.json.pre-openai-tiers-v2.bak - | - +---------------------+----------------------+ - | | | - v v v - /etc/chef/opencodex/ live checkout deploy checkout - config.json 70KB /opt/chef/services/ /home/chef/opencodex-psp - 2026-08-22 06:34Z opencodex @ 71c3cad1 @ 4a589932 (1.2.2) - same 18 provider ids 1.2.1 live-v1.2.1 NOT the running path - same 876 disabled no state files no state files - same apiKey ids - MISSING desktopProfile - | - +-- 2026-08-22 09:56 code-only trees (NOT datastores) - .opencodex-backup-20260822T095647Z (repo 1.1.1) - opencodex.pre-git-20260822T133750Z (npm 1.2.1) - -Code-level format migrations that DID exist (git), vs what the live disk shows: - - auth.json legacy → multiauth (src/oauth/store.ts, one-time - auth.json.pre-multiauth) ABSENT on disk - openai-multi/chatgpt → openai v2 (openaiProviderTierVersion, - config.json.pre-openai-tiers-v2.bak) - live file is already v2; backup ABSENT - Alibaba region backup (config.json.pre-alibaba-region-v1.bak) ABSENT - Claude authMode three-state claudeCode.authModeMigratedAt present - Codex history guardian journal says 0 threads remapped - schema_version NEVER existed in src/types.ts or configSchema -``` - -The 2026-08-22 trees named `*backup*` / `*pre-git*` are **runtime-code -snapshots**, not datastore snapshots. Treating them as recoverable OCX state -would be a category error. - -The 2026-08-04 move **did copy the config-shaped home**: usage from 2026-08-03, -device ids from 2026-08-03, a pre-control admin-token bak, and a SETUP.md that -still talks about tunneling to control-01. It did **not** leave an `auth.json` -on Azure, and we cannot see whether one existed on control-01. - -## 3. Record-level reconciliation - -Sources compared: - -- **current** = `/home/chef/.opencodex` -- **etc** = `/etc/chef/opencodex` (only other reachable config-shaped store) -- **code-legacy-1.1.1** = `.opencodex-backup-20260822T095647Z` (no state) -- **code-legacy-1.2.1** = `opencodex.pre-git-20260822T133750Z` (no state) -- **control-01** = unreachable - -Provider objects in current vs etc are **byte-identical** (18/18). The 89213 vs -70641 byte gap is `claudeCode.desktopProfile`, present only on current -(added after the 06:34Z snapshot). Disabled-model set hash -`ee2334c82007d978` matches (876 ids). Admission-key id -`e224fe64-19e2-4cce-a9c3-3d32dbae2904` matches (name `default`, created -2026-07-31, key length 44, not an env-ref). Combo `google-combo` matches -(2 google targets, failover). Admin token sha of etc == current; both differ -from `admin-api-token.bak-pre-control-20260804` (same length 53, different -content). Service-api-token sha of etc == current (length 32). - -| Domain | current | etc | code-legacy ×2 | control-01 | Missing from current | Conflicts | -| --- | ---: | ---: | ---: | --- | --- | --- | -| providers | 18 | 18 | 0 | UNREACHABLE | none vs etc | none vs etc (adapter/authMode/disabled/pool ids) | -| oauth providers in `auth.json` | **0 (file absent)** | 0 | 0 | UNREACHABLE | **entire auth store if control-01 had one** | n/a | -| oauth accounts | 0 | 0 | 0 | UNREACHABLE | **unknown** | n/a | -| Codex pool accounts (`codex-accounts.json`) | **0 (file absent)** | 0 | 0 | UNREACHABLE | **unknown** | n/a | -| `config.codexAccounts` | 0 | 0 | 0 | UNREACHABLE | none vs etc | n/a | -| admission `apiKeys` | 1 | 1 | 0 | UNREACHABLE | none vs etc | none | -| provider `apiKey` present | 11 | 11 | 0 | UNREACHABLE | none vs etc | none (same env-ref / length) | -| provider `apiKeyPool` entries | 13 | 13 | 0 | UNREACHABLE | none vs etc | none (same ids) | -| env-ref keys | 7 | 7 | 0 | — | — | — | -| inline keys (length only) | 6 | 6 | 0 | — | — | — | -| `disabledModels` | 876 | 876 | 0 | UNREACHABLE | 0 | 0 | -| `subagentModels` | 4 | 4 | 0 | UNREACHABLE | 0 | 0 | -| `providerContextCaps` | 7 | 7 | 0 | UNREACHABLE | 0 | 0 | -| `providerCooldowns` | 0 | 0 | 0 | UNREACHABLE | 0 | 0 | -| combos | 1 | 1 | 0 | UNREACHABLE | 0 | 0 | -| usage.jsonl lines | 174 | 0 | 0 | UNREACHABLE | none vs reachable sources; **pre-2026-08-03 history unknown** | 5 bogus `ocx-early` rows at ts=1000 | -| usage request ids | 170 unique | 0 | 0 | UNREACHABLE | unknown before 2026-08-03 | — | -| responses-state | v2, 3 keys | 0 | 0 | UNREACHABLE | — | — | -| `schemaVersion` | **absent** | **absent** | — | — | — | — | - -Current providers (disabled noted): `openai` (disabled, forward), `cursor` -(disabled, oauth), `google-antigravity` (oauth), `github-copilot` (oauth), -`opencode-free` (key, no key — keyOptional), `mimo-free` (key, no key — -keyOptional), `api-for-cursor` (disabled, env-ref + 2 pool ids `2f193882`, -`a89443c4`), `cline-pass`, `groq`, `nvidia`, `opencode-go` (disabled), `zai`, -`openrouter` (disabled), `deepseek` (2 pool ids `34b8a2c2`, `13e6526e`), -`google`, `vercel-ai-gateway`, `orcarouter`, `kilo`. - -Default provider is `deepseek`. `hostname` is the tailscale bind -`100.109.39.86`. `effortCap` / `subagentEffortCap` = `low`. -`googleAntigravityAccountPool.enabled` = true, strategy `round-robin`. -`tokenGuardian.enabled` = true. `claudeCode.authMode` = `proxy`. -Three oauth-mode providers are configured and **have no on-disk tokens**. - -Usage days on current: 2026-08-03:32, 08-08:64, 08-14:8, 08-18:33, 08-21:8, -08-22:24, plus 5 bogus `ocx-early` rows. Providers seen in the log: -`anthropic` 108 (Claude inbound surface, not a configured provider), -`combo` 18, `no-such-provider` 18, `kilo` 10, `opencode-free` 8, `deepseek` 6, -`unknown` 3, `anthropic-native` 1, `github-copilot` 1, `orcarouter` 1. -**No `google-antigravity` rows.** Success-ish 154 / fail-ish 20. - -Catalog backups `catalog-backup.json`, `catalog-backup-5f03f1706cc5a50f.json`, -`catalog-backup-aaa4c6cf793fb310.json` are the same 321150 B / same sha -(8 models). They are Codex catalog snapshots, not config history. - -`~/.codex` has no ChatGPT `auth.json`. Extra homes -(`~/.claude`, `~/.grok`, `~/.cursor`, `~/.factory`, `~/.junie`) are absent. -A host-wide find for `auth.json` / `codex-accounts.json` under -`/home/chef`, `/opt/chef`, `/etc/chef`, `/var/lib/chef` returned none. - -## 4. What is missing if chef-control-01 never returns - -Proven already on Azure (so not uniquely at risk): - -- The 18-provider config, 876 disabled models, combo, admission key id, - usage from 2026-08-03 onward, device ids, rotated admin token, service token. - -Unprovable, and lost-if-offline: - -1. **`~/.opencodex/auth.json` and `auth.json.pre-multiauth`.** Three live - providers are `authMode=oauth` (`cursor` disabled, `google-antigravity` - and `github-copilot` enabled). Zero token files exist on Azure. If those - logins were completed on control-01, the refresh tokens were not migrated. - Re-login is the only recovery. If they were never completed, nothing was - lost — we cannot tell which. -2. **`~/.opencodex/codex-accounts.json` and any ChatGPT `~/.codex/auth.json`.** - Azure journal `originalConfig` is empty; current `.codex` is a fresh - inject. Any Codex Desktop / pool credentials on control-01 are gone. -3. **usage.jsonl before 2026-08-03.** Current file starts the day of the - host-move artifacts. Earlier request history has no reachable copy. -4. **format-migration backups** (`config.json.pre-openai-tiers-v2.bak`, - Alibaba region bak). Live config is already v2, so rollback-to-v1 is - impossible from Azure disk alone. -5. **anything else under the old home** (hand-edited config, extra apiKey - pool entries, older disabled-model sets). The Azure copy looks like a - single tree that kept being written (config mtime 10:10Z, usage 10:07Z - on 2026-08-22), not a merge of two authorities. - -Risk if it stays offline: **oauth session continuity is the real one**. -Provider *configuration* survived. Tokens may not have. Key-auth providers -that use env-refs still resolve from `service.env`; the six inline keys live -in current `config.json` and the etc snapshot (same lengths). - -## 5. Code migration paths (what the repo actually does) - -There is **no general `schemaVersion`** and no scheduled backup. `loadConfig` -(`src/config.ts:1195`) / `saveConfig` (`:1365`) / `configSchema` (`:741`) / -`getDefaultConfig` (`:1659`) read and write the whole JSON object with -passthrough + a merge-defaults repair. Invalid files are copied to -`config.json.invalid-` and replaced with defaults (data-loss path). - -Targeted migrations that do exist: - -| Path | Trigger | Backup | On Azure disk | -| --- | --- | --- | --- | -| `runOpenAiTierStartupMigration` | startup, if projection.changed | `config.json.pre-openai-tiers-v2.bak` via `backupConfigBeforeOpenAiTierMigration` | already v2; bak absent | -| `oauth/store.ts` `backupLegacyOnce` | first persist of multiauth over legacy | `auth.json.pre-multiauth` | both absent | -| `alibaba-region-backup.ts` | Alibaba region rewrite | `config.json.pre-alibaba-region-v1.bak` | absent | -| history-migration guardian | Codex thread visibility | journal | 0 threads | -| Claude `authMode` three-state | load/reconcile | in-object `authModeMigratedAt` | present | - -A host-to-host OCX state promotion pipeline has never existed. That is what -`scripts/state-reconcile.ts` now is (design + implementation, dry-run -default, not executed against production). - -## 6. Promotion pipeline (design = the committed script) - -`bun scripts/state-reconcile.ts` runs exactly: - -1. **Backup current** into `--backup-dir` with a sha256 manifest of the - state filenames (`config.json`, `auth.json`, `codex-accounts.json`, - `usage.jsonl`, tokens, …). Apply/promote **refuse** without a verified - manifest; tamper fails verification. -2. **Import legacy into staging** (`--staging`). Current is never the write - target. -3. **Schema normalize** — stamp `schemaVersion: 1`, lift legacy single-slot - auth.json into `{activeAccountId, accounts[]}`, seed `apiKeyPool` from a - bare `apiKey`. -4. **Dedupe** — provider name, apiKey id, oauth `(provider, account id)`, - combo id, disabled-model set-union, usage `(requestId, timestamp)`. - Default `--prefer current`. -5. **Referential checks** — combo targets must name a provider; `authMode=oauth` - without auth.json accounts is a FAIL row (the Azure situation). -6. **Record-count reconciliation** — printed per domain: current count, - per-legacy count, missing ids, conflicts. -7. **Functional smoke** — `validateConfigCandidate`, auth shape, JSONL parse. -8. **Promote** — only with `--apply --promote`. Copies live → - `--backup-dir/pre-promote`, writes a rollback pointer, then copies - staging → live. `--rollback` reverses that. - -Safety properties: - -- Default is dry-run. Dry-run writes nothing under current/legacy/backup/staging - (preview materialization uses `os.tmpdir()` and deletes it). -- Apply writes staging only. -- Promote is the only live writer. -- Idempotent: a second apply against the same inputs yields the same - `stagingDigest`. -- Reversible via the pre-promote snapshot. -- Diff/report redacts `apiKey` / `key` / `access` / `refresh` / token values - to length + env-ref. -- Not executed against production in this lane. - -## 7. Minimal schema version + backup (proposal only) - -Do not invent a second config file. Add one field next to the existing -migration marker. - -**Field.** `schemaVersion?: number` on `OcxConfig` (`src/types.ts` ~533) and -`configSchema` (`src/config.ts` ~741), camelCase to match -`openaiProviderTierVersion`. On-disk absence means **1**. -`getDefaultConfig()` writes `schemaVersion: 1`. `loadConfig` / -`readConfigDiagnostics` treat missing as 1 and do not rewrite the file on -read. `saveConfig` / `saveConfigPreservingClaudeCode` persist whatever is on -the object; the first ordinary save after deploy stamps `1`. Bump the -constant in one place when a future rewrite needs a gate. Do not alias -`schema_version` unless a hand-edited file is found — none exists on Azure. - -**Backup.** Two layers, both missing today: - -1. On `saveConfig` success, hard-link/copy `config.json` to - `~/.opencodex/backups/config/config-.json` and keep 14 copies. Reuse - `atomicWriteFile` + `backupConfigBeforeOpenAiTierMigration`'s - no-replace discipline. Same for `auth.json` when the oauth store writes. -2. A systemd timer modeled on `chef-vault-backup.timer` (already on the host) - that copies the `STATE_FILES` list from `scripts/state-reconcile.ts` into - `/var/lib/chef/backups/opencodex//` with a sha256 manifest. Daily is - enough; the store is tens of kilobytes plus 2.7 MB catalogs if included. - -Until both exist, V8 stays open: one bad `saveConfig` after a schema -mismatch still hits `backupInvalidConfig` and can drop providers on the -unrecoverable-parse path. - -## 8. Open UNKNOWNs (need a human) - -1. **Did chef-control-01 ever have `auth.json`?** If yes, promote is a - re-login project, not a file copy, unless the disk comes back. -2. **Should the offline host be powered on / imaged before it is wiped?** - Last seen 2026-08-22 01:31Z. Every day increases the chance the disk is - gone. -3. **Are the six inline (non-env-ref) provider keys supposed to move into - `service.env`?** They live in `config.json` today. Out of scope to change. -4. **`no-such-provider` (18) and `unknown` (3) usage rows** — operator - error vs a dropped provider name from an older config we cannot see. -5. **Keep or delete `/etc/chef/opencodex/config.json`?** It is a useful - 2026-08-22 06:34Z rollback point but it is also a second copy of live - secrets sitting next to `service.env`. -6. **Stamp `schemaVersion` on the next ordinary save, or wait for a - dedicated migrate PR?** Recommendation: dedicated PR so the first write - is intentional. -7. **Who owns restoring google-antigravity / github-copilot / cursor - sessions?** Config says they exist; the token file does not. - -## 9. Safety attestation - -- Production (`chef-control-az-01`) was accessed only with read-only - commands (`ls`, `find`, `stat`, `cat` / `sudo cat`, `systemctl status|cat`, - `curl` to `/healthz`, `node` over SSH stdin). No writes, moves, deletes, - chmods, restarts, restores, or cron/timer changes. -- `scripts/state-reconcile.ts` was not pointed at the live store. -- This document contains no API key, token, cookie, or refresh-token - values. `/etc/chef/opencodex/service.env` is reported by key name only. -- No merge, push, tag, or PR was created. +# Runtime state forensics + +OpenCodex persists product state in local configuration/state files rather than +requiring a database. This document records the public methodology for +determining state authority without publishing a specific operator's production +topology. + +## What to inventory + +For every candidate runtime, record: + +- exact OpenCodex version and source/package identity; +- effective configuration root and state paths; +- service manager ownership and the process actually holding the listen socket; +- credential *references* and environment variable names, never credential values; +- config/session/provider state file names, sizes, mtimes and hashes; +- backups and snapshots that can restore those files; +- whether generated Codex/client artifacts are authoritative or reproducible. + +## Authority rules + +1. A running process is not automatically the state authority. +2. A deployment checkout is not automatically the state authority. +3. Generated client/shim/catalog files are derived unless the product explicitly + documents otherwise. +4. Secret files and browser/OAuth material must be inspected only by metadata or + hash/reference; do not copy values into evidence. +5. If an older candidate state store is unreachable, completeness remains + unproven until it is recovered or explicitly retired with evidence. + +## Safe evidence collection + +Prefer read-only commands such as `ocx doctor`, `ocx status`, +`systemctl show`, `stat`, `find`, checksums, and configuration validation. +Capture host-specific results in the deployment operator's private repository, +not in this public product repository. + +The public repository owns the product's state semantics and recovery tooling. +Production hostnames, private network addresses, release-tree paths, account +identifiers, provider fleet inventory and incident evidence are deployment +state and must remain private. diff --git a/docs/models.md b/docs/models.md index 39b524a4..cc957650 100644 --- a/docs/models.md +++ b/docs/models.md @@ -1,45 +1,34 @@ -# Fleet model catalog - -This is the ChefGroep runtime catalog for the OpenCodex proxy. It is not a -built-in provider preset. Keys stay in the host environment file. The key-free -shape lives in [`deploy/container/model-catalog.example.json`](../deploy/container/model-catalog.example.json). - -Verified 2026-09-22 against the three Foundry resources in subscription -"Azure subscription 1". Each deployment was `Succeeded` and `chatCompletion=true`. -Chat on `https://.cognitiveservices.azure.com/openai/v1/chat/completions` -returns 200 with `Authorization: Bearer` for these deployments, which is the -`openai-chat` adapter (`baseUrl` plus `/chat/completions`, no `api-version` -query). The same resources also answer on `openai.azure.com` and -`services.ai.azure.com`; the catalog uses one base URL per resource because -OCX rejects query strings on `baseUrl`. - -| Provider id | Resource | Region | Resource group | Env var | -| ------------------ | ------------------ | ------------- | -------------------- | ----------------------------------- | -| `azure-us` | `openaichef` | eastus | `azureai-us0-east` | `AZURE_OPENAI_KEY_OPENAICHEF` | -| `azure-se` | `openaichef-se` | swedencentral | `azureai-se-central` | `AZURE_OPENAI_KEY_OPENAICHEF_SE` | -| `azure-foundry-us` | `azure-foundry-us` | eastus | `foundry` | `AZURE_OPENAI_KEY_AZURE_FOUNDRY_US` | - -The 2026-09-24 live deployment check moved DeepSeek V4 Pro off `openaichef`: only `azure-foundry-us/fw-deepseek-v4-pro` is advertised for that model family now. - -Public model ids are `/` because a provider id cannot -contain `/`. Examples: `azure-se/grok-4-6`, `azure-us/DeepSeek-V4-1-Flash`, -`azure-foundry-us/fw-deepseek-v4-pro`. Deployment names are sent upstream -unchanged. - -A bare deployment name routes only when it is unique. `grok-4-6` and -`DeepSeek-V4-1-Flash` exist on both `azure-us` and `azure-se`; the first -configured provider (`azure-us`, also `defaultProvider`) wins for those bare -names. Use the provider prefix when the region matters. - -## Not in this catalog - -Do not add these upstreams back: - -- `jort-7512-resource` (retired Foundry resource, not in the current subscription) -- AWS hosts (`chef-platform-aws-01`, `*.amazonaws.com` Bedrock) — AWS is closed -- `chef-control-az-01` — retired control host -- llama.cpp / weg54 local inference — those servers are stopped -- offline Tailscale addresses - -The former `deploy.yml` Azure route is retired fail-closed. It is not a model -provider and must not be retargeted through this catalog. +# Model catalog example + +OpenCodex ships a key-free example catalog at +[`deploy/container/model-catalog.example.json`](../deploy/container/model-catalog.example.json). +It demonstrates the configuration shape only; it is not a production inventory. + +Provider credentials belong in environment variables or the configured secret +plane. Production provider names, account/resource identifiers, private network +locations and deployment-specific model allowlists must stay outside the public +product repository. + +The example uses an OpenAI-compatible chat endpoint: + +```json +{ + "defaultProvider": "example-openai", + "providers": { + "example-openai": { + "adapter": "openai-chat", + "baseUrl": "https://api.example.com/v1", + "authMode": "key", + "apiKey": "$EXAMPLE_OPENAI_API_KEY", + "defaultModel": "example-chat", + "models": ["example-chat"], + "selectedModels": ["example-chat"] + } + } +} +``` + +For a real deployment, maintain the fleet/provider inventory in the deployment +repository or secret/configuration system that owns that environment. Validate +the resulting OpenCodex configuration before restart and verify `/v1/models` +through the deployment's authenticated data-plane path. diff --git a/scripts/oidc-authorize-canary.sh b/scripts/oidc-authorize-canary.sh index e6eaa98d..77a18d55 100755 --- a/scripts/oidc-authorize-canary.sh +++ b/scripts/oidc-authorize-canary.sh @@ -1,21 +1,30 @@ #!/bin/sh -# Probe the live Authentik issuer and, optionally, a running OCX authorize -# entry. Never prints secrets. Default issuer is the ChefGroep Auth apply -# from 2026-09-18. /healthz is not this script — use scripts/healthz-smoke.sh. +# Probe a configured Authentik issuer and, optionally, a running OpenCodex +# authorize entry. Never prints secrets. /healthz is not this script — use +# scripts/healthz-smoke.sh. # +# Required: +# OIDC_ISSUER +# OIDC_CLIENT_ID # Optional: -# OIDC_ISSUER default https://auth.chefgroep.online/application/o/ocx/ -# OIDC_CLIENT_ID default chefgroep-ocx-oidc # OPENCODEX_OIDC_CANARY_URL proxy origin, e.g. http://127.0.0.1:10100 # When set, GET /oauth/login must 302 to Authentik. set -eu -ISSUER="${OIDC_ISSUER:-https://auth.chefgroep.online/application/o/ocx/}" +ISSUER="${OIDC_ISSUER:-}" +[ -n "$ISSUER" ] || { + echo "oidc-authorize-canary: OIDC_ISSUER is required" >&2 + exit 78 +} case "$ISSUER" in */) ;; *) ISSUER="${ISSUER}/" ;; esac -CLIENT_ID="${OIDC_CLIENT_ID:-chefgroep-ocx-oidc}" +CLIENT_ID="${OIDC_CLIENT_ID:-}" +[ -n "$CLIENT_ID" ] || { + echo "oidc-authorize-canary: OIDC_CLIENT_ID is required" >&2 + exit 78 +} DISCOVERY="${ISSUER}.well-known/openid-configuration" if ! command -v python3 >/dev/null 2>&1; then diff --git a/tests/azure-fleet-catalog.test.ts b/tests/azure-fleet-catalog.test.ts deleted file mode 100644 index 3ac3b87a..00000000 --- a/tests/azure-fleet-catalog.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { validateConfigCandidate } from "../src/config"; - -const repoRoot = fileURLToPath(new URL("..", import.meta.url)); -const catalogPath = join( - repoRoot, - "deploy/container/model-catalog.example.json", -); - -describe("Azure Foundry fleet catalog", () => { - test("example config validates and lists only the three live resources", () => { - const raw = readFileSync(catalogPath, "utf8"); - expect(raw).not.toContain('"DeepSeek-V4-Pro"'); - expect(raw).not.toContain('"DeepSeek-V4-Pro-dz"'); - expect(raw).not.toContain('"fw-deepseek-v4-1-flash"'); - expect(raw).not.toContain('"grok-4-20-reasoning"'); - expect(raw).not.toContain('"grok-4-1-fast'); - expect(raw).not.toMatch( - /jort-7512|amazonaws\.com|chef-platform-aws|chef-control-az-01|llama\.cpp/i, - ); - expect(raw).not.toMatch(/\bsk-[A-Za-z0-9_-]{20,}\b/); - const parsed = JSON.parse(raw) as { - defaultProvider: string; - providers: Record< - string, - { apiKey?: string; baseUrl: string; models: string[] } - >; - }; - const result = validateConfigCandidate(parsed); - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.config.defaultProvider).toBe("azure-us"); - expect(Object.keys(result.config.providers).sort()).toEqual([ - "azure-foundry-us", - "azure-se", - "azure-us", - ]); - expect(result.config.providers["azure-us"]?.models).toEqual([ - "grok-4-6", - "DeepSeek-V4-1-Flash", - "fw-glm-5-3-flash", - "fw-kimi-k3", - "deepseek-v4-flash-0731", - "kimi-k2-7-code", - "fw-glm-5-3-dz", - ]); - expect(result.config.providers["azure-se"]?.models).toHaveLength(5); - expect(result.config.providers["azure-foundry-us"]?.models).toEqual([ - "fw-deepseek-v4-pro", - ]); - expect(result.config.providers["azure-us"]?.baseUrl).toBe( - "https://openaichef.cognitiveservices.azure.com/openai/v1", - ); - expect(result.config.providers["azure-se"]?.baseUrl).toBe( - "https://openaichef-se.cognitiveservices.azure.com/openai/v1", - ); - expect(result.config.providers["azure-foundry-us"]?.baseUrl).toBe( - "https://azure-foundry-us.cognitiveservices.azure.com/openai/v1", - ); - for (const provider of Object.values(result.config.providers)) { - expect(provider.apiKey).toMatch(/^\$[A-Z0-9_]+$/); - } - }); -}); diff --git a/tests/ci-workflows.test.ts b/tests/ci-workflows.test.ts index 2e393caf..5b2fda38 100644 --- a/tests/ci-workflows.test.ts +++ b/tests/ci-workflows.test.ts @@ -617,10 +617,10 @@ describe("GitHub Actions hardening", () => { expect(deploy.env?.DEPLOY).toBe("${{ inputs.deploy }}"); expect(deploy.run).toContain('if [ "$DEPLOY" != "true" ]'); expect(deploy.run).toContain( - "deploy=true is disabled because deploy.yml targets retired chef-control-az-01.", + "deploy=true is disabled because deploy.yml targets retired production host.", ); expect(deploy.run).toContain( - "Use the separately verified bc-scan-2 package deployment contract.", + "Use the separately verified private production deployment contract.", ); expect(deploy.run).not.toContain("gh workflow run deploy.yml"); expect(deploy.run).toContain("exit 1"); @@ -3236,12 +3236,12 @@ describe("GitHub Actions hardening", () => { // The retirement is documented in-line so the release path cannot silently // regain the old Azure side effect as the workflows evolve independently. expect(text).toContain( - "former chef-control-az-01 deploy route is permanently retired", + "former production deploy route is permanently retired", ); expect(text).toContain( "Publication intentionally has no runtime side effect", ); - expect(text).toContain("bc-scan-2 package deployment contract"); + expect(text).toContain("private production deployment contract"); }); test("service-lifecycle workflow file has no trailing blank line", async () => { @@ -3631,7 +3631,7 @@ describe("GitHub Actions hardening", () => { ); expect(text).not.toContain("chef-control"); - expect(text).not.toContain("/home/joep"); + expect(text).not.toMatch(/\/home\/[A-Za-z0-9_-]+\//); expect(text).not.toContain("deploy.yml"); expect(text).not.toMatch(/uses:\s+\S+@(?:v\d+|main|master)\b/); expect(text).not.toMatch( diff --git a/tests/deploy-workflow-contract.test.ts b/tests/deploy-workflow-contract.test.ts index 91945ba5..d3fa68ac 100644 --- a/tests/deploy-workflow-contract.test.ts +++ b/tests/deploy-workflow-contract.test.ts @@ -68,10 +68,10 @@ describe("retired deploy workflow contract", () => { expect(refusal?.uses).toBeUndefined(); expect(refusal?.env).toBeUndefined(); expect(refusal?.run).toContain( - "The chef-control-az-01 deployment route is permanently retired.", + "The previous production deployment route is permanently retired.", ); expect(refusal?.run).toContain( - "use the separately verified bc-scan-2 package deployment contract", + "use the separately verified private production deployment contract", ); expect(refusal?.run?.trim().endsWith("exit 1")).toBe(true); // This is a fail-closed audit stub. Do not let a future edit revive remote @@ -131,8 +131,8 @@ describe("retired deploy workflow contract", () => { ); expect(releaseProcess).not.toContain("gh workflow run deploy.yml"); - expect(containerReadme).toContain("retired fail-closed"); - expect(containerReadme).toContain("separate operation"); + expect(containerReadme).toContain("intentionally fails closed"); + expect(containerReadme).toContain("private deployment repository"); expect(containerReadme).not.toContain("OPENCODEX_IMAGE"); expect(publishOnTag).toContain("Publication does not deploy a runtime"); diff --git a/tests/local-dev-contract.test.ts b/tests/local-dev-contract.test.ts index 51f150b3..4a43a997 100644 --- a/tests/local-dev-contract.test.ts +++ b/tests/local-dev-contract.test.ts @@ -127,37 +127,36 @@ describe("local/dev complete path", () => { expect(compose).not.toContain("OIDC_CLIENT_SECRET_FILE:?"); }); - test("systemd unit keeps the compose place lock and documents 1.5.1 / c88648fa8", async () => { + test("systemd example is deployment-neutral and compose-only", async () => { const unit = await readRepo("deploy/container/opencodex-proxy.service"); expect(unit).toContain( "ExecStart=/usr/bin/docker compose up -d --remove-orphans", ); expect(unit).toContain("ExecStop=/usr/bin/docker compose down"); - expect(unit).toContain("WorkingDirectory=/opt/chef/deploy/opencodex"); - expect(unit).toContain("EnvironmentFile=-/opt/chef/deploy/opencodex/.env"); + expect(unit).toContain("WorkingDirectory=/opt/opencodex"); + expect(unit).toContain("EnvironmentFile=-/opt/opencodex/.env"); expect(unit).toContain( "After=docker.service network-online.target tailscaled.service", ); - expect(unit).toContain("1.5.1"); - expect(unit).toContain("c88648fa87001d04beedc8df853bee7700253422"); - expect(unit).toContain("GET :10100/healthz"); + expect(unit).toContain("Wants=network-online.target tailscaled.service"); + expect(unit).not.toContain("/opt/chef/"); + expect(unit).not.toMatch(/100\.\d+\.\d+\.\d+/); + expect(unit).not.toMatch(/[0-9a-f]{40}/); }); - test(".env.example and OIDC placeholder carry redirects and no secrets", async () => { + test(".env.example and OIDC placeholder are generic and secret-free", async () => { const envExample = await readRepo(".env.example"); expect(envExample).toContain( - "OIDC_ISSUER=https://auth.chefgroep.online/application/o/ocx/", + "OIDC_ISSUER=https://id.example.com/application/o/opencodex/", ); - expect(envExample).toContain("OIDC_CLIENT_ID=chefgroep-ocx-oidc"); + expect(envExample).toContain("OIDC_CLIENT_ID=opencodex"); expect(envExample).toContain("OIDC_CLIENT_SECRET_FILE="); expect(envExample).toContain("OIDC_ALLOWED_HOSTS="); - expect(envExample).toContain("CUTOVER-CHECKLIST.md"); - expect(envExample).toContain("APPLY DONE 2026-09-18"); expect(envExample).toContain( "OIDC_REDIRECT_URI=http://127.0.0.1:10100/oauth/callback", ); - expect(envExample).toContain("https://ocx.chefgroep.online/oauth/callback"); - expect(envExample).toContain("c88648fa87001d04beedc8df853bee7700253422"); + expect(envExample).not.toContain("chefgroep.online"); + expect(envExample).not.toMatch(/[0-9a-f]{40}/); expect(envExample).not.toMatch(/OIDC_CLIENT_SECRET=/); expect(envExample).not.toMatch(/\bsk-[A-Za-z0-9_-]{20,}\b/); expect(envExample).not.toMatch(/\bghp_[A-Za-z0-9_]{20,}\b/); @@ -175,44 +174,30 @@ describe("local/dev complete path", () => { post_logout_redirect_uris: string[]; }; }; - expect(oidc.status).toBe("consumer-wired"); + expect(oidc.status).toBe("example-only"); expect(oidc.application.client_secret).toBeNull(); - expect(oidc.application.client_id).toBe("chefgroep-ocx-oidc"); + expect(oidc.application.client_id).toBe("opencodex"); expect(oidc.application.issuer).toBe( - "https://auth.chefgroep.online/application/o/ocx/", + "https://id.example.com/application/o/opencodex/", ); - expect(oidc.notes.join("\n")).toContain("APPLY DONE 2026-09-18"); - expect(oidc.notes.join("\n")).toContain("not DNS HOLD"); - expect(oidc.notes.join("\n")).toContain( - "The proxy verifies Authentik ID tokens", - ); - expect(oidc.notes.join("\n")).toContain( - "Cloudflare Access remains the live public-host dashboard gate", - ); - expect(oidc.notes.join("\n")).toContain("CUTOVER-CHECKLIST.md"); expect(oidc.application.redirect_uris).toEqual([ "http://127.0.0.1:10100/oauth/callback", "http://localhost:10100/oauth/callback", - "https://ocx.chefgroep.online/oauth/callback", + "https://opencodex.example.com/oauth/callback", ]); expect(oidc.application.post_logout_redirect_uris).toContain( - "https://ocx.chefgroep.online/", + "https://opencodex.example.com/", ); + expect(oidc.notes.join("\n")).toContain("product example"); + expect(oidc.notes.join("\n")).toContain("OIDC_CLIENT_SECRET_FILE"); const operatorNotes = await readRepo("deploy/container/README.md"); - expect(operatorNotes).toContain("APPLY DONE 2026-09-18"); - expect(operatorNotes).toContain("chefgroep-ocx-oidc"); - expect(operatorNotes).toContain("Cloudflare Access remains the live"); - expect(operatorNotes).toMatch(/\*\*not\*\* DNS HOLD/); - expect(operatorNotes).toContain("Do not deploy this PR to bc-scan-2"); + expect(operatorNotes).toContain("Production boundary"); + expect(operatorNotes).toContain("private deployment"); expect(operatorNotes).toContain("CUTOVER-CHECKLIST.md"); - expect(operatorNotes).toContain("verifies Authentik ID tokens"); - expect(operatorNotes).not.toMatch( - /Authentik product gate is greenfield: no live issuer/, - ); - expect(operatorNotes).not.toMatch( - /The proxy does \*\*not\*\* verify Authentik tokens yet/, - ); + expect(operatorNotes).not.toContain("chefgroep.online"); + expect(operatorNotes).not.toMatch(/100\.\d+\.\d+\.\d+/); + expect(operatorNotes).not.toMatch(/\/home\/[A-Za-z0-9_-]+\//); }); test("devcontainer forwards :10100 and bootstraps Bun 1.4.0", async () => { @@ -245,25 +230,26 @@ describe("local/dev complete path", () => { expect(script).not.toMatch(/OPENCODEX_API_AUTH_TOKEN=/); }); - test("oidc-authorize-canary.sh probes discovery/JWKS and optional /oauth/login", async () => { + test("oidc-authorize-canary.sh requires deployment-owned issuer/client and probes discovery", async () => { const script = await readRepo("scripts/oidc-authorize-canary.sh"); - expect(script).toContain( - "https://auth.chefgroep.online/application/o/ocx/", - ); - expect(script).toContain("chefgroep-ocx-oidc"); + expect(script).toContain("OIDC_ISSUER is required"); + expect(script).toContain("OIDC_CLIENT_ID is required"); expect(script).toContain(".well-known/openid-configuration"); expect(script).toContain("/oauth/login"); expect(script).toContain("code_challenge="); + expect(script).not.toContain("auth.chefgroep.online"); + expect(script).not.toContain("chefgroep-ocx-oidc"); expect(script).not.toMatch(/OIDC_CLIENT_SECRET=/); expect(script).not.toMatch(/\bsk-[A-Za-z0-9_-]{20,}\b/); const checklist = await readRepo("deploy/oidc/CUTOVER-CHECKLIST.md"); - expect(checklist).toContain("Cloudflare Access remains the live"); + expect(checklist).toContain("deployment-neutral"); expect(checklist).toContain("GET /oauth/login"); - expect(checklist).toContain("Do not apply Cloudflare DNS"); - expect(checklist).toContain("ChefFactory"); - expect(checklist).toContain("1.5.1"); - expect(checklist).toContain(":10100"); + expect(checklist).toContain("Do not apply production DNS"); + expect(checklist).toContain("private deployment repository"); + expect(checklist).toContain("/v1/models"); + expect(checklist).not.toMatch(/100\.\d+\.\d+\.\d+/); + expect(checklist).not.toMatch(/[0-9a-f]{40}/); }); }); diff --git a/tests/model-catalog-example.test.ts b/tests/model-catalog-example.test.ts new file mode 100644 index 00000000..8730fcb0 --- /dev/null +++ b/tests/model-catalog-example.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { validateConfigCandidate } from "../src/config"; + +const repoRoot = fileURLToPath(new URL("..", import.meta.url)); +const catalogPath = join( + repoRoot, + "deploy/container/model-catalog.example.json", +); + +describe("generic model catalog example", () => { + test("validates without ChefGroep production inventory", () => { + const raw = readFileSync(catalogPath, "utf8"); + + expect(raw).toContain("example-openai"); + expect(raw).toContain("https://api.example.com/v1"); + expect(raw).toContain("$EXAMPLE_OPENAI_API_KEY"); + + for (const forbidden of [ + "amazonaws.com", + "/home/", + "tailscale", + "private deployment", + ]) { + expect(raw).not.toContain(forbidden); + } + expect(raw).not.toMatch(/\bsk-[A-Za-z0-9_-]{20,}\b/); + + const parsed = JSON.parse(raw); + const result = validateConfigCandidate(parsed); + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.config.defaultProvider).toBe("example-openai"); + expect(Object.keys(result.config.providers)).toEqual(["example-openai"]); + expect(result.config.providers["example-openai"]?.models).toEqual([ + "example-chat", + ]); + expect(result.config.providers["example-openai"]?.apiKey).toBe( + "$EXAMPLE_OPENAI_API_KEY", + ); + }); +});