diff --git a/.codex/hooks.json b/.codex/hooks.json new file mode 100644 index 0000000..555c5e9 --- /dev/null +++ b/.codex/hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "test -f docs/STATUS.md && test -f docs/AI_HANDOFF.md && test -f AGENTS.md", + "timeout": 10, + "statusMessage": "Checking CRP harness docs exist" + } + ] + } + ] + } +} diff --git a/.github/workflows/platform-tests.yml b/.github/workflows/platform-tests.yml new file mode 100644 index 0000000..e2646cb --- /dev/null +++ b/.github/workflows/platform-tests.yml @@ -0,0 +1,104 @@ +name: Platform Tests + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: platform-tests-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + platform: + name: Node 22 / ${{ matrix.label }} + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - label: Linux + os: ubuntu-latest + browser: false + - label: macOS + os: macos-latest + browser: true + - label: Windows + os: windows-latest + browser: true + defaults: + run: + working-directory: node + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + package-manager-cache: false + + - name: Install dependencies + run: npm ci + + - name: Run syntax checks + run: npm run lint + + - name: Run deterministic tests + run: npm test + + - name: Install Chromium + if: matrix.browser + run: npx playwright install chromium + + - name: Run browser E2E tests + if: matrix.browser + run: npm run test:e2e -- --project=chromium --workers=1 + + - name: Upload sanitized UI evidence + if: always() && matrix.browser + uses: actions/upload-artifact@v4 + with: + name: crp-ui-${{ matrix.label }}-${{ github.run_id }} + path: output/playwright/task11/** + if-no-files-found: warn + retention-days: 14 + + - name: Install Linux Secret Service + if: runner.os == 'Linux' + shell: bash + run: | + sudo apt-get update + sudo apt-get install --yes dbus-x11 gnome-keyring libglib2.0-bin + + - name: Smoke test Linux native keyring through Secret Service + if: runner.os == 'Linux' + shell: bash + env: + CRP_NATIVE_KEYRING_SMOKE: "1" + run: | + smoke_home="$RUNNER_TEMP/crp-native-keyring-home" + mkdir -p "$smoke_home/.local/share/keyrings" + chmod 700 "$smoke_home" "$smoke_home/.local" "$smoke_home/.local/share" "$smoke_home/.local/share/keyrings" + HOME="$smoke_home" dbus-run-session -- bash -euo pipefail -c ' + printf "%s" "crp-ci-keyring" | gnome-keyring-daemon --components=secrets --unlock >/dev/null + gdbus wait --session --timeout 10 org.freedesktop.secrets + gdbus call --session --dest org.freedesktop.secrets --object-path /org/freedesktop/secrets --method org.freedesktop.DBus.Peer.Ping >/dev/null + gdbus call --session --dest org.freedesktop.secrets --object-path /org/freedesktop/secrets/aliases/default --method org.freedesktop.DBus.Properties.Get org.freedesktop.Secret.Collection Label >/dev/null + node scripts/native-keyring-smoke.mjs + ' + + - name: Smoke test native keyring + if: runner.os != 'Linux' + env: + CRP_NATIVE_KEYRING_SMOKE: "1" + run: node scripts/native-keyring-smoke.mjs diff --git a/.github/workflows/release-preflight.yml b/.github/workflows/release-preflight.yml index 952ed1d..9e43eb5 100644 --- a/.github/workflows/release-preflight.yml +++ b/.github/workflows/release-preflight.yml @@ -5,6 +5,14 @@ on: paths: - ".github/workflows/release.yml" - ".github/workflows/release-preflight.yml" + - ".github/workflows/platform-tests.yml" + - "node/bin/**" + - "node/src/**" + - "node/ui/**" + - "node/scripts/**" + - "node/test/package-content.test.mjs" + - "node/test/native-keyring-smoke.test.mjs" + - "node/test/release-workflows.test.mjs" - "node/package.json" - "node/package-lock.json" - "node/.changeset/**" @@ -24,6 +32,7 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 + persist-credentials: false - name: Setup Node.js uses: actions/setup-node@v6 @@ -34,18 +43,67 @@ jobs: - name: Install dependencies run: npm ci - - name: Run tests if present - run: npm run test --if-present + - name: Run syntax checks + run: npm run lint - - name: Detect changeset files + - name: Run deterministic tests + run: npm test + + - name: Audit runtime dependencies + run: npm audit --omit=dev + + - name: Verify package contents + run: node --test test/package-content.test.mjs + + - name: Dry-run npm package + run: npm pack --dry-run --json --ignore-scripts + + - name: Classify Changesets release pull request + id: release_pr + if: >- + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository && + startsWith(github.event.pull_request.head.ref, 'changeset-release/') && + github.event.pull_request.user.login == 'github-actions[bot]' + run: echo "exempt=true" >> "$GITHUB_OUTPUT" + + - name: Detect release-impacting and changeset files id: changesets + shell: bash run: | - if git diff --name-only origin/main...HEAD | grep -E '^node/\.changeset/.*\.md$' | grep -v 'README.md' >/dev/null; then + git diff --name-only origin/main...HEAD > "$RUNNER_TEMP/crp-changed-files.txt" + if grep -E '^node/\.changeset/.*\.md$' "$RUNNER_TEMP/crp-changed-files.txt" | grep -v -E '^node/\.changeset/README\.md$' >/dev/null; then echo "present=true" >> "$GITHUB_OUTPUT" else echo "present=false" >> "$GITHUB_OUTPUT" fi + if grep -Eq '^node/(bin|src|ui)/|^node/package(-lock)?\.json$' "$RUNNER_TEMP/crp-changed-files.txt"; then + echo "release_impact=true" >> "$GITHUB_OUTPUT" + else + echo "release_impact=false" >> "$GITHUB_OUTPUT" + fi + + - name: Require a minor Changeset for package behavior changes + if: steps.release_pr.outputs.exempt != 'true' && steps.changesets.outputs.release_impact == 'true' && steps.changesets.outputs.present != 'true' + run: | + echo "A minor Changeset is required for package behavior changes." >&2 + exit 1 + + - name: Validate minor Changeset state + if: steps.release_pr.outputs.exempt != 'true' && steps.changesets.outputs.present == 'true' + shell: bash + run: | + status_file="$RUNNER_TEMP/crp-changeset-status.json" + npm run changeset -- status --since=origin/main --output "$status_file" + CHANGESET_STATUS_FILE="$status_file" node --input-type=module <<'NODE' + import { readFileSync } from "node:fs"; - - name: Validate changeset state - if: steps.changesets.outputs.present == 'true' - run: npm run changeset -- status --since=origin/main + const status = JSON.parse(readFileSync(process.env.CHANGESET_STATUS_FILE, "utf8")); + const release = status.releases?.find((entry) => ( + entry.name === "@cluic/codex-remote-proxy" + )); + if (!release || release.type !== "minor") { + process.stderr.write("The package requires a minor Changeset.\n"); + process.exit(1); + } + NODE diff --git a/.gitignore b/.gitignore index 501d03f..b21d4b4 100644 --- a/.gitignore +++ b/.gitignore @@ -35,7 +35,12 @@ coverage/ /scripts/__pycache__/ /node/node_modules/ /node/.npm/ +/output/ +/.playwright-cli/ +/crp-web-product.zip /local_docs/ /local_docs /python/ /__pycache__/ +.superpowers/ +.worktrees/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..208f472 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,77 @@ +# AGENTS.md + +## Project Map + +- CLI and supervisor entrypoint: `node/bin/crp.mjs` +- Proxy worker: `node/src/server.mjs` +- Provider, credential, and control-plane modules: `node/src/` +- Local Web management UI: `node/ui/` (target architecture) +- Tests: `node/test/` + +## Working Rules + +- Read this file before editing. +- Implement only within the user-approved scope. +- Keep Codex `model_provider` and the proxy address stable; provider switching belongs inside CRP. +- Never return, log, capture, or commit complete API keys. +- Update affected API, data, permissions, UI/UX, testing, status, and handoff documentation with behavior changes. +- Do not run parallel writable agents without exact scopes and no-edit areas. +- When the user asks to conserve root-agent context, delegate repository reconnaissance and review to a subagent. +- Record reusable work mistakes as one concise required or prohibited sentence. +- Clear secret state and its current DOM value before validation, requests, or re-rendering. +- Cancel stale asynchronous focus callbacks and preserve newer user focus. +- Browser fixtures must mirror production enum values and response contracts. +- Temporary-resource checks must stay within the current `$TMPDIR`; traversing all of `/var/folders` is prohibited. +- Temporary-resource checks must target task-specific glob paths instead of recursively scanning every `$TMPDIR` entry. +- Package-content tests must compare the exact reviewed allowlist. +- CI native-backend gates must probe the intended platform service and must not accept fallback storage. +- Tests must import only declared direct dependencies, and every checkout before pull-request code must set `persist-credentials: false`. +- Secret-bearing negative tests must assert absence before equality so a RED failure cannot print the sentinel. +- CLI human-output tests must set an explicit locale instead of inheriting the developer environment. +- Deterministic loopback D1 evidence must not be reported as native-keyring or external-upstream D2 completion. +- macOS native-keyring tests must isolate CRP paths through `CRP_HOME` while preserving the real `HOME` required to access the login Keychain. +- Read-only diagnostics must not run tests or commands that create temporary resources. +- Detached Supervisor startup failures must preserve only strictly allowlisted errors and must not collapse into readiness timeouts. +- Sensitive-diff scans must use separately quoted simple patterns; nested shell quoting in one composite regex is prohibited. +- Shell search patterns containing backticks must be single-quoted so command substitution cannot occur. +- Automatic first-provider selection must use compare-and-set and must not start or reconfigure the Worker. +- Partial-commit tests must encode deterministic resource order in fixture names instead of relying on filesystem traversal order. +- Public numeric summaries must enforce explicit lower and upper bounds; safe-integer checks alone are insufficient. +- Parallel-write coordination paths must exactly match the files assigned to each agent before writing begins. +- Production recovery adapters must inject every inspection and execution method required by the lower-level recovery contract. +- Any Codex configuration lock must make public readiness false until bootstrap safely resolves it. +- A multi-resource repair may modify only snapshots that were durably backed up in the same attempt. +- Recoverable deletion must use a fixed discoverable intermediate marker; a random claim alone is prohibited. +- Exact Codex provider inspection and patching must share one semantic statement scanner. +- The target config hash must be rechecked before history writes and before pending state is cleared. +- Rollout metadata must be durable before rename, and final verification must fsync every affected parent directory. +- Every committed `pending: true` failure must preserve a discoverable marker or retain the Codex config lock. +- Unexpected Worker recovery must run inside the strict Codex readiness gate and recheck its cancellation generation before spawning. +- When a workspace read returns `EPERM`, rerun the required command with sandbox escalation before attributing it to macOS privacy controls. +- Verify a prior writer's actual status before spawning a replacement for a temporarily missing agent-list entry. +- Response-start metrics must be measured at the first non-empty response body chunk, not when response headers arrive. +- Initialization effects must not depend on state they mutate when cleanup can abort bootstrap. +- Programmatic dialog transitions must cancel stale focus restoration and prioritize explicit autofocus. +- Closed off-canvas navigation must leave the focus and accessibility order. +- Setup selection must use no-start compare-and-set; stopped-worker activation must disclose that it starts the Worker. +- Visually hidden tables must be clipped by a non-table wrapper. +- Metrics storage limits must accommodate every valid maximum-cardinality document. +- Temporary release-smoke cleanup must stay inside its exact `$TMPDIR` root and use an allowed bounded filesystem operation. +- Session-bootstrap routes must compare the raw request target, and tests must preserve non-canonical targets. +- Asynchronous catalog refresh must preserve nonempty manual selections and clear provider-scoped catalogs before switching. +- Zsh scripts must not use `path` as a variable name because it overrides the executable search path. + +## Required Checks + +- Current test suite: `cd node && npm test` +- Runtime dependency audit: `cd node && npm audit --omit=dev` +- Future UI-bearing changes: run browser E2E and retain visual evidence. +- Future credential, config migration, or lifecycle changes are L3 and require expert confirmation. + +## Done Means + +- Relevant deterministic checks pass. +- Changed behavior has tests or a documented reason. +- Sensitive values are absent from logs, API responses, fixtures, and diffs. +- Affected public documentation reflects the resulting facts. +- The diff contains no unrelated changes and its merge risk is classified. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c4a4b3b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Cluic + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index bb979d1..c4edd13 100644 --- a/README.md +++ b/README.md @@ -2,251 +2,198 @@ # Codex Remote Proxy -Codex Remote Proxy lets Codex stay signed into ChatGPT for remote-control features while sending the actual model traffic to your own OpenAI-compatible `base_url` and API key. +Codex Remote Proxy (CRP) keeps Codex signed in with ChatGPT while routing model traffic to a selected OpenAI-compatible provider. Codex continues to use the built-in `OpenAI` provider identity, so switching upstreams does not move existing OpenAI-tagged threads. -[中文文档](./README.zh-CN.md) +[简体中文](./README.zh-CN.md) -Published on npm: +> Release status: npm `0.2.2` is still the published pre-supervisor version and does not include `crp ui`. The instructions below describe the pending next minor release; its external platform and L3 gates must pass before publication. -```bash -npm install -g @cluic/codex-remote-proxy -``` - -## What It Solves - -Codex splits request routing and authentication across two local files: - -- `~/.codex/config.toml` controls the OpenAI `base_url` -- `~/.codex/auth.json` controls the `Authorization` token +## Install After Release -When Codex is signed into ChatGPT, requests may still carry `tokens.access_token` instead of the API key required by your upstream provider. - -This project inserts a local proxy that: - -1. receives Codex requests on `127.0.0.1` -2. forwards them to the real upstream -3. rewrites `Authorization` to the real upstream API key - -## Recommended Installation - -The Node implementation is the recommended and most tested path for successful forwarding and conversations. - -### Global install +Node.js 22.13 or newer is required. ```bash npm install -g @cluic/codex-remote-proxy ``` -Then run: - -```bash -crp init -crp start -``` - -### Without global install +The ordinary-user entry point is: ```bash -npx @cluic/codex-remote-proxy init -npx @cluic/codex-remote-proxy start +crp ui ``` -### From this repository - -If you are running directly from the repository: +Without a global install: ```bash -cd node -npm install -node bin/crp.mjs start +npx @cluic/codex-remote-proxy ui ``` -After setup: - -1. Restart Codex Desktop -2. Sign in with your ChatGPT account -3. Use Codex normally +`crp ui` starts or discovers the local supervisor and opens the management UI. The interface starts in English on every browser and supports Simplified Chinese through the language selector. Only an explicitly selected locale is stored in browser storage, so a Chinese selection is retained on later launches. -## Global Home +The current development UI is implemented in `node/ui-src/` with React, TypeScript, and Vite. Those tools are build-time only: the package and Admin server still ship exactly `ui/index.html`, `ui/app.js`, and `ui/styles.css`, with no frontend runtime server, remote font, CDN, telemetry, source map, or dynamic chunk. -The CLI manages its own files under: +## What You Can Manage -```text -~/.codex-remote-proxy/ -``` - -This directory is used for: +The local UI supports the complete daily workflow: -- runtime config -- managed state -- proxy logs -- optional local shim files +- create named provider profiles; +- enter a credential through a write-only field; +- test OpenAI Responses API compatibility; +- switch eligible providers directly from provider cards; +- replace a credential on an inactive provider or delete an inactive provider; +- start, stop, restart, and inspect the proxy worker; +- inspect anonymous 24-hour or 7-day request, result, observed-Token, model, Provider, and bounded-latency Metrics on Overview; +- review sanitized control-plane Activity and read-only System facts; +- generate an in-memory diagnostic summary containing only creation state, generation time, and sanitized event count. -## Secret Handling +`Forwarding Records` is visible as a disabled `Coming soon` navigation item. This MVP has no forwarding-record route, request/response viewer, Capture control, or mock traffic data. Overview Metrics is anonymous aggregate state and remains independent from optional Capture. -You do not have to pass `base_url` and `api_key` to `crp start` every time. +Provider activation affects new requests. Requests already in flight keep the provider snapshot with which they started. The explicit activation route is also the production switch operation: it applies a new snapshot to a running Worker and starts a stopped Worker. The first-provider Setup path is deliberately different: a successful compatibility test uses compare-and-set selection while the Worker remains stopped. -Recommended options: +## Stable Codex Configuration -### Option 1: Save in `~/.codex/config.toml` - -Add an optional section like: +CRP bootstraps Codex once and preserves these invariants: ```toml -[codex_remote_proxy] -upstream_base_url = "https://your-upstream.example.com" -upstream_api_key = "sk-your-key" -capture_enabled = true -capture_db_path = "/Users/you/.codex-remote-proxy/traffic.sqlite3" +model_provider = "OpenAI" ``` -Then later runs only need: - -```bash -crp start +```text +http://127.0.0.1:15100 ``` -### Option 2: Save once locally with `crp init` +Provider switching happens inside CRP. Do not create a different Codex `model_provider` for each upstream and do not change the fixed proxy address during routine switching. -```bash -crp init -crp start -``` +On a clean home, explicit `crp start` creates the missing `.codex` directory and `config.toml` privately and atomically, without creating a backup for a file that did not exist. On supported POSIX systems the new directory is `0700` and the new file is `0600`. Repeating the bootstrap is a byte-identical no-op. An existing config still receives an adjacent private backup only when its content must change, and its unrelated settings, line endings, and mode are preserved. -`crp init` stores the upstream configuration under: +Before changing an existing Codex-level provider binding, exit Codex completely. Bootstrap reads the root `model_provider` and its supported `base_url` binding from one locked config snapshot. Invalid UTF-8 or a malformed/ambiguous selected-provider binding fails before backup, journal, or config writes; this focused scanner is not a whole-document TOML validator. A different or missing effective URL triggers history discovery. Only a nonempty write set receives private rollout snapshots, exclusive SQLite logical backups, and a forward-recovery journal under `.codex/.crp-history-repair`; an already aligned set uses a config-only commit. CRP then publishes the fixed config and changes only provider metadata in active/archived rollout `session_meta` records and supported `threads.model_provider` columns. A pending repair or config lock makes Codex not ready and blocks activation, Worker start/restart, and automatic crash recovery; the next bootstrap resumes it. Encrypted history content is never rewritten, and the CLI emits a static warning because some encrypted messages may remain unavailable. -```text -~/.codex-remote-proxy/config.json -``` +Routine CRP provider add/test/activate/hot-switch operations never invoke this repair. Per the URL-only trigger, a provider-name change with the same effective URL does not rewrite history metadata; operators migrating such a custom layout must review it separately. Managed config/history backups can contain private local state and must be protected like the original Codex directory. -After that, later runs only need: +## Credential Safety -```bash -crp start -``` +The public Supervisor requires the native operating-system credential store through service `org.cluic.codex-remote-proxy`: -### Option 3: Use environment variables +- macOS Keychain; +- Windows Credential Manager; +- a compatible Secret Service on Linux. -```bash -export CRP_UPSTREAM_BASE_URL="https://your-upstream.example.com" -export CRP_UPSTREAM_API_KEY="sk-your-key" -export CRP_CAPTURE_ENABLED="true" -export CRP_CAPTURE_DB_PATH="/Users/you/.codex-remote-proxy/traffic.sqlite3" -crp start -``` +If native storage cannot be constructed or later fails, public startup and credential operations fail closed. The current UI, CLI, and Admin API have no file-storage consent or selection control. A lower-level private file adapter remains available only through trusted dependency injection; exposing a startup consent path is future L3 work and no native operation is replayed into that adapter. -`crp start` resolves values in this order: +The UI never reads a saved key back. Secret fields are blank on edit, and complete keys are excluded from API reads, activity, diagnostics, state files, and logs. -1. CLI flags -2. Environment variables -3. `~/.codex/config.toml` under `[codex_remote_proxy]` using `upstream_base_url`, `upstream_api_key`, `capture_enabled`, and `capture_db_path` -4. Saved config from `crp init` -5. Interactive prompts +## Local Browser Security -## Request Capture +The Admin server binds only to `127.0.0.1:15101`, rejects unexpected `Host` and `Origin` values, disables CORS, and requires CSRF protection for browser mutations. -Request capture is optional and disabled by default. +`crp ui` puts the private local control token in the URL fragment. The fragment is not part of HTTP requests; the UI exchanges it for an in-memory CSRF token and an HttpOnly, `SameSite=Strict` session cookie, then removes the fragment and clears its local token reference. Tokens, credentials, provider drafts, responses, and errors are not persisted in browser storage. -When enabled, the proxy stores one SQLite row per proxied HTTP transaction under: +Reloading a tab with a still-valid session but no launch fragment first opens a GET-only workspace. The user may explicitly restore management while that authenticated cookie session remains valid; CRP requires an exact same-origin request plus a non-simple recovery header, rotates the session ID and CSRF token, and does not extend the original expiry. Reopen with `crp ui` after expiry. A failed launch exchange or later business-session/CSRF failure is terminal for that tab. + +## CLI + +The UI is recommended whenever a credential must be entered. These supervisor commands are also available: ```text -~/.codex-remote-proxy/traffic.sqlite3 +crp ui [--no-open] [--json] +crp start [--json] +crp status [--json] +crp stop [--json] +crp restart [--json] +crp shutdown [--json] +crp provider list [--json] +crp provider add --name --base-url --api-key [--model ] [--json] +crp provider models (--id | --name ) [--json] +crp provider test (--id | --name ) --model [--json] +crp provider activate (--id | --name ) [--json] +crp provider delete (--id | --name ) [--json] ``` -or a custom path you provide with `capture_db_path`. +The two recommended entry points are `crp ui` for guided setup and daily management, and `crp start` for headless CLI startup. `ui` starts or discovers the Supervisor and opens the management page; `start` starts or discovers the Supervisor, bootstraps the fixed Codex configuration, and starts the proxy Worker. -What is stored: +Every human CLI path supports English and Simplified Chinese. English is the default regardless of `CRP_LOCALE`, `LC_ALL`, `LC_MESSAGES`, `LANG`, or the terminal language. One global `--locale en|zh-CN` may appear anywhere in the command line; use `--locale zh-CN` to request Chinese for that invocation. The choice is process-local and never persisted. Locale changes human output only. With `--json`, a failure writes nothing to stdout and exactly one language-independent error document to stderr. -- full request headers after proxy rewrites -- full request body -- full response headers -- full response body -- SSE responses aggregated into one stored body +## License -Sensitive headers such as `Authorization`, `Cookie`, `Set-Cookie`, and token-like header names are redacted before writing. +This project is licensed under the [MIT License](./LICENSE). -Enable capture at startup: +Without `--json`, `provider list` renders a count plus each provider's active marker, name, ID, base URL without query/hash, test state, model mode/override, and credential-configured state. `status` renders Supervisor PID/start time, Worker phase/PID/generation/listening/in-flight state, active provider, Codex state, fixed `OpenAI` identity, and the `15100` proxy URL instead of a generic sentence. Dynamic terminal text is length-bounded and escapes control, escape, and bidirectional-control characters; credential references, extra headers, and complete keys are never rendered. -```bash -crp start --capture -crp start --capture --capture-db-path /Users/you/.codex-remote-proxy/custom-traffic.sqlite3 -``` +Root help presents aligned command descriptions plus consistent usage, options, and examples. Exact `-h`/`--help` is available for every supported first-level command, the `provider` group, and each provider action; help is resolved locally without starting or discovering the Supervisor. Help flags are parsed only at their exact argv positions, so trailing or misplaced input remains a validation error instead of being silently ignored. -Hot-toggle capture on a running managed proxy: +`crp stop` stops only the proxy Worker on `127.0.0.1:15100`; the Supervisor and management API on `127.0.0.1:15101` remain available. Use `crp shutdown` to stop the Worker and exit the Supervisor completely. A running Supervisor after `stop` is therefore expected, and detailed `status` output distinguishes the two processes. -```bash -crp capture on -crp capture off -crp capture status --json -``` +Human success copy preserves those distinctions: `shutdown` confirms both Supervisor and Worker shutdown. `crp start` reports failures at one stable stage: `supervisor_start`, `codex_bootstrap`, or `proxy_start`; `restart` also performs mandatory bootstrap first when Codex is not ready. Explicit activation/start/restart and unexpected-exit recovery share the same FIFO Codex readiness gate. A failed or pending bootstrap prevents lifecycle mutation. A successful config publication is not rolled back after later uncertainty: journaled history work remains pending, while config-only uncertainty is reported separately without claiming a pending repair. -You can also edit `~/.codex-remote-proxy/node/proxy-config.json` directly. Changes to `capture.enabled` hot-apply after the proxy validates the SQLite connection. Changes to `capture.dbPath` are detected, but require a restart before the new path is used. +Detached Supervisor startup uses a one-shot, strictly allowlisted IPC error. An approved migration-input failure is returned before the readiness timeout; malformed, unknown, or unapproved child messages become the generic `SUPERVISOR_START_FAILED` contract. -## Global CLI +The former compatibility aliases `crp init`, `crp install`, and `crp setup` have been removed. They fail locally with `CLI_COMMAND_REMOVED`, perform no Supervisor discovery or mutation, and point to `crp ui` or `crp start` as the replacement. `check`, `capture on|off|status`, `guide`, and the deprecated local-shim command `install-cli` remain available; the CLI still has no provider-update, Activity, Settings, or diagnostics operation. -Main commands: +`crp provider add` requires a write-only `--api-key` argument and supports advanced authentication and routing options. Optional `--model` is test input only; routing override remains `--model-mode override --model-override `. When `--model` is present, CRP saves the provider first and then runs the Responses compatibility test. The create and test steps are deliberately not one transaction: a failed compatibility result or an operational test error does not delete the saved provider, so the user can inspect and retry it. Command-line secrets may be visible in shell history or process inspection, so this path is intended only for controlled automation. -- `crp check` - Inspect Codex config, auth mode, runtime availability, and managed service state +`provider test`, `activate`, `delete`, and `models` require exactly one selector: `--id` or `--name`. Names resolve by exact case-insensitive match against the unique public provider list. `provider models` performs an authenticated, no-redirect refresh from `/models`; the Admin API also exposes a cached read separately. Discovery is bounded and rejects any model ID containing the complete credential before it can reach cache or output. It is independent from Responses compatibility testing, so a missing or incompatible model endpoint does not change provider test or activation state and a failed refresh does not erase the last good catalog. -- `crp start` - Accept upstream settings from CLI flags, environment variables, `~/.codex/config.toml` `[codex_remote_proxy]`, or prompts; choose a free port, patch Codex, and start the proxy in the background by default +CLI-triggered compatibility tests, including `provider add --model`, request initial selection only when no provider is active. The first successful candidate wins an atomic compare-and-set while the Worker is stopped. Selection writes `activeProviderId` but never starts or reconfigures the Worker; run `crp start` explicitly. Admin callers that omit `activateIfNone` retain non-selecting test behavior. The conditional Web Setup explicitly opts in and runs `save provider -> test and compare-and-set select -> prepare Codex/history repair -> start Worker`; it does not call explicit activation during first setup. Ordinary Provider-page tests remain non-selecting until the user chooses a switch action. -- `crp init` - Save upstream settings and optional capture defaults once under `~/.codex-remote-proxy/` so later `crp start` calls do not require secrets again if you do not want to place them in `~/.codex/config.toml` +## Upgrading From 0.2.2 -- `crp install` - Compatibility alias for `crp start` +The next minor release migrates the pre-supervisor flat configuration to provider-registry schema 2 on first supervisor startup. -- `crp capture on|off|status` - Toggle SQLite request capture on a running managed proxy, or persist the preference for the next start if the proxy is not running +1. Stop the old managed proxy. +2. Make a private backup of `~/.codex-remote-proxy/` and `~/.codex/config.toml`. Treat every backup as secret-bearing. +3. Run `crp ui`. +4. Review the migrated provider named `Default`, run its compatibility test, and activate it only after the test passes. -- `crp status` - Show managed service status and health. If the proxy is running but not managed by this CLI, it will try to detect that too +Migration reads the legacy `config.json` and runtime `node/proxy-config.json` when present. It creates collision-safe, byte-exact private backups, stores the credential through the required native backend, creates an inactive and untested schema-2 provider, validates the committed registry, and only then scrubs secret fields from the legacy files. Backups are retained. -- `crp stop` - Stop the managed service +If the legacy sources contain different credentials, migration returns `MIGRATION_INPUT_INVALID` before creating backups, accessing credential storage, writing the registry, or changing either source. CRP never chooses one credential automatically; resolve the conflict only through an operator-reviewed real-home migration. -- `crp guide` - Print AI-oriented usage guidance +If a transaction fails before commit, CRP attempts to restore the original bytes and remove only registry and credential state that the transaction can prove it owns. It never deletes a foreign replacement. A `MIGRATION_COMMITTED_DEGRADED`, `MIGRATION_COMMITTED_LOCK_DEGRADED`, or `MIGRATION_ROLLBACK_DEGRADED` result means the final state is uncertain or needs repair: stop CRP, do not repeatedly retry, preserve the backups, and review the sanitized Activity error code before changing files. Automatic restoration from a backup is intentionally not attempted in a degraded state. -Machine-readable examples: +Rollback to `0.2.2` is not a schema downgrade. Stop CRP first and restore the complete private pre-upgrade backup as one unit; do not copy a secret back into only one legacy file or mix schema-2 registry state with flat configuration. Real-home migration and rollback remain L3 operations and require platform-specific review. -```bash -crp check --json -crp capture status --json -crp guide --json -crp status --json -``` +## Development + +There are two intentionally different ways to exercise the development CLI: -## For AI Assistants +```bash +# Production-path smoke: reads and may update the real ~/.codex and +# ~/.codex-remote-proxy when a mutating command is used. +cd node +npm run dev:cli -- check --json -Recommended flow: +# Ordinary deterministic tests remain isolated and must not touch the real HOME. +npm test +``` -1. Run `crp check --json` -2. Read `recommendedImplementation` -3. If Node dependencies are ready, prefer `node` -4. Prefer existing `~/.codex/config.toml` `[codex_remote_proxy]` with `upstream_base_url`, `upstream_api_key`, `capture_enabled`, and `capture_db_path`, otherwise ask the user to run `crp init` once locally, or rely on environment variables already set outside the AI session -5. Run `crp start` -6. Read `proxyUrl`, `pid`, and `health` from the JSON result -7. Use `crp status --json` for later verification +Calling `runCli(..., { paths: getPaths(tempHome) })`, including through a local +`crpdev` shell wrapper, deliberately operates the Supervisor, Provider registry, +and Codex bootstrap against that temporary home. It is suitable for safe UI and +CLI feature testing, but it is not evidence that the real `~/.codex` was +modified. Use the direct `npm run dev:cli -- ` entry only when a +real-home operation has been explicitly authorized. Stop Codex before any +existing-config transition or history repair. -Notes: +```bash +cd node +npm ci +npm run lint +npm run typecheck:ui +npm run build:ui +npm run verify:ui-build +npm test +node scripts/run-test-group.mjs core-chain +npm run test:e2e -- --project=chromium --workers=1 +npm audit --omit=dev +npm pack --dry-run --json --ignore-scripts +``` -- `start` modifies `~/.codex/config.toml` and creates a backup -- the managed proxy runs in the background by default -- managed state and logs live under `~/.codex-remote-proxy/` -- request capture writes to SQLite only when enabled -- when running directly from this repository, install Node dependencies first -- `~/.codex/config.toml`, `crp init`, or environment variables can keep secrets out of later AI interactions +Tests use temporary homes, synthetic credentials, injected adapters, and loopback mock upstreams. Do not run supervisor startup or migration tests against a real home directory. -## Implementations +The serial `core-chain` gate exercises the real CLI, Admin server, registry/provider service, WorkerManager, forked proxy worker, fixed ports, provider switching with an in-flight request, restart, shutdown, and secret scans. It deliberately substitutes an in-memory credential adapter and loopback upstreams, so it does not prove native credential access or a real external provider. -- [`node/`](./node) - The packaged npm implementation. +Final M2E/V8 local verification passes exact `npm test` 463/463 (`412` unit-core + `8` isolated capture + `42` ordinary integration + `1` serial core-chain), Metrics storage focus 6/6, lint across 33 source files, UI typecheck/build/exact three-file verification, package-content 3/3 against the exact 33-file allowlist, Chromium 33/33 including the English/Chinese 1440/1024/390 responsive matrix, full and runtime audits with zero vulnerabilities, and same-state visual comparison recorded in `design-qa.md`. No deterministic gate is allowed to claim real Codex history, credentials, or an external provider; the earlier local macOS D2 native-Keychain/real-upstream result remains historical evidence for its reviewed tree. -- [`node/RELEASING.md`](./node/RELEASING.md) - Release setup and automated npm publishing flow. +Supervisor discovery uses a bounded 2-second liveness probe while normal Admin operations use a separate 30-second timeout, so a successful provider test is not misreported as `SUPERVISOR_UNAVAILABLE`. Proxy targets are joined structurally, so base URLs with or without a trailing slash produce one path separator. -- [`README.zh-CN.md`](./README.zh-CN.md) - Chinese documentation +Release preparation and remaining external gates are documented in [node/RELEASING.md](./node/RELEASING.md). diff --git a/README.zh-CN.md b/README.zh-CN.md index 74c0ce6..cefcc02 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -2,234 +2,197 @@ # Codex Remote Proxy 中文文档 -Codex Remote Proxy 的作用很直接: +Codex Remote Proxy(CRP)让 Codex 保持 ChatGPT 登录态,同时把模型请求转发到当前选中的 OpenAI 兼容提供商。Codex 始终使用内置的 `OpenAI` 提供商身份,因此切换上游不会改变已有 OpenAI 线程的归属。 -- 保留 Codex 的 ChatGPT 登录态,用于手机端远程连接和远程控制 -- 把真实模型请求转发到你自己的 OpenAI 兼容 `base_url` -- 把请求头里的 `Authorization` 改写成真实 API key +[English](./README.md) -已发布到 npm: +> 发布状态:npm 当前发布的仍是 pre-supervisor `0.2.2`,其中不包含 `crp ui`。下文说明待发布的下一个 minor 版本;必须先通过外部平台门禁与 L3 确认才能发布。 -```bash -npm install -g @cluic/codex-remote-proxy -``` - -## 它解决了什么问题 - -Codex 的两个本地文件分别控制不同事情: - -- `~/.codex/config.toml` 决定请求发往哪里 -- `~/.codex/auth.json` 决定 `Authorization` 里放什么 +## 发布后安装 -当 Codex 处于 ChatGPT 登录模式时,发出去的通常不是普通 API key,而是 `tokens.access_token`。很多第三方 OpenAI 兼容服务并不接受这个值,所以即使 `base_url` 改对了,也可能无法正常对话。 - -这个项目通过本地代理解决这个错位。 - -## 推荐安装方式 - -当前最推荐的路径是 Node 版本,也是目前已验证可正常转发和对话的主路径。 - -### 全局安装 +需要 Node.js 22.13 或更高版本。 ```bash npm install -g @cluic/codex-remote-proxy ``` -然后执行: +普通用户的主要入口是: ```bash -crp init -crp start +crp ui ``` -### 不做全局安装 +不做全局安装也可以运行: ```bash -npx @cluic/codex-remote-proxy init -npx @cluic/codex-remote-proxy start +npx @cluic/codex-remote-proxy ui ``` -### 直接从当前仓库运行 +`crp ui` 会启动或发现本地 Supervisor,并打开管理界面。界面首次启动始终使用 English,并可通过语言选择器切换为简体中文;浏览器只会保存用户明确选择的语言,因此选择中文后后续启动会保持中文。 -```bash -cd node -npm install -node bin/crp.mjs start -``` +当前开发版界面使用 `node/ui-src/` 中的 React、TypeScript 与 Vite 实现。这些工具只参与构建;发布包和 Admin Server 仍然只交付 `ui/index.html`、`ui/app.js` 与 `ui/styles.css`,不需要前端运行时服务器,也不包含远程字体、CDN、遥测、source map 或动态 chunk。 + +## 可以管理什么 -完成后: +本地管理界面覆盖完整的日常流程: -1. 重启 Codex Desktop -2. 使用 ChatGPT 账号登录 -3. 正常继续使用 Codex +- 创建具名提供商; +- 通过只写输入框填写凭据; +- 测试 OpenAI Responses API 兼容性; +- 直接在提供商卡片上切换符合条件的提供商; +- 替换非当前提供商的凭据或删除非当前提供商; +- 启动、停止、重启和查看代理 Worker; +- 在总览中查看匿名的 24 小时或 7 天请求、结果、已观测 Token、模型、Provider 与有界延迟 Metrics; +- 查看已脱敏的控制面 Activity 和只读系统事实; +- 生成只包含创建状态、生成时间和已脱敏事件数量的内存诊断摘要。 -## 全局目录 +侧边栏会显示不可操作的 `转发记录 / 即将上线` 占位项。本 MVP 不提供转发记录路由、请求/响应查看器、Capture 控件或模拟流量数据;总览 Metrics 是独立于可选 Capture 的匿名聚合状态。 -CLI 统一管理目录: +提供商切换只影响新请求。已经在处理中的请求继续使用其开始时捕获的提供商快照。显式 activation 路由同时也是生产切换操作:Worker 运行时应用新快照,Worker 停止时会启动它。首次 Setup 路径有意不同:兼容性测试成功后只通过 compare-and-set 选中首个 Provider,Worker 保持停止。 + +## 固定的 Codex 配置 + +CRP 只需引导配置一次,并持续保持以下不变量: + +```toml +model_provider = "OpenAI" +``` ```text -~/.codex-remote-proxy/ +http://127.0.0.1:15100 ``` -这里会保存: +提供商切换发生在 CRP 内部。日常切换时不要为每个上游创建不同的 Codex `model_provider`,也不要修改固定代理地址。 -- 运行配置 -- 托管状态 -- 代理日志 -- 可选的本地 shim 文件 +在全新 HOME 中,显式运行 `crp start` 会私有且原子地创建缺失的 `.codex` 目录和 `config.toml`,不会为原本不存在的文件创建备份。在支持的 POSIX 系统上,新目录权限为 `0700`,新文件权限为 `0600`。再次执行引导会保持文件字节完全不变。已有配置只有在内容确需改变时才会生成相邻私有备份,其无关设置、换行符和权限都会保留。 -## 密钥处理方式 +修改已有的 Codex 层 provider 绑定前,必须先完全退出 Codex。Bootstrap 会从同一份加锁配置快照读取根 `model_provider` 及其受支持的 `base_url` 绑定。无效 UTF-8 或 selected-provider 绑定畸形/歧义会在备份、journal 和配置写入前失败;该 scanner 只校验相关绑定,不是完整 TOML validator。有效 URL 不同或缺失时才发现历史写集;只有非空写集会创建私有 rollout 快照、排他 SQLite 逻辑备份和 `.codex/.crp-history-repair` 前向恢复 journal,已对齐的历史走无 journal 的 config-only 提交。随后 CRP 发布固定配置,并只修改 active/archive rollout 中 `session_meta` 的 provider 元数据及受支持 SQLite 中的 `threads.model_provider`。修复 pending 或存在 config lock 时 Codex 都不是 ready,provider activation、Worker start/restart 和崩溃自动恢复都会被阻止;下一次 bootstrap 会继续修复。加密历史内容绝不会被改写;CLI 会输出静态警告,因为部分加密消息仍可能不可用。 -你不必每次都把 `base_url` 和 `api_key` 再传给 `crp start`。 +CRP 内部的 provider add/test/activate/热切换绝不会触发该修复。按照仅比较 URL 的触发要求,如果 provider 名改变但有效 URL 相同,则不会重写历史元数据;迁移这类自定义布局需要单独人工审查。受管配置/历史备份可能包含本地私有状态,必须按原 Codex 目录同等保护。 -推荐三种方式: +## 凭据安全 -### 方式 1:写进 `~/.codex/config.toml` +公开 Supervisor 必须通过服务名 `org.cluic.codex-remote-proxy` 使用操作系统原生凭据存储: -可以额外加一段: +- macOS Keychain; +- Windows Credential Manager; +- Linux 上兼容的 Secret Service。 -```toml -[codex_remote_proxy] -upstream_base_url = "https://your-upstream.example.com" -upstream_api_key = "sk-your-key" -capture_enabled = true -capture_db_path = "/Users/you/.codex-remote-proxy/traffic.sqlite3" -``` +如果原生后端无法构造或后续失败,公开启动与凭据操作会直接失败。当前 UI、CLI 和 Admin API 都没有文件存储授权或选择控件。底层私有文件适配器只允许受信任的依赖注入使用;公开 startup consent 属于未来 L3 工作,原生操作绝不会重放到该适配器。 -之后直接执行: +界面不会读回已保存的密钥。编辑时密钥输入框始终为空,完整密钥不会出现在 API 读取结果、活动记录、诊断、状态文件或日志中。 -```bash -crp start -``` +## 本地浏览器安全 -### 方式 2:本地保存一次 +Admin 服务只绑定 `127.0.0.1:15101`,会拒绝不符合预期的 `Host` 和 `Origin`,禁用 CORS,并要求浏览器修改操作携带 CSRF 保护。 -```bash -crp init -crp start -``` +`crp ui` 会把私有的本地控制令牌放在 URL fragment 中。fragment 不会随 HTTP 请求发送;界面用它换取仅驻留内存的 CSRF 令牌和 HttpOnly、`SameSite=Strict` 会话 Cookie,随后移除 fragment 并清除本地令牌引用。令牌、凭据、提供商草稿、响应和错误都不会写入浏览器存储。 -`crp init` 会把配置保存到: +如果会话仍有效但刷新后的页面没有启动 fragment,界面会先进入仅 GET 工作区。用户可以在该认证 Cookie 会话仍有效时显式恢复管理权限;CRP 会强制精确同源请求和非简单恢复请求头,旋转会话 ID 与 CSRF,且不会延长原始到期时间。会话过期后仍需重新运行 `crp ui`。启动交换失败,或后续业务会话/CSRF 鉴权失败,都会使当前标签页进入终止状态。 -```text -~/.codex-remote-proxy/config.json -``` +## CLI -之后只需要: +凡是需要输入凭据,优先使用管理界面。Supervisor 同时提供以下命令: -```bash -crp start +```text +crp ui [--no-open] [--json] +crp start [--json] +crp status [--json] +crp stop [--json] +crp restart [--json] +crp shutdown [--json] +crp provider list [--json] +crp provider add --name --base-url --api-key [--model ] [--json] +crp provider models (--id | --name ) [--json] +crp provider test (--id | --name ) --model [--json] +crp provider activate (--id | --name ) [--json] +crp provider delete (--id | --name ) [--json] ``` -### 方式 3:使用环境变量 +正式推荐的入口只有两个:普通设置和日常管理使用 `crp ui`,无界面 CLI 启动使用 `crp start`。`ui` 会启动或发现 Supervisor 并打开管理页面;`start` 会启动或发现 Supervisor、引导固定的 Codex 配置,并启动代理 Worker。 -```bash -export CRP_UPSTREAM_BASE_URL="https://your-upstream.example.com" -export CRP_UPSTREAM_API_KEY="sk-your-key" -export CRP_CAPTURE_ENABLED="true" -export CRP_CAPTURE_DB_PATH="/Users/you/.codex-remote-proxy/traffic.sqlite3" -crp start -``` +所有 CLI 人类可读路径都支持 English 和简体中文。无论 `CRP_LOCALE`、`LC_ALL`、`LC_MESSAGES`、`LANG` 或终端语言是什么,默认始终输出 English。一个全局 `--locale en|zh-CN` 可以出现在命令行任意位置;只有显式提供 `--locale zh-CN` 时才输出中文。选择只对当前进程生效且不会持久化。语言只影响人类可读输出。使用 `--json` 时,失败不会写入 stdout,并且只向 stderr 写入一个语言无关的错误文档。 -`crp start` 的取值优先级是: +## 许可证 -1. CLI 参数 -2. 环境变量 -3. `~/.codex/config.toml` 里的 `[codex_remote_proxy]`,键名使用 `upstream_base_url`、`upstream_api_key`、`capture_enabled` 和 `capture_db_path` -4. `crp init` 保存的本地配置 -5. 交互式输入 +本项目采用 [MIT License](./LICENSE)。 -## 请求记录 +不使用 `--json` 时,`provider list` 会展示提供商数量,以及每个提供商的当前标记、名称、ID、移除 query/hash 后的基础地址、测试状态、模型模式/覆盖值和凭据配置状态。`status` 会展示 Supervisor PID/启动时间、Worker phase/PID/generation/listening/in-flight 状态、当前提供商、Codex 状态、固定的 `OpenAI` 身份和 `15100` 代理地址,而不是只输出笼统提示。动态终端文本会限制长度并转义控制字符、escape 和双向文本控制符;凭据引用、额外请求头和完整密钥绝不展示。 -SQLite 请求记录是可选功能,默认关闭。 +根帮助使用对齐的命令说明,并统一展示 usage、options 和 examples。每个受支持的一级命令、`provider` 命令组及每个 provider action 都支持精确位置的 `-h`/`--help`;帮助在本地解析,不会启动或发现 Supervisor。帮助标志只在准确的 argv 位置生效,尾随或错位输入仍返回校验错误,不会被静默忽略。 -开启后,代理会把每次完整请求/响应保存成一条 SQLite 记录,默认数据库路径是: +`crp stop` 只停止监听 `127.0.0.1:15100` 的代理 Worker;Supervisor 和 `127.0.0.1:15101` 管理 API 会继续运行。需要停止 Worker 并完全退出 Supervisor 时使用 `crp shutdown`。因此 `stop` 后 Supervisor 仍在运行是预期行为,详细的 `status` 输出会区分这两个进程。 -```text -~/.codex-remote-proxy/traffic.sqlite3 -``` +人类可读成功文案也保持这些区别:`shutdown` 明确确认 Supervisor 和 Worker 都已停止。`crp start` 会用稳定阶段标识失败:`supervisor_start`、`codex_bootstrap` 或 `proxy_start`;Codex 未 ready 时,`restart` 也会先执行必需的 bootstrap。显式 activation/start/restart 和 Worker 崩溃自动恢复共用同一个 FIFO Codex readiness gate。bootstrap 失败或仍 pending 时不会继续生命周期修改。配置一旦成功发布,后续不确定性不会回滚:有历史写集时保留 pending journal,无历史写集时单独报告 config-only committed-degraded,且不谎称存在 pending repair。 -启动时开启: +Detached Supervisor 启动只使用一次性、严格白名单化的 IPC 错误。获准的迁移输入错误会在就绪超时前返回;畸形、未知或未获准的子进程消息统一转为通用 `SUPERVISOR_START_FAILED` 契约。 -```bash -crp start --capture -crp start --capture --capture-db-path /Users/you/.codex-remote-proxy/custom-traffic.sqlite3 -``` +原兼容别名 `crp init`、`crp install` 和 `crp setup` 已删除。它们会在本地以 `CLI_COMMAND_REMOVED` 失败,不发现 Supervisor、不执行任何修改,并提示改用 `crp ui` 或 `crp start`。`check`、`capture on|off|status`、`guide` 和已弃用的本地入口命令 `install-cli` 仍可用;CLI 依然没有 provider update、Activity、Settings 或 diagnostics 操作。 -对正在运行的代理做热切换: +`crp provider add` 要求使用只写的 `--api-key` 参数,并支持高级鉴权和路由选项。可选 `--model` 只作为测试输入;路由覆盖仍使用 `--model-mode override --model-override `。提供 `--model` 时,CRP 会先保存 provider,再执行 Responses 兼容性测试。这两个阶段有意不组成单一事务:兼容性结果失败或测试操作发生错误,都不会删除已保存的 provider,用户可以查看后重试。命令行密钥可能出现在 shell 历史或进程检查中,因此该路径仅适合受控自动化。 -```bash -crp capture on -crp capture off -crp capture status --json -``` +`provider test`、`activate`、`delete` 和 `models` 必须且只能提供一个选择器:`--id` 或 `--name`。名称通过公开 provider 列表做精确的大小写不敏感匹配。`provider models` 会向 `/models` 发起带鉴权、禁止重定向的刷新;Admin API 另提供独立的缓存读取。模型发现有界,并会在进入缓存或输出前拒绝任何包含完整 credential 的模型 ID。它独立于 Responses 兼容性测试,因此模型端点缺失或不兼容不会修改 provider 的测试或激活状态,刷新失败也不会清除最后一次成功目录。 -你也可以直接编辑 `~/.codex-remote-proxy/node/proxy-config.json`。其中 `capture.enabled` 会在代理校验 SQLite 成功后热生效;`capture.dbPath` 的变化会被探测到,但需要重启后才会真正切到新路径。 +CLI 发起的兼容性测试(包括 `provider add --model`)只会在当前没有 provider 时请求首次选中。第一个成功候选在 Worker 已停止时通过原子 compare-and-set 胜出。选中只写入 `activeProviderId`,绝不会启动或重新配置 Worker;仍需显式运行 `crp start`。未提供 `activateIfNone` 的 Admin 调用继续保持不自动选中。条件式 Web Setup 会明确选择该行为,并按 `保存 Provider -> 测试并 CAS 选中 -> 配置 Codex/修复历史 -> 启动 Worker` 执行;首次设置不调用显式 activation。Provider 日常页面的普通测试仍不自动选中,只有用户选择切换操作时才切换。 -写入前会默认脱敏敏感请求头,例如 `Authorization`、`Cookie`、`Set-Cookie` 以及名称中包含 `token`、`secret`、`api-key` 的头。 +## 从 0.2.2 升级 -## 全局 CLI +下一个 minor 版本会在 Supervisor 首次启动时,把 pre-supervisor 扁平配置迁移到 provider registry schema 2。 -主要命令: +1. 停止旧的托管代理。 +2. 私下备份 `~/.codex-remote-proxy/` 和 `~/.codex/config.toml`;所有备份都应视为包含敏感信息。 +3. 运行 `crp ui`。 +4. 检查迁移得到的 `Default` 提供商,运行兼容性测试,并且只在测试通过后激活。 -- `crp check` - 查看 Codex 配置、鉴权模式、运行时状态和托管服务状态 +如果存在旧的 `config.json` 和运行时 `node/proxy-config.json`,迁移会读取它们。CRP 先创建防碰撞、字节完全一致的私有备份,再通过必需的原生凭据后端保存凭据,创建未激活且未测试的 schema-2 提供商,验证已经提交的 registry,最后才从旧文件中清除密钥字段。备份会保留。 -- `crp start` - 从 CLI 参数、环境变量、`~/.codex/config.toml` 的 `[codex_remote_proxy]` 或交互输入中获取上游配置,自动选择空闲端口,修改 Codex 配置,并默认后台启动代理 +如果多个旧配置源包含不同凭据,迁移会在创建备份、访问凭据存储、写入 registry 或修改任一源文件之前返回 `MIGRATION_INPUT_INVALID`。CRP 不会自动选择其中一个凭据;该冲突只能在经过操作员审查的真实 HOME 迁移中解决。 -- `crp init` - 先把上游配置和可选的请求记录默认值安全保存到 `~/.codex-remote-proxy/`,如果你不想把密钥写进 `~/.codex/config.toml`,以后 `crp start` 也不需要再重复输入 +如果事务在提交前失败,CRP 会尝试恢复原始字节,并且只删除能够证明属于本次事务的 registry 与凭据状态;外部替换的文件不会被删除。出现 `MIGRATION_COMMITTED_DEGRADED`、`MIGRATION_COMMITTED_LOCK_DEGRADED` 或 `MIGRATION_ROLLBACK_DEGRADED`,表示最终状态不确定或需要修复:停止 CRP,不要连续重试,保留备份,并在修改文件前查看 Activity 中已脱敏的错误码。处于降级状态时,CRP 不会擅自用备份自动覆盖当前状态。 -- `crp install` - 与 `crp start` 等价的兼容别名 +回退到 `0.2.2` 不是 schema 降级。必须先停止 CRP,再把完整的升级前私有备份作为一个整体恢复;不要只把密钥复制回某一个旧文件,也不要混用 schema-2 registry 与扁平配置。真实 HOME 上的迁移和回退仍属于 L3 操作,需要对应平台的人工审查。 -- `crp capture on|off|status` - 对托管中的代理热切换 SQLite 请求记录;如果代理当前没运行,则保存为下次启动时生效的偏好 +## 开发验证 -- `crp status` - 查看当前托管服务状态和健康检查结果。如果代理在运行但不是 CLI 托管的,也会尝试探测 +开发版 CLI 有两种用途不同的运行方式: -- `crp stop` - 停止托管服务 +```bash +# 生产路径冒烟验证:读取真实 ~/.codex;执行写操作时也会修改真实 +# ~/.codex 和 ~/.codex-remote-proxy。 +cd node +npm run dev:cli -- check --json -- `crp guide` - 输出给 AI 读取的调用说明 +# 普通确定性测试继续使用隔离目录,不得触碰真实 HOME。 +npm test +``` -常见 JSON 调用方式: +如果通过 `runCli(..., { paths: getPaths(tempHome) })` 调用 CLI,包括之前 +定义的本地 `crpdev` shell 包装器,那么 Supervisor、Provider registry 和 +Codex bootstrap 都会有意作用于该临时 HOME。它适合安全测试 UI 与 CLI +功能,但不能证明真实 `~/.codex` 已被修改。只有在真实 HOME 操作得到明确 +授权时,才使用 `npm run dev:cli -- ` 这个直接入口;现有配置迁移或 +历史修复前必须完全退出 Codex。 ```bash -crp check --json -crp capture status --json -crp guide --json -crp status --json +cd node +npm ci +npm run lint +npm run typecheck:ui +npm run build:ui +npm run verify:ui-build +npm test +node scripts/run-test-group.mjs core-chain +npm run test:e2e -- --project=chromium --workers=1 +npm audit --omit=dev +npm pack --dry-run --json --ignore-scripts ``` -## 给 AI 的建议 - -建议流程: - -1. 先跑 `crp check --json` -2. 读取 `recommendedImplementation` -3. 如果 Node 依赖就绪,优先走 `node` -4. 优先使用现有 `~/.codex/config.toml` 里的 `[codex_remote_proxy]`,并使用 `upstream_base_url` / `upstream_api_key` / `capture_enabled` / `capture_db_path` 这些键,否则让用户先在本地跑一次 `crp init`,或者提前在系统里设置好环境变量 -5. 再跑 `crp start` -6. 从返回结果中读取 `proxyUrl`、`pid`、`health` -7. 之后用 `crp status --json` 做确认 - -注意: +测试只使用临时 HOME、合成凭据、注入适配器和 loopback 模拟上游。不要让 Supervisor 启动或迁移测试操作真实 HOME。 -- `start` 会修改 `~/.codex/config.toml` -- `install` 会先创建备份 -- 托管状态和日志保存在 `~/.codex-remote-proxy/` -- 只有在显式开启时才会写 SQLite 请求记录 -- 如果你是直接从当前仓库运行,需要先执行 `cd node && npm install` -- `~/.codex/config.toml`、`crp init` 或环境变量模式都可以避免后续 AI 直接接触密钥 +串行 `core-chain` 门禁会覆盖真实 CLI、Admin 服务、registry/provider service、WorkerManager、fork 出的代理 Worker、固定端口、存在进行中请求时的提供商切换、重启、关闭和密钥扫描。该门禁会有意替换为内存凭据适配器和 loopback 上游,因此不能证明原生凭据读取或真实外部提供商链路。 -## 实现目录 +M2E/V8 最终本地验证通过 exact `npm test` 463/463(`412` unit-core + `8` 隔离 capture + `42` 普通 integration + `1` 串行 core-chain)、Metrics 存储聚焦 6/6、33 个源文件 lint、UI 类型检查/构建/精确三文件同步验证、精确 33 文件白名单 package-content 3/3、Chromium 33/33(包含英中双语 1440/1024/390 响应式矩阵)、完整与生产依赖审计 0 漏洞,以及 `design-qa.md` 中的同状态视觉对比。测试不得触碰真实 Codex 历史、凭据或外部 provider;本机 macOS D2 原生 Keychain/真实上游结果仅是其已审查代码树的历史证据。 -- [./node](./node) - npm 包实现 +Supervisor 发现使用有界的 2 秒探活,普通 Admin 操作另用 30 秒超时,因此已经成功的 provider test 不会再被误报为 `SUPERVISOR_UNAVAILABLE`。代理目标通过结构化方式拼接,无论 base URL 是否带尾斜杠都只产生一个路径分隔符。 -- [./node/RELEASING.md](./node/RELEASING.md) - 自动发布 npm 的配置与发布流程说明 +发布准备及仍待完成的外部门禁见 [node/RELEASING.md](./node/RELEASING.md)。 diff --git a/design-qa.md b/design-qa.md new file mode 100644 index 0000000..9f04a16 --- /dev/null +++ b/design-qa.md @@ -0,0 +1,36 @@ +# CRP Web V8 Design QA + +Date: 2026-07-16 + +## Comparison Contract + +- Reference: `output/web-v8/reference/v0-overview-default.png` +- Implementation: `output/web-v8/implementation/overview-1272x716.png` +- Combined comparison: `output/web-v8/comparison/overview-1272x716.png` +- Capture surface: the same local in-app browser and matched 1272 x 716 PNG viewport. +- State: Simplified Chinese, Supervisor connected, Worker running, active tested Provider, Codex configured, writable management session. +- Comparison layout: v0 reference on the left; V8 implementation on the right. + +## Visual Review + +The implementation retains the v0 target's quiet local-operations character: fixed sidebar, compact top bar, restrained white/gray surfaces, dark green runtime band, small radii, dense status facts, Lucide controls, and bilingual navigation. The approved V8 change intentionally replaces the lower first-screen proxy-control cards with anonymous Metrics so request volume, success rate, observed Tokens, model distribution, and Provider performance become immediately scannable. + +Issues found and fixed during comparison: + +- Removed the page-sized focus outline created by programmatic route focus while retaining focus rings on interactive controls. +- Wrapped visually hidden chart tables in a clipped non-table container so accessibility data cannot create mobile horizontal overflow. +- Kept all model requests visible through Top 7 plus Other aggregation. +- Hid empty Activity pagination rather than rendering an invalid `1-0` range. +- Rendered latency overflow as `> 300 s` instead of presenting it as missing data. + +## Additional Evidence + +- Provider cards: `output/web-v8/implementation/providers-1280x720.png` +- Mobile Overview: `output/web-v8/implementation/overview-390x844.png` +- Chromium acceptance: 33/33 passed. +- Responsive matrix: English and Simplified Chinese passed at 1440, 1024, and 390 widths. +- Stable mobile state: no document overflow, no visible off-screen controls, and the closed drawer is hidden from interaction and accessibility order. + +## Result + +PASS. The final comparison has no unresolved P0, P1, or P2 visual defect. diff --git a/node/.changeset/core-cli-readiness.md b/node/.changeset/core-cli-readiness.md new file mode 100644 index 0000000..e6dcf2c --- /dev/null +++ b/node/.changeset/core-cli-readiness.md @@ -0,0 +1,5 @@ +--- +"@cluic/codex-remote-proxy": minor +--- + +Safely bootstrap a clean Codex home, add complete English and Simplified Chinese CLI output with stable machine errors and start stages, cover the production core path with a serial integration gate, separate short Supervisor discovery probes from normal operation timeouts, join proxy target URLs structurally so trailing-slash base URLs forward correctly, keep CLI human-output tests independent of the host locale, and reconcile capture database changes from a synchronous content fingerprint. diff --git a/node/.changeset/multi-provider-local-ui.md b/node/.changeset/multi-provider-local-ui.md new file mode 100644 index 0000000..1e959b0 --- /dev/null +++ b/node/.changeset/multi-provider-local-ui.md @@ -0,0 +1,5 @@ +--- +"@cluic/codex-remote-proxy": minor +--- + +Add the supervisor, named-provider lifecycle, secure English/Simplified Chinese local UI, strict `init`-to-`ui` compatibility boundary, required native credential storage, and migration from pre-supervisor flat configuration. diff --git a/node/LICENSE b/node/LICENSE new file mode 100644 index 0000000..c4a4b3b --- /dev/null +++ b/node/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Cluic + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/node/README.md b/node/README.md index d81e996..770b1c7 100644 --- a/node/README.md +++ b/node/README.md @@ -1,159 +1,148 @@ # Codex Remote Proxy -Codex Remote Proxy lets Codex stay signed into ChatGPT for remote-control features while sending the actual model traffic to your own OpenAI-compatible upstream and API key. +`@cluic/codex-remote-proxy` keeps Codex signed in with ChatGPT while routing model requests through a selected OpenAI-compatible provider. -## Install +> Release status: npm `0.2.2` is still the published pre-supervisor version and does not include `crp ui`. This document describes the pending next minor release, which must not be published until its external platform and L3 gates pass. -```bash -npm install -g @cluic/codex-remote-proxy -``` +## Requirements and Install After Release -Then run: +Node.js 22.13 or newer is required. ```bash -crp start +npm install -g @cluic/codex-remote-proxy +crp ui ``` -You can also run it without a global install: +Or run without a global install: ```bash -npx @cluic/codex-remote-proxy start +npx @cluic/codex-remote-proxy ui ``` -## What It Solves +`crp ui` is the normal setup and management entry point. It starts or discovers the loopback supervisor, opens the local management UI, and supports complete English and Simplified Chinese interfaces. -Codex splits request routing and authentication across two local files: +The current development source is a responsive React + TypeScript SPA under `ui-src/`, built with Vite. Build tools and source are not shipped: the package contains exactly `ui/index.html`, `ui/app.js`, and `ui/styles.css` for the existing same-origin Admin server. -- `~/.codex/config.toml` controls the OpenAI `base_url` -- `~/.codex/auth.json` controls the `Authorization` token +## Product Behavior -When Codex is signed into ChatGPT, requests may still carry `tokens.access_token` instead of the API key required by your upstream provider. +The UI can create, test, switch, update, and delete named providers; start, stop, and restart the proxy worker; inspect anonymous 24-hour/7-day aggregate Metrics; review sanitized Activity; inspect read-only System facts; and generate in-memory diagnostic summary metadata. A provider must pass an OpenAI Responses compatibility test before explicit activation. Provider cards expose the legal switch action directly. Explicit activation applies a snapshot to a running Worker and starts a stopped Worker; the Setup-only first-provider compare-and-set selection never starts or reconfigures it. The active provider cannot be updated or deleted, even while the worker is stopped; switch to another provider first. -This package inserts a local proxy that: +`Forwarding Records` is a disabled coming-soon navigation item only. It has no route, traffic request, Capture control, payload viewer, or mock records. Overview Metrics is anonymous aggregation independent from optional Capture. -1. receives Codex requests on `127.0.0.1` -2. forwards them to the real upstream -3. rewrites `Authorization` to the real upstream API key - -## Recommended Setup - -The easiest persistent setup is to add this section to `~/.codex/config.toml`: +Codex remains configured as: ```toml -[codex_remote_proxy] -upstream_base_url = "https://your-upstream.example.com" -upstream_api_key = "sk-your-key" -capture_enabled = true -capture_db_path = "/Users/you/.codex-remote-proxy/traffic.sqlite3" +model_provider = "OpenAI" ``` -Then run: +Its proxy address remains fixed at `http://127.0.0.1:15100`. CRP switches upstreams internally for new requests while in-flight requests retain their starting snapshot. -```bash -crp start -``` +On a clean home, explicit `crp start` privately and atomically creates a missing `.codex` directory and `config.toml`, with no backup for a source that did not exist. New POSIX directory/file modes are `0700`/`0600`; a repeated bootstrap is byte-identical. Existing-file locking, identity/race checks, adjacent backup, mode preservation, and idempotency remain in force. -If you do not want to place secrets in `~/.codex/config.toml`, use one of these alternatives instead: +Exit Codex before a real existing-config transition. Bootstrap compares the root `model_provider` binding's effective `base_url` with the fixed proxy URL using a selected-binding scanner, not a whole-document TOML validator. A different or missing binding triggers discovery; only a nonempty history write set receives private active/archive rollout snapshots, exclusive SQLite logical backups, and a forward journal under `.codex/.crp-history-repair`. Pending repair or a config lock blocks explicit activation/start/restart and automatic crash recovery through one FIFO Codex gate. CRP-internal provider switching never triggers repair. Only provider metadata is rewritten, encrypted content is left intact, and same-URL provider-name changes remain outside the URL-only trigger. -### Option 1: Save once locally +## Credentials -```bash -crp init -crp start -``` +The public Supervisor requires the operating-system native store under service `org.cluic.codex-remote-proxy`. Native construction or operation failure fails closed. The UI, CLI, and Admin API expose no file-backend control. The lower-level private file adapter is limited to trusted dependency injection; a public startup-consent path remains future L3 work, and native operations never replay into the file namespace. -`crp init` stores the upstream configuration under: +Complete credentials are write-only. They are excluded from public provider projections, state, settings, activity, diagnostics, and errors. Prefer the UI for secret entry. `crp provider add` accepts a required `--api-key` for controlled automation, but command-line values may be exposed through shell history or process inspection. -```text -~/.codex-remote-proxy/config.json -``` +## Browser Session Boundary -### Option 2: Use environment variables +The Admin server binds exactly to `127.0.0.1:15101`, checks `Host` and `Origin`, disables CORS, and requires CSRF for browser mutations. `crp ui` launches with a control token in the URL fragment; the app exchanges it once for an HttpOnly `SameSite=Strict` session cookie plus an in-memory CSRF token, then removes and clears the fragment token. -```bash -export CRP_UPSTREAM_BASE_URL="https://your-upstream.example.com" -export CRP_UPSTREAM_API_KEY="sk-your-key" -export CRP_CAPTURE_ENABLED="true" -export CRP_CAPTURE_DB_PATH="/Users/you/.codex-remote-proxy/traffic.sqlite3" -crp start +The Web UI defaults to English on first launch regardless of browser language. Only an explicit `crp.locale` selection may enter browser storage, and a selected `zh-CN` locale is retained on later launches. Session/control/CSRF tokens, credentials, drafts, responses, and errors remain memory-only. Reload with a valid cookie but no launch fragment starts GET-only; an explicit same-origin recovery may rotate that still-valid browser session and restore mutations without extending its expiry. Reopen with `crp ui` after expiry. Launch exchange and later business-session/CSRF failures remain terminal for the tab. + +## Commands + +```text +crp ui [--no-open] [--json] +crp start [--json] +crp status [--json] +crp stop [--json] +crp restart [--json] +crp shutdown [--json] +crp provider list [--json] +crp provider add --name --base-url --api-key [--model ] [--json] +crp provider models (--id | --name ) [--json] +crp provider test (--id | --name ) --model [--json] +crp provider activate (--id | --name ) [--json] +crp provider delete (--id | --name ) [--json] +crp guide [--json] ``` -`crp start` resolves values in this order: +Use `crp ui` for guided setup and daily management, or `crp start` for headless CLI startup. These are the two supported setup/start entry points. -1. CLI flags -2. Environment variables -3. `~/.codex/config.toml` under `[codex_remote_proxy]` using `upstream_base_url`, `upstream_api_key`, `capture_enabled`, and `capture_db_path` -4. Saved config from `crp init` -5. Interactive prompts +Human CLI output supports `en` and `zh-CN`. English is the default regardless of process or terminal locale variables. A single global `--locale en|zh-CN` may appear anywhere; Chinese requires explicit `--locale zh-CN` and is never persisted. JSON keys, codes, enums, messages, and actions remain stable English contracts. JSON failures leave stdout empty and write exactly one parseable envelope to stderr. -## Request Capture +## License -SQLite request capture is optional and off by default. +This package is licensed under the [MIT License](./LICENSE). -When enabled, the proxy stores one full request/response transaction per row in: +Human `provider list` output renders count, active marker, name/ID, query/hash-free base URL, test state, model mode/override, and credential-configured state. Human `status` output renders Supervisor PID/start time; Worker phase/PID/generation/listening/in-flight state; active provider; and Codex, fixed `OpenAI`, and `15100` proxy state. Dynamic terminal text is bounded and escapes control, escape, and bidirectional-control characters; private fields are not rendered. -```text -~/.codex-remote-proxy/traffic.sqlite3 -``` +Root help uses aligned command descriptions and consistent usage/options/examples sections. Exact `-h`/`--help` covers every supported first-level command, the provider group, and provider actions without Supervisor discovery. Flags are recognized only at exact argv positions; trailing or misplaced input remains a validation error. -You can enable it at startup: +`stop` stops only the proxy Worker and leaves the Supervisor/Admin API available; `shutdown` stops the Worker and exits the Supervisor. The supported fixed ports remain `15100` for the Worker and `15101` for the Supervisor. -```bash -crp start --capture -crp start --capture --capture-db-path /Users/you/.codex-remote-proxy/custom-traffic.sqlite3 -``` +Human success output confirms both processes for `shutdown`. `start` identifies failures as `supervisor_start`, `codex_bootstrap`, or `proxy_start`. Bootstrap failure short-circuits lifecycle mutation; explicit activation/start/restart and unexpected-exit recovery share one FIFO Codex readiness gate. A completed bootstrap remains durable if the proxy phase fails. -Or hot-toggle it on a running proxy: +Detached Supervisor startup uses a one-shot, strictly allowlisted IPC error. An approved migration-input failure wins over the readiness timeout; malformed, unknown, or unapproved child messages become the generic `SUPERVISOR_START_FAILED` contract. -```bash -crp capture on -crp capture off -crp capture status --json -``` +The former `init`, `install`, and `setup` compatibility aliases are removed. Each returns `CLI_COMMAND_REMOVED` locally without Supervisor discovery or mutation and points to `crp ui` or `crp start`. `check`, `capture on|off|status`, `guide`, and the deprecated `install-cli` shim command remain; there are no provider-update, Activity, Settings, or diagnostics CLI commands. -Edits to `~/.codex-remote-proxy/node/proxy-config.json` also hot-apply `capture.enabled`. Changes to `capture.dbPath` are detected but require a restart before the new database path is used. +Optional `provider add --model ` uses that value only for the follow-up Responses test; routing override remains `--model-mode override --model-override `. It saves the profile first, then tests, and creation remains committed when the compatibility result fails or the second-stage request cannot complete. `provider test`, `activate`, `delete`, and `models` require exactly one of `--id` or case-insensitive exact `--name`. `provider models` refreshes the authenticated, no-redirect `/models` catalog and rejects a complete credential reflected in any model ID before cache or output; discovery failure preserves the last good cache and does not change provider test or activation state. -## Main Commands +CLI tests request `activateIfNone` so the first successfully tested provider is selected through a first-wins compare-and-set while the Worker is stopped. That initial selection never starts or reconfigures the Worker; `crp start` remains explicit. Admin calls default `activateIfNone` to false. Conditional Web Setup opts in and runs `save -> test and CAS select -> Codex bootstrap/history repair -> Worker start`; ordinary Provider-page tests omit the flag. -- `crp check` - Inspect Codex config, auth mode, runtime availability, and managed service state +## Migration From 0.2.2 -- `crp start` - Accept upstream settings from CLI flags, environment variables, `~/.codex/config.toml` `[codex_remote_proxy]`, or prompts; choose a free port, patch Codex, and start the proxy in the background by default +Before first startup, stop the old proxy and privately back up the whole CRP home plus Codex configuration. Backups may contain credentials. -- `crp init` - Save upstream settings and optional capture defaults once under `~/.codex-remote-proxy/` +The first supervisor startup transactionally reads legacy `config.json` and `node/proxy-config.json` when present, writes collision-safe byte-exact private backups, stores the secret through the required native adapter, creates schema-2 `providers.json` with one inactive and untested `Default` profile, validates the registry, and only then scrubs legacy secret fields. The user must test and activate `Default`; migration does not assume compatibility. -- `crp capture on|off|status` - Toggle SQLite request capture at runtime for a managed proxy, or save the preference for the next start +Different credentials in the legacy sources produce `MIGRATION_INPUT_INVALID` before backups, credential operations, registry writes, or source mutation. CRP does not choose one source automatically; resolving a real-home conflict remains an operator-reviewed L3 migration action. -- `crp status` - Show managed service status and health +On an ordinary pre-commit failure, CRP attempts reverse-order restoration and removes only transaction-owned registry/credential state. Foreign replacements and all backups are preserved. Committed or rollback-degraded migration codes require CRP to remain stopped while an operator reviews Activity and the private backups; repeated retry or partial manual copying can make an uncertain state worse. -- `crp stop` - Stop the managed service +Returning to `0.2.2` requires stopping CRP and restoring the complete pre-upgrade backup as a unit. Schema 2 is not automatically downgraded. Real-home migration, native stores, and rollback are L3 platform operations. -- `crp guide` - Print AI-oriented usage guidance +## Development and Release Gates -## Release Flow +Run the source CLI directly when a specifically authorized smoke test must use +the real user paths: -This package uses Changesets and GitHub Actions for npm releases. +```bash +npm run dev:cli -- check --json +``` -From `node/`: +The direct entry resolves `~/.codex` and `~/.codex-remote-proxy` from the real +home directory. A test wrapper that injects `getPaths(tempHome)` is intentionally +isolated and cannot prove a real-home transition. Do not run a mutating +real-home command while Codex is active; copied-corpus rehearsal and L3 review +remain required before migration or history-repair release evidence. ```bash -npm run changeset +npm ci +npm run lint +npm run typecheck:ui +npm run build:ui +npm run verify:ui-build +npm test +node scripts/run-test-group.mjs core-chain +node --test test/session-auth.test.mjs test/integration/admin-server.test.mjs +npm run test:e2e -- --project=chromium --workers=1 +npm audit --omit=dev +node --test test/package-content.test.mjs test/native-keyring-smoke.test.mjs test/release-workflows.test.mjs +npm pack --dry-run --json --ignore-scripts ``` -Commit the generated file under `.changeset/` with your feature PR. After the PR is merged to `main`, GitHub Actions will open or update a release PR. Merging that release PR publishes the package to npm. +The current package-content test requires the exact reviewed 34-file allowlist, including the MIT License, provider-model cache, Codex history-repair, Metrics store, and exactly three generated UI assets. It rejects UI development source, runtime state, credentials, tests, Changesets, logs, databases, and generated output outside the reviewed UI files. Deterministic tests use temporary homes, synthetic credentials, injected credential adapters, and loopback upstreams. + +The serial `core-chain` group uses the production CLI/Admin/registry/provider/WorkerManager/forked-worker path and proves switching, in-flight snapshots, restart, shutdown, cleanup, and secret scans. Its injected memory credential adapter and loopback upstreams do not satisfy the separate real native-keyring/external-provider gate. -See [RELEASING.md](./RELEASING.md) for the one-time npm Trusted Publishing setup. +Final M2E/V8 local verification passes exact `npm test` 463/463 (`412` unit-core + `8` isolated capture + `42` ordinary integration + `1` serial core-chain), Metrics focus 6/6, lint across 33 source files, UI typecheck/build/exact-output verification, package-content 3/3 against the exact 33-file allowlist, Chromium 33/33 with the English/Chinese 1440/1024/390 responsive matrix, zero full/runtime audit vulnerabilities, and the same-state comparison in `../design-qa.md`. Copied-corpus real-home history rehearsal plus cross-platform native/filesystem/ACL/release L3 gates remain open. -## Notes +Supervisor discovery applies a 2-second liveness probe and returns a client with a separate 30-second operation timeout. Proxy forwarding joins base and incoming URLs structurally, preserving base paths and query parameters while avoiding duplicate path separators. The retained `provider add --api-key ` behavior and broader child-environment minimization remain explicit future follow-up work and do not block local core completion. -- `crp start` modifies `~/.codex/config.toml` and creates a backup -- the managed proxy runs in the background by default -- managed state and logs live under `~/.codex-remote-proxy/` -- request capture redacts sensitive headers before writing -- Node.js 22.13.0 or newer is required +This release requires a minor Changeset. Do not run `npm run version-packages` or `npm run release` during feature preparation. See [RELEASING.md](./RELEASING.md) for local evidence and remaining remote/human gates. diff --git a/node/RELEASING.md b/node/RELEASING.md index 52d94a1..bbfe9b8 100644 --- a/node/RELEASING.md +++ b/node/RELEASING.md @@ -1,36 +1,130 @@ # Releasing -This package publishes from the `node/` directory using Changesets and GitHub Actions. +This package publishes from `node/` through Changesets and GitHub Actions. The currently published version is `0.2.2`; the supervisor, multi-provider, and bilingual local-UI work is an unreleased minor change. -## One-time setup +## One-Time Publishing Setup Configure npm Trusted Publishing for `@cluic/codex-remote-proxy`: - Repository: `cluic/codex-remote-proxy` - Workflow file: `.github/workflows/release.yml` -- Environment: leave empty unless you intentionally scope publishing to a GitHub environment +- Environment: leave empty unless publishing is intentionally scoped to a GitHub environment -Trusted Publishing is configured in npm package settings for the published package. +Publishing uses GitHub OIDC and requires no long-lived `NPM_TOKEN`. -## Normal release flow +## Feature Pull Request -1. Make code changes. -2. Run `npm run changeset`. -3. Commit the generated file under `node/.changeset/`. -4. Merge the PR into `main`. -5. GitHub Actions opens or updates a release PR. -6. Merge the release PR. -7. GitHub Actions publishes the package to npm. +1. Add a minor Changeset under `node/.changeset/`. +2. Run every local gate below on the final tree. +3. Push a branch and open a pull request only after L3 human review is scheduled. +4. Wait for the macOS, Windows, Linux, and release-preflight workflows and retain their run URLs. +5. Attach the macOS and Windows sanitized UI artifacts plus real native-backend smoke evidence. +6. Obtain L3 expert approval before merge. -## Useful commands +Every checkout that occurs before pull-request code runs must use `persist-credentials: false`. Native credential smoke jobs must prove the intended Keychain, Credential Manager, or Secret Service backend; a file fallback is not acceptable evidence. + +## Local Deterministic Gate + +Do not confuse an injected development wrapper with a production-path smoke. +`runCli(..., { paths: getPaths(tempHome) })` intentionally keeps Supervisor, +Provider, and Codex-bootstrap effects under that temporary home. The direct +source entry below resolves the real `~/.codex` and `~/.codex-remote-proxy` and +must remain read-only unless a real-home L3 operation was explicitly approved: ```bash -cd node -npm run changeset -npm run version-packages +npm run dev:cli -- check --json +``` + +Run from `node/`: + +```bash +npm run lint +npm run typecheck:ui +npm run build:ui +npm run verify:ui-build +npm test +node --test test/codex-config.test.mjs test/codex-history-repair.test.mjs +node --test test/crp.test.mjs test/worker-manager.test.mjs test/integration/admin-server.test.mjs test/integration/crp-lifecycle.test.mjs test/integration/worker-restart.test.mjs +npm run test:e2e -- --project=chromium --workers=1 +npm audit --omit=dev +node --test test/package-content.test.mjs test/native-keyring-smoke.test.mjs test/release-workflows.test.mjs +npm pack --dry-run --json --ignore-scripts +npm run changeset -- status ``` -## Notes +Historical M2D/V7 working-tree evidence on Node 22.19 on 2026-07-16: + +- exact full suite: 451/451 total (`401` unit-core + `8` isolated capture + `41` ordinary integration + `1` serial core-chain); +- history/config focus: 98/98; +- strict status/lifecycle and Worker-gate focus: 105/105; +- browser E2E: 41/41; +- syntax check: 31 source files; +- runtime audit: 0 vulnerabilities; +- package-content: 3/3 against the exact reviewed 32-file allowlist; +- diff and sensitive-value scans: pass; both fixed ports released; +- final independent M2D/V7 L3 review: `PASS`, with no unresolved P0/P1/P2. + +M2E/V8 final local evidence on 2026-07-16: + +- React + TypeScript + Vite source is build-time only; the reviewed package still contains exactly `ui/index.html`, `ui/app.js`, and `ui/styles.css`; +- anonymous Metrics, Provider-card switching, conditional Setup CAS selection, responsive bilingual pages, and the disabled Forwarding Records placeholder are implemented locally; +- the exact package allowlist is now 33 files, adding `src/supervisor/metrics-store.mjs` without publishing `ui-src/` or frontend build tooling; +- exact `npm test` passes 463/463 (`412` unit-core + `8` isolated capture + `42` ordinary integration + `1` serial core-chain); Metrics focus passes 6/6 and lint checks 33 source files; +- UI typecheck/build/exact-output verification and package-content 3/3 against the exact 33-file allowlist pass; +- Chromium passes 33/33, including the complete English/Chinese 1440/1024/390 responsive matrix; +- full and runtime dependency audits report zero vulnerabilities, and `design-qa.md` records the matched same-state visual comparison with no unresolved P0/P1/P2. +- `git diff --check` and production/documentation sensitive-pattern scans pass; independent final review returns `PASS` after resolving all P2 documentation/evidence findings. + +V8.1 release-preparation rerun on 2026-07-17: + +- the user-authorized temporary Supervisor and Worker were shut down cleanly, + releasing fixed ports `15100` and `15101`; +- exact `npm test` passes 466/466 (`414` unit-core + `8` isolated Capture + + `43` ordinary integration + `1` serial core-chain); +- Chromium passes 39/39, and syntax, UI typecheck/build/exact-output, + package/release 21/21, installed-tarball CLI smoke, Changesets minor status, + sensitive-pattern, diff, and both dependency-audit gates pass; and +- the direct source CLI projects the real `~/.codex` and + `~/.codex-remote-proxy`, while the earlier `crpdev` wrapper is confirmed to + be intentionally isolated through its injected temporary paths. + +Publication remains blocked on deliberate working-tree staging, +copied-real-history rehearsal, remote platform evidence, and final L3 approval. + +Current release-preparation adjustments include a shipped MIT License and a +deterministic English default for CLI output and first-time Web sessions. The +Web UI retains only an explicit user language selection. + +The final local rerun passes CLI/i18n 30/30, Chromium 39/39, UI +typecheck/build/exact-output, lint, runtime audit, package/release tests 21/21, +the exact 34-file package dry run, and exact `npm test` 467/467 (`415` +unit-core + `8` Capture + `43` ordinary integration + `1` serial core chain). + +Historical commits remain Task 11 implementation `d114061`, Task 11 docs `dd4de3f`, Task 12 package/platform gates `af918d5`, and credential-boundary hardening `210cb71`; the then-current M2D/V7 evidence was for its uncommitted reviewed working tree. These local results used temporary roots and synthetic history and do not prove copied-corpus real-home performance/recovery, remote Keychain/Credential Manager/Secret Service behavior, cross-platform filesystem semantics, browser launch behavior, or platform screenshots. + +## Migration Review + +Treat upgrade and rollback as L3 operations. Before using a real home directory: + +1. stop the old proxy; +2. privately back up the entire CRP home and Codex configuration; +3. verify that the first supervisor start retains byte-exact legacy backups, creates schema-2 registry state, and scrubs legacy secret fields only after commit; +4. test and activate the migrated inactive `Default` provider; +5. inspect Activity for committed or rollback-degraded migration codes before any retry. + +Before exercising M2D/V7 history repair, fully stop Codex and use a private copy of a representative history corpus. Verify storage growth, the 300-second bootstrap budget, interruption after each durable phase, forward retry, fixed marker/lock recovery, and byte/logical backup restoration before authorizing any real-home run. + +Rollback to `0.2.2` requires the supervisor to be stopped and the complete pre-upgrade backup to be restored as one unit. Do not mix the schema-2 registry with restored flat files. Do not publish until migration and rollback evidence has passed real-platform expert review. + +## Release Pull Request and Publish + +After a feature pull request merges to `main`, the release workflow opens or updates a strict `changeset-release/*` pull request from `github-actions[bot]`. Review its version and changelog changes, remove or update the dated pre-release notices in all three READMEs, wait for all platform/preflight gates, and merge it only after L3 approval. The workflow then publishes through npm Trusted Publishing. + +Do not run these commands during feature preparation: + +```bash +npm run version-packages +npm run release +``` -- Publishing uses npm Trusted Publishing via GitHub OIDC, so no long-lived `NPM_TOKEN` is required. -- If a change should not release the npm package, do not add a changeset. +As of 2026-07-16, the historical local macOS D2 passed with the production Keychain adapter and a real upstream on its reviewed tree. Remote macOS/Windows/Linux workflow run URLs, remote/cross-platform native-service evidence, Windows screenshots, copied-corpus history repair, real-home migration/rollback, final release L3 approval, pull request, push, merge, versioning, publication, and release are still pending. diff --git a/node/bin/crp.mjs b/node/bin/crp.mjs index 07ed13a..fa48965 100644 --- a/node/bin/crp.mjs +++ b/node/bin/crp.mjs @@ -1,22 +1,35 @@ #!/usr/bin/env node import { spawn, spawnSync } from "node:child_process"; -import { chmodSync, closeSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; import net from "node:net"; import readline from "node:readline/promises"; -import os from "node:os"; import { setTimeout as delay } from "node:timers/promises"; +import { fileURLToPath } from "node:url"; import { DEFAULT_CAPTURE_DB_PATH } from "../src/capture-config.mjs"; +import { bootstrapCodexConfig } from "../src/codex/codex-config.mjs"; +import { CrpError } from "../src/shared/errors.mjs"; +import { getPaths } from "../src/shared/paths.mjs"; +import { + discoverSupervisor, + ensureSupervisor, + readControlToken, + readSupervisorState, + readSupervisorStateSnapshot, + removeStaleSupervisorState +} from "../src/supervisor/supervisor-client.mjs"; const PACKAGE_ROOT = resolve(import.meta.dirname, ".."); -const DEFAULT_CODEX_CONFIG_PATH = resolve(os.homedir(), ".codex", "config.toml"); -const DEFAULT_AUTH_PATH = resolve(os.homedir(), ".codex", "auth.json"); -const GLOBAL_HOME = resolve(os.homedir(), ".codex-remote-proxy"); +const { + codexConfigPath: DEFAULT_CODEX_CONFIG_PATH, + authPath: DEFAULT_AUTH_PATH, + globalHome: GLOBAL_HOME, + statePath: STATE_FILE, + logPath: LOG_FILE +} = getPaths(); const BIN_DIR = resolve(GLOBAL_HOME, "bin"); const CRP_SHIM_PATH = resolve(BIN_DIR, "crp"); -const STATE_FILE = resolve(GLOBAL_HOME, "state.json"); -const LOG_FILE = resolve(GLOBAL_HOME, "proxy.log"); const USER_CONFIG_FILE = resolve(GLOBAL_HOME, "config.json"); const NODE_RUNTIME_CONFIG_PATH = resolve(GLOBAL_HOME, "node", "proxy-config.json"); const OPENAI_SECTION_HEADER = "[model_providers.OpenAI]"; @@ -29,22 +42,638 @@ const ENV_KEYS = { captureEnabled: "CRP_CAPTURE_ENABLED", captureDbPath: "CRP_CAPTURE_DB_PATH" }; +const BOOLEAN_OPTIONS = new Set(["json", "no-open", "capture", "no-capture", "debug"]); +const HELP_FLAGS = new Set(["-h", "--help"]); +const PROVIDER_ACTIONS = new Set(["list", "add", "models", "test", "activate", "delete"]); +const SAFE_CLI_COMMANDS = new Set([ + "ui", "start", "status", "stop", "restart", "shutdown", + "provider", "check", "capture", "guide", "install-cli" +]); +const REMOVED_CLI_COMMANDS = new Map([ + ["init", "crp ui"], + ["install", "crp start"], + ["setup", "crp start"] +]); +const SAFE_ERROR_DETAIL_FIELDS = new Set([ + "field", "reason", "committed", "degraded", "pending", "generation", "httpStatus", + "forced", "graceful", "processStopped", "stateRemoved" +]); +const SHUTDOWN_FORCE_FALLBACK_CODES = new Set([ + "API_METHOD_NOT_ALLOWED", + "API_NOT_FOUND", + "SUPERVISOR_SHUTDOWN_UNAVAILABLE", + "SUPERVISOR_UNAVAILABLE" +]); +const CODEX_BOOTSTRAP_REQUEST_TIMEOUT_MS = 300_000; +const CLI_ERROR_CONTRACTS = Object.freeze({ + CLI_INPUT_INVALID: Object.freeze({ + message: "The command input is invalid.", + action: "Review the command options and try again." + }), + CLI_COMMAND_FAILED: Object.freeze({ + message: "CRP could not complete the command.", + action: "Review CRP activity and try again." + }) +}); +export const CLI_MESSAGES = Object.freeze({ + en: Object.freeze({ + "error.prefix": "Error: {message}", + "error.commandFailed": "CRP could not complete the command. Review CRP activity and try again.", + "error.public": "{message} {action}", + "help.usage": "Usage:", + "help.rootSyntax": " crp [options]", + "help.commands": "Commands:", + "help.examples": "Examples:", + "help.recommended": "Recommended commands:", + "help.providerCommands": "Provider commands:", + "help.otherCommands": "Other commands:", + "help.options": "Options:", + "help.option.json": " --json Write the stable machine-readable response.", + "help.option.noOpen": " --no-open Start management without opening a browser.", + "help.option.locale": " --locale Select human output language for this process.", + "help.option.help": " -h, --help Show help without starting CRP.", + "help.option.provider.name": " --name Select a provider by its unique name.", + "help.option.provider.id": " --id Select a provider by its stable ID.", + "help.option.provider.baseUrl": " --base-url Provider API base URL, normally ending in /v1.", + "help.option.provider.apiKey": " --api-key Write-only provider credential.", + "help.option.provider.model": " --model Model used for the compatibility test.", + "help.option.provider.authHeader": " --auth-header Authentication header name (default: authorization).", + "help.option.provider.authScheme": " --auth-scheme Authentication scheme (default: Bearer).", + "help.option.provider.modelMode": " --model-mode Routing mode: passthrough or override.", + "help.option.provider.modelOverride": " --model-override Model sent upstream when override mode is used.", + "help.hint": "Run `crp --help` for command-specific help.", + "help.check": " crp check [--json] [--codex-config PATH] [--auth PATH]", + "help.ui": " crp ui [--no-open] [--json]", + "help.start": " crp start [--json]", + "help.capture": " crp capture [--json]", + "help.status": " crp status [--json]", + "help.stop": " crp stop [--json]", + "help.restart": " crp restart [--json]", + "help.shutdown": " crp shutdown [--json]", + "help.provider": " crp provider [options]", + "help.guide": " crp guide [--json]", + "help.installCli": " crp install-cli [--json]", + "help.description.check": "Inspect local Codex and legacy CRP configuration.", + "help.description.ui": "Start the Supervisor if needed and open the local management UI.", + "help.description.start": "Ensure the Supervisor is running, bootstrap Codex if needed, and start the proxy Worker.", + "help.description.capture": "Inspect or change the legacy capture preference.", + "help.description.status": "Show Supervisor, Worker, active-provider, Codex, and proxy status.", + "help.description.stop": "Stop the proxy Worker while the Supervisor and management UI remain running.", + "help.description.restart": "Drain and replace the proxy Worker while keeping the Supervisor running.", + "help.description.shutdown": "Stop the proxy Worker and Supervisor completely.", + "help.description.provider": "Provider commands manage named upstream profiles.", + "help.description.guide": "Show the recommended provider and lifecycle flow.", + "help.description.installCli": "Install the deprecated local command shim.", + "help.provider.list": " crp provider list [--json]", + "help.provider.add": " crp provider add --name --base-url --api-key [--model ] [--json]", + "help.provider.models": " crp provider models (--id | --name ) [--json]", + "help.provider.test": " crp provider test (--id | --name ) --model [--json]", + "help.provider.activate": " crp provider activate (--id | --name ) [--json]", + "help.provider.delete": " crp provider delete (--id | --name ) [--json]", + "help.provider.addAdvanced": " --model runs a compatibility test after saving. Routing override: --model-mode, --model-override. Authentication: --auth-header, --auth-scheme.", + "help.provider.description.list": "List configured providers and their non-secret status.", + "help.provider.description.add": "Add a named provider profile and write its credential.", + "help.provider.description.models": "Refresh and list available models for one provider.", + "help.provider.description.test": "Test Responses API compatibility for one provider.", + "help.provider.description.activate": "Make a tested provider the active provider for new requests.", + "help.provider.description.delete": "Delete an inactive provider and its saved credential.", + "help.example.providerAdd": " crp provider add --name Primary --base-url https://api.example/v1 --api-key --model ", + "help.example.providerModels": " crp provider models --name Primary", + "help.example.providerList": " crp provider list", + "help.example.providerTest": " crp provider test --name Primary --model ", + "help.example.providerActivate": " crp provider activate --name Backup", + "help.example.providerDelete": " crp provider delete --name Retired", + "help.example.status": " crp status", + "validation.unexpectedPositional": "Unexpected positional argument.", + "validation.providerAction": "Unknown provider action.", + "validation.providerOption": "The provider command contains an unsupported option.", + "validation.providerRequired": "The provider {name} option is required.", + "validation.providerSelector": "Provide exactly one of --id or --name.", + "validation.commandOption": "The {command} command contains an unsupported option.", + "validation.localeDuplicate": "The --locale option may only be provided once.", + "validation.localeRequired": "The --locale option requires en or zh-CN.", + "validation.localeUnsupported": "The --locale option supports only en or zh-CN.", + "validation.captureAction": "Unknown capture action.", + "validation.captureOption": "The capture command contains an unsupported option.", + "common.yes": "yes", + "common.no": "no", + "common.unknown": "(unknown)", + "common.missing": "(missing)", + "common.notConfigured": "(not configured)", + "check.codexConfigPath": "Codex config path: {value}", + "check.authPath": "Codex auth path: {value}", + "check.authMode": "auth_mode: {value}", + "check.configured": "{name} configured: {value}", + "check.section": "Codex [{name}]:", + "check.field": " {name}: {value}", + "check.runtimeStatus": "Runtime status:", + "check.node": " node: {value}", + "check.installHint": " Run `npm install` in the package directory first.", + "check.globalHome": "Global home: {value}", + "check.globalCommand": "Global command: {value}", + "check.proxySection": "Codex proxy section: {value}", + "check.savedConfig": "Saved config: {value}", + "guide.header": "CRP V1 guide:", + "guide.add": " Add and test a provider with `{command}`; the first successful provider is selected automatically.", + "guide.models": " Refresh its model cache with `{command}`.", + "guide.test": " Retest a saved provider with `{command}`.", + "guide.activate": " Switch to another tested provider with `{command}`.", + "guide.start": " Start the proxy through the supervisor with `{command}`.", + "guide.status": " Confirm supervisor and worker health with `{command}`.", + "guide.ui": " Open the local management UI with `{command}`.", + "guide.shutdown": " When finished, stop the supervisor with `{command}`.", + "capture.running": "Capture running: {value}", + "capture.persistedEnabled": "Persisted capture enabled: {value}", + "capture.persistedDb": "Persisted capture DB: {value}", + "capture.runtimeEnabled": "Runtime capture enabled: {value}", + "capture.runtimeDb": "Runtime capture DB: {value}", + "capture.savedNextStart": "Capture preference saved. It will apply the next time the proxy starts.", + "capture.savedRuntime": "Capture preference saved and runtime config updated.", + "installCli.installed": "Legacy local shim installed.", + "installCli.prefer": "For public distribution, prefer:", + "ui.opened": "CRP management UI opened.", + "status.running": "CRP supervisor is running.", + "status.header": "CRP status:", + "status.supervisor": "Supervisor: {state}", + "status.worker": "Worker: {state}", + "status.pid": " PID: {value}", + "status.startedAt": " Started at: {value}", + "status.generation": " Generation: {value}", + "status.listening": " Listening: {value}", + "status.inFlight": " In flight: {value}", + "status.activeProvider": "Active provider: {name} ({id})", + "status.activeProviderNone": "Active provider: none", + "status.codex": "Codex: {state}", + "status.historyRepairPending": "History repair pending: {value}", + "status.modelProvider": "Model provider: {value}", + "status.proxyUrl": "Proxy URL: {value}", + "status.state.running": "running", + "status.state.stopped": "stopped", + "status.state.starting": "starting", + "status.state.draining": "draining", + "status.state.failed": "failed", + "status.state.crashed": "crashed", + "status.state.backoff": "waiting to retry", + "status.state.configured": "configured", + "status.state.notConfigured": "not configured", + "provider.add.completed": "Provider add completed.", + "provider.add.testPassed": "Automatic compatibility test passed.", + "provider.add.testFailed": "Provider saved, but the automatic test failed ({code}).", + "provider.test.initialSelected": "This is the first tested provider, so it is now selected. Run `crp start` to start the proxy Worker.", + "provider.activate.completed": "Provider activate completed.", + "provider.delete.completed": "Provider delete completed.", + "provider.list.completed": "Provider list completed.", + "provider.test.completed": "Provider test completed.", + "provider.models.completed": "Provider model discovery completed.", + "provider.models.header": "Models for {name} ({id}) ({count}):", + "provider.models.empty": "No models were returned by this provider.", + "provider.models.item": "- {model}", + "provider.models.more": "... and {count} more models", + "provider.list.header": "Providers ({count}):", + "provider.list.empty": "No providers configured.", + "provider.list.active": "* {name} (active)", + "provider.list.inactive": "- {name}", + "provider.list.id": " ID: {value}", + "provider.list.baseUrl": " Base URL: {value}", + "provider.list.test": " Test: {value}", + "provider.list.model": " Model: {value}", + "provider.list.credential": " Credential: {value}", + "provider.test.untested": "untested", + "provider.test.passed": "passed", + "provider.test.failed": "failed", + "provider.test.failedCode": "failed ({code})", + "provider.model.passthrough": "passthrough", + "provider.model.override": "override -> {model}", + "provider.credential.configured": "configured", + "provider.credential.notConfigured": "not configured", + "command.removed": "The `{command}` command has been removed. Use `{replacement}` instead.", + "start.ready": "Codex Remote Proxy is ready.", + "start.historyRepairEncryptedWarning": "Warning: Some historical sessions contain encrypted content. Their provider metadata was repaired, but some messages may remain unavailable.", + "status.notRunning": "CRP supervisor is not running.", + "stop.notRunning": "No running proxy worker to stop.", + "stop.completed": "Proxy worker stopped. CRP Supervisor is still running; use `crp shutdown` to stop it.", + "restart.completed": "Proxy worker restarted.", + "shutdown.notRunning": "CRP supervisor is not running.", + "shutdown.notRunningStaleRemoved": "CRP supervisor is not running. Stale local state was safely removed.", + "shutdown.identityChanged": "Supervisor identity changed; shutdown was cancelled.", + "shutdown.timeout": "The supervisor did not stop in time.", + "shutdown.stateTimeout": "The supervisor state was not cleaned up in time.", + "shutdown.unavailable": "The supervisor could not be reached for a safe shutdown.", + "shutdown.completed": "CRP Supervisor and proxy Worker stopped.", + "shutdown.forcedCompleted": "CRP Supervisor and proxy Worker stopped using the forced fallback.", + "shutdown.degradedCompleted": "CRP stopped, and stale local state was safely recovered.", + "stage.supervisor_start.failed": "Supervisor startup failed. Review the supervisor log and try again.", + "stage.codex_bootstrap.failed": "Codex configuration bootstrap failed. Review CRP activity and retry before starting the proxy.", + "stage.proxy_start.failed": "Proxy startup failed. Review CRP activity and try again." + }), + "zh-CN": Object.freeze({ + "error.prefix": "错误:{message}", + "error.commandFailed": "CRP 无法完成该命令。请查看 CRP 活动记录后重试。", + "error.public": "命令失败({code})。请查看 CRP 活动记录后重试。", + "help.usage": "用法:", + "help.rootSyntax": " crp <命令> [选项]", + "help.commands": "命令:", + "help.examples": "示例:", + "help.recommended": "推荐命令:", + "help.providerCommands": "提供商命令:", + "help.otherCommands": "其他命令:", + "help.options": "选项:", + "help.option.json": " --json 输出稳定的机器可读响应。", + "help.option.noOpen": " --no-open 启动管理服务但不打开浏览器。", + "help.option.locale": " --locale 选择当前进程的人类可读输出语言。", + "help.option.help": " -h, --help 显示帮助且不启动 CRP。", + "help.option.provider.name": " --name 按唯一名称选择提供商。", + "help.option.provider.id": " --id 按稳定 ID 选择提供商。", + "help.option.provider.baseUrl": " --base-url 提供商 API 基础地址,通常以 /v1 结尾。", + "help.option.provider.apiKey": " --api-key 只写的提供商凭据。", + "help.option.provider.model": " --model 用于兼容性测试的模型。", + "help.option.provider.authHeader": " --auth-header 认证请求头名称(默认:authorization)。", + "help.option.provider.authScheme": " --auth-scheme 认证方案(默认:Bearer)。", + "help.option.provider.modelMode": " --model-mode 路由模式:passthrough 或 override。", + "help.option.provider.modelOverride": " --model-override override 模式下发送给上游的模型。", + "help.hint": "运行 `crp <命令> --help` 查看命令专用帮助。", + "help.check": " crp check [--json] [--codex-config PATH] [--auth PATH]", + "help.ui": " crp ui [--no-open] [--json]", + "help.start": " crp start [--json]", + "help.capture": " crp capture [--json]", + "help.status": " crp status [--json]", + "help.stop": " crp stop [--json]", + "help.restart": " crp restart [--json]", + "help.shutdown": " crp shutdown [--json]", + "help.provider": " crp provider [options]", + "help.guide": " crp guide [--json]", + "help.installCli": " crp install-cli [--json]", + "help.description.check": "检查本地 Codex 和旧版 CRP 配置。", + "help.description.ui": "按需启动监督进程并打开本地管理页面。", + "help.description.start": "确保监督进程运行,按需引导 Codex 配置,然后启动代理工作进程。", + "help.description.capture": "检查或修改旧版抓取偏好。", + "help.description.status": "显示监督进程、工作进程、当前提供商、Codex 和代理状态。", + "help.description.stop": "停止代理工作进程,监督进程和管理页面继续运行。", + "help.description.restart": "排空并替换代理工作进程,同时保持监督进程运行。", + "help.description.shutdown": "完全停止代理工作进程和监督进程。", + "help.description.provider": "提供商命令用于管理具名上游配置。", + "help.description.guide": "显示推荐的提供商和生命周期流程。", + "help.description.installCli": "安装已弃用的本地命令入口。", + "help.provider.list": " crp provider list [--json]", + "help.provider.add": " crp provider add --name --base-url --api-key [--model ] [--json]", + "help.provider.models": " crp provider models (--id | --name ) [--json]", + "help.provider.test": " crp provider test (--id | --name ) --model [--json]", + "help.provider.activate": " crp provider activate (--id | --name ) [--json]", + "help.provider.delete": " crp provider delete (--id | --name ) [--json]", + "help.provider.addAdvanced": " --model 会在保存后执行兼容性测试。路由覆盖:--model-mode、--model-override。认证:--auth-header、--auth-scheme。", + "help.provider.description.list": "列出已配置的提供商及其非敏感状态。", + "help.provider.description.add": "添加具名提供商配置并写入凭据。", + "help.provider.description.models": "刷新并列出一个提供商的可用模型。", + "help.provider.description.test": "测试一个提供商的 Responses API 兼容性。", + "help.provider.description.activate": "将已测试的提供商设为新请求的当前提供商。", + "help.provider.description.delete": "删除一个未激活的提供商及其已保存凭据。", + "help.example.providerAdd": " crp provider add --name Primary --base-url https://api.example/v1 --api-key --model ", + "help.example.providerModels": " crp provider models --name Primary", + "help.example.providerList": " crp provider list", + "help.example.providerTest": " crp provider test --name Primary --model ", + "help.example.providerActivate": " crp provider activate --name Backup", + "help.example.providerDelete": " crp provider delete --name Retired", + "help.example.status": " crp status", + "validation.unexpectedPositional": "出现了意外的位置参数。", + "validation.providerAction": "未知的提供商操作。", + "validation.providerOption": "提供商命令包含不支持的选项。", + "validation.providerRequired": "提供商选项 {name} 为必填项。", + "validation.providerSelector": "必须且只能提供 --id 或 --name 其中一个选项。", + "validation.commandOption": "{command} 命令包含不支持的选项。", + "validation.localeDuplicate": "--locale 选项只能提供一次。", + "validation.localeRequired": "--locale 选项需要 en 或 zh-CN。", + "validation.localeUnsupported": "--locale 选项仅支持 en 或 zh-CN。", + "validation.captureAction": "未知的 capture 操作。", + "validation.captureOption": "capture 命令包含不支持的选项。", + "common.yes": "是", + "common.no": "否", + "common.unknown": "(未知)", + "common.missing": "(缺失)", + "common.notConfigured": "(未配置)", + "check.codexConfigPath": "Codex 配置路径:{value}", + "check.authPath": "Codex 认证路径:{value}", + "check.authMode": "auth_mode:{value}", + "check.configured": "{name} 已配置:{value}", + "check.section": "Codex [{name}]:", + "check.field": " {name}:{value}", + "check.runtimeStatus": "运行时状态:", + "check.node": " node:{value}", + "check.installHint": " 请先在软件包目录中运行 `npm install`。", + "check.globalHome": "全局目录:{value}", + "check.globalCommand": "全局命令:{value}", + "check.proxySection": "Codex 代理配置段:{value}", + "check.savedConfig": "已保存配置:{value}", + "guide.header": "CRP V1 指南:", + "guide.add": " 使用 `{command}` 添加并测试提供商;首个测试成功的提供商会被自动选中。", + "guide.models": " 使用 `{command}` 刷新其模型缓存。", + "guide.test": " 使用 `{command}` 重新测试已保存的提供商。", + "guide.activate": " 使用 `{command}` 切换到另一个已测试的提供商。", + "guide.start": " 使用 `{command}` 通过监督进程启动代理。", + "guide.status": " 使用 `{command}` 确认监督进程和工作进程状态。", + "guide.ui": " 使用 `{command}` 打开本地管理页面。", + "guide.shutdown": " 完成后使用 `{command}` 停止监督进程。", + "capture.running": "抓取功能运行中:{value}", + "capture.persistedEnabled": "持久化抓取设置已启用:{value}", + "capture.persistedDb": "持久化抓取数据库:{value}", + "capture.runtimeEnabled": "运行时抓取已启用:{value}", + "capture.runtimeDb": "运行时抓取数据库:{value}", + "capture.savedNextStart": "抓取偏好已保存,将在代理下次启动时生效。", + "capture.savedRuntime": "抓取偏好已保存,运行时配置已更新。", + "installCli.installed": "旧版本地命令入口已安装。", + "installCli.prefer": "公开分发请优先使用:", + "ui.opened": "CRP 管理页面已打开。", + "status.running": "CRP 监督进程正在运行。", + "status.header": "CRP 状态:", + "status.supervisor": "监督进程:{state}", + "status.worker": "工作进程:{state}", + "status.pid": " PID:{value}", + "status.startedAt": " 启动时间:{value}", + "status.generation": " 代次:{value}", + "status.listening": " 正在监听:{value}", + "status.inFlight": " 处理中:{value}", + "status.activeProvider": "当前提供商:{name}({id})", + "status.activeProviderNone": "当前提供商:无", + "status.codex": "Codex:{state}", + "status.historyRepairPending": "历史会话修复待完成:{value}", + "status.modelProvider": "模型提供商:{value}", + "status.proxyUrl": "代理地址:{value}", + "status.state.running": "运行中", + "status.state.stopped": "已停止", + "status.state.starting": "启动中", + "status.state.draining": "正在排空", + "status.state.failed": "失败", + "status.state.crashed": "已崩溃", + "status.state.backoff": "等待重试", + "status.state.configured": "已配置", + "status.state.notConfigured": "未配置", + "provider.add.completed": "提供商添加操作已完成。", + "provider.add.testPassed": "自动兼容性测试已通过。", + "provider.add.testFailed": "提供商已保存,但自动测试失败({code})。", + "provider.test.initialSelected": "这是首个测试通过的提供商,现已自动选中。运行 `crp start` 启动代理工作进程。", + "provider.activate.completed": "提供商激活操作已完成。", + "provider.delete.completed": "提供商删除操作已完成。", + "provider.list.completed": "提供商列表操作已完成。", + "provider.test.completed": "提供商测试操作已完成。", + "provider.models.completed": "提供商模型发现操作已完成。", + "provider.models.header": "{name}({id})可用模型({count}):", + "provider.models.empty": "该提供商未返回模型。", + "provider.models.item": "- {model}", + "provider.models.more": "……另有 {count} 个模型", + "provider.list.header": "提供商({count}):", + "provider.list.empty": "尚未配置提供商。", + "provider.list.active": "* {name}(当前)", + "provider.list.inactive": "- {name}", + "provider.list.id": " ID:{value}", + "provider.list.baseUrl": " 基础地址:{value}", + "provider.list.test": " 测试:{value}", + "provider.list.model": " 模型:{value}", + "provider.list.credential": " 凭据:{value}", + "provider.test.untested": "未测试", + "provider.test.passed": "已通过", + "provider.test.failed": "失败", + "provider.test.failedCode": "失败({code})", + "provider.model.passthrough": "透传", + "provider.model.override": "覆盖为 {model}", + "provider.credential.configured": "已配置", + "provider.credential.notConfigured": "未配置", + "command.removed": "`{command}` 命令已移除。请改用 `{replacement}`。", + "start.ready": "Codex Remote Proxy 已就绪。", + "start.historyRepairEncryptedWarning": "警告:部分历史会话包含加密内容。提供商元数据已修复,但部分消息可能仍不可用。", + "status.notRunning": "CRP 监督进程未运行。", + "stop.notRunning": "没有正在运行的代理工作进程可停止。", + "stop.completed": "代理工作进程已停止。CRP 监督进程仍在运行;如需停止,请使用 `crp shutdown`。", + "restart.completed": "代理工作进程已重启。", + "shutdown.notRunning": "CRP 监督进程未运行。", + "shutdown.notRunningStaleRemoved": "CRP 监督进程未运行,残留的本地状态已安全清理。", + "shutdown.identityChanged": "监督进程身份已变化,已取消关闭操作。", + "shutdown.timeout": "监督进程未能及时停止。", + "shutdown.stateTimeout": "监督进程状态未能及时清理。", + "shutdown.unavailable": "无法连接监督进程以安全关闭。", + "shutdown.completed": "CRP 监督进程和代理工作进程已停止。", + "shutdown.forcedCompleted": "CRP 监督进程和代理工作进程已通过强制回退停止。", + "shutdown.degradedCompleted": "CRP 已停止,残留的本地状态已安全恢复。", + "stage.supervisor_start.failed": "启动监督进程失败。请检查监督进程日志后重试。", + "stage.codex_bootstrap.failed": "引导 Codex 配置失败。请查看 CRP 活动记录,修复后再启动代理。", + "stage.proxy_start.failed": "启动代理失败。请查看 CRP 活动记录后重试。" + }) +}); + +function normalizeLocale(value) { + if (typeof value !== "string") return null; + const normalized = value.trim().split(/[.@]/, 1)[0].replaceAll("_", "-").toLowerCase(); + if (normalized.startsWith("zh")) return "zh-CN"; + if (normalized.startsWith("en")) return "en"; + return null; +} + +function cliInputError(messageKey, values = {}) { + const contract = CLI_ERROR_CONTRACTS.CLI_INPUT_INVALID; + const error = new CrpError( + "CLI_INPUT_INVALID", + contract.message, + contract.action, + { status: 400 } + ); + error.cliMessageKey = messageKey; + error.cliMessageValues = values; + return error; +} + +function resolveCliLocale(argv) { + const stripped = []; + let explicit = null; + for (let index = 0; index < argv.length; index += 1) { + if (argv[index] !== "--locale") { + stripped.push(argv[index]); + continue; + } + if (explicit !== null) throw cliInputError("validation.localeDuplicate"); + const value = argv[index + 1]; + if (typeof value !== "string" || value.startsWith("--")) { + throw cliInputError("validation.localeRequired"); + } + explicit = value; + index += 1; + } + if (explicit !== null) { + const locale = normalizeLocale(explicit); + if (locale === null) throw cliInputError("validation.localeUnsupported"); + return { argv: stripped, locale, explicit: true }; + } + return { argv: stripped, locale: "en", explicit: false }; +} + +function cliMessage(locale, key, values = {}) { + let message = CLI_MESSAGES[locale][key]; + for (const [name, value] of Object.entries(values)) { + message = message.replaceAll(`{${name}}`, () => String(value)); + } + return message; +} + +function safeCommandName(argv) { + return SAFE_CLI_COMMANDS.has(argv[0]) || REMOVED_CLI_COMMANDS.has(argv[0]) + ? argv[0] + : "unknown"; +} + +function removedCommandError(command, replacement) { + const error = new CrpError( + "CLI_COMMAND_REMOVED", + "This CLI command has been removed.", + `Use \`${replacement}\` instead.`, + { status: 400 } + ); + error.cliMessageKey = "command.removed"; + error.cliMessageValues = { command: `crp ${command}`, replacement }; + return error; +} + +function shutdownCliError(code, messageKey, { + status = 500, + details = {}, + cause +} = {}) { + const contracts = { + SUPERVISOR_IDENTITY_CHANGED: [ + "The local supervisor identity changed.", + "Refresh CRP status and retry against the current supervisor." + ], + SUPERVISOR_SHUTDOWN_TIMEOUT: [ + "The local supervisor did not stop in time.", + "Review CRP status and Activity before retrying shutdown." + ], + SUPERVISOR_STATE_CLEANUP_FAILED: [ + "The local supervisor stopped, but its state could not be cleaned up safely.", + "Do not remove unrelated files; review CRP Activity and retry shutdown." + ], + SUPERVISOR_SHUTDOWN_UNAVAILABLE: [ + "The local supervisor could not be reached for a safe shutdown.", + "Retry shutdown while the current supervisor is still running." + ], + SUPERVISOR_SHUTDOWN_RESPONSE_INVALID: [ + "The local supervisor returned an invalid shutdown response.", + "Review CRP Activity and retry shutdown." + ] + }; + const [message, action] = contracts[code] ?? contracts.SUPERVISOR_SHUTDOWN_UNAVAILABLE; + const error = new CrpError(code, message, action, { status, details, cause }); + error.cliMessageKey = messageKey; + return error; +} + +function sameSupervisorIdentity(left, right) { + if (!left || !right + || left.supervisorPid !== right.supervisorPid + || left.startedAt !== right.startedAt) { + return false; + } + const leftAdmin = left.admin; + const rightAdmin = right.admin; + return leftAdmin !== null && typeof leftAdmin === "object" + && rightAdmin !== null && typeof rightAdmin === "object" + && ["host", "port", "authority", "origin"].every( + (field) => leftAdmin[field] === rightAdmin[field] + ); +} + +function validShutdownAcceptance(payload, expected) { + const shutdown = payload?.shutdown; + return payload !== null && typeof payload === "object" && !Array.isArray(payload) + && Object.keys(payload).length === 1 + && shutdown !== null && typeof shutdown === "object" && !Array.isArray(shutdown) + && Object.keys(shutdown).length === 3 + && shutdown.accepted === true + && shutdown.supervisorPid === expected.supervisorPid + && shutdown.startedAt === expected.startedAt; +} + +function shutdownIdentityError() { + return shutdownCliError("SUPERVISOR_IDENTITY_CHANGED", "shutdown.identityChanged", { + status: 409 + }); +} + +function sanitizeCliErrorDetails(details) { + if (details === null || typeof details !== "object" || Array.isArray(details)) return {}; + const safe = {}; + for (const [key, value] of Object.entries(details)) { + if (!SAFE_ERROR_DETAIL_FIELDS.has(key)) continue; + if (typeof value === "boolean" || value === null + || (typeof value === "number" && Number.isFinite(value)) + || (typeof value === "string" && /^[A-Za-z0-9_.:-]{1,128}$/.test(value))) { + safe[key] = value; + } + } + return safe; +} -function parseCommandLine(argv) { - if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") { - printHelp(); - process.exit(0); +function projectCliError(error) { + if (error instanceof CrpError + && typeof error.code === "string" && /^[A-Z][A-Z0-9_]*$/.test(error.code) + && typeof error.message === "string" && error.message.length > 0 + && typeof error.action === "string" && error.action.length > 0) { + const projected = { + code: error.code, + message: error.message, + action: error.action, + details: sanitizeCliErrorDetails(error.details) + }; + if (typeof error.requestId === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(error.requestId)) { + projected.requestId = error.requestId; + } + return projected; } + return { + code: "CLI_COMMAND_FAILED", + ...CLI_ERROR_CONTRACTS.CLI_COMMAND_FAILED, + details: {} + }; +} +function withCliStage(error, stage) { + if (error !== null && (typeof error === "object" || typeof error === "function")) { + try { + Object.defineProperty(error, "cliStage", { value: stage, configurable: true }); + return error; + } catch { + // Fall through to a static wrapper when the original error is not extensible. + } + } + const wrapper = new Error(); + wrapper.cliStage = stage; + return wrapper; +} + +function humanCliError(error, locale) { + if (["supervisor_start", "codex_bootstrap", "proxy_start"].includes(error?.cliStage)) { + return cliMessage(locale, `stage.${error.cliStage}.failed`); + } + if (error instanceof CrpError && typeof error.cliMessageKey === "string") { + return cliMessage(locale, error.cliMessageKey, error.cliMessageValues); + } + if (error instanceof CrpError) { + return locale === "en" + ? cliMessage(locale, "error.public", { message: error.message, action: error.action }) + : cliMessage(locale, "error.public", { code: error.code }); + } + return cliMessage(locale, "error.commandFailed"); +} + +function parseCommandLine(argv, locale = "en") { const command = argv[0]; const options = {}; for (let index = 1; index < argv.length; index += 1) { const token = argv[index]; if (!token.startsWith("--")) { - throw new Error(`Unexpected argument: ${token}`); + throw cliInputError("validation.unexpectedPositional"); } const key = token.slice(2); + if (BOOLEAN_OPTIONS.has(key)) { + options[key] = true; + continue; + } const next = argv[index + 1]; if (!next || next.startsWith("--")) { options[key] = true; @@ -57,23 +686,153 @@ function parseCommandLine(argv) { return { command, options }; } -function printHelp() { - console.log("Usage:"); - console.log(" crp check [--json] [--codex-config PATH] [--auth PATH]"); - console.log(" crp init [--json] [--upstream-base-url URL] [--api-key KEY] [--listen-host 127.0.0.1] [--listen-port PORT] [--capture] [--no-capture] [--capture-db-path PATH]"); - console.log(" crp start [--json] [--upstream-base-url URL] [--api-key KEY] [--listen-host 127.0.0.1] [--listen-port PORT] [--capture] [--no-capture] [--capture-db-path PATH] [--debug]"); - console.log(" crp install [same as start]"); - console.log(" crp capture [--json]"); - console.log(" crp status [--json]"); - console.log(" crp stop [--json]"); - console.log(" crp setup [same as start]"); - console.log(" crp guide [--json]"); - console.log(" crp install-cli [--json]"); +function printHelpKeys(keys, writeLine, locale) { + for (const key of keys) writeLine(cliMessage(locale, key)); +} + +function printHelp(writeLine = (line) => console.log(line), locale = "en") { + printHelpKeys([ + "help.usage", + "help.rootSyntax", + "help.commands", + "help.ui", + "help.description.ui", + "help.start", + "help.description.start", + "help.status", + "help.description.status", + "help.stop", + "help.description.stop", + "help.restart", + "help.description.restart", + "help.shutdown", + "help.description.shutdown", + "help.provider", + "help.description.provider", + "help.check", + "help.description.check", + "help.capture", + "help.description.capture", + "help.guide", + "help.description.guide", + "help.installCli", + "help.description.installCli", + "help.options", + "help.option.json", + "help.option.locale", + "help.option.help", + "help.examples", + "help.example.providerAdd", + "help.example.status", + "help.hint" + ], writeLine, locale); +} + +function resolveHelpRequest(argv) { + if (argv.length === 0 + || (argv.length === 1 && HELP_FLAGS.has(argv[0]))) return { type: "root" }; + if (argv.length === 2 && HELP_FLAGS.has(argv[1]) && SAFE_CLI_COMMANDS.has(argv[0])) { + return argv[0] === "provider" + ? { type: "provider" } + : { type: "command", command: argv[0] }; + } + if (argv.length === 3 && argv[0] === "provider" + && PROVIDER_ACTIONS.has(argv[1]) && HELP_FLAGS.has(argv[2])) { + return { type: "providerAction", action: argv[1] }; + } + if (argv.length === 3 && argv[0] === "capture" + && ["on", "off", "status"].includes(argv[1]) && HELP_FLAGS.has(argv[2])) { + return { type: "command", command: "capture" }; + } + return null; +} + +function printCommandHelp(command, writeLine, locale) { + const keys = [ + "help.usage", + `help.${command === "install-cli" ? "installCli" : command}`, + `help.description.${command === "install-cli" ? "installCli" : command}`, + "help.options" + ]; + if (command === "ui") keys.push("help.option.noOpen"); + keys.push("help.option.json", "help.option.locale", "help.option.help"); + keys.push("help.examples", `help.${command === "install-cli" ? "installCli" : command}`); + printHelpKeys(keys, writeLine, locale); +} + +function printProviderHelp(writeLine, locale) { + printHelpKeys([ + "help.usage", + "help.provider", + "help.description.provider", + "help.commands", + "help.provider.list", + "help.provider.add", + "help.provider.models", + "help.provider.test", + "help.provider.activate", + "help.provider.delete", + "help.options", + "help.option.locale", + "help.option.help", + "help.examples", + "help.example.providerAdd", + "help.example.providerModels" + ], writeLine, locale); } -function maybePrintJson(options, payload) { +function printProviderActionHelp(action, writeLine, locale) { + const keys = [ + "help.usage", + `help.provider.${action}`, + `help.provider.description.${action}` + ]; + if (action === "add") keys.push("help.provider.addAdvanced"); + keys.push("help.options"); + if (["models", "test", "activate", "delete"].includes(action)) { + keys.push("help.option.provider.name", "help.option.provider.id"); + } + if (action === "add") { + keys.push( + "help.option.provider.name", + "help.option.provider.baseUrl", + "help.option.provider.apiKey", + "help.option.provider.model", + "help.option.provider.authHeader", + "help.option.provider.authScheme", + "help.option.provider.modelMode", + "help.option.provider.modelOverride" + ); + } else if (action === "test") { + keys.push("help.option.provider.model"); + } + keys.push("help.option.json", "help.option.locale", "help.option.help", "help.examples"); + keys.push({ + list: "help.example.providerList", + add: "help.example.providerAdd", + models: "help.example.providerModels", + test: "help.example.providerTest", + activate: "help.example.providerActivate", + delete: "help.example.providerDelete" + }[action]); + printHelpKeys(keys, writeLine, locale); +} + +function printResolvedHelp(request, writeLine, locale) { + if (request.type === "root") { + printHelp(writeLine, locale); + } else if (request.type === "provider") { + printProviderHelp(writeLine, locale); + } else if (request.type === "providerAction") { + printProviderActionHelp(request.action, writeLine, locale); + } else { + printCommandHelp(request.command, writeLine, locale); + } +} + +function maybePrintJson(options, payload, stdout = (text) => process.stdout.write(text)) { if (options.json) { - console.log(JSON.stringify(payload, null, 2)); + stdout(`${JSON.stringify(payload, null, 2)}\n`); return true; } return false; @@ -242,16 +1001,6 @@ function detectNodeRuntime() { }; } -function maskSecret(value) { - if (!value) { - return "(empty)"; - } - if (value.length <= 8) { - return value; - } - return `${value.slice(0, 4)}...${value.slice(-4)}`; -} - function quoteShell(value) { return `'${String(value).replace(/'/g, `'\\''`)}'`; } @@ -302,10 +1051,23 @@ function isProcessAlive(pid) { } function getManagedServiceInfo() { + const supervisorState = readSupervisorState({ path: STATE_FILE, adminPort: 15101 }); + if (supervisorState) { + return { + state: { + ...supervisorState, + alive: isProcessAlive(supervisorState.supervisorPid) + }, + staleStateRemoved: false + }; + } const state = loadManagedState(); if (!state) { return { state: null, staleStateRemoved: false }; } + if (!Number.isSafeInteger(state.pid) || state.pid < 1) { + return { state: null, staleStateRemoved: false }; + } const alive = Boolean(state.pid && isProcessAlive(state.pid)); if (!alive) { return { state: null, staleStateRemoved: removeManagedState() }; @@ -472,31 +1234,32 @@ function buildGuideData() { preferredImplementation: "node", commands: { inspect: "crp check --json", - init: "crp init --upstream-base-url --api-key [--capture] [--capture-db-path PATH] --json", - start: "crp start --upstream-base-url --api-key [--capture] [--capture-db-path PATH] --json", - captureOn: "crp capture on --json", - captureOff: "crp capture off --json", - captureStatus: "crp capture status --json", + providerAdd: "crp provider add --name --base-url --api-key --model --json", + providerModels: "crp provider models --name --json", + providerTest: "crp provider test --name --model --json", + providerActivate: "crp provider activate --name --json", + start: "crp start --json", status: "crp status --json", + ui: "crp ui --json", stop: "crp stop --json", + shutdown: "crp shutdown --json", installCli: "npm install -g @cluic/codex-remote-proxy", runWithoutInstall: "npx @cluic/codex-remote-proxy guide --json" }, expectedFlow: [ - "Run check --json first.", - "Read runtimeStatus and recommendedImplementation.", - "If node dependencies are ready, use the node path.", - "Optionally set [codex_remote_proxy] in ~/.codex/config.toml or run init once to save upstream settings under ~/.codex-remote-proxy/.", - "Run start. It will resolve settings from CLI flags, then environment variables, then ~/.codex/config.toml [codex_remote_proxy], then saved config, and only prompt as a last resort.", - "Use `crp capture on|off` for runtime capture toggling; manual edits to the runtime proxy config also hot-apply capture.enabled.", - "start launches the proxy in the background by default and patches ~/.codex/config.toml.", - "Use status --json to confirm the proxy is healthy." + "Add and test a provider with `crp provider add --name --base-url --api-key --model --json`; the first successful provider is selected automatically.", + "Start the proxy through the supervisor with `crp start --json`.", + "Confirm supervisor and worker health with `crp status --json`.", + "Open the local management UI with `crp ui --json`.", + "When finished, stop the supervisor with `crp shutdown --json`." ], notes: [ - "The start command modifies ~/.codex/config.toml and creates a backup.", - "The proxy configuration and state are stored under ~/.codex-remote-proxy/.", - "Use CRP_UPSTREAM_BASE_URL and CRP_UPSTREAM_API_KEY when you want non-interactive start without exposing secrets in later AI interactions.", - "The optional ~/.codex/config.toml [codex_remote_proxy] section supports upstream_base_url, upstream_api_key, capture_enabled, and capture_db_path as non-interactive sources." + "Use `crp provider models --name --json` to refresh the provider model cache.", + "Use provider test to retest a saved provider and provider activate to switch to another tested provider.", + "The start command creates a backup only when it changes ~/.codex/config.toml.", + "Provider credential is write-only and never echoed by CLI or Admin API responses.", + "The supervisor owns proxy lifecycle and applies the active provider when start is requested.", + "The proxy configuration and state are stored under ~/.codex-remote-proxy/." ] }; } @@ -526,15 +1289,17 @@ function buildCheckData(options) { }, codexRemoteProxy: { upstreamBaseUrl: codexRemoteProxyUpstreamBaseUrl, - upstreamApiKeyPreview: typeof codexRemoteProxyUpstreamApiKey === "string" ? maskSecret(codexRemoteProxyUpstreamApiKey) : null, + credentialConfigured: typeof codexRemoteProxyUpstreamApiKey === "string" + && codexRemoteProxyUpstreamApiKey.length > 0, captureEnabled: codexRemoteProxyCaptureEnabled, captureDbPath: codexRemoteProxyCaptureDbPath }, auth: { authMode: authData.auth_mode ?? null, - openAiApiKeyPreview: typeof authData.OPENAI_API_KEY === "string" ? maskSecret(authData.OPENAI_API_KEY) : null, - accessTokenPrefix: typeof authData?.tokens?.access_token === "string" ? authData.tokens.access_token.slice(0, 2) : null, - accessTokenLength: typeof authData?.tokens?.access_token === "string" ? authData.tokens.access_token.length : 0 + openAiApiKeyConfigured: typeof authData.OPENAI_API_KEY === "string" + && authData.OPENAI_API_KEY.length > 0, + accessTokenConfigured: typeof authData?.tokens?.access_token === "string" + && authData.tokens.access_token.length > 0 }, runtimeStatus, configSources: { @@ -553,7 +1318,19 @@ function buildCheckData(options) { implementation: { configPath: NODE_RUNTIME_CONFIG_PATH, configExists: existsSync(NODE_RUNTIME_CONFIG_PATH), - runtimeConfig: runtimeProxyConfig, + runtimeConfig: runtimeProxyConfig === null ? null : { + server: { + host: runtimeProxyConfig.server?.host ?? null, + port: runtimeProxyConfig.server?.port ?? null + }, + upstream: { + baseUrl: runtimeProxyConfig.upstream?.baseUrl ?? null + }, + capture: { + enabled: runtimeProxyConfig.capture?.enabled === true, + dbPath: runtimeProxyConfig.capture?.dbPath ?? null + } + }, startCommand: startCommand(NODE_RUNTIME_CONFIG_PATH) }, recommendedImplementation: "node", @@ -564,35 +1341,72 @@ function buildCheckData(options) { }; } -function printHumanCheck(data) { - console.log(`Codex config path: ${data.codexConfigPath}`); - console.log(`Codex auth path: ${data.authPath}`); - console.log(""); - console.log(`auth_mode: ${data.auth.authMode || "(unknown)"}`); - console.log(`OPENAI_API_KEY: ${data.auth.openAiApiKeyPreview || "(missing)"}`); - console.log(`tokens.access_token: ${data.auth.accessTokenPrefix ? `${data.auth.accessTokenPrefix}..., len=${data.auth.accessTokenLength}` : "(missing)"}`); - console.log(""); - console.log("Codex [model_providers.OpenAI]:"); - console.log(` base_url: ${data.codexOpenAiProvider.baseUrl || "(missing)"}`); - console.log(` wire_api: ${data.codexOpenAiProvider.wireApi || "(missing)"}`); - console.log(` requires_openai_auth: ${data.codexOpenAiProvider.requiresOpenAiAuth ?? "(missing)"}`); - console.log(""); - console.log("Codex [codex_remote_proxy]:"); - console.log(` upstream_base_url: ${data.codexRemoteProxy.upstreamBaseUrl || "(missing)"}`); - console.log(` upstream_api_key: ${data.codexRemoteProxy.upstreamApiKeyPreview || "(missing)"}`); - console.log(` capture_enabled: ${data.codexRemoteProxy.captureEnabled ?? "(missing)"}`); - console.log(` capture_db_path: ${data.codexRemoteProxy.captureDbPath || "(missing)"}`); - console.log(""); - console.log("Runtime status:"); - console.log(` node: ${data.runtimeStatus.node.available ? data.runtimeStatus.node.version : data.runtimeStatus.node.error}`); +function printHumanCheck(data, locale, stdout) { + const writeLine = (line = "") => stdout(`${line}\n`); + const value = (key) => cliMessage(locale, key); + const yesNo = (enabled) => value(enabled ? "common.yes" : "common.no"); + const missing = (candidate) => candidate ?? value("common.missing"); + writeLine(cliMessage(locale, "check.codexConfigPath", { value: data.codexConfigPath })); + writeLine(cliMessage(locale, "check.authPath", { value: data.authPath })); + writeLine(); + writeLine(cliMessage(locale, "check.authMode", { + value: data.auth.authMode || value("common.unknown") + })); + writeLine(cliMessage(locale, "check.configured", { + name: "OPENAI_API_KEY", + value: yesNo(data.auth.openAiApiKeyConfigured) + })); + writeLine(cliMessage(locale, "check.configured", { + name: "tokens.access_token", + value: yesNo(data.auth.accessTokenConfigured) + })); + writeLine(); + writeLine(cliMessage(locale, "check.section", { name: "model_providers.OpenAI" })); + writeLine(cliMessage(locale, "check.field", { name: "base_url", value: missing(data.codexOpenAiProvider.baseUrl) })); + writeLine(cliMessage(locale, "check.field", { name: "wire_api", value: missing(data.codexOpenAiProvider.wireApi) })); + writeLine(cliMessage(locale, "check.field", { + name: "requires_openai_auth", + value: missing(data.codexOpenAiProvider.requiresOpenAiAuth) + })); + writeLine(); + writeLine(cliMessage(locale, "check.section", { name: "codex_remote_proxy" })); + writeLine(cliMessage(locale, "check.field", { + name: "upstream_base_url", + value: missing(data.codexRemoteProxy.upstreamBaseUrl) + })); + writeLine(cliMessage(locale, "check.field", { + name: "credential configured", + value: yesNo(data.codexRemoteProxy.credentialConfigured) + })); + writeLine(cliMessage(locale, "check.field", { + name: "capture_enabled", + value: missing(data.codexRemoteProxy.captureEnabled) + })); + writeLine(cliMessage(locale, "check.field", { + name: "capture_db_path", + value: missing(data.codexRemoteProxy.captureDbPath) + })); + writeLine(); + writeLine(cliMessage(locale, "check.runtimeStatus")); + writeLine(cliMessage(locale, "check.node", { + value: data.runtimeStatus.node.available ? data.runtimeStatus.node.version : data.runtimeStatus.node.error + })); if (data.runtimeStatus.node.available && !data.runtimeStatus.node.dependenciesReady) { - console.log(` ${data.runtimeStatus.node.installHint}`); - } - console.log(""); - console.log(`Global home: ${data.globalHome}`); - console.log(`Global command: ${data.globalCommand}`); - console.log(`Codex proxy section: ${data.configSources.codexConfigSectionPresent ? data.codexConfigPath : "(not configured)"}`); - console.log(`Saved config: ${data.configSources.savedConfigPresent ? data.configSources.savedConfigPath : "(not configured)"}`); + writeLine(cliMessage(locale, "check.installHint")); + } + writeLine(); + writeLine(cliMessage(locale, "check.globalHome", { value: data.globalHome })); + writeLine(cliMessage(locale, "check.globalCommand", { value: data.globalCommand })); + writeLine(cliMessage(locale, "check.proxySection", { + value: data.configSources.codexConfigSectionPresent + ? data.codexConfigPath + : value("common.notConfigured") + })); + writeLine(cliMessage(locale, "check.savedConfig", { + value: data.configSources.savedConfigPresent + ? data.configSources.savedConfigPath + : value("common.notConfigured") + })); } function writeProxyConfig(path, config) { @@ -600,75 +1414,6 @@ function writeProxyConfig(path, config) { writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, "utf8"); } -function backupFile(path) { - const timestamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\..+/, "").replace("T", "-"); - const backupPath = `${path}.${timestamp}.bak`; - copyFileSync(path, backupPath); - return backupPath; -} - -function renderTomlString(value) { - return JSON.stringify(value); -} - -function upsertKey(lines, startIndex, endIndex, key, value) { - const rendered = typeof value === "boolean" ? (value ? "true" : "false") : renderTomlString(String(value)); - for (let index = startIndex; index < endIndex; index += 1) { - const stripped = lines[index].trim(); - if (!stripped || stripped.startsWith("#") || !stripped.includes("=")) { - continue; - } - const currentKey = stripped.split("=", 1)[0].trim(); - if (currentKey === key) { - lines[index] = `${key} = ${rendered}`; - return lines; - } - } - lines.splice(endIndex, 0, `${key} = ${rendered}`); - return lines; -} - -function firstSectionIndex(lines) { - for (let index = 0; index < lines.length; index += 1) { - const stripped = lines[index].trim(); - if (stripped.startsWith("[") && stripped.endsWith("]")) { - return index; - } - } - return lines.length; -} - -function patchCodexConfigText(text, proxyUrl) { - const lines = splitLines(text); - const topEnd = firstSectionIndex(lines); - upsertKey(lines, 0, topEnd, "model_provider", "OpenAI"); - let sectionRange = findSectionRange(lines, OPENAI_SECTION_HEADER); - - if (!sectionRange) { - if (lines.length && lines[lines.length - 1].trim()) { - lines.push(""); - } - lines.push( - OPENAI_SECTION_HEADER, - 'name = "OpenAI"', - `base_url = ${renderTomlString(proxyUrl)}`, - 'wire_api = "responses"', - "requires_openai_auth = true" - ); - return `${lines.join("\n")}\n`; - } - - const [sectionStart] = sectionRange; - upsertKey(lines, sectionStart + 1, sectionRange[1], "name", "OpenAI"); - sectionRange = findSectionRange(lines, OPENAI_SECTION_HEADER); - upsertKey(lines, sectionStart + 1, sectionRange[1], "base_url", proxyUrl); - sectionRange = findSectionRange(lines, OPENAI_SECTION_HEADER); - upsertKey(lines, sectionStart + 1, sectionRange[1], "wire_api", "responses"); - sectionRange = findSectionRange(lines, OPENAI_SECTION_HEADER); - upsertKey(lines, sectionStart + 1, sectionRange[1], "requires_openai_auth", true); - return `${lines.join("\n")}\n`; -} - function resolveConfigValue({ cliValue, envKey, savedValues = [], defaultValue = "" }) { if (typeof cliValue === "string" && cliValue.trim()) { return { value: cliValue.trim(), source: "cli" }; @@ -811,9 +1556,7 @@ async function installCommand(options) { if (!existsSync(codexConfigPath)) { throw new Error(`Codex config not found: ${codexConfigPath}`); } - const codexBackup = backupFile(codexConfigPath); - const patchedText = patchCodexConfigText(readFileSync(codexConfigPath, "utf8"), proxyUrl); - writeFileSync(codexConfigPath, patchedText, "utf8"); + const { backupPath } = await bootstrapCodexConfig({ configPath: codexConfigPath, proxyUrl }); const existingState = loadManagedState(); if (existingState?.pid && isProcessAlive(existingState.pid)) { @@ -834,7 +1577,7 @@ async function installCommand(options) { proxyConfigPath, logFile: startResult.logFile || null, upstreamBaseUrl, - codexConfigBackup: codexBackup, + codexConfigBackup: backupPath, startedAt: new Date().toISOString() }; saveManagedState(managedState); @@ -864,6 +1607,7 @@ async function installCommand(options) { upstreamBaseUrl, codexConfigPath, proxyConfigPath, + codexConfigBackup: backupPath, configSource: { upstreamBaseUrl: resolved.upstreamBaseUrl.source, apiKey: resolved.apiKey.source, @@ -904,63 +1648,29 @@ async function installCommand(options) { const startCommandAction = installCommand; -async function initCommand(options) { - const resolved = resolveUserSettings(options); - const upstreamBaseUrl = resolved.upstreamBaseUrl.value || await promptValue("Upstream base URL", ""); - const apiKey = resolved.apiKey.value || await promptSecret("Upstream API key", ""); - const listenHost = resolved.listenHost.value || "127.0.0.1"; - const listenPort = resolved.listenPort.value ? Number.parseInt(resolved.listenPort.value, 10) : undefined; - const captureEnabled = Boolean(resolved.captureEnabled.value); - const captureDbPath = ensureCaptureDbPath(resolved.captureDbPath.value); - - if (!upstreamBaseUrl || !apiKey) { - throw new Error("Upstream base URL and API key are required"); - } - - writeUserConfig({ - upstreamBaseUrl, - apiKey, - listenHost, - listenPort, - captureEnabled, - captureDbPath - }); - - const payload = { - ok: true, - configPath: USER_CONFIG_FILE, - saved: { - upstreamBaseUrl, - apiKeyPreview: maskSecret(apiKey), - listenHost, - listenPort: listenPort ?? null, - captureEnabled, - captureDbPath - } - }; - - if (!maybePrintJson(options, payload)) { - console.log("Saved CRP configuration."); - console.log(`Config path: ${USER_CONFIG_FILE}`); - console.log("You can now run: crp start"); - } -} - -function checkCommand(options) { +function checkCommand(options, locale, stdout) { const data = buildCheckData(options); - if (!maybePrintJson(options, data)) { - printHumanCheck(data); + if (!maybePrintJson(options, data, stdout)) { + printHumanCheck(data, locale, stdout); } } -function guideCommand(options) { +function guideCommand(options, locale, stdout) { const data = buildGuideData(); - if (!maybePrintJson(options, data)) { - console.log("AI guide:"); - console.log(` Entrypoint: ${data.entrypoint}`); - console.log(` Inspect first: ${data.commands.inspect}`); - console.log(` Install: ${data.commands.install}`); - console.log(` Status: ${data.commands.status}`); + if (!maybePrintJson(options, data, stdout)) { + stdout(`${cliMessage(locale, "guide.header")}\n`); + for (const [key, command] of [ + ["guide.add", data.commands.providerAdd], + ["guide.models", data.commands.providerModels], + ["guide.test", data.commands.providerTest], + ["guide.activate", data.commands.providerActivate], + ["guide.start", data.commands.start], + ["guide.status", data.commands.status], + ["guide.ui", data.commands.ui], + ["guide.shutdown", data.commands.shutdown] + ]) { + stdout(`${cliMessage(locale, key, { command })}\n`); + } } } @@ -1006,19 +1716,25 @@ async function statusCommand(options) { } } -async function captureCommand(options, action) { - if (!["on", "off", "status"].includes(action)) { - throw new Error(`Unknown capture action: ${action}`); - } - +async function captureCommand(options, action, locale, stdout) { if (action === "status") { const runtime = loadRuntimeProxyConfig(); const state = loadManagedState(); + const supervisorState = readSupervisorState({ path: STATE_FILE, adminPort: 15101 }); const payload = { ok: true, - running: Boolean(state?.pid && isProcessAlive(state.pid)), - persistedConfig: loadUserConfig(), - runtimeConfig: runtime?.capture ?? null + running: supervisorState + ? isProcessAlive(supervisorState.supervisorPid) + : Boolean(state?.pid && isProcessAlive(state.pid)), + managedBySupervisor: supervisorState !== null, + persistedConfig: { + captureEnabled: loadUserConfig().captureEnabled === true, + captureDbPath: loadUserConfig().captureDbPath ?? null + }, + runtimeConfig: runtime?.capture ? { + enabled: runtime.capture.enabled === true, + dbPath: runtime.capture.dbPath ?? null + } : null }; if (state?.proxyUrl && payload.running) { try { @@ -1027,18 +1743,30 @@ async function captureCommand(options, action) { payload.healthError = error.message; } } - if (!maybePrintJson(options, payload)) { - console.log(`Capture running: ${payload.running ? "yes" : "no"}`); - console.log(`Persisted capture enabled: ${payload.persistedConfig.captureEnabled ? "yes" : "no"}`); - console.log(`Persisted capture DB: ${payload.persistedConfig.captureDbPath || DEFAULT_CAPTURE_DB_PATH}`); + if (!maybePrintJson(options, payload, stdout)) { + const yesNo = (enabled) => cliMessage(locale, enabled ? "common.yes" : "common.no"); + stdout(`${cliMessage(locale, "capture.running", { value: yesNo(payload.running) })}\n`); + stdout(`${cliMessage(locale, "capture.persistedEnabled", { + value: yesNo(payload.persistedConfig.captureEnabled) + })}\n`); + stdout(`${cliMessage(locale, "capture.persistedDb", { + value: payload.persistedConfig.captureDbPath || DEFAULT_CAPTURE_DB_PATH + })}\n`); if (payload.runtimeConfig) { - console.log(`Runtime capture enabled: ${payload.runtimeConfig.enabled ? "yes" : "no"}`); - console.log(`Runtime capture DB: ${payload.runtimeConfig.dbPath || DEFAULT_CAPTURE_DB_PATH}`); + stdout(`${cliMessage(locale, "capture.runtimeEnabled", { + value: yesNo(payload.runtimeConfig.enabled) + })}\n`); + stdout(`${cliMessage(locale, "capture.runtimeDb", { + value: payload.runtimeConfig.dbPath || DEFAULT_CAPTURE_DB_PATH + })}\n`); } } return; } + if (readSupervisorState({ path: STATE_FILE, adminPort: 15101 })) { + throw new Error("Capture settings are read-only in this version."); + } const enabled = action === "on"; const persistedConfig = applyUserConfigPatch({ captureEnabled: enabled, @@ -1048,7 +1776,10 @@ async function captureCommand(options, action) { const payload = { ok: true, action, - persistedConfig, + persistedConfig: { + captureEnabled: persistedConfig.captureEnabled === true, + captureDbPath: persistedConfig.captureDbPath ?? null + }, runtimeUpdated: false, message: "" }; @@ -1057,8 +1788,8 @@ async function captureCommand(options, action) { const running = Boolean(managedState?.pid && isProcessAlive(managedState.pid)); if (!running) { payload.message = "Capture preference saved. It will apply the next time the proxy starts."; - if (!maybePrintJson(options, payload)) { - console.log(payload.message); + if (!maybePrintJson(options, payload, stdout)) { + stdout(`${cliMessage(locale, "capture.savedNextStart")}\n`); } return; } @@ -1086,8 +1817,8 @@ async function captureCommand(options, action) { } } - if (!maybePrintJson(options, payload)) { - console.log(payload.message); + if (!maybePrintJson(options, payload, stdout)) { + stdout(`${cliMessage(locale, "capture.savedRuntime")}\n`); } } @@ -1099,7 +1830,7 @@ async function stopCommand(options) { } } -async function installCliCommand(options) { +async function installCliCommand(options, locale, stdout) { const result = installCliShim(); const payload = { ok: true, @@ -1109,49 +1840,1047 @@ async function installCliCommand(options) { deprecated: true, message: "install-cli is deprecated for public distribution; prefer npm global installation." }; - if (!maybePrintJson(options, payload)) { - console.log("Legacy local shim installed."); - console.log("For public distribution, prefer:"); - console.log("npm install -g @cluic/codex-remote-proxy"); + if (!maybePrintJson(options, payload, stdout)) { + stdout(`${cliMessage(locale, "installCli.installed")}\n`); + stdout(`${cliMessage(locale, "installCli.prefer")}\n`); + stdout("npm install -g @cluic/codex-remote-proxy\n"); } } -async function main() { - const argv = process.argv.slice(2); - if (argv[0] === "capture") { - const action = argv[1]; - const options = {}; - for (let index = 2; index < argv.length; index += 1) { - const token = argv[index]; - if (!token.startsWith("--")) { - throw new Error(`Unexpected argument: ${token}`); +function writePayload(options, payload, stdout, humanMessage) { + if (options.json) { + stdout(`${JSON.stringify(payload, null, 2)}\n`); + return; + } + stdout(`${humanMessage}\n`); +} + +function writeHumanProviderAdd(result, locale, stdout) { + const lines = [cliMessage(locale, "provider.add.completed")]; + if (result?.test?.ok === true) { + lines.push(cliMessage(locale, "provider.add.testPassed")); + } else if (result?.test?.ok === false) { + const code = typeof result.test.code === "string" && /^[A-Z][A-Z0-9_]{0,127}$/.test(result.test.code) + ? result.test.code + : cliMessage(locale, "common.unknown"); + lines.push(cliMessage(locale, "provider.add.testFailed", { code })); + } + if (result?.initialActivation?.automatic === true) { + lines.push(cliMessage(locale, "provider.test.initialSelected")); + } + stdout(`${lines.join("\n")}\n`); +} + +function writeHumanProviderTestResult(result, locale, stdout) { + const lines = [cliMessage(locale, "provider.test.completed")]; + if (result?.result?.ok === true) { + lines.push(cliMessage(locale, "provider.test.passed")); + } else { + const code = typeof result?.result?.code === "string" + && /^[A-Z][A-Z0-9_]{0,127}$/.test(result.result.code) + ? result.result.code + : cliMessage(locale, "common.unknown"); + lines.push(cliMessage(locale, "provider.test.failedCode", { code })); + } + if (result?.result?.initialActivation?.automatic === true) { + lines.push(cliMessage(locale, "provider.test.initialSelected")); + } + stdout(`${lines.join("\n")}\n`); +} + +function terminalSafeText(value, { maxCodePoints = 160, fallback = "" } = {}) { + if (typeof value !== "string" || value.length === 0) return fallback; + const codePoints = Array.from(value); + const truncated = codePoints.length > maxCodePoints; + let output = ""; + for (const character of codePoints.slice(0, maxCodePoints)) { + if (character === "\\") { + output += "\\\\"; + continue; + } + if (character === "\"") { + output += "\\\""; + continue; + } + if (character === "\b") { + output += "\\b"; + continue; + } + if (character === "\f") { + output += "\\f"; + continue; + } + if (character === "\n") { + output += "\\n"; + continue; + } + if (character === "\r") { + output += "\\r"; + continue; + } + if (character === "\t") { + output += "\\t"; + continue; + } + const codePoint = character.codePointAt(0); + if (codePoint <= 0x1f + || (codePoint >= 0x7f && codePoint <= 0x9f) + || (codePoint >= 0xd800 && codePoint <= 0xdfff) + || codePoint === 0x061c + || (codePoint >= 0x200b && codePoint <= 0x200f) + || codePoint === 0x2028 || codePoint === 0x2029 + || (codePoint >= 0x202a && codePoint <= 0x202e) + || (codePoint >= 0x2060 && codePoint <= 0x206f) + || codePoint === 0xfeff) { + output += `\\u${codePoint.toString(16).padStart(4, "0")}`; + continue; + } + output += character; + } + return `${output}${truncated ? "..." : ""}`; +} + +function humanInteger(value, locale, { positive = false } = {}) { + if (!Number.isSafeInteger(value) || (positive ? value <= 0 : value < 0)) { + return cliMessage(locale, "common.unknown"); + } + return String(value); +} + +function humanBoolean(value, locale) { + if (value === true) return cliMessage(locale, "common.yes"); + if (value === false) return cliMessage(locale, "common.no"); + return cliMessage(locale, "common.unknown"); +} + +function humanWorkerPhase(value, locale) { + if (["running", "stopped", "starting", "draining", "failed", "crashed", "backoff"].includes(value)) { + return cliMessage(locale, `status.state.${value}`); + } + return cliMessage(locale, "common.unknown"); +} + +function humanPublicBaseUrl(value, locale) { + if (typeof value !== "string") return cliMessage(locale, "common.unknown"); + try { + const parsed = new URL(value); + return terminalSafeText(`${parsed.origin}${parsed.pathname}`, { + maxCodePoints: 320, + fallback: cliMessage(locale, "common.unknown") + }); + } catch { + return cliMessage(locale, "common.unknown"); + } +} + +function humanIsoTimestamp(value, locale) { + if (typeof value !== "string") return cliMessage(locale, "common.unknown"); + try { + if (new Date(value).toISOString() !== value) return cliMessage(locale, "common.unknown"); + } catch { + return cliMessage(locale, "common.unknown"); + } + return value; +} + +function humanProviderTest(provider, locale) { + if (provider?.lastTestStatus === "untested") return cliMessage(locale, "provider.test.untested"); + if (provider?.lastTestStatus === "passed") return cliMessage(locale, "provider.test.passed"); + if (provider?.lastTestStatus === "failed") { + return typeof provider.lastTestCode === "string" + && /^[A-Z][A-Z0-9_]{0,127}$/.test(provider.lastTestCode) + ? cliMessage(locale, "provider.test.failedCode", { code: provider.lastTestCode }) + : cliMessage(locale, "provider.test.failed"); + } + return cliMessage(locale, "common.unknown"); +} + +function humanProviderModel(provider, locale) { + if (provider?.modelMode === "passthrough") { + return cliMessage(locale, "provider.model.passthrough"); + } + if (provider?.modelMode === "override") { + return cliMessage(locale, "provider.model.override", { + model: terminalSafeText(provider.modelOverride, { + maxCodePoints: 160, + fallback: cliMessage(locale, "common.unknown") + }) + }); + } + return cliMessage(locale, "common.unknown"); +} + +function humanCredentialState(value, locale) { + if (value === true) return cliMessage(locale, "provider.credential.configured"); + if (value === false) return cliMessage(locale, "provider.credential.notConfigured"); + return cliMessage(locale, "common.unknown"); +} + +function writeHumanProviderList(providers, activeProviderId, locale, stdout) { + const safeProviders = Array.isArray(providers) ? providers : []; + const lines = [cliMessage(locale, "provider.list.header", { count: safeProviders.length })]; + if (safeProviders.length === 0) { + lines.push(cliMessage(locale, "provider.list.empty")); + stdout(`${lines.join("\n")}\n`); + return; + } + + for (const provider of safeProviders) { + const id = terminalSafeText(provider?.id, { + maxCodePoints: 128, + fallback: cliMessage(locale, "common.unknown") + }); + const name = terminalSafeText(provider?.name, { + maxCodePoints: 120, + fallback: cliMessage(locale, "common.unknown") + }); + const isActive = typeof provider?.id === "string" && provider.id === activeProviderId; + lines.push(cliMessage(locale, isActive ? "provider.list.active" : "provider.list.inactive", { name })); + lines.push(cliMessage(locale, "provider.list.id", { value: id })); + lines.push(cliMessage(locale, "provider.list.baseUrl", { + value: humanPublicBaseUrl(provider?.baseUrl, locale) + })); + lines.push(cliMessage(locale, "provider.list.test", { + value: humanProviderTest(provider, locale) + })); + lines.push(cliMessage(locale, "provider.list.model", { + value: humanProviderModel(provider, locale) + })); + lines.push(cliMessage(locale, "provider.list.credential", { + value: humanCredentialState(provider?.credentialConfigured, locale) + })); + } + stdout(`${lines.join("\n")}\n`); +} + +function writeHumanProviderModels(result, selector, locale, stdout) { + const catalog = result?.modelCatalog && typeof result.modelCatalog === "object" + ? result.modelCatalog + : result; + const provider = result?.provider && typeof result.provider === "object" + ? result.provider + : {}; + const models = Array.isArray(catalog?.models) + ? catalog.models.filter((model) => typeof model === "string").slice(0, 2_000) + : []; + const unknown = cliMessage(locale, "common.unknown"); + const providerId = terminalSafeText( + provider.id ?? catalog?.providerId ?? (selector?.type === "id" ? selector.value : null), + { maxCodePoints: 128, fallback: unknown } + ); + const providerName = terminalSafeText( + provider.name ?? (selector?.type === "name" ? selector.value : null), + { maxCodePoints: 120, fallback: unknown } + ); + const lines = [cliMessage(locale, "provider.models.header", { + name: providerName, + id: providerId, + count: models.length + })]; + if (models.length === 0) { + lines.push(cliMessage(locale, "provider.models.empty")); + } else { + for (const model of models.slice(0, 20)) { + lines.push(cliMessage(locale, "provider.models.item", { + model: terminalSafeText(model, { maxCodePoints: 160, fallback: unknown }) + })); + } + if (models.length > 20) { + lines.push(cliMessage(locale, "provider.models.more", { count: models.length - 20 })); + } + } + stdout(`${lines.join("\n")}\n`); +} + +function writeHumanStatus(payload, locale, stdout) { + if (payload.running !== true) { + stdout(`${cliMessage(locale, "status.notRunning")}\n`); + return; + } + const unknown = cliMessage(locale, "common.unknown"); + const supervisor = payload.supervisor && typeof payload.supervisor === "object" + ? payload.supervisor + : {}; + const worker = payload.worker && typeof payload.worker === "object" ? payload.worker : {}; + const workerState = worker.state && typeof worker.state === "object" ? worker.state : {}; + const activeProvider = payload.activeProvider && typeof payload.activeProvider === "object" + ? payload.activeProvider + : null; + const codex = payload.codex && typeof payload.codex === "object" ? payload.codex : {}; + const lines = [ + cliMessage(locale, "status.header"), + cliMessage(locale, "status.supervisor", { + state: cliMessage(locale, "status.state.running") + }), + cliMessage(locale, "status.pid", { + value: humanInteger(supervisor.pid, locale, { positive: true }) + }), + cliMessage(locale, "status.startedAt", { + value: humanIsoTimestamp(supervisor.startedAt, locale) + }), + cliMessage(locale, "status.worker", { state: humanWorkerPhase(worker.phase, locale) }), + cliMessage(locale, "status.pid", { + value: humanInteger(worker.pid, locale, { positive: true }) + }), + cliMessage(locale, "status.generation", { + value: humanInteger(worker.generation, locale) + }), + cliMessage(locale, "status.listening", { + value: humanBoolean(workerState.listening, locale) + }), + cliMessage(locale, "status.inFlight", { + value: humanInteger(workerState.inFlight, locale) + }) + ]; + + const activeId = typeof activeProvider?.id === "string" + ? activeProvider.id + : payload.activeProviderId; + if (typeof activeId === "string" && activeId.length > 0) { + lines.push(cliMessage(locale, "status.activeProvider", { + name: terminalSafeText(activeProvider?.name, { maxCodePoints: 120, fallback: unknown }), + id: terminalSafeText(activeId, { maxCodePoints: 128, fallback: unknown }) + })); + } else { + lines.push(cliMessage(locale, "status.activeProviderNone")); + } + let codexState = unknown; + if (codex.configured === true) codexState = cliMessage(locale, "status.state.configured"); + if (codex.configured === false) codexState = cliMessage(locale, "status.state.notConfigured"); + lines.push(cliMessage(locale, "status.codex", { state: codexState })); + lines.push(cliMessage(locale, "status.historyRepairPending", { + value: humanBoolean(codex.historyRepairPending, locale) + })); + lines.push(cliMessage(locale, "status.modelProvider", { + value: codex.modelProvider === "OpenAI" ? "OpenAI" : unknown + })); + lines.push(cliMessage(locale, "status.proxyUrl", { + value: codex.proxyUrl === "http://127.0.0.1:15100" ? codex.proxyUrl : unknown + })); + stdout(`${lines.join("\n")}\n`); +} + +export function openManagementUrl(url, { + platform = process.platform, + spawnImpl = spawn +} = {}) { + let parsed; + try { + parsed = new URL(url); + } catch { + throw new Error("The local management URL is invalid."); + } + if (parsed.protocol !== "http:" || parsed.hostname !== "127.0.0.1" + || !parsed.port || parsed.pathname !== "/" || parsed.search + || !/^#token=[A-Za-z0-9_-]{43}$/.test(parsed.hash)) { + throw new Error("The local management URL is invalid."); + } + let command; + let args; + if (platform === "darwin") { + command = "open"; + args = [url]; + } else if (platform === "win32") { + command = "cmd"; + args = ["/d", "/s", "/c", "start", "", url]; + } else { + command = "xdg-open"; + args = [url]; + } + const child = spawnImpl(command, args, { + detached: true, + stdio: "ignore", + shell: false, + windowsHide: true + }); + child.once?.("error", () => {}); + child.unref(); + return child; +} + +function parseProviderOptions(argv, locale) { + const action = argv[1]; + if (!PROVIDER_ACTIONS.has(action)) { + throw cliInputError("validation.providerAction"); + } + const { options } = parseCommandLine(["provider", ...argv.slice(2)], locale); + const allowed = { + list: new Set(["json"]), + add: new Set([ + "json", + "name", + "base-url", + "api-key", + "model", + "auth-header", + "auth-scheme", + "model-mode", + "model-override" + ]), + models: new Set(["json", "id", "name"]), + test: new Set(["json", "id", "name", "model"]), + activate: new Set(["json", "id", "name"]), + delete: new Set(["json", "id", "name"]) + }[action]; + if (Object.keys(options).some((field) => !allowed.has(field))) { + throw cliInputError("validation.providerOption"); + } + if (["models", "test", "activate", "delete"].includes(action)) { + providerSelector(options, locale); + } + if (action === "test") requiredOption(options, "model", locale); + if (action === "add" && Object.hasOwn(options, "model")) { + requiredOption(options, "model", locale); + } + return { action, options }; +} + +function requiredOption(options, name, locale) { + const value = options[name]; + if (typeof value !== "string" || value.trim().length === 0) { + throw cliInputError("validation.providerRequired", { name }); + } + return value.trim(); +} + +function providerSelector(options, locale) { + const hasId = Object.hasOwn(options, "id"); + const hasName = Object.hasOwn(options, "name"); + if (hasId === hasName) throw cliInputError("validation.providerSelector"); + return hasId + ? { type: "id", value: requiredOption(options, "id", locale) } + : { type: "name", value: requiredOption(options, "name", locale) }; +} + +function providerNotFoundError() { + return new CrpError( + "PROVIDER_NOT_FOUND", + "The provider does not exist.", + "Run provider list and try again.", + { status: 404 } + ); +} + +async function resolveProviderSelector(client, selector) { + if (selector.type === "id") return selector.value; + const response = await client.request("GET", "/providers"); + const expected = selector.value.toLowerCase(); + const matches = Array.isArray(response?.providers) + ? response.providers.filter((provider) => ( + typeof provider?.name === "string" && provider.name.toLowerCase() === expected + )) + : []; + if (matches.length !== 1 + || typeof matches[0]?.id !== "string" || matches[0].id.length === 0) { + throw providerNotFoundError(); + } + const providerId = matches[0].id; + const current = await client.request( + "GET", + `/providers/${encodeURIComponent(providerId)}` + ); + if (current?.provider?.id !== providerId + || typeof current.provider.name !== "string" + || current.provider.name.toLowerCase() !== expected) { + throw providerNotFoundError(); + } + return providerId; +} + +function providerAddTestFailed(cause) { + const degraded = cause instanceof CrpError + && cause.details?.committed === true + && cause.details?.degraded === true; + const safeCauseAction = typeof cause?.action === "string" + && cause.action.length > 0 && cause.action.length <= 512 + && !/[\u0000-\u001f\u007f]/.test(cause.action) + ? cause.action + : null; + const error = degraded + ? new CrpError( + "PROVIDER_ADD_TEST_COMMITTED_DEGRADED", + "The provider was added and its test result was saved, but persistence degraded.", + safeCauseAction ?? "Repair CRP persistence before retrying the provider test.", + { + status: 500, + cause, + details: { committed: true, degraded: true } + } + ) + : new CrpError( + "PROVIDER_ADD_TEST_FAILED", + "The provider was added, but its automatic compatibility test could not be completed.", + "Run provider list, then retry provider test for the saved provider.", + { status: 500, cause, details: { committed: true } } + ); + if (typeof cause?.requestId === "string" + && /^[A-Za-z0-9_-]{1,128}$/.test(cause.requestId)) { + error.requestId = cause.requestId; + } + return error; +} + +async function dispatchProviderCommand(argv, dependencies) { + const { action, options } = parseProviderOptions(argv, dependencies.locale); + let addRequest = null; + let addTestModel = null; + let selector = null; + if (action === "add") { + const provider = { + name: requiredOption(options, "name", dependencies.locale), + baseUrl: requiredOption(options, "base-url", dependencies.locale) + }; + for (const [option, field] of [ + ["auth-header", "authHeader"], + ["auth-scheme", "authScheme"], + ["model-mode", "modelMode"], + ["model-override", "modelOverride"] + ]) { + if (typeof options[option] === "string") provider[field] = options[option]; + } + addRequest = { + provider, + credential: requiredOption(options, "api-key", dependencies.locale) + }; + if (Object.hasOwn(options, "model")) { + addTestModel = requiredOption(options, "model", dependencies.locale); + } + } else if (action !== "list") { + selector = providerSelector(options, dependencies.locale); + } + + const context = await dependencies.ensureSupervisorImpl({ + paths: dependencies.paths, + adminPort: dependencies.adminPort + }); + let result; + if (action === "list") { + result = await context.client.request("GET", "/providers"); + } else if (action === "add") { + result = await context.client.request("POST", "/providers", addRequest); + if (addTestModel !== null) { + const providerId = result?.provider?.id; + if (typeof providerId !== "string" || providerId.length === 0) { + throw providerAddTestFailed(new Error("provider identity missing after create")); + } + let tested; + try { + tested = await context.client.request( + "POST", + `/providers/${encodeURIComponent(providerId)}/test`, + { model: addTestModel, activateIfNone: true } + ); + } catch (error) { + throw providerAddTestFailed(error); + } + result = { + ...result, + test: { + ok: tested?.result?.ok === true, + code: typeof tested?.result?.code === "string" ? tested.result.code : null + }, + ...(tested?.result?.initialActivation + ? { initialActivation: tested.result.initialActivation } + : {}) + }; + } + } else { + const providerId = await resolveProviderSelector(context.client, selector); + const encodedId = encodeURIComponent(providerId); + if (action === "test") { + result = await context.client.request("POST", `/providers/${encodedId}/test`, { + model: requiredOption(options, "model", dependencies.locale), + activateIfNone: true + }); + } else if (action === "activate") { + result = await context.client.request("POST", `/providers/${encodedId}/activate`); + } else if (action === "models") { + result = await context.client.request("POST", `/providers/${encodedId}/models`); + } else { + result = await context.client.request("DELETE", `/providers/${encodedId}`); + } + } + const payload = { + ok: true, + action, + ...result + }; + if (action === "list" && options.json !== true) { + writeHumanProviderList( + result?.providers, + context.status?.activeProviderId ?? null, + dependencies.locale, + dependencies.stdout + ); + } else if (action === "models" && options.json !== true) { + writeHumanProviderModels(result, selector, dependencies.locale, dependencies.stdout); + } else if (action === "add" && options.json !== true) { + writeHumanProviderAdd(result, dependencies.locale, dependencies.stdout); + } else if (action === "test" && options.json !== true) { + writeHumanProviderTestResult(result, dependencies.locale, dependencies.stdout); + } else { + writePayload( + options, + payload, + dependencies.stdout, + cliMessage(dependencies.locale, `provider.${action}.completed`) + ); + } +} + +function parseSupervisorOptions(argv, locale) { + const { command, options } = parseCommandLine(argv, locale); + const allowed = { + ui: new Set(["json", "no-open"]), + start: new Set(["json"]), + status: new Set(["json"]), + stop: new Set(["json"]), + restart: new Set(["json"]), + shutdown: new Set(["json"]) + }[command]; + if (Object.keys(options).some((field) => !allowed.has(field))) { + throw cliInputError("validation.commandOption", { command }); + } + return { command, options }; +} + +function parseCaptureOptions(argv, locale) { + const action = argv[1]; + if (!["on", "off", "status"].includes(action)) { + throw cliInputError("validation.captureAction"); + } + const { options } = parseCommandLine(["capture", ...argv.slice(2)], locale); + if (Object.keys(options).some((field) => field !== "json")) { + throw cliInputError("validation.captureOption"); + } + return { action, options }; +} + +async function dispatchSupervisorCommand(argv, dependencies) { + const supervisorCommands = new Set([ + "ui", + "start", + "status", + "stop", + "restart", + "shutdown", + "provider" + ]); + if (!supervisorCommands.has(argv[0])) return false; + if (argv[0] === "provider") { + await dispatchProviderCommand(argv, dependencies); + return true; + } + const { command, options } = parseSupervisorOptions(argv, dependencies.locale); + const { + paths, + adminPort, + ensureSupervisorImpl, + discoverSupervisorImpl, + stdout, + killProcess, + isProcessAliveImpl, + wait, + now, + shutdownTimeoutMs, + readControlTokenImpl, + openManagementUrlImpl, + readSupervisorStateImpl, + readSupervisorStateSnapshotImpl, + removeStaleSupervisorStateImpl + } = dependencies; + const discoveryOptions = { paths, adminPort }; + + if (command === "ui") { + const context = await ensureSupervisorImpl(discoveryOptions); + const token = readControlTokenImpl({ path: paths.controlTokenPath }); + const url = `${context.origin}/#token=${token}`; + const opened = options["no-open"] !== true; + if (opened) openManagementUrlImpl(url); + writePayload(options["no-open"] === true ? { ...options, json: true } : options, { + ok: true, + opened, + origin: context.origin, + supervisorPid: context.state.supervisorPid, + url + }, stdout, opened ? cliMessage(dependencies.locale, "ui.opened") : url); + return true; + } + + if (command === "status") { + const context = await discoverSupervisorImpl(discoveryOptions); + const payload = context === null + ? { ok: true, running: false, reason: "supervisor_not_running" } + : { ok: true, running: true, ...context.status }; + if (options.json) { + writePayload(options, payload, stdout, ""); + } else { + writeHumanStatus(payload, dependencies.locale, stdout); + } + return true; + } + + if (command === "stop") { + const context = await discoverSupervisorImpl(discoveryOptions); + if (context === null) { + writePayload(options, { + ok: true, + stopped: false, + reason: "supervisor_not_running" + }, stdout, cliMessage(dependencies.locale, "stop.notRunning")); + return true; + } + const result = await context.client.request("POST", "/proxy/stop"); + const payload = { + ok: true, + stopped: result?.worker?.phase === "stopped", + worker: result?.worker ?? null + }; + writePayload(options, payload, stdout, cliMessage(dependencies.locale, "stop.completed")); + return true; + } + + if (command === "shutdown") { + const stateSnapshot = readSupervisorStateSnapshotImpl({ + path: paths.statePath, + adminPort + }); + const context = await discoverSupervisorImpl(discoveryOptions); + if (context === null) { + let cleanupSnapshot = stateSnapshot; + let staleState = readSupervisorStateImpl({ path: paths.statePath, adminPort }); + if (staleState === null && !existsSync(paths.statePath)) { + const claimPath = `${paths.statePath}.stale`; + cleanupSnapshot ??= readSupervisorStateSnapshotImpl({ + path: claimPath, + adminPort + }); + staleState = readSupervisorStateImpl({ path: claimPath, adminPort }); } - const key = token.slice(2); - const next = argv[index + 1]; - if (!next || next.startsWith("--")) { - options[key] = true; - continue; + let staleStateRemoved = false; + if (cleanupSnapshot !== null && staleState !== null) { + const staleWorkerPid = Number.isSafeInteger(staleState.worker?.pid) + ? staleState.worker.pid + : null; + if (isProcessAliveImpl(staleState.supervisorPid) + || staleWorkerPid !== null && isProcessAliveImpl(staleWorkerPid)) { + throw shutdownCliError( + "SUPERVISOR_SHUTDOWN_UNAVAILABLE", + "shutdown.unavailable", + { + details: { + processStopped: false, + stateRemoved: false + } + } + ); + } + const cleanup = removeStaleSupervisorStateImpl({ + path: paths.statePath, + expectedSnapshot: cleanupSnapshot, + adminPort, + isProcessAlive: isProcessAliveImpl + }); + staleStateRemoved = cleanup?.removed === true; + if (!staleStateRemoved && cleanup?.reason !== "state_missing") { + throw shutdownCliError( + "SUPERVISOR_STATE_CLEANUP_FAILED", + "shutdown.stateTimeout", + { + details: { + reason: cleanup?.reason ?? "cleanup_failed", + processStopped: true, + stateRemoved: false + } + } + ); + } + } + writePayload(options, { + ok: true, + shutdown: false, + reason: "supervisor_not_running", + ...(staleStateRemoved ? { staleStateRemoved: true } : {}) + }, stdout, cliMessage( + dependencies.locale, + staleStateRemoved ? "shutdown.notRunningStaleRemoved" : "shutdown.notRunning" + )); + return true; + } + const supervisorPid = context.state.supervisorPid; + const startedAt = context.state.startedAt; + const currentState = readSupervisorStateImpl({ path: paths.statePath, adminPort }); + if (stateSnapshot === null || !sameSupervisorIdentity(currentState, context.state)) { + throw shutdownIdentityError(); + } + const request = { supervisorPid, startedAt }; + let forced = false; + try { + const accepted = await context.client.request( + "POST", + "/supervisor/shutdown", + request, + { expectedStatus: 202 } + ); + if (!validShutdownAcceptance(accepted, request)) { + throw shutdownCliError( + "SUPERVISOR_SHUTDOWN_RESPONSE_INVALID", + "shutdown.unavailable" + ); + } + } catch (error) { + if (error?.code === "SUPERVISOR_IDENTITY_CHANGED") throw shutdownIdentityError(); + if (!SHUTDOWN_FORCE_FALLBACK_CODES.has(error?.code)) throw error; + const fallbackState = readSupervisorStateImpl({ path: paths.statePath, adminPort }); + if (!sameSupervisorIdentity(fallbackState, context.state)) throw shutdownIdentityError(); + const latestState = readSupervisorStateImpl({ path: paths.statePath, adminPort }); + if (!sameSupervisorIdentity(latestState, context.state)) throw shutdownIdentityError(); + try { + killProcess(supervisorPid, "SIGTERM"); + } catch (cause) { + throw shutdownCliError( + "SUPERVISOR_SHUTDOWN_UNAVAILABLE", + "shutdown.unavailable", + { cause } + ); + } + forced = true; + } + + const workerPid = Number.isSafeInteger(context.status?.worker?.pid) + && context.status.worker.pid > 0 + ? context.status.worker.pid + : null; + const deadline = now() + shutdownTimeoutMs; + while ((isProcessAliveImpl(supervisorPid) + || workerPid !== null && isProcessAliveImpl(workerPid)) && now() < deadline) { + await wait(Math.min(100, deadline - now())); + } + const supervisorStopped = !isProcessAliveImpl(supervisorPid); + const workerStopped = workerPid === null || !isProcessAliveImpl(workerPid); + if (!supervisorStopped || !workerStopped) { + throw shutdownCliError("SUPERVISOR_SHUTDOWN_TIMEOUT", "shutdown.timeout", { + details: { + forced, + graceful: !forced, + processStopped: supervisorStopped && workerStopped, + stateRemoved: !existsSync(paths.statePath) + } + }); + } + + let recoveredStaleState = false; + if (existsSync(paths.statePath)) { + const cleanup = removeStaleSupervisorStateImpl({ + path: paths.statePath, + expectedSnapshot: stateSnapshot, + adminPort, + isProcessAlive: isProcessAliveImpl + }); + recoveredStaleState = cleanup?.removed === true; + if (!recoveredStaleState && cleanup?.reason !== "state_missing") { + throw shutdownCliError( + "SUPERVISOR_STATE_CLEANUP_FAILED", + "shutdown.stateTimeout", + { + details: { + reason: cleanup?.reason ?? "cleanup_failed", + forced, + graceful: false, + processStopped: true, + stateRemoved: false + } + } + ); } - options[key] = next; - index += 1; } - return await captureCommand(options, action); + const stateRemoved = !existsSync(paths.statePath); + const degraded = !forced && recoveredStaleState; + const humanMessageKey = forced + ? "shutdown.forcedCompleted" + : degraded + ? "shutdown.degradedCompleted" + : "shutdown.completed"; + writePayload(options, { + ok: true, + shutdown: true, + graceful: !forced && !degraded, + forced, + degraded, + supervisorPid, + workerStopped, + stateRemoved + }, stdout, cliMessage(dependencies.locale, humanMessageKey)); + return true; } - const { command, options } = parseCommandLine(argv); - if (command === "check") return checkCommand(options); - if (command === "init") return await initCommand(options); - if (command === "guide") return guideCommand(options); - if (command === "start" || command === "install" || command === "setup") return await startCommandAction(options); + let context; + try { + context = await ensureSupervisorImpl(discoveryOptions); + } catch (error) { + if (command === "start") { + throw withCliStage(error, "supervisor_start"); + } + throw error; + } + let codexBootstrap = null; + if (context.status?.codex?.configured !== true + || context.status?.codex?.historyRepairPending === true) { + try { + const bootstrap = await context.client.request( + "POST", + "/codex/bootstrap", + undefined, + { requestTimeoutMs: CODEX_BOOTSTRAP_REQUEST_TIMEOUT_MS } + ); + codexBootstrap = bootstrap?.result ?? null; + } catch (error) { + throw withCliStage(error, "codex_bootstrap"); + } + } + if (command === "restart") { + const result = await context.client.request("POST", "/proxy/restart"); + const payload = { + ok: true, + command: "restart", + supervisorPid: context.state.supervisorPid, + worker: result?.worker ?? null, + ...(codexBootstrap === null ? {} : { codexBootstrap }) + }; + const humanMessage = codexBootstrap?.historyRepair?.encryptedContentDetected === true + ? `${cliMessage(dependencies.locale, "restart.completed")}\n${cliMessage( + dependencies.locale, + "start.historyRepairEncryptedWarning" + )}` + : cliMessage(dependencies.locale, "restart.completed"); + writePayload(options, payload, stdout, humanMessage); + return true; + } + let result; + try { + result = await context.client.request("POST", "/proxy/start"); + } catch (error) { + throw withCliStage(error, "proxy_start"); + } + const payload = { + ok: true, + command: "start", + implementation: "node", + supervisorPid: context.state.supervisorPid, + proxyUrl: context.status?.codex?.proxyUrl ?? "http://127.0.0.1:15100", + worker: result?.worker ?? null, + codexBootstrap + }; + const humanMessage = codexBootstrap?.historyRepair?.encryptedContentDetected === true + ? `${cliMessage(dependencies.locale, "start.ready")}\n${cliMessage( + dependencies.locale, + "start.historyRepairEncryptedWarning" + )}` + : cliMessage(dependencies.locale, "start.ready"); + writePayload(options, payload, stdout, humanMessage); + return true; +} + +async function main(argv = process.argv.slice(2), { + locale = "en", + stdout = (text) => process.stdout.write(text) +} = {}) { + if (argv[0] === "capture") { + const { action, options } = parseCaptureOptions(argv, locale); + return await captureCommand(options, action, locale, stdout); + } + + const { command, options } = parseCommandLine(argv, locale); + if (command === "check") return checkCommand(options, locale, stdout); + if (command === "guide") return guideCommand(options, locale, stdout); + if (command === "start") return await startCommandAction(options); if (command === "status") return await statusCommand(options); if (command === "stop") return await stopCommand(options); - if (command === "install-cli") return await installCliCommand(options); - throw new Error(`Unknown command: ${command}`); + if (command === "install-cli") return await installCliCommand(options, locale, stdout); + throw new Error("Unknown command."); +} + +export async function runCli(argv, { + stdout = (text) => process.stdout.write(text), + stderr = (text) => process.stderr.write(text), + paths = getPaths(), + adminPort = 15101, + ensureSupervisorImpl = ensureSupervisor, + discoverSupervisorImpl = discoverSupervisor, + readControlTokenImpl = readControlToken, + readSupervisorStateImpl = readSupervisorState, + readSupervisorStateSnapshotImpl = readSupervisorStateSnapshot, + removeStaleSupervisorStateImpl = removeStaleSupervisorState, + openManagementUrlImpl = (url) => openManagementUrl(url), + killProcess = (pid, signal) => process.kill(pid, signal), + isProcessAlive: isProcessAliveImpl = isProcessAlive, + wait = (milliseconds) => delay(milliseconds), + now = () => Date.now(), + shutdownTimeoutMs = 8_000, + environment = process.env +} = {}) { + let locale = "en"; + const jsonIntent = argv.includes("--json"); + let commandName = safeCommandName(argv); + try { + const resolved = resolveCliLocale(argv); + argv = resolved.argv; + locale = resolved.locale; + commandName = safeCommandName(argv); + const helpRequest = resolveHelpRequest(argv); + if (helpRequest !== null) { + const helpLocale = resolved.explicit ? locale : "en"; + printResolvedHelp(helpRequest, (line) => stdout(`${line}\n`), helpLocale); + return 0; + } + if (REMOVED_CLI_COMMANDS.has(argv[0])) { + throw removedCommandError(argv[0], REMOVED_CLI_COMMANDS.get(argv[0])); + } + const handled = await dispatchSupervisorCommand(argv, { + paths, + adminPort, + ensureSupervisorImpl, + discoverSupervisorImpl, + stdout, + killProcess, + isProcessAliveImpl, + wait, + now, + shutdownTimeoutMs, + readControlTokenImpl, + readSupervisorStateImpl, + readSupervisorStateSnapshotImpl, + removeStaleSupervisorStateImpl, + openManagementUrlImpl, + locale + }); + if (!handled) await main(argv, { locale, stdout }); + return 0; + } catch (error) { + if (jsonIntent) { + stderr(`${JSON.stringify({ + ok: false, + command: commandName, + stage: ["supervisor_start", "codex_bootstrap", "proxy_start"].includes(error?.cliStage) + ? error.cliStage + : null, + error: projectCliError(error) + }, null, 2)}\n`); + } else { + stderr(`${cliMessage(locale, "error.prefix", { + message: humanCliError(error, locale) + })}\n`); + } + return 1; + } +} + +function isDirectExecution(metaUrl = import.meta.url, argv1 = process.argv[1]) { + return typeof argv1 === "string" + && argv1.length > 0 + && resolve(fileURLToPath(metaUrl)) === resolve(argv1); } -try { - await main(); -} catch (error) { - console.error(`Error: ${error.message}`); - process.exit(1); +if (isDirectExecution()) { + process.exitCode = await runCli(process.argv.slice(2)); } diff --git a/node/package-lock.json b/node/package-lock.json index bd23b90..4983332 100644 --- a/node/package-lock.json +++ b/node/package-lock.json @@ -1,20 +1,30 @@ { "name": "@cluic/codex-remote-proxy", - "version": "0.1.3", + "version": "0.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cluic/codex-remote-proxy", - "version": "0.1.3", + "version": "0.2.2", "dependencies": { + "@napi-rs/keyring": "1.3.0", "fzstd": "^0.1.1" }, "bin": { "crp": "bin/crp.mjs" }, "devDependencies": { - "@changesets/cli": "^2.31.0" + "@changesets/cli": "^2.31.0", + "@playwright/test": "1.61.1", + "@types/react": "19.2.17", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "6.0.3", + "lucide-react": "1.24.0", + "react": "19.2.7", + "react-dom": "19.2.7", + "typescript": "7.0.2", + "vite": "8.1.5" }, "engines": { "node": ">=22.13.0" @@ -272,6 +282,40 @@ "prettier": "^2.7.1" } }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@inquirer/external-editor": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", @@ -312,108 +356,1033 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz", "integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==", "dev": true, - "license": "MIT" + "license": "MIT" + }, + "node_modules/@manypkg/find-root/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/@manypkg/get-packages": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@manypkg/get-packages/-/get-packages-1.1.3.tgz", + "integrity": "sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.5.5", + "@changesets/types": "^4.0.1", + "@manypkg/find-root": "^1.1.0", + "fs-extra": "^8.1.0", + "globby": "^11.0.0", + "read-yaml-file": "^1.1.0" + } + }, + "node_modules/@manypkg/get-packages/node_modules/@changesets/types": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@changesets/types/-/types-4.1.0.tgz", + "integrity": "sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@manypkg/get-packages/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/@napi-rs/keyring": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.3.0.tgz", + "integrity": "sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/keyring-darwin-arm64": "1.3.0", + "@napi-rs/keyring-darwin-x64": "1.3.0", + "@napi-rs/keyring-freebsd-x64": "1.3.0", + "@napi-rs/keyring-linux-arm-gnueabihf": "1.3.0", + "@napi-rs/keyring-linux-arm64-gnu": "1.3.0", + "@napi-rs/keyring-linux-arm64-musl": "1.3.0", + "@napi-rs/keyring-linux-riscv64-gnu": "1.3.0", + "@napi-rs/keyring-linux-x64-gnu": "1.3.0", + "@napi-rs/keyring-linux-x64-musl": "1.3.0", + "@napi-rs/keyring-win32-arm64-msvc": "1.3.0", + "@napi-rs/keyring-win32-ia32-msvc": "1.3.0", + "@napi-rs/keyring-win32-x64-msvc": "1.3.0" + } + }, + "node_modules/@napi-rs/keyring-darwin-arm64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-arm64/-/keyring-darwin-arm64-1.3.0.tgz", + "integrity": "sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-darwin-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-1.3.0.tgz", + "integrity": "sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-freebsd-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-1.3.0.tgz", + "integrity": "sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm-gnueabihf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-1.3.0.tgz", + "integrity": "sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-1.3.0.tgz", + "integrity": "sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-1.3.0.tgz", + "integrity": "sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-riscv64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-1.3.0.tgz", + "integrity": "sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-1.3.0.tgz", + "integrity": "sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-1.3.0.tgz", + "integrity": "sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-arm64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-1.3.0.tgz", + "integrity": "sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-ia32-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-1.3.0.tgz", + "integrity": "sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-x64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-1.3.0.tgz", + "integrity": "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/node": { + "version": "25.9.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.0.tgz", + "integrity": "sha512-AOQwYUNolgy3VosiRqXrACUXTN8nJUtPl7FJXMqZVyxiiCLhQuG3jXKvCS1ALr+Y2OmZhzzLVlYPEqJaiqkaJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } }, - "node_modules/@manypkg/find-root/node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": ">=6 <7 || >=8" + "node": ">=16.20.0" } }, - "node_modules/@manypkg/get-packages": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@manypkg/get-packages/-/get-packages-1.1.3.tgz", - "integrity": "sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==", + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.5.5", - "@changesets/types": "^4.0.1", - "@manypkg/find-root": "^1.1.0", - "fs-extra": "^8.1.0", - "globby": "^11.0.0", - "read-yaml-file": "^1.1.0" + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" } }, - "node_modules/@manypkg/get-packages/node_modules/@changesets/types": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@changesets/types/-/types-4.1.0.tgz", - "integrity": "sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw==", + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } }, - "node_modules/@manypkg/get-packages/node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": ">=6 <7 || >=8" + "node": ">=16.20.0" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], "engines": { - "node": ">= 8" + "node": ">=16.20.0" } }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 8" + "node": ">=16.20.0" } }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 8" + "node": ">=16.20.0" } }, - "node_modules/@types/node": { - "version": "25.9.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.0.tgz", - "integrity": "sha512-AOQwYUNolgy3VosiRqXrACUXTN8nJUtPl7FJXMqZVyxiiCLhQuG3jXKvCS1ALr+Y2OmZhzzLVlYPEqJaiqkaJQ==", + "node_modules/@vitejs/plugin-react": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { - "undici-types": ">=7.24.0 <7.24.7" + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } } }, "node_modules/ansi-colors": { @@ -501,6 +1470,13 @@ "node": ">= 8" } }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/detect-indent": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz", @@ -511,6 +1487,16 @@ "node": ">=8" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -628,6 +1614,21 @@ "node": ">=6 <7 || >=8" } }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/fzstd": { "version": "0.1.1", "resolved": "https://registry.npmmirror.com/fzstd/-/fzstd-0.1.1.tgz", @@ -735,67 +1736,338 @@ "node": ">=0.10.0" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-subdir": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-subdir/-/is-subdir-1.2.0.tgz", + "integrity": "sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "better-path-resolve": "1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=0.12.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/is-subdir": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-subdir/-/is-subdir-1.2.0.tgz", - "integrity": "sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==", + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "better-path-resolve": "1.0.0" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=4" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/is-windows": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", - "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=0.10.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "ISC" + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, "node_modules/locate-path": { @@ -818,6 +2090,16 @@ "dev": true, "license": "MIT" }, + "node_modules/lucide-react": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.24.0.tgz", + "integrity": "sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -852,6 +2134,25 @@ "node": ">=4" } }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/outdent": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/outdent/-/outdent-0.5.0.tgz", @@ -991,6 +2292,67 @@ "node": ">=6" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/prettier": { "version": "2.8.8", "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", @@ -1045,6 +2407,29 @@ ], "license": "MIT" }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, "node_modules/read-yaml-file": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/read-yaml-file/-/read-yaml-file-1.1.0.tgz", @@ -1072,9 +2457,9 @@ } }, "node_modules/read-yaml-file/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", + "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", "dev": true, "license": "MIT", "dependencies": { @@ -1106,6 +2491,40 @@ "node": ">=0.10.0" } }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -1137,6 +2556,13 @@ "dev": true, "license": "MIT" }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/semver": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", @@ -1196,6 +2622,16 @@ "node": ">=8" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/spawndamnit": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/spawndamnit/-/spawndamnit-3.0.1.tgz", @@ -1250,6 +2686,54 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -1263,6 +2747,49 @@ "node": ">=8.0" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, "node_modules/undici-types": { "version": "7.24.6", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", @@ -1282,6 +2809,112 @@ "node": ">= 4.0.0" } }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/node/package.json b/node/package.json index 8f469eb..771d958 100644 --- a/node/package.json +++ b/node/package.json @@ -1,7 +1,16 @@ { "name": "@cluic/codex-remote-proxy", "version": "0.2.2", - "description": "Node.js local proxy for Codex remote ChatGPT login patch", + "description": "Local supervisor and Web UI for routing Codex through OpenAI-compatible providers while preserving ChatGPT sign-in", + "license": "MIT", + "keywords": [ + "codex", + "openai", + "chatgpt", + "proxy", + "cli", + "provider" + ], "type": "module", "repository": { "type": "git", @@ -14,6 +23,7 @@ "files": [ "bin/", "src/", + "ui/", "proxy-config.example.json", "README.md" ], @@ -25,7 +35,16 @@ "check": "node bin/crp.mjs check", "status": "node bin/crp.mjs status", "guide": "node bin/crp.mjs guide", - "test": "node --test", + "dev:cli": "node bin/crp.mjs", + "lint": "node scripts/check-source.mjs", + "build:ui": "vite build --config vite.config.mjs", + "typecheck:ui": "tsc -p tsconfig.ui.json", + "verify:ui-build": "node scripts/verify-ui-build.mjs", + "test": "node scripts/run-tests.mjs", + "test:unit": "node scripts/run-test-group.mjs unit", + "test:integration": "node scripts/run-test-group.mjs integration", + "test:e2e": "playwright test", + "test:all": "npm run lint && npm run test && npm run test:e2e", "changeset": "changeset", "version-packages": "changeset version", "release": "changeset publish" @@ -37,9 +56,19 @@ "node": ">=22.13.0" }, "dependencies": { + "@napi-rs/keyring": "1.3.0", "fzstd": "^0.1.1" }, "devDependencies": { - "@changesets/cli": "^2.31.0" + "@changesets/cli": "^2.31.0", + "@playwright/test": "1.61.1", + "@types/react": "19.2.17", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "6.0.3", + "lucide-react": "1.24.0", + "react": "19.2.7", + "react-dom": "19.2.7", + "typescript": "7.0.2", + "vite": "8.1.5" } } diff --git a/node/playwright.config.mjs b/node/playwright.config.mjs new file mode 100644 index 0000000..a509191 --- /dev/null +++ b/node/playwright.config.mjs @@ -0,0 +1,32 @@ +import { defineConfig, devices } from "@playwright/test"; +import { resolve } from "node:path"; + +export default defineConfig({ + testDir: "./test/e2e", + fullyParallel: false, + workers: 1, + reporter: "line", + timeout: 30_000, + expect: { + timeout: 5_000 + }, + outputDir: resolve(import.meta.dirname, "../output/playwright/task11"), + preserveOutput: "always", + use: { + headless: true, + viewport: { width: 1440, height: 900 }, + reducedMotion: "reduce", + screenshot: "only-on-failure", + trace: "off", + video: "off" + }, + projects: [ + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + viewport: { width: 1440, height: 900 } + } + } + ] +}); diff --git a/node/scripts/check-source.mjs b/node/scripts/check-source.mjs new file mode 100644 index 0000000..f35eb36 --- /dev/null +++ b/node/scripts/check-source.mjs @@ -0,0 +1,30 @@ +import { readdirSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const root = fileURLToPath(new URL("..", import.meta.url)); +const roots = [join(root, "bin"), join(root, "src"), join(root, "scripts"), join(root, "ui")]; +const files = []; + +function walk(dir) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) walk(path); + if (entry.isFile() && /\.(mjs|js)$/.test(entry.name)) files.push(path); + } +} + +for (const dir of roots) { + if (statSync(dir, { throwIfNoEntry: false })?.isDirectory()) walk(dir); +} + +for (const file of files.sort()) { + const result = spawnSync(process.execPath, ["--check", file], { encoding: "utf8" }); + if (result.status !== 0) { + process.stderr.write(`${relative(root, file)}\n${result.stderr}`); + process.exit(result.status ?? 1); + } +} + +console.log(`Syntax checked ${files.length} source files.`); diff --git a/node/scripts/native-keyring-smoke.mjs b/node/scripts/native-keyring-smoke.mjs new file mode 100644 index 0000000..73e648d --- /dev/null +++ b/node/scripts/native-keyring-smoke.mjs @@ -0,0 +1,138 @@ +import { randomUUID } from "node:crypto"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { NativeKeyringStore } from "../src/credentials/native-keyring.mjs"; + +class NativeKeyringSmokeError extends Error { + constructor(code, message) { + super(message); + this.name = "NativeKeyringSmokeError"; + this.code = code; + } +} + +function smokeFailed() { + return new NativeKeyringSmokeError( + "NATIVE_KEYRING_SMOKE_FAILED", + "Native keyring smoke failed." + ); +} + +function cleanupFailed() { + return new NativeKeyringSmokeError( + "NATIVE_KEYRING_SMOKE_CLEANUP_FAILED", + "Native keyring smoke cleanup failed." + ); +} + +export function isNativeKeyringSmokeAuthorized(environment = process.env) { + return environment.GITHUB_ACTIONS === "true" + && environment.CRP_NATIVE_KEYRING_SMOKE === "1"; +} + +export async function runNativeKeyringSmoke({ + storeFactory = () => new NativeKeyringStore(), + createRef = () => `crp-ci-${randomUUID()}`, + createSecret = () => `crp-ci-secret-${randomUUID()}-${randomUUID()}`, + writeLine = (line) => process.stdout.write(`${line}\n`) +} = {}) { + let store; + let ref; + let secret; + try { + store = storeFactory(); + ref = createRef(); + secret = createSecret(); + if (!store || typeof ref !== "string" || ref.length === 0 + || typeof secret !== "string" || secret.length === 0) { + throw new TypeError("Invalid smoke dependency"); + } + } catch { + throw smokeFailed(); + } + + let deleted = false; + let failure = null; + try { + await store.set(ref, secret); + if (await store.get(ref) !== secret) { + throw smokeFailed(); + } + if (await store.has(ref) !== true) { + throw smokeFailed(); + } + + let deletion; + try { + deletion = await store.delete(ref); + } catch { + throw cleanupFailed(); + } + if (deletion !== true) { + throw cleanupFailed(); + } + if (await store.has(ref) !== false) { + throw smokeFailed(); + } + deleted = true; + } catch (error) { + failure = error instanceof NativeKeyringSmokeError ? error : smokeFailed(); + } finally { + if (!deleted) { + try { + await store.delete(ref); + if (await store.has(ref) !== false) { + throw cleanupFailed(); + } + } catch { + failure = cleanupFailed(); + } + } + } + + if (failure) { + throw failure; + } + try { + writeLine("Native keyring smoke passed."); + } catch { + throw smokeFailed(); + } + return { ok: true }; +} + +export async function runNativeKeyringSmokeMain({ + environment = process.env, + smokeOptions = {}, + runSmoke = runNativeKeyringSmoke, + writeStdout = (chunk) => process.stdout.write(chunk), + writeStderr = (chunk) => process.stderr.write(chunk) +} = {}) { + if (!isNativeKeyringSmokeAuthorized(environment)) { + writeStderr( + "Native keyring smoke requires an explicitly authorized platform runner.\n" + ); + return 2; + } + + try { + await runSmoke({ + ...smokeOptions, + writeLine: (line) => writeStdout(`${line}\n`) + }); + return 0; + } catch (error) { + const code = error instanceof NativeKeyringSmokeError + ? error.code + : "NATIVE_KEYRING_SMOKE_FAILED"; + writeStderr(`Native keyring smoke failed (${code}).\n`); + return 1; + } +} + +const directInvocation = process.argv[1] + && import.meta.url === pathToFileURL(resolve(process.argv[1])).href; +if (directInvocation) { + process.exitCode = await runNativeKeyringSmokeMain(); +} diff --git a/node/scripts/run-test-group.mjs b/node/scripts/run-test-group.mjs new file mode 100644 index 0000000..89e3ce6 --- /dev/null +++ b/node/scripts/run-test-group.mjs @@ -0,0 +1,44 @@ +import { readdirSync, statSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; + +const group = process.argv[2]; +const supportedGroups = new Set(["unit", "unit-core", "capture", "integration", "core-chain"]); +if (!supportedGroups.has(group)) throw new Error(`Unknown test group: ${group}`); +const testRoot = resolve("test"); +const integrationRoot = join(testRoot, "integration"); +const coreChainPath = join(integrationRoot, "core-real-chain.test.mjs"); +const selectedRoot = group === "integration" || group === "core-chain" ? integrationRoot : testRoot; +const recursive = group === "integration" || group === "core-chain"; + +function collect(dir, descend) { + const files = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory() && descend) files.push(...collect(path, true)); + if (entry.isFile() && entry.name.endsWith(".test.mjs")) files.push(path); + } + return files; +} + +let files = statSync(selectedRoot, { throwIfNoEntry: false })?.isDirectory() + ? collect(selectedRoot, recursive).sort() + : []; +if (group === "unit-core") { + files = files.filter((file) => file !== join(testRoot, "capture-store.test.mjs")); +} +if (group === "capture") { + files = files.filter((file) => file === join(testRoot, "capture-store.test.mjs")); +} +if (group === "integration") { + files = files.filter((file) => file !== coreChainPath); +} +if (group === "core-chain") { + files = files.filter((file) => file === coreChainPath); +} +if (files.length === 0) throw new Error(`No ${group} test files found`); +const testArgs = group === "core-chain" + ? ["--test", "--test-concurrency=1", ...files] + : ["--test", ...files]; +const result = spawnSync(process.execPath, testArgs, { stdio: "inherit" }); +process.exit(result.status ?? 1); diff --git a/node/scripts/run-tests.mjs b/node/scripts/run-tests.mjs new file mode 100644 index 0000000..b10e2dd --- /dev/null +++ b/node/scripts/run-tests.mjs @@ -0,0 +1,11 @@ +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const groupRunner = fileURLToPath(new URL("./run-test-group.mjs", import.meta.url)); + +for (const group of ["unit-core", "capture", "integration", "core-chain"]) { + const result = spawnSync(process.execPath, [groupRunner, group], { stdio: "inherit" }); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} diff --git a/node/scripts/verify-ui-build.mjs b/node/scripts/verify-ui-build.mjs new file mode 100644 index 0000000..6307b6f --- /dev/null +++ b/node/scripts/verify-ui-build.mjs @@ -0,0 +1,102 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdtemp, readFile, readdir, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; +import { gzipSync } from "node:zlib"; + +const execFileAsync = promisify(execFile); +const packageRoot = resolve(import.meta.dirname, ".."); +const committedRoot = join(packageRoot, "ui"); +const expectedFiles = ["app.js", "index.html", "styles.css"]; +const budgets = { + "app.js": 300 * 1024, + "index.html": 20 * 1024, + "styles.css": 50 * 1024 +}; + +const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]"]); + +function assertAllowedAbsoluteUrl(rawUrl, context) { + if (rawUrl === "http://" || rawUrl === "https://" || rawUrl.startsWith("http://$") || rawUrl.startsWith("https://$")) { + return; + } + let parsed; + try { + parsed = new URL(rawUrl); + } catch { + assert.fail(`${context} contains an invalid absolute URL: ${rawUrl}`); + } + assert.ok(LOOPBACK_HOSTS.has(parsed.hostname), `${context} contains a non-loopback absolute URL: ${rawUrl}`); +} + +function assertNoHardcodedRemoteUrls(source, context) { + for (const match of source.matchAll(/https?:\/\/(?:\[[0-9A-Fa-f:]+\]|[A-Za-z0-9._~-]+)(?::\d+)?(?:\/[A-Za-z0-9._~!$&'()*+,;=:@%/?#-]*)?/g)) { + assertAllowedAbsoluteUrl(match[0], context); + } +} + +function assertNoForbiddenMarkup(html) { + assert.doesNotMatch(html, /)/i, "inline style elements are prohibited"); + assert.doesNotMatch(html, /]*\bsrc=)[^>]*>/i, "inline scripts are prohibited"); + assert.doesNotMatch(html, /\son[a-z]+\s*=/i, "inline event handlers are prohibited"); + for (const match of html.matchAll(/\b(?:src|href|action|poster)=["'](https?:\/\/[^"']+)["']/gi)) { + assertAllowedAbsoluteUrl(match[1], "built HTML resource"); + } +} + +async function assertUiSourceUrls(directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) await assertUiSourceUrls(path); + else if (/\.(?:ts|tsx|html)$/.test(entry.name)) { + assertNoHardcodedRemoteUrls(await readFile(path, "utf8"), `UI source ${entry.name}`); + } + } +} + +const temporaryRoot = await mkdtemp(join(tmpdir(), "crp-ui-build-")); +const temporaryOutput = join(temporaryRoot, "ui"); + +try { + await assertUiSourceUrls(join(packageRoot, "ui-src")); + await execFileAsync( + process.execPath, + [ + join(packageRoot, "node_modules", "vite", "bin", "vite.js"), + "build", + "--config", + join(packageRoot, "vite.config.mjs"), + "--outDir", + temporaryOutput, + "--emptyOutDir" + ], + { cwd: packageRoot } + ); + + const actualFiles = (await readdir(temporaryOutput)).sort(); + assert.deepEqual(actualFiles, expectedFiles, "the UI build must contain exactly three files"); + + for (const filename of expectedFiles) { + const [generated, committed] = await Promise.all([ + readFile(join(temporaryOutput, filename)), + readFile(join(committedRoot, filename)) + ]); + assert.deepEqual(generated, committed, `${filename} is not synchronized with ui-src`); + assert.equal((await stat(join(temporaryOutput, filename))).isFile(), true); + assert.ok(gzipSync(generated).length <= budgets[filename], `${filename} exceeds its gzip budget`); + } + + assertNoForbiddenMarkup(await readFile(join(temporaryOutput, "index.html"), "utf8")); + const javascript = await readFile(join(temporaryOutput, "app.js"), "utf8"); + assert.doesNotMatch(javascript, /sourceMappingURL=/, "source maps are prohibited"); + for (const match of javascript.matchAll(/\bfetch\(["'](https?:\/\/[^"']+)["']/g)) { + assertAllowedAbsoluteUrl(match[1], "runtime request"); + } + assert.match(javascript, /SPDX-License-Identifier: MIT AND ISC/, "bundled license notice is required"); +} finally { + await rm(temporaryRoot, { recursive: true, force: true }); +} + +console.log("UI build matches the committed three-file output and size/security policy."); diff --git a/node/src/capture-store.mjs b/node/src/capture-store.mjs index 695dbcd..34c15dc 100644 --- a/node/src/capture-store.mjs +++ b/node/src/capture-store.mjs @@ -1,4 +1,5 @@ -import { watchFile, unwatchFile, readFileSync, mkdirSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { readFileSync, mkdirSync } from "node:fs"; import { dirname, isAbsolute, resolve } from "node:path"; import { DatabaseSync } from "node:sqlite"; @@ -19,6 +20,15 @@ const HEADER_REDACTION_SUBSTRINGS = ["token", "secret", "api-key"]; function defaultLogger() {} +function fingerprintRuntimeConfig(configPath) { + try { + return `sha256:${createHash("sha256").update(readFileSync(configPath)).digest("hex")}`; + } catch (error) { + const code = typeof error?.code === "string" ? error.code : "UNKNOWN"; + return `error:${code}`; + } +} + function resolvePathValue(value, baseDir) { return isAbsolute(value) ? value : resolve(baseDir, value); } @@ -282,17 +292,31 @@ export class CaptureManager { this.lastWriteErrorMessage = null; this.lastErrorAt = null; this.lastErrorMessage = null; + this.started = false; this.closed = false; this.watchTimer = null; + this.watchInterval = null; + this.runtimeConfigFingerprint = null; this.handleRuntimeConfigChange = this.handleRuntimeConfigChange.bind(this); + this.pollRuntimeConfig = this.pollRuntimeConfig.bind(this); } start() { - if (this.desiredConfig.enabled) { - this.enableFromConfig(this.desiredConfig, { source: "startup" }); - } - if (this.watchRuntimeConfig) { - watchFile(this.configPath, { interval: WATCH_INTERVAL_MS }, this.handleRuntimeConfigChange); + if (this.started || this.closed) return this; + this.started = true; + try { + if (this.desiredConfig.enabled) { + this.enableFromConfig(this.desiredConfig, { source: "startup" }); + } + if (this.watchRuntimeConfig) { + this.runtimeConfigFingerprint = fingerprintRuntimeConfig(this.configPath); + this.reloadRuntimeConfig(); + this.watchInterval = setInterval(this.pollRuntimeConfig, WATCH_INTERVAL_MS); + this.watchInterval.unref?.(); + } + } catch (error) { + this.started = false; + throw error; } return this; } @@ -302,9 +326,11 @@ export class CaptureManager { return; } this.closed = true; - if (this.watchRuntimeConfig) { - unwatchFile(this.configPath, this.handleRuntimeConfigChange); + if (this.watchInterval) { + clearInterval(this.watchInterval); + this.watchInterval = null; } + this.runtimeConfigFingerprint = null; if (this.watchTimer) { clearTimeout(this.watchTimer); this.watchTimer = null; @@ -312,6 +338,14 @@ export class CaptureManager { this.closeDatabase(); } + pollRuntimeConfig() { + if (this.closed) return; + const nextFingerprint = fingerprintRuntimeConfig(this.configPath); + if (nextFingerprint === this.runtimeConfigFingerprint) return; + this.runtimeConfigFingerprint = nextFingerprint; + this.handleRuntimeConfigChange(); + } + handleRuntimeConfigChange() { if (this.watchTimer) { clearTimeout(this.watchTimer); diff --git a/node/src/codex/codex-config.mjs b/node/src/codex/codex-config.mjs new file mode 100644 index 0000000..0e4b40d --- /dev/null +++ b/node/src/codex/codex-config.mjs @@ -0,0 +1,1073 @@ +import { createHash, randomUUID } from "node:crypto"; +import { + chmodSync, + closeSync, + constants, + fstatSync, + fsyncSync, + linkSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + rmSync, + statSync, + writeFileSync +} from "node:fs"; +import { basename, dirname, join } from "node:path"; + +import { + hasPendingCodexHistoryRepair, + inspectPendingCodexHistoryRepair, + patchCodexProviderConfigText, + planCodexProviderTransition, + runCodexHistoryRepairTransition +} from "./codex-history-repair.mjs"; + +const STABLE_CONFIG_ERROR_CODES = new Set([ + "CODEX_CONFIG_PARENT_UNSAFE", + "CODEX_CONFIG_BUSY", + "CODEX_CONFIG_CHANGED", + "CODEX_CONFIG_COMMITTED_DEGRADED", + "CODEX_CONFIG_READ_FAILED", + "CODEX_CONFIG_WRITE_FAILED", + "CODEX_HISTORY_REPAIR_INVALID", + "CODEX_HISTORY_REPAIR_CONFLICT", + "CODEX_HISTORY_REPAIR_FAILED", + "CODEX_HISTORY_REPAIR_COMMITTED_DEGRADED" +]); +const TARGET_PROVIDER = "OpenAI"; +const CONFIG_LOCK_SCHEMA_VERSION = 1; +const CONFIG_LOCK_MANAGED_BY = "codex-remote-proxy/config-lock"; +const CONFIG_LOCK_FIELDS = new Set([ + "schemaVersion", + "managedBy", + "owner", + "phase", + "binding" +]); +const CONFIG_LOCK_OWNER_FIELDS = new Set(["pid", "startedAt", "instanceId"]); +const CONFIG_LOCK_BINDING_FIELDS = new Set([ + "operationId", + "sourceConfigSha256", + "targetConfigSha256", + "pendingRequired" +]); +const SAFE_LOCK_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/; +const SHA256_PATTERN = /^[a-f0-9]{64}$/; +const PROCESS_STARTED_AT = new Date( + Date.now() - Math.floor(process.uptime() * 1000) +).toISOString(); +const NO_HISTORY_REPAIR = Object.freeze({ + required: false, + completed: false, + resumed: false, + backupCreated: false, + rolloutFiles: 0, + rolloutRecords: 0, + sqliteFiles: 0, + sqliteRows: 0, + encryptedContentDetected: false +}); +const DEFAULT_HISTORY_REPAIR = Object.freeze({ + plan: planCodexProviderTransition, + hasPending: hasPendingCodexHistoryRepair, + inspectPending: inspectPendingCodexHistoryRepair, + run: runCodexHistoryRepairTransition +}); +const DEFAULT_FILE_OPERATIONS = { + chmodSync, + closeSync, + constants, + fstatSync, + fsyncSync, + linkSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + rmSync, + fsyncDirectorySync: defaultFsyncDirectorySync, + statSync, + writeFileSync +}; + +function defaultFsyncDirectorySync(path) { + if (process.platform === "win32") return; + const directoryFlag = typeof constants.O_DIRECTORY === "number" + ? constants.O_DIRECTORY + : 0; + let descriptor; + try { + descriptor = openSync(path, constants.O_RDONLY | directoryFlag); + const stats = fstatSync(descriptor); + if (!stats.isDirectory()) throw new Error("Directory identity is invalid."); + fsyncSync(descriptor); + } finally { + if (descriptor !== undefined) closeSync(descriptor); + } +} + +function syncDirectory(path, fileOperations) { + if (typeof fileOperations.fsyncDirectorySync !== "function") { + throw new Error("Directory fsync is unavailable."); + } + fileOperations.fsyncDirectorySync(path); +} + +function exactObjectFields(value, fields) { + return value !== null && typeof value === "object" && !Array.isArray(value) + && Object.keys(value).length === fields.size + && Object.keys(value).every((key) => fields.has(key)); +} + +function configSha256(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} + +function createDefaultConfigLockOwner() { + return Object.freeze({ + pid: process.pid, + startedAt: PROCESS_STARTED_AT, + instanceId: randomUUID() + }); +} + +function defaultConfigLockOwnerLiveness(owner) { + if (owner.pid === process.pid && owner.startedAt === PROCESS_STARTED_AT) return "live"; + try { + process.kill(owner.pid, 0); + return "live"; + } catch (error) { + return error?.code === "ESRCH" ? "dead" : "unknown"; + } +} + +function makeBackupStem(configPath, date) { + const timestamp = date.toISOString() + .replace(/[-:]/g, "") + .replace(/\..+/, "") + .replace("T", "-"); + return `${configPath}.${timestamp}`; +} + +function copyBackupExclusively(configPath, source, date, fileOperations) { + const stem = makeBackupStem(configPath, date); + const bytes = source.bytes; + let suffix = 1; + let backupPath = `${stem}.bak`; + + while (true) { + let descriptor; + let identity; + let writtenBytes = Buffer.alloc(0); + try { + descriptor = fileOperations.openSync(backupPath, "wx", source.mode); + identity = fileOperations.fstatSync(descriptor); + if (!identity.isFile()) throw new Error("Backup identity is invalid."); + fileOperations.writeFileSync(descriptor, bytes); + writtenBytes = bytes; + fileOperations.fsyncSync(descriptor); + fileOperations.closeSync(descriptor); + descriptor = undefined; + const backup = readClaimedPath(backupPath, fileOperations); + if (!sameIdentity(backup.identity, identity) || !backup.bytes.equals(bytes)) { + throw new Error("Backup identity changed."); + } + syncDirectory(dirname(backupPath), fileOperations); + return backupPath; + } catch (error) { + if (descriptor !== undefined) { + try { fileOperations.closeSync(descriptor); } catch {} + } + if (identity !== undefined) { + claimOwnedPath(backupPath, identity, fileOperations, { expectedBytes: writtenBytes }); + } + if (error?.code !== "EEXIST") { + throw error; + } + backupPath = `${stem}.${suffix}.bak`; + suffix += 1; + } + } +} + +function createConfigError(code, message, cause) { + const error = new Error(message, { cause }); + error.code = code; + return error; +} + +function configCommittedDegraded(cause) { + const error = createConfigError( + "CODEX_CONFIG_COMMITTED_DEGRADED", + "The Codex configuration was updated, but completion could not be confirmed.", + cause + ); + error.action = "Review the Codex configuration and retry before starting the proxy."; + error.status = 500; + error.details = { committed: true, degraded: true, pending: false }; + return error; +} + +function classifyConfigError(error, phase) { + if (STABLE_CONFIG_ERROR_CODES.has(error?.code)) return error; + return phase === "read" + ? createConfigError( + "CODEX_CONFIG_READ_FAILED", + "Codex configuration could not be read safely.", + error + ) + : createConfigError( + "CODEX_CONFIG_WRITE_FAILED", + "Codex configuration could not be written safely.", + error + ); +} + +function sameIdentity(left, right) { + return left.dev === right.dev && left.ino === right.ino; +} + +function parentUnsafe(cause) { + return createConfigError( + "CODEX_CONFIG_PARENT_UNSAFE", + "The Codex configuration directory is unsafe.", + cause + ); +} + +function ensureConfigParent(configPath, fileOperations) { + const parentPath = dirname(configPath); + let parent; + let created = false; + try { + parent = fileOperations.lstatSync(parentPath); + } catch (error) { + if (error?.code !== "ENOENT") throw parentUnsafe(error); + try { + fileOperations.mkdirSync(parentPath, { mode: 0o700 }); + fileOperations.chmodSync(parentPath, 0o700); + created = true; + } catch (mkdirError) { + if (mkdirError?.code !== "EEXIST") throw mkdirError; + } + try { + parent = fileOperations.lstatSync(parentPath); + } catch (error) { + throw parentUnsafe(error); + } + } + if (parent.isSymbolicLink() || !parent.isDirectory()) { + throw parentUnsafe(); + } + syncDirectory(parentPath, fileOperations); + if (created) syncDirectory(dirname(parentPath), fileOperations); + return { path: parentPath, identity: parent }; +} + +function assertConfigParent(parent, fileOperations) { + let current; + try { + current = fileOperations.lstatSync(parent.path); + } catch (error) { + throw parentUnsafe(error); + } + if (current.isSymbolicLink() + || !current.isDirectory() + || !sameIdentity(current, parent.identity)) { + throw parentUnsafe(); + } +} + +function configChanged(cause) { + return createConfigError( + "CODEX_CONFIG_CHANGED", + "Codex configuration changed during bootstrap.", + cause + ); +} + +function readConfigSource(path, fileOperations, { missing = false } = {}) { + let before; + try { + before = fileOperations.lstatSync(path); + } catch (error) { + if (missing && error?.code === "ENOENT") return null; + throw error; + } + if (!before.isFile() || before.isSymbolicLink()) { + throw createConfigError( + "CODEX_CONFIG_READ_FAILED", + "Codex configuration could not be read safely." + ); + } + + const noFollow = typeof fileOperations.constants.O_NOFOLLOW === "number" + ? fileOperations.constants.O_NOFOLLOW + : 0; + let descriptor; + try { + descriptor = fileOperations.openSync( + path, + fileOperations.constants.O_RDONLY | noFollow + ); + const opened = fileOperations.fstatSync(descriptor); + if (!opened.isFile() || !sameIdentity(before, opened)) { + throw configChanged(); + } + const bytes = Buffer.from(fileOperations.readFileSync(descriptor)); + let after; + try { + after = fileOperations.lstatSync(path); + } catch (error) { + throw configChanged(error); + } + if (!after.isFile() || after.isSymbolicLink() || !sameIdentity(opened, after)) { + throw configChanged(); + } + let text; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch (error) { + throw createConfigError( + "CODEX_CONFIG_READ_FAILED", + "Codex configuration could not be read safely.", + error + ); + } + return { + bytes, + text, + identity: opened, + mode: opened.mode & 0o7777 + }; + } finally { + if (descriptor !== undefined) fileOperations.closeSync(descriptor); + } +} + +function assertCurrentConfig(path, source, fileOperations) { + const current = readConfigSource(path, fileOperations, { missing: true }); + if (current === null + || !sameIdentity(current.identity, source.identity) + || !current.bytes.equals(source.bytes)) { + throw configChanged(); + } +} + +function ensureCanonicalBlocker(path, residualPath, fileOperations) { + try { + fileOperations.lstatSync(path); + return true; + } catch (error) { + if (error?.code !== "ENOENT") return false; + } + + if (residualPath !== null) { + try { + fileOperations.linkSync(residualPath, path); + syncDirectory(dirname(path), fileOperations); + return true; + } catch (error) { + if (error?.code === "EEXIST") return true; + } + } + + let descriptor; + try { + descriptor = fileOperations.openSync(path, "wx", 0o600); + fileOperations.writeFileSync(descriptor, "crp-blocked\n", "utf8"); + fileOperations.fsyncSync(descriptor); + fileOperations.closeSync(descriptor); + descriptor = undefined; + syncDirectory(dirname(path), fileOperations); + return true; + } catch (error) { + if (descriptor !== undefined) { + try { fileOperations.closeSync(descriptor); } catch {} + } + return error?.code === "EEXIST"; + } +} + +function readClaimedPath(path, fileOperations) { + const before = fileOperations.lstatSync(path); + if (!before.isFile() || before.isSymbolicLink()) { + throw new Error("Claimed path is not a regular file."); + } + const noFollow = typeof fileOperations.constants.O_NOFOLLOW === "number" + ? fileOperations.constants.O_NOFOLLOW + : 0; + let descriptor; + try { + descriptor = fileOperations.openSync( + path, + fileOperations.constants.O_RDONLY | noFollow + ); + const opened = fileOperations.fstatSync(descriptor); + if (!opened.isFile() || !sameIdentity(before, opened)) { + throw new Error("Claimed path identity changed."); + } + const bytes = Buffer.from(fileOperations.readFileSync(descriptor)); + const after = fileOperations.lstatSync(path); + if (!after.isFile() || after.isSymbolicLink() || !sameIdentity(opened, after)) { + throw new Error("Claimed path identity changed."); + } + return { identity: opened, bytes }; + } finally { + if (descriptor !== undefined) fileOperations.closeSync(descriptor); + } +} + +function restoreCanonicalBlocker(claimPath, path, fileOperations) { + try { + fileOperations.linkSync(claimPath, path); + syncDirectory(dirname(path), fileOperations); + } catch (error) { + if (error?.code === "EEXIST") return true; + return ensureCanonicalBlocker(path, claimPath, fileOperations); + } + try { + fileOperations.rmSync(claimPath); + } catch { + // The restored canonical hard link remains the blocker. + } + return true; +} + +function claimOwnedPath( + path, + expectedIdentity, + fileOperations, + { expectedBytes = null, missingIsRemoved = true, suffix = "claim" } = {} +) { + const claimPath = join(dirname(path), `.${basename(path)}.${randomUUID()}.${suffix}`); + try { + fileOperations.renameSync(path, claimPath); + syncDirectory(dirname(path), fileOperations); + } catch (error) { + if (error?.code === "ENOENT" && missingIsRemoved) return true; + ensureCanonicalBlocker(path, null, fileOperations); + return false; + } + + let claimed; + try { + claimed = readClaimedPath(claimPath, fileOperations); + } catch { + restoreCanonicalBlocker(claimPath, path, fileOperations); + return false; + } + if (!sameIdentity(claimed.identity, expectedIdentity) + || expectedBytes !== null && !claimed.bytes.equals(expectedBytes)) { + restoreCanonicalBlocker(claimPath, path, fileOperations); + return false; + } + try { + fileOperations.rmSync(claimPath); + syncDirectory(dirname(path), fileOperations); + return true; + } catch { + restoreCanonicalBlocker(claimPath, path, fileOperations); + return false; + } +} + +function validConfigLockOwner(owner) { + return exactObjectFields(owner, CONFIG_LOCK_OWNER_FIELDS) + && Number.isInteger(owner.pid) && owner.pid > 0 && owner.pid <= 0xffffffff + && typeof owner.startedAt === "string" && owner.startedAt.length <= 64 + && !Number.isNaN(Date.parse(owner.startedAt)) + && new Date(owner.startedAt).toISOString() === owner.startedAt + && typeof owner.instanceId === "string" + && SAFE_LOCK_ID_PATTERN.test(owner.instanceId); +} + +function validConfigLockBinding(binding) { + return exactObjectFields(binding, CONFIG_LOCK_BINDING_FIELDS) + && SAFE_LOCK_ID_PATTERN.test(binding.operationId) + && (binding.sourceConfigSha256 === null + || SHA256_PATTERN.test(binding.sourceConfigSha256)) + && SHA256_PATTERN.test(binding.targetConfigSha256) + && typeof binding.pendingRequired === "boolean"; +} + +function configLockBytes(owner, phase = "acquired", binding = null) { + if (!validConfigLockOwner(owner) + || !new Set(["acquired", "prepared", "completed"]).has(phase) + || (binding === null ? phase !== "acquired" : !validConfigLockBinding(binding))) { + throw createConfigError( + "CODEX_CONFIG_WRITE_FAILED", + "Codex configuration lock metadata is invalid." + ); + } + return Buffer.from(`${JSON.stringify({ + schemaVersion: CONFIG_LOCK_SCHEMA_VERSION, + managedBy: CONFIG_LOCK_MANAGED_BY, + owner, + phase, + binding + })}\n`, "utf8"); +} + +function parseConfigLock(source) { + let value; + try { + value = JSON.parse(source.bytes.toString("utf8")); + } catch { + return null; + } + if (!exactObjectFields(value, CONFIG_LOCK_FIELDS) + || value.schemaVersion !== CONFIG_LOCK_SCHEMA_VERSION + || value.managedBy !== CONFIG_LOCK_MANAGED_BY + || !validConfigLockOwner(value.owner) + || !new Set(["acquired", "prepared", "completed"]).has(value.phase) + || (value.binding === null + ? value.phase !== "acquired" + : !validConfigLockBinding(value.binding))) { + return null; + } + return value; +} + +function samePendingAndLockBinding(pending, binding) { + return pending !== null && binding !== null + && pending.operationId === binding.operationId + && pending.sourceConfigSha256 === binding.sourceConfigSha256 + && pending.targetConfigSha256 === binding.targetConfigSha256; +} + +function configHashForRecovery(configPath, fileOperations) { + const source = readConfigSource(configPath, fileOperations, { missing: true }); + return source === null ? null : configSha256(source.bytes); +} + +function staleLockCanBeRecovered({ + document, + pending, + configHash +}) { + if (document.binding === null) return pending === null; + const binding = document.binding; + const sourceMatches = configHash === binding.sourceConfigSha256; + const targetMatches = configHash === binding.targetConfigSha256; + if (document.phase === "completed") { + return targetMatches && (pending === null + || binding.pendingRequired && samePendingAndLockBinding(pending, binding)); + } + if (pending !== null) { + return binding.pendingRequired + && samePendingAndLockBinding(pending, binding) + && (sourceMatches || targetMatches); + } + if (document.phase !== "prepared") return false; + if (!binding.pendingRequired) return sourceMatches || targetMatches; + return sourceMatches; +} + +function busyConfigLock(cause) { + return createConfigError( + "CODEX_CONFIG_BUSY", + "Codex configuration is already being updated.", + cause + ); +} + +function acquireConfigLock(lockPath, fileOperations, { + owner, + ownerLiveness, + pending, + configPath, + initialBinding = null, + allowRecovery = true +}) { + let descriptor; + try { + descriptor = fileOperations.openSync(lockPath, "wx", 0o600); + } catch (error) { + if (error?.code !== "EEXIST") throw error; + if (!allowRecovery) throw busyConfigLock(error); + let existing; + try { + existing = readClaimedPath(lockPath, fileOperations); + } catch (readError) { + throw busyConfigLock(readError); + } + const document = parseConfigLock(existing); + if (document === null) throw busyConfigLock(error); + let liveness = "unknown"; + try { + liveness = ownerLiveness(Object.freeze({ ...document.owner })); + } catch {} + if (!new Set(["live", "dead", "unknown"]).has(liveness)) liveness = "unknown"; + if (liveness !== "dead") throw busyConfigLock(error); + const configHash = configHashForRecovery(configPath, fileOperations); + if (!staleLockCanBeRecovered({ document, pending, configHash })) { + throw createConfigError( + "CODEX_HISTORY_REPAIR_CONFLICT", + "Codex history repair state conflicts with the configuration." + ); + } + if (!claimOwnedPath(lockPath, existing.identity, fileOperations, { + expectedBytes: existing.bytes, + missingIsRemoved: false, + suffix: "stale" + })) { + throw busyConfigLock(error); + } + return acquireConfigLock(lockPath, fileOperations, { + owner, + ownerLiveness, + pending, + configPath, + initialBinding, + allowRecovery: false + }); + } + + let identity; + const phase = initialBinding === null ? "acquired" : "prepared"; + const token = configLockBytes(owner, phase, initialBinding); + try { + identity = fileOperations.fstatSync(descriptor); + fileOperations.writeFileSync(descriptor, token); + fileOperations.fsyncSync(descriptor); + syncDirectory(dirname(lockPath), fileOperations); + return { descriptor, identity, token, owner, phase, binding: initialBinding }; + } catch (error) { + let closed = false; + try { + fileOperations.closeSync(descriptor); + closed = true; + } catch {} + if (identity === undefined || !closed) { + ensureCanonicalBlocker(lockPath, null, fileOperations); + } else if (!claimOwnedPath(lockPath, identity, fileOperations)) { + ensureCanonicalBlocker(lockPath, null, fileOperations); + } + throw error; + } +} + +function assertConfigLockOwned(lockPath, lock, fileOperations) { + const current = readClaimedPath(lockPath, fileOperations); + if (!sameIdentity(current.identity, lock.identity) + || !current.bytes.equals(lock.token)) { + throw createConfigError( + "CODEX_CONFIG_WRITE_FAILED", + "Codex configuration lock ownership changed." + ); + } +} + +function replaceConfigLockMetadata( + lockPath, + lock, + owner, + phase, + binding, + fileOperations +) { + assertConfigLockOwned(lockPath, lock, fileOperations); + if (lock.descriptor !== undefined) { + fileOperations.closeSync(lock.descriptor); + lock.descriptor = undefined; + } + const bytes = configLockBytes(owner, phase, binding); + writeFileAtomically( + lockPath, + bytes, + 0o600, + fileOperations, + () => assertConfigLockOwned(lockPath, lock, fileOperations) + ); + const current = readClaimedPath(lockPath, fileOperations); + if (!current.bytes.equals(bytes)) { + throw createConfigError( + "CODEX_CONFIG_CHANGED", + "Codex configuration lock metadata changed." + ); + } + lock.identity = current.identity; + lock.token = current.bytes; + lock.phase = phase; + lock.binding = binding; +} + +function releaseConfigLock(lockPath, lock, parent, fileOperations) { + let cleanupError; + if (lock.descriptor !== undefined) { + try { + fileOperations.closeSync(lock.descriptor); + lock.descriptor = undefined; + } catch (error) { + cleanupError = error; + } + } + try { + assertConfigParent(parent, fileOperations); + const removed = claimOwnedPath(lockPath, lock.identity, fileOperations, { + expectedBytes: lock.token, + missingIsRemoved: false, + suffix: "release" + }); + if (!removed) { + ensureCanonicalBlocker(lockPath, null, fileOperations); + throw new Error("Codex configuration lock cleanup is uncertain."); + } + } catch (error) { + cleanupError ??= error; + } + if (cleanupError) { + throw cleanupError; + } +} + +function writeFileAtomically( + path, + text, + mode, + fileOperations, + beforePublish, + { exclusive = false } = {} +) { + const tempPath = join( + dirname(path), + `.${basename(path)}.${process.pid}.${randomUUID()}.tmp` + ); + let fileDescriptor; + let tempIdentity; + let writtenBytes = Buffer.alloc(0); + const targetBytes = Buffer.from(text, "utf8"); + + try { + fileDescriptor = fileOperations.openSync(tempPath, "wx", mode); + tempIdentity = fileOperations.fstatSync(fileDescriptor); + fileOperations.writeFileSync(fileDescriptor, text, "utf8"); + writtenBytes = targetBytes; + fileOperations.fsyncSync(fileDescriptor); + fileOperations.closeSync(fileDescriptor); + fileDescriptor = undefined; + const currentTemp = fileOperations.lstatSync(tempPath); + if (!sameIdentity(currentTemp, tempIdentity)) { + throw createConfigError( + "CODEX_CONFIG_CHANGED", + "Codex configuration changed during bootstrap." + ); + } + fileOperations.chmodSync(tempPath, mode); + beforePublish(); + if (exclusive) { + try { + fileOperations.linkSync(tempPath, path); + } catch (error) { + if (error?.code === "EEXIST") { + throw createConfigError( + "CODEX_CONFIG_CHANGED", + "Codex configuration changed during bootstrap.", + error + ); + } + throw error; + } + if (!claimOwnedPath(tempPath, tempIdentity, fileOperations, { + expectedBytes: writtenBytes + })) { + throw new Error("Codex configuration temp cleanup is uncertain."); + } + } else { + fileOperations.renameSync(tempPath, path); + } + syncDirectory(dirname(path), fileOperations); + tempIdentity = undefined; + } catch (error) { + if (fileDescriptor !== undefined) { + try { + fileOperations.closeSync(fileDescriptor); + } catch { + // Preserve the original write failure. + } + } + if (tempIdentity !== undefined) { + try { + claimOwnedPath(tempPath, tempIdentity, fileOperations, { + expectedBytes: writtenBytes + }); + } catch { + // Preserve the original write failure. + } + } + throw error; + } +} + +export function patchCodexConfigText(text, proxyUrl) { + return patchCodexProviderConfigText(text, proxyUrl); +} + +export async function bootstrapCodexConfig({ + configPath, + proxyUrl, + now = () => new Date(), + fileOperations: fileOverrides = DEFAULT_FILE_OPERATIONS, + historyRepair = DEFAULT_HISTORY_REPAIR, + configLockOwner = createDefaultConfigLockOwner(), + configLockOwnerLiveness = defaultConfigLockOwnerLiveness +}) { + const fileOperations = { ...DEFAULT_FILE_OPERATIONS, ...fileOverrides }; + const customFileOperations = fileOverrides !== DEFAULT_FILE_OPERATIONS; + if (!validConfigLockOwner(configLockOwner) + || typeof configLockOwnerLiveness !== "function") { + throw createConfigError( + "CODEX_CONFIG_WRITE_FAILED", + "Codex configuration lock ownership input is invalid." + ); + } + const lockPath = `${configPath}.crp.lock`; + let parent; + let lock; + let primaryError; + let committedWithoutPending = false; + let phase = "write"; + + try { + parent = ensureConfigParent(configPath, fileOperations); + const pendingOptions = { codexRoot: dirname(configPath) }; + if (customFileOperations) pendingOptions.fileOperations = fileOperations; + const inspectedPending = typeof historyRepair.inspectPending === "function" + ? historyRepair.inspectPending(pendingOptions) + : null; + const historyRepairPending = inspectedPending !== null + || historyRepair.hasPending(pendingOptions); + const initialBinding = inspectedPending === null ? null : { + ...inspectedPending, + pendingRequired: true + }; + lock = acquireConfigLock(lockPath, fileOperations, { + owner: configLockOwner, + ownerLiveness: configLockOwnerLiveness, + pending: inspectedPending, + configPath, + initialBinding + }); + assertConfigParent(parent, fileOperations); + phase = "read"; + const source = readConfigSource(configPath, fileOperations, { missing: true }); + const sourceExists = source !== null; + const originalText = source?.text ?? ""; + const patchedText = patchCodexConfigText(originalText, proxyUrl); + const targetBytes = Buffer.from(patchedText, "utf8"); + const transition = historyRepair.plan({ + sourceExists, + sourceText: originalText, + targetText: patchedText, + targetProvider: TARGET_PROVIDER, + targetBaseUrl: proxyUrl + }); + const normalizeLockBinding = (binding) => { + if (!validConfigLockBinding(binding) + || binding.targetConfigSha256 !== transition.targetConfigSha256 + || (inspectedPending !== null + ? !samePendingAndLockBinding(inspectedPending, binding) + : binding.sourceConfigSha256 !== transition.sourceConfigSha256)) { + throw createConfigError( + "CODEX_HISTORY_REPAIR_CONFLICT", + "Codex history repair lock binding is invalid." + ); + } + return Object.freeze({ ...binding }); + }; + const bindPreparedLock = (binding) => { + phase = "write"; + const normalized = normalizeLockBinding(binding); + if (lock.binding !== null) { + if (JSON.stringify(lock.binding) !== JSON.stringify(normalized)) { + throw createConfigError( + "CODEX_HISTORY_REPAIR_CONFLICT", + "Codex history repair lock binding changed." + ); + } + assertConfigLockOwned(lockPath, lock, fileOperations); + return; + } + replaceConfigLockMetadata( + lockPath, + lock, + configLockOwner, + "prepared", + normalized, + fileOperations + ); + }; + const assertBoundLock = (binding) => { + const normalized = normalizeLockBinding(binding); + if (lock.binding === null + || JSON.stringify(lock.binding) !== JSON.stringify(normalized)) { + throw createConfigError( + "CODEX_HISTORY_REPAIR_CONFLICT", + "Codex history repair lock binding changed." + ); + } + assertConfigLockOwned(lockPath, lock, fileOperations); + }; + const completeBoundLock = (binding) => { + phase = "write"; + const normalized = normalizeLockBinding(binding); + assertBoundLock(normalized); + replaceConfigLockMetadata( + lockPath, + lock, + configLockOwner, + "completed", + normalized, + fileOperations + ); + }; + + const publishConfig = (bytes = targetBytes) => { + const bytesToPublish = Buffer.from(bytes); + if (!bytesToPublish.equals(targetBytes)) { + throw createConfigError( + "CODEX_HISTORY_REPAIR_CONFLICT", + "Codex history repair target changed during bootstrap." + ); + } + if (!sourceExists) { + phase = "write"; + writeFileAtomically( + configPath, + patchedText, + 0o600, + fileOperations, + () => { + assertConfigParent(parent, fileOperations); + try { + fileOperations.lstatSync(configPath); + } catch (error) { + if (error?.code === "ENOENT") return; + throw error; + } + throw createConfigError( + "CODEX_CONFIG_CHANGED", + "Codex configuration changed during bootstrap." + ); + }, + { exclusive: true } + ); + return { changed: true, backupPath: null }; + } + + assertCurrentConfig(configPath, source, fileOperations); + phase = "write"; + const backupPath = copyBackupExclusively(configPath, source, now(), fileOperations); + phase = "read"; + assertCurrentConfig(configPath, source, fileOperations); + phase = "write"; + writeFileAtomically( + configPath, + patchedText, + source.mode, + fileOperations, + () => { + assertConfigParent(parent, fileOperations); + assertCurrentConfig(configPath, source, fileOperations); + } + ); + return { changed: true, backupPath }; + }; + + if (!transition.required && !historyRepairPending) { + let result; + if (patchedText === originalText) { + result = { changed: false, backupPath: null }; + } else { + const configOnlyBinding = { + operationId: randomUUID(), + sourceConfigSha256: transition.sourceConfigSha256, + targetConfigSha256: transition.targetConfigSha256, + pendingRequired: false + }; + bindPreparedLock(configOnlyBinding); + try { + result = publishConfig(); + } catch (error) { + if (new Set([ + "CODEX_CONFIG_PARENT_UNSAFE", + "CODEX_CONFIG_BUSY", + "CODEX_CONFIG_CHANGED", + "CODEX_CONFIG_READ_FAILED", + "CODEX_HISTORY_REPAIR_INVALID", + "CODEX_HISTORY_REPAIR_CONFLICT" + ]).has(error?.code)) { + throw error; + } + let published; + try { + published = readConfigSource(configPath, fileOperations, { missing: true }); + } catch (readError) { + throw configCommittedDegraded(readError); + } + if (published !== null && published.bytes.equals(targetBytes)) { + throw configCommittedDegraded(error); + } + throw error; + } + try { + completeBoundLock(configOnlyBinding); + const completed = readConfigSource(configPath, fileOperations, { missing: true }); + if (completed === null || !completed.bytes.equals(targetBytes)) { + throw createConfigError( + "CODEX_CONFIG_CHANGED", + "Codex configuration changed after publication." + ); + } + } catch (error) { + throw configCommittedDegraded(error); + } + committedWithoutPending = true; + } + return { ...result, historyRepair: NO_HISTORY_REPAIR }; + } + + const repaired = await historyRepair.run({ + codexRoot: dirname(configPath), + currentConfigBytes: source?.bytes ?? Buffer.alloc(0), + targetConfigBytes: targetBytes, + transition, + publishConfig, + beforeJournalPublish: bindPreparedLock, + beforePendingClear: completeBoundLock, + assertConfigLock: assertBoundLock, + ...(customFileOperations ? { fileOperations } : {}) + }); + if (repaired?.handled !== true) { + throw createConfigError( + "CODEX_HISTORY_REPAIR_CONFLICT", + "Codex history repair state changed during bootstrap." + ); + } + committedWithoutPending = true; + return { + ...(repaired.publishResult ?? { changed: false, backupPath: null }), + historyRepair: repaired.historyRepair + }; + } catch (error) { + primaryError = classifyConfigError(error, phase); + throw primaryError; + } finally { + if (lock !== undefined && primaryError?.retainConfigLock !== true) { + try { + releaseConfigLock(lockPath, lock, parent, fileOperations); + } catch (cleanupError) { + if (primaryError === undefined) { + throw committedWithoutPending + ? configCommittedDegraded(cleanupError) + : classifyConfigError(cleanupError, "write"); + } + } + } + } +} diff --git a/node/src/codex/codex-history-repair.mjs b/node/src/codex/codex-history-repair.mjs new file mode 100644 index 0000000..dd129a8 --- /dev/null +++ b/node/src/codex/codex-history-repair.mjs @@ -0,0 +1,2369 @@ +import { createHash, randomUUID } from "node:crypto"; +import { + chmodSync, + closeSync, + copyFileSync, + constants as FS_CONSTANTS, + fchmodSync, + fstatSync, + fsyncSync, + futimesSync, + linkSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + writeFileSync +} from "node:fs"; +import { + basename, + dirname, + extname, + isAbsolute, + join, + relative, + resolve, + sep +} from "node:path"; + +import { CrpError } from "../shared/errors.mjs"; + +const MANAGED_BY = "codex-remote-proxy/history-repair"; +const SCHEMA_VERSION = 1; +const MANAGED_DIRECTORY = ".crp-history-repair"; +const PENDING_FILE = "pending.json"; +const CLEARING_FILE = "pending.json.clearing"; +const BACKUP_DIRECTORY = "backups"; +const BACKUP_METADATA_FILE = "metadata.json"; +const TARGET_PROVIDER = "OpenAI"; +const SAFE_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/; +const SHA256_PATTERN = /^[a-f0-9]{64}$/; +const DATABASE_EXTENSIONS = new Set([".db", ".sqlite", ".sqlite3"]); +const DATABASE_SIDECAR_SUFFIXES = Object.freeze(["-wal", "-shm", "-journal"]); +const MAX_SUMMARY_COUNT = 1_000_000; +const EXACT_JOURNAL_FIELDS = new Set([ + "schemaVersion", + "managedBy", + "operationId", + "sourceConfigSha256", + "targetConfigSha256", + "targetProvider", + "createdAt" +]); + +const DEFAULT_FILE_OPERATIONS = { + chmodSync, + closeSync, + copyFileSync, + constants: FS_CONSTANTS, + fchmodSync, + fstatSync, + fsyncSync, + futimesSync, + linkSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + fsyncDirectorySync: defaultFsyncDirectorySync, + writeFileSync +}; + +let sqliteModulePromise; + +function defaultFsyncDirectorySync(path) { + if (process.platform === "win32") return; + const directoryFlag = typeof FS_CONSTANTS.O_DIRECTORY === "number" + ? FS_CONSTANTS.O_DIRECTORY + : 0; + let descriptor; + try { + descriptor = openSync(path, FS_CONSTANTS.O_RDONLY | directoryFlag); + const stats = fstatSync(descriptor); + if (!stats.isDirectory()) throw new Error("Directory identity is invalid."); + fsyncSync(descriptor); + } finally { + if (descriptor !== undefined) closeSync(descriptor); + } +} + +const DEFAULT_DATABASE_OPERATIONS = { + async open(path) { + sqliteModulePromise ??= import("node:sqlite"); + const { DatabaseSync } = await sqliteModulePromise; + return new DatabaseSync(path); + }, + async backup(database, destination) { + sqliteModulePromise ??= import("node:sqlite"); + const { backup } = await sqliteModulePromise; + await backup(database, destination); + } +}; + +function repairError(code, cause) { + const contracts = { + CODEX_HISTORY_REPAIR_INVALID: [ + "The Codex history repair input is invalid.", + "Review the Codex provider configuration before retrying.", + 400 + ], + CODEX_HISTORY_REPAIR_CONFLICT: [ + "The Codex history repair state conflicts with the current configuration.", + "Stop Codex, review the pending local transition, and retry.", + 409 + ], + CODEX_HISTORY_REPAIR_FAILED: [ + "CRP could not repair Codex history safely.", + "Stop Codex, review local storage health, and retry.", + 500 + ], + CODEX_HISTORY_REPAIR_COMMITTED_DEGRADED: [ + "The Codex provider change was published, but history repair is incomplete.", + "Keep the pending repair state and retry before starting Codex.", + 500 + ], + CODEX_CONFIG_COMMITTED_DEGRADED: [ + "The Codex configuration was updated, but completion could not be confirmed.", + "Review the Codex configuration and retry before starting the proxy.", + 500 + ] + }; + const [message, action, status] = contracts[code] + ?? contracts.CODEX_HISTORY_REPAIR_FAILED; + const details = code === "CODEX_HISTORY_REPAIR_COMMITTED_DEGRADED" + ? { committed: true, degraded: true, pending: true } + : code === "CODEX_CONFIG_COMMITTED_DEGRADED" + ? { committed: true, degraded: true, pending: false } + : {}; + return new CrpError(code, message, action, { status, details, cause }); +} + +function invalid(cause) { + return repairError("CODEX_HISTORY_REPAIR_INVALID", cause); +} + +function conflict(cause) { + return repairError("CODEX_HISTORY_REPAIR_CONFLICT", cause); +} + +function failed(cause) { + return repairError("CODEX_HISTORY_REPAIR_FAILED", cause); +} + +function isHardLinkUnavailable(error) { + return error?.code === "EPERM" || error?.code === "EACCES" || error?.code === "ENOTSUP"; +} + +function committedDegraded(cause) { + return repairError("CODEX_HISTORY_REPAIR_COMMITTED_DEGRADED", cause); +} + +function configCommittedDegraded(cause) { + return repairError("CODEX_CONFIG_COMMITTED_DEGRADED", cause); +} + +function isPlainObject(value) { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function exactFields(value, fields) { + return isPlainObject(value) + && Object.keys(value).length === fields.size + && Object.keys(value).every((key) => fields.has(key)); +} + +function asBytes(value) { + if (Buffer.isBuffer(value) || value instanceof Uint8Array) return Buffer.from(value); + if (typeof value === "string") return Buffer.from(value, "utf8"); + throw invalid(); +} + +function sha256(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} + +function sameIdentity(left, right) { + return left !== null && right !== null + && left.dev === right.dev + && left.ino === right.ino; +} + +function identityOf(stats) { + return { dev: stats.dev, ino: stats.ino }; +} + +function boundedAdd(value, increment = 1) { + return Math.min(MAX_SUMMARY_COUNT, value + increment); +} + +function nextSafeId(createId) { + const value = createId(); + if (typeof value !== "string" || !SAFE_ID_PATTERN.test(value)) throw invalid(); + return value; +} + +function syncDirectory(path, fileOperations) { + if (typeof fileOperations.fsyncDirectorySync !== "function") throw invalid(); + fileOperations.fsyncDirectorySync(path); +} + +function normalizedUrl(value) { + if (typeof value !== "string" || value.length === 0 || value !== value.trim()) { + throw invalid(); + } + let parsed; + try { + parsed = new URL(value); + } catch (error) { + throw invalid(error); + } + if (!new Set(["http:", "https:"]).has(parsed.protocol) + || parsed.username.length > 0 || parsed.password.length > 0 + || parsed.hash.length > 0) { + throw invalid(); + } + return parsed.href; +} + +function parseBasicString(text, start) { + const multiline = text.slice(start, start + 3) === "\"\"\""; + const delimiterLength = multiline ? 3 : 1; + let value = ""; + let index = start + delimiterLength; + if (multiline) { + if (text.slice(index, index + 2) === "\r\n") index += 2; + else if (text[index] === "\n") index += 1; + } + for (; index < text.length; index += 1) { + const character = text[index]; + if (character === "\"") { + if (!multiline) return { value, end: index + 1 }; + let quoteCount = 1; + while (text[index + quoteCount] === "\"") quoteCount += 1; + if (quoteCount >= 3) { + if (quoteCount > 5) throw invalid(); + value += "\"".repeat(quoteCount - 3); + return { value, end: index + quoteCount }; + } + value += "\"".repeat(quoteCount); + index += quoteCount - 1; + continue; + } + if (!multiline && (character === "\n" || character === "\r")) throw invalid(); + if (character !== "\\") { + if (character === "\0" + || (character < " " && character !== "\t" && character !== "\n" && character !== "\r")) { + throw invalid(); + } + value += character; + continue; + } + index += 1; + if (index >= text.length) throw invalid(); + if (multiline) { + let continuation = index; + while (text[continuation] === " " || text[continuation] === "\t") { + continuation += 1; + } + if (text[continuation] === "\n" || text[continuation] === "\r") { + index = continuation; + if (text[index] === "\r") { + if (text[index + 1] !== "\n") throw invalid(); + index += 1; + } + while (/\s/.test(text[index + 1] ?? "")) index += 1; + continue; + } + } + const escape = text[index]; + const simple = { + b: "\b", + t: "\t", + n: "\n", + f: "\f", + r: "\r", + "\"": "\"", + "\\": "\\" + }; + if (Object.hasOwn(simple, escape)) { + value += simple[escape]; + continue; + } + if (escape !== "u" && escape !== "U") throw invalid(); + const length = escape === "u" ? 4 : 8; + const encoded = text.slice(index + 1, index + 1 + length); + if (!new RegExp(`^[A-Fa-f0-9]{${length}}$`).test(encoded)) throw invalid(); + const codePoint = Number.parseInt(encoded, 16); + if (codePoint > 0x10ffff || (codePoint >= 0xd800 && codePoint <= 0xdfff)) { + throw invalid(); + } + value += String.fromCodePoint(codePoint); + index += length; + } + throw invalid(); +} + +function parseLiteralString(text, start) { + const multiline = text.slice(start, start + 3) === "'''"; + let index = start + (multiline ? 3 : 1); + if (multiline) { + if (text.slice(index, index + 2) === "\r\n") index += 2; + else if (text[index] === "\n") index += 1; + } + let value = ""; + for (; index < text.length; index += 1) { + if (text[index] === "'") { + if (!multiline) return { value, end: index + 1 }; + let quoteCount = 1; + while (text[index + quoteCount] === "'") quoteCount += 1; + if (quoteCount >= 3) { + if (quoteCount > 5) throw invalid(); + value += "'".repeat(quoteCount - 3); + return { value, end: index + quoteCount }; + } + value += "'".repeat(quoteCount); + index += quoteCount - 1; + continue; + } + if (!multiline && (text[index] === "\n" || text[index] === "\r")) throw invalid(); + if (text[index] === "\0" + || (text[index] < " " && text[index] !== "\t" + && text[index] !== "\n" && text[index] !== "\r")) { + throw invalid(); + } + value += text[index]; + } + throw invalid(); +} + +function parseTomlStringAt(text, start = 0) { + if (text[start] === "\"") return parseBasicString(text, start); + if (text[start] === "'") return parseLiteralString(text, start); + throw invalid(); +} + +function assertOnlyComment(text, start) { + const remainder = text.slice(start).trimStart(); + if (remainder.length > 0 && !remainder.startsWith("#")) throw invalid(); +} + +function parseStringValue(text) { + const start = text.search(/\S/); + if (start === -1) throw invalid(); + const parsed = parseTomlStringAt(text, start); + assertOnlyComment(text, parsed.end); + return parsed.value; +} + +function parseDottedKey(text) { + const parts = []; + let index = 0; + while (index < text.length) { + while (/\s/.test(text[index] ?? "")) index += 1; + if (index >= text.length) throw invalid(); + if (text[index] === "\"" || text[index] === "'") { + if (text.slice(index, index + 3) === "\"\"\"" + || text.slice(index, index + 3) === "'''") { + throw invalid(); + } + const parsed = parseTomlStringAt(text, index); + parts.push(parsed.value); + index = parsed.end; + } else { + const match = /^[A-Za-z0-9_-]+/.exec(text.slice(index)); + if (!match) throw invalid(); + parts.push(match[0]); + index += match[0].length; + } + while (/\s/.test(text[index] ?? "")) index += 1; + if (index >= text.length) break; + if (text[index] !== ".") throw invalid(); + index += 1; + if (text.slice(index).trim().length === 0) throw invalid(); + } + return parts; +} + +function findUnquoted(text, wanted) { + let quote = null; + let escaped = false; + for (let index = 0; index < text.length; index += 1) { + const character = text[index]; + if (quote === "\"") { + if (escaped) escaped = false; + else if (character === "\\") escaped = true; + else if (character === quote) quote = null; + continue; + } + if (quote === "'") { + if (character === quote) quote = null; + continue; + } + if (character === "\"" || character === "'") { + quote = character; + continue; + } + if (character === wanted) return index; + } + return -1; +} + +function parseHeader(line) { + const stripped = line.trimStart(); + const array = stripped.startsWith("[["); + const openingLength = array ? 2 : 1; + const end = findUnquoted(stripped.slice(openingLength), "]"); + if (end === -1) throw invalid(); + const closing = end + openingLength; + const afterClosing = array ? closing + 2 : closing + 1; + if (array && stripped[closing + 1] !== "]") throw invalid(); + assertOnlyComment(stripped, afterClosing); + return { + array, + path: parseDottedKey(stripped.slice(openingLength, closing)) + }; +} + +function parseAssignment(line) { + const equals = findUnquoted(line, "="); + if (equals === -1) return null; + return { + key: parseDottedKey(line.slice(0, equals)), + value: line.slice(equals + 1) + }; +} + +function scanTomlValueEnd(lines, startLine, startColumn) { + const fragments = lines.slice(startLine); + fragments[0] = fragments[0].slice(startColumn); + const text = fragments.join("\n"); + let mode = null; + let bracketDepth = 0; + let braceDepth = 0; + let lineOffset = 0; + let sawValue = false; + + for (let index = 0; index < text.length; index += 1) { + const character = text[index]; + if (mode === "basic") { + if (character === "\\") { + if (text[index + 1] === undefined || text[index + 1] === "\n") throw invalid(); + index += 1; + } else if (character === "\"") { + mode = null; + } else if (character === "\n" || character === "\r") { + throw invalid(); + } + continue; + } + if (mode === "literal") { + if (character === "'") mode = null; + else if (character === "\n" || character === "\r") throw invalid(); + continue; + } + if (mode === "multiline-basic") { + if (character === "\\") { + if (text[index + 1] === "\n") lineOffset += 1; + if (text[index + 1] !== undefined) index += 1; + continue; + } + if (text.slice(index, index + 3) === "\"\"\"") { + let count = 3; + while (text[index + count] === "\"") count += 1; + if (count > 5) throw invalid(); + mode = null; + index += count - 1; + } else if (character === "\n") { + lineOffset += 1; + } + continue; + } + if (mode === "multiline-literal") { + if (text.slice(index, index + 3) === "'''") { + let count = 3; + while (text[index + count] === "'") count += 1; + if (count > 5) throw invalid(); + mode = null; + index += count - 1; + } else if (character === "\n") { + lineOffset += 1; + } + continue; + } + if (character === "#") { + const newline = text.indexOf("\n", index); + if (newline === -1) { + if (!sawValue || bracketDepth !== 0 || braceDepth !== 0) throw invalid(); + return startLine + lineOffset; + } + index = newline - 1; + continue; + } + if (character === "\n") { + if (bracketDepth === 0 && braceDepth === 0) { + if (!sawValue) throw invalid(); + return startLine + lineOffset; + } + lineOffset += 1; + continue; + } + if (character === " " || character === "\t" || character === "\r") continue; + sawValue = true; + if (text.slice(index, index + 3) === "\"\"\"") { + mode = "multiline-basic"; + index += 2; + } else if (text.slice(index, index + 3) === "'''") { + mode = "multiline-literal"; + index += 2; + } else if (character === "\"") { + mode = "basic"; + } else if (character === "'") { + mode = "literal"; + } else if (character === "[") { + bracketDepth += 1; + } else if (character === "]") { + bracketDepth -= 1; + if (bracketDepth < 0) throw invalid(); + } else if (character === "{") { + braceDepth += 1; + } else if (character === "}") { + braceDepth -= 1; + if (braceDepth < 0) throw invalid(); + } + } + if (!sawValue || mode !== null || bracketDepth !== 0 || braceDepth !== 0) throw invalid(); + return lines.length - 1; +} + +function statementValue(lines, statement) { + const fragments = lines.slice(statement.startLine, statement.endLine + 1); + fragments[0] = fragments[0].slice(statement.valueColumn); + return fragments.join("\n"); +} + +function absoluteAssignmentPath(section, key) { + return section === null ? [...key] : [...section.path, ...key]; +} + +function samePath(left, right) { + return left.length === right.length + && left.every((part, index) => part === right[index]); +} + +function startsWithPath(path, prefix) { + return path.length >= prefix.length + && prefix.every((part, index) => path[index] === part); +} + +function pathsOverlap(left, right) { + return startsWithPath(left, right) || startsWithPath(right, left); +} + +function scanTomlDocument(text) { + if (typeof text !== "string") throw invalid(); + const lineEnding = text.match(/\r\n|\n/)?.[0] ?? "\n"; + const lines = text.split(/\r?\n/); + if (lines.at(-1) === "") lines.pop(); + const statements = []; + let currentSection = null; + + for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) { + const rawLine = lines[lineIndex]; + const stripped = rawLine.trimStart(); + if (stripped.length === 0 || stripped.startsWith("#")) continue; + if (stripped.startsWith("[")) { + const header = parseHeader(rawLine); + currentSection = header; + statements.push({ + kind: header.array ? "array-table" : "table", + path: header.path, + startLine: lineIndex, + endLine: lineIndex + }); + continue; + } + const assignment = parseAssignment(rawLine); + if (assignment === null) throw invalid(); + const equals = findUnquoted(rawLine, "="); + const endLine = scanTomlValueEnd(lines, lineIndex, equals + 1); + const section = currentSection === null ? null : { + array: currentSection.array, + path: [...currentSection.path] + }; + const statement = { + kind: "assignment", + key: assignment.key, + absolutePath: absoluteAssignmentPath(section, assignment.key), + section, + startLine: lineIndex, + endLine, + valueColumn: equals + 1 + }; + statements.push(statement); + lineIndex = endLine; + } + return { lineEnding, lines, statements }; +} + +function assignmentsAt(document, path) { + return document.statements.filter( + (statement) => statement.kind === "assignment" + && samePath(statement.absolutePath, path) + ); +} + +function tablesAt(document, path, array) { + return document.statements.filter( + (statement) => statement.kind === (array ? "array-table" : "table") + && samePath(statement.path, path) + ); +} + +export function inspectCodexProviderBinding(text) { + const document = scanTomlDocument(text); + const rootAssignments = assignmentsAt(document, ["model_provider"]); + const rootConflicts = document.statements.filter( + (statement) => (statement.kind === "assignment" + ? pathsOverlap(statement.absolutePath, ["model_provider"]) + && !samePath(statement.absolutePath, ["model_provider"]) + : startsWithPath(statement.path, ["model_provider"])) + ); + if (rootAssignments.length > 1 || rootConflicts.length > 0) throw invalid(); + const providerName = rootAssignments.length === 0 + ? null + : parseStringValue(statementValue(document.lines, rootAssignments[0])); + if (providerName === null) { + return Object.freeze({ + providerName: null, + baseUrl: null, + normalizedBaseUrl: null + }); + } + if (providerName.length === 0) throw invalid(); + const providerPath = ["model_providers", providerName]; + const sections = tablesAt(document, providerPath, false); + const arraySections = tablesAt(document, providerPath, true); + const baseUrlAssignments = assignmentsAt(document, [...providerPath, "base_url"]); + const selectedAssignments = document.statements.filter( + (statement) => statement.kind === "assignment" + && startsWithPath(statement.absolutePath, providerPath) + ); + const selectedContext = (statement) => { + if (statement.section === null && startsWithPath(statement.key, providerPath)) { + return "root-dotted"; + } + if (statement.section?.array === false + && samePath(statement.section.path, ["model_providers"]) + && startsWithPath(statement.key, [providerName])) { + return "provider-parent"; + } + if (statement.section?.array === false + && samePath(statement.section.path, providerPath)) { + return "provider-table"; + } + return null; + }; + const selectedContexts = new Set( + selectedAssignments.map(selectedContext).filter((context) => context !== null) + ); + const providerConflicts = document.statements.filter( + (statement) => statement.kind === "assignment" + && pathsOverlap(statement.absolutePath, providerPath) + && (!startsWithPath(statement.absolutePath, providerPath) + || samePath(statement.absolutePath, providerPath)) + ); + const baseUrlTableConflicts = document.statements.filter( + (statement) => statement.kind !== "assignment" + && startsWithPath(statement.path, [...providerPath, "base_url"]) + ); + if (sections.length > 1 || arraySections.length > 0 || baseUrlAssignments.length > 1 + || providerConflicts.length > 0 || baseUrlTableConflicts.length > 0 + || (sections.length === 1 + && [...selectedContexts].some((context) => context !== "provider-table")) + || (sections.length === 0 && selectedContexts.size > 1)) { + throw invalid(); + } + const baseUrl = baseUrlAssignments.length === 0 + ? null + : parseStringValue(statementValue(document.lines, baseUrlAssignments[0])); + return Object.freeze({ + providerName, + baseUrl, + normalizedBaseUrl: baseUrl === null ? null : normalizedUrl(baseUrl) + }); +} + +function replaceStatement(lines, statement, replacement) { + lines.splice( + statement.startLine, + statement.endLine - statement.startLine + 1, + replacement + ); +} + +function renderedDocument(lines, lineEnding) { + return `${lines.join(lineEnding)}${lineEnding}`; +} + +export function patchCodexProviderConfigText(text, proxyUrl) { + normalizedUrl(proxyUrl); + let document = scanTomlDocument(text); + const rootAssignments = assignmentsAt(document, ["model_provider"]); + const rootConflicts = document.statements.filter( + (statement) => (statement.kind === "assignment" + ? pathsOverlap(statement.absolutePath, ["model_provider"]) + && !samePath(statement.absolutePath, ["model_provider"]) + : startsWithPath(statement.path, ["model_provider"])) + ); + if (rootAssignments.length > 1 || rootConflicts.length > 0) throw invalid(); + if (rootAssignments.length === 1) { + replaceStatement(document.lines, rootAssignments[0], 'model_provider = "OpenAI"'); + } else { + const firstHeader = document.statements.find( + (statement) => statement.kind === "table" || statement.kind === "array-table" + ); + document.lines.splice(firstHeader?.startLine ?? document.lines.length, 0, + 'model_provider = "OpenAI"'); + } + + document = scanTomlDocument(renderedDocument(document.lines, document.lineEnding)); + const providerPath = ["model_providers", TARGET_PROVIDER]; + const providerTables = tablesAt(document, providerPath, false); + const providerArrays = tablesAt(document, providerPath, true); + const overlappingArrays = document.statements.filter( + (statement) => statement.kind === "array-table" + && startsWithPath(providerPath, statement.path) + ); + if (providerTables.length > 1 || providerArrays.length > 0 + || overlappingArrays.length > 0) { + throw invalid(); + } + const providerAssignments = document.statements.filter( + (statement) => statement.kind === "assignment" + && startsWithPath(statement.absolutePath, providerPath) + ); + const providerParentConflicts = document.statements.filter( + (statement) => statement.kind === "assignment" + && startsWithPath(providerPath, statement.absolutePath) + ); + if (providerParentConflicts.length > 0) throw invalid(); + + const desired = [ + [[...providerPath, "name"], "name", '"OpenAI"'], + [[...providerPath, "base_url"], "base_url", JSON.stringify(proxyUrl)], + [[...providerPath, "wire_api"], "wire_api", '"responses"'], + [[...providerPath, "requires_openai_auth"], "requires_openai_auth", "true"] + ]; + const directContext = (statement) => { + if (statement.section === null && startsWithPath(statement.key, providerPath)) { + return "root-dotted"; + } + if (statement.section?.array === false + && samePath(statement.section.path, ["model_providers"]) + && startsWithPath(statement.key, [TARGET_PROVIDER])) { + return "provider-parent"; + } + if (statement.section?.array === false + && samePath(statement.section.path, providerPath)) { + return "provider-table"; + } + return null; + }; + const contexts = new Set( + providerAssignments.map(directContext).filter((context) => context !== null) + ); + let context; + if (providerTables.length === 1) { + if ([...contexts].some((value) => value !== "provider-table")) throw invalid(); + context = "provider-table"; + } else if (contexts.size === 1) { + [context] = contexts; + } else if (contexts.size > 1) { + throw invalid(); + } else { + if (document.lines.length > 0 && document.lines.at(-1).trim() !== "") { + document.lines.push(""); + } + document.lines.push( + "[model_providers.OpenAI]", + ...desired.map(([, field, value]) => `${field} = ${value}`) + ); + const output = renderedDocument(document.lines, document.lineEnding); + const binding = inspectCodexProviderBinding(output); + if (binding.providerName !== TARGET_PROVIDER + || binding.normalizedBaseUrl !== normalizedUrl(proxyUrl)) { + throw invalid(); + } + return output; + } + + const lineFor = (field, value) => { + if (context === "root-dotted") { + return `model_providers.OpenAI.${field} = ${value}`; + } + if (context === "provider-parent") return `OpenAI.${field} = ${value}`; + return `${field} = ${value}`; + }; + + const existing = []; + for (const [path, field, value] of desired) { + const matches = assignmentsAt(document, path); + if (matches.length > 1) throw invalid(); + const childConflicts = document.statements.filter( + (statement) => statement.kind === "assignment" + ? startsWithPath(statement.absolutePath, path) + && !samePath(statement.absolutePath, path) + : startsWithPath(statement.path, path) + ); + if (childConflicts.length > 0) throw invalid(); + if (matches.length === 1) { + const [statement] = matches; + if (directContext(statement) !== context) throw invalid(); + existing.push({ statement, replacement: lineFor(field, value) }); + } + } + existing.sort((left, right) => right.statement.startLine - left.statement.startLine); + for (const entry of existing) { + replaceStatement(document.lines, entry.statement, entry.replacement); + } + + document = scanTomlDocument(renderedDocument(document.lines, document.lineEnding)); + let insertionLine; + if (context === "root-dotted") { + const firstHeader = document.statements.find( + (statement) => statement.kind === "table" || statement.kind === "array-table" + ); + insertionLine = firstHeader?.startLine ?? document.lines.length; + } else { + const hostPath = context === "provider-parent" ? ["model_providers"] : providerPath; + const hosts = tablesAt(document, hostPath, false); + if (hosts.length !== 1 || tablesAt(document, hostPath, true).length > 0) throw invalid(); + const nextHeader = document.statements.find( + (statement) => (statement.kind === "table" || statement.kind === "array-table") + && statement.startLine > hosts[0].startLine + ); + insertionLine = nextHeader?.startLine ?? document.lines.length; + } + const missing = desired.filter(([path]) => assignmentsAt(document, path).length === 0); + if (missing.length > 0) { + document.lines.splice( + insertionLine, + 0, + ...missing.map(([, field, value]) => lineFor(field, value)) + ); + } + const output = renderedDocument(document.lines, document.lineEnding); + const binding = inspectCodexProviderBinding(output); + if (binding.providerName !== TARGET_PROVIDER + || binding.normalizedBaseUrl !== normalizedUrl(proxyUrl)) { + throw invalid(); + } + return output; +} + +export function planCodexProviderTransition({ + sourceExists, + sourceText, + targetText, + targetProvider, + targetBaseUrl +}) { + if (typeof sourceExists !== "boolean" || typeof targetText !== "string" + || typeof targetProvider !== "string" || targetProvider.length === 0 + || typeof targetBaseUrl !== "string") { + throw invalid(); + } + if (targetProvider !== TARGET_PROVIDER) throw invalid(); + const target = inspectCodexProviderBinding(targetText); + const expectedTargetUrl = normalizedUrl(targetBaseUrl); + if (target.providerName !== targetProvider + || target.normalizedBaseUrl !== expectedTargetUrl) { + throw invalid(); + } + const targetBytes = Buffer.from(targetText, "utf8"); + if (!sourceExists) { + return Object.freeze({ + required: false, + reason: "source-missing", + sourceConfigSha256: null, + targetConfigSha256: sha256(targetBytes) + }); + } + if (typeof sourceText !== "string") throw invalid(); + const source = inspectCodexProviderBinding(sourceText); + const sourceHash = sha256(Buffer.from(sourceText, "utf8")); + const targetHash = sha256(targetBytes); + if (source.normalizedBaseUrl === expectedTargetUrl) { + return Object.freeze({ + required: false, + reason: "base-url-unchanged", + sourceConfigSha256: sourceHash, + targetConfigSha256: targetHash + }); + } + return Object.freeze({ + required: true, + reason: source.normalizedBaseUrl === null + ? "source-binding-missing" + : "base-url-changed", + sourceConfigSha256: sourceHash, + targetConfigSha256: targetHash + }); +} + +function rootContext(codexRoot, fileOperations) { + if (typeof codexRoot !== "string" || !isAbsolute(codexRoot)) throw invalid(); + const path = resolve(codexRoot); + let stats; + try { + stats = fileOperations.lstatSync(path); + } catch (error) { + throw invalid(error); + } + if (!stats.isDirectory() || stats.isSymbolicLink()) throw invalid(); + return { path, identity: identityOf(stats) }; +} + +function assertRoot(context, fileOperations) { + let stats; + try { + stats = fileOperations.lstatSync(context.path); + } catch (error) { + throw conflict(error); + } + if (!stats.isDirectory() || stats.isSymbolicLink() + || !sameIdentity(context.identity, identityOf(stats))) { + throw conflict(); + } +} + +function pathWithin(root, path) { + const suffix = relative(root, path); + return suffix === "" || (!suffix.startsWith(`..${sep}`) && suffix !== ".." && !isAbsolute(suffix)); +} + +function safeChild(root, ...parts) { + const path = resolve(root, ...parts); + if (!pathWithin(root, path)) throw invalid(); + return path; +} + +function lstatMaybe(path, fileOperations) { + try { + return fileOperations.lstatSync(path); + } catch (error) { + if (error?.code === "ENOENT") return null; + throw error; + } +} + +function ensurePrivateDirectory(path, parent, fileOperations) { + if (!pathWithin(parent, path) || path === parent) throw invalid(); + let stats = lstatMaybe(path, fileOperations); + let created = false; + if (stats === null) { + try { + fileOperations.mkdirSync(path, { mode: 0o700 }); + } catch (error) { + if (error?.code !== "EEXIST") throw error; + } + stats = fileOperations.lstatSync(path); + created = true; + } + if (!stats.isDirectory() || stats.isSymbolicLink()) throw invalid(); + fileOperations.chmodSync(path, 0o700); + syncDirectory(path, fileOperations); + if (created) syncDirectory(parent, fileOperations); + return identityOf(stats); +} + +function ensurePrivateTree(root, relativeParts, fileOperations) { + let parent = root; + for (const part of relativeParts) { + if (typeof part !== "string" || part.length === 0 || part === "." || part === ".." + || part.includes("/") || part.includes("\\")) { + throw invalid(); + } + const path = safeChild(parent, part); + ensurePrivateDirectory(path, parent, fileOperations); + parent = path; + } + return parent; +} + +function readSafeFile(path, fileOperations, { missing = false } = {}) { + let before; + try { + before = fileOperations.lstatSync(path); + } catch (error) { + if (missing && error?.code === "ENOENT") return null; + throw error; + } + if (!before.isFile() || before.isSymbolicLink()) throw invalid(); + const noFollow = typeof fileOperations.constants.O_NOFOLLOW === "number" + ? fileOperations.constants.O_NOFOLLOW + : 0; + let descriptor; + try { + descriptor = fileOperations.openSync( + path, + fileOperations.constants.O_RDONLY | noFollow + ); + const opened = fileOperations.fstatSync(descriptor); + if (!opened.isFile() || !sameIdentity(identityOf(before), identityOf(opened))) { + throw conflict(); + } + const bytes = Buffer.from(fileOperations.readFileSync(descriptor)); + const after = fileOperations.lstatSync(path); + if (!after.isFile() || after.isSymbolicLink() + || !sameIdentity(identityOf(opened), identityOf(after))) { + throw conflict(); + } + return { + bytes, + identity: identityOf(opened), + mode: opened.mode & 0o7777, + atime: opened.atime, + mtime: opened.mtime + }; + } finally { + if (descriptor !== undefined) fileOperations.closeSync(descriptor); + } +} + +function writePrivateExclusive(path, bytes, fileOperations, onIdentity = null) { + let descriptor; + try { + descriptor = fileOperations.openSync(path, "wx", 0o600); + fileOperations.writeFileSync(descriptor, bytes); + fileOperations.fchmodSync(descriptor, 0o600); + fileOperations.fsyncSync(descriptor); + const identity = identityOf(fileOperations.fstatSync(descriptor)); + if (onIdentity !== null) onIdentity(identity); + fileOperations.closeSync(descriptor); + descriptor = undefined; + syncDirectory(dirname(path), fileOperations); + return identity; + } finally { + if (descriptor !== undefined) fileOperations.closeSync(descriptor); + } +} + +function removeOwnedFile(path, expectedIdentity, expectedBytes, fileOperations, createId) { + const claimPath = join(dirname(path), `.${basename(path)}.${nextSafeId(createId)}.claim`); + try { + fileOperations.renameSync(path, claimPath); + syncDirectory(dirname(path), fileOperations); + } catch (error) { + if (error?.code === "ENOENT") return true; + return false; + } + try { + const claimed = readSafeFile(claimPath, fileOperations); + if (!sameIdentity(claimed.identity, expectedIdentity) + || !claimed.bytes.equals(expectedBytes)) { + try { fileOperations.renameSync(claimPath, path); } catch {} + return false; + } + fileOperations.rmSync(claimPath); + syncDirectory(dirname(path), fileOperations); + return true; + } catch { + try { fileOperations.renameSync(claimPath, path); } catch {} + return false; + } +} + +function removeOwnedPath(path, expectedIdentity, fileOperations, createId) { + const claimPath = join(dirname(path), `.${basename(path)}.${nextSafeId(createId)}.claim`); + try { + fileOperations.renameSync(path, claimPath); + syncDirectory(dirname(path), fileOperations); + } catch (error) { + if (error?.code === "ENOENT") return true; + return false; + } + const restoreClaim = () => { + try { + fileOperations.linkSync(claimPath, path); + fileOperations.rmSync(claimPath); + syncDirectory(dirname(path), fileOperations); + } catch {} + }; + try { + const claimed = fileOperations.lstatSync(claimPath); + if (!claimed.isFile() || claimed.isSymbolicLink() + || !sameIdentity(identityOf(claimed), expectedIdentity)) { + restoreClaim(); + return false; + } + fileOperations.rmSync(claimPath); + syncDirectory(dirname(path), fileOperations); + return true; + } catch { + restoreClaim(); + return false; + } +} + +function writeAtomicExclusive(path, bytes, fileOperations, createId) { + const tempPath = join(dirname(path), `.${basename(path)}.${nextSafeId(createId)}.tmp`); + let identity = null; + try { + identity = writePrivateExclusive(tempPath, bytes, fileOperations); + fileOperations.linkSync(tempPath, path); + syncDirectory(dirname(path), fileOperations); + const published = readSafeFile(path, fileOperations); + if (!sameIdentity(published.identity, identity) || !published.bytes.equals(bytes)) { + throw failed(); + } + if (!removeOwnedFile(tempPath, identity, bytes, fileOperations, createId)) { + throw failed(); + } + return published; + } catch (error) { + if (identity !== null) { + removeOwnedFile(tempPath, identity, bytes, fileOperations, createId); + } + throw error; + } +} + +function parseJournal(source) { + let value; + try { + value = JSON.parse(source.bytes.toString("utf8")); + } catch (error) { + throw conflict(error); + } + if (!exactFields(value, EXACT_JOURNAL_FIELDS) + || value.schemaVersion !== SCHEMA_VERSION || value.managedBy !== MANAGED_BY + || !SAFE_ID_PATTERN.test(value.operationId) + || !SHA256_PATTERN.test(value.sourceConfigSha256) + || !SHA256_PATTERN.test(value.targetConfigSha256) + || value.targetProvider !== TARGET_PROVIDER + || typeof value.createdAt !== "string" || value.createdAt.length === 0 + || value.createdAt.length > 64) { + throw conflict(); + } + return value; +} + +function managedPaths(root) { + const managed = safeChild(root, MANAGED_DIRECTORY); + return { + managed, + pending: safeChild(managed, PENDING_FILE), + clearing: safeChild(managed, CLEARING_FILE), + backups: safeChild(managed, BACKUP_DIRECTORY) + }; +} + +function readPending(root, fileOperations) { + const paths = managedPaths(root); + const managed = lstatMaybe(paths.managed, fileOperations); + if (managed === null) return null; + if (!managed.isDirectory() || managed.isSymbolicLink()) throw invalid(); + const canonical = readSafeFile(paths.pending, fileOperations, { missing: true }); + const clearing = readSafeFile(paths.clearing, fileOperations, { missing: true }); + if (canonical !== null && clearing !== null) throw conflict(); + const source = canonical ?? clearing; + if (source === null) return null; + return { + source, + sourcePath: canonical === null ? paths.clearing : paths.pending, + journal: parseJournal(source), + paths + }; +} + +function pendingBinding(pending) { + return Object.freeze({ + operationId: pending.journal.operationId, + sourceConfigSha256: pending.journal.sourceConfigSha256, + targetConfigSha256: pending.journal.targetConfigSha256 + }); +} + +function sameBinding(left, right) { + return isPlainObject(left) && isPlainObject(right) + && left.operationId === right.operationId + && left.sourceConfigSha256 === right.sourceConfigSha256 + && left.targetConfigSha256 === right.targetConfigSha256; +} + +export function inspectPendingCodexHistoryRepair({ + codexRoot, + fileOperations: overrides = {} +}) { + const fileOperations = { ...DEFAULT_FILE_OPERATIONS, ...overrides }; + const root = rootContext(codexRoot, fileOperations); + try { + const pending = readPending(root.path, fileOperations); + return pending === null ? null : pendingBinding(pending); + } catch (error) { + if (error instanceof CrpError) throw error; + throw invalid(error); + } +} + +export function hasPendingCodexHistoryRepair({ codexRoot, fileOperations: overrides = {} }) { + return inspectPendingCodexHistoryRepair({ codexRoot, fileOperations: overrides }) !== null; +} + +function parseJsonLine(line) { + try { + return JSON.parse(line); + } catch { + return null; + } +} + +function containsEncryptedContent(value, seen = new Set()) { + if (value === null || typeof value !== "object" || seen.has(value)) return false; + seen.add(value); + if (Object.hasOwn(value, "encrypted_content")) return true; + if (Array.isArray(value)) return value.some((item) => containsEncryptedContent(item, seen)); + return Object.values(value).some((item) => containsEncryptedContent(item, seen)); +} + +function transformRollout(bytes, targetProvider) { + let text; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch (error) { + throw invalid(error); + } + const pieces = text.split(/(\r\n|\n|\r)/); + let records = 0; + let encryptedContentDetected = false; + for (let index = 0; index < pieces.length; index += 2) { + const value = parseJsonLine(pieces[index]); + if (value === null) continue; + if (!encryptedContentDetected && containsEncryptedContent(value)) { + encryptedContentDetected = true; + } + if (!isPlainObject(value) || value.type !== "session_meta" + || !isPlainObject(value.payload) + || value.payload.model_provider === targetProvider) { + continue; + } + value.payload.model_provider = targetProvider; + pieces[index] = JSON.stringify(value); + records = boundedAdd(records); + } + return { + bytes: Buffer.from(pieces.join(""), "utf8"), + records, + encryptedContentDetected + }; +} + +function scanRolloutDirectory({ root, relativeRoot, fileOperations, targetProvider }) { + const base = safeChild(root.path, relativeRoot); + const baseStats = lstatMaybe(base, fileOperations); + if (baseStats === null) return []; + if (!baseStats.isDirectory() || baseStats.isSymbolicLink()) throw invalid(); + const results = []; + + function visit(path, expectedIdentity) { + assertRoot(root, fileOperations); + const before = fileOperations.lstatSync(path); + if (!before.isDirectory() || before.isSymbolicLink() + || !sameIdentity(identityOf(before), expectedIdentity)) { + throw conflict(); + } + const entries = fileOperations.readdirSync(path, { withFileTypes: true }); + for (const entry of entries) { + const child = safeChild(path, entry.name); + if (!pathWithin(base, child)) throw invalid(); + if (entry.isSymbolicLink()) throw invalid(); + if (entry.isDirectory()) { + const stats = fileOperations.lstatSync(child); + if (!stats.isDirectory() || stats.isSymbolicLink()) throw invalid(); + visit(child, identityOf(stats)); + continue; + } + if (!entry.isFile()) throw invalid(); + if (!/^rollout-.*\.jsonl$/.test(entry.name)) continue; + const source = readSafeFile(child, fileOperations); + const transformed = transformRollout(source.bytes, targetProvider); + results.push({ + path: child, + relativePath: relative(root.path, child).split(sep), + source, + transformed + }); + } + const after = fileOperations.lstatSync(path); + if (!after.isDirectory() || after.isSymbolicLink() + || !sameIdentity(expectedIdentity, identityOf(after))) { + throw conflict(); + } + } + + visit(base, identityOf(baseStats)); + return results; +} + +function discoverRollouts(root, fileOperations, targetProvider) { + return ["sessions", "archived_sessions"].flatMap((relativeRoot) => ( + scanRolloutDirectory({ root, relativeRoot, fileOperations, targetProvider }) + )); +} + +function discoverDatabases(root, fileOperations) { + const candidates = []; + const legacy = safeChild(root.path, "state_5.sqlite"); + const legacyStats = lstatMaybe(legacy, fileOperations); + if (legacyStats !== null) { + if (!legacyStats.isFile() || legacyStats.isSymbolicLink() || legacyStats.nlink !== 1) { + throw invalid(); + } + assertSafeDatabaseSidecars(legacy, fileOperations, invalid); + candidates.push({ + path: legacy, + relativePath: ["state_5.sqlite"], + identity: identityOf(legacyStats) + }); + } + + const sqliteDirectory = safeChild(root.path, "sqlite"); + const sqliteStats = lstatMaybe(sqliteDirectory, fileOperations); + if (sqliteStats !== null) { + if (!sqliteStats.isDirectory() || sqliteStats.isSymbolicLink()) throw invalid(); + const entries = fileOperations.readdirSync(sqliteDirectory, { withFileTypes: true }); + for (const entry of entries) { + const extension = extname(entry.name).toLowerCase(); + if (!DATABASE_EXTENSIONS.has(extension)) { + if (entry.isSymbolicLink()) throw invalid(); + continue; + } + if (!entry.isFile() || entry.isSymbolicLink()) throw invalid(); + const path = safeChild(sqliteDirectory, entry.name); + const stats = fileOperations.lstatSync(path); + if (!stats.isFile() || stats.isSymbolicLink() || stats.nlink !== 1) throw invalid(); + assertSafeDatabaseSidecars(path, fileOperations, invalid); + candidates.push({ + path, + relativePath: ["sqlite", entry.name], + identity: identityOf(stats) + }); + } + const after = fileOperations.lstatSync(sqliteDirectory); + if (!after.isDirectory() || after.isSymbolicLink() + || !sameIdentity(identityOf(sqliteStats), identityOf(after))) { + throw conflict(); + } + } + + candidates.sort((left, right) => left.path.localeCompare(right.path, "en")); + const identities = new Set(); + for (const candidate of candidates) { + const key = `${candidate.identity.dev}:${candidate.identity.ino}`; + if (identities.has(key)) throw invalid(); + identities.add(key); + } + return candidates; +} + +function assertSafeDatabaseSidecars(path, fileOperations, errorFactory = conflict) { + const parent = dirname(path); + const name = basename(path); + for (const suffix of DATABASE_SIDECAR_SUFFIXES) { + const sidecar = safeChild(parent, `${name}${suffix}`); + const stats = lstatMaybe(sidecar, fileOperations); + if (stats !== null + && (!stats.isFile() || stats.isSymbolicLink() || stats.nlink !== 1)) { + throw errorFactory(); + } + } +} + +function statementGet(statement, ...parameters) { + return statement.get(...parameters); +} + +function databaseHasProviderColumn(database) { + const table = statementGet( + database.prepare("SELECT 1 AS present FROM sqlite_schema WHERE type = ? AND name = ? LIMIT 1"), + "table", + "threads" + ); + if (!table) return false; + const column = statementGet( + database.prepare("SELECT 1 AS present FROM pragma_table_info('threads') WHERE name = ? LIMIT 1"), + "model_provider" + ); + return Boolean(column); +} + +function mismatchedDatabaseRows(database, targetProvider) { + const row = statementGet( + database.prepare("SELECT COUNT(*) AS count FROM threads WHERE model_provider IS NOT ?"), + targetProvider + ); + const count = Number(row?.count ?? 0); + if (!Number.isSafeInteger(count) || count < 0) throw invalid(); + return count; +} + +async function inspectDatabases(candidates, databaseOperations, targetProvider, fileOperations) { + const results = []; + for (const candidate of candidates) { + const before = fileOperations.lstatSync(candidate.path); + if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 + || !sameIdentity(candidate.identity, identityOf(before))) { + throw conflict(); + } + assertSafeDatabaseSidecars(candidate.path, fileOperations); + let database; + try { + database = await databaseOperations.open(candidate.path); + const afterOpen = fileOperations.lstatSync(candidate.path); + if (!afterOpen.isFile() || afterOpen.isSymbolicLink() || afterOpen.nlink !== 1 + || !sameIdentity(candidate.identity, identityOf(afterOpen))) { + throw conflict(); + } + assertSafeDatabaseSidecars(candidate.path, fileOperations); + const supported = databaseHasProviderColumn(database); + const rows = supported ? mismatchedDatabaseRows(database, targetProvider) : 0; + results.push({ ...candidate, supported, rows }); + } finally { + if (database !== undefined) database.close(); + } + } + return results; +} + +function journalBytes(journal) { + return Buffer.from(`${JSON.stringify(journal, null, 2)}\n`, "utf8"); +} + +function createManagedOperation({ + root, + transition, + targetProvider, + fileOperations, + now, + createId +}) { + const paths = managedPaths(root.path); + ensurePrivateDirectory(paths.managed, root.path, fileOperations); + ensurePrivateDirectory(paths.backups, paths.managed, fileOperations); + const operationId = nextSafeId(createId); + const operationRoot = safeChild(paths.backups, operationId); + ensurePrivateDirectory(operationRoot, paths.backups, fileOperations); + const createdAt = now(); + if (typeof createdAt !== "string" || createdAt.length === 0 || createdAt.length > 64 + || Number.isNaN(Date.parse(createdAt)) + || new Date(createdAt).toISOString() !== createdAt) { + throw invalid(); + } + const journal = { + schemaVersion: SCHEMA_VERSION, + managedBy: MANAGED_BY, + operationId, + sourceConfigSha256: transition.sourceConfigSha256, + targetConfigSha256: transition.targetConfigSha256, + targetProvider, + createdAt + }; + const bytes = journalBytes(journal); + const metadataPath = safeChild(operationRoot, BACKUP_METADATA_FILE); + writePrivateExclusive(metadataPath, bytes, fileOperations); + return { paths, operationRoot, journal, bytes }; +} + +function assertManagedOperation(pending, fileOperations) { + for (const path of [pending.paths.managed, pending.paths.backups]) { + const ancestor = fileOperations.lstatSync(path); + if (!ancestor.isDirectory() || ancestor.isSymbolicLink()) throw conflict(); + } + const operationRoot = safeChild(pending.paths.backups, pending.journal.operationId); + const stats = fileOperations.lstatSync(operationRoot); + if (!stats.isDirectory() || stats.isSymbolicLink()) throw conflict(); + fileOperations.chmodSync(operationRoot, 0o700); + const metadata = readSafeFile(safeChild(operationRoot, BACKUP_METADATA_FILE), fileOperations); + if (!metadata.bytes.equals(journalBytes(pending.journal))) throw conflict(); + return operationRoot; +} + +function operationHasBackups(operationRoot, fileOperations) { + for (const name of ["rollouts", "databases"]) { + const base = safeChild(operationRoot, name); + const baseStats = lstatMaybe(base, fileOperations); + if (baseStats === null) continue; + if (!baseStats.isDirectory() || baseStats.isSymbolicLink()) throw conflict(); + const queue = [base]; + while (queue.length > 0) { + const directory = queue.pop(); + for (const entry of fileOperations.readdirSync(directory, { withFileTypes: true })) { + if (entry.isSymbolicLink()) throw conflict(); + const child = safeChild(directory, entry.name); + if (!pathWithin(base, child)) throw conflict(); + if (entry.isDirectory()) queue.push(child); + else if (entry.isFile()) return true; + else throw conflict(); + } + } + } + return false; +} + +function backupRollouts(rollouts, operationRoot, fileOperations, createId) { + let created = false; + const rolloutRoot = ensurePrivateTree(operationRoot, ["rollouts"], fileOperations); + for (const rollout of rollouts) { + if (rollout.transformed.records === 0) continue; + const directory = ensurePrivateTree( + rolloutRoot, + rollout.relativePath.slice(0, -1), + fileOperations + ); + const destination = safeChild( + directory, + `${rollout.relativePath.at(-1)}.${sha256(rollout.source.bytes)}.bak` + ); + const existing = readSafeFile(destination, fileOperations, { missing: true }); + if (existing !== null) { + if (!existing.bytes.equals(rollout.source.bytes)) throw conflict(); + continue; + } + try { + writeAtomicExclusive(destination, rollout.source.bytes, fileOperations, createId); + created = true; + } catch (error) { + if (error?.code !== "EEXIST") throw error; + const raced = readSafeFile(destination, fileOperations); + if (!raced.bytes.equals(rollout.source.bytes)) throw conflict(error); + } + } + return created; +} + +function availableDatabaseBackupPath(directory, name, token, fileOperations) { + let suffix = 0; + while (suffix < 10_000) { + const ending = suffix === 0 ? "" : `.${suffix}`; + const destination = safeChild(directory, `${name}.${token}${ending}.bak`); + if (lstatMaybe(destination, fileOperations) === null) return destination; + suffix += 1; + } + throw failed(); +} + +function rollbackAndClosePrepared(prepared) { + for (const snapshot of prepared) { + if (snapshot.closed) continue; + if (snapshot.transaction) { + try { snapshot.database.exec("ROLLBACK"); } catch {} + snapshot.transaction = false; + } + try { snapshot.database.close(); } catch {} + snapshot.closed = true; + } +} + +function collectMismatchedRowIds(database, targetProvider) { + const rows = database.prepare( + "SELECT rowid AS rowId FROM threads WHERE model_provider IS NOT ? ORDER BY rowid" + ).all(targetProvider); + const rowIds = []; + for (const row of rows) { + const rowId = row?.rowId; + if (!(typeof rowId === "number" && Number.isSafeInteger(rowId)) + && typeof rowId !== "bigint") { + throw invalid(); + } + rowIds.push(rowId); + } + return rowIds; +} + +async function backupOpenDatabase( + candidate, + operationRoot, + databaseOperations, + fileOperations, + createId +) { + const databaseRoot = ensurePrivateTree(operationRoot, ["databases"], fileOperations); + const directory = ensurePrivateTree( + databaseRoot, + candidate.relativePath.slice(0, -1), + fileOperations + ); + const destination = availableDatabaseBackupPath( + directory, + candidate.relativePath.at(-1), + nextSafeId(createId), + fileOperations + ); + const tempPath = join( + directory, + `.${basename(destination)}.${nextSafeId(createId)}.tmp` + ); + let tempIdentity = null; + let tempOwned = false; + let backupDatabase; + try { + const current = fileOperations.lstatSync(candidate.path); + if (!current.isFile() || current.isSymbolicLink() || current.nlink !== 1 + || !sameIdentity(candidate.identity, identityOf(current))) { + throw conflict(); + } + assertSafeDatabaseSidecars(candidate.path, fileOperations); + backupDatabase = await databaseOperations.open(candidate.path); + const afterBackupOpen = fileOperations.lstatSync(candidate.path); + if (!afterBackupOpen.isFile() || afterBackupOpen.isSymbolicLink() + || afterBackupOpen.nlink !== 1 + || !sameIdentity(candidate.identity, identityOf(afterBackupOpen))) { + throw conflict(); + } + assertSafeDatabaseSidecars(candidate.path, fileOperations); + writePrivateExclusive(tempPath, Buffer.alloc(0), fileOperations, (identity) => { + tempIdentity = identity; + tempOwned = true; + }); + await databaseOperations.backup(backupDatabase, tempPath); + backupDatabase.close(); + backupDatabase = undefined; + const afterBackup = fileOperations.lstatSync(candidate.path); + if (!afterBackup.isFile() || afterBackup.isSymbolicLink() || afterBackup.nlink !== 1 + || !sameIdentity(candidate.identity, identityOf(afterBackup))) { + throw conflict(); + } + assertSafeDatabaseSidecars(candidate.path, fileOperations); + const temp = fileOperations.lstatSync(tempPath); + if (!temp.isFile() || temp.isSymbolicLink()) throw failed(); + if (!sameIdentity(tempIdentity, identityOf(temp))) throw conflict(); + const tempHash = sha256(readSafeFile(tempPath, fileOperations).bytes); + fileOperations.chmodSync(tempPath, 0o600); + if (process.platform !== "win32") { + let descriptor; + try { + descriptor = fileOperations.openSync( + tempPath, + fileOperations.constants.O_RDONLY + | (typeof fileOperations.constants.O_NOFOLLOW === "number" + ? fileOperations.constants.O_NOFOLLOW + : 0) + ); + fileOperations.fsyncSync(descriptor); + } finally { + if (descriptor !== undefined) fileOperations.closeSync(descriptor); + } + } + const beforeRename = fileOperations.lstatSync(tempPath); + if (!beforeRename.isFile() || beforeRename.isSymbolicLink() + || !sameIdentity(tempIdentity, identityOf(beforeRename))) { + throw conflict(); + } + if (lstatMaybe(destination, fileOperations) !== null) throw conflict(); + let publishedWithHardLink = false; + try { + fileOperations.linkSync(tempPath, destination); + publishedWithHardLink = true; + } catch (error) { + if (error?.code === "EEXIST") throw conflict(error); + if (!isHardLinkUnavailable(error)) throw error; + try { + fileOperations.copyFileSync( + tempPath, + destination, + fileOperations.constants.COPYFILE_EXCL + ); + } catch (copyError) { + if (copyError?.code === "EEXIST") throw conflict(copyError); + throw copyError; + } + } + syncDirectory(dirname(destination), fileOperations); + const committed = readSafeFile(destination, fileOperations); + if ((publishedWithHardLink && !sameIdentity(tempIdentity, committed.identity)) + || sha256(committed.bytes) !== tempHash) { + throw failed(); + } + if (!removeOwnedPath(tempPath, tempIdentity, fileOperations, createId)) throw failed(); + tempOwned = false; + const finalDestination = readSafeFile(destination, fileOperations); + if ((publishedWithHardLink && !sameIdentity(tempIdentity, finalDestination.identity)) + || sha256(finalDestination.bytes) !== tempHash) { + throw conflict(); + } + return true; + } catch (error) { + if (tempOwned && tempIdentity !== null + && !removeOwnedPath(tempPath, tempIdentity, fileOperations, createId)) { + throw failed(error); + } + throw error; + } finally { + if (backupDatabase !== undefined) { + try { backupDatabase.close(); } catch {} + } + } +} + +async function prepareDatabaseSnapshots( + candidates, + operationRoot, + databaseOperations, + targetProvider, + fileOperations, + createId +) { + const prepared = []; + let created = false; + try { + for (const candidate of candidates) { + const current = fileOperations.lstatSync(candidate.path); + if (!current.isFile() || current.isSymbolicLink() || current.nlink !== 1 + || !sameIdentity(candidate.identity, identityOf(current))) { + throw conflict(); + } + assertSafeDatabaseSidecars(candidate.path, fileOperations); + const database = await databaseOperations.open(candidate.path); + const snapshot = { + ...candidate, + database, + transaction: false, + closed: false, + rowIds: [] + }; + try { + const afterOpen = fileOperations.lstatSync(candidate.path); + if (!afterOpen.isFile() || afterOpen.isSymbolicLink() || afterOpen.nlink !== 1 + || !sameIdentity(candidate.identity, identityOf(afterOpen))) { + throw conflict(); + } + assertSafeDatabaseSidecars(candidate.path, fileOperations); + database.exec("BEGIN IMMEDIATE"); + assertSafeDatabaseSidecars(candidate.path, fileOperations); + snapshot.transaction = true; + if (!databaseHasProviderColumn(database)) { + database.exec("ROLLBACK"); + snapshot.transaction = false; + database.close(); + snapshot.closed = true; + continue; + } + snapshot.rowIds = collectMismatchedRowIds(database, targetProvider); + if (snapshot.rowIds.length === 0) { + database.exec("ROLLBACK"); + snapshot.transaction = false; + database.close(); + snapshot.closed = true; + continue; + } + const beforeBackup = fileOperations.lstatSync(candidate.path); + if (!beforeBackup.isFile() || beforeBackup.isSymbolicLink() || beforeBackup.nlink !== 1 + || !sameIdentity(candidate.identity, identityOf(beforeBackup))) { + throw conflict(); + } + assertSafeDatabaseSidecars(candidate.path, fileOperations); + created = await backupOpenDatabase( + candidate, + operationRoot, + databaseOperations, + fileOperations, + createId + ) || created; + prepared.push(snapshot); + } catch (error) { + rollbackAndClosePrepared([snapshot]); + throw error; + } + } + return { created, prepared }; + } catch (error) { + rollbackAndClosePrepared(prepared); + throw error; + } +} + +function assertRolloutSnapshots(rollouts, fileOperations) { + for (const rollout of rollouts) { + if (rollout.transformed.records === 0) continue; + const current = readSafeFile(rollout.path, fileOperations); + if (!sameIdentity(current.identity, rollout.source.identity) + || !current.bytes.equals(rollout.source.bytes)) { + throw conflict(); + } + } +} + +function replaceRollout(rollout, fileOperations, createId) { + const tempPath = join( + dirname(rollout.path), + `.${basename(rollout.path)}.${nextSafeId(createId)}.tmp` + ); + let descriptor; + let tempIdentity = null; + let committed = false; + try { + descriptor = fileOperations.openSync(tempPath, "wx", 0o600); + tempIdentity = identityOf(fileOperations.fstatSync(descriptor)); + fileOperations.writeFileSync(descriptor, rollout.transformed.bytes); + fileOperations.fchmodSync(descriptor, rollout.source.mode); + fileOperations.futimesSync(descriptor, rollout.source.atime, rollout.source.mtime); + fileOperations.fsyncSync(descriptor); + const current = readSafeFile(rollout.path, fileOperations); + if (!sameIdentity(current.identity, rollout.source.identity) + || !current.bytes.equals(rollout.source.bytes)) { + throw conflict(); + } + fileOperations.renameSync(tempPath, rollout.path); + committed = true; + const published = fileOperations.lstatSync(rollout.path); + if (!published.isFile() || published.isSymbolicLink() + || !sameIdentity(tempIdentity, identityOf(published))) { + throw committedDegraded(); + } + fileOperations.closeSync(descriptor); + descriptor = undefined; + syncDirectory(dirname(rollout.path), fileOperations); + } catch (error) { + if (descriptor !== undefined) { + try { fileOperations.closeSync(descriptor); } catch {} + } + if (!committed && tempIdentity !== null) { + try { + const temp = readSafeFile(tempPath, fileOperations); + if (sameIdentity(temp.identity, tempIdentity)) fileOperations.rmSync(tempPath); + } catch {} + } + if (committed && !(error instanceof CrpError + && error.code === "CODEX_HISTORY_REPAIR_COMMITTED_DEGRADED")) { + throw committedDegraded(error); + } + throw error; + } +} + +function repairRollouts(rollouts, fileOperations, createId) { + const summary = { + rolloutFiles: 0, + rolloutRecords: 0, + encryptedContentDetected: false + }; + for (const rollout of rollouts) { + summary.encryptedContentDetected ||= rollout.transformed.encryptedContentDetected; + if (rollout.transformed.records === 0) continue; + replaceRollout(rollout, fileOperations, createId); + summary.rolloutFiles = boundedAdd(summary.rolloutFiles); + summary.rolloutRecords = boundedAdd( + summary.rolloutRecords, + rollout.transformed.records + ); + } + return summary; +} + +function rowIdBatches(rowIds, size = 400) { + const batches = []; + for (let index = 0; index < rowIds.length; index += size) { + batches.push(rowIds.slice(index, index + size)); + } + return batches; +} + +function numericChanges(value) { + const number = typeof value === "bigint" ? Number(value) : value; + if (!Number.isSafeInteger(number) || number < 0) throw failed(); + return number; +} + +async function repairPreparedDatabases(prepared, targetProvider, fileOperations) { + const summary = { sqliteFiles: 0, sqliteRows: 0 }; + for (let index = 0; index < prepared.length; index += 1) { + const snapshot = prepared[index]; + try { + const current = fileOperations.lstatSync(snapshot.path); + if (!current.isFile() || current.isSymbolicLink() || current.nlink !== 1 + || !sameIdentity(snapshot.identity, identityOf(current))) { + throw conflict(); + } + assertSafeDatabaseSidecars(snapshot.path, fileOperations); + let changes = 0; + for (const batch of rowIdBatches(snapshot.rowIds)) { + const placeholders = batch.map(() => "?").join(", "); + const result = snapshot.database.prepare( + `UPDATE threads SET model_provider = ? WHERE rowid IN (${placeholders}) AND model_provider IS NOT ?` + ).run(targetProvider, ...batch, targetProvider); + changes = boundedAdd(changes, numericChanges(result?.changes ?? 0)); + const remaining = snapshot.database.prepare( + `SELECT COUNT(*) AS count FROM threads WHERE rowid IN (${placeholders}) AND model_provider IS NOT ?` + ).get(...batch, targetProvider); + if (numericChanges(remaining?.count ?? 0) !== 0) throw failed(); + } + snapshot.database.exec("COMMIT"); + snapshot.transaction = false; + assertSafeDatabaseSidecars(snapshot.path, fileOperations); + snapshot.database.close(); + snapshot.closed = true; + summary.sqliteFiles = boundedAdd(summary.sqliteFiles); + summary.sqliteRows = boundedAdd(summary.sqliteRows, changes); + } catch (error) { + rollbackAndClosePrepared(prepared.slice(index)); + throw error; + } + } + return summary; +} + +async function verifyHistory(root, fileOperations, databaseOperations, targetProvider) { + const rollouts = discoverRollouts(root, fileOperations, targetProvider); + if (rollouts.some((rollout) => rollout.transformed.records > 0)) throw failed(); + const rolloutDirectories = [...new Set(rollouts.map((rollout) => dirname(rollout.path)))]; + rolloutDirectories.sort((left, right) => left.localeCompare(right, "en")); + for (const directory of rolloutDirectories) syncDirectory(directory, fileOperations); + const databases = discoverDatabases(root, fileOperations); + const inspected = await inspectDatabases( + databases, + databaseOperations, + targetProvider, + fileOperations + ); + if (inspected.some((database) => database.rows > 0)) throw failed(); + return rollouts.some((rollout) => rollout.transformed.encryptedContentDetected); +} + +function validateTransition(transition, targetBytes) { + if (!isPlainObject(transition) || typeof transition.required !== "boolean" + || (transition.sourceConfigSha256 !== null + && !SHA256_PATTERN.test(transition.sourceConfigSha256)) + || !SHA256_PATTERN.test(transition.targetConfigSha256) + || transition.targetConfigSha256 !== sha256(targetBytes)) { + throw invalid(); + } + if (transition.required && transition.sourceConfigSha256 === null) throw invalid(); +} + +function configHashAtRoot(root, fileOperations) { + const config = readSafeFile(safeChild(root.path, "config.toml"), fileOperations, { + missing: true + }); + return config === null ? null : sha256(config.bytes); +} + +function assertCurrentConfig(root, expectedHash, fileOperations) { + const actual = configHashAtRoot(root, fileOperations); + if (actual !== expectedHash) throw conflict(); +} + +function publishTarget({ + root, + targetBytes, + publishConfig, + sourceHash, + targetHash, + fileOperations, + committedFailure = committedDegraded +}) { + let publishResult; + let callbackError = null; + try { + publishResult = publishConfig(Buffer.from(targetBytes)); + } catch (error) { + callbackError = error; + } + if (callbackError !== null) { + let actual; + try { + actual = configHashAtRoot(root, fileOperations); + } catch (error) { + throw committedFailure(error); + } + if (actual === targetHash) { + return { publishResult: undefined, callbackError }; + } + if (actual === sourceHash) throw failed(callbackError); + throw conflict(callbackError); + } + if (publishResult !== null && typeof publishResult === "object" + && typeof publishResult.then === "function") { + throw invalid(); + } + let actual; + try { + actual = configHashAtRoot(root, fileOperations); + } catch (error) { + throw committedFailure(error); + } + if (actual === sourceHash) throw failed(); + if (actual !== targetHash) throw conflict(); + return { publishResult, callbackError: null }; +} + +function restorePendingMarker(pending, fileOperations, createId) { + try { + if (lstatMaybe(pending.paths.pending, fileOperations) !== null + || lstatMaybe(pending.paths.clearing, fileOperations) !== null) { + return true; + } + const restored = writeAtomicExclusive( + pending.paths.pending, + pending.source.bytes, + fileOperations, + createId + ); + return restored.bytes.equals(pending.source.bytes); + } catch { + try { + return lstatMaybe(pending.paths.pending, fileOperations) !== null + || lstatMaybe(pending.paths.clearing, fileOperations) !== null; + } catch { + return false; + } + } +} + +function retainConfigLock(error) { + Object.defineProperty(error, "retainConfigLock", { + value: true, + enumerable: false + }); + return error; +} + +function clearPending(pending, fileOperations, createId) { + let clearingClaimed = pending.sourcePath === pending.paths.clearing; + try { + if (!clearingClaimed) { + if (lstatMaybe(pending.paths.clearing, fileOperations) !== null) return false; + fileOperations.renameSync(pending.paths.pending, pending.paths.clearing); + syncDirectory(pending.paths.managed, fileOperations); + clearingClaimed = true; + } + const clearing = readSafeFile(pending.paths.clearing, fileOperations); + if (!sameIdentity(clearing.identity, pending.source.identity) + || !clearing.bytes.equals(pending.source.bytes)) { + return false; + } + fileOperations.rmSync(pending.paths.clearing); + syncDirectory(pending.paths.managed, fileOperations); + return true; + } catch { + if (!restorePendingMarker(pending, fileOperations, createId)) { + throw retainConfigLock(committedDegraded()); + } + return false; + } +} + +function callSynchronousHook(hook, binding) { + if (hook === null) return; + const result = hook(Object.freeze({ ...binding })); + if (result !== null && typeof result === "object" + && typeof result.then === "function") { + throw invalid(); + } +} + +function committedError(error) { + return error instanceof CrpError + && (error.code === "CODEX_HISTORY_REPAIR_COMMITTED_DEGRADED" + || error.code === "CODEX_CONFIG_COMMITTED_DEGRADED") + ? error + : committedDegraded(error); +} + +function emptyHistorySummary({ required, resumed }) { + return { + required, + completed: false, + resumed, + backupCreated: false, + rolloutFiles: 0, + rolloutRecords: 0, + sqliteFiles: 0, + sqliteRows: 0, + encryptedContentDetected: false + }; +} + +export async function runCodexHistoryRepairTransition({ + codexRoot, + currentConfigBytes, + targetConfigBytes, + transition, + publishConfig, + fileOperations: fileOverrides = {}, + databaseOperations: databaseOverrides = {}, + now = () => new Date().toISOString(), + createId = randomUUID, + beforeJournalPublish = null, + beforePendingClear = null, + assertConfigLock = null +}) { + if (typeof publishConfig !== "function" || typeof now !== "function" + || typeof createId !== "function" + || ![beforeJournalPublish, beforePendingClear, assertConfigLock].every( + (hook) => hook === null || typeof hook === "function" + )) { + throw invalid(); + } + const fileOperations = { ...DEFAULT_FILE_OPERATIONS, ...fileOverrides }; + const databaseOperations = { ...DEFAULT_DATABASE_OPERATIONS, ...databaseOverrides }; + if (typeof databaseOperations.open !== "function" + || typeof databaseOperations.backup !== "function") { + throw invalid(); + } + const root = rootContext(codexRoot, fileOperations); + const currentBytes = asBytes(currentConfigBytes); + const targetBytes = asBytes(targetConfigBytes); + validateTransition(transition, targetBytes); + + let configPublished = false; + let preparedDatabases = []; + + try { + assertRoot(root, fileOperations); + let pending = readPending(root.path, fileOperations); + if (pending === null && !transition.required) { + return { + handled: false, + configPublished: false, + publishResult: undefined, + historyRepair: { + ...emptyHistorySummary({ required: false, resumed: false }), + completed: true + } + }; + } + + const resumed = pending !== null; + let operationRoot; + let backupCreated = false; + let rollouts = []; + let databaseCandidates = []; + const targetBinding = inspectCodexProviderBinding(targetBytes.toString("utf8")); + if (targetBinding.providerName === null || targetBinding.normalizedBaseUrl === null) { + throw invalid(); + } + + if (pending === null) { + if (sha256(currentBytes) !== transition.sourceConfigSha256) throw conflict(); + assertCurrentConfig(root, transition.sourceConfigSha256, fileOperations); + rollouts = discoverRollouts(root, fileOperations, targetBinding.providerName); + databaseCandidates = discoverDatabases(root, fileOperations); + const initialDatabases = await inspectDatabases( + databaseCandidates, + databaseOperations, + targetBinding.providerName, + fileOperations + ); + const historyChangesRequired = rollouts.some( + (rollout) => rollout.transformed.records > 0 + ) || initialDatabases.some((database) => database.rows > 0); + if (!historyChangesRequired) { + const configOnlyBinding = { + operationId: nextSafeId(createId), + sourceConfigSha256: transition.sourceConfigSha256, + targetConfigSha256: transition.targetConfigSha256, + pendingRequired: false + }; + callSynchronousHook(beforeJournalPublish, configOnlyBinding); + const published = publishTarget({ + root, + targetBytes, + publishConfig, + sourceHash: transition.sourceConfigSha256, + targetHash: transition.targetConfigSha256, + fileOperations, + committedFailure: configCommittedDegraded + }); + configPublished = true; + if (published.callbackError !== null) { + throw configCommittedDegraded(published.callbackError); + } + try { + callSynchronousHook(beforePendingClear, configOnlyBinding); + assertCurrentConfig(root, transition.targetConfigSha256, fileOperations); + } catch (error) { + throw configCommittedDegraded(error); + } + return { + handled: true, + configPublished: true, + publishResult: published.publishResult, + historyRepair: { + required: true, + completed: true, + resumed: false, + backupCreated: false, + rolloutFiles: 0, + rolloutRecords: 0, + sqliteFiles: 0, + sqliteRows: 0, + encryptedContentDetected: rollouts.some( + (rollout) => rollout.transformed.encryptedContentDetected + ) + } + }; + } + const managed = createManagedOperation({ + root, + transition, + targetProvider: targetBinding.providerName, + fileOperations, + now, + createId + }); + operationRoot = managed.operationRoot; + backupCreated = backupRollouts(rollouts, operationRoot, fileOperations, createId); + const prepared = await prepareDatabaseSnapshots( + databaseCandidates, + operationRoot, + databaseOperations, + targetBinding.providerName, + fileOperations, + createId + ); + preparedDatabases = prepared.prepared; + backupCreated = prepared.created || backupCreated; + assertRolloutSnapshots(rollouts, fileOperations); + const binding = { + ...pendingBinding({ journal: managed.journal }), + pendingRequired: true + }; + callSynchronousHook(beforeJournalPublish, binding); + try { + const source = writeAtomicExclusive( + managed.paths.pending, + managed.bytes, + fileOperations, + createId + ); + pending = { + source, + sourcePath: managed.paths.pending, + journal: managed.journal, + paths: managed.paths + }; + } catch (error) { + throw error?.code === "EEXIST" ? conflict(error) : error; + } + } + + if (pending.journal.targetConfigSha256 !== transition.targetConfigSha256) { + throw conflict(); + } + const currentHash = sha256(currentBytes); + if (currentHash !== pending.journal.sourceConfigSha256 + && currentHash !== pending.journal.targetConfigSha256) { + throw conflict(); + } + configPublished = currentHash === pending.journal.targetConfigSha256; + operationRoot = assertManagedOperation(pending, fileOperations); + backupCreated ||= operationHasBackups(operationRoot, fileOperations); + assertCurrentConfig(root, currentHash, fileOperations); + if (targetBinding.providerName !== pending.journal.targetProvider) throw conflict(); + const binding = { ...pendingBinding(pending), pendingRequired: true }; + callSynchronousHook(assertConfigLock, binding); + + if (resumed) { + rollouts = discoverRollouts(root, fileOperations, targetBinding.providerName); + databaseCandidates = discoverDatabases(root, fileOperations); + backupCreated = backupRollouts(rollouts, operationRoot, fileOperations, createId) + || backupCreated; + const prepared = await prepareDatabaseSnapshots( + databaseCandidates, + operationRoot, + databaseOperations, + targetBinding.providerName, + fileOperations, + createId + ); + preparedDatabases = prepared.prepared; + backupCreated = prepared.created || backupCreated; + assertRolloutSnapshots(rollouts, fileOperations); + callSynchronousHook(assertConfigLock, binding); + } + + let publishResult; + let publishCallbackError = null; + if (!configPublished) { + const published = publishTarget({ + root, + targetBytes, + publishConfig, + sourceHash: pending.journal.sourceConfigSha256, + targetHash: pending.journal.targetConfigSha256, + fileOperations + }); + publishResult = published.publishResult; + publishCallbackError = published.callbackError; + configPublished = true; + } + callSynchronousHook(assertConfigLock, binding); + assertCurrentConfig(root, pending.journal.targetConfigSha256, fileOperations); + + let rolloutSummary; + let sqliteSummary; + try { + rolloutSummary = repairRollouts(rollouts, fileOperations, createId); + sqliteSummary = await repairPreparedDatabases( + preparedDatabases, + targetBinding.providerName, + fileOperations + ); + preparedDatabases = []; + const encryptedAfterVerify = await verifyHistory( + root, + fileOperations, + databaseOperations, + targetBinding.providerName + ); + rolloutSummary.encryptedContentDetected ||= encryptedAfterVerify; + } catch (error) { + if (configPublished) throw committedDegraded(error); + throw error; + } + + if (publishCallbackError !== null) throw committedDegraded(publishCallbackError); + callSynchronousHook(beforePendingClear, binding); + callSynchronousHook(assertConfigLock, binding); + const encryptedBeforeClear = await verifyHistory( + root, + fileOperations, + databaseOperations, + targetBinding.providerName + ); + rolloutSummary.encryptedContentDetected ||= encryptedBeforeClear; + callSynchronousHook(assertConfigLock, binding); + assertCurrentConfig(root, pending.journal.targetConfigSha256, fileOperations); + if (!clearPending(pending, fileOperations, createId)) throw committedDegraded(); + return { + handled: true, + configPublished, + publishResult, + historyRepair: { + required: true, + completed: true, + resumed, + backupCreated, + rolloutFiles: rolloutSummary.rolloutFiles, + rolloutRecords: rolloutSummary.rolloutRecords, + sqliteFiles: sqliteSummary.sqliteFiles, + sqliteRows: sqliteSummary.sqliteRows, + encryptedContentDetected: rolloutSummary.encryptedContentDetected + } + }; + } catch (error) { + rollbackAndClosePrepared(preparedDatabases); + if (configPublished) throw committedError(error); + if (error instanceof CrpError) throw error; + throw failed(error); + } +} diff --git a/node/src/credentials/credential-store.mjs b/node/src/credentials/credential-store.mjs new file mode 100644 index 0000000..b628d02 --- /dev/null +++ b/node/src/credentials/credential-store.mjs @@ -0,0 +1,71 @@ +import { FileCredentialStore } from "./file-credential-store.mjs"; +import { NativeKeyringStore } from "./native-keyring.mjs"; +import { getPaths } from "../shared/paths.mjs"; +import { CrpError } from "../shared/errors.mjs"; + +function backendInvalid() { + return new CrpError( + "CREDENTIAL_BACKEND_INVALID", + "The credential backend is invalid.", + "Choose the native or file credential backend.", + { status: 400 } + ); +} + +function fallbackConsentRequired() { + return new CrpError( + "CREDENTIAL_FALLBACK_CONSENT_REQUIRED", + "File credential storage requires explicit consent.", + "Confirm file fallback storage before trying again.", + { status: 400 } + ); +} + +function backendUnavailable(cause) { + return new CrpError( + "CREDENTIAL_BACKEND_UNAVAILABLE", + "The credential backend is unavailable.", + "Check the native credential service or explicitly consent to file fallback storage.", + { status: 500, cause } + ); +} + +function asBackendUnavailable(error) { + if (error instanceof CrpError && error.code === "CREDENTIAL_BACKEND_UNAVAILABLE") { + return error; + } + return backendUnavailable(error); +} + +function createFileStore(paths, fileStoreFactory) { + try { + return fileStoreFactory({ path: paths.secretFallbackPath }); + } catch (error) { + if (error instanceof CrpError) throw error; + throw backendUnavailable(error); + } +} + +export function createCredentialStore({ + backend = "native", + fallbackConsent = false, + paths = getPaths(), + nativeStoreFactory = () => new NativeKeyringStore(), + fileStoreFactory = (options) => new FileCredentialStore(options) +} = {}) { + if (backend !== "native" && backend !== "file") throw backendInvalid(); + + if (backend === "file") { + if (fallbackConsent !== true) throw fallbackConsentRequired(); + return createFileStore(paths, fileStoreFactory); + } + + let nativeStore; + try { + nativeStore = nativeStoreFactory(); + } catch (error) { + if (fallbackConsent !== true) throw asBackendUnavailable(error); + return createFileStore(paths, fileStoreFactory); + } + return nativeStore; +} diff --git a/node/src/credentials/file-credential-store.mjs b/node/src/credentials/file-credential-store.mjs new file mode 100644 index 0000000..48dee06 --- /dev/null +++ b/node/src/credentials/file-credential-store.mjs @@ -0,0 +1,796 @@ +import { randomUUID } from "node:crypto"; +import { + chmodSync, + closeSync, + constants as fsConstants, + existsSync, + fstatSync, + fsyncSync, + linkSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + rmdirSync, + rmSync, + writeFileSync +} from "node:fs"; +import { basename, dirname, join } from "node:path"; + +import { CrpError } from "../shared/errors.mjs"; + +const DOCUMENT_FIELDS = new Set(["schemaVersion", "credentials"]); +const FORBIDDEN_REFS = new Set(["__proto__", "constructor", "prototype"]); +const REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/; +const LOCK_CLEANUP_ATTEMPTS = 2; +const DEFAULT_FILE_OPERATIONS = { + chmodSync, + closeSync, + existsSync, + fstatSync, + fsyncSync, + linkSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + rmdirSync, + rmSync, + writeFileSync +}; + +function emptyDocument() { + return { schemaVersion: 1, credentials: {} }; +} + +function clone(value) { + return structuredClone(value); +} + +function isPlainObject(value) { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function isValidRef(ref) { + return typeof ref === "string" + && REF_PATTERN.test(ref) + && !FORBIDDEN_REFS.has(ref); +} + +function credentialInputInvalid() { + return new CrpError( + "CREDENTIAL_INPUT_INVALID", + "The credential input is invalid.", + "Use a valid credential reference and a non-empty secret.", + { status: 400 } + ); +} + +function credentialNotFound() { + return new CrpError( + "CREDENTIAL_NOT_FOUND", + "The credential does not exist.", + "Save the provider credential and try again.", + { status: 404 } + ); +} + +function credentialFileInvalid(cause) { + return new CrpError( + "CREDENTIAL_FILE_INVALID", + "The credential fallback file is invalid.", + "Restore a valid credential fallback file or remove it after making a backup.", + { status: 500, cause } + ); +} + +function credentialFileInsecure(cause) { + return new CrpError( + "CREDENTIAL_FILE_INSECURE", + "The credential fallback file is not secure.", + "Replace it with a regular private file and try again.", + { status: 500, cause } + ); +} + +function backendUnavailable(cause) { + return new CrpError( + "CREDENTIAL_BACKEND_UNAVAILABLE", + "The credential backend is unavailable.", + "Check local file permissions and try again.", + { status: 500, cause } + ); +} + +function credentialStoreBusy(cause) { + return new CrpError( + "CREDENTIAL_STORE_BUSY", + "The credential fallback is already being updated.", + "Wait for the current credential update to finish and try again.", + { status: 409, cause } + ); +} + +function committedLockDegraded() { + return new CrpError( + "CREDENTIAL_STORE_COMMITTED_LOCK_DEGRADED", + "The credential change was saved, but its lock could not be fully released.", + "Stop CRP, explicitly repair the residual credential lock, then restart CRP.", + { status: 500, details: { committed: true } } + ); +} + +function credentialLockDegraded() { + return new CrpError( + "CREDENTIAL_STORE_LOCK_DEGRADED", + "The credential fallback lock could not be safely recovered.", + "Stop CRP, explicitly repair the residual credential lock, then restart CRP.", + { status: 500, details: { committed: false } } + ); +} + +function credentialTempDegraded(cause) { + return new CrpError( + "CREDENTIAL_STORE_TEMP_DEGRADED", + "A credential temporary file could not be safely removed.", + "Stop CRP, explicitly remove the residual credential temporary file, then restart CRP.", + { status: 500, details: { committed: false }, cause } + ); +} + +function assertRef(ref) { + if (!isValidRef(ref)) throw credentialInputInvalid(); +} + +function assertSecret(secret) { + if (typeof secret !== "string" || secret.length === 0) { + throw credentialInputInvalid(); + } +} + +function validateDocument(document) { + try { + if ( + !isPlainObject(document) + || Object.keys(document).length !== DOCUMENT_FIELDS.size + || !Object.keys(document).every((key) => DOCUMENT_FIELDS.has(key)) + || document.schemaVersion !== 1 + || !isPlainObject(document.credentials) + ) { + throw new Error("invalid credential document"); + } + for (const [ref, secret] of Object.entries(document.credentials)) { + if (!isValidRef(ref) || typeof secret !== "string" || secret.length === 0) { + throw new Error("invalid credential entry"); + } + } + return document; + } catch (error) { + if (error instanceof CrpError && error.code === "CREDENTIAL_FILE_INVALID") { + throw error; + } + throw credentialFileInvalid(error); + } +} + +function parseDocument(bytes) { + let document; + try { + document = JSON.parse(bytes); + } catch (error) { + throw credentialFileInvalid(error); + } + return validateDocument(document); +} + +function safeFileError(error) { + return error instanceof CrpError ? error : backendUnavailable(error); +} + +function sameFileIdentity(first, second) { + for (const field of ["dev", "ino"]) { + if (first[field] !== undefined && second[field] !== undefined) { + if (first[field] !== second[field]) return false; + } + } + return true; +} + +export class FileCredentialStore { + #document; + #degradedLock = null; + #degradedTemp = null; + + constructor({ path, fileOperations, platform = process.platform } = {}) { + if (typeof path !== "string" || path.length === 0) { + throw credentialInputInvalid(); + } + this.backend = "file"; + this.path = path; + this.lockPath = `${path}.crp.lock`; + this.gatePath = `${this.lockPath}.gate`; + this.platform = platform; + this.fileOperations = { ...DEFAULT_FILE_OPERATIONS, ...fileOperations }; + this.#document = this.#load(); + } + + #load() { + const parent = dirname(this.path); + const parentStats = this.#inspectParent(parent); + if (parentStats === null) return emptyDocument(); + + let pathStats; + try { + pathStats = this.fileOperations.lstatSync(this.path); + } catch (error) { + if (error?.code === "ENOENT") return emptyDocument(); + throw backendUnavailable(error); + } + this.#assertCredentialFile(pathStats); + + const noFollow = this.platform !== "win32" + && typeof fsConstants.O_NOFOLLOW === "number" + ? fsConstants.O_NOFOLLOW + : 0; + const flags = fsConstants.O_RDONLY | noFollow; + let fileDescriptor; + let bytes; + let primaryError; + try { + fileDescriptor = this.fileOperations.openSync(this.path, flags); + const openedStats = this.fileOperations.fstatSync(fileDescriptor); + this.#assertCredentialFile(openedStats); + if (!sameFileIdentity(pathStats, openedStats)) { + throw credentialFileInsecure(new Error("Credential file identity changed")); + } + const finalPathStats = this.fileOperations.lstatSync(this.path); + this.#assertCredentialFile(finalPathStats); + if (!sameFileIdentity(openedStats, finalPathStats)) { + throw credentialFileInsecure(new Error("Credential file identity changed")); + } + bytes = this.fileOperations.readFileSync(fileDescriptor, "utf8"); + } catch (error) { + primaryError = error?.code === "ELOOP" || error?.code === "ENOENT" + ? credentialFileInsecure(error) + : safeFileError(error); + } + let closeError; + if (fileDescriptor !== undefined) { + try { + this.fileOperations.closeSync(fileDescriptor); + } catch (error) { + closeError = error; + } + } + if (primaryError !== undefined) throw primaryError; + if (closeError !== undefined) throw backendUnavailable(closeError); + return parseDocument(bytes); + } + + #inspectParent(parent) { + let stats; + try { + stats = this.fileOperations.lstatSync(parent); + } catch (error) { + if (error?.code === "ENOENT") return null; + throw backendUnavailable(error); + } + if (stats.isSymbolicLink() || !stats.isDirectory()) { + throw credentialFileInsecure(new Error("Credential parent is not a real directory")); + } + if (this.platform !== "win32" && (stats.mode & 0o777) !== 0o700) { + throw credentialFileInsecure(new Error("Credential parent permissions are not private")); + } + return stats; + } + + #assertCredentialFile(stats) { + if (stats.isSymbolicLink?.() || !stats.isFile()) { + throw credentialFileInsecure(new Error("Credential path is not a regular file")); + } + if (this.platform !== "win32" && (stats.mode & 0o777) !== 0o600) { + throw credentialFileInsecure(new Error("Credential file permissions are not private")); + } + } + + #refresh() { + const document = this.#load(); + this.#document = document; + return document; + } + + #prepareParent() { + const parent = dirname(this.path); + if (this.#inspectParent(parent) !== null) return; + const created = this.fileOperations.mkdirSync(parent, { + recursive: true, + mode: 0o700 + }); + if (this.platform !== "win32" && created !== undefined) { + this.fileOperations.chmodSync(parent, 0o700); + } + this.#inspectParent(parent); + } + + #acquireGate() { + try { + this.fileOperations.mkdirSync(this.gatePath, { mode: 0o700 }); + } catch (error) { + if (error?.code === "EEXIST") throw credentialStoreBusy(error); + throw error; + } + + let stats; + try { + stats = this.fileOperations.lstatSync(this.gatePath); + } catch { + this.#degradedLock = { path: this.gatePath }; + throw credentialLockDegraded(); + } + if ( + stats.isSymbolicLink() + || !stats.isDirectory() + || (this.platform !== "win32" && (stats.mode & 0o777) !== 0o700) + ) { + this.#degradedLock = { path: this.gatePath }; + throw credentialLockDegraded(); + } + return { active: true, stats }; + } + + #releaseGate(gate) { + if (!gate?.active) { + return { + ok: true, + primaryReleaseSafe: true, + residualGate: false, + foreign: false, + path: null + }; + } + gate.active = false; + const claimPath = join( + dirname(this.gatePath), + `.${basename(this.gatePath)}.${process.pid}.${randomUUID()}.claim` + ); + try { + this.fileOperations.renameSync(this.gatePath, claimPath); + } catch (error) { + const blocked = this.#ensureGateBlocked(); + return { + ok: false, + primaryReleaseSafe: blocked, + residualGate: true, + foreign: false, + path: this.gatePath + }; + } + + let claimed; + try { + claimed = this.fileOperations.lstatSync(claimPath); + } catch { + const blocked = this.#ensureGateBlocked(); + return { + ok: false, + primaryReleaseSafe: blocked, + residualGate: true, + foreign: false, + path: blocked ? this.gatePath : claimPath + }; + } + if ( + claimed.isSymbolicLink() + || !claimed.isDirectory() + || !sameFileIdentity(gate.stats, claimed) + ) { + const blocked = this.#ensureGateBlocked(); + return { + ok: false, + primaryReleaseSafe: blocked, + residualGate: true, + foreign: true, + path: claimPath + }; + } + for (let attempt = 0; attempt < LOCK_CLEANUP_ATTEMPTS; attempt += 1) { + try { + this.fileOperations.rmdirSync(claimPath); + return { + ok: true, + primaryReleaseSafe: true, + residualGate: false, + foreign: false, + path: null + }; + } catch (error) { + if (error?.code === "ENOENT") { + return { + ok: true, + primaryReleaseSafe: true, + residualGate: false, + foreign: false, + path: null + }; + } + } + } + return { + ok: false, + primaryReleaseSafe: true, + residualGate: this.fileOperations.existsSync(claimPath), + foreign: false, + path: claimPath + }; + } + + #ensureGateBlocked() { + try { + this.fileOperations.mkdirSync(this.gatePath, { mode: 0o700 }); + return true; + } catch (error) { + return error?.code === "EEXIST"; + } + } + + #acquireLock() { + if (this.#degradedTemp !== null) { + throw credentialTempDegraded(); + } + if (this.#degradedLock !== null) { + throw credentialLockDegraded(); + } + this.#prepareParent(); + const gate = this.#acquireGate(); + const token = `${randomUUID()}\n`; + let fileDescriptor; + try { + fileDescriptor = this.fileOperations.openSync(this.lockPath, "wx", 0o600); + } catch (error) { + const gateCleanup = this.#releaseGate(gate); + if (!gateCleanup.ok) { + this.#degradedLock = { path: gateCleanup.path }; + } + if (error?.code === "EEXIST") throw credentialStoreBusy(error); + throw error; + } + + const lock = { fileDescriptor, token, gate, active: true }; + try { + this.fileOperations.writeFileSync(fileDescriptor, token, "utf8"); + return lock; + } catch (error) { + const cleanup = this.#releaseLock(lock); + if (cleanup.residualLock) this.#degradedLock = { token }; + throw error; + } + } + + #closeLock(fileDescriptor) { + let error = null; + for (let attempt = 0; attempt < LOCK_CLEANUP_ATTEMPTS; attempt += 1) { + try { + this.fileOperations.closeSync(fileDescriptor); + return { closed: true, error: null }; + } catch (caught) { + if (attempt > 0 && caught?.code === "EBADF") { + return { closed: true, error: null }; + } + error = caught; + } + } + return { closed: false, error }; + } + + #removeOwnedLock(token) { + let releasePath; + let error = null; + for (let attempt = 0; attempt < LOCK_CLEANUP_ATTEMPTS; attempt += 1) { + releasePath = join( + dirname(this.lockPath), + `.${basename(this.lockPath)}.${process.pid}.${randomUUID()}.release` + ); + try { + this.fileOperations.renameSync(this.lockPath, releasePath); + error = null; + break; + } catch (caught) { + if (caught?.code === "ENOENT") { + return { + removed: true, + residualLock: false, + foreign: false, + error: null, + residualPath: null + }; + } + error = caught; + } + } + if (error !== null) { + let residualLock = true; + try { + residualLock = this.fileOperations.existsSync(this.lockPath); + } catch { + // Uncertain claim state is treated as a permanent residual lock. + } + return { + removed: false, + residualLock, + foreign: false, + error, + residualPath: residualLock ? this.lockPath : null + }; + } + + let currentToken; + try { + currentToken = this.fileOperations.readFileSync(releasePath, "utf8"); + } catch (caught) { + return { + removed: false, + residualLock: true, + foreign: false, + error: caught, + residualPath: releasePath + }; + } + if (currentToken !== token) { + return { + removed: false, + residualLock: true, + foreign: true, + error: null, + residualPath: releasePath + }; + } + + for (let attempt = 0; attempt < LOCK_CLEANUP_ATTEMPTS; attempt += 1) { + try { + this.fileOperations.rmSync(releasePath, { force: true }); + return { + removed: true, + residualLock: false, + foreign: false, + error: null, + residualPath: null + }; + } catch (caught) { + if (caught?.code === "ENOENT") { + return { + removed: true, + residualLock: false, + foreign: false, + error: null, + residualPath: null + }; + } + error = caught; + } + } + let residualLock = true; + try { + residualLock = this.fileOperations.existsSync(releasePath); + } catch { + // Uncertain ownership state is treated as a permanent residual lock. + } + return { + removed: false, + residualLock, + foreign: false, + error, + residualPath: residualLock ? releasePath : null + }; + } + + #canonicalState() { + try { + const stats = this.fileOperations.lstatSync(this.lockPath); + return { + known: true, + blocking: stats.isFile() ? stats.size > 0 : true + }; + } catch (error) { + if (error?.code === "ENOENT") return { known: true, blocking: false }; + return { known: false, blocking: false }; + } + } + + #ensureCanonicalBlocked(residualPath) { + const initial = this.#canonicalState(); + if (initial.blocking) return true; + if (!initial.known) return false; + + if (residualPath !== null && residualPath !== this.lockPath) { + try { + this.fileOperations.linkSync(residualPath, this.lockPath); + return this.#canonicalState().blocking; + } catch (error) { + if (error?.code === "EEXIST") { + return this.#canonicalState().blocking; + } + } + } + + const marker = `blocked-${randomUUID()}\n`; + let fileDescriptor; + try { + fileDescriptor = this.fileOperations.openSync(this.lockPath, "wx", 0o600); + this.fileOperations.writeFileSync(fileDescriptor, marker, "utf8"); + this.fileOperations.fsyncSync(fileDescriptor); + this.fileOperations.closeSync(fileDescriptor); + fileDescriptor = undefined; + return this.#canonicalState().blocking; + } catch (error) { + if (fileDescriptor !== undefined) { + try { + this.fileOperations.closeSync(fileDescriptor); + } catch { + // Retain the gate when canonical occupancy cannot be proven. + } + } + if (error?.code === "EEXIST") return this.#canonicalState().blocking; + return false; + } + } + + #releaseLock(lock) { + if (!lock?.active) { + return { ok: true, residualLock: false, foreignLock: false }; + } + lock.active = false; + const gate = this.#releaseGate(lock.gate); + const close = this.#closeLock(lock.fileDescriptor); + const removal = gate.primaryReleaseSafe + ? this.#removeOwnedLock(lock.token) + : { + removed: false, + residualLock: true, + foreign: false, + error: null, + residualPath: this.lockPath + }; + const canonicalBlocked = !removal.residualLock + || this.#ensureCanonicalBlocked(removal.residualPath); + return { + ok: close.closed && removal.removed && canonicalBlocked && gate.ok, + closeError: close.error, + removalError: removal.error, + residualLock: removal.residualLock || gate.residualGate, + foreignLock: removal.foreign || gate.foreign, + residualPath: gate.residualGate ? gate.path : removal.residualPath + }; + } + + #recordCleanupState(lock, cleanup) { + this.#degradedLock = cleanup.residualLock + ? { token: lock.token, path: cleanup.residualPath } + : null; + } + + #persist(document) { + const bytes = `${JSON.stringify(document, null, 2)}\n`; + const parent = dirname(this.path); + const tempPath = join( + parent, + `.${basename(this.path)}.${process.pid}.${randomUUID()}.tmp` + ); + let fileDescriptor; + try { + fileDescriptor = this.fileOperations.openSync(tempPath, "wx", 0o600); + this.fileOperations.writeFileSync(fileDescriptor, bytes, "utf8"); + this.fileOperations.fsyncSync(fileDescriptor); + this.fileOperations.closeSync(fileDescriptor); + fileDescriptor = undefined; + this.fileOperations.chmodSync(tempPath, 0o600); + this.fileOperations.renameSync(tempPath, this.path); + } catch (error) { + if (fileDescriptor !== undefined) { + this.#closeTemporary(fileDescriptor); + } + const cleanup = this.#removeTemporary(tempPath); + if (!cleanup.removed) { + this.#degradedTemp = { path: tempPath }; + throw credentialTempDegraded(error); + } + throw error; + } + } + + #closeTemporary(fileDescriptor) { + for (let attempt = 0; attempt < LOCK_CLEANUP_ATTEMPTS; attempt += 1) { + try { + this.fileOperations.closeSync(fileDescriptor); + return true; + } catch (error) { + if (attempt > 0 && error?.code === "EBADF") return true; + } + } + return false; + } + + #removeTemporary(tempPath) { + for (let attempt = 0; attempt < LOCK_CLEANUP_ATTEMPTS; attempt += 1) { + try { + this.fileOperations.rmSync(tempPath, { force: true }); + return { removed: true, residual: false }; + } catch (error) { + if (error?.code === "ENOENT") { + return { removed: true, residual: false }; + } + } + } + let residual = true; + try { + residual = this.fileOperations.existsSync(tempPath); + } catch { + // Uncertain cleanup state is treated as a residual secret-bearing file. + } + return { removed: !residual, residual }; + } + + #commit(mutator) { + let lock; + let result; + let primaryError; + let committed = false; + try { + lock = this.#acquireLock(); + const candidate = clone(this.#load()); + result = mutator(candidate); + validateDocument(candidate); + this.#persist(candidate); + this.#document = candidate; + committed = true; + } catch (error) { + primaryError = error; + } + + let cleanup = { ok: true, residualLock: false }; + if (lock !== undefined) { + cleanup = this.#releaseLock(lock); + this.#recordCleanupState(lock, cleanup); + } + if (primaryError !== undefined) throw safeFileError(primaryError); + if (!cleanup.ok) { + if (committed) throw committedLockDegraded(); + throw credentialLockDegraded(); + } + return result; + } + + async set(ref, secret) { + assertRef(ref); + assertSecret(secret); + this.#commit((document) => { + document.credentials[ref] = secret; + }); + } + + async get(ref) { + assertRef(ref); + const document = this.#refresh(); + if (!Object.hasOwn(document.credentials, ref)) throw credentialNotFound(); + return document.credentials[ref]; + } + + async has(ref) { + assertRef(ref); + return Object.hasOwn(this.#refresh().credentials, ref); + } + + async delete(ref) { + assertRef(ref); + return this.#commit((document) => { + if (!Object.hasOwn(document.credentials, ref)) return false; + delete document.credentials[ref]; + return true; + }); + } +} diff --git a/node/src/credentials/native-keyring.mjs b/node/src/credentials/native-keyring.mjs new file mode 100644 index 0000000..da895fa --- /dev/null +++ b/node/src/credentials/native-keyring.mjs @@ -0,0 +1,133 @@ +import { createRequire } from "node:module"; + +import { CrpError } from "../shared/errors.mjs"; + +const SERVICE = "org.cluic.codex-remote-proxy"; +const FORBIDDEN_REFS = new Set(["__proto__", "constructor", "prototype"]); +const REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/; +const require = createRequire(import.meta.url); + +function loadEntry() { + const { Entry } = require("@napi-rs/keyring"); + if (typeof Entry !== "function") { + throw new TypeError("Native credential Entry is unavailable"); + } + return Entry; +} + +function credentialInputInvalid() { + return new CrpError( + "CREDENTIAL_INPUT_INVALID", + "The credential input is invalid.", + "Use a valid credential reference and a non-empty secret.", + { status: 400 } + ); +} + +function credentialNotFound() { + return new CrpError( + "CREDENTIAL_NOT_FOUND", + "The credential does not exist.", + "Save the provider credential and try again.", + { status: 404 } + ); +} + +function backendUnavailable(cause) { + return new CrpError( + "CREDENTIAL_BACKEND_UNAVAILABLE", + "The credential backend is unavailable.", + "Check the operating-system credential service and try again.", + { status: 500, cause } + ); +} + +function assertRef(ref) { + if ( + typeof ref !== "string" + || !REF_PATTERN.test(ref) + || FORBIDDEN_REFS.has(ref) + ) { + throw credentialInputInvalid(); + } +} + +function assertSecret(secret) { + if (typeof secret !== "string" || secret.length === 0) { + throw credentialInputInvalid(); + } +} + +export class NativeKeyringStore { + constructor({ entryLoader = loadEntry, entryFactory } = {}) { + this.backend = "native"; + if (entryFactory !== undefined) { + if (typeof entryFactory !== "function") { + throw backendUnavailable(new TypeError("Native entry factory is invalid")); + } + this.entryFactory = entryFactory; + return; + } + try { + const EntryClass = entryLoader(); + if (typeof EntryClass !== "function") { + throw new TypeError("Native credential Entry is unavailable"); + } + this.entryFactory = (service, ref) => new EntryClass(service, ref); + } catch (error) { + throw backendUnavailable(error); + } + } + + #entry(ref) { + return this.entryFactory(SERVICE, ref); + } + + async set(ref, secret) { + assertRef(ref); + assertSecret(secret); + try { + this.#entry(ref).setPassword(secret); + } catch (error) { + throw backendUnavailable(error); + } + } + + async get(ref) { + assertRef(ref); + let password; + try { + password = this.#entry(ref).getPassword(); + } catch (error) { + throw backendUnavailable(error); + } + if (password === null || password === undefined || password === "") { + throw credentialNotFound(); + } + if (typeof password !== "string") { + throw backendUnavailable(new TypeError("Native credential was not a string")); + } + return password; + } + + async has(ref) { + try { + await this.get(ref); + return true; + } catch (error) { + if (error instanceof CrpError && error.code === "CREDENTIAL_NOT_FOUND") { + return false; + } + throw error; + } + } + + async delete(ref) { + assertRef(ref); + try { + return this.#entry(ref).deletePassword() !== false; + } catch (error) { + throw backendUnavailable(error); + } + } +} diff --git a/node/src/providers/provider-model-cache.mjs b/node/src/providers/provider-model-cache.mjs new file mode 100644 index 0000000..3a17185 --- /dev/null +++ b/node/src/providers/provider-model-cache.mjs @@ -0,0 +1,601 @@ +import { createHash, randomUUID } from "node:crypto"; +import { validateHeaderName, validateHeaderValue } from "node:http"; +import { + chmodSync, + closeSync, + existsSync, + fsyncSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + rmSync, + statSync, + writeFileSync +} from "node:fs"; +import { basename, dirname, join } from "node:path"; + +import { CrpError } from "../shared/errors.mjs"; + +export const MAX_PROVIDER_MODELS = 2_000; +export const MAX_MODEL_ID_LENGTH = 256; +export const MAX_MODEL_CACHE_ENTRIES = 512; +export const MAX_MODEL_CACHE_FILE_BYTES = 16 * 1024 * 1024; +export const MODEL_CACHE_FRESH_TTL_MS = 24 * 60 * 60 * 1_000; +export const MODEL_CACHE_STALE_RETENTION_MS = 30 * 24 * 60 * 60 * 1_000; + +const DOCUMENT_FIELDS = new Set(["schemaVersion", "entries"]); +const ENTRY_FIELDS = new Set([ + "providerId", + "sourceFingerprint", + "fetchedAt", + "models" +]); +const MAX_PROVIDER_ID_LENGTH = 128; +const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f-\u009f]/; +const PROVIDER_CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/; +const HEADER_TOKEN_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/; +const SOURCE_FINGERPRINT_PATTERN = /^sha256:[a-f0-9]{64}$/; +const LOCK_CLEANUP_ATTEMPTS = 2; +const DEFAULT_FILE_OPERATIONS = { + chmodSync, + closeSync, + existsSync, + fsyncSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + rmSync, + statSync, + writeFileSync +}; + +function clone(value) { + return structuredClone(value); +} + +function emptyDocument() { + return { schemaVersion: 1, entries: [] }; +} + +function isPlainObject(value) { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function hasExactFields(value, fields) { + if (!isPlainObject(value)) return false; + const keys = Object.keys(value); + return keys.length === fields.size && keys.every((key) => fields.has(key)); +} + +function hasBoundedCodePoints(value, maximum) { + return value.length <= maximum * 2 && [...value].length <= maximum; +} + +function isValidBoundedText(value, maximum, { + allowEmpty = false, + trim = false +} = {}) { + return typeof value === "string" + && (allowEmpty || value.length > 0) + && hasBoundedCodePoints(value, maximum) + && !CONTROL_CHARACTER_PATTERN.test(value) + && (!trim || value.trim() === value); +} + +function parseCanonicalTimestamp(value) { + if (typeof value !== "string") return null; + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp)) return null; + return new Date(timestamp).toISOString() === value ? timestamp : null; +} + +function cacheInvalid(cause) { + return new CrpError( + "PROVIDER_MODEL_CACHE_INVALID", + "The provider model cache is invalid.", + "Back up the invalid cache, then remove it before refreshing provider models.", + { status: 500, cause } + ); +} + +function cacheReadFailed(cause) { + return new CrpError( + "PROVIDER_MODEL_CACHE_READ_FAILED", + "The provider model cache could not be read.", + "Check the cache file permissions and try again.", + { status: 500, cause } + ); +} + +function cacheInputInvalid(cause) { + return new CrpError( + "PROVIDER_MODEL_CACHE_INPUT_INVALID", + "The provider model cache input is invalid.", + "Provide bounded provider model metadata and try again.", + { status: 400, cause } + ); +} + +function cacheBusy(cause) { + return new CrpError( + "PROVIDER_MODEL_CACHE_BUSY", + "The provider model cache is already being updated.", + "Wait for the current cache update to finish and try again.", + { status: 409, cause } + ); +} + +function committedLockDegraded() { + return new CrpError( + "PROVIDER_MODEL_CACHE_COMMITTED_LOCK_DEGRADED", + "The provider model cache was saved, but its lock could not be fully released.", + "Stop CRP, explicitly repair the residual cache lock, then restart CRP.", + { status: 500, details: { committed: true } } + ); +} + +function cacheLockDegraded() { + return new CrpError( + "PROVIDER_MODEL_CACHE_LOCK_DEGRADED", + "The provider model cache lock could not be safely recovered.", + "Stop CRP, explicitly repair the residual cache lock, then restart CRP.", + { status: 500, details: { committed: false } } + ); +} + +function assertProviderId(providerId) { + if (!isValidBoundedText(providerId, MAX_PROVIDER_ID_LENGTH, { trim: true })) { + throw new Error("invalid provider id"); + } +} + +function assertSourceFingerprint(sourceFingerprint) { + if (typeof sourceFingerprint !== "string" + || !SOURCE_FINGERPRINT_PATTERN.test(sourceFingerprint)) { + throw new Error("invalid source fingerprint"); + } +} + +function assertModelId(modelId) { + if (!isValidBoundedText(modelId, MAX_MODEL_ID_LENGTH, { trim: true })) { + throw new Error("invalid model id"); + } +} + +function validateEntry(entry) { + if (!hasExactFields(entry, ENTRY_FIELDS)) { + throw new Error("invalid entry fields"); + } + assertProviderId(entry.providerId); + assertSourceFingerprint(entry.sourceFingerprint); + if (parseCanonicalTimestamp(entry.fetchedAt) === null) { + throw new Error("invalid fetched timestamp"); + } + if (!Array.isArray(entry.models) || entry.models.length > MAX_PROVIDER_MODELS) { + throw new Error("invalid model list"); + } + const modelIds = new Set(); + for (const modelId of entry.models) { + assertModelId(modelId); + if (modelIds.has(modelId)) throw new Error("duplicate model id"); + modelIds.add(modelId); + } +} + +function validateDocument(document) { + try { + if (!hasExactFields(document, DOCUMENT_FIELDS)) { + throw new Error("invalid document fields"); + } + if (document.schemaVersion !== 1 || !Array.isArray(document.entries) + || document.entries.length > MAX_MODEL_CACHE_ENTRIES) { + throw new Error("invalid document shape"); + } + const providerIds = new Set(); + for (const entry of document.entries) { + validateEntry(entry); + if (providerIds.has(entry.providerId)) { + throw new Error("duplicate provider id"); + } + providerIds.add(entry.providerId); + } + } catch (error) { + if (error instanceof CrpError + && error.code === "PROVIDER_MODEL_CACHE_INVALID") { + throw error; + } + throw cacheInvalid(error); + } +} + +function validateInputEntry(entry) { + try { + validateEntry(entry); + } catch (error) { + throw cacheInputInvalid(error); + } +} + +function validateProviderIdInput(providerId) { + try { + assertProviderId(providerId); + } catch (error) { + throw cacheInputInvalid(error); + } +} + +function validateFingerprintInput(sourceFingerprint) { + if (sourceFingerprint === undefined) return; + try { + assertSourceFingerprint(sourceFingerprint); + } catch (error) { + throw cacheInputInvalid(error); + } +} + +function parseDocument(bytes) { + let document; + try { + document = JSON.parse(bytes); + } catch (error) { + throw cacheInvalid(error); + } + validateDocument(document); + return document; +} + +function serializeBoundedDocument(document) { + if (document.entries.length === 0) { + return "{\n \"schemaVersion\": 1,\n \"entries\": []\n}\n"; + } + const prefix = "{\n \"schemaVersion\": 1,\n \"entries\": [\n"; + const suffix = "\n ]\n}\n"; + const fragments = []; + let byteLength = Buffer.byteLength(prefix, "utf8") + + Buffer.byteLength(suffix, "utf8"); + for (const entry of document.entries) { + const fragment = JSON.stringify(entry, null, 2) + .split("\n") + .map((line) => ` ${line}`) + .join("\n"); + byteLength += Buffer.byteLength(fragment, "utf8") + + (fragments.length === 0 ? 0 : 2); + if (byteLength > MAX_MODEL_CACHE_FILE_BYTES) { + throw cacheInputInvalid(new Error("cache file exceeds the size limit")); + } + fragments.push(fragment); + } + return `${prefix}${fragments.join(",\n")}${suffix}`; +} + +function parseNow(value) { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (value instanceof Date && Number.isFinite(value.getTime())) return value.getTime(); + if (typeof value === "string") { + const timestamp = Date.parse(value); + if (Number.isFinite(timestamp)) return timestamp; + } + throw new TypeError("ProviderModelCache now() must return a valid time."); +} + +function missingProjection(providerId) { + return { + providerId, + state: "missing", + fetchedAt: null, + expiresAt: null, + models: [] + }; +} + +function projectEntry(entry, nowMs) { + const fetchedAtMs = parseCanonicalTimestamp(entry.fetchedAt); + const ageMs = nowMs - fetchedAtMs; + if (ageMs < 0 || ageMs >= MODEL_CACHE_STALE_RETENTION_MS) { + return missingProjection(entry.providerId); + } + return { + providerId: entry.providerId, + state: ageMs < MODEL_CACHE_FRESH_TTL_MS ? "fresh" : "stale", + fetchedAt: entry.fetchedAt, + expiresAt: new Date(fetchedAtMs + MODEL_CACHE_FRESH_TTL_MS).toISOString(), + models: [...entry.models] + }; +} + +function validateFingerprintSettings(profile) { + if (!isPlainObject(profile) + || typeof profile.baseUrl !== "string" || profile.baseUrl.length === 0 + || profile.baseUrl.trim() !== profile.baseUrl + || PROVIDER_CONTROL_CHARACTER_PATTERN.test(profile.baseUrl) + || typeof profile.authHeader !== "string" || profile.authHeader.length === 0 + || profile.authHeader.trim() !== profile.authHeader + || !HEADER_TOKEN_PATTERN.test(profile.authHeader) + || typeof profile.authScheme !== "string" + || profile.authScheme.trim() !== profile.authScheme + || (profile.authScheme.length > 0 && !HEADER_TOKEN_PATTERN.test(profile.authScheme)) + || !isPlainObject(profile.extraHeaders)) { + throw cacheInputInvalid(new Error("invalid provider request settings")); + } + try { + validateHeaderName(profile.authHeader); + } catch (error) { + throw cacheInputInvalid(error); + } + const headers = []; + for (const [name, value] of Object.entries(profile.extraHeaders)) { + if (typeof value !== "string") { + throw cacheInputInvalid(new Error("invalid extra header")); + } + try { + validateHeaderName(name); + validateHeaderValue(name, value); + } catch (error) { + throw cacheInputInvalid(error); + } + headers.push([name, value]); + } + headers.sort(([left], [right]) => ( + left < right ? -1 : left > right ? 1 : 0 + )); + return { + baseUrl: profile.baseUrl, + authHeader: profile.authHeader, + authScheme: profile.authScheme, + extraHeaders: headers + }; +} + +export function createProviderSourceFingerprint(profile) { + const settings = validateFingerprintSettings(profile); + const digest = createHash("sha256") + .update(JSON.stringify(settings), "utf8") + .digest("hex"); + return `sha256:${digest}`; +} + +export class ProviderModelCache { + constructor({ + path, + now = () => new Date().toISOString(), + fileOperations + }) { + this.path = path; + this.lockPath = `${path}.crp.lock`; + this.now = now; + this.fileOperations = { ...DEFAULT_FILE_OPERATIONS, ...fileOperations }; + this.degradedLock = null; + } + + #loadStrict() { + let bytes; + try { + const stats = this.fileOperations.statSync(this.path); + if (!Number.isFinite(stats?.size) || stats.size < 0 + || stats.size > MAX_MODEL_CACHE_FILE_BYTES) { + throw cacheInvalid(new Error("cache file exceeds the size limit")); + } + bytes = this.fileOperations.readFileSync(this.path, "utf8"); + if (Buffer.byteLength(bytes, "utf8") > MAX_MODEL_CACHE_FILE_BYTES) { + throw cacheInvalid(new Error("cache file exceeds the size limit")); + } + } catch (error) { + if (error?.code === "ENOENT") return emptyDocument(); + if (error instanceof CrpError) throw error; + throw cacheReadFailed(error); + } + return parseDocument(bytes); + } + + #loadForRead() { + try { + return this.#loadStrict(); + } catch { + return null; + } + } + + #acquireLock() { + this.fileOperations.mkdirSync(dirname(this.path), { recursive: true }); + if (this.degradedLock !== null) throw cacheLockDegraded(); + + let fileDescriptor; + const token = `${randomUUID()}\n`; + try { + fileDescriptor = this.fileOperations.openSync(this.lockPath, "wx", 0o600); + } catch (error) { + if (error?.code === "EEXIST") throw cacheBusy(error); + throw error; + } + + const lock = { fileDescriptor, token }; + try { + this.fileOperations.writeFileSync(fileDescriptor, token, "utf8"); + return lock; + } catch (error) { + const cleanup = this.#releaseLock(lock); + if (cleanup.residualLock) this.degradedLock = { token }; + throw error; + } + } + + #closeLock(fileDescriptor) { + let error = null; + for (let attempt = 0; attempt < LOCK_CLEANUP_ATTEMPTS; attempt += 1) { + try { + this.fileOperations.closeSync(fileDescriptor); + return { closed: true, error: null }; + } catch (caught) { + if (attempt > 0 && caught?.code === "EBADF") { + return { closed: true, error: null }; + } + error = caught; + } + } + return { closed: false, error }; + } + + #removeOwnedLock(token) { + let error = null; + for (let attempt = 0; attempt < LOCK_CLEANUP_ATTEMPTS; attempt += 1) { + let currentToken; + try { + currentToken = this.fileOperations.readFileSync(this.lockPath, "utf8"); + } catch (caught) { + if (caught?.code === "ENOENT") { + return { removed: true, residualLock: false, foreign: false, error: null }; + } + error = caught; + continue; + } + if (currentToken !== token) { + return { removed: false, residualLock: true, foreign: true, error: null }; + } + try { + this.fileOperations.rmSync(this.lockPath, { force: true }); + return { removed: true, residualLock: false, foreign: false, error: null }; + } catch (caught) { + error = caught; + } + } + return { + removed: false, + residualLock: this.fileOperations.existsSync(this.lockPath), + foreign: false, + error + }; + } + + #releaseLock(lock) { + const close = this.#closeLock(lock.fileDescriptor); + const removal = this.#removeOwnedLock(lock.token); + return { + ok: close.closed && removal.removed, + closeError: close.error, + removalError: removal.error, + residualLock: removal.residualLock, + foreignLock: removal.foreign + }; + } + + #persist(document) { + const bytes = serializeBoundedDocument(document); + const parent = dirname(this.path); + const tempPath = join( + parent, + `.${basename(this.path)}.${process.pid}.${randomUUID()}.tmp` + ); + let fileDescriptor; + + this.fileOperations.mkdirSync(parent, { recursive: true }); + try { + fileDescriptor = this.fileOperations.openSync(tempPath, "wx", 0o600); + this.fileOperations.writeFileSync(fileDescriptor, bytes, "utf8"); + this.fileOperations.fsyncSync(fileDescriptor); + this.fileOperations.closeSync(fileDescriptor); + fileDescriptor = undefined; + this.fileOperations.chmodSync(tempPath, 0o600); + this.fileOperations.renameSync(tempPath, this.path); + } catch (error) { + if (fileDescriptor !== undefined) { + try { + this.fileOperations.closeSync(fileDescriptor); + } catch { + // Preserve the original persistence error. + } + } + try { + this.fileOperations.rmSync(tempPath, { force: true }); + } catch { + // Preserve the original persistence error. + } + throw error; + } + } + + #commit(mutator) { + let lock; + let result; + let primaryError; + let committed = false; + try { + lock = this.#acquireLock(); + const candidate = clone(this.#loadStrict()); + const mutation = mutator(candidate); + result = clone(mutation.result); + if (mutation.changed) { + validateDocument(candidate); + this.#persist(candidate); + committed = true; + } + } catch (error) { + primaryError = error; + } + + let cleanup = { ok: true, residualLock: false }; + if (lock !== undefined) { + cleanup = this.#releaseLock(lock); + if (cleanup.residualLock) { + this.degradedLock = { token: lock.token }; + } else { + this.degradedLock = null; + } + } + + if (primaryError !== undefined) throw primaryError; + if (!cleanup.ok) { + if (committed) throw committedLockDegraded(); + throw cacheLockDegraded(); + } + return result; + } + + get(providerId, sourceFingerprint) { + validateProviderIdInput(providerId); + validateFingerprintInput(sourceFingerprint); + const document = this.#loadForRead(); + if (document === null) return missingProjection(providerId); + const entry = document.entries.find((candidate) => candidate.providerId === providerId); + if (entry === undefined + || (sourceFingerprint !== undefined + && entry.sourceFingerprint !== sourceFingerprint)) { + return missingProjection(providerId); + } + return clone(projectEntry(entry, parseNow(this.now()))); + } + + put(entry) { + validateInputEntry(entry); + const stored = clone(entry); + return this.#commit((document) => { + const index = document.entries.findIndex((candidate) => ( + candidate.providerId === stored.providerId + )); + if (index === -1 && document.entries.length >= MAX_MODEL_CACHE_ENTRIES) { + throw cacheInputInvalid(new Error("cache entry limit reached")); + } + if (index === -1) document.entries.push(stored); + else document.entries[index] = stored; + return { + changed: true, + result: projectEntry(stored, parseNow(this.now())) + }; + }); + } + + delete(providerId) { + validateProviderIdInput(providerId); + return this.#commit((document) => { + const index = document.entries.findIndex((entry) => entry.providerId === providerId); + if (index === -1) return { changed: false, result: false }; + document.entries.splice(index, 1); + return { changed: true, result: true }; + }); + } +} diff --git a/node/src/providers/provider-registry.mjs b/node/src/providers/provider-registry.mjs new file mode 100644 index 0000000..bd66c78 --- /dev/null +++ b/node/src/providers/provider-registry.mjs @@ -0,0 +1,655 @@ +import { randomUUID } from "node:crypto"; +import { + chmodSync, + closeSync, + existsSync, + fsyncSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + rmSync, + writeFileSync +} from "node:fs"; +import { basename, dirname, join } from "node:path"; +import { isDeepStrictEqual } from "node:util"; + +import { CrpError } from "../shared/errors.mjs"; +import { + normalizeProvider, + validateProviderInput, + validateStoredProvider +} from "./provider-schema.mjs"; + +const DEFAULT_SETTINGS = Object.freeze({ + proxyHost: "127.0.0.1", + proxyPort: 15100, + adminHost: "127.0.0.1", + adminPort: 15101, + captureEnabled: false +}); +const DOCUMENT_FIELDS = new Set([ + "schemaVersion", + "activeProviderId", + "providers", + "settings" +]); +const SETTINGS_FIELDS = new Set(Object.keys(DEFAULT_SETTINGS)); +const EDITABLE_FIELDS = new Set([ + "name", + "baseUrl", + "authHeader", + "authScheme", + "extraHeaders", + "modelMode", + "modelOverride" +]); +const IMMUTABLE_FIELDS = new Set(["id", "createdAt", "credentialRef"]); +const TEST_INVALIDATING_FIELDS = [ + "baseUrl", + "authHeader", + "authScheme", + "extraHeaders", + "modelMode", + "modelOverride" +]; +const TEST_CODE_PATTERN = /^[A-Z][A-Z0-9_]*$/; +const LOCK_CLEANUP_ATTEMPTS = 2; +const NO_CHANGE = Symbol("provider-registry-no-change"); +const DEFAULT_FILE_OPERATIONS = { + chmodSync, + closeSync, + existsSync, + fsyncSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + rmSync, + writeFileSync +}; + +function clone(value) { + return structuredClone(value); +} + +function noChange(result) { + return { [NO_CHANGE]: true, result }; +} + +function emptyDocument() { + return { + schemaVersion: 2, + activeProviderId: null, + providers: [], + settings: { ...DEFAULT_SETTINGS } + }; +} + +function isPlainObject(value) { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function registryInvalid(cause) { + return new CrpError( + "PROVIDER_REGISTRY_INVALID", + "The provider registry is invalid.", + "Restore a valid provider registry or remove it after making a backup.", + { status: 500, cause } + ); +} + +function inputError(code, message, action, status = 400) { + return new CrpError(code, message, action, { status }); +} + +function normalizedName(name) { + return name.toLowerCase(); +} + +function validateExactFields(value, fields) { + return isPlainObject(value) + && Object.keys(value).length === fields.size + && Object.keys(value).every((key) => fields.has(key)); +} + +function validateDocument(document) { + try { + if (!validateExactFields(document, DOCUMENT_FIELDS)) { + throw new Error("invalid document fields"); + } + if (document.schemaVersion !== 2) { + throw new Error("unsupported schema version"); + } + if (!Array.isArray(document.providers)) { + throw new Error("providers must be an array"); + } + if (!validateExactFields(document.settings, SETTINGS_FIELDS)) { + throw new Error("invalid settings fields"); + } + for (const [key, value] of Object.entries(DEFAULT_SETTINGS)) { + if (document.settings[key] !== value) { + throw new Error("fixed settings changed"); + } + } + if (document.activeProviderId !== null && typeof document.activeProviderId !== "string") { + throw new Error("invalid active provider id"); + } + + const ids = new Set(); + const names = new Set(); + for (const profile of document.providers) { + validateStoredProvider(profile); + if (ids.has(profile.id)) { + throw new Error("duplicate provider id"); + } + const nameKey = normalizedName(profile.name); + if (names.has(nameKey)) { + throw new Error("duplicate provider name"); + } + ids.add(profile.id); + names.add(nameKey); + } + if (document.activeProviderId !== null && !ids.has(document.activeProviderId)) { + throw new Error("active provider does not exist"); + } + return true; + } catch (error) { + if (error instanceof CrpError && error.code === "PROVIDER_REGISTRY_INVALID") { + throw error; + } + throw registryInvalid(error); + } +} + +function parseDocument(bytes) { + let document; + try { + document = JSON.parse(bytes); + } catch (error) { + throw registryInvalid(error); + } + validateDocument(document); + return document; +} + +function providerNotFound() { + return inputError( + "PROVIDER_NOT_FOUND", + "The provider does not exist.", + "Refresh the provider list and try again.", + 404 + ); +} + +function registryBusy(cause) { + return new CrpError( + "PROVIDER_REGISTRY_BUSY", + "The provider registry is already being updated.", + "Wait for the current registry update to finish and try again.", + { status: 409, cause } + ); +} + +function committedLockDegraded() { + return new CrpError( + "PROVIDER_REGISTRY_COMMITTED_LOCK_DEGRADED", + "The provider change was saved, but its registry lock could not be fully released.", + "Stop CRP, explicitly repair the residual registry lock, then restart CRP.", + { status: 500, details: { committed: true } } + ); +} + +function registryLockDegraded() { + return new CrpError( + "PROVIDER_REGISTRY_LOCK_DEGRADED", + "The provider registry lock could not be safely recovered.", + "Stop CRP, explicitly repair the residual registry lock, then restart CRP.", + { status: 500, details: { committed: false } } + ); +} + +function assertPatch(patch) { + if (!isPlainObject(patch)) { + throw inputError( + "PROVIDER_INPUT_INVALID", + "Provider settings are invalid.", + "Submit a provider settings object and try again." + ); + } + for (const key of Object.keys(patch)) { + if (IMMUTABLE_FIELDS.has(key)) { + throw inputError( + "PROVIDER_IMMUTABLE_FIELD", + "An immutable provider field cannot be changed.", + "Create a new provider when its identity or credential reference must change." + ); + } + if (!EDITABLE_FIELDS.has(key)) { + throw inputError( + "PROVIDER_INPUT_INVALID", + "Provider settings are invalid.", + "Remove system-managed fields and try again." + ); + } + if (patch[key] === undefined) { + throw inputError( + "PROVIDER_INPUT_INVALID", + "Provider settings are invalid.", + "Provide an explicit value for every updated field." + ); + } + } +} + +export class ProviderRegistry { + constructor({ + path, + createId = randomUUID, + now = () => new Date().toISOString(), + fileOperations + }) { + this.path = path; + this.lockPath = `${path}.crp.lock`; + this.createId = createId; + this.now = now; + this.fileOperations = { ...DEFAULT_FILE_OPERATIONS, ...fileOperations }; + this.degradedLock = null; + this.document = this.#load(); + } + + #load() { + if (!this.fileOperations.existsSync(this.path)) { + return emptyDocument(); + } + let bytes; + try { + bytes = this.fileOperations.readFileSync(this.path, "utf8"); + } catch (error) { + throw new CrpError( + "PROVIDER_REGISTRY_READ_FAILED", + "The provider registry could not be read.", + "Check the registry file permissions and try again.", + { status: 500, cause: error } + ); + } + return parseDocument(bytes); + } + + #findIndex(document, id) { + return document.providers.findIndex((profile) => profile.id === id); + } + + #refresh() { + const document = this.#load(); + this.document = document; + return document; + } + + #acquireLock() { + this.fileOperations.mkdirSync(dirname(this.path), { recursive: true }); + if (this.degradedLock !== null) { + throw registryLockDegraded(); + } + + let fileDescriptor; + const token = `${randomUUID()}\n`; + try { + fileDescriptor = this.fileOperations.openSync(this.lockPath, "wx", 0o600); + } catch (error) { + if (error?.code === "EEXIST") { + throw registryBusy(error); + } + throw error; + } + + const lock = { fileDescriptor, token }; + try { + this.fileOperations.writeFileSync(fileDescriptor, token, "utf8"); + return lock; + } catch (error) { + const cleanup = this.#releaseLock(lock); + if (cleanup.residualLock) { + this.degradedLock = { token }; + } + throw error; + } + } + + #closeLock(fileDescriptor) { + let error = null; + for (let attempt = 0; attempt < LOCK_CLEANUP_ATTEMPTS; attempt += 1) { + try { + this.fileOperations.closeSync(fileDescriptor); + return { closed: true, error: null }; + } catch (caught) { + if (attempt > 0 && caught?.code === "EBADF") { + return { closed: true, error: null }; + } + error = caught; + } + } + return { closed: false, error }; + } + + #removeOwnedLock(token) { + let error = null; + for (let attempt = 0; attempt < LOCK_CLEANUP_ATTEMPTS; attempt += 1) { + let currentToken; + try { + currentToken = this.fileOperations.readFileSync(this.lockPath, "utf8"); + } catch (caught) { + if (caught?.code === "ENOENT") { + return { removed: true, residualLock: false, foreign: false, error: null }; + } + error = caught; + continue; + } + if (currentToken !== token) { + return { removed: false, residualLock: true, foreign: true, error: null }; + } + try { + this.fileOperations.rmSync(this.lockPath, { force: true }); + return { removed: true, residualLock: false, foreign: false, error: null }; + } catch (caught) { + error = caught; + } + } + return { + removed: false, + residualLock: this.fileOperations.existsSync(this.lockPath), + foreign: false, + error + }; + } + + #releaseLock(lock) { + const close = this.#closeLock(lock.fileDescriptor); + const removal = this.#removeOwnedLock(lock.token); + return { + ok: close.closed && removal.removed, + closeError: close.error, + removalError: removal.error, + residualLock: removal.residualLock, + foreignLock: removal.foreign + }; + } + + #recordCleanupState(lock, cleanup) { + if (cleanup.residualLock) { + this.degradedLock = { token: lock.token }; + } else { + this.degradedLock = null; + } + } + + #getIndex(document, id) { + const index = this.#findIndex(document, id); + if (index === -1) { + throw providerNotFound(); + } + return index; + } + + #assertUniqueName(document, name, excludedId = null) { + const nameKey = normalizedName(name); + if (document.providers.some((profile) => ( + profile.id !== excludedId && normalizedName(profile.name) === nameKey + ))) { + throw inputError( + "PROVIDER_NAME_CONFLICT", + "A provider with this name already exists.", + "Choose a different provider name.", + 409 + ); + } + } + + #persist(document) { + const bytes = `${JSON.stringify(document, null, 2)}\n`; + const parent = dirname(this.path); + const tempPath = join( + parent, + `.${basename(this.path)}.${process.pid}.${randomUUID()}.tmp` + ); + let fileDescriptor; + + this.fileOperations.mkdirSync(parent, { recursive: true }); + try { + fileDescriptor = this.fileOperations.openSync(tempPath, "wx", 0o600); + this.fileOperations.writeFileSync(fileDescriptor, bytes, "utf8"); + this.fileOperations.fsyncSync(fileDescriptor); + this.fileOperations.closeSync(fileDescriptor); + fileDescriptor = undefined; + this.fileOperations.chmodSync(tempPath, 0o600); + this.fileOperations.renameSync(tempPath, this.path); + } catch (error) { + if (fileDescriptor !== undefined) { + try { + this.fileOperations.closeSync(fileDescriptor); + } catch { + // Preserve the original persistence error. + } + } + try { + this.fileOperations.rmSync(tempPath, { force: true }); + } catch { + // Preserve the original persistence error. + } + throw error; + } + } + + #commit(mutator) { + let lock; + let result; + let primaryError; + let committed = false; + try { + lock = this.#acquireLock(); + const candidate = clone(this.#load()); + const mutationResult = mutator(candidate); + const changed = mutationResult?.[NO_CHANGE] !== true; + validateDocument(candidate); + result = clone(changed ? mutationResult : mutationResult.result); + if (changed) this.#persist(candidate); + this.document = candidate; + committed = changed; + } catch (error) { + primaryError = error; + } + + let cleanup = { ok: true, residualLock: false }; + if (lock !== undefined) { + cleanup = this.#releaseLock(lock); + this.#recordCleanupState(lock, cleanup); + } + + if (primaryError !== undefined) { + throw primaryError; + } + if (!cleanup.ok) { + if (committed) { + throw committedLockDegraded(); + } + throw registryLockDegraded(); + } + return result; + } + + list() { + return clone(this.#refresh().providers); + } + + get(id) { + const document = this.#refresh(); + const index = this.#getIndex(document, id); + return clone(document.providers[index]); + } + + create(input) { + validateProviderInput(input); + const id = this.createId(); + const profile = normalizeProvider(input, { id, now: this.now() }); + return this.#commit((document) => { + if (this.#findIndex(document, profile.id) !== -1) { + throw inputError( + "PROVIDER_ID_CONFLICT", + "A provider identity conflict occurred.", + "Retry creating the provider.", + 409 + ); + } + this.#assertUniqueName(document, profile.name); + document.providers.push(profile); + return profile; + }); + } + + update(id, patch) { + assertPatch(patch); + const timestamp = this.now(); + + return this.#commit((document) => { + const index = this.#getIndex(document, id); + const current = document.providers[index]; + const normalized = normalizeProvider({ + name: current.name, + baseUrl: current.baseUrl, + credentialRef: current.credentialRef, + authHeader: current.authHeader, + authScheme: current.authScheme, + extraHeaders: current.extraHeaders, + modelMode: current.modelMode, + modelOverride: current.modelOverride, + ...patch + }, { id: current.id, now: timestamp }); + this.#assertUniqueName(document, normalized.name, id); + const invalidatesTest = TEST_INVALIDATING_FIELDS.some((field) => ( + !isDeepStrictEqual(current[field], normalized[field]) + )); + const updated = { + ...normalized, + credentialRef: current.credentialRef, + lastTestAt: invalidatesTest ? null : current.lastTestAt, + lastTestStatus: invalidatesTest ? "untested" : current.lastTestStatus, + lastTestCode: invalidatesTest ? null : current.lastTestCode, + createdAt: current.createdAt, + updatedAt: timestamp + }; + document.providers[index] = updated; + return updated; + }); + } + + delete(id) { + return this.#commit((document) => { + const index = this.#getIndex(document, id); + if (document.activeProviderId === id) { + throw inputError( + "PROVIDER_ACTIVE", + "The active provider cannot be deleted.", + "Activate another provider or stop the proxy first.", + 409 + ); + } + const [deleted] = document.providers.splice(index, 1); + return deleted; + }); + } + + markTest(id, { status, code = null } = {}) { + if (status !== "untested" && status !== "passed" && status !== "failed") { + throw inputError( + "PROVIDER_TEST_RESULT_INVALID", + "The provider test result is invalid.", + "Record an untested, passed, or failed compatibility test result." + ); + } + if (status === "untested" && code !== null) { + throw inputError( + "PROVIDER_TEST_RESULT_INVALID", + "The provider test result is invalid.", + "Do not include an error code when resetting the test result." + ); + } + if (status === "passed" && code !== null) { + throw inputError( + "PROVIDER_TEST_RESULT_INVALID", + "The provider test result is invalid.", + "Do not include an error code for a passed test." + ); + } + if (status === "failed" && ( + typeof code !== "string" || !TEST_CODE_PATTERN.test(code) + )) { + throw inputError( + "PROVIDER_TEST_RESULT_INVALID", + "The provider test result is invalid.", + "Record a stable error code for a failed test." + ); + } + const timestamp = this.now(); + return this.#commit((document) => { + const index = this.#getIndex(document, id); + const updated = { + ...document.providers[index], + lastTestAt: status === "untested" ? null : timestamp, + lastTestStatus: status, + lastTestCode: status === "untested" ? null : code, + updatedAt: timestamp + }; + document.providers[index] = updated; + return updated; + }); + } + + setActive(id) { + if (id === null) { + return this.#commit((document) => { + document.activeProviderId = null; + return null; + }); + } + return this.#commit((document) => { + const index = this.#getIndex(document, id); + document.activeProviderId = id; + return document.providers[index]; + }); + } + + setActiveIfNull(id) { + return this.#commit((document) => { + this.#getIndex(document, id); + if (document.activeProviderId !== null) return noChange(false); + document.activeProviderId = id; + return true; + }); + } + + clearActiveIf(id) { + return this.#commit((document) => { + this.#getIndex(document, id); + if (document.activeProviderId !== id) return noChange(false); + document.activeProviderId = null; + return true; + }); + } + + getActive() { + const document = this.#refresh(); + if (document.activeProviderId === null) { + return null; + } + const index = this.#getIndex(document, document.activeProviderId); + return clone(document.providers[index]); + } + + getDocument() { + return clone(this.#refresh()); + } +} diff --git a/node/src/providers/provider-schema.mjs b/node/src/providers/provider-schema.mjs new file mode 100644 index 0000000..79f8285 --- /dev/null +++ b/node/src/providers/provider-schema.mjs @@ -0,0 +1,346 @@ +import { CrpError } from "../shared/errors.mjs"; +import { validateHeaderValue } from "node:http"; + +export const TEST_STATUSES = new Set(["untested", "passed", "failed"]); + +const INPUT_FIELDS = new Set([ + "name", + "baseUrl", + "credentialRef", + "authHeader", + "authScheme", + "extraHeaders", + "modelMode", + "modelOverride" +]); +const PROFILE_FIELDS = new Set([ + "id", + ...INPUT_FIELDS, + "lastTestAt", + "lastTestStatus", + "lastTestCode", + "createdAt", + "updatedAt" +]); +const SENSITIVE_HEADER_TERMS = [ + "authorization", + "cookie", + "token", + "secret", + "apikey" +]; +const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/; +const TEST_CODE_PATTERN = /^[A-Z][A-Z0-9_]*$/; +const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/; + +function inputError(field, reason) { + throw new CrpError( + "PROVIDER_INPUT_INVALID", + "Provider settings are invalid.", + "Review the provider settings and try again.", + { status: 400, details: { field, reason } } + ); +} + +function isPlainObject(value) { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function assertExactFields(value, allowedFields, field) { + for (const key of Object.keys(value)) { + if (!allowedFields.has(key)) { + inputError(field, "contains an unsupported field"); + } + } +} + +function normalizeRequiredString(value, field) { + if (typeof value !== "string" || value.trim().length === 0) { + inputError(field, "must be a non-empty string"); + } + return value.trim(); +} + +function normalizeBaseUrl(value) { + if (typeof value !== "string" || value.trim().length === 0) { + inputError("baseUrl", "must be a non-empty string"); + } + if (CONTROL_CHARACTER_PATTERN.test(value)) { + inputError("baseUrl", "must not contain control characters"); + } + const baseUrl = value.trim(); + let parsed; + try { + parsed = new URL(baseUrl); + } catch { + inputError("baseUrl", "must be a valid URL"); + } + + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + inputError("baseUrl", "must use HTTP or HTTPS"); + } + if (authorityContainsUserInfo(baseUrl) || parsed.username || parsed.password) { + inputError("baseUrl", "must not contain credentials"); + } + if (parsed.protocol === "http:" && !isLoopbackHostname(parsed.hostname)) { + inputError("baseUrl", "HTTP is allowed only for loopback hosts"); + } + return parsed.toString(); +} + +function authorityContainsUserInfo(value) { + const authorityStart = value.indexOf("://"); + if (authorityStart === -1) { + return false; + } + const remainder = value.slice(authorityStart + 3); + const authorityEnd = remainder.search(/[/?#]/); + const authority = authorityEnd === -1 ? remainder : remainder.slice(0, authorityEnd); + return authority.includes("@"); +} + +function isLoopbackHostname(hostname) { + const lower = hostname.toLowerCase(); + if (lower === "localhost" || lower === "::1" || lower === "[::1]") { + return true; + } + const octets = lower.split("."); + if (octets.length !== 4 || octets.some((octet) => !/^\d{1,3}$/.test(octet))) { + return false; + } + const numbers = octets.map(Number); + return numbers[0] === 127 && numbers.every((octet) => octet >= 0 && octet <= 255); +} + +function normalizeAuthHeader(value) { + const authHeader = value === undefined + ? "authorization" + : normalizeRequiredString(value, "authHeader"); + if (!HEADER_NAME_PATTERN.test(authHeader)) { + inputError("authHeader", "must be a valid HTTP header name"); + } + return authHeader; +} + +function normalizeAuthScheme(value) { + if (value === undefined) { + return "Bearer"; + } + if (typeof value !== "string") { + inputError("authScheme", "must be a string"); + } + if (CONTROL_CHARACTER_PATTERN.test(value)) { + inputError("authScheme", "must not contain control characters"); + } + const authScheme = value.trim(); + if (authScheme && !HEADER_NAME_PATTERN.test(authScheme)) { + inputError("authScheme", "must be empty or an HTTP token"); + } + return authScheme; +} + +function isSensitiveHeaderName(name) { + const compact = name.toLowerCase().replace(/[^a-z0-9]/g, ""); + return SENSITIVE_HEADER_TERMS.some((term) => compact.includes(term)); +} + +function normalizeExtraHeaders(value) { + if (value === undefined) { + return {}; + } + if (!isPlainObject(value)) { + inputError("extraHeaders", "must be a string map"); + } + + const extraHeaders = {}; + for (const [name, headerValue] of Object.entries(value)) { + if (!HEADER_NAME_PATTERN.test(name)) { + inputError("extraHeaders", "contains an invalid HTTP header name"); + } + if (isSensitiveHeaderName(name)) { + inputError("extraHeaders", "contains a sensitive header name"); + } + if (typeof headerValue !== "string") { + inputError("extraHeaders", "must contain only string values"); + } + try { + validateHeaderValue(name, headerValue); + } catch { + inputError("extraHeaders", "contains an invalid HTTP header value"); + } + extraHeaders[name] = headerValue; + } + return extraHeaders; +} + +function normalizeModelPolicy(modeValue, overrideValue) { + const modelMode = modeValue === undefined ? "passthrough" : modeValue; + if (modelMode !== "passthrough" && modelMode !== "override") { + inputError("modelMode", "must be passthrough or override"); + } + + let modelOverride = overrideValue === undefined ? null : overrideValue; + if (modelOverride !== null) { + if (typeof modelOverride !== "string" || modelOverride.trim().length === 0) { + inputError("modelOverride", "must be a non-empty string or null"); + } + modelOverride = modelOverride.trim(); + } + if (modelMode === "override" && modelOverride === null) { + inputError("modelOverride", "is required in override mode"); + } + return { modelMode, modelOverride }; +} + +function normalizeInput(input) { + if (!isPlainObject(input)) { + inputError("provider", "must be an object"); + } + assertExactFields(input, INPUT_FIELDS, "provider"); + const modelPolicy = normalizeModelPolicy(input.modelMode, input.modelOverride); + return { + name: normalizeRequiredString(input.name, "name"), + baseUrl: normalizeBaseUrl(input.baseUrl), + credentialRef: normalizeRequiredString(input.credentialRef, "credentialRef"), + authHeader: normalizeAuthHeader(input.authHeader), + authScheme: normalizeAuthScheme(input.authScheme), + extraHeaders: normalizeExtraHeaders(input.extraHeaders), + ...modelPolicy + }; +} + +function isIsoTimestamp(value) { + if (typeof value !== "string") { + return false; + } + try { + return new Date(value).toISOString() === value; + } catch { + return false; + } +} + +function assertStoredValue(condition, field, reason) { + if (!condition) { + inputError(field, reason); + } +} + +export function validateProviderInput(input) { + normalizeInput(input); + return true; +} + +export function normalizeProvider(input, { id, now }) { + const normalized = normalizeInput(input); + const providerId = normalizeRequiredString(id, "id"); + if (!isIsoTimestamp(now)) { + inputError("now", "must be an ISO timestamp"); + } + return { + id: providerId, + ...normalized, + lastTestAt: null, + lastTestStatus: "untested", + lastTestCode: null, + createdAt: now, + updatedAt: now + }; +} + +export function validateStoredProvider(profile) { + if (!isPlainObject(profile)) { + inputError("provider", "must be an object"); + } + assertExactFields(profile, PROFILE_FIELDS, "provider"); + assertStoredValue( + Object.keys(profile).length === PROFILE_FIELDS.size, + "provider", + "is missing required fields" + ); + + const normalized = normalizeInput({ + name: profile.name, + baseUrl: profile.baseUrl, + credentialRef: profile.credentialRef, + authHeader: profile.authHeader, + authScheme: profile.authScheme, + extraHeaders: profile.extraHeaders, + modelMode: profile.modelMode, + modelOverride: profile.modelOverride + }); + assertStoredValue( + typeof profile.id === "string" && profile.id.trim() === profile.id && profile.id.length > 0, + "id", + "must be a normalized non-empty string" + ); + for (const key of INPUT_FIELDS) { + assertStoredValue( + JSON.stringify(profile[key]) === JSON.stringify(normalized[key]), + key, + "must be normalized" + ); + } + assertStoredValue(isIsoTimestamp(profile.createdAt), "createdAt", "must be an ISO timestamp"); + assertStoredValue(isIsoTimestamp(profile.updatedAt), "updatedAt", "must be an ISO timestamp"); + assertStoredValue(profile.updatedAt >= profile.createdAt, "updatedAt", "must not precede createdAt"); + assertStoredValue(TEST_STATUSES.has(profile.lastTestStatus), "lastTestStatus", "is invalid"); + assertStoredValue( + profile.lastTestAt === null || isIsoTimestamp(profile.lastTestAt), + "lastTestAt", + "must be null or an ISO timestamp" + ); + if (profile.lastTestAt !== null) { + assertStoredValue( + profile.lastTestAt >= profile.createdAt && profile.lastTestAt <= profile.updatedAt, + "lastTestAt", + "must be between createdAt and updatedAt" + ); + } + assertStoredValue( + profile.lastTestCode === null + || (typeof profile.lastTestCode === "string" && TEST_CODE_PATTERN.test(profile.lastTestCode)), + "lastTestCode", + "must be null or a stable error code" + ); + + if (profile.lastTestStatus === "untested") { + assertStoredValue(profile.lastTestAt === null, "lastTestAt", "must be null when untested"); + assertStoredValue(profile.lastTestCode === null, "lastTestCode", "must be null when untested"); + } else { + assertStoredValue(profile.lastTestAt !== null, "lastTestAt", "is required after a test"); + } + if (profile.lastTestStatus === "passed") { + assertStoredValue(profile.lastTestCode === null, "lastTestCode", "must be null after a passed test"); + } + if (profile.lastTestStatus === "failed") { + assertStoredValue(profile.lastTestCode !== null, "lastTestCode", "is required after a failed test"); + } + return true; +} + +export function toPublicProvider(profile, credentialConfigured) { + if (typeof credentialConfigured !== "boolean") { + inputError("credentialConfigured", "must be a boolean"); + } + return { + id: profile.id, + name: profile.name, + baseUrl: profile.baseUrl, + authHeader: profile.authHeader, + authScheme: profile.authScheme, + extraHeaders: { ...profile.extraHeaders }, + modelMode: profile.modelMode, + modelOverride: profile.modelOverride, + lastTestAt: profile.lastTestAt, + lastTestStatus: profile.lastTestStatus, + lastTestCode: profile.lastTestCode, + createdAt: profile.createdAt, + updatedAt: profile.updatedAt, + credentialConfigured + }; +} diff --git a/node/src/server.mjs b/node/src/server.mjs index 6ae6e20..7fef56b 100644 --- a/node/src/server.mjs +++ b/node/src/server.mjs @@ -19,6 +19,25 @@ import { const CONFIG_ENV_VAR = "CODEX_PROXY_CONFIG"; const DEFAULT_CONFIG_PATH = resolve(import.meta.dirname, "..", "proxy-config.json"); const HEALTH_PATH = "/_proxy/health"; +const METRIC_MODEL_MAX_BYTES = 64 * 1024; +const METRIC_USAGE_MAX_BYTES = 1024 * 1024; +const METRIC_MAX_MODEL_CODE_POINTS = 256; +const METRIC_MAX_OBSERVATION_TOKENS = 100_000_000; +const METRIC_TEXT_CONTROL_PATTERN = /[\u0000-\u001f\u007f-\u009f]/; +const METRIC_LATENCY_BOUNDS_MS = [ + 50, + 100, + 250, + 500, + 1_000, + 2_500, + 5_000, + 10_000, + 30_000, + 60_000, + 120_000, + 300_000 +]; const HOP_BY_HOP_HEADERS = new Set([ "connection", @@ -32,8 +51,6 @@ const HOP_BY_HOP_HEADERS = new Set([ "upgrade" ]); -let DEBUG_ENABLED = false; - export function resolveConfigPath() { return process.env[CONFIG_ENV_VAR] ? resolve(process.env[CONFIG_ENV_VAR]) : DEFAULT_CONFIG_PATH; } @@ -98,7 +115,7 @@ function maskSecret(value) { return "(empty)"; } if (value.length <= 8) { - return value; + return "[REDACTED]"; } return `${value.slice(0, 4)}...${value.slice(-4)}`; } @@ -109,8 +126,8 @@ export function log(level, message, fields = {}) { console.log(`${new Date().toISOString()} ${level.toUpperCase()} ${message}${suffix}`); } -function debugLog(label, data) { - if (!DEBUG_ENABLED) return; +function debugLog(label, data, enabled) { + if (!enabled) return; const timestamp = new Date().toISOString(); const json = typeof data === "string" ? data : JSON.stringify(data, null, 2); for (const line of json.split("\n")) { @@ -129,9 +146,18 @@ function safeBodyPreview(buffer, maxLen = 4096) { } export function buildTargetUrl(baseUrl, requestUrl) { + const target = new URL(baseUrl); const incoming = new URL(requestUrl, "http://127.0.0.1"); - const path = incoming.pathname === "/" ? "" : incoming.pathname; - return new URL(`${baseUrl}${path}${incoming.search}`); + const baseSearch = target.search; + if (incoming.pathname !== "/") { + const basePath = target.pathname.replace(/\/+$/, ""); + target.pathname = `${basePath}${incoming.pathname}`; + } + target.search = baseSearch && incoming.search + ? `${baseSearch}&${incoming.search.slice(1)}` + : baseSearch || incoming.search; + target.hash = ""; + return target; } function formatAuthorization(upstream) { @@ -140,6 +166,14 @@ function formatAuthorization(upstream) { } const CONTENT_HEADERS = new Set(["content-encoding", "content-length"]); +const DEBUG_SENSITIVE_HEADER_NAMES = new Set([ + "authorization", + "proxy-authorization", + "cookie", + "set-cookie", + "x-api-key" +]); +const DEBUG_SENSITIVE_HEADER_PARTS = ["token", "secret", "api-key"]; function decompressBody(buffer, encoding) { const enc = encoding.toLowerCase().trim(); @@ -164,10 +198,26 @@ function autoDecompress(buffer) { try { return zlib.brotliDecompressSync(buffer); } catch { return null; } } -function sanitizeHeadersForDebug(headersObject) { +function sanitizeHeadersForDebug(headersObject, authHeader = "authorization") { const result = {}; + const activeAuthHeader = authHeader.toLowerCase(); for (const [key, value] of Object.entries(headersObject)) { - result[key] = key.toLowerCase() === "authorization" ? maskSecret(String(value)) : value; + const loweredKey = key.toLowerCase(); + const sensitive = loweredKey === activeAuthHeader + || DEBUG_SENSITIVE_HEADER_NAMES.has(loweredKey) + || DEBUG_SENSITIVE_HEADER_PARTS.some((part) => loweredKey.includes(part)); + result[key] = sensitive ? maskSecret(String(value)) : value; + } + return result; +} + +function sanitizeHeadersForCapture(headersInput, authHeader) { + const result = headersToObject(headersInput); + const activeAuthHeader = authHeader.toLowerCase(); + for (const key of Object.keys(result)) { + if (key.toLowerCase() === activeAuthHeader) { + result[key] = "[REDACTED]"; + } } return result; } @@ -239,7 +289,98 @@ function isEventStream(contentType = "") { return contentType.split(";", 1)[0].trim().toLowerCase() === "text/event-stream"; } -function buildHealthPayload(settings, captureManager) { +function metricLatencyBin(value) { + const duration = Number.isFinite(value) && value >= 0 ? value : Number.POSITIVE_INFINITY; + const index = METRIC_LATENCY_BOUNDS_MS.findIndex((boundary) => duration <= boundary); + return index === -1 ? METRIC_LATENCY_BOUNDS_MS.length : index; +} + +function parseBoundedJson(buffer, maximumBytes) { + if (!Buffer.isBuffer(buffer) || buffer.length === 0 || buffer.length > maximumBytes) return null; + try { + const text = new TextDecoder("utf-8", { fatal: true }).decode(buffer); + const value = JSON.parse(text); + return value && typeof value === "object" && !Array.isArray(value) ? value : null; + } catch { + return null; + } +} + +function safeMetricModel(value, settings) { + if (typeof value !== "string" || value.length === 0 + || value.length > METRIC_MAX_MODEL_CODE_POINTS * 2 + || [...value].length > METRIC_MAX_MODEL_CODE_POINTS + || value.trim() !== value + || METRIC_TEXT_CONTROL_PATTERN.test(value)) { + return null; + } + const protectedValues = [ + settings.upstream.apiKey, + ...Object.values(settings.upstream.extraHeaders) + ].filter((candidate) => typeof candidate === "string" && candidate.length > 0); + return protectedValues.some((secret) => value.includes(secret)) ? null : value; +} + +function extractMetricModel(body, settings) { + const parsed = parseBoundedJson(body, METRIC_MODEL_MAX_BYTES); + return parsed ? safeMetricModel(parsed.model, settings) : null; +} + +function normalizeMetricUsage(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const inputTokens = value.input_tokens; + const outputTokens = value.output_tokens; + if (!Number.isSafeInteger(inputTokens) || inputTokens < 0 + || inputTokens > METRIC_MAX_OBSERVATION_TOKENS + || !Number.isSafeInteger(outputTokens) || outputTokens < 0 + || outputTokens > METRIC_MAX_OBSERVATION_TOKENS) { + return null; + } + return { inputTokens, outputTokens }; +} + +function extractMetricUsage(body, stream) { + if (!Buffer.isBuffer(body) || body.length === 0 || body.length > METRIC_USAGE_MAX_BYTES) return null; + if (!stream) { + const parsed = parseBoundedJson(body, METRIC_USAGE_MAX_BYTES); + return normalizeMetricUsage(parsed?.usage); + } + let text; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(body); + } catch { + return null; + } + let observed = null; + for (const line of text.split(/\r?\n/)) { + if (!line.startsWith("data:")) continue; + const data = line.slice(5).trimStart(); + if (data.length === 0 || data === "[DONE]") continue; + try { + const event = JSON.parse(data); + const usage = normalizeMetricUsage(event?.response?.usage); + if (usage) observed = usage; + } catch { + // Ignore non-JSON or partial SSE data without retaining it. + } + } + return observed; +} + +function metricResultForStatus(statusCode) { + if (statusCode >= 200 && statusCode <= 299) return "success"; + if (statusCode >= 400 && statusCode <= 499) return "upstreamRejected"; + return "upstreamError"; +} + +function buildHealthPayload(settings, captureManager, settingsSource) { + if (settingsSource) { + return { + ok: true, + ...settingsSource.publicState(), + ...captureManager.getPublicState() + }; + } return { ok: true, configPath: settings.configPath, @@ -265,6 +406,8 @@ function buildRequestContext({ req, settings, targetUrl, requestId, requestHeade } } + const captureHeaders = sanitizeHeadersForCapture(requestHeaders, settings.upstream.authHeader); + return { requestId, sessionId: typeof req.headers["session-id"] === "string" @@ -276,7 +419,7 @@ function buildRequestContext({ req, settings, targetUrl, requestId, requestHeade method: req.method || "GET", incomingUrl: new URL(req.url, `http://${settings.server.host}:${settings.server.port}`).href, targetUrl: targetUrl.href, - requestHeaders: headersToObject(requestHeaders), + requestHeaders: captureHeaders, requestBody, startedAt: new Date(startedAt).toISOString(), captureHandle @@ -309,7 +452,13 @@ function saveCaptureRecord(captureContext, fields) { }); } -export function createServer(settings, { captureManager = createCaptureManager({ configPath: settings.configPath, capture: settings.capture, log }).start(), logFn = log } = {}) { +export function createServer(settings, { + captureManager = createCaptureManager({ configPath: settings.configPath, capture: settings.capture, log }).start(), + logFn = log, + settingsSource, + recordMetric = () => {}, + metricNow = Date.now +} = {}) { return http.createServer((req, res) => { if (!req.url) { writeJson(res, 400, { error: { message: "Missing request URL", type: "proxy_bad_request" } }); @@ -317,14 +466,30 @@ export function createServer(settings, { captureManager = createCaptureManager({ } if (req.url === HEALTH_PATH) { - writeJson(res, 200, buildHealthPayload(settings, captureManager)); + writeJson(res, 200, buildHealthPayload(settings, captureManager, settingsSource)); return; } - const requestId = req.headers[settings.proxy.requestIdHeader] || req.headers["x-request-id"] || "-"; - const targetUrl = buildTargetUrl(settings.upstream.baseUrl, req.url); + let active; + try { + active = settingsSource ? settingsSource.current() : { generation: 0, settings }; + } catch (error) { + const unavailable = error?.code === "RUNTIME_SETTINGS_UNAVAILABLE"; + writeJson(res, unavailable ? 503 : 500, { + error: { + code: unavailable ? "RUNTIME_SETTINGS_UNAVAILABLE" : "RUNTIME_SETTINGS_ERROR", + message: unavailable ? "Proxy settings are not configured." : "Proxy settings could not be loaded." + } + }); + return; + } + const requestSettings = active.settings; + const requestDebugEnabled = requestSettings.server.logLevel.toLowerCase() === "debug"; + const requestId = req.headers[requestSettings.proxy.requestIdHeader] || req.headers["x-request-id"] || "-"; + const targetUrl = buildTargetUrl(requestSettings.upstream.baseUrl, req.url); const transport = targetUrl.protocol === "https:" ? https : http; const startedAt = Date.now(); + const metricStartedAt = metricNow(); const chunks = []; req.on("data", (chunk) => chunks.push(chunk)); @@ -349,13 +514,13 @@ export function createServer(settings, { captureManager = createCaptureManager({ originalSize: body.length, decompressedSize: decompressed.length, magicBytes: `0x${body[0].toString(16).padStart(2, "0")} 0x${body[1].toString(16).padStart(2, "0")}` - }); + }, requestDebugEnabled); body = decompressed; bodyTransformed = true; } } - const headers = buildUpstreamHeaders(req, settings, targetUrl, { + const headers = buildUpstreamHeaders(req, requestSettings, targetUrl, { stripContentHeaders: bodyTransformed }); if (bodyTransformed && body.length) { @@ -365,7 +530,7 @@ export function createServer(settings, { captureManager = createCaptureManager({ const captureHandle = captureManager.beginRecord() ?? createNoopCaptureHandle(); const captureContext = buildRequestContext({ req, - settings, + settings: requestSettings, targetUrl, requestId, requestHeaders: headers, @@ -374,7 +539,10 @@ export function createServer(settings, { captureManager = createCaptureManager({ captureHandle }); let captureSaved = false; + let metricSaved = false; let responseCompleted = false; + let responseStartBin = null; + const metricModel = extractMetricModel(body, requestSettings); function finalizeCapture(fields) { if (captureSaved) { @@ -384,14 +552,44 @@ export function createServer(settings, { captureManager = createCaptureManager({ saveCaptureRecord(captureContext, fields); } + function finalizeMetric(result, { responseBody = null, stream = false } = {}) { + if (metricSaved) return; + metricSaved = true; + if (!Number.isSafeInteger(active.generation) || active.generation <= 0) return; + const usage = result === "success" && responseBody !== null + ? extractMetricUsage(responseBody, stream) + : null; + const observation = { + generation: active.generation, + result, + model: metricModel, + inputTokens: usage?.inputTokens ?? null, + outputTokens: usage?.outputTokens ?? null, + durationBin: metricLatencyBin(metricNow() - metricStartedAt), + responseStartBin + }; + try { + const pending = recordMetric(observation); + if (pending && typeof pending.then === "function") void pending.catch(() => {}); + } catch { + // Operational metrics must never affect proxy forwarding. + } + } + debugLog("REQUEST", { method: req.method, path: req.url, targetUrl: targetUrl.href, - incomingHeaders: sanitizeHeadersForDebug(Object.fromEntries(Object.entries(req.headers))), - upstreamHeaders: Object.fromEntries(headers.map(([k, v]) => [k, k.toLowerCase() === "authorization" ? maskSecret(v) : v])), + incomingHeaders: sanitizeHeadersForDebug( + Object.fromEntries(Object.entries(req.headers)), + requestSettings.upstream.authHeader + ), + upstreamHeaders: sanitizeHeadersForDebug( + Object.fromEntries(headers), + requestSettings.upstream.authHeader + ), body: safeBodyPreview(body) - }); + }, requestDebugEnabled); const upstreamRequest = transport.request( { @@ -401,18 +599,27 @@ export function createServer(settings, { captureManager = createCaptureManager({ port: targetUrl.port || undefined, path: `${targetUrl.pathname}${targetUrl.search}`, headers, - rejectUnauthorized: settings.upstream.verifySsl + rejectUnauthorized: requestSettings.upstream.verifySsl }, (upstreamResponse) => { const stream = isEventStream(upstreamResponse.headers["content-type"]); debugLog("RESPONSE HEADERS", { status: upstreamResponse.statusCode, - headers: upstreamResponse.headers - }); - - const responseHeaders = headersToObject(upstreamResponse.rawHeaders); + headers: sanitizeHeadersForDebug( + upstreamResponse.headers, + requestSettings.upstream.authHeader + ) + }, requestDebugEnabled); + + const responseHeaders = sanitizeHeadersForCapture( + upstreamResponse.headers, + requestSettings.upstream.authHeader + ); const respChunks = []; upstreamResponse.on("data", (chunk) => { + if (responseStartBin === null && chunk.length > 0) { + responseStartBin = metricLatencyBin(metricNow() - metricStartedAt); + } respChunks.push(chunk); }); @@ -426,7 +633,7 @@ export function createServer(settings, { captureManager = createCaptureManager({ debugLog("RESPONSE BODY", { status: upstreamResponse.statusCode, body: safeBodyPreview(responseBody) - }); + }, requestDebugEnabled); } finalizeCapture({ responseStatus: upstreamResponse.statusCode || 502, @@ -435,6 +642,10 @@ export function createServer(settings, { captureManager = createCaptureManager({ isStream: stream, upstreamRequestId: typeof upstreamResponse.headers["x-request-id"] === "string" ? upstreamResponse.headers["x-request-id"] : null }); + finalizeMetric(metricResultForStatus(upstreamResponse.statusCode || 502), { + responseBody, + stream + }); logFn("info", "Proxied request", { request_id: requestId, method: req.method || "GET", @@ -444,10 +655,12 @@ export function createServer(settings, { captureManager = createCaptureManager({ duration_ms: Date.now() - startedAt }); }); + upstreamResponse.once("aborted", () => finalizeMetric("networkError")); + upstreamResponse.once("error", () => finalizeMetric("networkError")); } ); - upstreamRequest.setTimeout(settings.upstream.timeoutMs, () => { + upstreamRequest.setTimeout(requestSettings.upstream.timeoutMs, () => { upstreamRequest.destroy(new Error("upstream timeout")); }); @@ -471,7 +684,7 @@ export function createServer(settings, { captureManager = createCaptureManager({ error: error.message, code: error.code || "(none)", stack: error.stack - }); + }, requestDebugEnabled); if (!res.headersSent) { writeJson(res, statusCode, payload); } else { @@ -485,6 +698,7 @@ export function createServer(settings, { captureManager = createCaptureManager({ errorMessage: error.message, upstreamRequestId: null }); + finalizeMetric(statusCode === 504 ? "timeout" : "networkError"); logFn("warn", "Proxy request failed", { request_id: requestId, method: req.method || "GET", @@ -508,6 +722,7 @@ export function createServer(settings, { captureManager = createCaptureManager({ errorType: "proxy_client_abort", errorMessage: "Client closed connection" }); + finalizeMetric("clientAbort"); }); upstreamRequest.end(body); @@ -515,8 +730,7 @@ export function createServer(settings, { captureManager = createCaptureManager({ }); } -export function createApp(settings = loadConfig()) { - DEBUG_ENABLED = settings.server.logLevel.toLowerCase() === "debug"; +export function createApp(settings = loadConfig(), { settingsSource, recordMetric } = {}) { const captureManager = createCaptureManager({ configPath: settings.configPath, capture: settings.capture, @@ -533,7 +747,12 @@ export function createApp(settings = loadConfig()) { capture_db_path: settings.capture.dbPath }); - const server = createServer(settings, { captureManager, logFn: log }); + const server = createServer(settings, { + captureManager, + logFn: log, + settingsSource, + recordMetric + }); server.on("close", () => { captureManager.close(); }); @@ -566,6 +785,10 @@ function isWindowsStylePath(filePath) { return /^[A-Za-z]:[\\/]/.test(filePath); } +function isPosixStylePath(filePath) { + return filePath.startsWith("/") && !/^\/[A-Za-z]:\//.test(filePath); +} + function modulePathFromMetaUrl(metaUrl) { const url = new URL(metaUrl); if (url.protocol !== "file:") { @@ -575,6 +798,9 @@ function modulePathFromMetaUrl(metaUrl) { if (/^\/[A-Za-z]:\//.test(pathname)) { return path.win32.normalize(pathname.slice(1)); } + if (pathname.startsWith("/")) { + return path.posix.normalize(pathname); + } return fileURLToPath(metaUrl); } @@ -585,6 +811,9 @@ function normalizeExecutionPath(filePath) { if (isWindowsStylePath(filePath)) { return path.win32.normalize(filePath).toLowerCase(); } + if (isPosixStylePath(filePath)) { + return path.posix.normalize(filePath); + } return resolve(filePath); } diff --git a/node/src/shared/errors.mjs b/node/src/shared/errors.mjs new file mode 100644 index 0000000..9be7b0e --- /dev/null +++ b/node/src/shared/errors.mjs @@ -0,0 +1,131 @@ +export class CrpError extends Error { + constructor(code, message, action, { status = 500, details = {}, cause } = {}) { + super(message, { cause }); + this.name = "CrpError"; + this.code = code; + this.action = action; + this.status = status; + this.details = details; + } +} + +const STARTUP_MESSAGE_FIELDS = new Set(["version", "type", "error"]); +const STARTUP_ERROR_FIELDS = new Set(["code", "message", "action", "status", "details"]); +const ERROR_CODE_PATTERN = /^[A-Z][A-Z0-9_]{0,127}$/; +const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/; +const MAX_STARTUP_MESSAGE_BYTES = 8 * 1024; +const STARTUP_ERROR_CONTRACTS = new Map([ + ["SUPERVISOR_START_FAILED", Object.freeze({ + message: "The local supervisor could not be started.", + action: "Review the supervisor log and try again.", + status: 500, + detailFields: new Set() + })], + ["MIGRATION_INPUT_INVALID", Object.freeze({ + message: "The legacy provider configuration is invalid.", + action: "Restore a complete legacy provider URL and credential before migrating.", + status: 400, + detailFields: new Set() + })] +]); + +function isPlainObject(value) { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function hasExactFields(value, fields) { + if (!isPlainObject(value)) return false; + const keys = Object.keys(value); + return keys.length === fields.size && keys.every((key) => fields.has(key)); +} + +function isSafeText(value) { + return typeof value === "string" && value.length > 0 && value.length <= 512 + && !CONTROL_CHARACTER_PATTERN.test(value); +} + +function sanitizeStartupDetails(details, allowedFields) { + if (!isPlainObject(details)) return {}; + const safe = {}; + for (const [key, value] of Object.entries(details)) { + if (allowedFields.has(key) && ( + typeof value === "boolean" || value === null + || (typeof value === "number" && Number.isFinite(value)) + || (typeof value === "string" && /^[A-Za-z0-9_.:-]{1,128}$/.test(value)) + )) safe[key] = value; + } + return safe; +} + +function startupContract(code) { + return STARTUP_ERROR_CONTRACTS.get(code) + ?? STARTUP_ERROR_CONTRACTS.get("SUPERVISOR_START_FAILED"); +} + +export function createStartupFailureMessage(error) { + const code = error instanceof CrpError && STARTUP_ERROR_CONTRACTS.has(error.code) + ? error.code + : "SUPERVISOR_START_FAILED"; + const contract = startupContract(code); + return { + version: 1, + type: "startup-failed", + error: { + code, + message: contract.message, + action: contract.action, + status: contract.status, + details: sanitizeStartupDetails(error?.details, contract.detailFields) + } + }; +} + +export function parseStartupFailureMessage(message) { + try { + if (Buffer.byteLength(JSON.stringify(message), "utf8") > MAX_STARTUP_MESSAGE_BYTES + || !hasExactFields(message, STARTUP_MESSAGE_FIELDS) + || message.version !== 1 || message.type !== "startup-failed" + || !hasExactFields(message.error, STARTUP_ERROR_FIELDS)) { + return null; + } + const error = message.error; + const contract = STARTUP_ERROR_CONTRACTS.get(error.code); + if (!contract || typeof error.code !== "string" || !ERROR_CODE_PATTERN.test(error.code) + || !isSafeText(error.message) || !isSafeText(error.action) + || !Number.isInteger(error.status) || error.status < 400 || error.status > 599 + || !isPlainObject(error.details) + || error.message !== contract.message || error.action !== contract.action + || error.status !== contract.status) { + return null; + } + const details = sanitizeStartupDetails(error.details, contract.detailFields); + if (Object.keys(details).length !== Object.keys(error.details).length) return null; + return new CrpError(error.code, error.message, error.action, { + status: error.status, + details + }); + } catch { + return null; + } +} + +export function toPublicError(error, requestId) { + const safe = error instanceof CrpError + ? error + : new CrpError( + "INTERNAL_ERROR", + "CRP could not complete the operation.", + "Open Activity for details." + ); + return { + error: { + code: safe.code, + message: safe.message, + action: safe.action, + requestId, + details: safe.details + } + }; +} diff --git a/node/src/shared/paths.mjs b/node/src/shared/paths.mjs new file mode 100644 index 0000000..bbb2f96 --- /dev/null +++ b/node/src/shared/paths.mjs @@ -0,0 +1,21 @@ +import os from "node:os"; +import { resolve } from "node:path"; + +export function getPaths(home = os.homedir()) { + const resolvedHome = resolve(home); + const globalHome = resolve(resolvedHome, ".codex-remote-proxy"); + + return { + globalHome, + registryPath: resolve(globalHome, "providers.json"), + modelCachePath: resolve(globalHome, "provider-model-cache.json"), + metricsPath: resolve(globalHome, "metrics.json"), + secretFallbackPath: resolve(globalHome, "secrets.json"), + statePath: resolve(globalHome, "state.json"), + controlTokenPath: resolve(globalHome, "control-token"), + activityPath: resolve(globalHome, "activity.jsonl"), + logPath: resolve(globalHome, "supervisor.log"), + codexConfigPath: resolve(resolvedHome, ".codex", "config.toml"), + authPath: resolve(resolvedHome, ".codex", "auth.json") + }; +} diff --git a/node/src/supervisor/activity-store.mjs b/node/src/supervisor/activity-store.mjs new file mode 100644 index 0000000..8581c02 --- /dev/null +++ b/node/src/supervisor/activity-store.mjs @@ -0,0 +1,382 @@ +import { + chmodSync, + closeSync, + existsSync, + fsyncSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + rmSync, + writeFileSync +} from "node:fs"; +import { randomUUID } from "node:crypto"; +import { basename, dirname, join } from "node:path"; + +import { CrpError } from "../shared/errors.mjs"; + +const REDACTED = "[REDACTED]"; +const UNSERIALIZABLE = "[UNSERIALIZABLE]"; +const CIRCULAR = "[CIRCULAR]"; +const SENSITIVE_TERMS = [ + "authorization", + "cookie", + "token", + "secret", + "apikey", + "credentialref", + "requestbody", + "responsebody", + "cause", + "stack", + "headers", + "backuppath" +]; +const EVENT_FIELDS = new Set([ + "timestamp", + "category", + "action", + "providerId", + "result", + "errorCode", + "details" +]); +const DEFAULT_MAX_EVENTS = 10_000; +const DEFAULT_RETENTION_MS = 30 * 24 * 60 * 60 * 1_000; +const DEFAULT_FILE_OPERATIONS = { + chmodSync, + closeSync, + existsSync, + fsyncSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + rmSync, + writeFileSync +}; + +function activityError(code, message, action, status = 500, details = {}) { + return new CrpError(code, message, action, { status, details }); +} + +function invalidActivity() { + return activityError( + "ACTIVITY_EVENT_INVALID", + "The activity event is invalid.", + "Record only supported lifecycle activity fields.", + 400 + ); +} + +function storeError(code = "ACTIVITY_STORE_WRITE_FAILED", details = {}) { + const messages = { + ACTIVITY_STORE_BUSY: [ + "The activity store is already being updated.", + "Wait for the current activity update to finish and try again." + ], + ACTIVITY_STORE_INVALID: [ + "The activity store is invalid.", + "Restore a valid activity file or remove it after making a backup." + ], + ACTIVITY_STORE_WRITE_FAILED: [ + "The activity event could not be saved.", + "Check local storage permissions and try again." + ], + ACTIVITY_STORE_COMMITTED_LOCK_DEGRADED: [ + "The activity event was saved, but its lock could not be fully released.", + "Stop CRP, explicitly repair the residual activity lock, then restart CRP." + ], + ACTIVITY_STORE_LOCK_DEGRADED: [ + "The activity store lock could not be safely recovered.", + "Stop CRP, explicitly repair the residual activity lock, then restart CRP." + ] + }; + const [message, action] = messages[code] ?? messages.ACTIVITY_STORE_WRITE_FAILED; + return activityError(code, message, action, code === "ACTIVITY_STORE_BUSY" ? 409 : 500, details); +} + +function isIsoTimestamp(value) { + if (typeof value !== "string") return false; + try { + return new Date(value).toISOString() === value; + } catch { + return false; + } +} + +function normalizeString(value, { nullable = false } = {}) { + if (nullable && value === null) return null; + if (typeof value !== "string" || value.length === 0) throw invalidActivity(); + return value; +} + +function isSensitiveKey(key) { + const compact = String(key).toLowerCase().replace(/[^a-z0-9]/g, ""); + return SENSITIVE_TERMS.some((term) => compact.includes(term)); +} + +function sanitizeValue(value, seen) { + if (value === null || typeof value === "boolean" || typeof value === "string") { + return value; + } + if (typeof value === "number") { + return Number.isFinite(value) ? value : UNSERIALIZABLE; + } + if (typeof value === "bigint") return value.toString(); + if (typeof value === "undefined" || typeof value === "function" || typeof value === "symbol") { + return UNSERIALIZABLE; + } + if (seen.has(value)) return CIRCULAR; + seen.add(value); + + try { + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? UNSERIALIZABLE : value.toISOString(); + } + if (Array.isArray(value)) { + return value.map((item) => sanitizeValue(item, seen)); + } + + const sanitized = {}; + if (value instanceof Error) sanitized.name = String(value.name || "Error"); + for (const key of Object.keys(value)) { + if (isSensitiveKey(key)) { + sanitized[key] = REDACTED; + continue; + } + let property; + try { + property = value[key]; + } catch { + property = UNSERIALIZABLE; + } + sanitized[key] = property === UNSERIALIZABLE + ? UNSERIALIZABLE + : sanitizeValue(property, seen); + } + return sanitized; + } finally { + seen.delete(value); + } +} + +export function sanitizeActivityValue(value) { + return sanitizeValue(value, new WeakSet()); +} + +function normalizeEvent(event, now) { + if (event === null || typeof event !== "object" || Array.isArray(event)) { + throw invalidActivity(); + } + const timestamp = event.timestamp ?? now(); + if (!isIsoTimestamp(timestamp)) throw invalidActivity(); + const errorCode = event.errorCode ?? null; + if (errorCode !== null && !/^[A-Z][A-Z0-9_]*$/.test(errorCode)) { + throw invalidActivity(); + } + return { + timestamp, + category: normalizeString(event.category), + action: normalizeString(event.action), + providerId: normalizeString(event.providerId ?? null, { nullable: true }), + result: normalizeString(event.result), + errorCode, + details: sanitizeActivityValue(event.details ?? {}) + }; +} + +function validateStoredEvent(event) { + if (event === null || typeof event !== "object" || Array.isArray(event) + || Object.keys(event).length !== EVENT_FIELDS.size + || Object.keys(event).some((key) => !EVENT_FIELDS.has(key))) { + throw storeError("ACTIVITY_STORE_INVALID"); + } + const normalized = normalizeEvent(event, () => event.timestamp); + if (JSON.stringify(normalized) !== JSON.stringify(event)) { + throw storeError("ACTIVITY_STORE_INVALID"); + } + return normalized; +} + +export class ActivityStore { + constructor({ + path, + now = () => new Date().toISOString(), + maxEvents = DEFAULT_MAX_EVENTS, + retentionMs = DEFAULT_RETENTION_MS, + fileOperations = DEFAULT_FILE_OPERATIONS, + createId = randomUUID + }) { + if (typeof path !== "string" || path.length === 0 + || !Number.isSafeInteger(maxEvents) || maxEvents < 1 + || !Number.isSafeInteger(retentionMs) || retentionMs < 1) { + throw invalidActivity(); + } + this.path = path; + this.lockPath = `${path}.crp.lock`; + this.now = now; + this.maxEvents = maxEvents; + this.retentionMs = retentionMs; + this.fileOperations = fileOperations; + this.createId = createId; + this.degraded = false; + } + + append(event) { + const normalized = normalizeEvent(event, this.now); + this.#ensureParent(); + if (this.degraded) throw storeError("ACTIVITY_STORE_LOCK_DEGRADED"); + const lock = this.#acquireLock(); + let primaryError; + let committed = false; + try { + const events = this.#load(); + events.push(normalized); + const nowMs = new Date(this.now()).getTime(); + const cutoff = nowMs - this.retentionMs; + const retained = events + .filter((entry) => new Date(entry.timestamp).getTime() >= cutoff) + .slice(-this.maxEvents); + this.#persist(retained); + committed = true; + } catch (error) { + primaryError = error instanceof CrpError + ? error + : storeError("ACTIVITY_STORE_WRITE_FAILED"); + } + + const released = this.#releaseLock(lock); + if (!released) this.degraded = true; + if (primaryError) throw primaryError; + if (!released) { + throw storeError("ACTIVITY_STORE_COMMITTED_LOCK_DEGRADED", { committed }); + } + return structuredClone(normalized); + } + + list({ limit = this.maxEvents } = {}) { + if (!Number.isSafeInteger(limit) || limit < 1 || limit > this.maxEvents) { + throw invalidActivity(); + } + this.#ensureParent(); + return this.#load().slice(-limit).reverse().map((event) => structuredClone(event)); + } + + #ensureParent() { + const parent = dirname(this.path); + this.fileOperations.mkdirSync(parent, { recursive: true, mode: 0o700 }); + try { + this.fileOperations.chmodSync(parent, 0o700); + } catch { + // Windows ACL verification remains an L3 platform gate. + } + } + + #load() { + if (!this.fileOperations.existsSync(this.path)) return []; + let text; + try { + text = this.fileOperations.readFileSync(this.path, "utf8"); + } catch { + throw storeError("ACTIVITY_STORE_INVALID"); + } + if (text.length === 0) return []; + try { + return text.trimEnd().split("\n").map((line) => validateStoredEvent(JSON.parse(line))); + } catch (error) { + if (error instanceof CrpError && error.code === "ACTIVITY_STORE_INVALID") throw error; + throw storeError("ACTIVITY_STORE_INVALID"); + } + } + + #acquireLock() { + const token = `${this.createId()}\n`; + let descriptor; + let owned = false; + let closed = false; + try { + descriptor = this.fileOperations.openSync(this.lockPath, "wx", 0o600); + owned = true; + this.fileOperations.writeFileSync(descriptor, token, "utf8"); + this.fileOperations.fsyncSync(descriptor); + this.fileOperations.closeSync(descriptor); + closed = true; + descriptor = undefined; + this.fileOperations.chmodSync(this.lockPath, 0o600); + return token; + } catch (error) { + if (!owned) { + if (error?.code === "EEXIST") throw storeError("ACTIVITY_STORE_BUSY"); + throw storeError("ACTIVITY_STORE_WRITE_FAILED"); + } + if (!closed && descriptor !== undefined) { + try { + this.fileOperations.closeSync(descriptor); + closed = true; + } catch {} + } + const cleaned = closed ? this.#releaseLock(token) : false; + if (!closed || !cleaned) { + this.degraded = true; + throw storeError("ACTIVITY_STORE_LOCK_DEGRADED", { committed: false }); + } + throw storeError("ACTIVITY_STORE_WRITE_FAILED"); + } + } + + #releaseLock(token) { + const claimPath = join( + dirname(this.lockPath), + `.${basename(this.lockPath)}.${this.createId()}.release` + ); + let claimed = false; + try { + this.fileOperations.renameSync(this.lockPath, claimPath); + claimed = true; + if (this.fileOperations.readFileSync(claimPath, "utf8") !== token) { + this.#restoreClaim(claimPath); + return false; + } + this.fileOperations.rmSync(claimPath); + return true; + } catch { + if (claimed) this.#restoreClaim(claimPath); + return false; + } + } + + #restoreClaim(claimPath) { + try { + this.fileOperations.renameSync(claimPath, this.lockPath); + } catch { + // A foreign canonical lock is already a blocker; never remove it. + } + } + + #persist(events) { + const tempPath = join( + dirname(this.path), + `.${basename(this.path)}.${this.createId()}.tmp` + ); + const bytes = events.length === 0 + ? "" + : `${events.map((event) => JSON.stringify(event)).join("\n")}\n`; + let descriptor; + try { + descriptor = this.fileOperations.openSync(tempPath, "wx", 0o600); + this.fileOperations.writeFileSync(descriptor, bytes, "utf8"); + this.fileOperations.fsyncSync(descriptor); + this.fileOperations.closeSync(descriptor); + descriptor = undefined; + this.fileOperations.chmodSync(tempPath, 0o600); + this.fileOperations.renameSync(tempPath, this.path); + } catch { + if (descriptor !== undefined) { + try { this.fileOperations.closeSync(descriptor); } catch {} + } + try { this.fileOperations.rmSync(tempPath, { force: true }); } catch {} + throw storeError("ACTIVITY_STORE_WRITE_FAILED"); + } + } +} diff --git a/node/src/supervisor/admin-server.mjs b/node/src/supervisor/admin-server.mjs new file mode 100644 index 0000000..3b6772c --- /dev/null +++ b/node/src/supervisor/admin-server.mjs @@ -0,0 +1,1065 @@ +import { randomBytes } from "node:crypto"; +import http from "node:http"; +import { readFile } from "node:fs/promises"; +import { extname, join } from "node:path"; + +import { sanitizeActivityValue } from "./activity-store.mjs"; +import { CrpError, toPublicError } from "../shared/errors.mjs"; + +const API_PREFIX = "/api/v1"; +const PUBLIC_PROVIDER_FIELDS = [ + "id", + "name", + "baseUrl", + "authHeader", + "authScheme", + "extraHeaders", + "modelMode", + "modelOverride", + "lastTestAt", + "lastTestStatus", + "lastTestCode", + "createdAt", + "updatedAt", + "credentialConfigured" +]; +const PUBLIC_WORKER_FIELDS = [ + "phase", + "pid", + "generation", + "state", + "restartCount", + "startedAt", + "error" +]; +const PUBLIC_CHILD_STATE_FIELDS = [ + "phase", + "configured", + "generation", + "listening", + "listenHost", + "listenPort", + "inFlight" +]; +const SAFE_ERROR_DETAIL_FIELDS = new Set([ + "field", + "reason", + "committed", + "degraded", + "pending", + "generation", + "httpStatus" +]); +const METRIC_RESULTS = [ + "success", + "upstreamRejected", + "upstreamError", + "timeout", + "networkError", + "clientAbort" +]; +const METRIC_MAX_COUNT = 1_000_000_000_000; +const METRIC_MAX_TOKEN_TOTAL = 9_000_000_000_000_000; +const METRIC_MAX_LATENCY_MS = 300_000; +const MAX_SUPERVISOR_PID = 4_294_967_295; + +function apiError(code, message, action, status) { + return new CrpError(code, message, action, { status }); +} + +function bodyError(code) { + const contracts = { + API_CONTENT_TYPE_UNSUPPORTED: [ + "The request content type is not supported.", + "Send a UTF-8 application/json request body.", + 415 + ], + API_BODY_TOO_LARGE: [ + "The request body is too large.", + "Reduce the request body and try again.", + 413 + ], + API_BODY_INVALID: [ + "The request body is invalid.", + "Send only the documented JSON fields and try again.", + 400 + ] + }; + const [message, action, status] = contracts[code] ?? contracts.API_BODY_INVALID; + return apiError(code, message, action, status); +} + +function codexNotReady() { + return new CrpError( + "CODEX_NOT_READY", + "The Codex configuration is not ready.", + "Complete Codex bootstrap before activating a provider or starting or restarting the proxy.", + { status: 409 } + ); +} + +function createSerialGate() { + let tail = Promise.resolve(); + return (operation) => { + const previous = tail; + let release; + tail = new Promise((resolvePromise) => { + release = resolvePromise; + }); + return (async () => { + await previous; + try { + return await operation(); + } finally { + release(); + } + })(); + }; +} + +function isPlainObject(value) { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function exactObject(value, { allowed, required = [] }) { + if (!isPlainObject(value) + || Object.keys(value).some((field) => !allowed.includes(field)) + || required.some((field) => !Object.hasOwn(value, field))) { + throw bodyError("API_BODY_INVALID"); + } + return value; +} + +function assertJsonContentType(contentType) { + if (typeof contentType !== "string") throw bodyError("API_CONTENT_TYPE_UNSUPPORTED"); + const [mediaType, ...parameters] = contentType.split(";").map((part) => part.trim().toLowerCase()); + if (mediaType !== "application/json" + || parameters.some((parameter) => parameter !== "charset=utf-8")) { + throw bodyError("API_CONTENT_TYPE_UNSUPPORTED"); + } +} + +async function readJsonBody(request, maxBodyBytes) { + assertJsonContentType(request.headers["content-type"]); + const declaredLength = Number(request.headers["content-length"]); + if (Number.isFinite(declaredLength) && declaredLength > maxBodyBytes) { + request.resume(); + throw bodyError("API_BODY_TOO_LARGE"); + } + const chunks = []; + let length = 0; + let tooLarge = false; + for await (const chunk of request) { + length += chunk.length; + if (length > maxBodyBytes) { + tooLarge = true; + chunks.length = 0; + continue; + } + if (!tooLarge) chunks.push(chunk); + } + if (tooLarge) throw bodyError("API_BODY_TOO_LARGE"); + if (length === 0) throw bodyError("API_BODY_INVALID"); + try { + return JSON.parse(Buffer.concat(chunks, length).toString("utf8")); + } catch { + throw bodyError("API_BODY_INVALID"); + } +} + +async function requireEmptyBody(request, maxBodyBytes) { + let length = 0; + for await (const chunk of request) { + length += chunk.length; + if (length > maxBodyBytes) throw bodyError("API_BODY_TOO_LARGE"); + } + if (length !== 0) throw bodyError("API_BODY_INVALID"); +} + +function setSafeHeaders(response) { + response.setHeader("cache-control", "no-store"); + response.setHeader("x-content-type-options", "nosniff"); + response.setHeader("referrer-policy", "no-referrer"); + response.setHeader("content-security-policy", "default-src 'self'; frame-ancestors 'none'; base-uri 'none'"); +} + +function sendJson(response, status, payload, extraHeaders = {}) { + const bytes = Buffer.from(`${JSON.stringify(payload)}\n`, "utf8"); + response.writeHead(status, { + "content-type": "application/json; charset=utf-8", + "content-length": bytes.length, + ...extraHeaders + }); + response.end(bytes); +} + +function sendBytes(response, status, bytes, contentType, { head = false } = {}) { + response.writeHead(status, { + "content-type": contentType, + "content-length": bytes.length + }); + response.end(head ? undefined : bytes); +} + +function pick(source, fields) { + const result = {}; + for (const field of fields) { + if (Object.hasOwn(source ?? {}, field)) result[field] = structuredClone(source[field]); + } + return result; +} + +function projectProvider(provider) { + return provider === null ? null : pick(provider, PUBLIC_PROVIDER_FIELDS); +} + +function projectWorker(worker) { + if (worker === null || typeof worker !== "object") return null; + const projected = pick(worker, PUBLIC_WORKER_FIELDS); + projected.state = worker.state === null ? null : pick(worker.state, PUBLIC_CHILD_STATE_FIELDS); + if (worker.error !== null && typeof worker.error === "object") { + projected.error = pick(worker.error, ["code", "message"]); + } + return projected; +} + +function projectProviderStatus(status) { + return { + activeProviderId: status?.activeProviderId ?? null, + activeProvider: projectProvider(status?.activeProvider ?? null), + generation: Number.isSafeInteger(status?.generation) ? status.generation : 0, + worker: projectWorker(status?.worker ?? null) + }; +} + +function projectTestResult(result) { + return { + ok: result?.ok === true, + code: typeof result?.code === "string" ? result.code : null, + initialActivation: projectInitialActivation(result?.initialActivation ?? null) + }; +} + +function projectInitialActivation(activation) { + if (activation === null || typeof activation !== "object") return null; + return { + automatic: activation.automatic === true, + activeProviderId: typeof activation.activeProviderId === "string" + ? activation.activeProviderId + : null, + workerStarted: activation.workerStarted === true + }; +} + +function projectModelCatalog(catalog) { + const state = ["missing", "fresh", "stale"].includes(catalog?.state) + ? catalog.state + : "missing"; + return { + providerId: typeof catalog?.providerId === "string" ? catalog.providerId : null, + state, + fetchedAt: typeof catalog?.fetchedAt === "string" ? catalog.fetchedAt : null, + expiresAt: typeof catalog?.expiresAt === "string" ? catalog.expiresAt : null, + models: Array.isArray(catalog?.models) + ? catalog.models.filter((model) => typeof model === "string").slice(0, 2_000) + : [] + }; +} + +function projectActivation(result) { + return { + activeProviderId: typeof result?.activeProviderId === "string" + ? result.activeProviderId + : null, + activeProvider: projectProvider(result?.activeProvider ?? null), + generation: Number.isSafeInteger(result?.generation) ? result.generation : 0, + worker: projectWorker(result?.worker ?? null) + }; +} + +function projectActivityEvent(event) { + return { + timestamp: typeof event?.timestamp === "string" ? event.timestamp : null, + category: typeof event?.category === "string" ? event.category : null, + action: typeof event?.action === "string" ? event.action : null, + providerId: typeof event?.providerId === "string" ? event.providerId : null, + result: typeof event?.result === "string" ? event.result : null, + errorCode: typeof event?.errorCode === "string" ? event.errorCode : null, + details: sanitizeActivityValue(event?.details ?? {}) + }; +} + +function projectSettings(settings) { + return { + proxyHost: typeof settings?.proxyHost === "string" ? settings.proxyHost : null, + proxyPort: Number.isInteger(settings?.proxyPort) ? settings.proxyPort : null, + adminHost: typeof settings?.adminHost === "string" ? settings.adminHost : null, + adminPort: Number.isInteger(settings?.adminPort) ? settings.adminPort : null, + captureEnabled: settings?.captureEnabled === true, + credentialBackend: typeof settings?.credentialBackend === "string" + ? settings.credentialBackend + : null + }; +} + +function projectHistoryCount(value) { + return Number.isSafeInteger(value) && value >= 0 && value <= 1_000_000 + ? value + : 0; +} + +function projectBootstrap(result) { + const historyRepair = result?.historyRepair && typeof result.historyRepair === "object" + ? result.historyRepair + : {}; + return { + changed: result?.changed === true, + backupCreated: typeof result?.backupPath === "string" && result.backupPath.length > 0, + historyRepair: { + required: historyRepair.required === true, + completed: historyRepair.completed === true, + resumed: historyRepair.resumed === true, + backupCreated: historyRepair.backupCreated === true, + rolloutFiles: projectHistoryCount(historyRepair.rolloutFiles), + rolloutRecords: projectHistoryCount(historyRepair.rolloutRecords), + sqliteFiles: projectHistoryCount(historyRepair.sqliteFiles), + sqliteRows: projectHistoryCount(historyRepair.sqliteRows), + encryptedContentDetected: historyRepair.encryptedContentDetected === true + } + }; +} + +function projectDiagnostics(result) { + return { + created: result?.created === true, + generatedAt: typeof result?.generatedAt === "string" ? result.generatedAt : null, + eventCount: Number.isSafeInteger(result?.eventCount) ? result.eventCount : null + }; +} + +function projectMetricInteger(value, maximum = METRIC_MAX_COUNT) { + return Number.isSafeInteger(value) && value >= 0 && value <= maximum ? value : 0; +} + +function projectMetricResults(results) { + return Object.fromEntries(METRIC_RESULTS.map((result) => ( + [result, projectMetricInteger(results?.[result])] + ))); +} + +function projectMetricTokens(tokens) { + return { + input: projectMetricInteger(tokens?.input, METRIC_MAX_TOKEN_TOTAL), + output: projectMetricInteger(tokens?.output, METRIC_MAX_TOKEN_TOTAL), + observedRequests: projectMetricInteger(tokens?.observedRequests) + }; +} + +function projectMetricPercentile(value) { + return value === null + ? null + : (Number.isSafeInteger(value) && value >= 0 && value <= METRIC_MAX_LATENCY_MS ? value : null); +} + +function projectMetricLatency(latency) { + return { + p50UpperBoundMs: projectMetricPercentile(latency?.p50UpperBoundMs), + p95UpperBoundMs: projectMetricPercentile(latency?.p95UpperBoundMs), + overflowRequests: projectMetricInteger(latency?.overflowRequests) + }; +} + +function projectMetricText(value, maximum) { + return typeof value === "string" + && value.length > 0 + && value.length <= maximum * 2 + && [...value].length <= maximum + && value.trim() === value + && !/[\u0000-\u001f\u007f-\u009f]/.test(value) + ? value + : null; +} + +function projectMetricTimestamp(value) { + if (typeof value !== "string") return null; + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) && new Date(timestamp).toISOString() === value + ? value + : null; +} + +function projectMetricsOverview(metrics, requestedWindow) { + const window = requestedWindow; + const maximumSeries = window === "7d" ? 168 : 24; + return { + window, + bucketMinutes: 60, + storageState: ["ready", "degraded", "unavailable"].includes(metrics?.storageState) + ? metrics.storageState + : "unavailable", + summary: { + requests: projectMetricInteger(metrics?.summary?.requests), + results: projectMetricResults(metrics?.summary?.results), + tokens: projectMetricTokens(metrics?.summary?.tokens), + latency: projectMetricLatency(metrics?.summary?.latency), + responseStart: projectMetricLatency(metrics?.summary?.responseStart) + }, + series: Array.isArray(metrics?.series) + ? metrics.series.slice(0, maximumSeries).map((bucket) => ({ + start: projectMetricTimestamp(bucket?.start), + requests: projectMetricInteger(bucket?.requests), + results: projectMetricResults(bucket?.results), + tokens: projectMetricTokens(bucket?.tokens) + })).filter((bucket) => bucket.start !== null) + : [], + providers: Array.isArray(metrics?.providers) + ? metrics.providers.slice(0, 16).map((provider) => ({ + providerId: projectMetricText(provider?.providerId, 128), + requests: projectMetricInteger(provider?.requests), + successfulRequests: projectMetricInteger(provider?.successfulRequests), + tokens: projectMetricTokens(provider?.tokens), + latency: projectMetricLatency(provider?.latency) + })).filter((provider) => provider.providerId !== null) + : [], + providerOtherRequests: projectMetricInteger(metrics?.providerOtherRequests), + models: Array.isArray(metrics?.models) + ? metrics.models.slice(0, 16).map((model) => ({ + model: projectMetricText(model?.model, 256), + requests: projectMetricInteger(model?.requests), + tokens: projectMetricTokens(model?.tokens) + })).filter((model) => model.model !== null) + : [], + modelOtherRequests: projectMetricInteger(metrics?.modelOtherRequests), + dataQuality: { + unknownModelRequests: projectMetricInteger(metrics?.dataQuality?.unknownModelRequests), + modelOverflowRequests: projectMetricInteger(metrics?.dataQuality?.modelOverflowRequests), + providerOverflowRequests: projectMetricInteger(metrics?.dataQuality?.providerOverflowRequests), + droppedObservations: projectMetricInteger(metrics?.dataQuality?.droppedObservations) + } + }; +} + +function projectSupervisorState(state) { + return { + pid: Number.isSafeInteger(state?.pid) ? state.pid : null, + startedAt: typeof state?.startedAt === "string" ? state.startedAt : null + }; +} + +function isSupervisorPid(value) { + return Number.isSafeInteger(value) && value >= 1 && value <= MAX_SUPERVISOR_PID; +} + +function isCanonicalTimestamp(value) { + if (typeof value !== "string") return false; + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) && new Date(timestamp).toISOString() === value; +} + +function projectShutdownAcceptance(state) { + if (!isSupervisorPid(state?.pid) || !isCanonicalTimestamp(state?.startedAt)) { + throw new TypeError("Supervisor identity is invalid."); + } + return { + accepted: true, + supervisorPid: state.pid, + startedAt: state.startedAt + }; +} + +function supervisorIdentityChanged() { + return new CrpError( + "SUPERVISOR_IDENTITY_CHANGED", + "The local supervisor identity changed.", + "Refresh CRP status and retry against the current supervisor.", + { status: 409 } + ); +} + +function supervisorShutdownUnavailable() { + return new CrpError( + "SUPERVISOR_SHUTDOWN_UNAVAILABLE", + "Supervisor shutdown is unavailable.", + "Use CRP through the running Supervisor and try again.", + { status: 503 } + ); +} + +function projectCodexState(state) { + return { + configured: state?.configured === true, + historyRepairPending: state?.historyRepairPending === true, + modelProvider: typeof state?.modelProvider === "string" ? state.modelProvider : null, + proxyUrl: typeof state?.proxyUrl === "string" ? state.proxyUrl : null + }; +} + +function sanitizePublicError(error, requestId) { + const payload = toPublicError(error, requestId); + const sanitized = sanitizeActivityValue(payload.error.details); + const details = {}; + for (const [key, value] of Object.entries(sanitized)) { + if (SAFE_ERROR_DETAIL_FIELDS.has(key) || value === "[REDACTED]") details[key] = value; + } + payload.error.details = details; + return payload; +} + +function currentAddress(server, host, configuredPort) { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : configuredPort; + return { + host, + port, + authority: `${host}:${port}`, + origin: `http://${host}:${port}` + }; +} + +function parseProviderRoute(pathname) { + const prefix = `${API_PREFIX}/providers/`; + if (!pathname.startsWith(prefix)) return null; + const rawParts = pathname.slice(prefix.length).split("/"); + if (rawParts.length < 1 || rawParts.length > 2 || rawParts.some((part) => part.length === 0)) { + return null; + } + let id; + try { + id = decodeURIComponent(rawParts[0]); + } catch { + return null; + } + if (id.length === 0 || id.length > 128 || /[\\/\u0000-\u001f\u007f]/.test(id)) return null; + const action = rawParts[1] ?? null; + if (action !== null && action !== "test" && action !== "activate" && action !== "models") { + return null; + } + return { id, action }; +} + +function providerNotFound() { + return new CrpError( + "PROVIDER_NOT_FOUND", + "The provider does not exist.", + "Refresh the provider list and try again.", + { status: 404 } + ); +} + +function allowedMethods(pathname) { + const exact = new Map([ + [`${API_PREFIX}/session`, ["POST"]], + [`${API_PREFIX}/session/resume`, ["POST"]], + [`${API_PREFIX}/status`, ["GET"]], + [`${API_PREFIX}/metrics/overview`, ["GET"]], + [`${API_PREFIX}/providers`, ["GET", "POST"]], + [`${API_PREFIX}/proxy/start`, ["POST"]], + [`${API_PREFIX}/proxy/stop`, ["POST"]], + [`${API_PREFIX}/proxy/restart`, ["POST"]], + [`${API_PREFIX}/supervisor/shutdown`, ["POST"]], + [`${API_PREFIX}/activity`, ["GET"]], + [`${API_PREFIX}/settings`, ["GET", "PATCH"]], + [`${API_PREFIX}/codex/bootstrap`, ["POST"]], + [`${API_PREFIX}/diagnostics/export`, ["POST"]] + ]); + if (exact.has(pathname)) return exact.get(pathname); + const providerRoute = parseProviderRoute(pathname); + if (!providerRoute) return null; + if (providerRoute.action === null) return ["GET", "PATCH", "DELETE"]; + return providerRoute.action === "models" ? ["GET", "POST"] : ["POST"]; +} + +function positiveQueryInteger(url, name, fallback, { min = 0, max }) { + const values = url.searchParams.getAll(name); + if (values.length === 0) return fallback; + if (values.length !== 1 || !/^\d+$/.test(values[0])) { + throw bodyError("API_BODY_INVALID"); + } + const value = Number(values[0]); + if (!Number.isSafeInteger(value) || value < min || value > max) { + throw bodyError("API_BODY_INVALID"); + } + return value; +} + +function uiAsset(pathname) { + let decoded; + try { + decoded = decodeURIComponent(pathname); + } catch { + return null; + } + if (/[\\\u0000-\u001f\u007f]/.test(decoded) + || decoded.split("/").includes("..")) { + return null; + } + const explicit = new Map([ + ["/", ["index.html", "text/html; charset=utf-8"]], + ["/index.html", ["index.html", "text/html; charset=utf-8"]], + ["/favicon.ico", [null, "image/x-icon"]], + ["/styles.css", ["styles.css", "text/css; charset=utf-8"]], + ["/app.js", ["app.js", "text/javascript; charset=utf-8"]] + ]); + if (explicit.has(decoded)) return explicit.get(decoded); + if (extname(decoded) === "") return explicit.get("/"); + return null; +} + +export function createAdminServer({ + auth, + providerService, + activityStore, + settingsService, + codexService, + diagnosticsService, + metricsService, + getSupervisorState = () => ({ pid: process.pid, startedAt: null }), + requestSupervisorShutdown = null, + uiDir, + host = "127.0.0.1", + port = 15101, + maxBodyBytes = 64 * 1_024, + createRequestId = () => randomBytes(12).toString("base64url") +} = {}) { + if (host !== "127.0.0.1" || !Number.isInteger(port) || port < 0 || port > 65_535 + || !Number.isSafeInteger(maxBodyBytes) || maxBodyBytes < 1 + || !auth || !providerService) { + throw new TypeError("Admin server options are invalid."); + } + + if (typeof getSupervisorState !== "function" + || (requestSupervisorShutdown !== null + && typeof requestSupervisorShutdown !== "function")) { + throw new TypeError("Admin shutdown options are invalid."); + } + + const runCodexExclusive = createSerialGate(); + const runWhenCodexReady = (operation) => runCodexExclusive(async () => { + if (typeof codexService?.runWhenReady === "function") { + return await codexService.runWhenReady(operation); + } + const status = await codexService?.getStatus?.(); + if (status?.configured !== true || status?.historyRepairPending === true) { + throw codexNotReady(); + } + return await operation(); + }); + let shutdownRequestPromise = null; + const requestShutdownOnce = () => { + if (shutdownRequestPromise) return shutdownRequestPromise; + const attempt = Promise.resolve().then(() => requestSupervisorShutdown()); + shutdownRequestPromise = attempt; + void attempt.catch(() => { + if (shutdownRequestPromise === attempt) shutdownRequestPromise = null; + }); + return attempt; + }; + const scheduleShutdownAfterResponse = (response) => { + response.once("finish", () => { + setImmediate(() => { + void requestShutdownOnce(); + }); + }); + }; + + const server = http.createServer((request, response) => { + setSafeHeaders(response); + const requestId = createRequestId(); + response.setHeader("x-request-id", requestId); + void handleRequest(request, response, requestId).catch((error) => { + if (response.headersSent) { + response.destroy(); + return; + } + if (error?.clearCookie === true) response.setHeader("set-cookie", auth.clearCookie()); + const status = error instanceof CrpError ? error.status : 500; + sendJson(response, status, sanitizePublicError(error, requestId)); + }); + }); + + async function handleRequest(request, response, requestId) { + const address = currentAddress(server, host, port); + if (request.headers.host !== address.authority) { + throw apiError( + "API_HOST_INVALID", + "The local request host is invalid.", + "Open CRP through its configured loopback address.", + 403 + ); + } + const origin = request.headers.origin; + if (origin !== undefined && origin !== address.origin) { + throw apiError( + "API_ORIGIN_INVALID", + "The local request origin is invalid.", + "Open CRP through its configured loopback address.", + 403 + ); + } + if (request.method === "OPTIONS") { + throw apiError( + "API_CORS_FORBIDDEN", + "Cross-origin requests are not allowed.", + "Use the bundled local CRP UI or CLI.", + 403 + ); + } + + const url = new URL(request.url, address.origin); + if (url.pathname === `${API_PREFIX}/session`) { + if (request.method !== "POST") { + throw apiError( + "API_METHOD_NOT_ALLOWED", + "The API method is not allowed.", + "Use the documented method for this endpoint.", + 405 + ); + } + await requireEmptyBody(request, maxBodyBytes); + const session = auth.createBrowserSession(request.headers.authorization); + sendJson(response, 200, { + csrfToken: session.csrfToken, + expiresAt: session.expiresAt + }, { "set-cookie": session.setCookie }); + return; + } + if (url.pathname === `${API_PREFIX}/session/resume`) { + if (request.method !== "POST") { + throw apiError( + "API_METHOD_NOT_ALLOWED", + "The API method is not allowed.", + "Use the documented method for this endpoint.", + 405 + ); + } + if (request.url !== `${API_PREFIX}/session/resume`) throw bodyError("API_BODY_INVALID"); + if (origin !== address.origin) { + throw apiError( + "API_ORIGIN_INVALID", + "The local request origin is invalid.", + "Open CRP through its configured loopback address.", + 403 + ); + } + if (request.headers["x-crp-session-resume"] !== "1") { + throw apiError( + "AUTH_CSRF_INVALID", + "The request could not be verified.", + "Refresh the local CRP UI and try again.", + 403 + ); + } + await requireEmptyBody(request, maxBodyBytes); + const session = auth.resumeBrowserSession({ + cookie: request.headers.cookie, + authorization: request.headers.authorization + }); + sendJson(response, 200, { + csrfToken: session.csrfToken, + expiresAt: session.expiresAt + }, { "set-cookie": session.setCookie }); + return; + } + + const apiNamespace = url.pathname === "/api" + || url.pathname.startsWith("/api/"); + if (!apiNamespace) { + const asset = uiAsset(url.pathname); + if (!asset) { + throw apiError( + "UI_NOT_FOUND", + "The local UI resource was not found.", + "Open the CRP UI root.", + 404 + ); + } + if (request.method !== "GET" && request.method !== "HEAD") { + response.setHeader("allow", "GET, HEAD"); + throw apiError( + "API_METHOD_NOT_ALLOWED", + "The API method is not allowed.", + "Use GET to load local UI resources.", + 405 + ); + } + if (typeof uiDir !== "string" || uiDir.length === 0) { + throw apiError( + "UI_NOT_FOUND", + "The local UI resource was not found.", + "Install the bundled CRP UI files and try again.", + 404 + ); + } + if (asset[0] === null) { + sendBytes(response, 204, Buffer.alloc(0), asset[1], { head: true }); + return; + } + let bytes; + try { + bytes = await readFile(join(uiDir, asset[0])); + } catch { + throw apiError( + "UI_NOT_FOUND", + "The local UI resource was not found.", + "Install the bundled CRP UI files and try again.", + 404 + ); + } + sendBytes(response, 200, bytes, asset[1], { head: request.method === "HEAD" }); + return; + } + auth.authorize({ + authorization: request.headers.authorization, + cookie: request.headers.cookie, + csrfToken: request.headers["x-crp-csrf"], + mutation: request.method !== "GET" && request.method !== "HEAD" + }); + + if (url.pathname === `${API_PREFIX}/status` && request.method === "GET") { + const [providerStatus, codexStatus] = await Promise.all([ + providerService.getStatus(), + codexService?.getStatus?.() ?? { configured: false } + ]); + sendJson(response, 200, { + supervisor: projectSupervisorState(getSupervisorState()), + ...projectProviderStatus(providerStatus), + codex: projectCodexState(codexStatus) + }); + return; + } + if (url.pathname === `${API_PREFIX}/metrics/overview` && request.method === "GET") { + for (const key of url.searchParams.keys()) { + if (key !== "window") throw bodyError("API_BODY_INVALID"); + } + const values = url.searchParams.getAll("window"); + if (values.length > 1 || (values.length === 1 && !["24h", "7d"].includes(values[0]))) { + throw bodyError("API_BODY_INVALID"); + } + const window = values[0] ?? "24h"; + const metrics = await metricsService?.getOverview?.({ window }); + sendJson(response, 200, { metrics: projectMetricsOverview(metrics, window) }); + return; + } + if (url.pathname === `${API_PREFIX}/providers` && request.method === "GET") { + const providers = await providerService.listProviders(); + sendJson(response, 200, { providers: providers.map(projectProvider) }); + return; + } + if (url.pathname === `${API_PREFIX}/providers` && request.method === "POST") { + const body = exactObject(await readJsonBody(request, maxBodyBytes), { + allowed: ["provider", "credential"], + required: ["provider", "credential"] + }); + if (!isPlainObject(body.provider) + || typeof body.credential !== "string" || body.credential.length === 0) { + throw bodyError("API_BODY_INVALID"); + } + const provider = await providerService.createProvider( + body.provider, + body.credential + ); + sendJson(response, 201, { provider: projectProvider(provider) }); + return; + } + const providerRoute = parseProviderRoute(url.pathname); + if (providerRoute?.action === null && request.method === "GET") { + const providers = await providerService.listProviders(); + const provider = providers.find((candidate) => candidate.id === providerRoute.id); + if (!provider) throw providerNotFound(); + sendJson(response, 200, { provider: projectProvider(provider) }); + return; + } + if (providerRoute?.action === null && request.method === "PATCH") { + const body = exactObject(await readJsonBody(request, maxBodyBytes), { + allowed: ["patch", "replacementCredential"], + required: ["patch"] + }); + if (!isPlainObject(body.patch) + || (body.replacementCredential !== undefined + && (typeof body.replacementCredential !== "string" + || body.replacementCredential.length === 0))) { + throw bodyError("API_BODY_INVALID"); + } + const provider = await providerService.updateProvider( + providerRoute.id, + body.patch, + body.replacementCredential + ); + sendJson(response, 200, { provider: projectProvider(provider) }); + return; + } + if (providerRoute?.action === null && request.method === "DELETE") { + await requireEmptyBody(request, maxBodyBytes); + const provider = await providerService.deleteProvider(providerRoute.id); + sendJson(response, 200, { provider: projectProvider(provider) }); + return; + } + if (providerRoute?.action === "test" && request.method === "POST") { + const body = exactObject(await readJsonBody(request, maxBodyBytes), { + allowed: ["model", "activateIfNone"], + required: ["model"] + }); + if (typeof body.model !== "string" || body.model.trim().length === 0 + || (body.activateIfNone !== undefined && typeof body.activateIfNone !== "boolean")) { + throw bodyError("API_BODY_INVALID"); + } + const result = await providerService.testProvider(providerRoute.id, body.model, { + activateIfNone: body.activateIfNone === true + }); + sendJson(response, 200, { result: projectTestResult(result) }); + return; + } + if (providerRoute?.action === "models" && request.method === "GET") { + const modelCatalog = await providerService.getProviderModels(providerRoute.id); + sendJson(response, 200, { modelCatalog: projectModelCatalog(modelCatalog) }); + return; + } + if (providerRoute?.action === "models" && request.method === "POST") { + await requireEmptyBody(request, maxBodyBytes); + const modelCatalog = await providerService.refreshProviderModels(providerRoute.id); + sendJson(response, 200, { modelCatalog: projectModelCatalog(modelCatalog) }); + return; + } + if (providerRoute?.action === "activate" && request.method === "POST") { + await requireEmptyBody(request, maxBodyBytes); + const activation = await runWhenCodexReady( + () => providerService.activate(providerRoute.id) + ); + sendJson(response, 200, { activation: projectActivation(activation) }); + return; + } + if (url.pathname === `${API_PREFIX}/proxy/start` && request.method === "POST") { + await requireEmptyBody(request, maxBodyBytes); + const worker = await runWhenCodexReady(() => providerService.startProxy()); + sendJson(response, 200, { worker: projectWorker(worker) }); + return; + } + if (url.pathname === `${API_PREFIX}/proxy/stop` && request.method === "POST") { + await requireEmptyBody(request, maxBodyBytes); + const worker = await providerService.stopProxy(); + sendJson(response, 200, { worker: projectWorker(worker) }); + return; + } + if (url.pathname === `${API_PREFIX}/proxy/restart` && request.method === "POST") { + await requireEmptyBody(request, maxBodyBytes); + const worker = await runWhenCodexReady(() => providerService.restartProxy()); + sendJson(response, 200, { worker: projectWorker(worker) }); + return; + } + if (url.pathname === `${API_PREFIX}/supervisor/shutdown` && request.method === "POST") { + if (request.url !== `${API_PREFIX}/supervisor/shutdown`) { + throw bodyError("API_BODY_INVALID"); + } + const body = exactObject(await readJsonBody(request, maxBodyBytes), { + allowed: ["supervisorPid", "startedAt"], + required: ["supervisorPid", "startedAt"] + }); + if (!isSupervisorPid(body.supervisorPid) || !isCanonicalTimestamp(body.startedAt)) { + throw bodyError("API_BODY_INVALID"); + } + const current = getSupervisorState(); + if (body.supervisorPid !== current?.pid || body.startedAt !== current?.startedAt) { + throw supervisorIdentityChanged(); + } + if (typeof requestSupervisorShutdown !== "function") { + throw supervisorShutdownUnavailable(); + } + const shutdown = projectShutdownAcceptance(current); + scheduleShutdownAfterResponse(response); + sendJson(response, 202, { shutdown }); + return; + } + if (url.pathname === `${API_PREFIX}/activity` && request.method === "GET") { + for (const key of url.searchParams.keys()) { + if (key !== "limit" && key !== "offset") throw bodyError("API_BODY_INVALID"); + } + const limit = positiveQueryInteger(url, "limit", 50, { min: 1, max: 100 }); + const offset = positiveQueryInteger(url, "offset", 0, { min: 0, max: 9_999 }); + const events = activityStore.list({ limit: Math.min(10_000, offset + limit + 1) }); + const page = events.slice(offset, offset + limit).map(projectActivityEvent); + sendJson(response, 200, { + events: page, + page: { + limit, + offset, + nextOffset: events.length > offset + limit ? offset + limit : null + } + }); + return; + } + if (url.pathname === `${API_PREFIX}/settings` && request.method === "GET") { + sendJson(response, 200, { settings: projectSettings(await settingsService.getSettings()) }); + return; + } + if (url.pathname === `${API_PREFIX}/settings` && request.method === "PATCH") { + const patch = exactObject(await readJsonBody(request, maxBodyBytes), { + allowed: ["captureEnabled"] + }); + if (Object.keys(patch).length === 0 || typeof patch.captureEnabled !== "boolean") { + throw bodyError("API_BODY_INVALID"); + } + throw new CrpError( + "SETTINGS_READ_ONLY", + "Local settings are read-only in this version.", + "Keep the fixed proxy settings and use a supported provider operation.", + { status: 409 } + ); + } + if (url.pathname === `${API_PREFIX}/codex/bootstrap` && request.method === "POST") { + await requireEmptyBody(request, maxBodyBytes); + const result = await runCodexExclusive(() => codexService.bootstrap()); + sendJson(response, 200, { result: projectBootstrap(result) }); + return; + } + if (url.pathname === `${API_PREFIX}/diagnostics/export` && request.method === "POST") { + await requireEmptyBody(request, maxBodyBytes); + const result = await diagnosticsService.exportDiagnostics(); + sendJson(response, 200, { diagnostics: projectDiagnostics(result) }); + return; + } + const methods = allowedMethods(url.pathname); + if (methods) { + response.setHeader("allow", methods.join(", ")); + throw apiError( + "API_METHOD_NOT_ALLOWED", + "The API method is not allowed.", + "Use the documented method for this endpoint.", + 405 + ); + } + throw apiError( + "API_NOT_FOUND", + "The API endpoint was not found.", + "Use a documented local API endpoint.", + 404 + ); + } + + return { + server, + async listen() { + if (!server.listening) { + await new Promise((resolvePromise, rejectPromise) => { + server.once("error", rejectPromise); + server.listen(port, host, () => { + server.off("error", rejectPromise); + resolvePromise(); + }); + }); + } + return currentAddress(server, host, port); + }, + async close() { + if (!server.listening) return; + await new Promise((resolvePromise, rejectPromise) => { + server.close((error) => { + if (error) rejectPromise(error); + else resolvePromise(); + }); + server.closeAllConnections?.(); + }); + } + }; +} diff --git a/node/src/supervisor/metrics-store.mjs b/node/src/supervisor/metrics-store.mjs new file mode 100644 index 0000000..631aa8d --- /dev/null +++ b/node/src/supervisor/metrics-store.mjs @@ -0,0 +1,749 @@ +import { randomUUID } from "node:crypto"; +import { + chmodSync, + closeSync, + constants, + fchmodSync, + fstatSync, + fsyncSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + rmSync, + writeFileSync +} from "node:fs"; +import { basename, dirname, join } from "node:path"; + +export const METRICS_SCHEMA_VERSION = 1; +export const METRICS_BUCKET_MINUTES = 60; +export const METRICS_RETENTION_BUCKETS = 7 * 24; +export const METRICS_MAX_FILE_BYTES = 32 * 1024 * 1024; +export const METRICS_LATENCY_BOUNDS_MS = Object.freeze([ + 50, + 100, + 250, + 500, + 1_000, + 2_500, + 5_000, + 10_000, + 30_000, + 60_000, + 120_000, + 300_000 +]); + +const LATENCY_BIN_COUNT = METRICS_LATENCY_BOUNDS_MS.length + 1; +const BUCKET_MS = METRICS_BUCKET_MINUTES * 60 * 1_000; +const MAX_PROVIDERS_PER_BUCKET = 32; +const MAX_MODELS_PER_BUCKET = 64; +const MAX_PROVIDER_ID_LENGTH = 128; +const MAX_MODEL_ID_LENGTH = 256; +const MAX_COUNTER = 1_000_000_000_000; +const MAX_OBSERVATION_TOKENS = 100_000_000; +const MAX_TOKEN_TOTAL = 9_000_000_000_000_000; +const DEFAULT_FLUSH_DELAY_MS = 1_000; +const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f-\u009f]/; +const RESULTS = Object.freeze([ + "success", + "upstreamRejected", + "upstreamError", + "timeout", + "networkError", + "clientAbort" +]); +const OBSERVATION_FIELDS = new Set([ + "providerId", + "result", + "model", + "inputTokens", + "outputTokens", + "durationBin", + "responseStartBin" +]); +const DOCUMENT_FIELDS = new Set(["schemaVersion", "bucketMinutes", "retentionBuckets", "buckets"]); +const BUCKET_FIELDS = new Set([ + "start", + "requests", + "results", + "usageObservedRequests", + "inputTokens", + "outputTokens", + "durationBins", + "responseStartBins", + "unknownModelRequests", + "modelOverflowRequests", + "providerOverflowRequests", + "droppedObservations", + "providers", + "models" +]); +const PROVIDER_FIELDS = new Set([ + "providerId", + "requests", + "successfulRequests", + "usageObservedRequests", + "inputTokens", + "outputTokens", + "durationBins" +]); +const MODEL_FIELDS = new Set([ + "model", + "requests", + "usageObservedRequests", + "inputTokens", + "outputTokens" +]); +const DEFAULT_FILE_OPERATIONS = { + chmodSync, + closeSync, + constants, + fchmodSync, + fstatSync, + fsyncSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + rmSync, + writeFileSync +}; + +function isPlainObject(value) { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function compareText(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} + +function hasExactFields(value, fields) { + if (!isPlainObject(value)) return false; + const keys = Object.keys(value); + return keys.length === fields.size && keys.every((key) => fields.has(key)); +} + +function isBoundedText(value, maximum) { + return typeof value === "string" + && value.length > 0 + && value.length <= maximum * 2 + && [...value].length <= maximum + && value.trim() === value + && !CONTROL_CHARACTER_PATTERN.test(value); +} + +function isBoundedInteger(value, maximum) { + return Number.isSafeInteger(value) && value >= 0 && value <= maximum; +} + +function canonicalHour(value) { + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp) || timestamp % BUCKET_MS !== 0) return null; + const canonical = new Date(timestamp).toISOString(); + return canonical === value ? timestamp : null; +} + +function emptyResults() { + return Object.fromEntries(RESULTS.map((result) => [result, 0])); +} + +function emptyBins() { + return Array.from({ length: LATENCY_BIN_COUNT }, () => 0); +} + +function emptyBucket(start) { + return { + start, + requests: 0, + results: emptyResults(), + usageObservedRequests: 0, + inputTokens: 0, + outputTokens: 0, + durationBins: emptyBins(), + responseStartBins: emptyBins(), + unknownModelRequests: 0, + modelOverflowRequests: 0, + providerOverflowRequests: 0, + droppedObservations: 0, + providers: [], + models: [] + }; +} + +function validateResults(results) { + return hasExactFields(results, new Set(RESULTS)) + && RESULTS.every((result) => isBoundedInteger(results[result], MAX_COUNTER)); +} + +function validateBins(bins) { + return Array.isArray(bins) + && bins.length === LATENCY_BIN_COUNT + && bins.every((count) => isBoundedInteger(count, MAX_COUNTER)); +} + +function validateProviderRow(row) { + return hasExactFields(row, PROVIDER_FIELDS) + && isBoundedText(row.providerId, MAX_PROVIDER_ID_LENGTH) + && isBoundedInteger(row.requests, MAX_COUNTER) + && isBoundedInteger(row.successfulRequests, MAX_COUNTER) + && row.successfulRequests <= row.requests + && isBoundedInteger(row.usageObservedRequests, MAX_COUNTER) + && row.usageObservedRequests <= row.requests + && isBoundedInteger(row.inputTokens, MAX_TOKEN_TOTAL) + && isBoundedInteger(row.outputTokens, MAX_TOKEN_TOTAL) + && validateBins(row.durationBins) + && row.durationBins.reduce((sum, count) => sum + count, 0) === row.requests; +} + +function validateModelRow(row) { + return hasExactFields(row, MODEL_FIELDS) + && isBoundedText(row.model, MAX_MODEL_ID_LENGTH) + && isBoundedInteger(row.requests, MAX_COUNTER) + && isBoundedInteger(row.usageObservedRequests, MAX_COUNTER) + && row.usageObservedRequests <= row.requests + && isBoundedInteger(row.inputTokens, MAX_TOKEN_TOTAL) + && isBoundedInteger(row.outputTokens, MAX_TOKEN_TOTAL); +} + +function validateBucket(bucket) { + if (!hasExactFields(bucket, BUCKET_FIELDS) + || canonicalHour(bucket.start) === null + || !isBoundedInteger(bucket.requests, MAX_COUNTER) + || !validateResults(bucket.results) + || RESULTS.reduce((sum, result) => sum + bucket.results[result], 0) !== bucket.requests + || !isBoundedInteger(bucket.usageObservedRequests, MAX_COUNTER) + || bucket.usageObservedRequests > bucket.requests + || !isBoundedInteger(bucket.inputTokens, MAX_TOKEN_TOTAL) + || !isBoundedInteger(bucket.outputTokens, MAX_TOKEN_TOTAL) + || !validateBins(bucket.durationBins) + || !validateBins(bucket.responseStartBins) + || bucket.durationBins.reduce((sum, count) => sum + count, 0) !== bucket.requests + || bucket.responseStartBins.reduce((sum, count) => sum + count, 0) > bucket.requests + || !isBoundedInteger(bucket.unknownModelRequests, MAX_COUNTER) + || !isBoundedInteger(bucket.modelOverflowRequests, MAX_COUNTER) + || !isBoundedInteger(bucket.providerOverflowRequests, MAX_COUNTER) + || !isBoundedInteger(bucket.droppedObservations, MAX_COUNTER) + || !Array.isArray(bucket.providers) + || bucket.providers.length > MAX_PROVIDERS_PER_BUCKET + || !bucket.providers.every(validateProviderRow) + || new Set(bucket.providers.map((row) => row.providerId)).size !== bucket.providers.length + || !Array.isArray(bucket.models) + || bucket.models.length > MAX_MODELS_PER_BUCKET + || !bucket.models.every(validateModelRow) + || new Set(bucket.models.map((row) => row.model)).size !== bucket.models.length + || bucket.providers.reduce((sum, row) => sum + row.requests, 0) + + bucket.providerOverflowRequests !== bucket.requests + || bucket.models.reduce((sum, row) => sum + row.requests, 0) + + bucket.unknownModelRequests + bucket.modelOverflowRequests !== bucket.requests) { + throw new Error("Metrics bucket is invalid."); + } + return structuredClone(bucket); +} + +function validateDocument(document) { + if (!hasExactFields(document, DOCUMENT_FIELDS) + || document.schemaVersion !== METRICS_SCHEMA_VERSION + || document.bucketMinutes !== METRICS_BUCKET_MINUTES + || document.retentionBuckets !== METRICS_RETENTION_BUCKETS + || !Array.isArray(document.buckets) + || document.buckets.length > METRICS_RETENTION_BUCKETS) { + throw new Error("Metrics document is invalid."); + } + const buckets = document.buckets.map(validateBucket); + for (let index = 1; index < buckets.length; index += 1) { + if (buckets[index - 1].start >= buckets[index].start) { + throw new Error("Metrics buckets are not strictly ordered."); + } + } + return buckets; +} + +function validateObservation(observation) { + if (!hasExactFields(observation, OBSERVATION_FIELDS) + || !isBoundedText(observation.providerId, MAX_PROVIDER_ID_LENGTH) + || !RESULTS.includes(observation.result) + || (observation.model !== null && !isBoundedText(observation.model, MAX_MODEL_ID_LENGTH)) + || !Number.isInteger(observation.durationBin) + || observation.durationBin < 0 + || observation.durationBin >= LATENCY_BIN_COUNT + || (observation.responseStartBin !== null + && (!Number.isInteger(observation.responseStartBin) + || observation.responseStartBin < 0 + || observation.responseStartBin >= LATENCY_BIN_COUNT))) { + return false; + } + const noUsage = observation.inputTokens === null && observation.outputTokens === null; + const completeUsage = isBoundedInteger(observation.inputTokens, MAX_OBSERVATION_TOKENS) + && isBoundedInteger(observation.outputTokens, MAX_OBSERVATION_TOKENS); + return noUsage || completeUsage; +} + +function sameIdentity(left, right) { + return left.dev === right.dev && left.ino === right.ino; +} + +function privateMode(stats, platform) { + return platform === "win32" || (stats.mode & 0o777) === 0o600; +} + +function addBounded(target, field, amount, maximum, bucket) { + const next = target[field] + amount; + if (Number.isSafeInteger(next) && next <= maximum) { + target[field] = next; + return; + } + target[field] = maximum; + bucket.droppedObservations = Math.min(MAX_COUNTER, bucket.droppedObservations + 1); +} + +function addBin(bins, index, bucket) { + const next = bins[index] + 1; + if (next <= MAX_COUNTER) bins[index] = next; + else bucket.droppedObservations = Math.min(MAX_COUNTER, bucket.droppedObservations + 1); +} + +function mergeBins(target, source) { + for (let index = 0; index < LATENCY_BIN_COUNT; index += 1) { + target[index] = Math.min(MAX_COUNTER, target[index] + source[index]); + } +} + +function percentileUpperBound(bins, percentile) { + const total = bins.reduce((sum, count) => sum + count, 0); + if (total === 0) return null; + const threshold = Math.max(1, Math.ceil(total * percentile)); + let observed = 0; + for (let index = 0; index < bins.length; index += 1) { + observed += bins[index]; + if (observed >= threshold) return METRICS_LATENCY_BOUNDS_MS[index] ?? null; + } + return null; +} + +function latencyProjection(bins) { + return { + p50UpperBoundMs: percentileUpperBound(bins, 0.5), + p95UpperBoundMs: percentileUpperBound(bins, 0.95), + overflowRequests: bins.at(-1) + }; +} + +function tokenProjection(source) { + return { + input: source.inputTokens, + output: source.outputTokens, + observedRequests: source.usageObservedRequests + }; +} + +export function latencyBinForMs(value) { + if (!Number.isFinite(value) || value < 0) return METRICS_LATENCY_BOUNDS_MS.length; + const index = METRICS_LATENCY_BOUNDS_MS.findIndex((boundary) => value <= boundary); + return index === -1 ? METRICS_LATENCY_BOUNDS_MS.length : index; +} + +export class MetricsStore { + constructor({ + path, + now = () => Date.now(), + flushDelayMs = DEFAULT_FLUSH_DELAY_MS, + fileOperations = DEFAULT_FILE_OPERATIONS, + createId = randomUUID, + platform = process.platform + } = {}) { + if (typeof path !== "string" || path.length === 0 + || typeof now !== "function" + || !Number.isSafeInteger(flushDelayMs) || flushDelayMs < 0 + || !fileOperations || typeof createId !== "function") { + throw new TypeError("Metrics store options are invalid."); + } + this.path = path; + this.now = now; + this.flushDelayMs = flushDelayMs; + this.fileOperations = fileOperations; + this.createId = createId; + this.platform = platform; + this.buckets = []; + this.storageState = "ready"; + this.writeBlocked = false; + this.dirty = false; + this.closed = false; + this.flushTimer = null; + this.#load(); + } + + record(observation) { + if (this.closed || !validateObservation(observation)) return false; + const nowMs = this.#nowMs(); + this.#prune(nowMs); + const start = new Date(Math.floor(nowMs / BUCKET_MS) * BUCKET_MS).toISOString(); + let bucket = this.buckets.at(-1); + if (!bucket || bucket.start !== start) { + bucket = emptyBucket(start); + this.buckets.push(bucket); + } + + addBounded(bucket, "requests", 1, MAX_COUNTER, bucket); + addBounded(bucket.results, observation.result, 1, MAX_COUNTER, bucket); + addBin(bucket.durationBins, observation.durationBin, bucket); + if (observation.responseStartBin !== null) { + addBin(bucket.responseStartBins, observation.responseStartBin, bucket); + } + const hasUsage = observation.inputTokens !== null && observation.outputTokens !== null; + if (hasUsage) { + addBounded(bucket, "usageObservedRequests", 1, MAX_COUNTER, bucket); + addBounded(bucket, "inputTokens", observation.inputTokens, MAX_TOKEN_TOTAL, bucket); + addBounded(bucket, "outputTokens", observation.outputTokens, MAX_TOKEN_TOTAL, bucket); + } + + let provider = bucket.providers.find((row) => row.providerId === observation.providerId); + if (!provider && bucket.providers.length < MAX_PROVIDERS_PER_BUCKET) { + provider = { + providerId: observation.providerId, + requests: 0, + successfulRequests: 0, + usageObservedRequests: 0, + inputTokens: 0, + outputTokens: 0, + durationBins: emptyBins() + }; + bucket.providers.push(provider); + } + if (provider) { + addBounded(provider, "requests", 1, MAX_COUNTER, bucket); + if (observation.result === "success") { + addBounded(provider, "successfulRequests", 1, MAX_COUNTER, bucket); + } + addBin(provider.durationBins, observation.durationBin, bucket); + if (hasUsage) { + addBounded(provider, "usageObservedRequests", 1, MAX_COUNTER, bucket); + addBounded(provider, "inputTokens", observation.inputTokens, MAX_TOKEN_TOTAL, bucket); + addBounded(provider, "outputTokens", observation.outputTokens, MAX_TOKEN_TOTAL, bucket); + } + } else { + addBounded(bucket, "providerOverflowRequests", 1, MAX_COUNTER, bucket); + } + + if (observation.model === null) { + addBounded(bucket, "unknownModelRequests", 1, MAX_COUNTER, bucket); + } else { + let model = bucket.models.find((row) => row.model === observation.model); + if (!model && bucket.models.length < MAX_MODELS_PER_BUCKET) { + model = { + model: observation.model, + requests: 0, + usageObservedRequests: 0, + inputTokens: 0, + outputTokens: 0 + }; + bucket.models.push(model); + } + if (model) { + addBounded(model, "requests", 1, MAX_COUNTER, bucket); + if (hasUsage) { + addBounded(model, "usageObservedRequests", 1, MAX_COUNTER, bucket); + addBounded(model, "inputTokens", observation.inputTokens, MAX_TOKEN_TOTAL, bucket); + addBounded(model, "outputTokens", observation.outputTokens, MAX_TOKEN_TOTAL, bucket); + } + } else { + addBounded(bucket, "modelOverflowRequests", 1, MAX_COUNTER, bucket); + } + } + + bucket.providers.sort((left, right) => compareText(left.providerId, right.providerId)); + bucket.models.sort((left, right) => compareText(left.model, right.model)); + this.dirty = true; + this.#scheduleFlush(); + return true; + } + + noteDropped() { + if (this.closed) return; + const nowMs = this.#nowMs(); + this.#prune(nowMs); + const start = new Date(Math.floor(nowMs / BUCKET_MS) * BUCKET_MS).toISOString(); + let bucket = this.buckets.at(-1); + if (!bucket || bucket.start !== start) { + bucket = emptyBucket(start); + this.buckets.push(bucket); + } + bucket.droppedObservations = Math.min(MAX_COUNTER, bucket.droppedObservations + 1); + this.dirty = true; + this.#scheduleFlush(); + } + + getOverview({ window = "24h" } = {}) { + const bucketCount = window === "24h" ? 24 : window === "7d" ? METRICS_RETENTION_BUCKETS : null; + if (bucketCount === null) throw new TypeError("Metrics window is invalid."); + const nowMs = this.#nowMs(); + this.#prune(nowMs); + const currentStart = Math.floor(nowMs / BUCKET_MS) * BUCKET_MS; + const firstStart = currentStart - ((bucketCount - 1) * BUCKET_MS); + const selected = this.buckets.filter((bucket) => Date.parse(bucket.start) >= firstStart); + const byStart = new Map(selected.map((bucket) => [bucket.start, bucket])); + const summary = emptyBucket(new Date(firstStart).toISOString()); + const providerTotals = new Map(); + const modelTotals = new Map(); + const series = []; + + for (let index = 0; index < bucketCount; index += 1) { + const start = new Date(firstStart + (index * BUCKET_MS)).toISOString(); + const bucket = byStart.get(start) ?? emptyBucket(start); + addBounded(summary, "requests", bucket.requests, MAX_COUNTER, summary); + for (const result of RESULTS) { + addBounded(summary.results, result, bucket.results[result], MAX_COUNTER, summary); + } + addBounded(summary, "usageObservedRequests", bucket.usageObservedRequests, MAX_COUNTER, summary); + addBounded(summary, "inputTokens", bucket.inputTokens, MAX_TOKEN_TOTAL, summary); + addBounded(summary, "outputTokens", bucket.outputTokens, MAX_TOKEN_TOTAL, summary); + mergeBins(summary.durationBins, bucket.durationBins); + mergeBins(summary.responseStartBins, bucket.responseStartBins); + for (const field of [ + "unknownModelRequests", + "modelOverflowRequests", + "providerOverflowRequests", + "droppedObservations" + ]) { + addBounded(summary, field, bucket[field], MAX_COUNTER, summary); + } + series.push({ + start, + requests: bucket.requests, + results: { ...bucket.results }, + tokens: tokenProjection(bucket) + }); + + for (const row of bucket.providers) { + let total = providerTotals.get(row.providerId); + if (!total) { + total = { + providerId: row.providerId, + requests: 0, + successfulRequests: 0, + usageObservedRequests: 0, + inputTokens: 0, + outputTokens: 0, + durationBins: emptyBins() + }; + providerTotals.set(row.providerId, total); + } + for (const field of ["requests", "successfulRequests", "usageObservedRequests"]) { + total[field] = Math.min(MAX_COUNTER, total[field] + row[field]); + } + total.inputTokens = Math.min(MAX_TOKEN_TOTAL, total.inputTokens + row.inputTokens); + total.outputTokens = Math.min(MAX_TOKEN_TOTAL, total.outputTokens + row.outputTokens); + mergeBins(total.durationBins, row.durationBins); + } + for (const row of bucket.models) { + let total = modelTotals.get(row.model); + if (!total) { + total = { + model: row.model, + requests: 0, + usageObservedRequests: 0, + inputTokens: 0, + outputTokens: 0 + }; + modelTotals.set(row.model, total); + } + for (const field of ["requests", "usageObservedRequests"]) { + total[field] = Math.min(MAX_COUNTER, total[field] + row[field]); + } + total.inputTokens = Math.min(MAX_TOKEN_TOTAL, total.inputTokens + row.inputTokens); + total.outputTokens = Math.min(MAX_TOKEN_TOTAL, total.outputTokens + row.outputTokens); + } + } + + const providerRows = [...providerTotals.values()] + .sort((left, right) => right.requests - left.requests || compareText(left.providerId, right.providerId)); + const modelRows = [...modelTotals.values()] + .sort((left, right) => right.requests - left.requests || compareText(left.model, right.model)); + return { + window, + bucketMinutes: METRICS_BUCKET_MINUTES, + storageState: this.storageState, + summary: { + requests: summary.requests, + results: { ...summary.results }, + tokens: tokenProjection(summary), + latency: latencyProjection(summary.durationBins), + responseStart: latencyProjection(summary.responseStartBins) + }, + series, + providers: providerRows.slice(0, 16).map((row) => ({ + providerId: row.providerId, + requests: row.requests, + successfulRequests: row.successfulRequests, + tokens: tokenProjection(row), + latency: latencyProjection(row.durationBins) + })), + providerOtherRequests: providerRows.slice(16).reduce( + (sum, row) => Math.min(MAX_COUNTER, sum + row.requests), + 0 + ), + models: modelRows.slice(0, 16).map((row) => ({ + model: row.model, + requests: row.requests, + tokens: tokenProjection(row) + })), + modelOtherRequests: modelRows.slice(16).reduce( + (sum, row) => Math.min(MAX_COUNTER, sum + row.requests), + 0 + ), + dataQuality: { + unknownModelRequests: summary.unknownModelRequests, + modelOverflowRequests: summary.modelOverflowRequests, + providerOverflowRequests: summary.providerOverflowRequests, + droppedObservations: summary.droppedObservations + } + }; + } + + flush() { + if (this.closed || !this.dirty || this.writeBlocked) return false; + if (this.flushTimer) { + clearTimeout(this.flushTimer); + this.flushTimer = null; + } + try { + this.#persist(); + this.dirty = false; + this.storageState = "ready"; + return true; + } catch { + this.storageState = "degraded"; + return false; + } + } + + close() { + if (this.closed) return; + if (this.flushTimer) { + clearTimeout(this.flushTimer); + this.flushTimer = null; + } + if (this.dirty && !this.writeBlocked) { + try { + this.#persist(); + this.dirty = false; + } catch { + this.storageState = "degraded"; + } + } + this.closed = true; + } + + #nowMs() { + const value = this.now(); + const timestamp = value instanceof Date || typeof value === "string" ? Date.parse(value) : Number(value); + if (!Number.isFinite(timestamp) || timestamp < 0 || !Number.isSafeInteger(Math.trunc(timestamp))) { + throw new TypeError("Metrics clock is invalid."); + } + return Math.trunc(timestamp); + } + + #prune(nowMs) { + const currentStart = Math.floor(nowMs / BUCKET_MS) * BUCKET_MS; + const firstStart = currentStart - ((METRICS_RETENTION_BUCKETS - 1) * BUCKET_MS); + this.buckets = this.buckets.filter((bucket) => { + const start = Date.parse(bucket.start); + return start >= firstStart && start <= currentStart; + }); + } + + #scheduleFlush() { + if (this.writeBlocked || this.flushTimer || this.closed) return; + this.flushTimer = setTimeout(() => { + this.flushTimer = null; + this.flush(); + }, this.flushDelayMs); + this.flushTimer.unref?.(); + } + + #load() { + let before; + try { + before = this.fileOperations.lstatSync(this.path); + } catch (error) { + if (error?.code === "ENOENT") return; + this.#blockWrites(); + return; + } + try { + if (!before.isFile() || before.isSymbolicLink() + || before.size > METRICS_MAX_FILE_BYTES + || !privateMode(before, this.platform)) { + throw new Error("Metrics path is unsafe."); + } + const noFollow = typeof this.fileOperations.constants.O_NOFOLLOW === "number" + ? this.fileOperations.constants.O_NOFOLLOW + : 0; + let descriptor; + try { + descriptor = this.fileOperations.openSync( + this.path, + this.fileOperations.constants.O_RDONLY | noFollow + ); + const opened = this.fileOperations.fstatSync(descriptor); + if (!opened.isFile() || !sameIdentity(before, opened) + || opened.size > METRICS_MAX_FILE_BYTES || !privateMode(opened, this.platform)) { + throw new Error("Metrics identity changed."); + } + const bytes = this.fileOperations.readFileSync(descriptor); + const after = this.fileOperations.lstatSync(this.path); + if (!after.isFile() || after.isSymbolicLink() || !sameIdentity(opened, after)) { + throw new Error("Metrics identity changed."); + } + const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + this.buckets = validateDocument(JSON.parse(text)); + this.#prune(this.#nowMs()); + } finally { + if (descriptor !== undefined) this.fileOperations.closeSync(descriptor); + } + } catch { + this.buckets = []; + this.#blockWrites(); + } + } + + #blockWrites() { + this.storageState = "unavailable"; + this.writeBlocked = true; + } + + #persist() { + const parent = dirname(this.path); + this.fileOperations.mkdirSync(parent, { recursive: true, mode: 0o700 }); + try { this.fileOperations.chmodSync(parent, 0o700); } catch {} + const document = { + schemaVersion: METRICS_SCHEMA_VERSION, + bucketMinutes: METRICS_BUCKET_MINUTES, + retentionBuckets: METRICS_RETENTION_BUCKETS, + buckets: this.buckets + }; + const bytes = Buffer.from(`${JSON.stringify(document)}\n`, "utf8"); + if (bytes.length > METRICS_MAX_FILE_BYTES) throw new Error("Metrics document is too large."); + const tempPath = join(parent, `.${basename(this.path)}.${this.createId()}.tmp`); + let descriptor; + try { + descriptor = this.fileOperations.openSync(tempPath, "wx", 0o600); + this.fileOperations.writeFileSync(descriptor, bytes); + this.fileOperations.fsyncSync(descriptor); + this.fileOperations.fchmodSync(descriptor, 0o600); + this.fileOperations.closeSync(descriptor); + descriptor = undefined; + this.fileOperations.renameSync(tempPath, this.path); + } catch (error) { + if (descriptor !== undefined) { + try { this.fileOperations.closeSync(descriptor); } catch {} + } + try { this.fileOperations.rmSync(tempPath, { force: true }); } catch {} + throw error; + } + } +} diff --git a/node/src/supervisor/migration.mjs b/node/src/supervisor/migration.mjs new file mode 100644 index 0000000..9944848 --- /dev/null +++ b/node/src/supervisor/migration.mjs @@ -0,0 +1,678 @@ +import { randomUUID } from "node:crypto"; +import { + closeSync, + constants as FS_CONSTANTS, + fchmodSync, + fstatSync, + fsyncSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + rmSync, + writeFileSync +} from "node:fs"; +import { basename, dirname, join } from "node:path"; + +import { ProviderRegistry } from "../providers/provider-registry.mjs"; +import { CrpError } from "../shared/errors.mjs"; + +const DEFAULT_FILE_OPERATIONS = { + closeSync, + fchmodSync, + fstatSync, + fsyncSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + rmSync, + writeFileSync +}; +const BACKUP_ATTEMPTS = 8; + +function migrationError(code, cause, details = {}) { + const contracts = { + MIGRATION_BUSY: [ + "Provider migration is already running.", + "Wait for the current migration to finish and try again.", + 409 + ], + MIGRATION_INPUT_INVALID: [ + "The legacy provider configuration is invalid.", + "Restore a complete legacy provider URL and credential before migrating.", + 400 + ], + MIGRATION_REGISTRY_CONFLICT: [ + "The provider registry was created by another process during migration.", + "Review the current provider registry before retrying migration.", + 409 + ], + MIGRATION_FAILED: [ + "CRP could not migrate the legacy provider configuration.", + "Review local storage and credential-backend health, then retry migration.", + 500 + ], + MIGRATION_ROLLBACK_DEGRADED: [ + "Provider migration failed and rollback could not be completed safely.", + "Stop CRP and restore the retained migration backup before restarting.", + 500 + ], + MIGRATION_COMMITTED_LOCK_DEGRADED: [ + "Provider migration completed, but its transaction lock could not be fully released.", + "Stop CRP, explicitly repair the residual migration lock, then restart CRP.", + 500 + ], + MIGRATION_COMMITTED_DEGRADED: [ + "Provider migration completed, but persistence cleanup degraded.", + "Stop CRP and repair the retained migration state before restarting.", + 500 + ] + }; + const [message, action, status] = contracts[code] ?? contracts.MIGRATION_FAILED; + return new CrpError(code, message, action, { status, cause, details }); +} + +function isCommittedError(error) { + return error instanceof CrpError && error.details?.committed === true; +} + +function parseJson(bytes) { + try { + const value = JSON.parse(bytes); + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new Error("not an object"); + } + return value; + } catch (error) { + throw migrationError("MIGRATION_INPUT_INVALID", error); + } +} + +function optionalString(...values) { + return values.find((value) => typeof value === "string" && value.trim().length > 0)?.trim() ?? null; +} + +function sourceValues(config, runtime) { + const runtimeUpstream = runtime?.upstream; + const runtimeObject = runtimeUpstream !== null && typeof runtimeUpstream === "object" + && !Array.isArray(runtimeUpstream) ? runtimeUpstream : {}; + const secretCandidates = [ + runtimeObject.apiKey, + runtime?.apiKey, + runtime?.upstreamApiKey, + runtime?.upstream_api_key, + config?.apiKey, + config?.upstreamApiKey, + config?.upstream_api_key + ].filter((value) => typeof value === "string" && value.length > 0); + if (new Set(secretCandidates).size > 1) { + throw migrationError("MIGRATION_INPUT_INVALID"); + } + return { + baseUrl: optionalString( + runtimeObject.baseUrl, + runtime?.baseUrl, + runtime?.upstreamBaseUrl, + runtime?.upstream_base_url, + config?.upstreamBaseUrl, + config?.upstream_base_url, + config?.baseUrl + ), + secret: secretCandidates[0] ?? null, + authHeader: optionalString(runtimeObject.authHeader, runtime?.authHeader) ?? "authorization", + authScheme: typeof runtimeObject.authScheme === "string" + ? runtimeObject.authScheme + : (typeof runtime?.authScheme === "string" ? runtime.authScheme : "Bearer"), + extraHeaders: runtimeObject.extraHeaders ?? runtime?.extraHeaders ?? {} + }; +} + +function scrubDocument(document) { + const next = structuredClone(document); + for (const key of ["apiKey", "upstreamApiKey", "upstream_api_key"]) delete next[key]; + if (next.upstream !== null && typeof next.upstream === "object" && !Array.isArray(next.upstream)) { + delete next.upstream.apiKey; + } + return next; +} + +function emptyRegistryBytes() { + return Buffer.from(`${JSON.stringify({ + schemaVersion: 2, + activeProviderId: null, + providers: [], + settings: { + proxyHost: "127.0.0.1", + proxyPort: 15100, + adminHost: "127.0.0.1", + adminPort: 15101, + captureEnabled: false + } + }, null, 2)}\n`, "utf8"); +} + +function identityOf(stats) { + return { dev: stats.dev, ino: stats.ino }; +} + +function sameIdentity(left, right) { + return left !== null && right !== null + && left.dev === right.dev + && left.ino === right.ino; +} + +function lstatRegular(path, fileOperations, { missing = false } = {}) { + let stats; + try { + stats = fileOperations.lstatSync(path); + } catch (error) { + if (missing && error?.code === "ENOENT") return null; + throw migrationError("MIGRATION_INPUT_INVALID", error); + } + if (!stats.isFile() || stats.isSymbolicLink()) { + throw migrationError("MIGRATION_INPUT_INVALID"); + } + return { stats, identity: identityOf(stats) }; +} + +function readSafeFile(path, fileOperations, { missing = false } = {}) { + const before = lstatRegular(path, fileOperations, { missing }); + if (before === null) return null; + const noFollow = typeof FS_CONSTANTS.O_NOFOLLOW === "number" ? FS_CONSTANTS.O_NOFOLLOW : 0; + let descriptor; + try { + descriptor = fileOperations.openSync(path, FS_CONSTANTS.O_RDONLY | noFollow); + const descriptorStats = fileOperations.fstatSync(descriptor); + if (!descriptorStats.isFile() + || !sameIdentity(before.identity, identityOf(descriptorStats))) { + throw migrationError("MIGRATION_INPUT_INVALID"); + } + const bytes = fileOperations.readFileSync(descriptor); + fileOperations.closeSync(descriptor); + descriptor = undefined; + return { path, bytes, identity: before.identity }; + } catch (error) { + if (descriptor !== undefined) { + try { fileOperations.closeSync(descriptor); } catch {} + } + if (error instanceof CrpError) throw error; + throw migrationError("MIGRATION_INPUT_INVALID", error); + } +} + +function restoreClaim(claimPath, canonicalPath, fileOperations) { + try { + fileOperations.renameSync(claimPath, canonicalPath); + } catch { + // A foreign canonical path is already a blocker; never remove it. + } +} + +function ensureCanonicalBlocker(path, fileOperations) { + let descriptor; + try { + descriptor = fileOperations.openSync(path, "wx", 0o600); + fileOperations.writeFileSync(descriptor, "migration-degraded\n", "utf8"); + fileOperations.fsyncSync(descriptor); + fileOperations.closeSync(descriptor); + descriptor = undefined; + return true; + } catch (error) { + if (descriptor !== undefined) { + try { fileOperations.closeSync(descriptor); } catch {} + } + return error?.code === "EEXIST"; + } +} + +function claimOwnedPath(path, expectedIdentity, fileOperations, createId = randomUUID) { + const claimPath = join(dirname(path), `.${basename(path)}.${createId()}.claim`); + try { + fileOperations.renameSync(path, claimPath); + } catch { + return false; + } + let claim; + try { + claim = lstatRegular(claimPath, fileOperations); + } catch { + restoreClaim(claimPath, path, fileOperations); + return false; + } + if (!sameIdentity(claim.identity, expectedIdentity)) { + restoreClaim(claimPath, path, fileOperations); + return false; + } + try { + fileOperations.rmSync(claimPath); + return true; + } catch { + restoreClaim(claimPath, path, fileOperations); + return false; + } +} + +function createLock({ lockPath, fileOperations, createId }) { + fileOperations.mkdirSync(dirname(lockPath), { recursive: true, mode: 0o700 }); + const token = `${createId()}\n`; + let descriptor; + let identity = null; + let closed = false; + try { + descriptor = fileOperations.openSync(lockPath, "wx", 0o600); + identity = identityOf(fileOperations.fstatSync(descriptor)); + fileOperations.writeFileSync(descriptor, token, "utf8"); + fileOperations.fchmodSync(descriptor, 0o600); + fileOperations.fsyncSync(descriptor); + fileOperations.closeSync(descriptor); + closed = true; + descriptor = undefined; + return { token, identity }; + } catch (error) { + if (identity === null) { + if (error?.code === "EEXIST") throw migrationError("MIGRATION_BUSY", error); + throw migrationError("MIGRATION_FAILED", error); + } + if (!closed && descriptor !== undefined) { + try { + fileOperations.closeSync(descriptor); + closed = true; + } catch {} + } + const cleaned = closed && claimOwnedPath(lockPath, identity, fileOperations, createId); + if (!cleaned) { + ensureCanonicalBlocker(lockPath, fileOperations); + throw migrationError("MIGRATION_ROLLBACK_DEGRADED", error, { + committed: false, + degraded: true + }); + } + throw migrationError("MIGRATION_FAILED", error); + } +} + +function releaseLock({ lockPath, lock, fileOperations, createId }) { + const claimPath = join( + dirname(lockPath), + `.${basename(lockPath)}.${createId()}.release` + ); + try { + fileOperations.renameSync(lockPath, claimPath); + } catch { + ensureCanonicalBlocker(lockPath, fileOperations); + return false; + } + try { + const claimed = readSafeFile(claimPath, fileOperations); + if (!sameIdentity(claimed.identity, lock.identity) + || claimed.bytes.toString("utf8") !== lock.token) { + restoreClaim(claimPath, lockPath, fileOperations); + ensureCanonicalBlocker(lockPath, fileOperations); + return false; + } + fileOperations.rmSync(claimPath); + return true; + } catch { + restoreClaim(claimPath, lockPath, fileOperations); + ensureCanonicalBlocker(lockPath, fileOperations); + return false; + } +} + +function writeExclusive(path, bytes, fileOperations, createId = randomUUID) { + let descriptor; + let identity = null; + let closed = false; + try { + descriptor = fileOperations.openSync(path, "wx", 0o600); + identity = identityOf(fileOperations.fstatSync(descriptor)); + fileOperations.writeFileSync(descriptor, bytes); + fileOperations.fchmodSync(descriptor, 0o600); + fileOperations.fsyncSync(descriptor); + fileOperations.closeSync(descriptor); + closed = true; + descriptor = undefined; + const committed = lstatRegular(path, fileOperations); + if (!sameIdentity(committed.identity, identity)) { + throw migrationError("MIGRATION_ROLLBACK_DEGRADED", null, { + committed: false, + degraded: true + }); + } + return committed.identity; + } catch (error) { + if (identity === null) throw error; + if (!closed && descriptor !== undefined) { + try { + fileOperations.closeSync(descriptor); + closed = true; + } catch {} + } + const cleaned = closed && claimOwnedPath(path, identity, fileOperations, createId); + if (!cleaned) { + throw migrationError("MIGRATION_ROLLBACK_DEGRADED", error, { + committed: false, + degraded: true + }); + } + throw error; + } +} + +function createBackup(source, fileOperations, createBackupId) { + for (let attempt = 0; attempt < BACKUP_ATTEMPTS; attempt += 1) { + const backupPath = `${source.path}.${createBackupId()}.bak`; + try { + writeExclusive(backupPath, source.bytes, fileOperations, createBackupId); + return backupPath; + } catch (error) { + if (error?.code !== "EEXIST") throw error; + } + } + throw migrationError("MIGRATION_FAILED"); +} + +function replaceFile(path, bytes, expectedIdentity, fileOperations, createId) { + const tempPath = join(dirname(path), `.${basename(path)}.${createId()}.tmp`); + let tempIdentity = null; + try { + const before = lstatRegular(path, fileOperations); + if (!sameIdentity(before.identity, expectedIdentity)) { + throw migrationError("MIGRATION_ROLLBACK_DEGRADED", null, { + committed: false, + degraded: true + }); + } + tempIdentity = writeExclusive(tempPath, bytes, fileOperations, createId); + const current = lstatRegular(path, fileOperations); + if (!sameIdentity(current.identity, expectedIdentity)) { + throw migrationError("MIGRATION_ROLLBACK_DEGRADED", null, { + committed: false, + degraded: true + }); + } + fileOperations.renameSync(tempPath, path); + const committed = lstatRegular(path, fileOperations); + if (!sameIdentity(committed.identity, tempIdentity)) { + throw migrationError("MIGRATION_ROLLBACK_DEGRADED", null, { + committed: true, + degraded: true + }); + } + return committed.identity; + } catch (error) { + if (tempIdentity !== null) { + claimOwnedPath(tempPath, tempIdentity, fileOperations, createId); + } + throw error; + } +} + +function readSource(path, fileOperations) { + const source = readSafeFile(path, fileOperations, { missing: true }); + if (source === null) return null; + return { ...source, currentIdentity: source.identity, document: parseJson(source.bytes) }; +} + +function assertCurrentRegistry(path, fileOperations) { + const source = readSafeFile(path, fileOperations, { missing: true }); + if (source === null) return false; + const document = parseJson(source.bytes); + if (document.schemaVersion !== 2) throw migrationError("MIGRATION_INPUT_INVALID"); + new ProviderRegistry({ path, fileOperations }); + const after = lstatRegular(path, fileOperations); + if (!sameIdentity(after.identity, source.identity)) { + throw migrationError("MIGRATION_INPUT_INVALID"); + } + return true; +} + +export async function migrateLegacyConfiguration({ + paths, + credentialStore, + activityStore = null, + now = () => new Date().toISOString(), + createProviderId = randomUUID, + createCredentialRef = randomUUID, + createBackupId = randomUUID, + createLockId = randomUUID, + fileOperations: overrides = {} +}) { + if (!paths || typeof paths.globalHome !== "string" || typeof paths.registryPath !== "string" + || !credentialStore || typeof credentialStore.set !== "function" + || typeof credentialStore.delete !== "function") { + throw migrationError("MIGRATION_INPUT_INVALID"); + } + const fileOperations = { ...DEFAULT_FILE_OPERATIONS, ...overrides }; + const legacyConfigPath = paths.legacyConfigPath ?? join(paths.globalHome, "config.json"); + const runtimeConfigPath = paths.runtimeConfigPath + ?? join(paths.globalHome, "node", "proxy-config.json"); + const lockPath = `${paths.registryPath}.migration.lock`; + const lock = createLock({ + lockPath, + fileOperations, + createId: createLockId + }); + + let completed = false; + let providerId = null; + let credentialRef = null; + let credentialAttempted = false; + let registryOwned = null; + let commitWarning = null; + const sources = []; + const scrubbedSources = []; + let failure; + + try { + if (assertCurrentRegistry(paths.registryPath, fileOperations)) { + completed = true; + return { migrated: false, reason: "already-current" }; + } + + const configSource = readSource(legacyConfigPath, fileOperations); + const runtimeSource = readSource(runtimeConfigPath, fileOperations); + if (configSource) sources.push(configSource); + if (runtimeSource) sources.push(runtimeSource); + if (sources.length === 0) { + completed = true; + return { migrated: false, reason: "no-legacy-config" }; + } + + const values = sourceValues(configSource?.document, runtimeSource?.document); + if (!values.baseUrl || !values.secret) throw migrationError("MIGRATION_INPUT_INVALID"); + + fileOperations.mkdirSync(paths.globalHome, { recursive: true, mode: 0o700 }); + for (const source of sources) { + createBackup(source, fileOperations, createBackupId); + } + + providerId = createProviderId(); + credentialRef = createCredentialRef(); + credentialAttempted = true; + try { + await credentialStore.set(credentialRef, values.secret); + } catch (error) { + if (isCommittedError(error)) commitWarning = error; + else throw error; + } + + const initialRegistryBytes = emptyRegistryBytes(); + try { + registryOwned = { + identity: writeExclusive( + paths.registryPath, + initialRegistryBytes, + fileOperations, + createLockId + ), + bytes: Buffer.from(initialRegistryBytes) + }; + } catch (error) { + if (error?.code === "EEXIST") { + throw migrationError("MIGRATION_REGISTRY_CONFLICT", error); + } + throw error; + } + + const registry = new ProviderRegistry({ + path: paths.registryPath, + createId: () => providerId, + now, + fileOperations + }); + try { + registry.create({ + name: "Default", + baseUrl: values.baseUrl, + credentialRef, + authHeader: values.authHeader, + authScheme: values.authScheme, + extraHeaders: values.extraHeaders, + modelMode: "passthrough", + modelOverride: null + }); + } catch (error) { + if (isCommittedError(error)) commitWarning = error; + else throw error; + } + const committed = registry.getDocument(); + if (committed.schemaVersion !== 2 + || committed.activeProviderId !== null + || committed.providers.length !== 1 + || committed.providers[0].lastTestStatus !== "untested") { + throw migrationError("MIGRATION_FAILED"); + } + const committedRegistry = readSafeFile(paths.registryPath, fileOperations); + registryOwned = { + identity: committedRegistry.identity, + bytes: Buffer.from(committedRegistry.bytes) + }; + + for (const source of sources) { + const scrubbedBytes = Buffer.from( + `${JSON.stringify(scrubDocument(source.document), null, 2)}\n`, + "utf8" + ); + source.currentIdentity = replaceFile( + source.path, + scrubbedBytes, + source.currentIdentity, + fileOperations, + createLockId + ); + scrubbedSources.push(source); + } + + if (activityStore) { + await activityStore.append({ + category: "migration", + action: "legacy-config", + providerId, + result: commitWarning ? "degraded" : "success", + errorCode: commitWarning ? "MIGRATION_COMMITTED_DEGRADED" : null, + details: { sourceCount: sources.length } + }); + } + completed = true; + if (commitWarning) { + throw migrationError("MIGRATION_COMMITTED_DEGRADED", commitWarning, { + committed: true, + degraded: true + }); + } + return { migrated: true, providerId }; + } catch (error) { + if (completed && isCommittedError(error)) throw error; + failure = error; + let rollbackFailed = error?.code === "MIGRATION_ROLLBACK_DEGRADED" + || error?.details?.degraded === true; + for (const source of scrubbedSources.reverse()) { + try { + source.currentIdentity = replaceFile( + source.path, + source.bytes, + source.currentIdentity, + fileOperations, + createLockId + ); + } catch { + rollbackFailed = true; + } + } + if (registryOwned !== null) { + try { + const currentRegistry = readSafeFile(paths.registryPath, fileOperations, { missing: true }); + if (currentRegistry === null + || !sameIdentity(currentRegistry.identity, registryOwned.identity) + || !currentRegistry.bytes.equals(registryOwned.bytes) + || !claimOwnedPath( + paths.registryPath, + registryOwned.identity, + fileOperations, + createLockId + )) { + rollbackFailed = true; + } + } catch { + rollbackFailed = true; + } + } + if (credentialAttempted && credentialRef !== null) { + try { await credentialStore.delete(credentialRef); } catch { rollbackFailed = true; } + } + if (activityStore) { + try { + const safeFailureCode = error instanceof CrpError + && error.code === "MIGRATION_INPUT_INVALID" + ? error.code + : "MIGRATION_FAILED"; + await activityStore.append({ + category: "migration", + action: "legacy-config", + providerId, + result: "failed", + errorCode: rollbackFailed ? "MIGRATION_ROLLBACK_DEGRADED" : safeFailureCode, + details: { rollbackDegraded: rollbackFailed } + }); + } catch { + rollbackFailed = true; + } + } + if (rollbackFailed) { + throw migrationError("MIGRATION_ROLLBACK_DEGRADED", failure, { + committed: false, + degraded: true + }); + } + if (error instanceof CrpError && ( + error.code === "MIGRATION_INPUT_INVALID" + || error.code === "MIGRATION_REGISTRY_CONFLICT" + )) { + throw error; + } + throw migrationError("MIGRATION_FAILED", failure, { committed: false }); + } finally { + const released = releaseLock({ + lockPath, + lock, + fileOperations, + createId: createLockId + }); + if (!released && completed) { + throw migrationError("MIGRATION_COMMITTED_LOCK_DEGRADED", null, { + committed: true, + degraded: true + }); + } + if (!released && !completed) { + throw migrationError("MIGRATION_ROLLBACK_DEGRADED", failure, { + committed: false, + degraded: true + }); + } + } +} diff --git a/node/src/supervisor/provider-service.mjs b/node/src/supervisor/provider-service.mjs new file mode 100644 index 0000000..e7ae5a4 --- /dev/null +++ b/node/src/supervisor/provider-service.mjs @@ -0,0 +1,1218 @@ +import { randomUUID } from "node:crypto"; + +import { + createProviderSourceFingerprint, + MAX_MODEL_ID_LENGTH, + MAX_PROVIDER_MODELS +} from "../providers/provider-model-cache.mjs"; +import { toPublicProvider } from "../providers/provider-schema.mjs"; +import { CrpError } from "../shared/errors.mjs"; + +const MAX_MODELS_RESPONSE_BYTES = 1_048_576; +const MODEL_ID_CONTROL_PATTERN = /[\u0000-\u001f\u007f-\u009f]/; + +function serviceError(code, { status = 500, cause, details = {} } = {}) { + const contracts = { + PROVIDER_SECRET_INVALID: [ + "The provider credential is invalid.", + "Enter a non-empty provider credential and try again." + ], + PROVIDER_ACTIVE: [ + "The active provider cannot be deleted.", + "Activate another provider or stop the proxy first." + ], + PROVIDER_CREATE_FAILED: [ + "The provider could not be created.", + "Review the provider settings and try again." + ], + PROVIDER_CREATE_ROLLBACK_DEGRADED: [ + "Provider creation failed and its credential could not be removed safely.", + "Stop CRP and repair the credential entry before retrying." + ], + PROVIDER_CREATE_COMMITTED_DEGRADED: [ + "The provider was created, but its persistence cleanup degraded.", + "Stop CRP and repair the residual provider state before restarting." + ], + PROVIDER_UPDATE_ROLLBACK_DEGRADED: [ + "Provider update failed and its prior credential could not be restored safely.", + "Stop CRP and repair the provider credential before retrying." + ], + PROVIDER_UPDATE_COMMITTED_DEGRADED: [ + "The provider update was saved, but its persistence cleanup degraded.", + "Stop CRP and repair the residual provider state before restarting." + ], + PROVIDER_DELETE_FAILED: [ + "The provider could not be deleted.", + "Review Activity and try again." + ], + PROVIDER_DELETE_ROLLBACK_DEGRADED: [ + "Provider deletion failed and its credential could not be restored safely.", + "Stop CRP and repair the provider credential before retrying." + ], + PROVIDER_DELETE_COMMITTED_DEGRADED: [ + "The provider was deleted, but its persistence cleanup degraded.", + "Stop CRP and repair the residual provider state before restarting." + ], + PROVIDER_TEST_INPUT_INVALID: [ + "The provider test model is invalid.", + "Enter a non-empty model name and try again." + ], + PROVIDER_TEST_COMMITTED_DEGRADED: [ + "The provider test result was saved, but persistence cleanup degraded.", + "Stop CRP, inspect Activity and provider-registry persistence, then repair the residual state." + ], + PROVIDER_INITIAL_ACTIVATION_UNSAFE: [ + "The first provider cannot be selected while the proxy Worker is not stopped.", + "Stop the proxy Worker, then test the provider again." + ], + PROVIDER_MODELS_REDIRECT: [ + "The provider model endpoint redirected the request.", + "Use a direct provider base URL and try again." + ], + PROVIDER_MODELS_AUTH: [ + "The provider rejected model discovery credentials.", + "Repair the provider credential and try again." + ], + PROVIDER_MODELS_NOT_FOUND: [ + "The provider does not expose a model endpoint at this base URL.", + "Check the provider base URL or continue with a manually entered model." + ], + PROVIDER_MODELS_HTTP: [ + "The provider model endpoint returned an error.", + "Wait or repair the provider configuration, then try again." + ], + PROVIDER_MODELS_INVALID_JSON: [ + "The provider model endpoint returned invalid JSON.", + "Check provider compatibility and try again." + ], + PROVIDER_MODELS_INVALID_RESPONSE: [ + "The provider model endpoint returned an invalid model list.", + "Use a compatible model endpoint or enter a model manually." + ], + PROVIDER_MODELS_RESPONSE_TOO_LARGE: [ + "The provider model response is too large.", + "Use a provider endpoint with a bounded model catalog." + ], + PROVIDER_MODELS_TIMEOUT: [ + "The provider model request timed out.", + "Check provider connectivity and try again." + ], + PROVIDER_MODELS_DNS: [ + "The provider model host could not be resolved.", + "Check the provider base URL and network, then try again." + ], + PROVIDER_MODELS_TLS: [ + "The provider model endpoint failed TLS verification.", + "Repair the provider certificate or base URL and try again." + ], + PROVIDER_MODELS_NETWORK: [ + "The provider model endpoint could not be reached.", + "Check network connectivity and try again." + ], + PROVIDER_MODELS_COMMITTED_DEGRADED: [ + "The provider model cache was saved, but persistence cleanup degraded.", + "Stop CRP, inspect Activity and model-cache persistence, then repair the residual state." + ], + PROVIDER_NOT_READY: [ + "The provider has not passed its compatibility test.", + "Test the provider successfully before activating it." + ], + PROVIDER_ACTIVATION_FAILED: [ + "The provider could not be activated.", + "Review worker health and try the activation again." + ], + PROVIDER_ACTIVATION_ROLLBACK_DEGRADED: [ + "Provider activation failed and the prior active provider could not be restored safely.", + "Stop CRP and repair the active-provider state before restarting." + ], + PROVIDER_ACTIVATION_COMMITTED_DEGRADED: [ + "The provider was activated, but its persistence cleanup degraded.", + "Stop CRP and repair the residual active-provider state before restarting." + ], + PROXY_NOT_CONFIGURED: [ + "No active provider is configured for the proxy.", + "Test and activate a provider before starting the proxy." + ], + PROXY_START_FAILED: [ + "The proxy worker could not be started.", + "Review worker health and try again." + ], + PROXY_STOP_FAILED: [ + "The proxy worker could not be stopped.", + "Review worker health and try again." + ], + PROXY_RESTART_FAILED: [ + "The proxy worker could not be restarted.", + "Review worker health and try again." + ] + }; + const [message, action] = contracts[code] ?? [ + "The provider operation failed.", + "Review Activity and try again." + ]; + return new CrpError(code, message, action, { status, cause, details }); +} + +function assertSecret(secret) { + if (typeof secret !== "string" || secret.length === 0) { + throw serviceError("PROVIDER_SECRET_INVALID", { status: 400 }); + } +} + +function isCommittedError(error) { + return error instanceof CrpError && error.details?.committed === true; +} + +function committedServiceError(action, cause) { + const error = serviceError(`PROVIDER_${action.toUpperCase()}_COMMITTED_DEGRADED`, { + cause, + details: { committed: true, degraded: true } + }); + if (cause instanceof CrpError + && typeof cause.action === "string" + && cause.action.length > 0 && cause.action.length <= 512 + && !/[\u0000-\u001f\u007f-\u009f\u061c\u200b-\u200f\u2028-\u202e\u2066-\u2069\ufeff]/u.test(cause.action)) { + error.action = cause.action; + } + return error; +} + +export class ProviderService { + #operationTail = Promise.resolve(); + #lifecycleOperation = null; + + constructor({ + registry, + credentialStore, + activityStore, + workerManager, + modelCache, + createCredentialRef = randomUUID, + now = () => new Date().toISOString(), + ...options + }) { + if (!registry || !credentialStore || !activityStore || !workerManager || !modelCache) { + throw new TypeError("ProviderService dependencies are required."); + } + this.registry = registry; + this.credentialStore = credentialStore; + this.activityStore = activityStore; + this.workerManager = workerManager; + this.modelCache = modelCache; + this.createCredentialRef = createCredentialRef; + this.now = now; + this.options = options; + this.fetchImpl = options.fetchImpl ?? globalThis.fetch; + this.createTimeoutSignal = options.createTimeoutSignal + ?? ((timeoutMs) => AbortSignal.timeout(timeoutMs)); + this.testTimeoutMs = options.testTimeoutMs ?? 15_000; + this.modelsResponseMaxBytes = options.modelsResponseMaxBytes ?? MAX_MODELS_RESPONSE_BYTES; + this.verifyWorkerHealth = options.verifyWorkerHealth ?? (async (generation, state) => ( + state?.phase === "running" && state?.generation === generation + )); + this.paths = options.paths ?? {}; + const workerGeneration = workerManager.getPublicState()?.generation; + this.confirmedGeneration = Number.isSafeInteger(workerGeneration) && workerGeneration >= 0 + ? workerGeneration + : 0; + this.confirmedSnapshot = options.initialSnapshot + ? structuredClone(options.initialSnapshot) + : null; + } + + async listProviders() { + const profiles = this.registry.list(); + return await Promise.all(profiles.map((profile) => this.#toPublic(profile))); + } + + createProvider(input, secret) { + return this.#runExclusive(async () => { + let credentialRef = null; + let credentialWritten = false; + let credentialCommitWarning = null; + let profile; + try { + assertSecret(secret); + credentialRef = this.createCredentialRef(); + try { + await this.credentialStore.set(credentialRef, secret); + credentialWritten = true; + } catch (error) { + if (isCommittedError(error)) { + credentialWritten = true; + credentialCommitWarning = error; + } else { + throw error; + } + } + profile = this.registry.create({ ...input, credentialRef }); + } catch (error) { + if (isCommittedError(error)) { + profile = this.registry.list().find((candidate) => ( + candidate.credentialRef === credentialRef + )); + if (profile) { + const committed = committedServiceError("create", error); + await this.#recordCommitted("create", profile.id, committed); + throw committed; + } + const degraded = serviceError("PROVIDER_CREATE_ROLLBACK_DEGRADED", { + cause: error, + details: { committed: false, degraded: true } + }); + await this.#safeRecordFailure("create", null, degraded); + throw degraded; + } + let failure = error; + if (credentialWritten) { + try { + await this.credentialStore.delete(credentialRef); + } catch (rollbackError) { + failure = serviceError("PROVIDER_CREATE_ROLLBACK_DEGRADED", { + cause: rollbackError, + details: { committed: false, degraded: true } + }); + } + } + if (!(failure instanceof CrpError)) { + failure = serviceError("PROVIDER_CREATE_FAILED", { cause: failure }); + } + await this.#safeRecordFailure("create", null, failure); + throw failure; + } + if (credentialCommitWarning) { + const committed = committedServiceError("create", credentialCommitWarning); + await this.#recordCommitted("create", profile.id, committed); + throw committed; + } + const publicProfile = toPublicProvider(profile, true); + await this.#record("create", profile.id, "success", null, {}); + return publicProfile; + }); + } + + updateProvider(id, patch, replacementSecret) { + return this.#runExclusive(async () => { + let profile; + let safeId = null; + try { + const current = this.registry.get(id); + safeId = current.id; + if (this.registry.getDocument().activeProviderId === current.id) { + throw serviceError("PROVIDER_ACTIVE", { status: 409 }); + } + if (replacementSecret !== undefined) assertSecret(replacementSecret); + profile = replacementSecret === undefined + ? this.registry.update(id, patch) + : await this.#updateWithReplacementSecret(current, patch, replacementSecret); + if (replacementSecret !== undefined) { + try { + this.modelCache.delete(current.id); + } catch (error) { + throw committedServiceError("update", error); + } + } + } catch (error) { + if (isCommittedError(error)) { + const committed = committedServiceError("update", error); + await this.#recordCommitted("update", safeId, committed); + throw committed; + } + await this.#safeRecordFailure("update", safeId, error); + throw error; + } + const publicProfile = await this.#toPublic(profile); + await this.#record( + "update", + profile.id, + "success", + null, + replacementSecret === undefined ? {} : { credentialReplaced: true } + ); + return publicProfile; + }); + } + + deleteProvider(id) { + return this.#runExclusive(async () => { + let profile; + let oldSecret; + let deleted; + let credentialDeleted = false; + let credentialCommitWarning = null; + try { + const document = this.registry.getDocument(); + if (document.activeProviderId === id) { + throw serviceError("PROVIDER_ACTIVE", { status: 409 }); + } + profile = this.registry.get(id); + oldSecret = await this.credentialStore.get(profile.credentialRef); + try { + credentialDeleted = await this.credentialStore.delete(profile.credentialRef); + } catch (error) { + if (isCommittedError(error)) { + credentialDeleted = true; + credentialCommitWarning = error; + } else { + throw error; + } + } + if (!credentialDeleted) throw serviceError("PROVIDER_DELETE_FAILED"); + deleted = this.registry.delete(id); + try { + this.modelCache.delete(id); + } catch (error) { + throw committedServiceError("delete", error); + } + } catch (error) { + if (isCommittedError(error)) { + const committed = committedServiceError("delete", error); + await this.#recordCommitted("delete", profile?.id ?? id, committed); + throw committed; + } + if (credentialCommitWarning) { + const degraded = serviceError("PROVIDER_DELETE_ROLLBACK_DEGRADED", { + cause: error, + details: { committed: false, degraded: true } + }); + await this.#safeRecordFailure("delete", profile?.id ?? id, degraded); + throw degraded; + } + let failure = error; + if (credentialDeleted) { + try { + await this.credentialStore.set(profile.credentialRef, oldSecret); + } catch (rollbackError) { + failure = serviceError("PROVIDER_DELETE_ROLLBACK_DEGRADED", { + cause: rollbackError, + details: { committed: false, degraded: true } + }); + } + } + if (!(failure instanceof CrpError)) { + failure = serviceError("PROVIDER_DELETE_FAILED", { cause: failure }); + } + await this.#safeRecordFailure("delete", profile?.id ?? null, failure); + throw failure; + } + if (credentialCommitWarning) { + const committed = committedServiceError("delete", credentialCommitWarning); + await this.#recordCommitted("delete", deleted.id, committed); + throw committed; + } + await this.#record("delete", deleted.id, "success", null, {}); + return toPublicProvider(deleted, false); + }); + } + + getProviderModels(id) { + const profile = this.registry.get(id); + return this.modelCache.get(id, createProviderSourceFingerprint(profile)); + } + + refreshProviderModels(id) { + return this.#runExclusive(async () => { + const profile = this.registry.get(id); + const secret = await this.credentialStore.get(profile.credentialRef); + let status = null; + let cacheCommitted = false; + try { + const response = await this.fetchImpl(buildProviderEndpointUrl(profile.baseUrl, "models"), { + method: "GET", + redirect: "manual", + headers: providerRequestHeaders(profile, secret), + signal: this.createTimeoutSignal(this.testTimeoutMs) + }); + status = Number.isInteger(response?.status) ? response.status : null; + if (!response?.ok) { + throw serviceError(classifyModelsHttpStatus(status), { + status: 502, + details: status === null ? {} : { httpStatus: status } + }); + } + const payload = await readBoundedJson(response, this.modelsResponseMaxBytes); + const models = normalizeModelCatalog(payload, secret); + let catalog; + try { + catalog = this.modelCache.put({ + providerId: id, + sourceFingerprint: createProviderSourceFingerprint(profile), + fetchedAt: this.now(), + models + }); + cacheCommitted = true; + } catch (error) { + if (!isCommittedError(error)) throw error; + cacheCommitted = true; + throw committedServiceError("models", error); + } + try { + await this.#record("models", id, "success", null, { + modelCount: models.length, + ...(status === null ? {} : { httpStatus: status }) + }); + } catch (error) { + throw committedServiceError("models", error); + } + return catalog; + } catch (error) { + if (cacheCommitted) { + throw error instanceof CrpError + && error.code === "PROVIDER_MODELS_COMMITTED_DEGRADED" + ? error + : committedServiceError("models", error); + } + const failure = normalizeModelsError(error); + await this.#safeRecordFailure( + "models", + id, + failure, + status === null ? {} : { httpStatus: status } + ); + throw failure; + } + }); + } + + testProvider(id, model, { activateIfNone = false } = {}) { + return this.#runExclusive(async () => { + if (typeof model !== "string" || model.trim().length === 0) { + throw serviceError("PROVIDER_TEST_INPUT_INVALID", { status: 400 }); + } + const profile = this.registry.get(id); + if (typeof activateIfNone !== "boolean") { + throw serviceError("PROVIDER_TEST_INPUT_INVALID", { status: 400 }); + } + const activeProviderId = this.registry.getDocument().activeProviderId; + if (activateIfNone && activeProviderId === null + && this.workerManager.getPublicState()?.phase !== "stopped") { + throw serviceError("PROVIDER_INITIAL_ACTIVATION_UNSAFE", { status: 409 }); + } + const secret = await this.credentialStore.get(profile.credentialRef); + let result; + let status = null; + try { + const response = await this.fetchImpl(buildProviderEndpointUrl(profile.baseUrl, "responses"), { + method: "POST", + redirect: "manual", + headers: { + "content-type": "application/json", + ...providerRequestHeaders(profile, secret) + }, + body: JSON.stringify({ + model: model.trim(), + stream: false, + input: "Reply with OK." + }), + signal: this.createTimeoutSignal(this.testTimeoutMs) + }); + status = Number.isInteger(response?.status) ? response.status : null; + if (!response?.ok) { + result = { ok: false, code: classifyHttpStatus(status) }; + } else { + let payload; + try { + payload = await response.json(); + } catch { + result = { ok: false, code: "PROVIDER_TEST_INVALID_JSON" }; + } + if (!result) { + result = isCompatibleResponsesPayload(payload) + ? { ok: true, code: null } + : { ok: false, code: "PROVIDER_TEST_INVALID_RESPONSES" }; + } + } + } catch (error) { + result = { ok: false, code: classifyFetchError(error) }; + } + + try { + this.registry.markTest(id, result.ok + ? { status: "passed" } + : { status: "failed", code: result.code }); + } catch (error) { + if (!isCommittedError(error)) throw error; + throw committedServiceError("test", error); + } + try { + await this.#record( + "test", + id, + result.ok ? "success" : "failed", + result.code, + status === null ? {} : { httpStatus: status } + ); + } catch (error) { + throw committedServiceError("test", error); + } + let initialActivation = null; + if (result.ok && activateIfNone) { + initialActivation = await this.#selectInitialProvider(id); + } + return { ...result, initialActivation }; + }); + } + + activate(id) { + return this.#runExclusive(async () => { + const profile = this.registry.get(id); + if (profile.lastTestStatus !== "passed") { + await this.#record( + "activate", + id, + "failed", + "PROVIDER_NOT_READY", + {} + ); + throw serviceError("PROVIDER_NOT_READY", { status: 409 }); + } + const secret = await this.credentialStore.get(profile.credentialRef); + const previousId = this.registry.getDocument().activeProviderId; + const generation = this.confirmedGeneration + 1; + if (!Number.isSafeInteger(generation) || generation < 1) { + throw serviceError("PROVIDER_ACTIVATION_FAILED"); + } + const snapshot = this.#buildSnapshot(profile, secret, generation); + let activePersisted = false; + let workerAttempted = false; + let activeCommitWarning = null; + let activationCompleted = false; + try { + try { + this.registry.setActive(id); + activePersisted = true; + } catch (error) { + if (isCommittedError(error) + && this.registry.getDocument().activeProviderId === id) { + activePersisted = true; + activeCommitWarning = error; + } else { + throw error; + } + } + const before = this.workerManager.getPublicState(); + workerAttempted = true; + const workerState = before.phase === "running" + ? await this.workerManager.applySnapshot(snapshot) + : await this.workerManager.start(snapshot); + if (!isConfirmedWorkerState(workerState, generation)) { + throw new Error("worker generation was not confirmed"); + } + const healthy = await this.verifyWorkerHealth(generation, workerState); + if (healthy !== true) throw new Error("worker health was not confirmed"); + this.confirmedGeneration = generation; + this.confirmedSnapshot = structuredClone(snapshot); + if (activeCommitWarning) { + const committed = committedServiceError("activation", activeCommitWarning); + await this.#recordCommitted("activate", id, committed, { generation }); + activationCompleted = true; + throw committed; + } + await this.#record("activate", id, "success", null, { generation }); + return { + activeProviderId: id, + activeProvider: toPublicProvider(profile, true), + generation, + worker: publicWorkerState(workerState) + }; + } catch (error) { + if (activationCompleted && isCommittedError(error)) throw error; + let rollbackFailure = null; + if (activePersisted) { + try { + this.registry.setActive(previousId); + } catch (rollbackError) { + rollbackFailure = rollbackError; + } + } + if (workerAttempted) { + try { + if (this.confirmedSnapshot) { + const observedGeneration = this.workerManager.getPublicState()?.generation; + const rollbackGeneration = Math.max( + generation, + this.confirmedGeneration, + Number.isSafeInteger(observedGeneration) ? observedGeneration : 0 + ) + 1; + if (!Number.isSafeInteger(rollbackGeneration)) { + throw new Error("worker rollback generation is invalid"); + } + const rollbackSnapshot = structuredClone(this.confirmedSnapshot); + rollbackSnapshot.generation = rollbackGeneration; + const rollbackState = this.workerManager.getPublicState(); + const restored = rollbackState?.phase === "running" + ? await this.workerManager.applySnapshot(rollbackSnapshot) + : await this.workerManager.restart(rollbackSnapshot); + if (!isConfirmedWorkerState(restored, rollbackGeneration)) { + throw new Error("worker rollback was not confirmed"); + } + const rollbackHealthy = await this.verifyWorkerHealth( + rollbackGeneration, + restored + ); + if (rollbackHealthy !== true) { + throw new Error("worker rollback health was not confirmed"); + } + this.confirmedGeneration = rollbackGeneration; + this.confirmedSnapshot = structuredClone(rollbackSnapshot); + } else { + const stopped = await this.workerManager.stop(); + if (stopped?.phase !== "stopped") { + throw new Error("unconfirmed worker did not stop"); + } + } + } catch (workerRollbackError) { + rollbackFailure ??= workerRollbackError; + } + } + if (rollbackFailure) { + const degraded = serviceError("PROVIDER_ACTIVATION_ROLLBACK_DEGRADED", { + cause: rollbackFailure, + details: { committed: false, degraded: true } + }); + await this.#safeRecordFailure("activate", id, degraded, { generation }); + throw degraded; + } + await this.#safeRecordFailure("activate", id, error, { generation }); + throw serviceError("PROVIDER_ACTIVATION_FAILED", { cause: error }); + } + }); + } + + startProxy() { + return this.#runLifecycleOperation(async () => { + const current = this.workerManager.getPublicState(); + if (current?.phase === "running") { + const worker = publicWorkerState(current); + await this.#recordProxy("start", "success", null, { alreadyRunning: true }); + return worker; + } + return await this.#startOrRestartProxy("start"); + }); + } + + stopProxy() { + return this.#runLifecycleOperation(async () => { + try { + const state = await this.workerManager.stop(); + const worker = publicWorkerState(state); + await this.#recordProxy("stop", "success", null, {}); + return worker; + } catch (error) { + const failure = serviceError("PROXY_STOP_FAILED", { cause: error }); + await this.#safeRecordProxyFailure("stop", failure); + throw failure; + } + }); + } + + restartProxy() { + return this.#runLifecycleOperation(() => this.#startOrRestartProxy("restart")); + } + + async getStatus() { + const document = this.registry.getDocument(); + const activeProfile = document.activeProviderId === null + ? null + : document.providers.find((profile) => profile.id === document.activeProviderId) ?? null; + return { + activeProviderId: document.activeProviderId, + activeProvider: activeProfile === null ? null : await this.#toPublic(activeProfile), + generation: this.confirmedGeneration, + worker: publicWorkerState(this.workerManager.getPublicState()) + }; + } + + async #selectInitialProvider(id) { + let selected; + try { + selected = this.registry.setActiveIfNull(id); + } catch (error) { + if (isCommittedError(error) + && this.registry.getDocument().activeProviderId === id) { + const committed = committedServiceError("activation", error); + await this.#recordCommitted("activate", id, committed, { + automatic: true, + workerStarted: false + }); + throw committed; + } + const failure = serviceError("PROVIDER_ACTIVATION_FAILED", { cause: error }); + await this.#safeRecordFailure("activate", id, failure, { + automatic: true, + workerStarted: false + }); + throw failure; + } + if (!selected) return null; + + try { + await this.#record("activate", id, "success", null, { + automatic: true, + workerStarted: false + }); + } catch (error) { + let rolledBack = false; + let rollbackError = null; + try { + rolledBack = this.registry.clearActiveIf(id); + } catch (caught) { + rollbackError = caught; + } + if (!rolledBack || rollbackError) { + const degraded = serviceError("PROVIDER_ACTIVATION_ROLLBACK_DEGRADED", { + cause: rollbackError ?? error, + details: { committed: false, degraded: true } + }); + await this.#safeRecordFailure("activate", id, degraded, { + automatic: true, + workerStarted: false + }); + throw degraded; + } + const failure = serviceError("PROVIDER_ACTIVATION_FAILED", { cause: error }); + await this.#safeRecordFailure("activate", id, failure, { + automatic: true, + workerStarted: false + }); + throw failure; + } + return { + automatic: true, + activeProviderId: id, + workerStarted: false + }; + } + + async #updateWithReplacementSecret(current, patch, replacementSecret) { + const oldSecret = await this.credentialStore.get(current.credentialRef); + let replacementWritten = false; + let replacementCommitWarning = null; + let testReset = false; + try { + try { + await this.credentialStore.set(current.credentialRef, replacementSecret); + replacementWritten = true; + } catch (error) { + if (isCommittedError(error)) { + replacementWritten = true; + replacementCommitWarning = error; + } else { + throw error; + } + } + this.registry.markTest(current.id, { status: "untested" }); + testReset = true; + this.registry.update(current.id, patch); + if (replacementCommitWarning) throw replacementCommitWarning; + return this.registry.get(current.id); + } catch (error) { + if (isCommittedError(error)) throw error; + let rollbackFailure = null; + let credentialRestored = false; + if (replacementWritten) { + try { + await this.credentialStore.set(current.credentialRef, oldSecret); + credentialRestored = true; + } catch (rollbackError) { + rollbackFailure = rollbackError; + } + } + if (testReset && credentialRestored) { + try { + this.registry.markTest(current.id, { + status: current.lastTestStatus, + code: current.lastTestCode + }); + } catch (rollbackError) { + rollbackFailure ??= rollbackError; + } + } + if (rollbackFailure) { + throw serviceError("PROVIDER_UPDATE_ROLLBACK_DEGRADED", { + cause: rollbackFailure, + details: { committed: false, degraded: true } + }); + } + throw error; + } + } + + async #toPublic(profile) { + const configured = await this.credentialStore.has(profile.credentialRef); + return toPublicProvider(profile, configured); + } + + async #startOrRestartProxy(action) { + let workerAttempted = false; + let generation = null; + try { + const document = this.registry.getDocument(); + if (document.activeProviderId === null) { + throw serviceError("PROXY_NOT_CONFIGURED", { status: 409 }); + } + const profile = document.providers.find(({ id }) => id === document.activeProviderId); + if (!profile || profile.lastTestStatus !== "passed") { + throw serviceError("PROXY_NOT_CONFIGURED", { status: 409 }); + } + const secret = await this.credentialStore.get(profile.credentialRef); + const observedGeneration = this.workerManager.getPublicState()?.generation; + generation = Math.max( + this.confirmedGeneration, + Number.isSafeInteger(observedGeneration) ? observedGeneration : 0 + ) + 1; + if (!Number.isSafeInteger(generation)) throw new Error("proxy generation is invalid"); + const snapshot = this.#buildSnapshot(profile, secret, generation); + workerAttempted = true; + const state = action === "restart" + ? await this.workerManager.restart(snapshot) + : await this.workerManager.start(snapshot); + if (!isConfirmedWorkerState(state, generation)) { + throw new Error("worker generation was not confirmed"); + } + if (await this.verifyWorkerHealth(generation, state) !== true) { + throw new Error("worker health was not confirmed"); + } + this.confirmedGeneration = generation; + this.confirmedSnapshot = structuredClone(snapshot); + const worker = publicWorkerState(state); + await this.#recordProxy(action, "success", null, { generation }); + return worker; + } catch (error) { + if (error instanceof CrpError && error.code === "PROXY_NOT_CONFIGURED") { + await this.#safeRecordProxyFailure(action, error); + throw error; + } + let cleanupFailure = null; + if (workerAttempted) { + try { + await this.workerManager.stop(); + } catch (caught) { + cleanupFailure = caught; + } + } + const code = action === "restart" ? "PROXY_RESTART_FAILED" : "PROXY_START_FAILED"; + const failure = serviceError(code, { + cause: cleanupFailure ?? error, + details: cleanupFailure ? { degraded: true } : {} + }); + await this.#safeRecordProxyFailure(action, failure, generation === null ? {} : { generation }); + throw failure; + } + } + + #buildSnapshot(profile, secret, generation) { + const document = this.registry.getDocument(); + const runtimeConfigPath = this.paths.runtimeConfigPath; + const capturePath = this.paths.capturePath; + if (typeof runtimeConfigPath !== "string" || runtimeConfigPath.length === 0 + || typeof capturePath !== "string" || capturePath.length === 0) { + throw serviceError("PROVIDER_ACTIVATION_FAILED"); + } + return { + providerId: profile.id, + generation, + settings: { + configPath: runtimeConfigPath, + server: { + host: document.settings.proxyHost, + port: document.settings.proxyPort, + logLevel: "info" + }, + upstream: { + baseUrl: profile.baseUrl, + apiKey: secret, + timeoutMs: 300_000, + verifySsl: true, + authHeader: profile.authHeader, + authScheme: profile.authScheme, + extraHeaders: { ...profile.extraHeaders } + }, + proxy: { + overrideAuthorization: true, + requestIdHeader: "x-client-request-id" + }, + capture: { + enabled: document.settings.captureEnabled, + dbPath: capturePath + } + } + }; + } + + async #record(action, providerId, result, errorCode, details) { + await this.activityStore.append({ + category: "provider", + action, + providerId, + result, + errorCode, + details + }); + } + + async #recordProxy(action, result, errorCode, details) { + await this.activityStore.append({ + category: "proxy", + action, + providerId: null, + result, + errorCode, + details + }); + } + + async #safeRecordProxyFailure(action, error, details = {}) { + try { + await this.#recordProxy( + action, + "failed", + stableErrorCode(error, `PROXY_${action.toUpperCase()}_FAILED`), + details + ); + } catch { + // Preserve the lifecycle error when the audit store is unavailable. + } + } + + async #safeRecordFailure(action, providerId, error, details = {}) { + const errorCode = stableErrorCode(error, `PROVIDER_${action.toUpperCase()}_FAILED`); + try { + await this.#record(action, providerId, "failed", errorCode, details); + } catch { + // Preserve the operation error when the audit store is also unavailable. + } + } + + async #recordCommitted(action, providerId, error, details = {}) { + try { + await this.#record(action, providerId, "degraded", error.code, details); + } catch { + // The committed primary error remains authoritative when Activity is unavailable. + } + } + + #runLifecycleOperation(operation) { + if (this.#lifecycleOperation) return this.#lifecycleOperation; + const run = this.#runExclusive(operation); + this.#lifecycleOperation = run; + const clear = () => { + if (this.#lifecycleOperation === run) this.#lifecycleOperation = null; + }; + void run.then(clear, clear); + return run; + } + + #runExclusive(operation) { + const run = this.#operationTail.then(operation, operation); + this.#operationTail = run.catch(() => {}); + return run; + } +} + +function buildProviderEndpointUrl(baseUrl, endpoint) { + const target = new URL(baseUrl); + const basePath = target.pathname.replace(/\/+$/, ""); + target.pathname = `${basePath}/${endpoint}`; + target.hash = ""; + return target.toString(); +} + +function providerRequestHeaders(profile, secret) { + return { + ...profile.extraHeaders, + [profile.authHeader]: profile.authScheme + ? `${profile.authScheme} ${secret}` + : secret + }; +} + +async function readBoundedJson(response, maximumBytes) { + let bytes; + const declaredLength = Number(response?.headers?.get?.("content-length")); + if (Number.isFinite(declaredLength) && declaredLength > maximumBytes) { + throw serviceError("PROVIDER_MODELS_RESPONSE_TOO_LARGE", { status: 502 }); + } + + if (response?.body?.getReader) { + const reader = response.body.getReader(); + const chunks = []; + let length = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + length += value.byteLength; + if (length > maximumBytes) { + await reader.cancel().catch(() => {}); + throw serviceError("PROVIDER_MODELS_RESPONSE_TOO_LARGE", { status: 502 }); + } + chunks.push(Buffer.from(value)); + } + } finally { + reader.releaseLock?.(); + } + bytes = Buffer.concat(chunks, length).toString("utf8"); + } else if (typeof response?.text === "function") { + bytes = await response.text(); + if (Buffer.byteLength(bytes, "utf8") > maximumBytes) { + throw serviceError("PROVIDER_MODELS_RESPONSE_TOO_LARGE", { status: 502 }); + } + } else if (typeof response?.json === "function") { + const payload = await response.json(); + const serialized = JSON.stringify(payload); + if (typeof serialized !== "string") { + throw serviceError("PROVIDER_MODELS_INVALID_JSON", { status: 502 }); + } + if (Buffer.byteLength(serialized, "utf8") > maximumBytes) { + throw serviceError("PROVIDER_MODELS_RESPONSE_TOO_LARGE", { status: 502 }); + } + return payload; + } else { + throw serviceError("PROVIDER_MODELS_INVALID_JSON", { status: 502 }); + } + + try { + return JSON.parse(bytes); + } catch (error) { + throw serviceError("PROVIDER_MODELS_INVALID_JSON", { status: 502, cause: error }); + } +} + +function normalizeModelCatalog(payload, secret) { + if (payload === null || typeof payload !== "object" || Array.isArray(payload) + || !Array.isArray(payload.data) + || (payload.object !== undefined && payload.object !== "list") + || payload.data.length > MAX_PROVIDER_MODELS) { + throw serviceError("PROVIDER_MODELS_INVALID_RESPONSE", { status: 502 }); + } + const seen = new Set(); + const models = []; + for (const entry of payload.data) { + const id = entry !== null && typeof entry === "object" && !Array.isArray(entry) + ? entry.id + : null; + if (typeof id !== "string" || id.length === 0 || id.trim() !== id + || id.length > MAX_MODEL_ID_LENGTH * 2 + || [...id].length > MAX_MODEL_ID_LENGTH + || MODEL_ID_CONTROL_PATTERN.test(id) + || (typeof secret === "string" && secret.length > 0 && id.includes(secret))) { + throw serviceError("PROVIDER_MODELS_INVALID_RESPONSE", { status: 502 }); + } + if (!seen.has(id)) { + seen.add(id); + models.push(id); + } + } + return models; +} + +function classifyModelsHttpStatus(status) { + if (status >= 300 && status <= 399) return "PROVIDER_MODELS_REDIRECT"; + if (status === 401 || status === 403) return "PROVIDER_MODELS_AUTH"; + if (status === 404) return "PROVIDER_MODELS_NOT_FOUND"; + return "PROVIDER_MODELS_HTTP"; +} + +function normalizeModelsError(error) { + if (error instanceof CrpError && /^PROVIDER_(?:MODELS|MODEL_CACHE)_/.test(error.code)) { + return error; + } + return serviceError(classifyFetchError(error, "PROVIDER_MODELS"), { + status: 502, + cause: error + }); +} + +function isCompatibleResponsesPayload(payload) { + return payload !== null + && typeof payload === "object" + && !Array.isArray(payload) + && typeof payload.id === "string" + && payload.id.length > 0 + && payload.object === "response" + && Array.isArray(payload.output); +} + +function classifyHttpStatus(status) { + if (status >= 300 && status <= 399) return "PROVIDER_TEST_REDIRECT"; + if (status === 401 || status === 403) return "PROVIDER_TEST_AUTH"; + if (status === 404) return "PROVIDER_TEST_NOT_FOUND"; + return "PROVIDER_TEST_HTTP"; +} + +function collectErrorCodes(error) { + const codes = []; + const seen = new Set(); + let current = error; + while (current && typeof current === "object" && !seen.has(current)) { + seen.add(current); + if (typeof current.code === "string") codes.push(current.code.toUpperCase()); + current = current.cause; + } + return codes; +} + +function classifyFetchError(error, prefix = "PROVIDER_TEST") { + const codes = collectErrorCodes(error); + if (error?.name === "AbortError" || error?.name === "TimeoutError" + || codes.includes("ETIMEDOUT") || codes.includes("UND_ERR_CONNECT_TIMEOUT")) { + return `${prefix}_TIMEOUT`; + } + if (codes.includes("ENOTFOUND") || codes.includes("EAI_AGAIN")) { + return `${prefix}_DNS`; + } + if (codes.some((code) => code.startsWith("CERT_") + || code.startsWith("ERR_TLS_") + || code === "DEPTH_ZERO_SELF_SIGNED_CERT" + || code === "UNABLE_TO_VERIFY_LEAF_SIGNATURE")) { + return `${prefix}_TLS`; + } + return `${prefix}_NETWORK`; +} + +function stableErrorCode(error, fallback) { + return typeof error?.code === "string" && /^[A-Z][A-Z0-9_]*$/.test(error.code) + ? error.code + : fallback; +} + +function isConfirmedWorkerState(state, generation) { + return state !== null + && typeof state === "object" + && state.phase === "running" + && state.generation === generation + && state.state !== null + && typeof state.state === "object" + && state.state.phase === "running" + && state.state.configured === true + && state.state.generation === generation + && state.state.listening === true; +} + +function publicWorkerState(state) { + if (state === null || typeof state !== "object") return null; + const nested = state.state === null || typeof state.state !== "object" + ? null + : { + phase: state.state.phase, + configured: state.state.configured, + generation: state.state.generation, + listening: state.state.listening, + listenHost: state.state.listenHost, + listenPort: state.state.listenPort, + inFlight: state.state.inFlight + }; + const error = state.error === null || typeof state.error !== "object" + ? null + : { code: state.error.code, message: state.error.message }; + return { + phase: state.phase, + pid: state.pid, + generation: state.generation, + state: nested, + restartCount: state.restartCount, + startedAt: state.startedAt, + error + }; +} diff --git a/node/src/supervisor/session-auth.mjs b/node/src/supervisor/session-auth.mjs new file mode 100644 index 0000000..7f54da4 --- /dev/null +++ b/node/src/supervisor/session-auth.mjs @@ -0,0 +1,306 @@ +import { randomBytes as cryptoRandomBytes, timingSafeEqual } from "node:crypto"; +import { + chmodSync, + closeSync, + constants, + fchmodSync, + fstatSync, + fsyncSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + writeFileSync +} from "node:fs"; +import { dirname } from "node:path"; + +import { CrpError } from "../shared/errors.mjs"; + +export const SESSION_COOKIE_NAME = "crp_session"; + +const TOKEN_BYTES = 32; +const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/; +const DEFAULT_SESSION_TTL_MS = 8 * 60 * 60 * 1_000; +const DEFAULT_FILE_OPERATIONS = { + chmodSync, + closeSync, + fchmodSync, + fstatSync, + fsyncSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + writeFileSync +}; + +function authError(code, { clearCookie = false, cause } = {}) { + const contracts = { + AUTH_CONTROL_TOKEN_INVALID: [ + "The local control token is invalid.", + "Stop CRP, repair the private control token file, and restart CRP.", + 500 + ], + AUTH_REQUIRED: [ + "Local authentication is required.", + "Open the local CRP UI again or provide the local control token.", + 401 + ], + AUTH_SESSION_EXPIRED: [ + "The local session has expired.", + "Open the local CRP UI again to create a new session.", + 401 + ], + AUTH_CSRF_INVALID: [ + "The request could not be verified.", + "Refresh the local CRP UI and try again.", + 403 + ] + }; + const [message, action, status] = contracts[code] ?? contracts.AUTH_REQUIRED; + const error = new CrpError(code, message, action, { status, cause }); + if (clearCookie) { + Object.defineProperty(error, "clearCookie", { value: true, enumerable: false }); + } + return error; +} + +function sameIdentity(left, right) { + return left.dev === right.dev && left.ino === right.ino; +} + +function isPrivateMode(stats, platform) { + return platform === "win32" || (stats.mode & 0o777) === 0o600; +} + +function canonicalToken(bytes) { + if (!Buffer.isBuffer(bytes) && !(bytes instanceof Uint8Array)) { + throw new TypeError("randomBytes must return bytes"); + } + const buffer = Buffer.from(bytes); + if (buffer.length !== TOKEN_BYTES) throw new TypeError("token byte count is invalid"); + return buffer.toString("base64url"); +} + +function parseStoredToken(text) { + if (typeof text !== "string") throw new TypeError("control token bytes are invalid"); + const token = text.endsWith("\n") ? text.slice(0, -1) : text; + if (!TOKEN_PATTERN.test(token) || text !== token && text !== `${token}\n`) { + throw new TypeError("control token format is invalid"); + } + const bytes = Buffer.from(token, "base64url"); + if (bytes.length !== TOKEN_BYTES || bytes.toString("base64url") !== token) { + throw new TypeError("control token encoding is invalid"); + } + return token; +} + +function secureEqual(left, right) { + if (typeof left !== "string" || typeof right !== "string") return false; + const leftBytes = Buffer.from(left, "utf8"); + const rightBytes = Buffer.from(right, "utf8"); + return leftBytes.length === rightBytes.length && timingSafeEqual(leftBytes, rightBytes); +} + +function ensurePrivateParent(path, fileOperations, platform) { + const parent = dirname(path); + fileOperations.mkdirSync(parent, { recursive: true, mode: 0o700 }); + let stats = fileOperations.lstatSync(parent); + if (!stats.isDirectory() || stats.isSymbolicLink()) { + throw new TypeError("control token parent is unsafe"); + } + if (platform !== "win32") { + fileOperations.chmodSync(parent, 0o700); + stats = fileOperations.lstatSync(parent); + if ((stats.mode & 0o777) !== 0o700) throw new TypeError("control token parent is not private"); + } +} + +function readExistingToken(path, fileOperations, platform) { + const before = fileOperations.lstatSync(path); + if (!before.isFile() || before.isSymbolicLink() || !isPrivateMode(before, platform)) { + throw new TypeError("control token path is unsafe"); + } + if (before.size < 43 || before.size > 44) throw new TypeError("control token size is invalid"); + + let descriptor; + try { + const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0; + descriptor = fileOperations.openSync(path, constants.O_RDONLY | noFollow); + const opened = fileOperations.fstatSync(descriptor); + if (!opened.isFile() || !sameIdentity(before, opened) || !isPrivateMode(opened, platform)) { + throw new TypeError("control token identity changed"); + } + const text = fileOperations.readFileSync(descriptor, "utf8"); + const after = fileOperations.lstatSync(path); + if (!after.isFile() || after.isSymbolicLink() || !sameIdentity(opened, after)) { + throw new TypeError("control token identity changed"); + } + return parseStoredToken(text); + } finally { + if (descriptor !== undefined) fileOperations.closeSync(descriptor); + } +} + +function createTokenFile(path, token, fileOperations) { + let descriptor; + try { + descriptor = fileOperations.openSync(path, "wx", 0o600); + fileOperations.writeFileSync(descriptor, `${token}\n`, "utf8"); + fileOperations.fsyncSync(descriptor); + fileOperations.fchmodSync(descriptor, 0o600); + } finally { + if (descriptor !== undefined) fileOperations.closeSync(descriptor); + } +} + +function loadOrCreateControlToken({ + path, + randomBytes, + fileOperations, + platform +}) { + try { + ensurePrivateParent(path, fileOperations, platform); + try { + return readExistingToken(path, fileOperations, platform); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + const token = canonicalToken(randomBytes(TOKEN_BYTES)); + try { + createTokenFile(path, token, fileOperations); + return token; + } catch (error) { + if (error?.code !== "EEXIST") throw error; + return readExistingToken(path, fileOperations, platform); + } + } catch (error) { + if (error instanceof CrpError) throw error; + throw authError("AUTH_CONTROL_TOKEN_INVALID", { cause: error }); + } +} + +function bearerToken(authorization) { + if (typeof authorization !== "string") return null; + const match = /^Bearer ([A-Za-z0-9_-]+)$/.exec(authorization); + return match?.[1] ?? null; +} + +function sessionCookie(cookie) { + if (typeof cookie !== "string") return null; + const matches = []; + for (const part of cookie.split(";")) { + const separator = part.indexOf("="); + if (separator === -1) continue; + const name = part.slice(0, separator).trim(); + if (name !== SESSION_COOKIE_NAME) continue; + matches.push(part.slice(separator + 1).trim()); + } + if (matches.length !== 1 || !TOKEN_PATTERN.test(matches[0])) return null; + return matches[0]; +} + +export class SessionAuth { + #controlToken; + #randomBytes; + #now; + #sessionTtlMs; + #sessions = new Map(); + + constructor({ + controlTokenPath, + randomBytes = cryptoRandomBytes, + now = () => Date.now(), + sessionTtlMs = DEFAULT_SESSION_TTL_MS, + fileOperations = DEFAULT_FILE_OPERATIONS, + platform = process.platform + } = {}) { + if (typeof controlTokenPath !== "string" || controlTokenPath.length === 0 + || !Number.isSafeInteger(sessionTtlMs) || sessionTtlMs < 1) { + throw authError("AUTH_CONTROL_TOKEN_INVALID"); + } + this.#randomBytes = randomBytes; + this.#now = now; + this.#sessionTtlMs = sessionTtlMs; + this.#controlToken = loadOrCreateControlToken({ + path: controlTokenPath, + randomBytes, + fileOperations, + platform + }); + } + + createBrowserSession(authorization) { + const token = bearerToken(authorization); + if (!secureEqual(token, this.#controlToken)) throw authError("AUTH_REQUIRED"); + this.#purgeExpired(); + return this.#issueBrowserSession(this.#now() + this.#sessionTtlMs); + } + + resumeBrowserSession({ cookie, authorization } = {}) { + if (authorization !== undefined && authorization !== null) throw authError("AUTH_REQUIRED"); + const sessionId = sessionCookie(cookie); + const session = sessionId === null ? null : this.#sessions.get(sessionId); + if (!session) throw authError("AUTH_REQUIRED"); + if (this.#now() >= session.expiresAtMs) { + this.#sessions.delete(sessionId); + throw authError("AUTH_SESSION_EXPIRED", { clearCookie: true }); + } + const resumed = this.#issueBrowserSession(session.expiresAtMs, sessionId); + this.#sessions.delete(sessionId); + this.#purgeExpired(); + return resumed; + } + + #issueBrowserSession(expiresAtMs, replacedSessionId = null) { + const sessionId = canonicalToken(this.#randomBytes(TOKEN_BYTES)); + if (sessionId === replacedSessionId || this.#sessions.has(sessionId)) { + throw new TypeError("random session token collision"); + } + const csrfToken = canonicalToken(this.#randomBytes(TOKEN_BYTES)); + const remainingMs = expiresAtMs - this.#now(); + if (remainingMs <= 0) throw authError("AUTH_SESSION_EXPIRED", { clearCookie: true }); + this.#sessions.set(sessionId, { csrfToken, expiresAtMs }); + return { + csrfToken, + expiresAt: new Date(expiresAtMs).toISOString(), + setCookie: `${SESSION_COOKIE_NAME}=${sessionId}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${Math.ceil(remainingMs / 1_000)}` + }; + } + + authorize({ authorization, cookie, csrfToken, mutation = false } = {}) { + if (authorization !== undefined && authorization !== null) { + const token = bearerToken(authorization); + if (!secureEqual(token, this.#controlToken)) throw authError("AUTH_REQUIRED"); + return { kind: "cli" }; + } + + const sessionId = sessionCookie(cookie); + const session = sessionId === null ? null : this.#sessions.get(sessionId); + if (!session) throw authError("AUTH_REQUIRED"); + if (this.#now() >= session.expiresAtMs) { + this.#sessions.delete(sessionId); + throw authError("AUTH_SESSION_EXPIRED", { clearCookie: true }); + } + if (mutation && !secureEqual(csrfToken, session.csrfToken)) { + throw authError("AUTH_CSRF_INVALID"); + } + return { kind: "browser" }; + } + + clearCookie() { + return `${SESSION_COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0`; + } + + close() { + this.#sessions.clear(); + } + + #purgeExpired() { + const now = this.#now(); + for (const [sessionId, session] of this.#sessions) { + if (now >= session.expiresAtMs) this.#sessions.delete(sessionId); + } + } +} diff --git a/node/src/supervisor/supervisor-client.mjs b/node/src/supervisor/supervisor-client.mjs new file mode 100644 index 0000000..39c68c7 --- /dev/null +++ b/node/src/supervisor/supervisor-client.mjs @@ -0,0 +1,921 @@ +import { spawn } from "node:child_process"; +import { + chmodSync, + closeSync, + constants, + fchmodSync, + fstatSync, + linkSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + rmSync +} from "node:fs"; +import { basename, dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { setTimeout as delay } from "node:timers/promises"; + +import { CrpError, parseStartupFailureMessage } from "../shared/errors.mjs"; + +const PACKAGE_ROOT = fileURLToPath(new URL("../..", import.meta.url)); +const SUPERVISOR_ENTRY = fileURLToPath(new URL("./supervisor-entry.mjs", import.meta.url)); +const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/; +const ERROR_CODE_PATTERN = /^[A-Z][A-Z0-9_]*$/; +const MAX_STATE_BYTES = 64 * 1_024; +const MAX_RESPONSE_BYTES = 1024 * 1_024; +const SAFE_ERROR_DETAIL_FIELDS = new Set([ + "field", + "reason", + "committed", + "degraded", + "pending", + "generation", + "httpStatus" +]); +const STATE_FIELDS = new Set([ + "schemaVersion", + "supervisorPid", + "startedAt", + "admin", + "worker" +]); +const ADMIN_FIELDS = new Set(["host", "port", "authority", "origin"]); +const WORKER_FIELDS = new Set([ + "phase", + "pid", + "generation", + "state", + "restartCount", + "startedAt", + "error" +]); +const CHILD_STATE_FIELDS = new Set([ + "phase", + "configured", + "generation", + "listening", + "listenHost", + "listenPort", + "inFlight" +]); +const WORKER_ERROR_FIELDS = new Set(["code", "message"]); +const REQUEST_OPTIONS_FIELDS = new Set(["requestTimeoutMs", "expectedStatus"]); +const STALE_STATE_REMOVAL_REASONS = Object.freeze({ + INVALID_INPUT: "invalid_input", + PROCESS_RUNNING: "process_running", + STATE_MISSING: "state_missing", + STATE_CHANGED: "state_changed", + CLAIM_CONFLICT: "claim_conflict", + CLAIM_CHANGED: "claim_changed", + CLEANUP_FAILED: "cleanup_failed", + REMOVED: "removed" +}); +const DEFAULT_FILE_OPERATIONS = { + chmodSync, + closeSync, + fchmodSync, + fstatSync, + linkSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + rmSync +}; +const pendingEnsures = new Map(); +const supervisorStateSnapshots = new WeakMap(); + +function clientError(code, { status = 500 } = {}) { + const contracts = { + SUPERVISOR_CLIENT_INPUT_INVALID: [ + "The supervisor client input is invalid.", + "Use the fixed local supervisor address and a documented Admin path." + ], + SUPERVISOR_TOKEN_INVALID: [ + "The local control token is invalid.", + "Stop CRP, repair the private control token file, and restart CRP." + ], + SUPERVISOR_UNAVAILABLE: [ + "The local supervisor is unavailable.", + "Start the local supervisor and try again." + ], + SUPERVISOR_RESPONSE_INVALID: [ + "The local supervisor returned an invalid response.", + "Restart CRP and try again." + ], + SUPERVISOR_START_FAILED: [ + "The local supervisor could not be started.", + "Review the supervisor log and try again." + ], + SUPERVISOR_START_TIMEOUT: [ + "The local supervisor did not become ready in time.", + "Review the supervisor log and try again." + ] + }; + const [message, action] = contracts[code] ?? contracts.SUPERVISOR_UNAVAILABLE; + return new CrpError(code, message, action, { status }); +} + +function isPlainObject(value) { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function hasExactFields(value, fields) { + return isPlainObject(value) + && Object.keys(value).length === fields.size + && Object.keys(value).every((field) => fields.has(field)); +} + +function hasOnlyFields(value, fields) { + return isPlainObject(value) + && Object.keys(value).length > 0 + && Object.keys(value).every((field) => fields.has(field)); +} + +function isIsoTimestamp(value) { + if (typeof value !== "string") return false; + try { + return new Date(value).toISOString() === value; + } catch { + return false; + } +} + +function isSafePid(value) { + return Number.isSafeInteger(value) && value > 0; +} + +function isNullableSafeInteger(value, { min = 0 } = {}) { + return value === null || (Number.isSafeInteger(value) && value >= min); +} + +function sameIdentity(left, right) { + return left.dev === right.dev && left.ino === right.ino; +} + +function isPrivateMode(stats, platform) { + return platform === "win32" || (stats.mode & 0o777) === 0o600; +} + +function readPrivateFileSnapshot({ path, fileOperations, platform, maxBytes }) { + const parent = fileOperations.lstatSync(dirname(path)); + if (!parent.isDirectory() || parent.isSymbolicLink() + || platform !== "win32" && (parent.mode & 0o777) !== 0o700) { + throw new TypeError("private file parent is unsafe"); + } + const before = fileOperations.lstatSync(path); + if (!before.isFile() || before.isSymbolicLink() || !isPrivateMode(before, platform) + || before.size < 1 || before.size > maxBytes) { + throw new TypeError("private file path is unsafe"); + } + + let descriptor; + try { + const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0; + descriptor = fileOperations.openSync(path, constants.O_RDONLY | noFollow); + const opened = fileOperations.fstatSync(descriptor); + if (!opened.isFile() || !sameIdentity(before, opened) || !isPrivateMode(opened, platform) + || opened.size < 1 || opened.size > maxBytes) { + throw new TypeError("private file identity changed"); + } + const bytes = fileOperations.readFileSync(descriptor); + const after = fileOperations.lstatSync(path); + if (!after.isFile() || after.isSymbolicLink() || !sameIdentity(opened, after)) { + throw new TypeError("private file identity changed"); + } + return { + bytes: Buffer.from(bytes), + identity: { dev: opened.dev, ino: opened.ino } + }; + } finally { + if (descriptor !== undefined) fileOperations.closeSync(descriptor); + } +} + +function readPrivateFile(options) { + return readPrivateFileSnapshot(options).bytes; +} + +function validChildState(state) { + return hasExactFields(state, CHILD_STATE_FIELDS) + && typeof state.phase === "string" && state.phase.length > 0 + && typeof state.configured === "boolean" + && Number.isSafeInteger(state.generation) && state.generation >= 0 + && typeof state.listening === "boolean" + && (state.listenHost === null || typeof state.listenHost === "string") + && isNullableSafeInteger(state.listenPort) + && Number.isSafeInteger(state.inFlight) && state.inFlight >= 0; +} + +function validWorkerError(error) { + return hasExactFields(error, WORKER_ERROR_FIELDS) + && typeof error.code === "string" && ERROR_CODE_PATTERN.test(error.code) + && typeof error.message === "string" && error.message.length > 0; +} + +function validWorkerState(worker) { + return hasExactFields(worker, WORKER_FIELDS) + && typeof worker.phase === "string" && worker.phase.length > 0 + && isNullableSafeInteger(worker.pid, { min: 1 }) + && Number.isSafeInteger(worker.generation) && worker.generation >= 0 + && (worker.state === null || validChildState(worker.state)) + && Number.isSafeInteger(worker.restartCount) && worker.restartCount >= 0 + && (worker.startedAt === null || isIsoTimestamp(worker.startedAt)) + && (worker.error === null || validWorkerError(worker.error)); +} + +function expectedAdmin(adminPort) { + return { + host: "127.0.0.1", + port: adminPort, + authority: `127.0.0.1:${adminPort}`, + origin: `http://127.0.0.1:${adminPort}` + }; +} + +function validateSupervisorState(state, adminPort) { + const admin = expectedAdmin(adminPort); + return hasExactFields(state, STATE_FIELDS) + && state.schemaVersion === 1 + && isSafePid(state.supervisorPid) + && isIsoTimestamp(state.startedAt) + && hasExactFields(state.admin, ADMIN_FIELDS) + && Object.keys(admin).every((field) => state.admin[field] === admin[field]) + && validWorkerState(state.worker); +} + +export function readSupervisorStateSnapshot({ + path, + adminPort = 15101, + fileOperations = DEFAULT_FILE_OPERATIONS, + platform = process.platform +} = {}) { + if (typeof path !== "string" || path.length === 0 + || !Number.isInteger(adminPort) || adminPort < 1 || adminPort > 65_535) { + return null; + } + try { + const resolvedPath = resolve(path); + const snapshot = readPrivateFileSnapshot({ + path: resolvedPath, + fileOperations, + platform, + maxBytes: MAX_STATE_BYTES + }); + const state = JSON.parse(snapshot.bytes.toString("utf8")); + if (!validateSupervisorState(state, adminPort)) return null; + const opaqueSnapshot = Object.freeze(Object.create(null)); + supervisorStateSnapshots.set(opaqueSnapshot, { + path: resolvedPath, + state: structuredClone(state), + identity: { ...snapshot.identity }, + bytes: Buffer.from(snapshot.bytes), + adminPort + }); + return opaqueSnapshot; + } catch { + return null; + } +} + +export function readSupervisorState(options) { + const snapshot = readSupervisorStateSnapshot(options); + const owned = snapshot === null ? null : supervisorStateSnapshots.get(snapshot); + return owned ? structuredClone(owned.state) : null; +} + +function matchesExpectedStateSnapshot(snapshot, expectedSnapshot) { + const current = snapshot === null ? null : supervisorStateSnapshots.get(snapshot); + return current !== null && current !== undefined + && sameIdentity(current.identity, expectedSnapshot.identity) + && current.bytes.equals(expectedSnapshot.bytes) + && current.state.supervisorPid === expectedSnapshot.state.supervisorPid + && current.state.startedAt === expectedSnapshot.state.startedAt + && Object.keys(current.state.admin).every( + (field) => current.state.admin[field] === expectedSnapshot.state.admin[field] + ); +} + +function pathExists(path, fileOperations) { + try { + fileOperations.lstatSync(path); + return true; + } catch (error) { + if (error?.code === "ENOENT") return false; + throw error; + } +} + +function restoreClaimWithoutReplacement(claimPath, statePath, fileOperations) { + try { + fileOperations.linkSync(claimPath, statePath); + fileOperations.rmSync(claimPath); + return true; + } catch (error) { + if (error?.code === "EEXIST") return false; + throw error; + } +} + +function staleStateClaimPath(statePath) { + return resolve(dirname(statePath), `${basename(statePath)}.stale`); +} + +function expectedManagedProcessAlive(expected, isProcessAlive) { + const workerPid = expected.state.worker.pid; + return isProcessAlive(expected.state.supervisorPid) + || workerPid !== null && isProcessAlive(workerPid); +} + +export function removeStaleSupervisorState({ + path, + expectedSnapshot, + adminPort = 15101, + fileOperations = DEFAULT_FILE_OPERATIONS, + platform = process.platform, + isProcessAlive = defaultIsProcessAlive +} = {}) { + if (typeof path !== "string" || path.length === 0 + || !Number.isInteger(adminPort) || adminPort < 1 || adminPort > 65_535 + || typeof isProcessAlive !== "function") { + return { removed: false, reason: STALE_STATE_REMOVAL_REASONS.INVALID_INPUT }; + } + const statePath = resolve(path); + const claimPath = staleStateClaimPath(statePath); + const expected = supervisorStateSnapshots.get(expectedSnapshot); + if (!expected + || expected.path !== statePath && expected.path !== claimPath + || expected.adminPort !== adminPort) { + return { removed: false, reason: STALE_STATE_REMOVAL_REASONS.INVALID_INPUT }; + } + + try { + if (expectedManagedProcessAlive(expected, isProcessAlive)) { + return { removed: false, reason: STALE_STATE_REMOVAL_REASONS.PROCESS_RUNNING }; + } + const canonicalExists = pathExists(statePath, fileOperations); + const claimExists = pathExists(claimPath, fileOperations); + let claimedByThisCall = false; + + if (canonicalExists) { + const current = readSupervisorStateSnapshot({ + path: statePath, + adminPort, + fileOperations, + platform + }); + if (!matchesExpectedStateSnapshot(current, expected)) { + return { removed: false, reason: STALE_STATE_REMOVAL_REASONS.STATE_CHANGED }; + } + if (claimExists) { + const residualClaim = readSupervisorStateSnapshot({ + path: claimPath, + adminPort, + fileOperations, + platform + }); + if (!matchesExpectedStateSnapshot(residualClaim, expected)) { + return { removed: false, reason: STALE_STATE_REMOVAL_REASONS.CLAIM_CONFLICT }; + } + if (expectedManagedProcessAlive(expected, isProcessAlive)) { + return { removed: false, reason: STALE_STATE_REMOVAL_REASONS.PROCESS_RUNNING }; + } + fileOperations.rmSync(claimPath); + } + fileOperations.renameSync(statePath, claimPath); + claimedByThisCall = true; + } else if (!claimExists) { + return { removed: false, reason: STALE_STATE_REMOVAL_REASONS.STATE_MISSING }; + } + + const claimed = readSupervisorStateSnapshot({ + path: claimPath, + adminPort, + fileOperations, + platform + }); + if (!matchesExpectedStateSnapshot(claimed, expected)) { + if (claimedByThisCall) { + restoreClaimWithoutReplacement(claimPath, statePath, fileOperations); + } + return { removed: false, reason: STALE_STATE_REMOVAL_REASONS.CLAIM_CHANGED }; + } + if (expectedManagedProcessAlive(expected, isProcessAlive)) { + if (claimedByThisCall) { + restoreClaimWithoutReplacement(claimPath, statePath, fileOperations); + } + return { removed: false, reason: STALE_STATE_REMOVAL_REASONS.PROCESS_RUNNING }; + } + + const confirmed = readSupervisorStateSnapshot({ + path: claimPath, + adminPort, + fileOperations, + platform + }); + if (!matchesExpectedStateSnapshot(confirmed, expected)) { + return { removed: false, reason: STALE_STATE_REMOVAL_REASONS.CLAIM_CHANGED }; + } + fileOperations.rmSync(claimPath); + return { removed: true, reason: STALE_STATE_REMOVAL_REASONS.REMOVED }; + } catch { + return { removed: false, reason: STALE_STATE_REMOVAL_REASONS.CLEANUP_FAILED }; + } +} + +export function readControlToken({ + path, + fileOperations = DEFAULT_FILE_OPERATIONS, + platform = process.platform +} = {}) { + try { + if (typeof path !== "string" || path.length === 0) throw new TypeError("token path is invalid"); + const text = readPrivateFile({ path, fileOperations, platform, maxBytes: 44 }).toString("utf8"); + const token = text.endsWith("\n") ? text.slice(0, -1) : text; + if (!TOKEN_PATTERN.test(token) || text !== token && text !== `${token}\n`) { + throw new TypeError("token format is invalid"); + } + const bytes = Buffer.from(token, "base64url"); + if (bytes.length !== 32 || bytes.toString("base64url") !== token) { + throw new TypeError("token encoding is invalid"); + } + return token; + } catch (error) { + if (error instanceof CrpError) throw error; + throw clientError("SUPERVISOR_TOKEN_INVALID"); + } +} + +function validateOrigin(origin) { + try { + const url = new URL(origin); + if (url.protocol !== "http:" || url.hostname !== "127.0.0.1" + || !url.port || url.username || url.password + || url.pathname !== "/" || url.search || url.hash) { + throw new TypeError("origin is unsafe"); + } + return url.origin; + } catch { + throw clientError("SUPERVISOR_CLIENT_INPUT_INVALID", { status: 400 }); + } +} + +function validateRequest(method, path) { + if (!["GET", "POST", "PATCH", "DELETE"].includes(method) + || typeof path !== "string" || !path.startsWith("/") || path.startsWith("//") + || /[\\\u0000-\u001f\u007f]/.test(path)) { + throw clientError("SUPERVISOR_CLIENT_INPUT_INVALID", { status: 400 }); + } +} + +function sanitizeErrorDetails(details) { + if (!isPlainObject(details)) return {}; + const projected = {}; + for (const [key, value] of Object.entries(details)) { + if (SAFE_ERROR_DETAIL_FIELDS.has(key) || value === "[REDACTED]") { + projected[key] = structuredClone(value); + } + } + return projected; +} + +function publicResponseError(payload, status) { + const error = payload?.error; + if (!isPlainObject(error) + || typeof error.code !== "string" || !ERROR_CODE_PATTERN.test(error.code) + || typeof error.message !== "string" || error.message.length === 0 + || typeof error.action !== "string" || error.action.length === 0) { + return clientError("SUPERVISOR_RESPONSE_INVALID"); + } + const projected = new CrpError(error.code, error.message, error.action, { + status, + details: sanitizeErrorDetails(error.details) + }); + if (typeof error.requestId === "string" && error.requestId.length > 0) { + projected.requestId = error.requestId; + } + return projected; +} + +export class SupervisorClient { + #origin; + #controlToken; + #fetch; + #requestTimeoutMs; + + constructor({ + origin, + controlTokenPath, + fetchImpl = globalThis.fetch, + requestTimeoutMs = 8_000, + fileOperations = DEFAULT_FILE_OPERATIONS, + platform = process.platform + } = {}) { + if (typeof fetchImpl !== "function" + || !Number.isSafeInteger(requestTimeoutMs) || requestTimeoutMs < 1) { + throw clientError("SUPERVISOR_CLIENT_INPUT_INVALID", { status: 400 }); + } + this.#origin = validateOrigin(origin); + this.#controlToken = readControlToken({ + path: controlTokenPath, + fileOperations, + platform + }); + this.#fetch = fetchImpl; + this.#requestTimeoutMs = requestTimeoutMs; + } + + request(method, path, body, options) { + validateRequest(method, path); + let requestTimeoutMs = this.#requestTimeoutMs; + let expectedStatus = null; + if (options !== undefined) { + if (!hasOnlyFields(options, REQUEST_OPTIONS_FIELDS) + || "requestTimeoutMs" in options + && (!Number.isSafeInteger(options.requestTimeoutMs) || options.requestTimeoutMs < 1) + || "expectedStatus" in options + && (!Number.isInteger(options.expectedStatus) + || options.expectedStatus < 100 || options.expectedStatus > 599)) { + throw clientError("SUPERVISOR_CLIENT_INPUT_INVALID", { status: 400 }); + } + if ("requestTimeoutMs" in options) requestTimeoutMs = options.requestTimeoutMs; + if ("expectedStatus" in options) expectedStatus = options.expectedStatus; + } + return this.#performRequest(method, path, body, requestTimeoutMs, expectedStatus); + } + + async #performRequest(method, path, body, requestTimeoutMs, expectedStatus) { + const headers = { authorization: `Bearer ${this.#controlToken}` }; + const options = { + method, + headers, + signal: AbortSignal.timeout(requestTimeoutMs) + }; + if (body !== undefined) { + headers["content-type"] = "application/json; charset=utf-8"; + options.body = JSON.stringify(body); + if (Buffer.byteLength(options.body, "utf8") > 64 * 1_024) { + throw clientError("SUPERVISOR_CLIENT_INPUT_INVALID", { status: 400 }); + } + } + + let response; + try { + response = await this.#fetch(`${this.#origin}/api/v1${path}`, options); + } catch { + throw clientError("SUPERVISOR_UNAVAILABLE"); + } + let text; + try { + text = await response.text(); + if (Buffer.byteLength(text, "utf8") > MAX_RESPONSE_BYTES) throw new Error("response too large"); + } catch { + throw clientError("SUPERVISOR_RESPONSE_INVALID"); + } + let payload; + try { + payload = JSON.parse(text); + } catch { + throw clientError("SUPERVISOR_RESPONSE_INVALID"); + } + if (!response.ok) throw publicResponseError(payload, response.status); + if (expectedStatus !== null && response.status !== expectedStatus) { + throw clientError("SUPERVISOR_RESPONSE_INVALID"); + } + if (!isPlainObject(payload)) throw clientError("SUPERVISOR_RESPONSE_INVALID"); + return payload; + } +} + +function defaultIsProcessAlive(pid) { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function ignoreLateChildError() {} + +function observeStartupFailure(child) { + if (!child || typeof child.on !== "function" || typeof child.once !== "function") { + return { failure: null, dispose() {} }; + } + let resolveFailure; + let disposed = false; + let closeFallback = null; + const failure = new Promise((resolvePromise) => { resolveFailure = resolvePromise; }); + const removeListeners = () => { + child.off?.("message", onMessage); + child.off?.("error", onError); + child.off?.("close", onClose); + if (closeFallback !== null) { + clearImmediate(closeFallback); + closeFallback = null; + } + child.on("error", ignoreLateChildError); + }; + const dispose = () => { + if (disposed) return; + disposed = true; + removeListeners(); + if (child.connected === true && typeof child.disconnect === "function") { + try { child.disconnect(); } catch {} + } + }; + const fail = (error) => { + if (disposed) return; + dispose(); + resolveFailure(error); + }; + const onMessage = (message) => { + fail(parseStartupFailureMessage(message) ?? clientError("SUPERVISOR_START_FAILED")); + }; + const onError = () => { fail(clientError("SUPERVISOR_START_FAILED")); }; + const onClose = () => { + if (closeFallback !== null) return; + closeFallback = setImmediate(() => { + closeFallback = null; + fail(clientError("SUPERVISOR_START_FAILED")); + }); + }; + child.on("message", onMessage); + child.once("error", onError); + child.once("close", onClose); + child.channel?.unref?.(); + return { failure, dispose }; +} + +async function raceStartupFailure(operation, startupOutcome) { + const observedOperation = Promise.resolve(operation).then( + (value) => ({ type: "operation", value }), + (error) => ({ type: "operation-error", error }) + ); + if (startupOutcome === null) { + const outcome = await observedOperation; + if (outcome.type === "operation-error") throw outcome.error; + return outcome.value; + } + const outcome = await Promise.race([observedOperation, startupOutcome]); + if (outcome.type === "startup-error" || outcome.type === "operation-error") { + throw outcome.error; + } + return outcome.value; +} + +export function spawnDetachedSupervisor({ + paths, + home = dirname(paths?.globalHome ?? ""), + spawnImpl = spawn, + fileOperations = DEFAULT_FILE_OPERATIONS +} = {}) { + if (!paths || typeof paths.globalHome !== "string" || typeof paths.logPath !== "string" + || typeof home !== "string" || home.length === 0 || typeof spawnImpl !== "function") { + throw clientError("SUPERVISOR_START_FAILED"); + } + fileOperations.mkdirSync(paths.globalHome, { recursive: true, mode: 0o700 }); + try { fileOperations.chmodSync(paths.globalHome, 0o700); } catch {} + let logDescriptor; + let child; + let monitor; + try { + logDescriptor = fileOperations.openSync(paths.logPath, "a", 0o600); + try { fileOperations.fchmodSync(logDescriptor, 0o600); } catch {} + child = spawnImpl(process.execPath, [SUPERVISOR_ENTRY], { + cwd: resolve(PACKAGE_ROOT), + env: { ...process.env, CRP_HOME: home }, + detached: true, + windowsHide: true, + stdio: ["ignore", logDescriptor, logDescriptor, "ipc"], + serialization: "json", + shell: false + }); + monitor = observeStartupFailure(child); + Object.defineProperties(child, { + startupFailure: { value: monitor.failure, configurable: true }, + disposeStartupMonitor: { value: monitor.dispose, configurable: true } + }); + child.unref(); + return child; + } catch { + try { monitor?.dispose(); } catch {} + try { + if (child?.listenerCount?.("error") === 0) child.on("error", ignoreLateChildError); + } catch {} + try { child?.kill?.("SIGTERM"); } catch {} + if (child?.connected === true && typeof child.disconnect === "function") { + try { child.disconnect(); } catch {} + } + throw clientError("SUPERVISOR_START_FAILED"); + } finally { + if (logDescriptor !== undefined) fileOperations.closeSync(logDescriptor); + } +} + +export async function discoverSupervisor({ + paths, + adminPort = 15101, + fetchImpl = globalThis.fetch, + fileOperations = DEFAULT_FILE_OPERATIONS, + platform = process.platform, + isProcessAlive = defaultIsProcessAlive, + probeTimeoutMs = 2_000, + requestTimeoutMs = 30_000 +} = {}) { + const state = readSupervisorState({ + path: paths?.statePath, + adminPort, + fileOperations, + platform + }); + if (!state || !isProcessAlive(state.supervisorPid)) return null; + const client = new SupervisorClient({ + origin: state.admin.origin, + controlTokenPath: paths.controlTokenPath, + fetchImpl, + requestTimeoutMs, + fileOperations, + platform + }); + let status; + try { + status = await client.request("GET", "/status", undefined, { + requestTimeoutMs: probeTimeoutMs + }); + } catch (error) { + if (error?.code === "SUPERVISOR_UNAVAILABLE") return null; + throw error; + } + if (!isPlainObject(status.supervisor) + || status.supervisor.pid !== state.supervisorPid + || status.supervisor.startedAt !== state.startedAt) { + return null; + } + return { + origin: state.admin.origin, + state, + status, + client, + spawned: false + }; +} + +async function ensureSupervisorInternal({ + paths, + adminPort, + spawnSupervisor, + fetchImpl, + fileOperations, + platform, + isProcessAlive, + probeTimeoutMs, + requestTimeoutMs, + timeoutMs, + pollIntervalMs, + now, + wait, + home +}) { + const discoveryOptions = { + paths, + adminPort, + fetchImpl, + fileOperations, + platform, + isProcessAlive, + probeTimeoutMs, + requestTimeoutMs + }; + const existing = await discoverSupervisor(discoveryOptions); + if (existing) return existing; + + try { + const statePath = resolve(paths.statePath); + const claimPath = staleStateClaimPath(statePath); + if (pathExists(claimPath, fileOperations)) { + if (pathExists(statePath, fileOperations)) throw new Error("state and claim coexist"); + const expectedSnapshot = readSupervisorStateSnapshot({ + path: claimPath, + adminPort, + fileOperations, + platform + }); + if (expectedSnapshot === null) throw new Error("claim is invalid"); + const cleanup = removeStaleSupervisorState({ + path: statePath, + expectedSnapshot, + adminPort, + fileOperations, + platform, + isProcessAlive + }); + if (cleanup.removed !== true && cleanup.reason !== STALE_STATE_REMOVAL_REASONS.STATE_MISSING) { + throw new Error("claim could not be recovered"); + } + } + } catch { + throw clientError("SUPERVISOR_START_FAILED"); + } + + let spawnedChild; + try { + spawnedChild = spawnSupervisor({ paths, home }); + } catch { + throw clientError("SUPERVISOR_START_FAILED"); + } + + const startupOutcome = spawnedChild?.startupFailure + && typeof spawnedChild.startupFailure.then === "function" + ? Promise.resolve(spawnedChild.startupFailure).then( + (error) => ({ + type: "startup-error", + error: error instanceof CrpError ? error : clientError("SUPERVISOR_START_FAILED") + }), + () => ({ type: "startup-error", error: clientError("SUPERVISOR_START_FAILED") }) + ) + : null; + try { + const deadline = now() + timeoutMs; + while (now() <= deadline) { + const discovered = await raceStartupFailure( + discoverSupervisor(discoveryOptions), + startupOutcome + ); + if (discovered) return { ...discovered, spawned: true }; + if (now() >= deadline) break; + await raceStartupFailure( + wait(Math.min(pollIntervalMs, deadline - now())), + startupOutcome + ); + } + throw clientError("SUPERVISOR_START_TIMEOUT"); + } catch (error) { + try { + if (typeof spawnedChild?.kill === "function") spawnedChild.kill("SIGTERM"); + } catch { + // Preserve the readiness/discovery error when cleanup is unavailable. + } + throw error; + } finally { + try { spawnedChild?.disposeStartupMonitor?.(); } catch {} + } +} + +export function ensureSupervisor({ + paths, + adminPort = 15101, + spawnSupervisor = (options) => spawnDetachedSupervisor(options), + fetchImpl = globalThis.fetch, + fileOperations = DEFAULT_FILE_OPERATIONS, + platform = process.platform, + isProcessAlive = defaultIsProcessAlive, + probeTimeoutMs = 2_000, + requestTimeoutMs = 30_000, + timeoutMs = 8_000, + pollIntervalMs = 100, + now = () => Date.now(), + wait = (milliseconds) => delay(milliseconds), + home = dirname(paths?.globalHome ?? "") +} = {}) { + if (!paths || typeof paths.statePath !== "string" || typeof paths.controlTokenPath !== "string" + || typeof paths.globalHome !== "string" || typeof paths.logPath !== "string" + || !Number.isInteger(adminPort) || adminPort < 1 || adminPort > 65_535 + || typeof spawnSupervisor !== "function" || typeof fetchImpl !== "function" + || typeof isProcessAlive !== "function" || typeof now !== "function" || typeof wait !== "function" + || !Number.isSafeInteger(probeTimeoutMs) || probeTimeoutMs < 1 + || !Number.isSafeInteger(requestTimeoutMs) || requestTimeoutMs < 1 + || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1 + || !Number.isSafeInteger(pollIntervalMs) || pollIntervalMs < 1) { + return Promise.reject(clientError("SUPERVISOR_CLIENT_INPUT_INVALID", { status: 400 })); + } + const key = `${resolve(paths.statePath)}\u0000${adminPort}`; + if (pendingEnsures.has(key)) return pendingEnsures.get(key); + const operation = ensureSupervisorInternal({ + paths, + adminPort, + spawnSupervisor, + fetchImpl, + fileOperations, + platform, + isProcessAlive, + probeTimeoutMs, + requestTimeoutMs, + timeoutMs, + pollIntervalMs, + now, + wait, + home + }); + pendingEnsures.set(key, operation); + const clear = () => { + if (pendingEnsures.get(key) === operation) pendingEnsures.delete(key); + }; + void operation.then(clear, clear); + return operation; +} diff --git a/node/src/supervisor/supervisor-entry.mjs b/node/src/supervisor/supervisor-entry.mjs new file mode 100644 index 0000000..c332cba --- /dev/null +++ b/node/src/supervisor/supervisor-entry.mjs @@ -0,0 +1,109 @@ +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { createStartupFailureMessage } from "../shared/errors.mjs"; +import { createSupervisor } from "./supervisor.mjs"; + +const STARTUP_SEND_TIMEOUT_MS = 250; + +function disconnectStartupChannel(processRef) { + if (typeof processRef.disconnect !== "function" || processRef.connected === false) return; + try { processRef.disconnect(); } catch {} +} + +async function reportStartupFailure(processRef, error) { + if (typeof processRef.send !== "function" || processRef.connected === false) return; + const message = createStartupFailureMessage(error); + await new Promise((resolvePromise) => { + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolvePromise(); + }; + const timer = setTimeout(finish, STARTUP_SEND_TIMEOUT_MS); + try { + processRef.send(message, undefined, undefined, finish); + } catch { + finish(); + } + }); +} + +export function isDirectExecution(metaUrl = import.meta.url, argv1 = process.argv[1]) { + if (typeof argv1 !== "string" || argv1.length === 0) return false; + return resolve(fileURLToPath(metaUrl)) === resolve(argv1); +} + +export async function runSupervisor({ + processRef = process, + createSupervisorImpl = createSupervisor, + supervisorOptions = {} +} = {}) { + let supervisor; + try { + supervisor = await createSupervisorImpl(supervisorOptions); + } catch (error) { + await reportStartupFailure(processRef, error); + disconnectStartupChannel(processRef); + throw error; + } + let shutdownPromise = null; + + const removeSignalHandlers = () => { + processRef.off("SIGTERM", onSignal); + processRef.off("SIGINT", onSignal); + }; + const shutdown = () => { + if (shutdownPromise) return shutdownPromise; + let requested; + try { + requested = typeof supervisor.requestShutdown === "function" + ? supervisor.requestShutdown() + : supervisor.close(); + } catch (error) { + requested = Promise.reject(error); + } + shutdownPromise = Promise.resolve(requested) + .then( + () => { + removeSignalHandlers(); + processRef.exitCode = 0; + }, + () => { + removeSignalHandlers(); + processRef.exitCode = 1; + } + ); + return shutdownPromise; + }; + const onSignal = () => { + void shutdown(); + }; + + processRef.once("SIGTERM", onSignal); + processRef.once("SIGINT", onSignal); + try { + await supervisor.listen(); + disconnectStartupChannel(processRef); + return supervisor; + } catch (error) { + removeSignalHandlers(); + await supervisor.close().catch(() => {}); + await reportStartupFailure(processRef, error); + disconnectStartupChannel(processRef); + throw error; + } +} + +if (isDirectExecution()) { + const home = typeof process.env.CRP_HOME === "string" && process.env.CRP_HOME.length > 0 + ? process.env.CRP_HOME + : undefined; + void runSupervisor({ supervisorOptions: { home } }).catch((error) => { + const code = createStartupFailureMessage(error).error.code; + process.stderr.write(`CRP supervisor failed to start (${code}).\n`); + process.exitCode = 1; + }); +} diff --git a/node/src/supervisor/supervisor.mjs b/node/src/supervisor/supervisor.mjs new file mode 100644 index 0000000..751a908 --- /dev/null +++ b/node/src/supervisor/supervisor.mjs @@ -0,0 +1,614 @@ +import { randomUUID } from "node:crypto"; +import { + chmodSync, + closeSync, + constants, + fchmodSync, + fstatSync, + fsyncSync, + linkSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + rmSync, + writeFileSync +} from "node:fs"; +import { fileURLToPath } from "node:url"; +import { basename, dirname, resolve } from "node:path"; + +import { bootstrapCodexConfig, patchCodexConfigText } from "../codex/codex-config.mjs"; +import { + hasPendingCodexHistoryRepair, + inspectPendingCodexHistoryRepair, + planCodexProviderTransition, + runCodexHistoryRepairTransition +} from "../codex/codex-history-repair.mjs"; +import { createCredentialStore } from "../credentials/credential-store.mjs"; +import { ProviderModelCache } from "../providers/provider-model-cache.mjs"; +import { ProviderRegistry } from "../providers/provider-registry.mjs"; +import { CrpError } from "../shared/errors.mjs"; +import { getPaths } from "../shared/paths.mjs"; +import { ActivityStore } from "./activity-store.mjs"; +import { createAdminServer } from "./admin-server.mjs"; +import { migrateLegacyConfiguration } from "./migration.mjs"; +import { MetricsStore } from "./metrics-store.mjs"; +import { ProviderService } from "./provider-service.mjs"; +import { SessionAuth } from "./session-auth.mjs"; +import { WorkerManager } from "./worker-manager.mjs"; + +const DEFAULT_UI_DIR = fileURLToPath(new URL("../../ui", import.meta.url)); +const DEFAULT_FILE_OPERATIONS = { + chmodSync, + closeSync, + constants, + fchmodSync, + fstatSync, + fsyncSync, + linkSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + rmSync, + writeFileSync +}; +const DEFAULT_CODEX_HISTORY_REPAIR = Object.freeze({ + plan: planCodexProviderTransition, + hasPending: hasPendingCodexHistoryRepair, + inspectPending: inspectPendingCodexHistoryRepair, + run: runCodexHistoryRepairTransition +}); + +function completePaths(home, provided) { + const base = provided ?? getPaths(home); + return { + ...base, + modelCachePath: base.modelCachePath + ?? resolve(base.globalHome, "provider-model-cache.json"), + metricsPath: base.metricsPath ?? resolve(base.globalHome, "metrics.json"), + runtimeConfigPath: base.runtimeConfigPath + ?? resolve(base.globalHome, "node", "proxy-config.json"), + capturePath: base.capturePath ?? resolve(base.globalHome, "traffic.sqlite3") + }; +} + +function publicChildState(state) { + if (state === null || typeof state !== "object") return null; + return { + phase: state.phase, + configured: state.configured, + generation: state.generation, + listening: state.listening, + listenHost: state.listenHost, + listenPort: state.listenPort, + inFlight: state.inFlight + }; +} + +function publicWorkerState(state) { + if (state === null || typeof state !== "object") return null; + return { + phase: state.phase, + pid: state.pid, + generation: state.generation, + state: publicChildState(state.state), + restartCount: state.restartCount, + startedAt: state.startedAt, + error: state.error === null || typeof state.error !== "object" + ? null + : { code: state.error.code, message: state.error.message } + }; +} + +const CODEX_BOOTSTRAP_ERROR_CONTRACTS = new Map([ + ["CODEX_CONFIG_PARENT_UNSAFE", { + message: "The Codex configuration directory is unsafe.", + action: "Repair the Codex configuration directory and retry.", + status: 500 + }], + ["CODEX_CONFIG_BUSY", { + message: "Codex configuration is already being updated.", + action: "Wait for the current update to finish and retry.", + status: 409 + }], + ["CODEX_CONFIG_CHANGED", { + message: "Codex configuration changed during bootstrap.", + action: "Review the current Codex configuration and retry.", + status: 409 + }], + ["CODEX_CONFIG_READ_FAILED", { + message: "Codex configuration could not be read safely.", + action: "Repair local filesystem access and retry.", + status: 500 + }], + ["CODEX_CONFIG_WRITE_FAILED", { + message: "Codex configuration could not be written safely.", + action: "Repair local filesystem access and retry.", + status: 500 + }], + ["CODEX_CONFIG_COMMITTED_DEGRADED", { + message: "The Codex configuration was updated, but completion could not be confirmed.", + action: "Review the Codex configuration and retry before starting the proxy.", + status: 500, + details: { committed: true, degraded: true, pending: false } + }], + ["CODEX_HISTORY_REPAIR_INVALID", { + message: "The Codex history repair input is invalid.", + action: "Repair the Codex configuration or history state and try again.", + status: 400 + }], + ["CODEX_HISTORY_REPAIR_CONFLICT", { + message: "Another Codex history repair transition is already pending.", + action: "Complete or recover the pending Codex history repair before retrying.", + status: 409 + }], + ["CODEX_HISTORY_REPAIR_FAILED", { + message: "Codex history repair could not be completed.", + action: "Repair local Codex history storage and retry before starting the proxy.", + status: 500 + }], + ["CODEX_HISTORY_REPAIR_COMMITTED_DEGRADED", { + message: "Codex configuration was updated, but history repair remains pending.", + action: "Retry crp start to resume Codex history repair before using the proxy.", + status: 500 + }] +]); + +function projectCodexBootstrapError(error) { + const code = CODEX_BOOTSTRAP_ERROR_CONTRACTS.has(error?.code) + ? error.code + : "CODEX_CONFIG_WRITE_FAILED"; + const contract = CODEX_BOOTSTRAP_ERROR_CONTRACTS.get(code); + const details = contract.details ?? (code === "CODEX_HISTORY_REPAIR_COMMITTED_DEGRADED" + ? { committed: true, degraded: true, pending: true } + : {}); + return new CrpError(code, contract.message, contract.action, { + status: contract.status, + details, + cause: error + }); +} + +function codexNotReady() { + return new CrpError( + "CODEX_NOT_READY", + "The Codex configuration is not ready.", + "Complete Codex bootstrap before activating a provider or starting or restarting the proxy.", + { status: 409 } + ); +} + +function createSerialGate() { + let tail = Promise.resolve(); + return (operation) => { + const previous = tail; + let release; + tail = new Promise((resolvePromise) => { + release = resolvePromise; + }); + return (async () => { + await previous; + try { + return await operation(); + } finally { + release(); + } + })(); + }; +} + +function readCodexConfigText(path, fileOperations) { + let before; + try { + before = fileOperations.lstatSync(path); + } catch (error) { + if (error?.code === "ENOENT") return null; + throw error; + } + if (!before.isFile() || before.isSymbolicLink()) { + throw new Error("Codex configuration status source is unsafe."); + } + const noFollow = typeof fileOperations.constants.O_NOFOLLOW === "number" + ? fileOperations.constants.O_NOFOLLOW + : 0; + let descriptor; + try { + descriptor = fileOperations.openSync( + path, + fileOperations.constants.O_RDONLY | noFollow + ); + const opened = fileOperations.fstatSync(descriptor); + if (!opened.isFile() || !sameIdentity(before, opened)) { + throw new Error("Codex configuration status identity changed."); + } + const bytes = Buffer.from(fileOperations.readFileSync(descriptor)); + const after = fileOperations.lstatSync(path); + if (!after.isFile() || after.isSymbolicLink() || !sameIdentity(opened, after)) { + throw new Error("Codex configuration status identity changed."); + } + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } finally { + if (descriptor !== undefined) fileOperations.closeSync(descriptor); + } +} + +function pathExists(path, fileOperations) { + try { + fileOperations.lstatSync(path); + return true; + } catch (error) { + if (error?.code === "ENOENT") return false; + throw error; + } +} + +function createCodexService({ + paths, + proxyUrl, + fileOperations, + bootstrapImpl, + historyRepair +}) { + const runExclusive = createSerialGate(); + const inspectStatus = () => { + let configured = false; + let historyRepairPending = false; + const operations = fileOperations ?? DEFAULT_FILE_OPERATIONS; + try { + const codexRoot = dirname(paths.codexConfigPath); + let rootBefore; + try { + rootBefore = operations.lstatSync(codexRoot); + } catch (error) { + if (error?.code === "ENOENT") { + return { + configured: false, + modelProvider: "OpenAI", + proxyUrl, + historyRepairPending: false + }; + } + throw error; + } + if (!rootBefore.isDirectory() || rootBefore.isSymbolicLink()) { + throw new Error("Codex configuration root is unsafe."); + } + const pendingOptions = { codexRoot }; + if (fileOperations !== undefined) pendingOptions.fileOperations = fileOperations; + historyRepairPending = historyRepair.hasPending(pendingOptions); + const configLockPath = `${paths.codexConfigPath}.crp.lock`; + const configLockedBeforeRead = pathExists(configLockPath, operations); + const text = readCodexConfigText(paths.codexConfigPath, operations); + const rootAfter = operations.lstatSync(codexRoot); + if (!rootAfter.isDirectory() || rootAfter.isSymbolicLink() + || !sameIdentity(rootBefore, rootAfter)) { + throw new Error("Codex configuration root identity changed."); + } + const configLockedAfterRead = pathExists(configLockPath, operations); + const pendingAfterRead = historyRepair.hasPending(pendingOptions); + historyRepairPending ||= pendingAfterRead; + const configLockedAfterPending = pathExists(configLockPath, operations); + if (text !== null && !configLockedBeforeRead && !configLockedAfterRead + && !configLockedAfterPending) { + const patchedText = patchCodexConfigText(text, proxyUrl); + const transition = planCodexProviderTransition({ + sourceExists: true, + sourceText: text, + targetText: patchedText, + targetProvider: "OpenAI", + targetBaseUrl: proxyUrl + }); + configured = patchedText === text + && transition.required === false + && !historyRepairPending; + } + } catch { + configured = false; + historyRepairPending = true; + } + return { + configured, + modelProvider: "OpenAI", + proxyUrl, + historyRepairPending + }; + }; + + return { + bootstrap() { + return runExclusive(async () => { + const options = { + configPath: paths.codexConfigPath, + proxyUrl, + historyRepair + }; + if (fileOperations !== undefined) options.fileOperations = fileOperations; + try { + return await bootstrapImpl(options); + } catch (error) { + throw projectCodexBootstrapError(error); + } + }); + }, + getStatus() { + return runExclusive(async () => inspectStatus()); + }, + runWhenReady(operation) { + if (typeof operation !== "function") { + return Promise.reject(new TypeError("Codex readiness operation is invalid.")); + } + return runExclusive(async () => { + const status = inspectStatus(); + if (status.configured !== true || status.historyRepairPending === true) { + throw codexNotReady(); + } + return await operation(); + }); + } + }; +} + +function createSettingsService({ registry, credentialStore }) { + return { + getSettings() { + return { + ...registry.getDocument().settings, + credentialBackend: credentialStore.backend ?? null + }; + } + }; +} + +function createDiagnosticsService({ activityStore, now }) { + return { + exportDiagnostics() { + return { + created: true, + generatedAt: now(), + eventCount: activityStore.list({ limit: 10_000 }).length + }; + } + }; +} + +function writeState(path, document, fileOperations, createId) { + const parent = dirname(path); + fileOperations.mkdirSync(parent, { recursive: true, mode: 0o700 }); + try { + fileOperations.chmodSync(parent, 0o700); + } catch { + // Windows ACL validation remains an L3 platform gate. + } + const bytes = Buffer.from(`${JSON.stringify(document, null, 2)}\n`, "utf8"); + const tempPath = resolve(parent, `.${basename(path)}.${createId()}.tmp`); + let descriptor; + try { + descriptor = fileOperations.openSync(tempPath, "wx", 0o600); + fileOperations.writeFileSync(descriptor, bytes); + fileOperations.fsyncSync(descriptor); + fileOperations.fchmodSync(descriptor, 0o600); + fileOperations.closeSync(descriptor); + descriptor = undefined; + fileOperations.renameSync(tempPath, path); + const identity = fileOperations.lstatSync(path); + return { bytes, identity }; + } catch (error) { + if (descriptor !== undefined) { + try { fileOperations.closeSync(descriptor); } catch {} + } + try { fileOperations.rmSync(tempPath, { force: true }); } catch {} + throw error; + } +} + +function sameIdentity(left, right) { + return left.dev === right.dev && left.ino === right.ino; +} + +function removeOwnedState(path, owned, fileOperations) { + if (!owned) return; + const claimPath = resolve(dirname(path), `${basename(path)}.stale`); + let before; + try { + before = fileOperations.lstatSync(path); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + try { + const claimed = fileOperations.lstatSync(claimPath); + if (sameIdentity(claimed, owned.identity) + && fileOperations.readFileSync(claimPath).equals(owned.bytes)) { + fileOperations.rmSync(claimPath); + } + } catch (claimError) { + if (claimError?.code !== "ENOENT") throw claimError; + } + return; + } + if (!sameIdentity(before, owned.identity)) return; + try { + const residual = fileOperations.lstatSync(claimPath); + if (!sameIdentity(residual, owned.identity) + || !fileOperations.readFileSync(claimPath).equals(owned.bytes)) { + throw new Error("Supervisor state cleanup marker is not owned by this process."); + } + fileOperations.rmSync(claimPath); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + fileOperations.renameSync(path, claimPath); + const claimed = fileOperations.lstatSync(claimPath); + if (!sameIdentity(claimed, owned.identity)) { + try { + fileOperations.linkSync(claimPath, path); + fileOperations.rmSync(claimPath); + } catch (error) { + if (error?.code !== "EEXIST") throw error; + } + return; + } + const bytes = fileOperations.readFileSync(claimPath); + if (!bytes.equals(owned.bytes)) { + try { + fileOperations.linkSync(claimPath, path); + fileOperations.rmSync(claimPath); + } catch (error) { + if (error?.code !== "EEXIST") throw error; + } + return; + } + fileOperations.rmSync(claimPath); +} + +export async function createSupervisor({ + home, + paths: providedPaths, + pid = process.pid, + now = () => new Date().toISOString(), + backend = "native", + fallbackConsent = false, + uiDir = DEFAULT_UI_DIR, + stateFileOperations = DEFAULT_FILE_OPERATIONS, + codexFileOperations, + bootstrapCodex = bootstrapCodexConfig, + codexHistoryRepair = DEFAULT_CODEX_HISTORY_REPAIR, + createStateId = randomUUID, + activityStoreFactory = (options) => new ActivityStore(options), + credentialStoreFactory = (options) => createCredentialStore(options), + migrate = migrateLegacyConfiguration, + registryFactory = (options) => new ProviderRegistry(options), + providerModelCacheFactory = (options) => new ProviderModelCache(options), + metricsStoreFactory = (options) => new MetricsStore(options), + workerManagerFactory = (options) => new WorkerManager(options), + providerServiceFactory = (options) => new ProviderService(options), + authFactory = (options) => new SessionAuth(options), + adminServerFactory = (options) => createAdminServer(options) +} = {}) { + const paths = completePaths(home, providedPaths); + const startedAt = now(); + const activityStore = activityStoreFactory({ path: paths.activityPath, now }); + const credentialStore = credentialStoreFactory({ + backend, + fallbackConsent, + paths + }); + await migrate({ paths, credentialStore, activityStore, now }); + const registry = registryFactory({ path: paths.registryPath, now }); + const modelCache = providerModelCacheFactory({ path: paths.modelCachePath, now }); + const settings = registry.getDocument().settings; + let workerManager; + let metricsStore; + let providerService; + let auth; + let codexService; + let settingsService; + let diagnosticsService; + let adminServer; + let address = null; + let ownedState = null; + let listenPromise = null; + let closePromise = null; + const close = () => { + if (closePromise) return closePromise; + const attempt = (async () => { + await workerManager.close(); + await adminServer.close(); + await auth.close(); + await metricsStore.close(); + removeOwnedState(paths.statePath, ownedState, stateFileOperations); + })(); + closePromise = attempt; + void attempt.catch(() => { + if (closePromise === attempt) closePromise = null; + }); + return attempt; + }; + const requestShutdown = () => close(); + try { + const proxyUrl = `http://${settings.proxyHost}:${settings.proxyPort}`; + codexService = createCodexService({ + paths, + proxyUrl, + fileOperations: codexFileOperations, + bootstrapImpl: bootstrapCodex, + historyRepair: codexHistoryRepair + }); + metricsStore = metricsStoreFactory({ path: paths.metricsPath, now }); + workerManager = workerManagerFactory({ + host: settings.proxyHost, + port: settings.proxyPort, + runRecoveryWhenReady: (operation) => codexService.runWhenReady(operation), + recordMetric: (observation) => metricsStore.record(observation), + noteDroppedMetric: () => metricsStore.noteDropped() + }); + providerService = providerServiceFactory({ + registry, + credentialStore, + activityStore, + workerManager, + modelCache, + now, + paths + }); + auth = authFactory({ controlTokenPath: paths.controlTokenPath }); + settingsService = createSettingsService({ registry, credentialStore }); + diagnosticsService = createDiagnosticsService({ activityStore, now }); + adminServer = adminServerFactory({ + auth, + providerService, + activityStore, + settingsService, + codexService, + diagnosticsService, + metricsService: metricsStore, + getSupervisorState: () => ({ pid, startedAt }), + requestSupervisorShutdown: requestShutdown, + uiDir, + host: settings.adminHost, + port: settings.adminPort + }); + } catch (error) { + try { await auth?.close?.(); } catch {} + try { await workerManager?.close?.(); } catch {} + try { await metricsStore?.close?.(); } catch {} + throw error; + } + + const getPublicState = () => ({ + supervisorPid: pid, + startedAt, + admin: address, + worker: publicWorkerState(workerManager.getPublicState()) + }); + return { + paths, + getPublicState, + listen() { + if (listenPromise) return listenPromise; + listenPromise = (async () => { + try { + address = await adminServer.listen(); + const state = { + schemaVersion: 1, + ...getPublicState() + }; + ownedState = writeState( + paths.statePath, + state, + stateFileOperations, + createStateId + ); + return { ...address }; + } catch (error) { + await close().catch(() => {}); + throw error; + } + })(); + return listenPromise; + }, + close, + requestShutdown + }; +} diff --git a/node/src/supervisor/worker-manager.mjs b/node/src/supervisor/worker-manager.mjs new file mode 100644 index 0000000..adec321 --- /dev/null +++ b/node/src/supervisor/worker-manager.mjs @@ -0,0 +1,891 @@ +import { fork } from "node:child_process"; +import { createServer } from "node:net"; +import { fileURLToPath } from "node:url"; + +import { + PROTOCOL_VERSION, + validateChildMessage, + validateParentMessage +} from "../worker/protocol.mjs"; + +const WORKER_ENTRY_PATH = fileURLToPath(new URL("../worker/worker-entry.mjs", import.meta.url)); +const CRASH_WINDOW_MS = 60_000; +const CRASH_BACKOFF_MS = Object.freeze([250, 500, 1_000, 2_000, 4_000]); +const MAX_METRIC_GENERATIONS = 256; +const MAX_PROVIDER_ID_CODE_POINTS = 128; +const PROVIDER_ID_CONTROL_PATTERN = /[\u0000-\u001f\u007f-\u009f]/; + +const REAL_CLOCK = Object.freeze({ + now: () => Date.now(), + setTimeout: (callback, delay) => setTimeout(callback, delay), + clearTimeout: (timer) => clearTimeout(timer) +}); + +const ERROR_MESSAGES = Object.freeze({ + WORKER_MANAGER_CLOSED: "Worker manager is closed.", + WORKER_READY_TIMEOUT: "Worker did not become ready in time.", + WORKER_ACK_TIMEOUT: "Worker did not acknowledge the request in time.", + WORKER_HEALTH_FAILED: "Worker health verification failed.", + WORKER_PROTOCOL_INVALID: "Worker protocol message is invalid.", + WORKER_START_FAILED: "Worker failed to start.", + WORKER_IPC_SEND_FAILED: "Worker IPC send failed.", + WORKER_NOT_RUNNING: "Worker is not running.", + WORKER_SNAPSHOT_INVALID: "Worker settings snapshot is invalid.", + WORKER_EXIT_TIMEOUT: "Worker did not exit in time.", + WORKER_STOP_FAILED: "Worker could not be stopped.", + WORKER_PORT_BUSY: "The fixed proxy port is still in use.", + WORKER_EXITED: "Proxy worker exited unexpectedly.", + WORKER_CONFIGURE_FAILED: "Worker configuration failed.", + WORKER_RUNTIME_FAILED: "Worker runtime failed.", + STALE_SNAPSHOT: "Worker rejected a stale settings snapshot.", + RUNTIME_SETTINGS_INVALID: "Worker settings are invalid." +}); + +function managerError(code) { + const error = new Error(ERROR_MESSAGES[code] ?? "Worker lifecycle operation failed."); + error.name = "WorkerManagerError"; + error.code = code; + return error; +} + +function defaultForkWorker(forkImpl = fork) { + return forkImpl(WORKER_ENTRY_PATH, [], { + execPath: process.execPath, + stdio: ["ignore", "ignore", "ignore", "ipc"], + windowsHide: true + }); +} + +function defaultWaitForPortFree(host, port) { + return new Promise((resolvePromise, rejectPromise) => { + const probe = createServer(); + probe.unref(); + probe.once("error", () => rejectPromise(managerError("WORKER_PORT_BUSY"))); + probe.listen({ host, port, exclusive: true }, () => { + probe.close((error) => { + if (error) rejectPromise(managerError("WORKER_PORT_BUSY")); + else resolvePromise(); + }); + }); + }); +} + +function publicStateCopy(state) { + return state ? { + phase: state.phase, + configured: state.configured, + generation: state.generation, + listening: state.listening, + listenHost: state.listenHost, + listenPort: state.listenPort, + inFlight: state.inFlight + } : null; +} + +function validProviderId(value) { + return typeof value === "string" + && value.length > 0 + && value.length <= MAX_PROVIDER_ID_CODE_POINTS * 2 + && [...value].length <= MAX_PROVIDER_ID_CODE_POINTS + && value.trim() === value + && !PROVIDER_ID_CONTROL_PATTERN.test(value); +} + +function optionalSnapshotProviderId(snapshot) { + if (!snapshot || !Object.hasOwn(snapshot, "providerId")) return null; + if (!validProviderId(snapshot.providerId)) throw managerError("WORKER_SNAPSHOT_INVALID"); + return snapshot.providerId; +} + +export class WorkerManager { + #host; + #port; + #clock; + #forkWorker; + #fetch; + #readyTimeoutMs; + #ackTimeoutMs; + #healthTimeoutMs; + #terminateTimeoutMs; + #killTimeoutMs; + #waitForPortFree; + #runRecoveryWhenReady; + #phase = "stopped"; + #child = null; + #epoch = 0; + #generation = 0; + #workerState = null; + #restartCount = 0; + #startedAt = null; + #lastError = null; + #lastSnapshot = null; + #requestSequence = 0; + #waiters = new Set(); + #exits = new Map(); + #expectedEpochs = new Set(); + #exitErrors = new Map(); + #crashTimes = []; + #recoveryTimer = null; + #recoveryVersion = 0; + #operation = null; + #closed = false; + #closePromise = null; + #recordMetric; + #noteDroppedMetric; + #metricProviders = new Map(); + + constructor({ + host = "127.0.0.1", + port = 15100, + clock = REAL_CLOCK, + forkWorker, + forkImpl = fork, + fetchImpl = globalThis.fetch, + readyTimeoutMs = 5_000, + ackTimeoutMs = 5_000, + healthTimeoutMs = 5_000, + terminateTimeoutMs = 1_000, + killTimeoutMs = 1_000, + waitForPortFree = defaultWaitForPortFree, + runRecoveryWhenReady, + recordMetric = () => true, + noteDroppedMetric = () => {} + } = {}) { + if (typeof runRecoveryWhenReady !== "function") { + throw new TypeError("Worker recovery operation runner is required."); + } + if (typeof recordMetric !== "function" || typeof noteDroppedMetric !== "function") { + throw new TypeError("Worker metrics callbacks are invalid."); + } + this.#host = host; + this.#port = port; + this.#clock = clock; + this.#forkWorker = forkWorker === undefined + ? () => defaultForkWorker(forkImpl) + : forkWorker; + this.#fetch = fetchImpl; + this.#readyTimeoutMs = readyTimeoutMs; + this.#ackTimeoutMs = ackTimeoutMs; + this.#healthTimeoutMs = healthTimeoutMs; + this.#terminateTimeoutMs = terminateTimeoutMs; + this.#killTimeoutMs = killTimeoutMs; + this.#waitForPortFree = waitForPortFree; + this.#runRecoveryWhenReady = runRecoveryWhenReady; + this.#recordMetric = recordMetric; + this.#noteDroppedMetric = noteDroppedMetric; + } + + getPublicState() { + return { + phase: this.#phase, + pid: this.#child?.pid ?? null, + generation: this.#generation, + state: publicStateCopy(this.#workerState), + restartCount: this.#restartCount, + startedAt: this.#startedAt, + error: this.#lastError ? { ...this.#lastError } : null + }; + } + + start(snapshot) { + if (this.#closed) { + return Promise.reject(managerError("WORKER_MANAGER_CLOSED")); + } + if (this.#operation) { + return this.#operation; + } + if (this.#phase === "running") { + return Promise.resolve(this.getPublicState()); + } + if (this.#child) { + return Promise.reject(managerError("WORKER_STOP_FAILED")); + } + return this.#trackOperation(this.#performStart(snapshot)); + } + + applySnapshot(snapshot) { + if (this.#closed) { + return Promise.reject(managerError("WORKER_MANAGER_CLOSED")); + } + if (this.#operation) { + return this.#operation; + } + if (this.#phase !== "running" || !this.#child) { + return Promise.reject(managerError("WORKER_NOT_RUNNING")); + } + return this.#trackOperation(this.#performApplySnapshot(snapshot)); + } + + stop({ drainTimeoutMs = 5_000 } = {}) { + if (this.#closed) { + return Promise.reject(managerError("WORKER_MANAGER_CLOSED")); + } + if (this.#operation) return this.#operation; + if (!this.#child || this.#phase === "stopped") { + this.#cancelRecovery(); + this.#phase = "stopped"; + this.#workerState = null; + return Promise.resolve(this.getPublicState()); + } + return this.#trackOperation(this.#performStop({ drainTimeoutMs })); + } + + restart(snapshot, { drainTimeoutMs = 5_000 } = {}) { + if (this.#closed) { + return Promise.reject(managerError("WORKER_MANAGER_CLOSED")); + } + if (this.#operation) return this.#operation; + let validatedSnapshot; + try { + validatedSnapshot = this.#validateSnapshot(snapshot); + } catch (error) { + return Promise.reject(error); + } + return this.#trackOperation(this.#performRestart(validatedSnapshot, { drainTimeoutMs })); + } + + close() { + if (this.#closePromise) return this.#closePromise; + this.#closed = true; + this.#cancelRecovery(); + const attempt = (this.#operation ?? Promise.resolve()) + .catch(() => {}) + .then(() => this.#performClose()); + this.#closePromise = attempt; + void attempt.catch(() => { + if (this.#closePromise === attempt && this.#child) this.#closePromise = null; + }); + return attempt; + } + + async #performClose() { + const child = this.#child; + const epoch = this.#epoch; + this.#rejectWaiters(epoch, managerError("WORKER_MANAGER_CLOSED")); + let failure = null; + if (child) { + this.#expectedEpochs.add(epoch); + try { + await this.#terminateChild(child, epoch); + await this.#waitForPortFree(this.#host, this.#port); + } catch (error) { + const code = error?.code === "WORKER_PORT_BUSY" ? "WORKER_PORT_BUSY" : "WORKER_STOP_FAILED"; + failure = managerError(code); + } finally { + if (this.#hasExited(child)) this.#releaseChild(child, epoch); + } + } else { + for (const staleEpoch of this.#exits.keys()) { + this.#detachChild(staleEpoch); + this.#exits.delete(staleEpoch); + } + } + this.#workerState = null; + if (failure) { + this.#phase = "failed"; + this.#lastError = { code: failure.code, message: failure.message }; + throw failure; + } + this.#phase = "stopped"; + return this.getPublicState(); + } + + async #performStart(snapshot) { + const providerId = optionalSnapshotProviderId(snapshot); + const requestId = this.#nextRequestId("configure"); + const configureMessage = { + version: PROTOCOL_VERSION, + type: "configure", + requestId, + generation: snapshot?.generation, + settings: snapshot?.settings + }; + validateParentMessage(configureMessage); + if (snapshot.settings.server.host !== this.#host || snapshot.settings.server.port !== this.#port) { + throw managerError("WORKER_START_FAILED"); + } + this.#rememberMetricProvider(snapshot.generation, providerId); + + this.#phase = "starting"; + let child; + try { + child = this.#forkWorker(); + } catch { + const error = managerError("WORKER_START_FAILED"); + this.#lastError = { code: error.code, message: error.message }; + this.#phase = "stopped"; + throw error; + } + const epoch = ++this.#epoch; + this.#child = child; + this.#attachChild(child, epoch); + + try { + const ready = await this.#waitForMessage({ + epoch, + requestId: "worker-ready", + type: "ready", + timeoutMs: this.#readyTimeoutMs, + timeoutCode: "WORKER_READY_TIMEOUT" + }); + if (ready.state.phase !== "ready" || ready.state.configured || ready.state.generation !== 0) { + throw managerError("WORKER_PROTOCOL_INVALID"); + } + + const configured = await this.#sendAndWait(child, configureMessage, { + epoch, + requestId, + type: "configured", + timeoutMs: this.#ackTimeoutMs, + timeoutCode: "WORKER_ACK_TIMEOUT" + }); + if (configured.state.phase !== "running" + || configured.state.generation !== snapshot.generation + || configured.state.listenHost !== this.#host + || configured.state.listenPort !== this.#port) { + throw managerError("WORKER_PROTOCOL_INVALID"); + } + + await this.#verifyHealth(snapshot.generation); + if (epoch !== this.#epoch || child !== this.#child) { + throw managerError("WORKER_START_FAILED"); + } + this.#workerState = publicStateCopy(configured.state); + this.#generation = snapshot.generation; + this.#lastSnapshot = structuredClone(snapshot); + this.#phase = "running"; + this.#startedAt = new Date(this.#clock.now()).toISOString(); + return this.getPublicState(); + } catch (error) { + this.#lastError = { + code: error?.code && ERROR_MESSAGES[error.code] ? error.code : "WORKER_START_FAILED", + message: ERROR_MESSAGES[error?.code] ?? ERROR_MESSAGES.WORKER_START_FAILED + }; + this.#expectedEpochs.add(epoch); + let cleanupError = null; + try { + await this.#terminateChild(child, epoch); + await this.#waitForPortFree(this.#host, this.#port); + } catch (cleanupFailure) { + cleanupError = cleanupFailure; + } + this.#workerState = null; + if (this.#hasExited(child)) { + this.#releaseChild(child, epoch); + if (cleanupError) { + const code = cleanupError?.code === "WORKER_PORT_BUSY" + ? "WORKER_PORT_BUSY" + : "WORKER_STOP_FAILED"; + const publicCleanupError = managerError(code); + this.#lastError = { + code: publicCleanupError.code, + message: publicCleanupError.message + }; + this.#phase = "failed"; + } else { + this.#phase = "stopped"; + } + } else { + this.#phase = "failed"; + } + throw error?.code ? error : managerError("WORKER_START_FAILED"); + } + } + + async #performApplySnapshot(snapshot) { + const child = this.#child; + const epoch = this.#epoch; + const requestId = this.#nextRequestId("configure"); + const message = { + version: PROTOCOL_VERSION, + type: "configure", + requestId, + generation: snapshot?.generation, + settings: snapshot?.settings + }; + try { + validateParentMessage(message); + optionalSnapshotProviderId(snapshot); + } catch { + throw managerError("WORKER_SNAPSHOT_INVALID"); + } + if (snapshot.generation <= this.#generation + || snapshot.settings.server.host !== this.#host + || snapshot.settings.server.port !== this.#port) { + throw managerError("WORKER_SNAPSHOT_INVALID"); + } + this.#rememberMetricProvider(snapshot.generation, snapshot.providerId ?? null); + const configured = await this.#sendAndWait(child, message, { + epoch, + requestId, + type: "configured", + timeoutMs: this.#ackTimeoutMs, + timeoutCode: "WORKER_ACK_TIMEOUT" + }); + if (epoch !== this.#epoch + || child !== this.#child + || configured.state.phase !== "running" + || configured.state.generation !== snapshot.generation + || configured.state.listenHost !== this.#host + || configured.state.listenPort !== this.#port) { + throw managerError("WORKER_PROTOCOL_INVALID"); + } + this.#workerState = publicStateCopy(configured.state); + this.#generation = snapshot.generation; + this.#lastSnapshot = structuredClone(snapshot); + return this.getPublicState(); + } + + async #performStop({ drainTimeoutMs }) { + const child = this.#child; + const epoch = this.#epoch; + this.#expectedEpochs.add(epoch); + this.#phase = "draining"; + + let drained = false; + let drainFailure = null; + const requestId = this.#nextRequestId("drain"); + try { + const message = await this.#sendAndWait( + child, + { version: PROTOCOL_VERSION, type: "drain", requestId }, + { + epoch, + requestId, + type: "drained", + timeoutMs: drainTimeoutMs, + timeoutCode: "WORKER_ACK_TIMEOUT" + } + ); + if (message.state.phase !== "drained" + || message.state.generation !== this.#generation + || message.state.listening) { + throw managerError("WORKER_PROTOCOL_INVALID"); + } + this.#workerState = publicStateCopy(message.state); + drained = true; + } catch (error) { + if (error?.code === "WORKER_IPC_SEND_FAILED") drainFailure = error; + // Escalation below owns cleanup after a bounded drain failure. + } + + let failure = null; + try { + if (drained) { + const shutdownRequestId = this.#nextRequestId("shutdown"); + try { + await this.#send(child, { + version: PROTOCOL_VERSION, + type: "shutdown", + requestId: shutdownRequestId + }); + await this.#waitForExit(epoch, this.#terminateTimeoutMs); + } catch { + await this.#terminateChild(child, epoch); + } + } else { + await this.#terminateChild(child, epoch); + } + await this.#waitForPortFree(this.#host, this.#port); + } catch (error) { + const code = error?.code === "WORKER_PORT_BUSY" ? "WORKER_PORT_BUSY" : "WORKER_STOP_FAILED"; + failure = managerError(code); + this.#lastError = { code: failure.code, message: failure.message }; + } finally { + if (this.#hasExited(child)) { + this.#releaseChild(child, epoch); + this.#workerState = null; + } + } + + if (failure) { + this.#phase = "failed"; + throw failure; + } + this.#phase = "stopped"; + if (drainFailure) { + this.#lastError = { code: drainFailure.code, message: drainFailure.message }; + throw drainFailure; + } + return this.getPublicState(); + } + + async #performRestart(snapshot, { drainTimeoutMs }) { + this.#cancelRecovery(); + if (this.#child && this.#phase !== "stopped") { + await this.#performStop({ drainTimeoutMs }); + this.#restartCount += 1; + } + return this.#performStart(snapshot); + } + + #validateSnapshot(snapshot) { + let cloned; + try { + cloned = structuredClone(snapshot); + validateParentMessage({ + version: PROTOCOL_VERSION, + type: "configure", + requestId: "snapshot-validation", + generation: cloned?.generation, + settings: cloned?.settings + }); + optionalSnapshotProviderId(cloned); + } catch { + throw managerError("WORKER_SNAPSHOT_INVALID"); + } + if (cloned.settings.server.host !== this.#host || cloned.settings.server.port !== this.#port) { + throw managerError("WORKER_SNAPSHOT_INVALID"); + } + return cloned; + } + + async #terminateChild(child, epoch) { + if (child.exitCode !== null || child.signalCode !== null) return; + child.kill("SIGTERM"); + try { + await this.#waitForExit(epoch, this.#terminateTimeoutMs); + return; + } catch { + // A stuck worker receives one final bounded escalation. + } + child.kill("SIGKILL"); + try { + await this.#waitForExit(epoch, this.#killTimeoutMs); + } catch { + throw managerError("WORKER_STOP_FAILED"); + } + } + + #hasExited(child) { + return child.exitCode !== null || child.signalCode !== null; + } + + #releaseChild(child, epoch) { + if (this.#child === child) this.#child = null; + this.#detachChild(epoch); + this.#exits.delete(epoch); + this.#expectedEpochs.delete(epoch); + this.#exitErrors.delete(epoch); + } + + #attachChild(child, epoch) { + let resolveExit; + const exitPromise = new Promise((resolvePromise) => { + resolveExit = resolvePromise; + }); + const onMessage = (rawMessage) => { + if (epoch !== this.#epoch || child !== this.#child) return; + let message; + try { + message = validateChildMessage(rawMessage); + } catch { + const error = managerError("WORKER_PROTOCOL_INVALID"); + this.#lastError = { code: error.code, message: error.message }; + this.#rejectWaiters(epoch, error); + if (this.#phase === "running") { + this.#exitErrors.set(epoch, this.#lastError); + void this.#terminateChild(child, epoch).catch(() => { + if (epoch !== this.#epoch || child !== this.#child) return; + const stopError = managerError("WORKER_STOP_FAILED"); + this.#lastError = { code: stopError.code, message: stopError.message }; + this.#phase = "failed"; + }); + } + return; + } + if (message.type === "fatal") { + const error = managerError(message.error.code); + this.#lastError = { code: error.code, message: error.message }; + for (const waiter of [...this.#waiters]) { + if (waiter.epoch === epoch + && (waiter.requestId === message.requestId || message.requestId === "worker-fatal")) { + waiter.reject(error); + } + } + return; + } + if (message.type === "metric") { + this.#acceptMetric(message.observation); + return; + } + for (const waiter of [...this.#waiters]) { + if (waiter.epoch === epoch + && waiter.requestId === message.requestId + && waiter.type === message.type) { + waiter.resolve(message); + } + } + }; + const onError = () => { + this.#rejectWaiters(epoch, managerError("WORKER_START_FAILED")); + }; + const onExit = (code, signal) => { + const exit = this.#exits.get(epoch); + if (exit && !exit.settled) { + exit.settled = true; + exit.result = { code, signal }; + resolveExit(exit.result); + } + this.#rejectWaiters(epoch, managerError("WORKER_START_FAILED")); + if (epoch === this.#epoch + && child === this.#child + && !this.#expectedEpochs.has(epoch) + && !this.#closed + && this.#phase === "running") { + this.#child = null; + this.#workerState = null; + const exitError = this.#exitErrors.get(epoch); + this.#exitErrors.delete(epoch); + this.#scheduleRecovery(exitError); + this.#detachChild(epoch); + this.#exits.delete(epoch); + } + }; + this.#exits.set(epoch, { + promise: exitPromise, + settled: false, + child, + onMessage, + onError, + onExit + }); + child.on("message", onMessage); + child.once("error", onError); + child.once("exit", onExit); + } + + #detachChild(epoch) { + const context = this.#exits.get(epoch); + if (!context) return; + context.child.off("message", context.onMessage); + context.child.off("error", context.onError); + context.child.off("exit", context.onExit); + } + + #scheduleRecovery(exitError = null) { + const now = this.#clock.now(); + this.#phase = "crashed"; + this.#lastError = exitError ? { ...exitError } : { + code: "WORKER_EXITED", + message: ERROR_MESSAGES.WORKER_EXITED + }; + this.#crashTimes = this.#crashTimes + .filter((crashedAt) => now - crashedAt < CRASH_WINDOW_MS); + this.#crashTimes.push(now); + if (this.#crashTimes.length >= CRASH_BACKOFF_MS.length) { + this.#phase = "failed"; + return; + } + + const delay = CRASH_BACKOFF_MS[this.#crashTimes.length - 1]; + const snapshot = structuredClone(this.#lastSnapshot); + this.#phase = "backoff"; + this.#restartCount += 1; + const recoveryVersion = ++this.#recoveryVersion; + this.#recoveryTimer = this.#clock.setTimeout(() => { + if (recoveryVersion !== this.#recoveryVersion) return; + this.#recoveryTimer = null; + if (!this.#canRecover(recoveryVersion)) return; + let attemptStarted = false; + const recovery = Promise.resolve() + .then(() => this.#waitForPortFree(this.#host, this.#port)) + .then(() => { + if (!this.#canRecover(recoveryVersion)) return undefined; + return this.#runRecoveryWhenReady(() => { + if (!this.#canRecover(recoveryVersion)) return this.getPublicState(); + attemptStarted = true; + return this.#trackOperation(this.#performStart(snapshot)); + }); + }); + void recovery.catch(() => { + if (recoveryVersion === this.#recoveryVersion + && !this.#closed + && this.#child === null + && (this.#phase === "backoff" || attemptStarted)) { + this.#scheduleRecovery(); + } + }); + }, delay); + } + + #canRecover(recoveryVersion) { + return recoveryVersion === this.#recoveryVersion + && !this.#closed + && this.#phase === "backoff" + && this.#child === null; + } + + #cancelRecovery() { + this.#recoveryVersion += 1; + if (this.#recoveryTimer !== null) { + this.#clock.clearTimeout(this.#recoveryTimer); + this.#recoveryTimer = null; + } + } + + #rememberMetricProvider(generation, providerId) { + if (!validProviderId(providerId)) return; + this.#metricProviders.delete(generation); + this.#metricProviders.set(generation, providerId); + while (this.#metricProviders.size > MAX_METRIC_GENERATIONS) { + this.#metricProviders.delete(this.#metricProviders.keys().next().value); + } + } + + #acceptMetric(observation) { + const providerId = this.#metricProviders.get(observation.generation); + if (!providerId) { + this.#dropMetric(); + return; + } + const { generation: _generation, ...fields } = observation; + try { + const result = this.#recordMetric({ providerId, ...fields }); + if (result && typeof result.then === "function") { + void result.then((accepted) => { + if (accepted === false) this.#dropMetric(); + }, () => this.#dropMetric()); + } else if (result === false) { + this.#dropMetric(); + } + } catch { + this.#dropMetric(); + } + } + + #dropMetric() { + try { + this.#noteDroppedMetric(); + } catch { + // Metrics degradation must remain outside worker lifecycle state. + } + } + + #waitForExit(epoch, timeoutMs) { + const exit = this.#exits.get(epoch); + if (!exit) return Promise.reject(managerError("WORKER_STOP_FAILED")); + if (exit.settled) return Promise.resolve(exit.result); + return this.#withTimeout(exit.promise, timeoutMs, "WORKER_EXIT_TIMEOUT"); + } + + #waitForMessage({ epoch, requestId, type, timeoutMs, timeoutCode }) { + return this.#registerMessageWaiter({ + epoch, + requestId, + type, + timeoutMs, + timeoutCode + }).promise; + } + + #registerMessageWaiter({ epoch, requestId, type, timeoutMs, timeoutCode }) { + const waiter = { + epoch, + requestId, + type, + timer: null, + settled: false, + resolve: null, + reject: null + }; + const promise = new Promise((resolvePromise, rejectPromise) => { + const settle = (callback, value) => { + if (waiter.settled) return; + waiter.settled = true; + this.#waiters.delete(waiter); + this.#clock.clearTimeout(waiter.timer); + callback(value); + }; + waiter.resolve = (value) => settle(resolvePromise, value); + waiter.reject = (error) => settle(rejectPromise, error); + }); + waiter.timer = this.#clock.setTimeout( + () => waiter.reject(managerError(timeoutCode)), + timeoutMs + ); + this.#waiters.add(waiter); + return { + promise, + cancel: (error) => waiter.reject(error) + }; + } + + async #sendAndWait(child, message, waitFor) { + const waiter = this.#registerMessageWaiter(waitFor); + const observed = waiter.promise.then( + (value) => ({ value }), + (error) => ({ error }) + ); + try { + await this.#send(child, message); + } catch (error) { + waiter.cancel(error); + await observed; + throw error; + } + const result = await observed; + if (result.error) throw result.error; + return result.value; + } + + #rejectWaiters(epoch, error) { + for (const waiter of [...this.#waiters]) { + if (waiter.epoch !== epoch) continue; + waiter.reject(error); + } + } + + #send(child, message) { + validateParentMessage(message); + return new Promise((resolvePromise, rejectPromise) => { + if (!child.connected) { + rejectPromise(managerError("WORKER_IPC_SEND_FAILED")); + return; + } + child.send(message, (error) => { + if (error) rejectPromise(managerError("WORKER_IPC_SEND_FAILED")); + else resolvePromise(); + }); + }); + } + + async #verifyHealth(generation) { + const healthPromise = Promise.resolve() + .then(() => this.#fetch(`http://${this.#host}:${this.#port}/_proxy/health`)) + .then(async (response) => { + if (!response?.ok) throw managerError("WORKER_HEALTH_FAILED"); + const health = await response.json(); + if (health?.configured !== true || health?.generation !== generation) { + throw managerError("WORKER_HEALTH_FAILED"); + } + }); + await this.#withTimeout(healthPromise, this.#healthTimeoutMs, "WORKER_HEALTH_FAILED"); + } + + #withTimeout(promise, timeoutMs, code) { + let timer; + const timeout = new Promise((_, rejectPromise) => { + timer = this.#clock.setTimeout(() => rejectPromise(managerError(code)), timeoutMs); + }); + return Promise.race([promise, timeout]).finally(() => this.#clock.clearTimeout(timer)); + } + + #nextRequestId(prefix) { + this.#requestSequence += 1; + return `${prefix}-${this.#requestSequence}`; + } + + #trackOperation(operation) { + this.#operation = operation; + void operation.then( + () => { + if (this.#operation === operation) this.#operation = null; + }, + () => { + if (this.#operation === operation) this.#operation = null; + } + ); + return operation; + } +} diff --git a/node/src/worker/protocol.mjs b/node/src/worker/protocol.mjs new file mode 100644 index 0000000..83f3b8f --- /dev/null +++ b/node/src/worker/protocol.mjs @@ -0,0 +1,420 @@ +import { validateHeaderValue } from "node:http"; + +export const PROTOCOL_VERSION = 1; + +const PARENT_TYPES = new Set(["configure", "drain", "shutdown", "status"]); +const CHILD_STATE_TYPES = new Set(["ready", "configured", "drained", "status"]); +const CHILD_TYPES = new Set([...CHILD_STATE_TYPES, "fatal", "metric"]); +const BASE_FIELDS = new Set(["version", "type", "requestId"]); +const CONFIGURE_FIELDS = new Set([...BASE_FIELDS, "generation", "settings"]); +const CHILD_STATE_FIELDS = new Set([...BASE_FIELDS, "state"]); +const FATAL_FIELDS = new Set([...BASE_FIELDS, "error"]); +const METRIC_FIELDS = new Set([...BASE_FIELDS, "observation"]); +const STATE_FIELDS = new Set([ + "phase", + "configured", + "generation", + "listening", + "listenHost", + "listenPort", + "inFlight" +]); +const ERROR_FIELDS = new Set(["code", "message"]); +const METRIC_OBSERVATION_FIELDS = new Set([ + "generation", + "result", + "model", + "inputTokens", + "outputTokens", + "durationBin", + "responseStartBin" +]); +const SETTINGS_FIELDS = new Set(["configPath", "server", "upstream", "proxy", "capture"]); +const SERVER_FIELDS = new Set(["host", "port", "logLevel"]); +const UPSTREAM_FIELDS = new Set([ + "baseUrl", + "apiKey", + "timeoutMs", + "verifySsl", + "authHeader", + "authScheme", + "extraHeaders" +]); +const PROXY_FIELDS = new Set(["overrideAuthorization", "requestIdHeader"]); +const CAPTURE_FIELDS = new Set(["enabled", "dbPath"]); +const WORKER_PHASES = new Set(["ready", "running", "draining", "drained", "stopping", "failed"]); +const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; +const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/; +const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/; +const METRIC_TEXT_CONTROL_PATTERN = /[\u0000-\u001f\u007f-\u009f]/; +const METRIC_RESULTS = new Set([ + "success", + "upstreamRejected", + "upstreamError", + "timeout", + "networkError", + "clientAbort" +]); +const METRIC_MAX_MODEL_CODE_POINTS = 256; +const METRIC_MAX_OBSERVATION_TOKENS = 100_000_000; +const METRIC_LATENCY_BIN_COUNT = 13; +const SENSITIVE_HEADER_TERMS = [ + "authorization", + "cookie", + "token", + "secret", + "apikey" +]; + +const FATAL_MESSAGES = new Map([ + ["WORKER_PROTOCOL_INVALID", "Worker protocol message is invalid."], + ["WORKER_CONFIGURE_FAILED", "Worker configuration failed."], + ["WORKER_START_FAILED", "Worker failed to start."], + ["WORKER_RUNTIME_FAILED", "Worker runtime failed."], + ["STALE_SNAPSHOT", "Worker rejected a stale settings snapshot."], + ["RUNTIME_SETTINGS_INVALID", "Worker settings are invalid."] +]); + +function protocolError() { + const error = new Error("Worker protocol message is invalid."); + error.name = "WorkerProtocolError"; + error.code = "WORKER_PROTOCOL_INVALID"; + return error; +} + +function isPlainObject(value) { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function hasExactFields(value, fields) { + const keys = Object.keys(value); + return keys.length === fields.size && keys.every((key) => fields.has(key)); +} + +function isRequestId(value) { + return typeof value === "string" && REQUEST_ID_PATTERN.test(value); +} + +function isNonEmptyString(value) { + return typeof value === "string" + && value.length > 0 + && !CONTROL_CHARACTER_PATTERN.test(value); +} + +function isValidHeaderName(value) { + return isNonEmptyString(value) && HEADER_NAME_PATTERN.test(value); +} + +function isSensitiveHeaderName(name) { + const compact = name.toLowerCase().replace(/[^a-z0-9]/g, ""); + return SENSITIVE_HEADER_TERMS.some((term) => compact.includes(term)); +} + +function isValidExtraHeaders(value, authHeader) { + if (!isPlainObject(value)) { + return false; + } + for (const [name, headerValue] of Object.entries(value)) { + if (!isValidHeaderName(name) + || isSensitiveHeaderName(name) + || name.toLowerCase() === authHeader.toLowerCase() + || typeof headerValue !== "string") { + return false; + } + try { + validateHeaderValue(name, headerValue); + } catch { + return false; + } + } + return true; +} + +function isValidAuthenticationHeader(upstream) { + const scheme = upstream.authScheme.trim(); + const value = scheme ? `${scheme} ${upstream.apiKey}` : upstream.apiKey; + try { + validateHeaderValue(upstream.authHeader, value); + return true; + } catch { + return false; + } +} + +function isValidBaseUrl(value) { + if (!isNonEmptyString(value)) { + return false; + } + try { + const parsed = new URL(value); + return (parsed.protocol === "http:" || parsed.protocol === "https:") + && parsed.username === "" + && parsed.password === "" + && !authorityContainsUserInfo(value) + && (parsed.protocol === "https:" || isLoopbackHostname(parsed.hostname)); + } catch { + return false; + } +} + +function authorityContainsUserInfo(value) { + const authorityStart = value.indexOf("://"); + if (authorityStart === -1) { + return false; + } + const remainder = value.slice(authorityStart + 3); + const authorityEnd = remainder.search(/[/?#]/); + const authority = authorityEnd === -1 ? remainder : remainder.slice(0, authorityEnd); + return authority.includes("@"); +} + +function isLoopbackHostname(hostname) { + const lower = hostname.toLowerCase(); + if (lower === "localhost" || lower === "::1" || lower === "[::1]") { + return true; + } + const octets = lower.split("."); + if (octets.length !== 4 || octets.some((octet) => !/^\d{1,3}$/.test(octet))) { + return false; + } + const numbers = octets.map(Number); + return numbers[0] === 127 && numbers.every((octet) => octet >= 0 && octet <= 255); +} + +function validateRuntimeSettings(settings) { + if (!isPlainObject(settings) + || !hasExactFields(settings, SETTINGS_FIELDS) + || !isNonEmptyString(settings.configPath) + || !isPlainObject(settings.server) + || !hasExactFields(settings.server, SERVER_FIELDS) + || settings.server.host !== "127.0.0.1" + || !Number.isInteger(settings.server.port) + || settings.server.port < 0 + || settings.server.port > 65535 + || !isNonEmptyString(settings.server.logLevel) + || !isPlainObject(settings.upstream) + || !hasExactFields(settings.upstream, UPSTREAM_FIELDS) + || !isValidBaseUrl(settings.upstream.baseUrl) + || typeof settings.upstream.apiKey !== "string" + || !Number.isFinite(settings.upstream.timeoutMs) + || settings.upstream.timeoutMs <= 0 + || typeof settings.upstream.verifySsl !== "boolean" + || !isValidHeaderName(settings.upstream.authHeader) + || typeof settings.upstream.authScheme !== "string" + || CONTROL_CHARACTER_PATTERN.test(settings.upstream.authScheme) + || (settings.upstream.authScheme !== "" + && !HEADER_NAME_PATTERN.test(settings.upstream.authScheme)) + || !isValidExtraHeaders(settings.upstream.extraHeaders, settings.upstream.authHeader) + || !isPlainObject(settings.proxy) + || !hasExactFields(settings.proxy, PROXY_FIELDS) + || typeof settings.proxy.overrideAuthorization !== "boolean" + || !isValidHeaderName(settings.proxy.requestIdHeader) + || (settings.proxy.overrideAuthorization + && !isValidAuthenticationHeader(settings.upstream)) + || (settings.proxy.overrideAuthorization && settings.upstream.apiKey.length === 0) + || !isPlainObject(settings.capture) + || !hasExactFields(settings.capture, CAPTURE_FIELDS) + || typeof settings.capture.enabled !== "boolean" + || !isNonEmptyString(settings.capture.dbPath)) { + throw protocolError(); + } + return settings; +} + +function validateBase(message, allowedTypes) { + if (!isPlainObject(message) + || message.version !== PROTOCOL_VERSION + || !allowedTypes.has(message.type) + || !isRequestId(message.requestId)) { + throw protocolError(); + } +} + +function isListenHost(value) { + return value === null + || (typeof value === "string" + && value.length > 0 + && value.length <= 255 + && !CONTROL_CHARACTER_PATTERN.test(value)); +} + +function isListenPort(value) { + return value === null || (Number.isInteger(value) && value >= 0 && value <= 65535); +} + +function validateState(state, { exact = true } = {}) { + if (!isPlainObject(state) + || (exact && !hasExactFields(state, STATE_FIELDS)) + || !WORKER_PHASES.has(state.phase) + || typeof state.configured !== "boolean" + || !Number.isSafeInteger(state.generation) + || state.generation < 0 + || typeof state.listening !== "boolean" + || !isListenHost(state.listenHost) + || !isListenPort(state.listenPort) + || !Number.isSafeInteger(state.inFlight) + || state.inFlight < 0) { + throw protocolError(); + } + return state; +} + +function validateFatalError(error) { + if (!isPlainObject(error) || !hasExactFields(error, ERROR_FIELDS)) { + throw protocolError(); + } + const expectedMessage = FATAL_MESSAGES.get(error.code); + if (!expectedMessage || error.message !== expectedMessage) { + throw protocolError(); + } + return error; +} + +function isBoundedMetricModel(value) { + return typeof value === "string" + && value.length > 0 + && value.length <= METRIC_MAX_MODEL_CODE_POINTS * 2 + && [...value].length <= METRIC_MAX_MODEL_CODE_POINTS + && value.trim() === value + && !METRIC_TEXT_CONTROL_PATTERN.test(value); +} + +function isMetricTokenCount(value) { + return Number.isSafeInteger(value) + && value >= 0 + && value <= METRIC_MAX_OBSERVATION_TOKENS; +} + +function isMetricLatencyBin(value) { + return Number.isInteger(value) && value >= 0 && value < METRIC_LATENCY_BIN_COUNT; +} + +function validateMetricObservation(observation) { + const noUsage = observation?.inputTokens === null && observation?.outputTokens === null; + const completeUsage = isMetricTokenCount(observation?.inputTokens) + && isMetricTokenCount(observation?.outputTokens); + if (!isPlainObject(observation) + || !hasExactFields(observation, METRIC_OBSERVATION_FIELDS) + || !Number.isSafeInteger(observation.generation) + || observation.generation <= 0 + || !METRIC_RESULTS.has(observation.result) + || (observation.model !== null && !isBoundedMetricModel(observation.model)) + || (!noUsage && !completeUsage) + || !isMetricLatencyBin(observation.durationBin) + || (observation.responseStartBin !== null + && !isMetricLatencyBin(observation.responseStartBin))) { + throw protocolError(); + } + return observation; +} + +function projectState(state) { + try { + validateState(state, { exact: false }); + } catch { + return undefined; + } + return { + phase: state.phase, + configured: state.configured, + generation: state.generation, + listening: state.listening, + listenHost: state.listenHost, + listenPort: state.listenPort, + inFlight: state.inFlight + }; +} + +export function validateParentMessage(message) { + validateBase(message, PARENT_TYPES); + if (message.type === "configure") { + if (!hasExactFields(message, CONFIGURE_FIELDS) + || !Number.isSafeInteger(message.generation) + || message.generation <= 0 + || !isPlainObject(message.settings)) { + throw protocolError(); + } + validateRuntimeSettings(message.settings); + return message; + } + if (!hasExactFields(message, BASE_FIELDS)) { + throw protocolError(); + } + return message; +} + +export function validateChildMessage(message) { + validateBase(message, CHILD_TYPES); + if (CHILD_STATE_TYPES.has(message.type)) { + if (!hasExactFields(message, CHILD_STATE_FIELDS)) { + throw protocolError(); + } + validateState(message.state); + return message; + } + if (message.type === "metric") { + if (!hasExactFields(message, METRIC_FIELDS) + || message.requestId !== "metric-observation") { + throw protocolError(); + } + validateMetricObservation(message.observation); + return message; + } + if (!hasExactFields(message, FATAL_FIELDS)) { + throw protocolError(); + } + validateFatalError(message.error); + return message; +} + +export function sanitizeProtocolMessage(message) { + if (!isPlainObject(message) + || message.version !== PROTOCOL_VERSION + || (!PARENT_TYPES.has(message.type) && !CHILD_TYPES.has(message.type)) + || !isRequestId(message.requestId) + || (message.type === "metric" && message.requestId !== "metric-observation")) { + return {}; + } + + const result = { + version: PROTOCOL_VERSION, + type: message.type, + requestId: message.requestId + }; + if (message.type === "configure" && Number.isSafeInteger(message.generation) && message.generation > 0) { + result.generation = message.generation; + } + if (CHILD_STATE_TYPES.has(message.type)) { + const state = projectState(message.state); + if (state) { + result.state = state; + } + } + if (message.type === "fatal" && isPlainObject(message.error)) { + const messageText = FATAL_MESSAGES.get(message.error.code); + if (messageText) { + result.error = { + code: message.error.code, + message: messageText + }; + } + } + return result; +} + +export function createFatalMessage({ requestId, code }) { + const safeCode = FATAL_MESSAGES.has(code) ? code : "WORKER_RUNTIME_FAILED"; + return { + version: PROTOCOL_VERSION, + type: "fatal", + requestId: isRequestId(requestId) ? requestId : "worker-fatal", + error: { + code: safeCode, + message: FATAL_MESSAGES.get(safeCode) + } + }; +} diff --git a/node/src/worker/runtime-settings.mjs b/node/src/worker/runtime-settings.mjs new file mode 100644 index 0000000..9d02783 --- /dev/null +++ b/node/src/worker/runtime-settings.mjs @@ -0,0 +1,98 @@ +function runtimeSettingsError(code, message) { + const error = new Error(`${code}: ${message}`); + error.name = "RuntimeSettingsError"; + error.code = code; + return error; +} + +function isPlainObject(value) { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function assertPlainData(value, seen = new WeakSet()) { + if (value === null || ["string", "boolean"].includes(typeof value)) { + return; + } + if (typeof value === "number") { + if (!Number.isFinite(value)) { + throw runtimeSettingsError("RUNTIME_SETTINGS_INVALID", "Settings must contain only finite numbers."); + } + return; + } + if (typeof value !== "object" || (!Array.isArray(value) && !isPlainObject(value))) { + throw runtimeSettingsError("RUNTIME_SETTINGS_INVALID", "Settings must contain only plain data."); + } + if (seen.has(value)) { + return; + } + seen.add(value); + for (const item of Array.isArray(value) ? value : Object.values(value)) { + assertPlainData(item, seen); + } +} + +function deepFreeze(value, seen = new WeakSet()) { + if (value === null || typeof value !== "object" || seen.has(value)) { + return value; + } + seen.add(value); + for (const item of Array.isArray(value) ? value : Object.values(value)) { + deepFreeze(item, seen); + } + return Object.freeze(value); +} + +function cloneAndFreezeSettings(settings) { + let cloned; + try { + cloned = structuredClone(settings); + } catch { + throw runtimeSettingsError("RUNTIME_SETTINGS_INVALID", "Settings could not be cloned."); + } + assertPlainData(cloned); + return deepFreeze(cloned); +} + +export class RuntimeSettingsSource { + #active = null; + + apply(snapshot) { + if (!isPlainObject(snapshot) || !Number.isSafeInteger(snapshot.generation) || snapshot.generation <= 0) { + throw runtimeSettingsError( + "RUNTIME_SETTINGS_INVALID", + "Snapshot generation must be a positive safe integer." + ); + } + if (!isPlainObject(snapshot.settings)) { + throw runtimeSettingsError("RUNTIME_SETTINGS_INVALID", "Snapshot settings must be an object."); + } + if (this.#active && snapshot.generation <= this.#active.generation) { + throw runtimeSettingsError("STALE_SNAPSHOT", "Snapshot generation must increase."); + } + + const next = Object.freeze({ + generation: snapshot.generation, + settings: cloneAndFreezeSettings(snapshot.settings) + }); + this.#active = next; + return next; + } + + current() { + if (!this.#active) { + throw runtimeSettingsError("RUNTIME_SETTINGS_UNAVAILABLE", "Runtime settings have not been configured."); + } + return this.#active; + } + + publicState() { + return Object.freeze({ + configured: this.#active !== null, + generation: this.#active?.generation ?? 0 + }); + } +} diff --git a/node/src/worker/worker-entry.mjs b/node/src/worker/worker-entry.mjs new file mode 100644 index 0000000..d10f4bb --- /dev/null +++ b/node/src/worker/worker-entry.mjs @@ -0,0 +1,331 @@ +import { createApp } from "../server.mjs"; +import { + PROTOCOL_VERSION, + createFatalMessage, + validateChildMessage, + validateParentMessage +} from "./protocol.mjs"; +import { RuntimeSettingsSource } from "./runtime-settings.mjs"; + +const PARENT_DISCONNECT_GRACE_MS = 250; + +const runtimeSettings = new RuntimeSettingsSource(); + +let app = null; +let phase = "ready"; +let listenHost = null; +let listenPort = null; +let inFlight = 0; +let stopping = false; +let intendedExitCode = 0; +let operation = Promise.resolve(); +let drainPromise = null; +let resourceClosePromise = null; +let parentDisconnectCleanupPromise = null; + +function getPublicState() { + const runtime = runtimeSettings.publicState(); + return { + phase, + configured: runtime.configured, + generation: runtime.generation, + listening: app?.server.listening === true, + listenHost: app?.server.listening === true ? listenHost : null, + listenPort: app?.server.listening === true ? listenPort : null, + inFlight + }; +} + +function sendChildMessage(message) { + validateChildMessage(message); + if (typeof process.send !== "function" || !process.connected) { + return Promise.reject(new Error("Worker IPC channel is unavailable.")); + } + return new Promise((resolvePromise, rejectPromise) => { + process.send(message, (error) => { + if (error) { + rejectPromise(error); + } else { + resolvePromise(); + } + }); + }); +} + +function lifecycleMessage(type, requestId) { + return { + version: PROTOCOL_VERSION, + type, + requestId, + state: getPublicState() + }; +} + +function emitMetric(observation) { + void sendChildMessage({ + version: PROTOCOL_VERSION, + type: "metric", + requestId: "metric-observation", + observation + }).catch(() => { + // Metrics are best effort and must not change request or worker lifecycle state. + }); +} + +function trackRequests(server) { + server.prependListener("request", (_req, res) => { + inFlight += 1; + let completed = false; + const complete = () => { + if (completed) { + return; + } + completed = true; + inFlight -= 1; + if (drainPromise && inFlight === 0) { + setImmediate(() => { + if (drainPromise && inFlight === 0) { + app?.server.closeIdleConnections?.(); + } + }); + } + }; + res.once("finish", complete); + res.once("close", complete); + }); +} + +function listen(server, settings) { + return new Promise((resolvePromise, rejectPromise) => { + const onError = (error) => { + server.off("listening", onListening); + rejectPromise(error); + }; + const onListening = () => { + server.off("error", onError); + const address = server.address(); + listenHost = typeof address === "object" && address ? address.address : settings.server.host; + listenPort = typeof address === "object" && address ? address.port : settings.server.port; + resolvePromise(); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen(settings.server.port, settings.server.host); + }); +} + +async function closeResources() { + if (!app) { + return; + } + if (drainPromise) { + await drainPromise; + return; + } + if (!resourceClosePromise) { + if (app.server.listening) { + resourceClosePromise = new Promise((resolvePromise) => app.server.close(resolvePromise)); + } else { + app.captureManager.close(); + resourceClosePromise = Promise.resolve(); + } + } + await resourceClosePromise; +} + +async function finishProcess(exitCode) { + intendedExitCode = exitCode; + await closeResources(); + process.exitCode = exitCode; + if (process.connected) { + process.disconnect(); + } +} + +async function failWorker({ requestId, code }) { + if (stopping) { + return; + } + stopping = true; + phase = "failed"; + intendedExitCode = 1; + try { + await sendChildMessage(createFatalMessage({ requestId, code })); + } catch { + // The parent may already be gone; cleanup still has to complete. + } + await finishProcess(1); +} + +async function configure(message) { + if (stopping || (phase !== "ready" && phase !== "running")) { + const error = new Error("Worker configuration is not allowed in the current phase."); + error.code = "WORKER_CONFIGURE_FAILED"; + throw error; + } + runtimeSettings.apply({ + generation: message.generation, + settings: message.settings + }); + if (!app) { + app = createApp(message.settings, { + settingsSource: runtimeSettings, + recordMetric: emitMetric + }); + trackRequests(app.server); + try { + await listen(app.server, message.settings); + } catch (error) { + error.workerFatalCode = "WORKER_START_FAILED"; + throw error; + } + } + phase = "running"; + await sendChildMessage(lifecycleMessage("configured", message.requestId)); +} + +async function shutdown() { + if (stopping) { + return; + } + stopping = true; + phase = "stopping"; + intendedExitCode = 0; + await finishProcess(0); +} + +function forceCloseResources() { + if (!app) { + return; + } + app.server.closeIdleConnections?.(); + app.server.closeAllConnections?.(); + app.captureManager.close(); +} + +async function performParentDisconnectCleanup() { + const exitCode = intendedExitCode; + if (!stopping) { + stopping = true; + phase = "stopping"; + } + + let deadline; + const timedOut = new Promise((resolvePromise) => { + deadline = setTimeout(() => resolvePromise(false), PARENT_DISCONNECT_GRACE_MS); + }); + const closedGracefully = closeResources() + .then(() => true) + .catch(() => false); + const outcome = await Promise.race([closedGracefully, timedOut]); + clearTimeout(deadline); + + if (outcome === true) { + process.exitCode = exitCode; + return; + } + try { + forceCloseResources(); + } finally { + process.exit(exitCode); + } +} + +function shutdownAfterParentDisconnect() { + if (!parentDisconnectCleanupPromise) { + parentDisconnectCleanupPromise = performParentDisconnectCleanup(); + } + return parentDisconnectCleanupPromise; +} + +function closeForDrain() { + if (!app?.server.listening) { + return Promise.resolve(); + } + return new Promise((resolvePromise, rejectPromise) => { + app.server.close((error) => { + if (error) { + rejectPromise(error); + } else { + resolvePromise(); + } + }); + }); +} + +function beginDrain(requestId) { + if (!drainPromise) { + phase = "draining"; + drainPromise = closeForDrain().then(() => { + phase = "drained"; + }); + } + void drainPromise + .then(() => { + phase = "drained"; + return sendChildMessage(lifecycleMessage("drained", requestId)); + }) + .catch(() => failWorker({ requestId, code: "WORKER_RUNTIME_FAILED" })); +} + +async function handleParentMessage(rawMessage) { + let message; + try { + message = validateParentMessage(rawMessage); + } catch (error) { + await failWorker({ + requestId: "worker-fatal", + code: error.code === "WORKER_PROTOCOL_INVALID" ? error.code : "WORKER_RUNTIME_FAILED" + }); + return; + } + + try { + if (message.type === "configure") { + await configure(message); + return; + } + if (message.type === "status") { + await sendChildMessage(lifecycleMessage("status", message.requestId)); + return; + } + if (message.type === "shutdown") { + await shutdown(); + return; + } + beginDrain(message.requestId); + } catch (error) { + await failWorker({ + requestId: message.requestId, + code: error.workerFatalCode ?? error.code ?? "WORKER_RUNTIME_FAILED" + }); + } +} + +function scheduleMessage(message) { + operation = operation + .then(() => handleParentMessage(message)) + .catch(() => failWorker({ requestId: "worker-fatal", code: "WORKER_RUNTIME_FAILED" })); +} + +async function startWorker() { + if (typeof process.send !== "function") { + throw new Error("Worker requires an IPC channel."); + } + process.on("message", scheduleMessage); + process.once("disconnect", () => { + void shutdownAfterParentDisconnect(); + }); + await sendChildMessage(lifecycleMessage("ready", "worker-ready")); +} + +process.once("uncaughtException", () => { + void failWorker({ requestId: "worker-fatal", code: "WORKER_RUNTIME_FAILED" }); +}); +process.once("unhandledRejection", () => { + void failWorker({ requestId: "worker-fatal", code: "WORKER_RUNTIME_FAILED" }); +}); + +void startWorker().catch(() => { + void failWorker({ requestId: "worker-fatal", code: "WORKER_START_FAILED" }); +}); diff --git a/node/test/activity-store.test.mjs b/node/test/activity-store.test.mjs new file mode 100644 index 0000000..2d3f91d --- /dev/null +++ b/node/test/activity-store.test.mjs @@ -0,0 +1,321 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import * as realFileOperations from "node:fs"; +import { + existsSync, + lstatSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync +} from "node:fs"; +import os from "node:os"; +import { join } from "node:path"; + +import { ActivityStore } from "../src/supervisor/activity-store.mjs"; + +const NOW = "2026-07-13T00:00:00.000Z"; + +function makeStore(t) { + const root = mkdtempSync(join(os.tmpdir(), "crp-activity-")); + const path = join(root, "private", "activity.jsonl"); + t.after(() => rmSync(root, { recursive: true, force: true })); + return { path, store: new ActivityStore({ path, now: () => NOW }) }; +} + +function event(action, details = {}) { + return { + category: "provider", + action, + providerId: "provider-1", + result: "success", + errorCode: null, + details + }; +} + +function makeSecret(label) { + return `${label}-${crypto.randomUUID()}`; +} + +test("persists the exact activity-event allowlist and recursively redacts sensitive fields", (t) => { + const { path, store } = makeStore(t); + const values = { + authorization: makeSecret("authorization"), + cookie: makeSecret("cookie"), + token: makeSecret("token"), + secret: makeSecret("secret"), + apiKey: makeSecret("api-key") + }; + + store.append({ + category: "provider", + action: "test", + providerId: "provider-1", + result: "failed", + errorCode: "PROVIDER_TEST_HTTP_401", + details: { + Authorization: values.authorization, + "session.cookie": values.cookie, + access_token: values.token, + clientSecret: values.secret, + "API-Key": values.apiKey, + safe: { attempt: 1 } + }, + ignored: "must-not-persist" + }); + + const bytes = readFileSync(path, "utf8"); + for (const value of Object.values(values)) { + assert.equal(bytes.includes(value), false); + } + const event = JSON.parse(bytes.trim()); + assert.deepEqual(Object.keys(event), [ + "timestamp", + "category", + "action", + "providerId", + "result", + "errorCode", + "details" + ]); + assert.equal(event.timestamp, NOW); + assert.equal(event.details.Authorization, "[REDACTED]"); + assert.equal(event.details["session.cookie"], "[REDACTED]"); + assert.equal(event.details.access_token, "[REDACTED]"); + assert.equal(event.details.clientSecret, "[REDACTED]"); + assert.equal(event.details["API-Key"], "[REDACTED]"); + assert.deepEqual(event.details.safe, { attempt: 1 }); + assert.equal("ignored" in event, false); +}); + +test("serializes nested Error, cyclic, bigint, and unsupported detail values safely", (t) => { + const { path, store } = makeStore(t); + const errorSecret = makeSecret("error-message"); + const nested = { count: 2n, missing: undefined }; + nested.self = nested; + const error = new Error(errorSecret, { + cause: new Error(makeSecret("cause-message")) + }); + error.accessToken = makeSecret("error-token"); + nested.error = error; + nested.callback = () => errorSecret; + + assert.doesNotThrow(() => store.append({ + category: "provider", + action: "test", + providerId: "provider-1", + result: "failed", + errorCode: "PROVIDER_TEST_FAILED", + details: nested + })); + + const bytes = readFileSync(path, "utf8"); + assert.equal(bytes.includes(errorSecret), false); + assert.equal(bytes.includes(error.accessToken), false); + assert.equal(bytes.includes(error.cause.message), false); + const event = JSON.parse(bytes.trim()); + assert.equal(event.details.count, "2"); + assert.equal(event.details.self, "[CIRCULAR]"); + assert.deepEqual(event.details.error, { + name: "Error", + accessToken: "[REDACTED]" + }); + assert.equal(event.details.missing, "[UNSERIALIZABLE]"); + assert.equal(event.details.callback, "[UNSERIALIZABLE]"); +}); + +test("redacts orchestration metadata, bodies, headers, paths, causes, and stacks", (t) => { + const { path, store } = makeStore(t); + const values = Object.fromEntries([ + "credential-ref", + "request-body", + "response-body", + "cause", + "stack", + "headers", + "backup-path" + ].map((label) => [label, makeSecret(label)])); + + store.append(event("security-boundary", { + "Credential.Ref": values["credential-ref"], + REQUEST_body: values["request-body"], + "response-body": values["response-body"], + CaUsE: values.cause, + "error.stack": values.stack, + requestHeaders: { safe: values.headers }, + "backup-path": values["backup-path"] + })); + + const bytes = readFileSync(path, "utf8"); + for (const value of Object.values(values)) assert.equal(bytes.includes(value), false); + const details = JSON.parse(bytes).details; + for (const value of Object.values(details)) assert.equal(value, "[REDACTED]"); +}); + +test("retains only events within 30 days and the newest configured row limit", (t) => { + const root = mkdtempSync(join(os.tmpdir(), "crp-activity-retention-")); + const path = join(root, "private", "activity.jsonl"); + t.after(() => rmSync(root, { recursive: true, force: true })); + const store = new ActivityStore({ + path, + now: () => "2026-07-13T00:00:00.000Z", + maxEvents: 3, + retentionMs: 30 * 24 * 60 * 60 * 1_000 + }); + + for (const [timestamp, sequence] of [ + ["2026-06-01T00:00:00.000Z", 0], + ["2026-07-09T00:00:00.000Z", 1], + ["2026-07-10T00:00:00.000Z", 2], + ["2026-07-11T00:00:00.000Z", 3], + ["2026-07-12T00:00:00.000Z", 4] + ]) { + store.append({ ...event("retention", { sequence }), timestamp }); + } + + assert.deepEqual( + store.list().map((entry) => entry.details.sequence), + [4, 3, 2] + ); + assert.deepEqual( + readFileSync(path, "utf8").trim().split("\n") + .map((line) => JSON.parse(line).details.sequence), + [2, 3, 4] + ); +}); + +test("uses a private atomic replacement and preserves old bytes on rename failure", (t) => { + const { path, store } = makeStore(t); + store.append(event("created")); + const original = readFileSync(path); + const failingStore = new ActivityStore({ + path, + now: () => NOW, + fileOperations: { + ...realFileOperations, + renameSync(from, to) { + if (to === path && from.endsWith(".tmp")) { + const error = new Error("private rename detail"); + error.code = "EIO"; + throw error; + } + return realFileOperations.renameSync(from, to); + } + } + }); + + assert.throws( + () => failingStore.append(event("failed-write")), + (error) => error?.code === "ACTIVITY_STORE_WRITE_FAILED" + && !error.message.includes("private rename detail") + ); + assert.deepEqual(readFileSync(path), original); + assert.equal(existsSync(`${path}.crp.lock`), false); + assert.deepEqual( + readdirSync(join(path, "..")).filter((name) => name.endsWith(".tmp")), + [] + ); + + if (process.platform !== "win32") { + assert.equal(lstatSync(join(path, "..")).mode & 0o777, 0o700); + assert.equal(lstatSync(path).mode & 0o777, 0o600); + } +}); + +test("rejects a foreign activity lock without changing or deleting it", (t) => { + const { path, store } = makeStore(t); + store.append(event("created")); + const original = readFileSync(path); + const lockPath = `${path}.crp.lock`; + const foreign = Buffer.from("foreign-activity-owner\n", "utf8"); + writeFileSync(lockPath, foreign, { mode: 0o600 }); + + assert.throws( + () => store.append(event("blocked")), + (error) => error?.code === "ACTIVITY_STORE_BUSY" + ); + assert.deepEqual(readFileSync(path), original); + assert.deepEqual(readFileSync(lockPath), foreign); +}); + +test("restores a canonical blocker and stops later mutations after committed lock degradation", (t) => { + const root = mkdtempSync(join(os.tmpdir(), "crp-activity-degraded-")); + const path = join(root, "private", "activity.jsonl"); + const lockPath = `${path}.crp.lock`; + t.after(() => rmSync(root, { recursive: true, force: true })); + const store = new ActivityStore({ + path, + now: () => NOW, + fileOperations: { + ...realFileOperations, + rmSync(target, options) { + if (typeof target === "string" && target.endsWith(".release")) { + const error = new Error("private claim cleanup failure"); + error.code = "EACCES"; + throw error; + } + return realFileOperations.rmSync(target, options); + } + } + }); + + assert.throws( + () => store.append(event("committed")), + (error) => error?.code === "ACTIVITY_STORE_COMMITTED_LOCK_DEGRADED" + && error.details.committed === true + ); + assert.equal(readFileSync(path, "utf8").trim().split("\n").length, 1); + assert.equal(existsSync(lockPath), true); + assert.deepEqual( + readdirSync(join(path, "..")).filter((name) => name.endsWith(".release")), + [] + ); + assert.throws( + () => store.append(event("must-not-write")), + (error) => error?.code === "ACTIVITY_STORE_LOCK_DEGRADED" + ); + assert.equal(readFileSync(path, "utf8").includes("must-not-write"), false); +}); + +test("never deletes a foreign canonical replacement after lock initialization fails", (t) => { + const root = mkdtempSync(join(os.tmpdir(), "crp-activity-acquire-swap-")); + const path = join(root, "private", "activity.jsonl"); + const lockPath = `${path}.crp.lock`; + const displacedPath = `${lockPath}.displaced`; + const foreign = Buffer.from("foreign-replacement\n", "utf8"); + let swapped = false; + t.after(() => rmSync(root, { recursive: true, force: true })); + const store = new ActivityStore({ + path, + now: () => NOW, + fileOperations: { + ...realFileOperations, + writeFileSync(target, bytes, options) { + if (!swapped && typeof target === "number") { + swapped = true; + realFileOperations.renameSync(lockPath, displacedPath); + realFileOperations.writeFileSync(lockPath, foreign, { mode: 0o600 }); + const error = new Error("private lock initialization failure"); + error.code = "EIO"; + throw error; + } + return realFileOperations.writeFileSync(target, bytes, options); + } + } + }); + + assert.throws( + () => store.append(event("must-not-commit")), + (error) => error?.code === "ACTIVITY_STORE_LOCK_DEGRADED" + && error.details.committed === false + ); + assert.deepEqual(readFileSync(lockPath), foreign); + assert.equal(existsSync(path), false); + assert.throws( + () => store.append(event("must-stay-blocked")), + (error) => error?.code === "ACTIVITY_STORE_LOCK_DEGRADED" + ); + assert.deepEqual(readFileSync(lockPath), foreign); +}); diff --git a/node/test/capture-store.test.mjs b/node/test/capture-store.test.mjs index 2e5184f..e6de39d 100644 --- a/node/test/capture-store.test.mjs +++ b/node/test/capture-store.test.mjs @@ -18,8 +18,15 @@ function makeTempDir(prefix) { return join(os.tmpdir(), `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`); } -function wait(ms = 700) { - return new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); +async function waitFor(condition, description, { timeoutMs = 5000, intervalMs = 25 } = {}) { + const deadline = Date.now() + timeoutMs; + + while (!condition()) { + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting for ${description} after ${timeoutMs}ms`); + } + await new Promise((resolvePromise) => setTimeout(resolvePromise, intervalMs)); + } } test("normalizeCaptureConfig applies defaults", () => { @@ -118,9 +125,17 @@ test("capture manager writes a complete request/response record", async () => { rmSync(dir, { recursive: true, force: true }); }); -test("capture manager hot-disables when runtime config changes", async () => { +test("capture manager hot-disables when runtime config changes", async (t) => { const dir = makeTempDir("crp-hot-disable"); mkdirSync(dir, { recursive: true }); + let manager; + t.after(() => { + try { + manager?.close(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); const runtimeConfigPath = join(dir, "proxy-config.json"); const dbPath = join(dir, "traffic.sqlite3"); writeFileSync(runtimeConfigPath, `${JSON.stringify({ @@ -130,13 +145,14 @@ test("capture manager hot-disables when runtime config changes", async () => { } }, null, 2)}\n`, "utf8"); - const manager = new CaptureManager({ + manager = new CaptureManager({ configPath: runtimeConfigPath, capture: { enabled: true, dbPath } - }).start(); + }); + manager.start(); assert.equal(manager.getPublicState().captureActive, true); writeFileSync(runtimeConfigPath, `${JSON.stringify({ @@ -145,18 +161,26 @@ test("capture manager hot-disables when runtime config changes", async () => { dbPath } }, null, 2)}\n`, "utf8"); - await wait(); + await waitFor( + () => manager.getPublicState().captureActive === false, + "capture recording to become inactive" + ); assert.equal(manager.getPublicState().captureActive, false); assert.equal(manager.getPublicState().captureState, "disabled"); - - manager.close(); - rmSync(dir, { recursive: true, force: true }); }); -test("capture manager marks restart required when db path changes", async () => { +test("capture manager marks restart required when db path changes", async (t) => { const dir = makeTempDir("crp-db-change"); mkdirSync(dir, { recursive: true }); + let manager; + t.after(() => { + try { + manager?.close(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); const runtimeConfigPath = join(dir, "proxy-config.json"); const dbPath = join(dir, "traffic.sqlite3"); const nextDbPath = join(dir, "traffic-next.sqlite3"); @@ -167,13 +191,14 @@ test("capture manager marks restart required when db path changes", async () => } }, null, 2)}\n`, "utf8"); - const manager = new CaptureManager({ + manager = new CaptureManager({ configPath: runtimeConfigPath, capture: { enabled: true, dbPath } - }).start(); + }); + manager.start(); writeFileSync(runtimeConfigPath, `${JSON.stringify({ capture: { @@ -181,15 +206,62 @@ test("capture manager marks restart required when db path changes", async () => dbPath: nextDbPath } }, null, 2)}\n`, "utf8"); - await wait(); + await waitFor( + () => manager.getPublicState().captureRestartRequired === true, + "capture restart to become required" + ); const state = manager.getPublicState(); assert.equal(state.captureRestartRequired, true); assert.equal(resolve(state.captureRuntimeDbPath), resolve(dbPath)); assert.equal(resolve(state.captureDbPath), resolve(nextDbPath)); +}); - manager.close(); - rmSync(dir, { recursive: true, force: true }); +test("capture manager reconciles a config change during startup", (t) => { + const dir = makeTempDir("crp-startup-change"); + mkdirSync(dir, { recursive: true }); + let manager; + t.after(() => { + try { + manager?.close(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + const runtimeConfigPath = join(dir, "proxy-config.json"); + const dbPath = join(dir, "traffic.sqlite3"); + const nextDbPath = join(dir, "traffic-next.sqlite3"); + writeFileSync(runtimeConfigPath, `${JSON.stringify({ + capture: { + enabled: true, + dbPath + } + }, null, 2)}\n`, "utf8"); + + manager = new CaptureManager({ + configPath: runtimeConfigPath, + capture: { + enabled: true, + dbPath + } + }); + const enableFromConfig = manager.enableFromConfig.bind(manager); + manager.enableFromConfig = (...args) => { + enableFromConfig(...args); + writeFileSync(runtimeConfigPath, `${JSON.stringify({ + capture: { + enabled: true, + dbPath: nextDbPath + } + }, null, 2)}\n`, "utf8"); + }; + manager.start(); + + const state = manager.getPublicState(); + assert.equal(state.captureRestartRequired, true); + assert.equal(resolve(state.captureRuntimeDbPath), resolve(dbPath)); + assert.equal(resolve(state.captureDbPath), resolve(nextDbPath)); + assert.equal(manager.start(), manager); }); test("loadRuntimeCaptureConfig validates malformed config", () => { diff --git a/node/test/cli-i18n.test.mjs b/node/test/cli-i18n.test.mjs new file mode 100644 index 0000000..c0c2a60 --- /dev/null +++ b/node/test/cli-i18n.test.mjs @@ -0,0 +1,1203 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import * as crpCli from "../bin/crp.mjs"; +import { CrpError } from "../src/shared/errors.mjs"; + +const { runCli } = crpCli; + +function adminStatus() { + return { + supervisor: { pid: 4242, startedAt: "2026-07-13T08:00:00.000Z" }, + activeProviderId: "provider-1", + activeProvider: { id: "provider-1", name: "Primary", credentialConfigured: true }, + generation: 0, + worker: { phase: "stopped", pid: null, generation: 0 }, + codex: { + configured: false, + modelProvider: "OpenAI", + proxyUrl: "http://127.0.0.1:15100", + historyRepairPending: false + } + }; +} + +function discoveredContext(client, status = adminStatus()) { + return { + origin: "http://127.0.0.1:15101", + state: { + supervisorPid: status.supervisor.pid, + startedAt: status.supervisor.startedAt, + admin: { + host: "127.0.0.1", + port: 15101, + authority: "127.0.0.1:15101", + origin: "http://127.0.0.1:15101" + }, + worker: status.worker + }, + status, + client, + spawned: false + }; +} + +async function invokeCli(args, overrides = {}) { + const stdout = []; + const stderr = []; + const status = await runCli(args, { + stdout: (text) => stdout.push(text), + stderr: (text) => stderr.push(text), + ...overrides + }); + return { status, stdout: stdout.join(""), stderr: stderr.join("") }; +} + +test("explicit locale works anywhere and normalizes common Chinese tags", async () => { + const dependencies = { discoverSupervisorImpl: async () => null }; + for (const args of [ + ["--locale", "zh_CN.UTF-8", "status"], + ["status", "--locale", "ZH-hans@variant"] + ]) { + const result = await invokeCli(args, dependencies); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, "CRP 监督进程未运行。\n"); + assert.equal(result.stderr, ""); + } +}); + +test("CLI defaults to English regardless of process locale variables", async () => { + const cases = [ + [{ CRP_LOCALE: "en_US.UTF-8", LC_ALL: "zh_CN", LC_MESSAGES: "zh_CN", LANG: "zh_CN" }, "CRP supervisor is not running.\n"], + [{ CRP_LOCALE: "fr-FR", LC_ALL: "zh_CN.UTF-8", LC_MESSAGES: "en_US", LANG: "en_US" }, "CRP supervisor is not running.\n"], + [{ CRP_LOCALE: "fr", LC_ALL: "de", LC_MESSAGES: "zh-Hans", LANG: "en_US" }, "CRP supervisor is not running.\n"], + [{ CRP_LOCALE: "fr", LC_ALL: "de", LC_MESSAGES: "ja", LANG: "en_GB@calendar" }, "CRP supervisor is not running.\n"], + [{ CRP_LOCALE: "fr", LC_ALL: "de", LC_MESSAGES: "ja", LANG: "ko" }, "CRP supervisor is not running.\n"] + ]; + + for (const [environment, expected] of cases) { + const result = await invokeCli(["status"], { + environment, + discoverSupervisorImpl: async () => null + }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, expected); + assert.equal(result.stderr, ""); + } +}); + +test("explicit locale selects Chinese and invalid explicit input fails before discovery", async () => { + let discoveryCalls = 0; + const dependencies = { + environment: { CRP_LOCALE: "zh-CN" }, + ensureSupervisorImpl: async () => { discoveryCalls += 1; return null; }, + discoverSupervisorImpl: async () => { discoveryCalls += 1; return null; } + }; + + const defaultResult = await invokeCli(["status"], dependencies); + assert.equal(defaultResult.status, 0, defaultResult.stderr); + assert.equal(defaultResult.stdout, "CRP supervisor is not running.\n"); + assert.equal(discoveryCalls, 1); + + discoveryCalls = 0; + const explicit = await invokeCli(["status", "--locale", "zh-CN"], dependencies); + assert.equal(explicit.status, 0, explicit.stderr); + assert.equal(explicit.stdout, "CRP 监督进程未运行。\n"); + assert.equal(discoveryCalls, 1); + + discoveryCalls = 0; + for (const args of [ + ["status", "--locale", "fr-FR"], + ["--locale", "zh-CN", "status", "--locale", "en"] + ]) { + const result = await invokeCli(args, dependencies); + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); + assert.match(result.stderr, /^Error: .+\n$/); + assert.equal(discoveryCalls, 0); + } +}); + +test("CLI validation errors remain English unless Chinese is explicitly selected", async () => { + const dependencies = { + environment: { CRP_LOCALE: "zh-CN", LC_ALL: "zh_CN.UTF-8", LANG: "zh_CN.UTF-8" }, + ensureSupervisorImpl: async () => { + throw new Error("validation must run before Supervisor discovery"); + }, + discoverSupervisorImpl: async () => { + throw new Error("validation must run before Supervisor discovery"); + } + }; + + const english = await invokeCli(["status", "--unsupported"], dependencies); + assert.equal(english.status, 1); + assert.equal(english.stdout, ""); + assert.equal(english.stderr, "Error: The status command contains an unsupported option.\n"); + + const chinese = await invokeCli(["status", "--unsupported", "--locale", "zh-CN"], dependencies); + assert.equal(chinese.status, 1); + assert.equal(chinese.stdout, ""); + assert.equal(chinese.stderr, "错误:status 命令包含不支持的选项。\n"); +}); + +test("locale extraction never consumes or changes the provider credential value", async () => { + const secret = "locale-scan-complete-secret-sentinel"; + const calls = []; + const client = { + async request(method, path, body) { + calls.push([method, path, body]); + return { provider: { id: "provider-1", name: "Primary", credentialConfigured: true } }; + } + }; + const result = await invokeCli([ + "provider", "add", + "--name", "Primary", + "--api-key", secret, + "--locale", "zh-CN", + "--base-url", "https://provider.example/v1" + ], { + ensureSupervisorImpl: async () => discoveredContext(client) + }); + + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout.includes(secret), false); + assert.equal(result.stderr.includes(secret), false); + assert.equal(calls[0][2].credential, secret); + assert.equal(result.stdout, "提供商添加操作已完成。\n"); +}); + +test("English and Chinese CLI dictionaries have exact non-empty key parity", () => { + assert.equal(typeof crpCli.CLI_MESSAGES, "object"); + const englishKeys = Object.keys(crpCli.CLI_MESSAGES.en).sort(); + const chineseKeys = Object.keys(crpCli.CLI_MESSAGES["zh-CN"]).sort(); + assert.deepEqual(chineseKeys, englishKeys); + assert.ok(englishKeys.length > 20); + for (const locale of ["en", "zh-CN"]) { + for (const key of englishKeys) { + assert.equal(typeof crpCli.CLI_MESSAGES[locale][key], "string"); + assert.notEqual(crpCli.CLI_MESSAGES[locale][key].length, 0); + } + } +}); + +test("help and validation output are bilingual while commands remain literal", async () => { + const english = await invokeCli(["--help"], { + environment: { CRP_LOCALE: "zh-CN", LANG: "zh_CN.UTF-8" } + }); + const chinese = await invokeCli(["--locale", "zh-CN", "--help"]); + assert.equal(english.status, 0, english.stderr); + assert.equal(chinese.status, 0, chinese.stderr); + assert.match(english.stdout, /^Usage:\n/); + assert.match(chinese.stdout, /^用法:\n/); + for (const heading of ["Commands:", "Options:", "Examples:"]) { + assert.equal(english.stdout.includes(heading), true); + } + for (const heading of ["命令:", "选项:", "示例:"]) { + assert.equal(chinese.stdout.includes(heading), true); + } + for (const literal of [ + "crp ui [--no-open] [--json]", + "crp start [--json]", + "crp status [--json]", + "crp stop [--json]", + "crp restart [--json]", + "crp shutdown [--json]", + "crp provider" + ]) { + assert.equal(english.stdout.includes(literal), true); + assert.equal(chinese.stdout.includes(literal), true); + } + for (const removed of ["init", "install", "setup"]) { + assert.doesNotMatch(english.stdout, new RegExp(`^\\s*(?:crp\\s+)?${removed}(?:\\s|$)`, "m")); + assert.doesNotMatch(chinese.stdout, new RegExp(`^\\s*(?:crp\\s+)?${removed}(?:\\s|$)`, "m")); + } + + let discoveryCalls = 0; + const invalid = await invokeCli(["status", "--unsupported", "--locale", "zh-CN"], { + ensureSupervisorImpl: async () => { discoveryCalls += 1; }, + discoverSupervisorImpl: async () => { discoveryCalls += 1; } + }); + assert.equal(invalid.status, 1); + assert.equal(invalid.stdout, ""); + assert.equal(invalid.stderr, "错误:status 命令包含不支持的选项。\n"); + assert.equal(discoveryCalls, 0); +}); + +test("layered command help is bilingual and exits before discovery or Admin requests", async () => { + let discoveryCalls = 0; + let adminCalls = 0; + const client = { + async request() { + adminCalls += 1; + throw new Error("help must not call Admin"); + } + }; + const dependencies = { + ensureSupervisorImpl: async () => { + discoveryCalls += 1; + return discoveredContext(client); + }, + discoverSupervisorImpl: async () => { + discoveryCalls += 1; + return discoveredContext(client); + } + }; + const providerActions = [ + ["list", "crp provider list [--json]", /configured providers/i, /已配置.*提供商/], + ["add", "crp provider add", /provider profile/i, /提供商配置/], + ["models", "crp provider models", /available models/i, /可用模型/], + ["test", "crp provider test", /compatibility/i, /兼容性/], + ["activate", "crp provider activate", /active provider/i, /当前提供商/], + ["delete", "crp provider delete", /inactive provider/i, /未激活.*提供商/] + ]; + const commands = [ + ["status", "crp status [--json]", /Supervisor.*Worker/i, /监督进程.*工作进程/], + ["start", "crp start [--json]", /Supervisor.*Worker/i, /监督进程.*工作进程/], + ["stop", "crp stop [--json]", /Supervisor.*running/i, /监督进程.*运行/], + [ + "shutdown", + "crp shutdown [--json]", + /Supervisor.*Worker|Worker.*Supervisor/i, + /监督进程.*工作进程|工作进程.*监督进程/ + ] + ]; + + for (const flag of ["-h", "--help"]) { + for (const [locale, usage, providerDescription] of [ + ["en", "Usage:", /provider commands/i], + ["zh-CN", "用法:", /提供商命令/] + ]) { + const group = await invokeCli(["provider", flag, "--locale", locale], dependencies); + assert.equal(group.status, 0, group.stderr); + assert.equal(group.stderr, ""); + assert.match(group.stdout, new RegExp(`^${usage}`)); + assert.match(group.stdout, providerDescription); + assert.match(group.stdout, locale === "en" ? /Commands:/ : /命令:/); + assert.match(group.stdout, locale === "en" ? /Options:/ : /选项:/); + assert.match(group.stdout, locale === "en" ? /Examples:/ : /示例:/); + for (const [, literal] of providerActions) assert.equal(group.stdout.includes(literal), true); + } + + for (const [action, literal, englishDescription, chineseDescription] of providerActions) { + for (const [locale, usage, description] of [ + ["en", "Usage:", englishDescription], + ["zh-CN", "用法:", chineseDescription] + ]) { + const result = await invokeCli([ + "provider", action, flag, "--locale", locale + ], dependencies); + assert.equal(result.status, 0, `${action} ${flag}: ${result.stderr}`); + assert.equal(result.stderr, ""); + assert.match(result.stdout, new RegExp(`^${usage}`)); + assert.equal(result.stdout.includes(literal), true); + assert.match(result.stdout, description); + assert.match(result.stdout, locale === "en" ? /Options:/ : /选项:/); + assert.match(result.stdout, locale === "en" ? /Examples:/ : /示例:/); + } + } + + for (const [command, literal, englishDescription, chineseDescription] of commands) { + for (const [locale, usage, description] of [ + ["en", "Usage:", englishDescription], + ["zh-CN", "用法:", chineseDescription] + ]) { + const result = await invokeCli([command, flag, "--locale", locale], dependencies); + assert.equal(result.status, 0, `${command} ${flag}: ${result.stderr}`); + assert.equal(result.stderr, ""); + assert.match(result.stdout, new RegExp(`^${usage}`)); + assert.equal(result.stdout.includes(literal), true); + assert.match(result.stdout, description); + assert.match(result.stdout, locale === "en" ? /Options:/ : /选项:/); + assert.match(result.stdout, locale === "en" ? /Examples:/ : /示例:/); + } + } + } + + assert.equal(discoveryCalls, 0); + assert.equal(adminCalls, 0); +}); + +test("a short help token used as an option value is not treated as a help request", async () => { + const calls = []; + const client = { + async request(method, path, body) { + calls.push([method, path, body]); + return { provider: { id: "provider-1", name: "Primary", credentialConfigured: true } }; + } + }; + const result = await invokeCli([ + "provider", "add", + "--name", "Primary", + "--base-url", "https://provider.example/v1", + "--api-key", "-h", + "--locale", "en" + ], { + ensureSupervisorImpl: async () => discoveredContext(client) + }); + + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ""); + assert.doesNotMatch(result.stdout, /^Usage:/); + assert.equal(calls.length, 1); + assert.equal(calls[0][2].credential, "-h"); +}); + +test("help with unexpected trailing input fails without discovery or process exit", async () => { + let discoveryCalls = 0; + let adminCalls = 0; + let processExitCalls = 0; + const client = { + async request() { + adminCalls += 1; + throw new Error("invalid help must not call Admin"); + } + }; + const dependencies = { + ensureSupervisorImpl: async () => { + discoveryCalls += 1; + return discoveredContext(client); + }, + discoverSupervisorImpl: async () => { + discoveryCalls += 1; + return discoveredContext(client); + } + }; + const originalExit = process.exit; + const results = []; + try { + process.exit = () => { + processExitCalls += 1; + throw new Error("runCli must return instead of exiting the process"); + }; + for (const args of [ + ["--help", "unexpected", "--locale", "en"], + ["status", "--help", "unexpected", "--locale", "en"], + ["provider", "--help", "unexpected", "--locale", "en"], + ["provider", "list", "--help", "unexpected", "--locale", "en"] + ]) { + results.push(await invokeCli(args, dependencies)); + } + } finally { + process.exit = originalExit; + } + + assert.equal(processExitCalls, 0); + assert.equal(discoveryCalls, 0); + assert.equal(adminCalls, 0); + for (const result of results) { + assert.equal(result.status, 1, result.stderr); + assert.equal(result.stdout, ""); + assert.doesNotMatch(result.stderr, /^Usage:/); + } +}); + +test("status human output shows Supervisor, Worker, active provider, Codex, and proxy details", async () => { + const status = adminStatus(); + status.generation = 7; + status.worker = { + phase: "running", + pid: 8001, + generation: 7, + state: { + phase: "running", + configured: true, + generation: 7, + listening: true, + listenHost: "127.0.0.1", + listenPort: 15100, + inFlight: 2 + } + }; + status.codex.configured = true; + const context = discoveredContext({ request: async () => ({}) }, status); + const cases = [ + [ + "en", + [ + /Supervisor:.*running/i, + /PID:.*4242/, + /Worker:.*running/i, + /PID:.*8001/, + /Active provider:.*Primary.*provider-1/i, + /Codex:.*configured/i, + /Model provider:.*OpenAI/i, + /Proxy URL:.*http:\/\/127\.0\.0\.1:15100/i + ] + ], + [ + "zh-CN", + [ + /监督进程:.*运行中/, + /PID:.*4242/, + /工作进程:.*运行中/, + /PID:.*8001/, + /当前提供商:.*Primary.*provider-1/, + /Codex:.*已配置/, + /模型提供商:.*OpenAI/, + /代理地址:.*http:\/\/127\.0\.0\.1:15100/ + ] + ] + ]; + + for (const [locale, patterns] of cases) { + const result = await invokeCli(["status", "--locale", locale], { + discoverSupervisorImpl: async () => context + }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ""); + for (const pattern of patterns) assert.match(result.stdout, pattern); + } +}); + +test("status human output distinguishes a stopped Worker from a missing Supervisor", async () => { + const stopped = adminStatus(); + stopped.activeProviderId = null; + stopped.activeProvider = null; + stopped.worker = { phase: "stopped", pid: null, generation: 3, state: null }; + stopped.codex.configured = false; + const context = discoveredContext({ request: async () => ({}) }, stopped); + + for (const [locale, workerPattern, providerPattern, codexPattern, missingSupervisor] of [ + ["en", /Worker:.*stopped/i, /Active provider:.*none/i, /Codex:.*not configured/i, "CRP supervisor is not running.\n"], + ["zh-CN", /工作进程:.*已停止/, /当前提供商:.*无/, /Codex:.*未配置/, "CRP 监督进程未运行。\n"] + ]) { + const stoppedResult = await invokeCli(["status", "--locale", locale], { + discoverSupervisorImpl: async () => context + }); + assert.equal(stoppedResult.status, 0, stoppedResult.stderr); + assert.match(stoppedResult.stdout, /4242/); + assert.match(stoppedResult.stdout, workerPattern); + assert.match(stoppedResult.stdout, providerPattern); + assert.match(stoppedResult.stdout, codexPattern); + + const missingResult = await invokeCli(["status", "--locale", locale], { + discoverSupervisorImpl: async () => null + }); + assert.equal(missingResult.status, 0, missingResult.stderr); + assert.equal(missingResult.stdout, missingSupervisor); + } +}); + +test("lifecycle success output is bilingual", async () => { + const client = { + async request(method, path, body) { + if (path === "/status") return adminStatus(); + if (path === "/proxy/stop") return { worker: { phase: "stopped", pid: null, generation: 1 } }; + if (path === "/supervisor/shutdown") { + return { + shutdown: { + accepted: true, + supervisorPid: body.supervisorPid, + startedAt: body.startedAt + } + }; + } + return { worker: { phase: "running", pid: 8001, generation: 1 } }; + } + }; + const configuredStatus = adminStatus(); + configuredStatus.codex.configured = true; + const context = discoveredContext(client, configuredStatus); + const paths = { statePath: join(tmpdir(), `crp-cli-i18n-no-state-${process.pid}`) }; + + const cases = [ + ["start", { ensureSupervisorImpl: async () => context }, "Codex Remote Proxy is ready.\n", "Codex Remote Proxy 已就绪。\n"], + ["restart", { ensureSupervisorImpl: async () => context }, "Proxy worker restarted.\n", "代理工作进程已重启。\n"] + ]; + + for (const [command, dependencies, english, chinese] of cases) { + const en = await invokeCli([command, "--locale", "en"], dependencies); + const zh = await invokeCli([command, "--locale", "zh-CN"], dependencies); + assert.equal(en.status, 0, `${command}: ${en.stderr}`); + assert.equal(zh.status, 0, `${command}: ${zh.stderr}`); + assert.equal(en.stdout, english); + assert.equal(zh.stdout, chinese); + } + + for (const [locale, supervisorPattern, workerPattern] of [ + ["en", /Supervisor.*stopped/i, /Worker.*stopped/i], + ["zh-CN", /监督进程.*停止/, /工作进程.*停止/] + ]) { + const result = await invokeCli(["shutdown", "--locale", locale], { + paths, + discoverSupervisorImpl: async () => context, + readSupervisorStateSnapshotImpl: () => Object.freeze({}), + readSupervisorStateImpl: () => context.state, + isProcessAlive: () => false + }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ""); + assert.match(result.stdout, supervisorPattern); + assert.match(result.stdout, workerPattern); + } +}); + +test("removed aliases provide localized migration guidance without side effects", async () => { + let ensureCalls = 0; + let discoverCalls = 0; + let openCalls = 0; + const dependencies = { + ensureSupervisorImpl: async () => { ensureCalls += 1; }, + discoverSupervisorImpl: async () => { discoverCalls += 1; }, + openManagementUrlImpl: () => { openCalls += 1; } + }; + + for (const [command, replacement] of [ + ["init", "ui"], + ["install", "start"], + ["setup", "start"] + ]) { + for (const [locale, removed, guidance] of [ + ["en", /removed/i, new RegExp(`crp ${replacement}`)], + ["zh-CN", /已移除/, new RegExp(`crp ${replacement}`)] + ]) { + for (const suffix of [[], ["--help"]]) { + const result = await invokeCli([ + command, + ...suffix, + "--locale", locale + ], dependencies); + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); + assert.match(result.stderr, removed); + assert.match(result.stderr, guidance); + } + } + } + assert.equal(ensureCalls, 0); + assert.equal(discoverCalls, 0); + assert.equal(openCalls, 0); +}); + +test("stop explains that the Supervisor remains running and points to shutdown", async () => { + const client = { + async request(method, path) { + assert.equal(method, "POST"); + assert.equal(path, "/proxy/stop"); + return { worker: { phase: "stopped", pid: null, generation: 1 } }; + } + }; + const dependencies = { + discoverSupervisorImpl: async () => discoveredContext(client) + }; + for (const [locale, stopped, supervisor] of [ + ["en", /Proxy worker stopped/i, /Supervisor.*still running/i], + ["zh-CN", /代理工作进程已停止/, /监督进程仍在运行/] + ]) { + const result = await invokeCli(["stop", "--locale", locale], dependencies); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ""); + assert.match(result.stdout, stopped); + assert.match(result.stdout, supervisor); + assert.match(result.stdout, /crp shutdown/); + } +}); + +test("provider list human output is bilingual, useful, active-aware, and terminal-safe", async () => { + const privateSentinel = "provider-private-complete-sentinel"; + const extraHeaderSentinel = "provider-extra-header-value-sentinel"; + const querySentinel = "provider-query-secret-sentinel"; + const fragmentSentinel = "provider-fragment-secret-sentinel"; + const longDynamicValue = `Backup-${"x".repeat(4096)}-provider-dynamic-tail-sentinel`; + const longTestCode = `PROVIDER_${"A".repeat(4096)}_FAILURE_CODE_TAIL`; + const providers = [ + { + id: "provider-1", + name: "Primary$&\u001b[31m\u0085\u061c\u200e\u200f\u202e\u206a\nLine", + baseUrl: `https://primary.example/v1?token=${querySentinel}#${fragmentSentinel}`, + modelMode: "passthrough", + modelOverride: null, + lastTestStatus: "passed", + credentialConfigured: true, + credentialRef: privateSentinel, + apiKey: privateSentinel, + extraHeaders: { "x-display-trap": extraHeaderSentinel } + }, + { + id: "provider-2", + name: longDynamicValue, + baseUrl: "https://backup.example/v1", + modelMode: "override", + modelOverride: "backup-model", + lastTestStatus: "failed", + lastTestCode: longTestCode, + credentialConfigured: false + } + ]; + const client = { + async request(method, path) { + assert.equal(method, "GET"); + assert.equal(path, "/providers"); + return { providers }; + } + }; + const context = discoveredContext(client); + const cases = [ + [ + "en", + [ + /Providers.*2/i, + /Primary\$&\\u001b\[31m\\u0085\\u061c\\u200e\\u200f\\u202e\\u206a\\nLine.*\(active\)/i, + /ID:.*provider-1/, + /Base URL:.*https:\/\/primary\.example\/v1/, + /Test:.*passed/i, + /Model:.*passthrough/i, + /Credential:.*configured/i, + /Backup-/, + /ID:.*provider-2/, + /Test: failed(?:\n|$)/i, + /Model:.*override.*backup-model/i + ] + ], + [ + "zh-CN", + [ + /提供商.*2/, + /Primary\$&\\u001b\[31m\\u0085\\u061c\\u200e\\u200f\\u202e\\u206a\\nLine(当前)/, + /ID:.*provider-1/, + /基础地址:.*https:\/\/primary\.example\/v1/, + /测试:.*已通过/, + /模型:.*透传/, + /凭据:.*已配置/, + /Backup-/, + /ID:.*provider-2/, + /测试:失败(?:\n|$)/, + /模型:.*覆盖.*backup-model/ + ] + ] + ]; + + for (const [locale, patterns] of cases) { + const result = await invokeCli(["provider", "list", "--locale", locale], { + ensureSupervisorImpl: async () => context + }); + assert.equal(result.stdout.includes(privateSentinel), false); + assert.equal(result.stderr.includes(privateSentinel), false); + assert.equal(result.stdout.includes(extraHeaderSentinel), false); + assert.equal(result.stderr.includes(extraHeaderSentinel), false); + assert.equal(result.stdout.includes(querySentinel), false); + assert.equal(result.stderr.includes(querySentinel), false); + assert.equal(result.stdout.includes(fragmentSentinel), false); + assert.equal(result.stderr.includes(fragmentSentinel), false); + assert.equal(result.stdout.includes(longDynamicValue), false); + assert.equal(result.stdout.includes(longTestCode), false); + assert.equal(result.stderr.includes(longTestCode), false); + assert.equal(result.stdout.includes("\u001b"), false); + assert.equal(result.stdout.includes("\u0085"), false); + assert.equal(result.stdout.includes("\u061c"), false); + assert.equal(result.stdout.includes("\u200e"), false); + assert.equal(result.stdout.includes("\u200f"), false); + assert.equal(result.stdout.includes("\u202e"), false); + assert.equal(result.stdout.includes("\u206a"), false); + assert.ok(result.stdout.length < 4096, `provider list output was ${result.stdout.length} bytes`); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ""); + for (const pattern of patterns) assert.match(result.stdout, pattern); + } +}); + +test("provider list human output has an explicit bilingual empty state", async () => { + const client = { request: async () => ({ providers: [] }) }; + const context = discoveredContext(client); + for (const [locale, expected] of [ + ["en", /No providers configured\./], + ["zh-CN", /尚未配置提供商。/] + ]) { + const result = await invokeCli(["provider", "list", "--locale", locale], { + ensureSupervisorImpl: async () => context + }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ""); + assert.match(result.stdout, expected); + } +}); + +test("provider models human output is bilingual, bounded, and terminal-safe", async () => { + const privateSentinel = "provider-models-private-complete-sentinel"; + const longModel = `long-model-${"x".repeat(4096)}-model-tail-sentinel`; + const unsafeModel = "model$&\u001b[31m\u061c\u202e\nnext"; + const provider = { id: "provider-1", name: "Primary" }; + const client = { + async request(method, path, body) { + if (method === "GET" && path === "/providers") return { providers: [provider] }; + if (method === "GET" && path === "/providers/provider-1") return { provider }; + assert.equal(method, "POST"); + assert.equal(path, "/providers/provider-1/models"); + assert.equal(body, undefined); + return { + modelCatalog: { + providerId: provider.id, + state: "fresh", + fetchedAt: "2026-07-16T00:00:00.000Z", + expiresAt: "2026-07-17T00:00:00.000Z", + models: ["model-a", unsafeModel, longModel], + credentialRef: privateSentinel, + extraHeaders: { "x-private": privateSentinel } + }, + privateValue: privateSentinel + }; + } + }; + const context = discoveredContext(client); + + for (const [locale, header] of [ + ["en", /Models.*Primary.*provider-1.*3/i], + ["zh-CN", /Primary.*provider-1.*模型.*3/] + ]) { + const result = await invokeCli([ + "provider", "models", + "--name", "Primary", + "--locale", locale + ], { ensureSupervisorImpl: async () => context }); + assert.equal(result.stdout.includes(privateSentinel), false); + assert.equal(result.stderr.includes(privateSentinel), false); + assert.equal(result.stdout.includes(longModel), false); + assert.equal(result.stdout.includes("\u001b"), false); + assert.equal(result.stdout.includes("\u061c"), false); + assert.equal(result.stdout.includes("\u202e"), false); + assert.ok(result.stdout.length < 4096, `provider models output was ${result.stdout.length} bytes`); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ""); + assert.match(result.stdout, header); + assert.match(result.stdout, /model-a/); + assert.match(result.stdout, /model\$&\\u001b\[31m\\u061c\\u202e\\nnext/); + } +}); + +test("provider models JSON preserves the exact public model projection", async () => { + const modelCatalog = { + providerId: "provider-1", + state: "fresh", + fetchedAt: "2026-07-16T00:00:00.000Z", + expiresAt: "2026-07-17T00:00:00.000Z", + models: ["model-a", "model-b"] + }; + const client = { + async request(method, path, body) { + assert.deepEqual([method, path, body], ["POST", "/providers/provider-1/models", undefined]); + return { modelCatalog }; + } + }; + const result = await invokeCli([ + "provider", "models", + "--id", "provider-1", + "--json", + "--locale", "en" + ], { ensureSupervisorImpl: async () => discoveredContext(client) }); + + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ""); + assert.deepEqual(JSON.parse(result.stdout), { + ok: true, + action: "models", + modelCatalog + }); +}); + +test("provider mutation success messages are bilingual", async () => { + const client = { + async request(method, path) { + if (path.endsWith("/test")) return { result: { ok: true, code: null } }; + if (path.endsWith("/activate")) return { activation: { activeProviderId: "provider-1", generation: 1 } }; + return { provider: { id: "provider-1", name: "Primary", credentialConfigured: true } }; + } + }; + const dependencies = { ensureSupervisorImpl: async () => discoveredContext(client) }; + const cases = [ + ["add", ["--name", "Primary", "--base-url", "https://provider.example/v1", "--api-key", "write-only"], "Provider add completed.\n", "提供商添加操作已完成。\n"], + ["activate", ["--id", "provider-1"], "Provider activate completed.\n", "提供商激活操作已完成。\n"], + ["delete", ["--id", "provider-1"], "Provider delete completed.\n", "提供商删除操作已完成。\n"] + ]; + + for (const [action, args, english, chinese] of cases) { + const en = await invokeCli(["provider", action, ...args, "--locale", "en"], dependencies); + const zh = await invokeCli(["provider", action, ...args, "--locale", "zh-CN"], dependencies); + assert.equal(en.status, 0, `${action}: ${en.stderr}`); + assert.equal(zh.status, 0, `${action}: ${zh.stderr}`); + assert.equal(en.stdout, english); + assert.equal(zh.stdout, chinese); + } +}); + +test("provider test human output reports passed and failed results with bounded safe codes", async () => { + let testResult = { ok: true, code: null }; + const client = { + async request(method, path, body) { + assert.deepEqual([method, path, body], [ + "POST", + "/providers/provider-1/test", + { model: "test-model", activateIfNone: true } + ]); + return { result: testResult }; + } + }; + const dependencies = { ensureSupervisorImpl: async () => discoveredContext(client) }; + const cases = [ + [ + "en", + { ok: true, code: null }, + [/Provider test completed\./, /passed/i] + ], + [ + "zh-CN", + { ok: true, code: null }, + [/提供商测试操作已完成。/, /已通过/] + ], + [ + "en", + { ok: false, code: "PROVIDER_TEST_AUTH" }, + [/Provider test completed\./, /failed.*PROVIDER_TEST_AUTH/i] + ], + [ + "zh-CN", + { ok: false, code: "PROVIDER_TEST_AUTH" }, + [/提供商测试操作已完成。/, /失败.*PROVIDER_TEST_AUTH/] + ] + ]; + + for (const [locale, response, patterns] of cases) { + testResult = response; + const result = await invokeCli([ + "provider", "test", + "--id", "provider-1", + "--model", "test-model", + "--locale", locale + ], dependencies); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ""); + assert.ok(result.stdout.length < 4096, `provider test output was ${result.stdout.length} bytes`); + for (const pattern of patterns) assert.match(result.stdout, pattern); + } + + const unsafeCode = `PROVIDER_TEST_${"X".repeat(4_096)}\u001b[31m\nInjected-Line`; + testResult = { ok: false, code: unsafeCode }; + const unsafe = await invokeCli([ + "provider", "test", + "--id", "provider-1", + "--model", "test-model", + "--locale", "en" + ], dependencies); + assert.equal(unsafe.stdout.includes(unsafeCode), false); + assert.equal(unsafe.stdout.includes("\u001b"), false); + assert.equal(unsafe.stdout.includes("Injected-Line"), false); + assert.equal(unsafe.status, 0, unsafe.stderr); + assert.equal(unsafe.stderr, ""); + assert.ok(unsafe.stdout.length < 4096, `provider test output was ${unsafe.stdout.length} bytes`); + assert.match(unsafe.stdout, /failed/i); +}); + +test("JSON validation failures are one language-independent stderr document", async () => { + let discoveryCalls = 0; + const expected = { + ok: false, + command: "status", + stage: null, + error: { + code: "CLI_INPUT_INVALID", + message: "The command input is invalid.", + action: "Review the command options and try again.", + details: {} + } + }; + const outputs = []; + for (const locale of ["en", "zh-CN"]) { + const result = await invokeCli(["status", "--unsupported", "--json", "--locale", locale], { + ensureSupervisorImpl: async () => { discoveryCalls += 1; }, + discoverSupervisorImpl: async () => { discoveryCalls += 1; } + }); + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); + assert.deepEqual(JSON.parse(result.stderr), expected); + assert.equal(result.stderr, `${JSON.stringify(expected, null, 2)}\n`); + outputs.push(result.stderr); + } + assert.equal(outputs[0], outputs[1]); + assert.equal(discoveryCalls, 0); +}); + +test("JSON failures retain only safe public Admin error fields", async () => { + const secret = "admin-error-complete-secret-sentinel"; + const error = new CrpError( + "PROVIDER_INPUT_INVALID", + "Provider settings are invalid.", + "Review the provider settings and try again.", + { details: { field: "name", privateValue: secret } } + ); + error.requestId = "request-safe_1"; + const client = { request: async () => { throw error; } }; + const result = await invokeCli(["provider", "list", "--json", "--locale", "zh-CN"], { + ensureSupervisorImpl: async () => discoveredContext(client) + }); + + assert.equal(result.stdout.includes(secret), false); + assert.equal(result.stderr.includes(secret), false); + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); + assert.deepEqual(JSON.parse(result.stderr), { + ok: false, + command: "provider", + stage: null, + error: { + code: "PROVIDER_INPUT_INVALID", + message: "Provider settings are invalid.", + action: "Review the provider settings and try again.", + details: { field: "name" }, + requestId: "request-safe_1" + } + }); +}); + +test("JSON failures replace unknown secret-bearing errors with a static contract", async () => { + const secret = "unknown-error-complete-secret-sentinel"; + const result = await invokeCli(["status", "--json", "--locale", "zh-CN"], { + discoverSupervisorImpl: async () => { + const error = new Error(secret); + error.cause = new Error(`cause-${secret}`); + throw error; + } + }); + + assert.equal(result.stdout.includes(secret), false); + assert.equal(result.stderr.includes(secret), false); + assert.equal(result.stderr.includes("cause"), false); + assert.equal(result.stderr.includes("stack"), false); + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); + assert.deepEqual(JSON.parse(result.stderr), { + ok: false, + command: "status", + stage: null, + error: { + code: "CLI_COMMAND_FAILED", + message: "CRP could not complete the command.", + action: "Review CRP activity and try again.", + details: {} + } + }); +}); + +test("start reports the exact failed stage and stops later phases", async () => { + const contracts = { + supervisor_start: [ + "SUPERVISOR_START_FAILED", + "The local supervisor could not be started.", + "Review the supervisor log and try again." + ], + codex_bootstrap: [ + "CODEX_CONFIG_WRITE_FAILED", + "Codex configuration could not be written safely.", + "Repair local filesystem access and retry." + ], + proxy_start: [ + "WORKER_START_FAILED", + "The proxy worker could not be started.", + "Review CRP activity and try again." + ] + }; + + for (const [stage, [code, message, action]] of Object.entries(contracts)) { + const calls = []; + const failure = new CrpError(code, message, action); + const client = { + async request(method, path) { + calls.push([method, path]); + if (stage === "codex_bootstrap" && path === "/codex/bootstrap") throw failure; + if (stage === "proxy_start" && path === "/proxy/start") throw failure; + if (path === "/codex/bootstrap") return { result: { changed: true, backupCreated: false } }; + return { worker: { phase: "running", pid: 8001, generation: 1 } }; + } + }; + const result = await invokeCli(["start", "--json", "--locale", "en"], { + ensureSupervisorImpl: async () => { + if (stage === "supervisor_start") throw failure; + return discoveredContext(client); + } + }); + + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); + const payload = JSON.parse(result.stderr); + assert.equal(payload.command, "start"); + assert.equal(payload.stage, stage); + assert.deepEqual(payload.error, { code, message, action, details: {} }); + if (stage === "supervisor_start") assert.deepEqual(calls, []); + if (stage === "codex_bootstrap") assert.deepEqual(calls, [["POST", "/codex/bootstrap"]]); + if (stage === "proxy_start") { + assert.deepEqual(calls, [ + ["POST", "/codex/bootstrap"], + ["POST", "/proxy/start"] + ]); + } + } +}); + +test("human start failures use localized stage guidance without raw errors", async () => { + const secret = "staged-start-complete-secret-sentinel"; + const cases = [ + ["en", "Error: Supervisor startup failed. Review the supervisor log and try again.\n"], + ["zh-CN", "错误:启动监督进程失败。请检查监督进程日志后重试。\n"] + ]; + for (const [locale, expected] of cases) { + const result = await invokeCli(["start", "--locale", locale], { + ensureSupervisorImpl: async () => { throw new Error(secret); } + }); + assert.equal(result.stdout.includes(secret), false); + assert.equal(result.stderr.includes(secret), false); + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); + assert.equal(result.stderr, expected); + } +}); + +test("pending restart bootstraps first and prints only the static encrypted-history warning", async () => { + const secret = "restart-history-warning-private-secret"; + const calls = []; + const client = { + async request(method, path) { + calls.push([method, path]); + if (path === "/codex/bootstrap") { + return { + result: { + changed: false, + backupCreated: true, + historyRepair: { + required: true, + completed: true, + resumed: true, + backupCreated: true, + rolloutFiles: 1, + rolloutRecords: 2, + sqliteFiles: 1, + sqliteRows: 3, + encryptedContentDetected: true, + privatePath: `/private/${secret}` + } + } + }; + } + return { worker: { phase: "running", pid: 8002, generation: 2 } }; + } + }; + const status = adminStatus(); + status.codex.configured = false; + status.codex.historyRepairPending = true; + const context = discoveredContext(client, status); + + const english = await invokeCli(["restart", "--locale", "en"], { + ensureSupervisorImpl: async () => context + }); + const chinese = await invokeCli(["restart", "--locale", "zh-CN"], { + ensureSupervisorImpl: async () => context + }); + + for (const result of [english, chinese]) { + assert.equal(result.stdout.includes(secret), false); + assert.equal(result.stderr.includes(secret), false); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ""); + } + assert.equal(english.stdout, [ + "Proxy worker restarted.", + "Warning: Some historical sessions contain encrypted content. Their provider metadata was repaired, but some messages may remain unavailable.", + "" + ].join("\n")); + assert.equal(chinese.stdout, [ + "代理工作进程已重启。", + "警告:部分历史会话包含加密内容。提供商元数据已修复,但部分消息可能仍不可用。", + "" + ].join("\n")); + assert.deepEqual(calls, [ + ["POST", "/codex/bootstrap"], + ["POST", "/proxy/restart"], + ["POST", "/codex/bootstrap"], + ["POST", "/proxy/restart"] + ]); +}); + +test("pending restart localizes codex_bootstrap failure and stops before Worker restart", async () => { + const secret = "restart-bootstrap-private-error"; + const failure = new CrpError( + "CODEX_CONFIG_WRITE_FAILED", + "Codex configuration could not be written safely.", + "Repair local filesystem access and retry.", + { cause: new Error(secret) } + ); + const cases = [ + ["en", "Error: Codex configuration bootstrap failed. Review CRP activity and retry before starting the proxy.\n"], + ["zh-CN", "错误:引导 Codex 配置失败。请查看 CRP 活动记录,修复后再启动代理。\n"] + ]; + for (const [locale, expected] of cases) { + const calls = []; + const status = adminStatus(); + status.codex.configured = false; + status.codex.historyRepairPending = true; + const result = await invokeCli(["restart", "--locale", locale], { + ensureSupervisorImpl: async () => discoveredContext({ + async request(method, path) { + calls.push([method, path]); + if (path === "/codex/bootstrap") throw failure; + return assert.fail("Worker restart must not run after bootstrap failure"); + } + }, status) + }); + + assert.equal(result.stdout.includes(secret), false); + assert.equal(result.stderr.includes(secret), false); + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); + assert.equal(result.stderr, expected); + assert.deepEqual(calls, [["POST", "/codex/bootstrap"]]); + } +}); + +test("configured start skips bootstrap and provider required fields fail before discovery", async () => { + const calls = []; + const client = { + async request(method, path) { + calls.push([method, path]); + return { worker: { phase: "running", pid: 8001, generation: 1 } }; + } + }; + const status = adminStatus(); + status.codex.configured = true; + const started = await invokeCli(["start", "--json"], { + ensureSupervisorImpl: async () => discoveredContext(client, status) + }); + assert.equal(started.status, 0, started.stderr); + assert.deepEqual(calls, [["POST", "/proxy/start"]]); + + let ensureCalls = 0; + for (const args of [ + ["provider", "add", "--name", "Primary", "--base-url", "https://provider.example/v1", "--json"], + ["provider", "test", "--id", "provider-1", "--json"], + ["provider", "activate", "--json"], + ["provider", "delete", "--json"] + ]) { + const result = await invokeCli(args, { + ensureSupervisorImpl: async () => { ensureCalls += 1; throw new Error("must not discover"); } + }); + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); + assert.equal(JSON.parse(result.stderr).error.code, "CLI_INPUT_INVALID"); + } + assert.equal(ensureCalls, 0); +}); + +test("JSON command is derived only from the real first argv token after locale removal", async () => { + for (const args of [ + ["not-a-command", "--label", "status", "--json"], + ["--locale", "zh-CN", "not-a-command", "--label", "provider", "--json"], + ["not-a-command", "--locale", "en", "--label", "start", "--json"] + ]) { + const result = await invokeCli(args); + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); + assert.equal(JSON.parse(result.stderr).command, "unknown"); + } + + const known = await invokeCli(["--locale", "zh-CN", "status", "--unsupported", "provider", "--json"]); + assert.equal(known.status, 1); + assert.equal(JSON.parse(known.stderr).command, "status"); +}); diff --git a/node/test/codex-config.test.mjs b/node/test/codex-config.test.mjs new file mode 100644 index 0000000..701690f --- /dev/null +++ b/node/test/codex-config.test.mjs @@ -0,0 +1,2163 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import * as realFileOperations from "node:fs"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync +} from "node:fs"; +import os from "node:os"; +import { dirname, join, resolve } from "node:path"; + +import { + bootstrapCodexConfig, + patchCodexConfigText +} from "../src/codex/codex-config.mjs"; +import { + inspectCodexProviderBinding, + planCodexProviderTransition +} from "../src/codex/codex-history-repair.mjs"; +import { CrpError, toPublicError } from "../src/shared/errors.mjs"; +import { getPaths } from "../src/shared/paths.mjs"; + +const PROXY_URL = "http://127.0.0.1:15100"; +const NO_HISTORY_REPAIR = Object.freeze({ + required: false, + completed: false, + resumed: false, + backupCreated: false, + rolloutFiles: 0, + rolloutRecords: 0, + sqliteFiles: 0, + sqliteRows: 0, + encryptedContentDetected: false +}); +const CONFIG_AT_PROXY_NEEDING_PATCH = [ + 'model_provider = "custom"', + "", + "[model_providers.custom]", + 'name = "Custom"', + `base_url = "${PROXY_URL}"`, + "" +].join("\n"); +const PACKAGE_ROOT = resolve(import.meta.dirname, ".."); +const CLI_PATH = join(PACKAGE_ROOT, "bin", "crp.mjs"); +function makeHomeEnv(homeDir) { + return { + ...process.env, + HOME: homeDir, + USERPROFILE: homeDir + }; +} + +function runCrp(args, env) { + return spawnSync(process.execPath, [CLI_PATH, ...args], { + cwd: PACKAGE_ROOT, + env, + encoding: "utf8", + timeout: 20_000, + killSignal: "SIGKILL" + }); +} + +function expectedBootstrap(changed, backupPath) { + return { changed, backupPath, historyRepair: NO_HISTORY_REPAIR }; +} + +test("patchCodexConfigText creates the fixed OpenAI provider and preserves custom providers", () => { + const original = [ + 'model_provider = "custom"', + "", + "[model_providers.custom]", + 'name = "Custom"', + 'base_url = "https://old.example/v1"', + "" + ].join("\n"); + + const once = patchCodexConfigText(original, PROXY_URL); + const twice = patchCodexConfigText(once, PROXY_URL); + + assert.match(once, /^model_provider = "OpenAI"$/m); + assert.match( + once, + /\[model_providers\.OpenAI\]\nname = "OpenAI"\nbase_url = "http:\/\/127\.0\.0\.1:15100"\nwire_api = "responses"\nrequires_openai_auth = true/ + ); + assert.match( + once, + /\[model_providers\.custom\]\nname = "Custom"\nbase_url = "https:\/\/old\.example\/v1"/ + ); + assert.equal(twice, once); +}); + +test("patchCodexConfigText updates every fixed OpenAI provider field", () => { + const original = [ + 'model_provider = "legacy"', + "", + "[model_providers.OpenAI]", + 'name = "Legacy"', + 'base_url = "https://wrong.example/v1"', + 'wire_api = "chat"', + "requires_openai_auth = false", + "" + ].join("\n"); + + const patched = patchCodexConfigText(original, PROXY_URL); + + assert.match(patched, /^model_provider = "OpenAI"$/m); + assert.match(patched, /^name = "OpenAI"$/m); + assert.match(patched, /^base_url = "http:\/\/127\.0\.0\.1:15100"$/m); + assert.match(patched, /^wire_api = "responses"$/m); + assert.match(patched, /^requires_openai_auth = true$/m); + assert.equal(patchCodexConfigText(patched, PROXY_URL), patched); +}); + +test("patchCodexConfigText preserves CRLF line endings byte-for-byte after the first patch", () => { + const original = [ + 'model_provider = "custom"', + "", + "[model_providers.custom]", + 'name = "Custom"', + "" + ].join("\r\n"); + + const once = patchCodexConfigText(original, PROXY_URL); + const twice = patchCodexConfigText(once, PROXY_URL); + + assert.equal(once.includes("\r\n"), true); + assert.equal(once.replaceAll("\r\n", "").includes("\n"), false); + assert.equal(twice, once); +}); + +test("patchCodexConfigText uses semantic TOML paths and ignores multiline decoys", () => { + const decoy = [ + 'developer_instructions = """', + "[model_providers.OpenAI]", + 'base_url = "https://decoy.example/v1"', + 'model_provider = "decoy"', + '"""' + ].join("\n"); + const original = [ + decoy, + '"model_provider" = "legacy"', + "", + "[ 'model_providers' . 'legacy' ] # retained provider", + "'base_url' = 'https://legacy.example/v1'", + "", + "[ 'model_providers' . \"OpenAI\" ] # managed provider", + "'name' = 'Wrong'", + "'base_url' = 'https://wrong.example/v1'", + "'wire_api' = 'chat'", + "'requires_openai_auth' = false", + "" + ].join("\n"); + + const patched = patchCodexConfigText(original, PROXY_URL); + + assert.equal(patched.includes(decoy), true); + assert.equal(patched.includes("[ 'model_providers' . \"OpenAI\" ] # managed provider"), true); + assert.equal(patched.includes('"model_provider" = "legacy"'), false); + assert.deepEqual(inspectCodexProviderBinding(patched), { + providerName: "OpenAI", + baseUrl: PROXY_URL, + normalizedBaseUrl: `${PROXY_URL}/` + }); + assert.equal(patchCodexConfigText(patched, PROXY_URL), patched); +}); + +test("patchCodexConfigText fails closed on conflicting or invalid TOML key paths", () => { + const invalidSources = [ + 'model_provider. = "legacy"\n', + '\"\"\"model_provider\"\"\" = "legacy"\n', + 'model_provider.child = "legacy"\n', + 'model_providers = { OpenAI = {} }\n', + '[model_provider]\nchild = "legacy"\n', + [ + 'model_provider = "OpenAI"', + "[model_providers.OpenAI]", + "[model_providers.OpenAI.base_url]", + 'child = "legacy"', + "" + ].join("\n"), + [ + 'model_provider = "OpenAI"', + "[[model_providers.OpenAI.base_url]]", + 'child = "legacy"', + "" + ].join("\n"), + ]; + + for (const source of invalidSources) { + assert.throws( + () => patchCodexConfigText(source, PROXY_URL), + (error) => error?.code === "CODEX_HISTORY_REPAIR_INVALID" + ); + } +}); + +test("patchCodexConfigText supports root and parent dotted provider fields", () => { + const cases = [ + [ + 'model_provider = "legacy"', + 'model_providers.OpenAI.base_url = "https://wrong.example/v1"', + 'model_providers.OpenAI.custom = "retained"', + "" + ].join("\n"), + [ + 'model_provider = "legacy"', + "[model_providers]", + 'OpenAI.base_url = "https://wrong.example/v1"', + 'OpenAI.custom = "retained"', + "" + ].join("\n") + ]; + + for (const source of cases) { + const patched = patchCodexConfigText(source, PROXY_URL); + assert.deepEqual(inspectCodexProviderBinding(patched), { + providerName: "OpenAI", + baseUrl: PROXY_URL, + normalizedBaseUrl: `${PROXY_URL}/` + }); + assert.equal(patched.includes('custom = "retained"'), true); + assert.equal(patchCodexConfigText(patched, PROXY_URL), patched); + } +}); + +test("bootstrapCodexConfig safely upgrades a dotted provider binding", async (t) => { + const homeDir = mkdtempSync(join(os.tmpdir(), "crp-codex-dotted-bootstrap-")); + const codexDir = join(homeDir, ".codex"); + const configPath = join(codexDir, "config.toml"); + const source = [ + 'model_provider = "legacy"', + 'model_providers.legacy.base_url = "https://legacy.example/v1"', + 'model_providers.OpenAI.base_url = "https://wrong.example/v1"', + 'model_providers.OpenAI.custom = "retained"', + "" + ].join("\n"); + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + mkdirSync(codexDir, { mode: 0o700 }); + writeFileSync(configPath, source, { mode: 0o600 }); + + const result = await bootstrapCodexConfig({ configPath, proxyUrl: PROXY_URL }); + const patched = readFileSync(configPath, "utf8"); + + assert.equal(result.changed, true); + assert.equal(result.historyRepair.required, true); + assert.equal(result.historyRepair.completed, true); + assert.equal(patched.includes('model_providers.OpenAI.custom = "retained"'), true); + assert.deepEqual(inspectCodexProviderBinding(patched), { + providerName: "OpenAI", + baseUrl: PROXY_URL, + normalizedBaseUrl: `${PROXY_URL}/` + }); +}); + +test("config-only completion failure reports committed degradation without pending", async (t) => { + const homeDir = mkdtempSync(join(os.tmpdir(), "crp-codex-config-only-degraded-")); + const codexDir = join(homeDir, ".codex"); + const configPath = join(codexDir, "config.toml"); + const lockPath = `${configPath}.crp.lock`; + let lockRenames = 0; + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + mkdirSync(codexDir, { mode: 0o700 }); + writeFileSync(configPath, CONFIG_AT_PROXY_NEEDING_PATCH, { mode: 0o600 }); + + const error = await bootstrapCodexConfig({ + configPath, + proxyUrl: PROXY_URL, + fileOperations: { + ...realFileOperations, + renameSync(source, destination, ...args) { + if (destination === lockPath) { + lockRenames += 1; + if (lockRenames === 2) throw new Error("simulated completion failure"); + } + return realFileOperations.renameSync(source, destination, ...args); + } + } + }).then(() => null, (failure) => failure); + + assert.equal(error?.code, "CODEX_CONFIG_COMMITTED_DEGRADED"); + assert.deepEqual(error?.details, { committed: true, degraded: true, pending: false }); + assert.equal(readFileSync(configPath, "utf8"), + patchCodexConfigText(CONFIG_AT_PROXY_NEEDING_PATCH, PROXY_URL)); + assert.equal(existsSync(lockPath), false); + assert.equal(existsSync(join(codexDir, ".crp-history-repair")), false); +}); + +test("config-only completion rechecks the published config before returning success", async (t) => { + const homeDir = mkdtempSync(join(os.tmpdir(), "crp-codex-config-only-final-check-")); + const codexDir = join(homeDir, ".codex"); + const configPath = join(codexDir, "config.toml"); + const lockPath = `${configPath}.crp.lock`; + const foreignConfig = 'model_provider = "foreign"\n'; + let lockRenames = 0; + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + mkdirSync(codexDir, { mode: 0o700 }); + writeFileSync(configPath, CONFIG_AT_PROXY_NEEDING_PATCH, { mode: 0o600 }); + + const error = await bootstrapCodexConfig({ + configPath, + proxyUrl: PROXY_URL, + fileOperations: { + ...realFileOperations, + renameSync(source, destination, ...args) { + const result = realFileOperations.renameSync(source, destination, ...args); + if (destination === lockPath) { + lockRenames += 1; + if (lockRenames === 2) writeFileSync(configPath, foreignConfig, "utf8"); + } + return result; + } + } + }).then(() => null, (failure) => failure); + + assert.equal(error?.code, "CODEX_CONFIG_COMMITTED_DEGRADED"); + assert.deepEqual(error?.details, { committed: true, degraded: true, pending: false }); + assert.equal(readFileSync(configPath, "utf8"), foreignConfig); + assert.equal(existsSync(lockPath), false); + assert.equal(existsSync(join(codexDir, ".crp-history-repair")), false); +}); + +test("successful config-only commit maps lock-release failure to pending-false degradation", async (t) => { + const homeDir = mkdtempSync(join(os.tmpdir(), "crp-codex-config-release-degraded-")); + const codexDir = join(homeDir, ".codex"); + const configPath = join(codexDir, "config.toml"); + const lockPath = `${configPath}.crp.lock`; + let releaseFailed = false; + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + mkdirSync(codexDir, { mode: 0o700 }); + writeFileSync(configPath, CONFIG_AT_PROXY_NEEDING_PATCH, { mode: 0o600 }); + + const error = await bootstrapCodexConfig({ + configPath, + proxyUrl: PROXY_URL, + fileOperations: { + ...realFileOperations, + rmSync(path, ...args) { + if (!releaseFailed && path.endsWith(".release")) { + releaseFailed = true; + throw new Error("simulated config lock release failure"); + } + return realFileOperations.rmSync(path, ...args); + } + } + }).then(() => null, (failure) => failure); + + assert.equal(releaseFailed, true); + assert.equal(error?.code, "CODEX_CONFIG_COMMITTED_DEGRADED"); + assert.deepEqual(error?.details, { committed: true, degraded: true, pending: false }); + assert.equal(readFileSync(configPath, "utf8"), + patchCodexConfigText(CONFIG_AT_PROXY_NEEDING_PATCH, PROXY_URL)); + assert.equal(existsSync(lockPath), true); +}); + +test("successful history repair maps lock-release failure to pending-false degradation", async (t) => { + const homeDir = mkdtempSync(join(os.tmpdir(), "crp-codex-history-release-degraded-")); + const codexRoot = join(homeDir, ".codex"); + const configPath = join(codexRoot, "config.toml"); + const lockPath = `${configPath}.crp.lock`; + const pendingPath = join(codexRoot, ".crp-history-repair", "pending.json"); + const clearingPath = `${pendingPath}.clearing`; + const rolloutPath = join(codexRoot, "sessions", "rollout-release-degraded.jsonl"); + const source = [ + 'model_provider = "legacy"', + "[model_providers.legacy]", + 'base_url = "https://legacy.example/v1"', + "" + ].join("\n"); + let releaseFailed = false; + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + mkdirSync(dirname(rolloutPath), { recursive: true, mode: 0o700 }); + writeFileSync(configPath, source, { mode: 0o600 }); + writeFileSync(rolloutPath, `${JSON.stringify({ + type: "session_meta", + payload: { id: "release-degraded", model_provider: "legacy" } + })}\n`, { mode: 0o600 }); + + const error = await bootstrapCodexConfig({ + configPath, + proxyUrl: PROXY_URL, + fileOperations: { + ...realFileOperations, + rmSync(path, ...args) { + if (!releaseFailed && path.endsWith(".release")) { + releaseFailed = true; + throw new Error("simulated config lock release failure"); + } + return realFileOperations.rmSync(path, ...args); + } + } + }).then(() => null, (failure) => failure); + + assert.equal(releaseFailed, true); + assert.equal(error?.code, "CODEX_CONFIG_COMMITTED_DEGRADED"); + assert.deepEqual(error?.details, { committed: true, degraded: true, pending: false }); + assert.equal(JSON.parse(readFileSync(rolloutPath, "utf8")).payload.model_provider, + "OpenAI"); + assert.equal(existsSync(pendingPath), false); + assert.equal(existsSync(clearingPath), false); + assert.equal(existsSync(lockPath), true); +}); + +test("bootstrapCodexConfig privately creates a missing Codex directory and config once", async () => { + const homeDir = mkdtempSync(join(os.tmpdir(), "crp-codex-clean-home-")); + const codexDir = join(homeDir, ".codex"); + const configPath = join(codexDir, "config.toml"); + const expectedText = patchCodexConfigText("", PROXY_URL); + + try { + assert.equal(existsSync(codexDir), false); + + const first = await bootstrapCodexConfig({ configPath, proxyUrl: PROXY_URL }); + + assert.deepEqual(first, expectedBootstrap(true, null)); + assert.equal(readFileSync(configPath, "utf8"), expectedText); + if (process.platform !== "win32") { + assert.equal(statSync(codexDir).mode & 0o777, 0o700); + assert.equal(statSync(configPath).mode & 0o777, 0o600); + } + assert.deepEqual(readdirSync(codexDir), ["config.toml"]); + + const firstBytes = readFileSync(configPath); + const firstMtime = statSync(configPath, { bigint: true }).mtimeNs; + const second = await bootstrapCodexConfig({ configPath, proxyUrl: PROXY_URL }); + + assert.deepEqual(second, expectedBootstrap(false, null)); + assert.deepEqual(readFileSync(configPath), firstBytes); + assert.equal(statSync(configPath, { bigint: true }).mtimeNs, firstMtime); + assert.deepEqual(readdirSync(codexDir), ["config.toml"]); + } finally { + rmSync(homeDir, { recursive: true, force: true }); + } +}); + +test("bootstrapCodexConfig does not overwrite a config that appears during first publish", async () => { + const homeDir = mkdtempSync(join(os.tmpdir(), "crp-codex-create-race-")); + const codexDir = join(homeDir, ".codex"); + const configPath = join(codexDir, "config.toml"); + const externalBytes = Buffer.from('model_provider = "external"\n', "utf8"); + let appearanceInjected = false; + const injectAppearance = () => { + if (appearanceInjected) return; + appearanceInjected = true; + realFileOperations.writeFileSync(configPath, externalBytes, { flag: "wx" }); + }; + + try { + mkdirSync(codexDir, { mode: 0o700 }); + await assert.rejects( + () => bootstrapCodexConfig({ + configPath, + proxyUrl: PROXY_URL, + fileOperations: { + ...realFileOperations, + linkSync(source, target) { + if (target === configPath) injectAppearance(); + return realFileOperations.linkSync(source, target); + }, + renameSync(source, target) { + if (target === configPath) injectAppearance(); + return realFileOperations.renameSync(source, target); + } + } + }), + (error) => error?.code === "CODEX_CONFIG_CHANGED" + ); + + assert.equal(appearanceInjected, true); + assert.deepEqual(readFileSync(configPath), externalBytes); + assert.equal(existsSync(`${configPath}.crp.lock`), false); + assert.deepEqual( + readdirSync(codexDir).filter((name) => name.endsWith(".tmp") || name.endsWith(".bak")), + [] + ); + } finally { + rmSync(homeDir, { recursive: true, force: true }); + } +}); + +test("bootstrapCodexConfig rejects a changed parent identity before publishing", async (t) => { + if (process.platform === "win32") { + t.skip("POSIX file identity semantics are required for this parent-race fixture"); + return; + } + const homeDir = mkdtempSync(join(os.tmpdir(), "crp-codex-parent-race-")); + const codexDir = join(homeDir, ".codex"); + const configPath = join(codexDir, "config.toml"); + let parentReads = 0; + + try { + mkdirSync(codexDir, { mode: 0o700 }); + await assert.rejects( + () => bootstrapCodexConfig({ + configPath, + proxyUrl: PROXY_URL, + fileOperations: { + ...realFileOperations, + lstatSync(path, ...args) { + const identity = realFileOperations.lstatSync(path, ...args); + if (path !== codexDir) return identity; + parentReads += 1; + if (parentReads < 2) return identity; + const changed = Object.create(identity); + Object.defineProperty(changed, "ino", { + value: typeof identity.ino === "bigint" ? identity.ino + 1n : identity.ino + 1 + }); + return changed; + } + } + }), + (error) => error?.code === "CODEX_CONFIG_PARENT_UNSAFE" + ); + + assert.ok(parentReads >= 2); + assert.equal(existsSync(configPath), false); + assert.equal(existsSync(`${configPath}.crp.lock`), true); + assert.deepEqual(readdirSync(codexDir), ["config.toml.crp.lock"]); + } finally { + rmSync(homeDir, { recursive: true, force: true }); + } +}); + +test("bootstrapCodexConfig never removes a foreign lock replacement", async () => { + const homeDir = mkdtempSync(join(os.tmpdir(), "crp-codex-lock-replacement-")); + const codexDir = join(homeDir, ".codex"); + const configPath = join(codexDir, "config.toml"); + const lockPath = `${configPath}.crp.lock`; + const foreignLockBytes = Buffer.from("foreign lock replacement\n", "utf8"); + let lockDescriptor; + let replaced = false; + + try { + mkdirSync(codexDir, { mode: 0o700 }); + await assert.rejects( + () => bootstrapCodexConfig({ + configPath, + proxyUrl: PROXY_URL, + fileOperations: { + ...realFileOperations, + openSync(path, ...args) { + const descriptor = realFileOperations.openSync(path, ...args); + if (path === lockPath) lockDescriptor = descriptor; + return descriptor; + }, + closeSync(descriptor) { + realFileOperations.closeSync(descriptor); + if (descriptor === lockDescriptor && !replaced) { + replaced = true; + realFileOperations.rmSync(lockPath); + realFileOperations.writeFileSync(lockPath, foreignLockBytes); + } + } + } + }), + (error) => error?.code === "CODEX_CONFIG_WRITE_FAILED" + ); + + assert.equal(replaced, true); + assert.deepEqual(readFileSync(lockPath), foreignLockBytes); + assert.equal(existsSync(configPath), false); + assert.deepEqual( + readdirSync(codexDir).filter((name) => name.endsWith(".tmp") || name.endsWith(".bak")), + [] + ); + } finally { + rmSync(homeDir, { recursive: true, force: true }); + } +}); + +test("bootstrapCodexConfig retains a foreign lock when lock identity acquisition fails", async () => { + const homeDir = mkdtempSync(join(os.tmpdir(), "crp-codex-lock-identity-failure-")); + const codexDir = join(homeDir, ".codex"); + const configPath = join(codexDir, "config.toml"); + const lockPath = `${configPath}.crp.lock`; + const foreignLockBytes = Buffer.from("foreign lock after identity failure\n", "utf8"); + const identityFailure = new Error("forced lock identity failure"); + let lockDescriptor; + + try { + mkdirSync(codexDir, { mode: 0o700 }); + await assert.rejects( + () => bootstrapCodexConfig({ + configPath, + proxyUrl: PROXY_URL, + fileOperations: { + ...realFileOperations, + openSync(path, ...args) { + const descriptor = realFileOperations.openSync(path, ...args); + if (path === lockPath) lockDescriptor = descriptor; + return descriptor; + }, + fstatSync(descriptor, ...args) { + if (descriptor === lockDescriptor) { + realFileOperations.rmSync(lockPath); + realFileOperations.writeFileSync(lockPath, foreignLockBytes); + throw identityFailure; + } + return realFileOperations.fstatSync(descriptor, ...args); + } + } + }), + (error) => error?.code === "CODEX_CONFIG_WRITE_FAILED" + && error?.cause === identityFailure + ); + + assert.deepEqual(readFileSync(lockPath), foreignLockBytes); + assert.equal(existsSync(configPath), false); + await assert.rejects( + () => bootstrapCodexConfig({ configPath, proxyUrl: PROXY_URL }), + (error) => error?.code === "CODEX_CONFIG_BUSY" + ); + } finally { + rmSync(homeDir, { recursive: true, force: true }); + } +}); + +test("bootstrapCodexConfig atomically claims a lock before removing owned state", async () => { + const homeDir = mkdtempSync(join(os.tmpdir(), "crp-codex-lock-claim-race-")); + const codexDir = join(homeDir, ".codex"); + const configPath = join(codexDir, "config.toml"); + const lockPath = `${configPath}.crp.lock`; + const foreignLockBytes = Buffer.from("foreign lock in cleanup race\n", "utf8"); + let injected = false; + const injectReplacement = () => { + if (injected) return; + injected = true; + const displacedPath = `${lockPath}.displaced`; + realFileOperations.renameSync(lockPath, displacedPath); + realFileOperations.rmSync(displacedPath); + realFileOperations.writeFileSync(lockPath, foreignLockBytes); + }; + + try { + mkdirSync(codexDir, { mode: 0o700 }); + await assert.rejects( + () => bootstrapCodexConfig({ + configPath, + proxyUrl: PROXY_URL, + fileOperations: { + ...realFileOperations, + renameSync(source, target, ...args) { + if (source === lockPath && /\.(?:claim|release)$/.test(target)) { + injectReplacement(); + } + return realFileOperations.renameSync(source, target, ...args); + }, + rmSync(path, ...args) { + if (path === lockPath) injectReplacement(); + return realFileOperations.rmSync(path, ...args); + } + } + }), + (error) => error?.code === "CODEX_CONFIG_COMMITTED_DEGRADED" + && error?.details?.committed === true + && error?.details?.degraded === true + && error?.details?.pending === false + ); + + assert.equal(injected, true); + assert.deepEqual(readFileSync(lockPath), foreignLockBytes); + assert.equal(readFileSync(configPath, "utf8"), patchCodexConfigText("", PROXY_URL)); + assert.deepEqual( + readdirSync(codexDir).filter((name) => name.endsWith(".claim") || name.endsWith(".release")), + [] + ); + } finally { + rmSync(homeDir, { recursive: true, force: true }); + } +}); + +test("bootstrapCodexConfig atomically claims a temp before cleanup", async () => { + const homeDir = mkdtempSync(join(os.tmpdir(), "crp-codex-temp-claim-race-")); + const codexDir = join(homeDir, ".codex"); + const configPath = join(codexDir, "config.toml"); + const foreignTempBytes = Buffer.from("foreign temp in cleanup race\n", "utf8"); + const publishFailure = new Error("forced first publish failure"); + let tempPath; + let injected = false; + const injectReplacement = () => { + if (injected) return; + injected = true; + const displacedPath = `${tempPath}.displaced`; + realFileOperations.renameSync(tempPath, displacedPath); + realFileOperations.rmSync(displacedPath); + realFileOperations.writeFileSync(tempPath, foreignTempBytes); + }; + + try { + mkdirSync(codexDir, { mode: 0o700 }); + await assert.rejects( + () => bootstrapCodexConfig({ + configPath, + proxyUrl: PROXY_URL, + fileOperations: { + ...realFileOperations, + openSync(path, ...args) { + const descriptor = realFileOperations.openSync(path, ...args); + if (path.endsWith(".tmp")) tempPath = path; + return descriptor; + }, + linkSync(source, target, ...args) { + if (target === configPath) throw publishFailure; + return realFileOperations.linkSync(source, target, ...args); + }, + renameSync(source, target, ...args) { + if (source === tempPath && target.endsWith(".claim")) injectReplacement(); + return realFileOperations.renameSync(source, target, ...args); + }, + rmSync(path, ...args) { + if (path === tempPath) injectReplacement(); + return realFileOperations.rmSync(path, ...args); + } + } + }), + (error) => error?.code === "CODEX_CONFIG_WRITE_FAILED" + && error?.cause === publishFailure + ); + + assert.equal(injected, true); + assert.deepEqual(readFileSync(tempPath), foreignTempBytes); + assert.equal(existsSync(configPath), false); + assert.equal(existsSync(`${configPath}.crp.lock`), false); + assert.deepEqual( + readdirSync(codexDir).filter((name) => name.endsWith(".claim")), + [] + ); + } finally { + rmSync(homeDir, { recursive: true, force: true }); + } +}); + +test("bootstrapCodexConfig preserves a primary write failure over lock cleanup failure", async () => { + const homeDir = mkdtempSync(join(os.tmpdir(), "crp-codex-primary-failure-")); + const codexDir = join(homeDir, ".codex"); + const configPath = join(codexDir, "config.toml"); + const lockPath = `${configPath}.crp.lock`; + const primaryFailure = new Error("forced primary write failure"); + const cleanupFailure = new Error("forced lock close failure"); + let lockDescriptor; + let tempDescriptor; + let lockCloseFailed = false; + let primaryThrown = false; + + try { + mkdirSync(codexDir, { mode: 0o700 }); + await assert.rejects( + () => bootstrapCodexConfig({ + configPath, + proxyUrl: PROXY_URL, + fileOperations: { + ...realFileOperations, + openSync(path, ...args) { + const descriptor = realFileOperations.openSync(path, ...args); + if (path === lockPath) lockDescriptor = descriptor; + else if (path.endsWith(".tmp")) tempDescriptor = descriptor; + return descriptor; + }, + writeFileSync(target, ...args) { + if (target === tempDescriptor) { + primaryThrown = true; + throw primaryFailure; + } + return realFileOperations.writeFileSync(target, ...args); + }, + closeSync(descriptor) { + realFileOperations.closeSync(descriptor); + if (descriptor === lockDescriptor && primaryThrown && !lockCloseFailed) { + lockCloseFailed = true; + throw cleanupFailure; + } + } + } + }), + (error) => error?.code === "CODEX_CONFIG_WRITE_FAILED" + && error?.cause === primaryFailure + ); + + assert.equal(existsSync(configPath), false); + assert.equal(existsSync(lockPath), false); + assert.deepEqual(readdirSync(codexDir), []); + } finally { + rmSync(homeDir, { recursive: true, force: true }); + } +}); + +test("bootstrapCodexConfig classifies private filesystem read and write failures", async () => { + for (const failureCase of [ + { + name: "read", + code: "CODEX_CONFIG_READ_FAILED", + prepare(configPath) { + writeFileSync(configPath, CONFIG_AT_PROXY_NEEDING_PATCH, "utf8"); + }, + operations(configPath, privateFailure) { + let sourceDescriptor; + return { + ...realFileOperations, + openSync(path, ...args) { + const descriptor = realFileOperations.openSync(path, ...args); + if (path === configPath) sourceDescriptor = descriptor; + return descriptor; + }, + readFileSync(target, ...args) { + if (target === configPath || target === sourceDescriptor) throw privateFailure; + return realFileOperations.readFileSync(target, ...args); + } + }; + } + }, + { + name: "write", + code: "CODEX_CONFIG_WRITE_FAILED", + prepare() {}, + operations(_configPath, privateFailure) { + let tempDescriptor; + return { + ...realFileOperations, + openSync(path, ...args) { + const descriptor = realFileOperations.openSync(path, ...args); + if (path.endsWith(".tmp")) tempDescriptor = descriptor; + return descriptor; + }, + fsyncSync(descriptor) { + if (descriptor === tempDescriptor) throw privateFailure; + return realFileOperations.fsyncSync(descriptor); + } + }; + } + } + ]) { + const homeDir = mkdtempSync(join(os.tmpdir(), `crp-codex-${failureCase.name}-failure-`)); + const codexDir = join(homeDir, ".codex"); + const configPath = join(codexDir, "config.toml"); + const privateMarker = `private-${failureCase.name}-filesystem-detail`; + const privateFailure = new Error(privateMarker); + let caught; + try { + mkdirSync(codexDir, { mode: 0o700 }); + failureCase.prepare(configPath); + try { + await bootstrapCodexConfig({ + configPath, + proxyUrl: PROXY_URL, + fileOperations: failureCase.operations(configPath, privateFailure) + }); + } catch (error) { + caught = error; + } + + assert.ok(caught); + assert.equal(caught.message.includes(privateMarker), false); + assert.equal(caught.code, failureCase.code); + assert.equal(caught.cause, privateFailure); + assert.equal(existsSync(`${configPath}.crp.lock`), false); + assert.deepEqual( + readdirSync(codexDir).filter((name) => name.endsWith(".tmp") || name.endsWith(".bak")), + [] + ); + } finally { + rmSync(homeDir, { recursive: true, force: true }); + } + } +}); + +test("bootstrapCodexConfig backs up and atomically writes only changed content", async () => { + const tempDir = mkdtempSync(join(os.tmpdir(), "crp-codex-config-")); + const configPath = join(tempDir, "config.toml"); + const original = CONFIG_AT_PROXY_NEEDING_PATCH; + + try { + writeFileSync(configPath, original, "utf8"); + chmodSync(configPath, 0o640); + + const first = await bootstrapCodexConfig({ + configPath, + proxyUrl: PROXY_URL, + now: () => new Date("2026-07-10T12:34:56.789Z") + }); + + assert.deepEqual(first, expectedBootstrap( + true, + `${configPath}.20260710-123456.bak` + )); + assert.equal(dirname(first.backupPath), tempDir); + assert.equal(readFileSync(first.backupPath, "utf8"), original); + assert.equal(readFileSync(configPath, "utf8"), patchCodexConfigText(original, PROXY_URL)); + if (process.platform !== "win32") { + assert.equal(statSync(configPath).mode & 0o777, 0o640); + } + assert.equal(readdirSync(tempDir).filter((name) => name.endsWith(".bak")).length, 1); + + const firstWriteMtime = statSync(configPath, { bigint: true }).mtimeNs; + const second = await bootstrapCodexConfig({ + configPath, + proxyUrl: PROXY_URL, + now: () => new Date("2026-07-10T12:35:56.789Z") + }); + + assert.deepEqual(second, expectedBootstrap(false, null)); + assert.equal(statSync(configPath, { bigint: true }).mtimeNs, firstWriteMtime); + assert.equal(readdirSync(tempDir).filter((name) => name.endsWith(".bak")).length, 1); + assert.deepEqual( + readdirSync(tempDir).filter((name) => name.endsWith(".tmp")), + [] + ); + assert.equal(existsSync(`${configPath}.crp.lock`), false); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test("bootstrapCodexConfig rejects an existing CRP lock without touching the config", async () => { + const tempDir = mkdtempSync(join(os.tmpdir(), "crp-codex-busy-")); + const configPath = join(tempDir, "config.toml"); + const lockPath = `${configPath}.crp.lock`; + const originalBytes = Buffer.from(CONFIG_AT_PROXY_NEEDING_PATCH, "utf8"); + const lockBytes = Buffer.from("existing lock owner\n", "utf8"); + + try { + writeFileSync(configPath, originalBytes); + chmodSync(configPath, 0o640); + writeFileSync(lockPath, lockBytes); + const originalMode = statSync(configPath).mode & 0o777; + + await assert.rejects( + () => bootstrapCodexConfig({ configPath, proxyUrl: PROXY_URL }), + (error) => error?.code === "CODEX_CONFIG_BUSY" + ); + assert.deepEqual(readFileSync(configPath), originalBytes); + assert.equal(statSync(configPath).mode & 0o777, originalMode); + assert.deepEqual(readFileSync(lockPath), lockBytes); + assert.deepEqual( + readdirSync(tempDir).filter((name) => name.endsWith(".bak") || name.endsWith(".tmp")), + [] + ); + + rmSync(lockPath); + const result = await bootstrapCodexConfig({ configPath, proxyUrl: PROXY_URL }); + assert.equal(result.changed, true); + assert.equal(existsSync(result.backupPath), true); + assert.equal(existsSync(lockPath), false); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test("bootstrapCodexConfig preserves the original and removes its temp file when rename fails", async () => { + const tempDir = mkdtempSync(join(os.tmpdir(), "crp-codex-rename-failure-")); + const configPath = join(tempDir, "config.toml"); + const originalBytes = Buffer.from(CONFIG_AT_PROXY_NEEDING_PATCH, "utf8"); + const renameError = new Error("forced atomic rename failure"); + + try { + writeFileSync(configPath, originalBytes); + chmodSync(configPath, 0o640); + const originalMode = statSync(configPath).mode & 0o777; + + await assert.rejects( + () => bootstrapCodexConfig({ + configPath, + proxyUrl: PROXY_URL, + now: () => new Date("2026-07-10T12:36:56.789Z"), + fileOperations: { + ...realFileOperations, + renameSync(source, target, ...args) { + if (target === configPath) throw renameError; + return realFileOperations.renameSync(source, target, ...args); + } + } + }), + (error) => error?.code === "CODEX_CONFIG_WRITE_FAILED" + && error?.cause === renameError + ); + + assert.deepEqual(readFileSync(configPath), originalBytes); + assert.equal(statSync(configPath).mode & 0o777, originalMode); + assert.deepEqual( + readdirSync(tempDir).filter((name) => name.endsWith(".tmp")), + [] + ); + assert.equal(existsSync(`${configPath}.crp.lock`), false); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test("bootstrapCodexConfig preserves an external source change detected before rename", async () => { + const tempDir = mkdtempSync(join(os.tmpdir(), "crp-codex-source-change-")); + const configPath = join(tempDir, "config.toml"); + const originalBytes = Buffer.from(CONFIG_AT_PROXY_NEEDING_PATCH, "utf8"); + const externalBytes = Buffer.from('model_provider = "external"\n', "utf8"); + let sourceReadCount = 0; + const sourceDescriptors = new Set(); + let renameCalled = false; + + try { + writeFileSync(configPath, originalBytes); + + await assert.rejects( + () => bootstrapCodexConfig({ + configPath, + proxyUrl: PROXY_URL, + fileOperations: { + ...realFileOperations, + openSync(path, ...args) { + const descriptor = realFileOperations.openSync(path, ...args); + if (path === configPath) sourceDescriptors.add(descriptor); + return descriptor; + }, + closeSync(descriptor) { + sourceDescriptors.delete(descriptor); + return realFileOperations.closeSync(descriptor); + }, + readFileSync(target, ...args) { + if (target === configPath || sourceDescriptors.has(target)) { + sourceReadCount += 1; + if (sourceReadCount === 2) { + realFileOperations.writeFileSync(configPath, externalBytes); + } + } + return realFileOperations.readFileSync(target, ...args); + }, + renameSync(source, target, ...args) { + if (target === configPath) renameCalled = true; + return realFileOperations.renameSync(source, target, ...args); + } + } + }), + (error) => error?.code === "CODEX_CONFIG_CHANGED" + ); + + assert.equal(sourceReadCount, 2); + assert.equal(renameCalled, false); + assert.deepEqual(readFileSync(configPath), externalBytes); + assert.deepEqual( + readdirSync(tempDir).filter((name) => name.endsWith(".tmp")), + [] + ); + assert.equal(existsSync(`${configPath}.crp.lock`), false); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test("bootstrapCodexConfig keeps both backups when changes share a timestamp", async () => { + const tempDir = mkdtempSync(join(os.tmpdir(), "crp-codex-backup-collision-")); + const configPath = join(tempDir, "config.toml"); + const original = CONFIG_AT_PROXY_NEEDING_PATCH; + const fixedNow = () => new Date("2026-07-10T12:37:56.789Z"); + + try { + writeFileSync(configPath, original, "utf8"); + + const first = await bootstrapCodexConfig({ + configPath, + proxyUrl: PROXY_URL, + now: fixedNow + }); + const firstPatched = readFileSync(configPath, "utf8"); + const secondSource = firstPatched.replace('name = "OpenAI"', 'name = "Legacy"'); + writeFileSync(configPath, secondSource, "utf8"); + const second = await bootstrapCodexConfig({ + configPath, + proxyUrl: PROXY_URL, + now: fixedNow + }); + + assert.equal(first.backupPath, `${configPath}.20260710-123756.bak`); + assert.equal(second.backupPath, `${configPath}.20260710-123756.1.bak`); + assert.equal(readFileSync(first.backupPath, "utf8"), original); + assert.equal(readFileSync(second.backupPath, "utf8"), secondSource); + assert.equal( + readFileSync(configPath, "utf8"), + patchCodexConfigText(secondSource, PROXY_URL) + ); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test("bootstrapCodexConfig rejects an existing config symlink without changing its target", async (t) => { + if (process.platform === "win32") { + t.skip("Windows symlink creation requires platform privileges"); + return; + } + const homeDir = mkdtempSync(join(os.tmpdir(), "crp-codex-config-symlink-")); + const codexDir = join(homeDir, ".codex"); + const configPath = join(codexDir, "config.toml"); + const targetPath = join(homeDir, "target.toml"); + const targetBytes = Buffer.from('model_provider = "external"\n', "utf8"); + + try { + mkdirSync(codexDir, { mode: 0o700 }); + writeFileSync(targetPath, targetBytes); + realFileOperations.symlinkSync(targetPath, configPath); + + await assert.rejects( + () => bootstrapCodexConfig({ configPath, proxyUrl: PROXY_URL }), + (error) => error?.code === "CODEX_CONFIG_READ_FAILED" + ); + + assert.equal(realFileOperations.lstatSync(configPath).isSymbolicLink(), true); + assert.deepEqual(readFileSync(targetPath), targetBytes); + assert.equal(existsSync(`${configPath}.crp.lock`), false); + assert.deepEqual( + readdirSync(codexDir).filter((name) => name.endsWith(".bak") || name.endsWith(".tmp")), + [] + ); + } finally { + rmSync(homeDir, { recursive: true, force: true }); + } +}); + +test("bootstrapCodexConfig rejects invalid UTF-8 before backup, journal, or config writes", async (t) => { + const homeDir = mkdtempSync(join(os.tmpdir(), "crp-codex-invalid-utf8-")); + const codexDir = join(homeDir, ".codex"); + const configPath = join(codexDir, "config.toml"); + const bytes = Buffer.concat([ + Buffer.from(patchCodexConfigText("", PROXY_URL), "utf8"), + Buffer.from("# invalid utf8 ", "utf8"), + Buffer.from([0xff]), + Buffer.from("\n", "utf8") + ]); + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + mkdirSync(codexDir, { mode: 0o700 }); + writeFileSync(configPath, bytes, { mode: 0o600 }); + + await assert.rejects( + () => bootstrapCodexConfig({ configPath, proxyUrl: PROXY_URL }), + (error) => error?.code === "CODEX_CONFIG_READ_FAILED" + ); + + assert.deepEqual(readFileSync(configPath), bytes); + assert.deepEqual(readdirSync(codexDir), ["config.toml"]); +}); + +for (const replacementPhase of ["after-read", "after-backup"]) { + test(`bootstrapCodexConfig rejects a same-byte inode replacement ${replacementPhase}`, async (t) => { + if (process.platform === "win32") { + t.skip("POSIX file identity semantics are required for this replacement-race fixture"); + return; + } + const homeDir = mkdtempSync(join(os.tmpdir(), `crp-codex-config-${replacementPhase}-`)); + const codexDir = join(homeDir, ".codex"); + const configPath = join(codexDir, "config.toml"); + const originalBytes = Buffer.from(CONFIG_AT_PROXY_NEEDING_PATCH, "utf8"); + let replaced = false; + let replacementObserved = false; + const replacedIdentity = (identity) => { + const changed = Object.create(identity); + Object.defineProperty(changed, "ino", { + value: typeof identity.ino === "bigint" ? identity.ino + 1n : identity.ino + 1 + }); + return changed; + }; + + try { + mkdirSync(codexDir, { mode: 0o700 }); + writeFileSync(configPath, originalBytes); + chmodSync(configPath, 0o640); + + await assert.rejects( + () => bootstrapCodexConfig({ + configPath, + proxyUrl: PROXY_URL, + now: () => new Date("2026-07-10T12:38:56.789Z"), + fileOperations: { + ...realFileOperations, + openSync(path, ...args) { + const descriptor = realFileOperations.openSync(path, ...args); + if (replacementPhase === "after-read" && path === configPath) replaced = true; + if (replacementPhase === "after-backup" + && path.startsWith(`${configPath}.`) && path.endsWith(".bak")) replaced = true; + return descriptor; + }, + lstatSync(path, ...args) { + const identity = realFileOperations.lstatSync(path, ...args); + if (path === configPath && replaced && !replacementObserved) { + replacementObserved = true; + return replacedIdentity(identity); + } + return identity; + } + } + }), + (error) => error?.code === "CODEX_CONFIG_CHANGED" + ); + + assert.equal(replaced, true); + assert.deepEqual(readFileSync(configPath), originalBytes); + if (process.platform !== "win32") { + assert.equal(statSync(configPath).mode & 0o777, 0o640); + } + assert.equal(existsSync(`${configPath}.crp.lock`), false); + assert.equal( + readdirSync(codexDir).filter((name) => name.endsWith(".bak")).length, + replacementPhase === "after-backup" ? 1 : 0 + ); + } finally { + rmSync(homeDir, { recursive: true, force: true }); + } + }); +} + +test("bootstrap plans from the locked root provider and journals before config publication", async (t) => { + const homeDir = mkdtempSync(join(os.tmpdir(), "crp-codex-history-bootstrap-")); + const codexRoot = join(homeDir, ".codex"); + const configPath = join(codexRoot, "config.toml"); + const pendingDir = join(codexRoot, ".crp-history-repair"); + const pendingPath = join(pendingDir, "pending.json"); + const original = [ + 'model_provider = "legacy"', + "", + "[model_providers.legacy]", + 'name = "Legacy"', + 'base_url = "https://legacy.example/v1"', + "", + "[model_providers.OpenAI]", + 'name = "OpenAI"', + `base_url = "${PROXY_URL}"`, + 'wire_api = "responses"', + "requires_openai_auth = true", + "" + ].join("\n"); + const target = patchCodexConfigText(original, PROXY_URL); + const planInputs = []; + const events = []; + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + mkdirSync(codexRoot, { recursive: true }); + writeFileSync(configPath, original, "utf8"); + + const result = await bootstrapCodexConfig({ + configPath, + proxyUrl: PROXY_URL, + historyRepair: { + plan(input) { + planInputs.push(input); + return planCodexProviderTransition(input); + }, + hasPending() { + return false; + }, + async run(input) { + events.push("repair-started"); + assert.deepEqual(input.currentConfigBytes, Buffer.from(original)); + assert.deepEqual(input.targetConfigBytes, Buffer.from(target)); + assert.equal(input.transition.required, true); + assert.equal(readFileSync(configPath, "utf8"), original); + mkdirSync(pendingDir, { mode: 0o700 }); + writeFileSync(pendingPath, "pending-before-config\n", { mode: 0o600 }); + events.push("journal-created"); + const publishResult = await input.publishConfig(); + events.push("config-published"); + assert.equal(readFileSync(configPath, "utf8"), target); + rmSync(pendingDir, { recursive: true }); + return { + handled: true, + configPublished: true, + publishResult, + historyRepair: { + required: true, + completed: true, + resumed: false, + backupCreated: true, + rolloutFiles: 2, + rolloutRecords: 3, + sqliteFiles: 1, + sqliteRows: 4, + encryptedContentDetected: false + } + }; + } + } + }); + + assert.equal(planInputs.length, 1); + assert.equal(planInputs[0].sourceExists, true); + assert.equal(planInputs[0].sourceText, original); + assert.equal(planInputs[0].targetText, target); + assert.equal(planInputs[0].targetProvider, "OpenAI"); + assert.equal(planInputs[0].targetBaseUrl, PROXY_URL); + assert.deepEqual(events, ["repair-started", "journal-created", "config-published"]); + assert.equal(existsSync(pendingPath), false); + assert.equal(result.changed, true); + assert.equal(typeof result.backupPath, "string"); + assert.deepEqual(result.historyRepair, { + required: true, + completed: true, + resumed: false, + backupCreated: true, + rolloutFiles: 2, + rolloutRecords: 3, + sqliteFiles: 1, + sqliteRows: 4, + encryptedContentDetected: false + }); +}); + +test("bootstrap skips history repair for the same effective URL and first creation", async (t) => { + const homeDir = mkdtempSync(join(os.tmpdir(), "crp-codex-history-skip-")); + const existingRoot = join(homeDir, "existing", ".codex"); + const newRoot = join(homeDir, "new", ".codex"); + const existingConfigPath = join(existingRoot, "config.toml"); + const newConfigPath = join(newRoot, "config.toml"); + const matching = `${patchCodexConfigText("", PROXY_URL)}\n[model_providers.unused]\nbase_url = "https://unused.example/v1"\n`; + const planInputs = []; + let runCalls = 0; + const historyRepair = { + plan(input) { + planInputs.push(input); + return planCodexProviderTransition(input); + }, + hasPending() { + return false; + }, + async run() { + runCalls += 1; + assert.fail("A non-transition bootstrap must not run history repair"); + } + }; + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + mkdirSync(existingRoot, { recursive: true }); + mkdirSync(dirname(newRoot), { recursive: true }); + writeFileSync(existingConfigPath, matching, "utf8"); + + const matchingResult = await bootstrapCodexConfig({ + configPath: existingConfigPath, + proxyUrl: PROXY_URL, + historyRepair + }); + const creationResult = await bootstrapCodexConfig({ + configPath: newConfigPath, + proxyUrl: PROXY_URL, + historyRepair + }); + + assert.deepEqual(matchingResult, expectedBootstrap(false, null)); + assert.deepEqual(creationResult, expectedBootstrap(true, null)); + assert.equal(runCalls, 0); + assert.equal(planInputs.length, 2); + assert.equal(planInputs[0].sourceExists, true); + assert.equal(planInputs[1].sourceExists, false); + assert.equal(planInputs[0].sourceText, matching); + assert.equal(planInputs[1].sourceText, ""); +}); + +test("bootstrap resumes pending history repair after config already matches", async (t) => { + const homeDir = mkdtempSync(join(os.tmpdir(), "crp-codex-history-resume-")); + const codexRoot = join(homeDir, ".codex"); + const configPath = join(codexRoot, "config.toml"); + const matching = patchCodexConfigText("", PROXY_URL); + let runCalls = 0; + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + mkdirSync(codexRoot, { recursive: true }); + writeFileSync(configPath, matching, "utf8"); + const originalMtime = statSync(configPath, { bigint: true }).mtimeNs; + + const result = await bootstrapCodexConfig({ + configPath, + proxyUrl: PROXY_URL, + historyRepair: { + plan: planCodexProviderTransition, + hasPending({ codexRoot: receivedRoot }) { + assert.equal(receivedRoot, codexRoot); + return true; + }, + async run(input) { + runCalls += 1; + assert.equal(input.transition.required, false); + assert.deepEqual(input.currentConfigBytes, Buffer.from(matching)); + assert.deepEqual(input.targetConfigBytes, Buffer.from(matching)); + assert.equal(readFileSync(configPath, "utf8"), matching); + return { + handled: true, + configPublished: true, + publishResult: undefined, + historyRepair: { + required: true, + completed: true, + resumed: true, + backupCreated: true, + rolloutFiles: 1, + rolloutRecords: 2, + sqliteFiles: 1, + sqliteRows: 3, + encryptedContentDetected: false + } + }; + } + } + }); + + assert.equal(runCalls, 1); + assert.deepEqual(result, { + changed: false, + backupPath: null, + historyRepair: { + required: true, + completed: true, + resumed: true, + backupCreated: true, + rolloutFiles: 1, + rolloutRecords: 2, + sqliteFiles: 1, + sqliteRows: 3, + encryptedContentDetected: false + } + }); + assert.equal(readFileSync(configPath, "utf8"), matching); + assert.equal(statSync(configPath, { bigint: true }).mtimeNs, originalMtime); +}); + +test("ambiguous root provider binding fails closed before history or config writes", async (t) => { + const homeDir = mkdtempSync(join(os.tmpdir(), "crp-codex-history-ambiguous-")); + const codexRoot = join(homeDir, ".codex"); + const configPath = join(codexRoot, "config.toml"); + const ambiguous = [ + 'model_provider = "one"', + 'model_provider = "two"', + "", + "[model_providers.one]", + 'base_url = "https://one.example/v1"', + "", + "[model_providers.two]", + 'base_url = "https://two.example/v1"', + "" + ].join("\n"); + let runCalls = 0; + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + mkdirSync(codexRoot, { recursive: true }); + writeFileSync(configPath, ambiguous, "utf8"); + + await assert.rejects( + () => bootstrapCodexConfig({ + configPath, + proxyUrl: PROXY_URL, + historyRepair: { + plan: planCodexProviderTransition, + hasPending() { return false; }, + async run() { runCalls += 1; } + } + }), + (error) => error?.code === "CODEX_HISTORY_REPAIR_INVALID" + ); + + assert.equal(runCalls, 0); + assert.equal(readFileSync(configPath, "utf8"), ambiguous); + assert.equal(existsSync(join(codexRoot, ".crp-history-repair")), false); + assert.deepEqual(readdirSync(codexRoot), ["config.toml"]); +}); + +test("mixed implicit and explicit selected-provider bindings fail before writes", async (t) => { + const homeDir = mkdtempSync(join(os.tmpdir(), "crp-codex-history-mixed-binding-")); + const codexRoot = join(homeDir, ".codex"); + const configPath = join(codexRoot, "config.toml"); + const source = [ + 'model_provider = "legacy"', + 'model_providers.legacy.base_url = "https://legacy.example/v1"', + "[model_providers.legacy]", + 'name = "Legacy"', + "" + ].join("\n"); + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + mkdirSync(codexRoot, { recursive: true }); + writeFileSync(configPath, source, "utf8"); + + await assert.rejects( + () => bootstrapCodexConfig({ configPath, proxyUrl: PROXY_URL }), + (error) => error?.code === "CODEX_HISTORY_REPAIR_INVALID" + ); + + assert.equal(readFileSync(configPath, "utf8"), source); + assert.deepEqual(readdirSync(codexRoot), ["config.toml"]); +}); + +test("committed repair failure preserves the new config and pending journal", async (t) => { + const secret = "history-repair-private-complete-secret"; + const homeDir = mkdtempSync(join(os.tmpdir(), "crp-codex-history-degraded-")); + const codexRoot = join(homeDir, ".codex"); + const configPath = join(codexRoot, "config.toml"); + const pendingDir = join(codexRoot, ".crp-history-repair"); + const pendingPath = join(pendingDir, "pending.json"); + const original = [ + 'model_provider = "legacy"', + "", + "[model_providers.legacy]", + 'base_url = "https://legacy.example/v1"', + "" + ].join("\n"); + const target = patchCodexConfigText(original, PROXY_URL); + let publicFailure; + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + mkdirSync(codexRoot, { recursive: true }); + writeFileSync(configPath, original, "utf8"); + + const caught = await bootstrapCodexConfig({ + configPath, + proxyUrl: PROXY_URL, + historyRepair: { + plan: planCodexProviderTransition, + hasPending() { return false; }, + async run(input) { + mkdirSync(pendingDir, { mode: 0o700 }); + writeFileSync(pendingPath, `${secret}\n`, { mode: 0o600 }); + await input.publishConfig(); + throw new CrpError( + "CODEX_HISTORY_REPAIR_COMMITTED_DEGRADED", + "Codex configuration was updated, but history repair remains pending.", + "Retry crp start to resume Codex history repair before using the proxy.", + { + status: 500, + details: { committed: true, degraded: true, pending: true }, + cause: new Error(`${secret} ${pendingPath}`) + } + ); + } + } + }).then( + () => null, + (error) => error + ); + publicFailure = toPublicError(caught, "request-history-degraded"); + const publicBytes = JSON.stringify(publicFailure); + + assert.equal(publicBytes.includes(secret), false); + assert.equal(publicBytes.includes(pendingPath), false); + assert.equal(caught?.code, "CODEX_HISTORY_REPAIR_COMMITTED_DEGRADED"); + assert.deepEqual(caught?.details, { committed: true, degraded: true, pending: true }); + assert.equal(readFileSync(configPath, "utf8"), target); + assert.equal(existsSync(pendingPath), true); + assert.deepEqual(publicFailure.error.details, { + committed: true, + degraded: true, + pending: true + }); +}); + +test("bootstrap retains its config lock when no pending marker can be preserved", async (t) => { + const homeDir = mkdtempSync(join(os.tmpdir(), "crp-codex-retain-config-lock-")); + const codexRoot = join(homeDir, ".codex"); + const configPath = join(codexRoot, "config.toml"); + const lockPath = `${configPath}.crp.lock`; + const source = [ + 'model_provider = "legacy"', + "[model_providers.legacy]", + 'base_url = "https://legacy.example/v1"', + "" + ].join("\n"); + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + mkdirSync(codexRoot, { recursive: true }); + writeFileSync(configPath, source, "utf8"); + + const error = await bootstrapCodexConfig({ + configPath, + proxyUrl: PROXY_URL, + historyRepair: { + plan: planCodexProviderTransition, + hasPending() { return false; }, + async run(input) { + const binding = { + operationId: "retain-config-lock", + sourceConfigSha256: input.transition.sourceConfigSha256, + targetConfigSha256: input.transition.targetConfigSha256, + pendingRequired: true + }; + input.beforeJournalPublish(binding); + input.publishConfig(); + input.beforePendingClear(binding); + const failure = new CrpError( + "CODEX_HISTORY_REPAIR_COMMITTED_DEGRADED", + "The Codex provider change was published, but history repair is incomplete.", + "Keep the pending repair state and retry before starting Codex.", + { + status: 500, + details: { committed: true, degraded: true, pending: true } + } + ); + Object.defineProperty(failure, "retainConfigLock", { value: true }); + throw failure; + } + } + }).then(() => null, (failure) => failure); + + assert.equal(error?.code, "CODEX_HISTORY_REPAIR_COMMITTED_DEGRADED"); + assert.deepEqual(error?.details, { committed: true, degraded: true, pending: true }); + assert.equal(existsSync(lockPath), true); + assert.equal(readFileSync(configPath, "utf8"), patchCodexConfigText(source, PROXY_URL)); + assert.equal(JSON.stringify(error).includes("retainConfigLock"), false); +}); + +for (const crashPhase of ["after-journal", "after-config"]) { + test(`bootstrap recovers an exact pending-bound dead lock after a crash ${crashPhase}`, async (t) => { + const homeDir = mkdtempSync(join(os.tmpdir(), `crp-codex-lock-recovery-${crashPhase}-`)); + const codexRoot = join(homeDir, ".codex"); + const configPath = join(codexRoot, "config.toml"); + const lockPath = `${configPath}.crp.lock`; + const pendingPath = join(codexRoot, ".crp-history-repair", "pending.json"); + const rolloutPath = join(codexRoot, "sessions", `rollout-${crashPhase}.jsonl`); + const original = [ + 'model_provider = "legacy"', + "", + "[model_providers.legacy]", + 'base_url = "https://legacy.example/v1"', + "" + ].join("\n"); + const staleOwner = Object.freeze({ + pid: 900001, + startedAt: "2026-07-16T00:00:00.000Z", + instanceId: `stale-${crashPhase}` + }); + const replacementOwner = Object.freeze({ + pid: 900002, + startedAt: "2026-07-16T00:01:00.000Z", + instanceId: `replacement-${crashPhase}` + }); + let injectedCrash = false; + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + mkdirSync(dirname(rolloutPath), { recursive: true, mode: 0o700 }); + writeFileSync(configPath, original, { mode: 0o600 }); + writeFileSync(rolloutPath, `${JSON.stringify({ + type: "session_meta", + payload: { id: "fixture", model_provider: "legacy" } + })}\n`, { mode: 0o600 }); + + const crashOperations = { + ...realFileOperations, + openSync(path, ...args) { + if (crashPhase === "after-journal" + && path.startsWith(`${configPath}.`) && path.endsWith(".bak") && !injectedCrash) { + injectedCrash = true; + throw new Error("simulated crash before config publication"); + } + if (crashPhase === "after-config" + && path.startsWith(join(dirname(rolloutPath), `.rollout-${crashPhase}.jsonl.`)) + && path.endsWith(".tmp") && !injectedCrash) { + injectedCrash = true; + throw new Error("simulated crash after config publication"); + } + return realFileOperations.openSync(path, ...args); + }, + renameSync(source, destination) { + if (source === lockPath && destination.endsWith(".release")) { + throw new Error("simulate process death before lock cleanup"); + } + return realFileOperations.renameSync(source, destination); + } + }; + + await assert.rejects(() => bootstrapCodexConfig({ + configPath, + proxyUrl: PROXY_URL, + fileOperations: crashOperations, + configLockOwner: staleOwner + })); + assert.equal(injectedCrash, true); + assert.equal(existsSync(pendingPath), true); + assert.equal(existsSync(lockPath), true); + const pending = JSON.parse(readFileSync(pendingPath, "utf8")); + const staleLockBytes = readFileSync(lockPath); + const staleLock = JSON.parse(staleLockBytes); + assert.deepEqual(staleLock.owner, staleOwner); + assert.deepEqual(staleLock.binding, { + operationId: pending.operationId, + sourceConfigSha256: pending.sourceConfigSha256, + targetConfigSha256: pending.targetConfigSha256, + pendingRequired: true + }); + + for (const liveness of ["live", "unknown"]) { + await assert.rejects( + () => bootstrapCodexConfig({ + configPath, + proxyUrl: PROXY_URL, + configLockOwner: replacementOwner, + configLockOwnerLiveness(owner) { + assert.deepEqual(owner, staleOwner); + return liveness; + } + }), + (error) => error?.code === "CODEX_CONFIG_BUSY" + ); + assert.deepEqual(readFileSync(lockPath), staleLockBytes); + assert.equal(existsSync(pendingPath), true); + } + await assert.rejects( + () => bootstrapCodexConfig({ + configPath, + proxyUrl: PROXY_URL, + configLockOwner: replacementOwner, + configLockOwnerLiveness() { + throw new Error("probe unavailable"); + } + }), + (error) => error?.code === "CODEX_CONFIG_BUSY" + ); + assert.deepEqual(readFileSync(lockPath), staleLockBytes); + + if (crashPhase === "after-journal") { + const mismatched = structuredClone(staleLock); + mismatched.binding.targetConfigSha256 = "0".repeat(64); + const mismatchedBytes = Buffer.from(`${JSON.stringify(mismatched)}\n`); + writeFileSync(lockPath, mismatchedBytes); + await assert.rejects( + () => bootstrapCodexConfig({ + configPath, + proxyUrl: PROXY_URL, + configLockOwner: replacementOwner, + configLockOwnerLiveness() { return "dead"; } + }), + (error) => error?.code === "CODEX_HISTORY_REPAIR_CONFLICT" + ); + assert.deepEqual(readFileSync(lockPath), mismatchedBytes); + assert.equal(existsSync(pendingPath), true); + writeFileSync(lockPath, staleLockBytes); + } + + const recovered = await bootstrapCodexConfig({ + configPath, + proxyUrl: PROXY_URL, + configLockOwner: replacementOwner, + configLockOwnerLiveness(owner) { + assert.deepEqual(owner, staleOwner); + return "dead"; + } + }); + assert.equal(recovered.historyRepair.completed, true); + assert.equal(recovered.historyRepair.resumed, true); + assert.equal(existsSync(lockPath), false); + assert.equal(existsSync(pendingPath), false); + assert.equal( + JSON.parse(readFileSync(rolloutPath, "utf8")).payload.model_provider, + "OpenAI" + ); + }); +} + +test("bootstrap recovers an exact dead lock left in the initial acquired phase", async (t) => { + const homeDir = mkdtempSync(join(os.tmpdir(), "crp-codex-lock-recovery-acquired-")); + const codexRoot = join(homeDir, ".codex"); + const configPath = join(codexRoot, "config.toml"); + const lockPath = `${configPath}.crp.lock`; + const original = 'model_provider = "legacy"\n'; + const staleOwner = { + pid: 900011, + startedAt: "2026-07-16T00:02:00.000Z", + instanceId: "stale-acquired" + }; + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + mkdirSync(codexRoot, { mode: 0o700 }); + writeFileSync(configPath, original, { mode: 0o600 }); + + await assert.rejects(() => bootstrapCodexConfig({ + configPath, + proxyUrl: PROXY_URL, + configLockOwner: staleOwner, + fileOperations: { + ...realFileOperations, + renameSync(source, destination) { + if (source === lockPath && destination.endsWith(".release")) { + throw new Error("simulate acquired-phase process death"); + } + return realFileOperations.renameSync(source, destination); + } + }, + historyRepair: { + inspectPending() { return null; }, + hasPending() { return false; }, + plan() { throw new Error("crash before transition binding"); }, + async run() { assert.fail("run must not begin"); } + } + })); + const stale = JSON.parse(readFileSync(lockPath, "utf8")); + assert.equal(stale.phase, "acquired"); + assert.equal(stale.binding, null); + + const recovered = await bootstrapCodexConfig({ + configPath, + proxyUrl: PROXY_URL, + configLockOwner: { + pid: 900012, + startedAt: "2026-07-16T00:03:00.000Z", + instanceId: "replacement-acquired" + }, + configLockOwnerLiveness(owner) { + assert.deepEqual(owner, staleOwner); + return "dead"; + } + }); + assert.equal(recovered.changed, true); + assert.equal(existsSync(lockPath), false); +}); + +test("bootstrap recovers a dead completed lock after pending was durably cleared", async (t) => { + const homeDir = mkdtempSync(join(os.tmpdir(), "crp-codex-lock-recovery-completed-")); + const codexRoot = join(homeDir, ".codex"); + const configPath = join(codexRoot, "config.toml"); + const lockPath = `${configPath}.crp.lock`; + const pendingPath = join(codexRoot, ".crp-history-repair", "pending.json"); + const rolloutPath = join(codexRoot, "sessions", "rollout-completed.jsonl"); + const original = [ + 'model_provider = "legacy"', + "[model_providers.legacy]", + 'base_url = "https://legacy.example/v1"', + "" + ].join("\n"); + const staleOwner = { + pid: 900021, + startedAt: "2026-07-16T00:04:00.000Z", + instanceId: "stale-completed" + }; + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + mkdirSync(dirname(rolloutPath), { recursive: true, mode: 0o700 }); + writeFileSync(configPath, original, { mode: 0o600 }); + writeFileSync(rolloutPath, `${JSON.stringify({ + type: "session_meta", + payload: { id: "completed", model_provider: "legacy" } + })}\n`, { mode: 0o600 }); + + await assert.rejects(() => bootstrapCodexConfig({ + configPath, + proxyUrl: PROXY_URL, + configLockOwner: staleOwner, + fileOperations: { + ...realFileOperations, + renameSync(source, destination) { + if (source === lockPath && destination.endsWith(".release")) { + throw new Error("simulate death after pending clear"); + } + return realFileOperations.renameSync(source, destination); + } + } + })); + assert.equal(existsSync(pendingPath), false); + const stale = JSON.parse(readFileSync(lockPath, "utf8")); + assert.equal(stale.phase, "completed"); + assert.equal(stale.binding.pendingRequired, true); + const configBeforeRetry = readFileSync(configPath); + const rolloutBeforeRetry = readFileSync(rolloutPath); + + const recovered = await bootstrapCodexConfig({ + configPath, + proxyUrl: PROXY_URL, + configLockOwner: { + pid: 900022, + startedAt: "2026-07-16T00:05:00.000Z", + instanceId: "replacement-completed" + }, + configLockOwnerLiveness(owner) { + assert.deepEqual(owner, staleOwner); + return "dead"; + } + }); + assert.equal(recovered.changed, false); + assert.deepEqual(readFileSync(configPath), configBeforeRetry); + assert.deepEqual(readFileSync(rolloutPath), rolloutBeforeRetry); + assert.equal(existsSync(lockPath), false); +}); + +test("bootstrap recovers a dead prepared config-only lock after config publication", async (t) => { + const homeDir = mkdtempSync(join(os.tmpdir(), "crp-codex-lock-recovery-config-only-")); + const codexRoot = join(homeDir, ".codex"); + const configPath = join(codexRoot, "config.toml"); + const lockPath = `${configPath}.crp.lock`; + const original = CONFIG_AT_PROXY_NEEDING_PATCH; + const staleOwner = { + pid: 900031, + startedAt: "2026-07-16T00:06:00.000Z", + instanceId: "stale-config-only" + }; + let lockTempOpens = 0; + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + mkdirSync(codexRoot, { mode: 0o700 }); + writeFileSync(configPath, original, { mode: 0o600 }); + + await assert.rejects(() => bootstrapCodexConfig({ + configPath, + proxyUrl: PROXY_URL, + configLockOwner: staleOwner, + fileOperations: { + ...realFileOperations, + openSync(path, ...args) { + if (path.startsWith(join(codexRoot, ".config.toml.crp.lock.")) + && path.endsWith(".tmp")) { + lockTempOpens += 1; + if (lockTempOpens === 2) { + throw new Error("simulate death before config-only completion marker"); + } + } + return realFileOperations.openSync(path, ...args); + }, + renameSync(source, destination) { + if (source === lockPath && destination.endsWith(".release")) { + throw new Error("simulate config-only process death"); + } + return realFileOperations.renameSync(source, destination); + } + } + })); + assert.equal(lockTempOpens, 2); + const stale = JSON.parse(readFileSync(lockPath, "utf8")); + assert.equal(stale.phase, "prepared"); + assert.equal(stale.binding.pendingRequired, false); + assert.equal(readFileSync(configPath, "utf8"), patchCodexConfigText(original, PROXY_URL)); + + const recovered = await bootstrapCodexConfig({ + configPath, + proxyUrl: PROXY_URL, + configLockOwner: { + pid: 900032, + startedAt: "2026-07-16T00:07:00.000Z", + instanceId: "replacement-config-only" + }, + configLockOwnerLiveness(owner) { + assert.deepEqual(owner, staleOwner); + return "dead"; + } + }); + assert.equal(recovered.changed, false); + assert.equal(existsSync(lockPath), false); +}); + +test("bootstrap durably publishes pending before the replacement config directory entry", async (t) => { + const homeDir = mkdtempSync(join(os.tmpdir(), "crp-codex-history-fsync-order-")); + const codexRoot = join(homeDir, ".codex"); + const configPath = join(codexRoot, "config.toml"); + const rolloutPath = join(codexRoot, "sessions", "rollout-fsync.jsonl"); + const pendingDirectory = join(codexRoot, ".crp-history-repair"); + const original = [ + 'model_provider = "legacy"', + "[model_providers.legacy]", + 'base_url = "https://legacy.example/v1"', + "" + ].join("\n"); + const events = []; + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + mkdirSync(dirname(rolloutPath), { recursive: true, mode: 0o700 }); + writeFileSync(configPath, original, { mode: 0o600 }); + writeFileSync(rolloutPath, `${JSON.stringify({ + type: "session_meta", + payload: { id: "fsync", model_provider: "legacy" } + })}\n`, { mode: 0o600 }); + + await bootstrapCodexConfig({ + configPath, + proxyUrl: PROXY_URL, + fileOperations: { + ...realFileOperations, + fsyncDirectorySync(path) { + events.push(path); + } + } + }); + + const pendingIndex = events.indexOf(pendingDirectory); + const configPublishIndex = events.lastIndexOf(codexRoot); + assert.ok(pendingIndex >= 0); + assert.ok(configPublishIndex > pendingIndex); +}); + +test("bootstrap fsyncs the config backup and parent before replacing config", async (t) => { + const homeDir = mkdtempSync(join(os.tmpdir(), "crp-codex-backup-fsync-order-")); + const codexRoot = join(homeDir, ".codex"); + const configPath = join(codexRoot, "config.toml"); + const descriptorPaths = new Map(); + const events = []; + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + mkdirSync(codexRoot, { mode: 0o700 }); + writeFileSync(configPath, CONFIG_AT_PROXY_NEEDING_PATCH, { mode: 0o640 }); + + const result = await bootstrapCodexConfig({ + configPath, + proxyUrl: PROXY_URL, + fileOperations: { + ...realFileOperations, + openSync(path, ...args) { + const descriptor = realFileOperations.openSync(path, ...args); + descriptorPaths.set(descriptor, path); + return descriptor; + }, + closeSync(descriptor) { + descriptorPaths.delete(descriptor); + return realFileOperations.closeSync(descriptor); + }, + fsyncSync(descriptor) { + events.push(["file-fsync", descriptorPaths.get(descriptor) ?? null]); + return realFileOperations.fsyncSync(descriptor); + }, + fsyncDirectorySync(path) { + events.push(["directory-fsync", path]); + }, + renameSync(source, destination) { + if (destination === configPath) events.push(["config-rename", destination]); + return realFileOperations.renameSync(source, destination); + } + } + }); + + const backupFileIndex = events.findIndex(([name, path]) => ( + name === "file-fsync" && path === result.backupPath + )); + const backupDirectoryIndex = events.findIndex(([name, path], index) => ( + index > backupFileIndex && name === "directory-fsync" && path === codexRoot + )); + const configRenameIndex = events.findIndex(([name]) => name === "config-rename"); + const configDirectoryIndex = events.findIndex(([name, path], index) => ( + index > configRenameIndex && name === "directory-fsync" && path === codexRoot + )); + assert.ok(backupFileIndex >= 0); + assert.ok(backupDirectoryIndex > backupFileIndex); + assert.ok(configRenameIndex > backupDirectoryIndex); + assert.ok(configDirectoryIndex > configRenameIndex); +}); + +for (const command of ["start", "install", "setup"]) { + test(`${command} safely rejects legacy direct-start options`, () => { + const homeDir = mkdtempSync(join(os.tmpdir(), `crp-${command}-home-`)); + const env = makeHomeEnv(homeDir); + const codexDir = join(homeDir, ".codex"); + const codexConfigPath = join(codexDir, "config.toml"); + const statePath = join(homeDir, ".codex-remote-proxy", "state.json"); + const originalConfig = 'model_provider = "custom"\n'; + const placeholderCredential = ["placeholder", "credential", randomUUID()].join("-"); + const upstreamBaseUrl = "http://127.0.0.1:1/v1"; + + try { + mkdirSync(codexDir, { recursive: true }); + writeFileSync(codexConfigPath, originalConfig, "utf8"); + + const result = runCrp([ + command, + "--json", + "--upstream-base-url", + upstreamBaseUrl, + "--api-key", + placeholderCredential + ], env); + + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); + const removed = command !== "start"; + const expectedError = { + ok: false, + command, + stage: null, + error: { + code: removed ? "CLI_COMMAND_REMOVED" : "CLI_INPUT_INVALID", + message: removed + ? "This CLI command has been removed." + : "The command input is invalid.", + action: removed + ? "Use `crp start` instead." + : "Review the command options and try again.", + details: {} + } + }; + assert.equal(result.stderr.includes(placeholderCredential), false); + assert.deepEqual(JSON.parse(result.stderr), expectedError); + assert.equal(result.stderr, `${JSON.stringify(expectedError, null, 2)}\n`); + assert.equal(readFileSync(codexConfigPath, "utf8"), originalConfig); + assert.equal(existsSync(statePath), false); + } finally { + rmSync(homeDir, { recursive: true, force: true }); + } + }); +} + +test("guide explains that Codex backups are created only for config changes", () => { + const homeDir = mkdtempSync(join(os.tmpdir(), "crp-guide-home-")); + try { + const result = runCrp(["guide", "--json"], makeHomeEnv(homeDir)); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal( + payload.notes.find((note) => note.includes("backup")), + "The start command creates a backup only when it changes ~/.codex/config.toml." + ); + } finally { + rmSync(homeDir, { recursive: true, force: true }); + } +}); + +test("getPaths derives every managed path from the injected home", () => { + const injectedHome = resolve(os.tmpdir(), "crp-injected-home"); + + assert.deepEqual(getPaths(injectedHome), { + globalHome: resolve(injectedHome, ".codex-remote-proxy"), + registryPath: resolve(injectedHome, ".codex-remote-proxy", "providers.json"), + modelCachePath: resolve( + injectedHome, + ".codex-remote-proxy", + "provider-model-cache.json" + ), + secretFallbackPath: resolve(injectedHome, ".codex-remote-proxy", "secrets.json"), + statePath: resolve(injectedHome, ".codex-remote-proxy", "state.json"), + controlTokenPath: resolve(injectedHome, ".codex-remote-proxy", "control-token"), + activityPath: resolve(injectedHome, ".codex-remote-proxy", "activity.jsonl"), + metricsPath: resolve(injectedHome, ".codex-remote-proxy", "metrics.json"), + logPath: resolve(injectedHome, ".codex-remote-proxy", "supervisor.log"), + codexConfigPath: resolve(injectedHome, ".codex", "config.toml"), + authPath: resolve(injectedHome, ".codex", "auth.json") + }); + assert.notEqual(getPaths(injectedHome).globalHome, getPaths().globalHome); +}); + +test("CrpError retains stable fields and toPublicError exposes only safe fields", () => { + const cause = new Error("private-cause-message"); + const error = new CrpError( + "PROVIDER_CONFLICT", + "That provider already exists.", + "Choose a different provider name.", + { status: 409, details: { field: "name" }, cause } + ); + + assert.equal(error.name, "CrpError"); + assert.equal(error.code, "PROVIDER_CONFLICT"); + assert.equal(error.message, "That provider already exists."); + assert.equal(error.action, "Choose a different provider name."); + assert.equal(error.status, 409); + assert.deepEqual(error.details, { field: "name" }); + assert.equal(error.cause, cause); + + const publicError = toPublicError(error, "request-known"); + assert.deepEqual(publicError, { + error: { + code: "PROVIDER_CONFLICT", + message: "That provider already exists.", + action: "Choose a different provider name.", + requestId: "request-known", + details: { field: "name" } + } + }); + assert.doesNotMatch(JSON.stringify(publicError), /cause|stack|private-cause-message/); +}); + +test("toPublicError replaces unknown errors without leaking their message or stack", () => { + const error = new Error("upstream returned a private credential"); + error.stack = "private stack trace"; + + const publicError = toPublicError(error, "request-unknown"); + + assert.deepEqual(publicError, { + error: { + code: "INTERNAL_ERROR", + message: "CRP could not complete the operation.", + action: "Open Activity for details.", + requestId: "request-unknown", + details: {} + } + }); + assert.doesNotMatch( + JSON.stringify(publicError), + /private credential|private stack trace|cause|stack/ + ); +}); diff --git a/node/test/codex-history-repair.test.mjs b/node/test/codex-history-repair.test.mjs new file mode 100644 index 0000000..cb01318 --- /dev/null +++ b/node/test/codex-history-repair.test.mjs @@ -0,0 +1,2395 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import * as realFileOperations from "node:fs"; +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + realpathSync, + rmSync, + statSync, + symlinkSync, + unlinkSync, + utimesSync, + writeFileSync +} from "node:fs"; +import os from "node:os"; +import { basename, dirname, join, relative, resolve, sep } from "node:path"; +import { backup as sqliteBackup, DatabaseSync } from "node:sqlite"; + +import { + hasPendingCodexHistoryRepair, + inspectCodexProviderBinding, + planCodexProviderTransition, + runCodexHistoryRepairTransition +} from "../src/codex/codex-history-repair.mjs"; + +const TARGET_PROVIDER = "OpenAI"; +const TARGET_BASE_URL = "http://127.0.0.1:15100"; +const FIXED_TIME = new Date("2025-01-02T03:04:05.000Z"); +const SECRET = "history-secret-sentinel-93fce7"; +const HISTORY_MODULE_PATH = resolve(import.meta.dirname, "../src/codex/codex-history-repair.mjs"); + +function sha256(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} + +function sourceConfig({ + provider = "legacy.provider", + baseUrl = "https://legacy.example/v1", + lineEnding = "\n", + quotedSection = true +} = {}) { + const section = quotedSection + ? `[model_providers."${provider}"]` + : `[model_providers.${provider}]`; + return Buffer.from([ + `model_provider = "${provider}"`, + "model = \"legacy-model\"", + "", + section, + `base_url = "${baseUrl}"`, + "wire_api = \"responses\"", + "" + ].join(lineEnding), "utf8"); +} + +function targetConfig() { + return Buffer.from([ + 'model_provider = "OpenAI"', + "", + "[model_providers.OpenAI]", + 'name = "OpenAI"', + `base_url = "${TARGET_BASE_URL}"`, + 'wire_api = "responses"', + "requires_openai_auth = true", + "" + ].join("\n"), "utf8"); +} + +function makeHarness(label) { + const tmpRoot = realpathSync(os.tmpdir()); + const sandbox = mkdtempSync(join(tmpRoot, `crp-history-${label}-`)); + const relation = relative(tmpRoot, realpathSync(sandbox)); + assert.equal(relation.startsWith(`..${sep}`) || relation === "..", false); + assert.equal(resolve(tmpRoot, relation), realpathSync(sandbox)); + + const codexRoot = join(sandbox, ".codex"); + mkdirSync(codexRoot, { mode: 0o700 }); + return { + sandbox, + codexRoot, + configPath: join(codexRoot, "config.toml"), + cleanup() { + rmSync(sandbox, { recursive: true, force: true }); + } + }; +} + +function writePrivate(path, bytes, mode = 0o600) { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + writeFileSync(path, bytes, { mode }); + if (process.platform !== "win32") chmodSync(path, mode); +} + +function rolloutLine(provider, id, extra = {}) { + return JSON.stringify({ + type: "session_meta", + payload: { + id, + model_provider: provider, + cwd: `/workspace/${SECRET}/${id}`, + ...extra + } + }); +} + +function writeRollout(path, bytes, { mode = 0o640, mtime = FIXED_TIME } = {}) { + writePrivate(path, bytes, mode); + utimesSync(path, mtime, mtime); +} + +function createThreadsDatabase(path, rows, { + columns = true, + table = true, + triggerSecret = null, + triggerIgnore = false +} = {}) { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + const database = new DatabaseSync(path); + try { + if (!table) { + database.exec("CREATE TABLE unrelated (id TEXT PRIMARY KEY, value TEXT)"); + return; + } + if (!columns) { + database.exec("CREATE TABLE threads (id TEXT PRIMARY KEY, cwd TEXT, has_user_event INTEGER)"); + for (const row of rows) { + database.prepare("INSERT INTO threads VALUES (?, ?, ?)") + .run(row.id, row.cwd, row.hasUserEvent); + } + return; + } + database.exec(`CREATE TABLE threads ( + id TEXT PRIMARY KEY, + model_provider TEXT, + cwd TEXT, + has_user_event INTEGER, + title TEXT + )`); + const insert = database.prepare("INSERT INTO threads VALUES (?, ?, ?, ?, ?)"); + for (const row of rows) { + insert.run( + row.id, + row.provider, + row.cwd, + row.hasUserEvent, + row.title ?? `title-${SECRET}` + ); + } + if (triggerSecret || triggerIgnore) { + database.exec(`CREATE TRIGGER block_history_repair + BEFORE UPDATE OF model_provider ON threads + BEGIN SELECT RAISE(${triggerIgnore ? "IGNORE" : `ABORT, '${triggerSecret}'`}); END`); + } + } finally { + database.close(); + } + if (process.platform !== "win32") chmodSync(path, 0o600); +} + +function readThreads(path) { + const database = new DatabaseSync(path, { readOnly: true }); + try { + return database.prepare( + "SELECT id, model_provider, cwd, has_user_event, title FROM threads ORDER BY id" + ).all().map((row) => ({ ...row })); + } finally { + database.close(); + } +} + +function removeTrigger(path) { + const database = new DatabaseSync(path); + try { + database.exec("DROP TRIGGER IF EXISTS block_history_repair"); + } finally { + database.close(); + } +} + +function mtimeNs(path) { + return statSync(path, { bigint: true }).mtimeNs; +} + +function captureFile(path) { + return { + bytes: readFileSync(path), + mtime: mtimeNs(path), + mode: statSync(path).mode & 0o777 + }; +} + +function assertUnchanged(path, snapshot) { + assert.deepEqual(readFileSync(path), snapshot.bytes); + assert.equal(mtimeNs(path), snapshot.mtime); + if (process.platform !== "win32") { + assert.equal(statSync(path).mode & 0o777, snapshot.mode); + } +} + +function plan(sourceBytes, { sourceExists = true } = {}) { + const targetBytes = targetConfig(); + return planCodexProviderTransition({ + sourceExists, + sourceText: sourceBytes.toString("utf8"), + targetText: targetBytes.toString("utf8"), + targetProvider: TARGET_PROVIDER, + targetBaseUrl: TARGET_BASE_URL + }); +} + +async function executeTransition(harness, sourceBytes, { + transition = plan(sourceBytes), + currentConfigBytes = sourceBytes, + targetConfigBytes = targetConfig(), + publishConfig, + fileOperations, + databaseOperations, + beforeJournalPublish, + beforePendingClear, + assertConfigLock, + id = "op-test-001" +} = {}) { + return runCodexHistoryRepairTransition({ + codexRoot: harness.codexRoot, + currentConfigBytes, + targetConfigBytes, + transition, + publishConfig: publishConfig ?? ((bytes) => { + writeFileSync(harness.configPath, bytes); + return { changed: true }; + }), + fileOperations, + databaseOperations, + beforeJournalPublish, + beforePendingClear, + assertConfigLock, + now: () => "2026-07-16T12:00:00.000Z", + createId: () => id + }); +} + +function pendingPath(codexRoot) { + return join(codexRoot, ".crp-history-repair", "pending.json"); +} + +function clearingPath(codexRoot) { + return join(codexRoot, ".crp-history-repair", "pending.json.clearing"); +} + +function backupPath(codexRoot, id = "op-test-001") { + return join(codexRoot, ".crp-history-repair", "backups", id); +} + +function publicErrorText(error) { + return JSON.stringify({ + code: error?.code, + message: error?.message, + action: error?.action, + details: error?.details + }); +} + +function assertSafeError(error, code, harness) { + const serialized = publicErrorText(error); + assert.equal(serialized.includes(SECRET), false); + assert.equal(serialized.includes(harness.codexRoot), false); + assert.equal(serialized.includes(harness.sandbox), false); + assert.equal(error?.code, code); + const expectedDetails = code === "CODEX_HISTORY_REPAIR_COMMITTED_DEGRADED" + ? { committed: true, degraded: true, pending: true } + : code === "CODEX_CONFIG_COMMITTED_DEGRADED" + ? { committed: true, degraded: true, pending: false } + : {}; + assert.deepEqual(error?.details ?? {}, expectedDetails); + return true; +} + +function assertPrivateTree(path) { + if (process.platform === "win32") return; + const visit = (current) => { + const entry = lstatSync(current); + assert.equal(entry.isSymbolicLink(), false); + if (entry.isDirectory()) { + assert.equal(entry.mode & 0o777, 0o700); + for (const name of readdirSync(current)) visit(join(current, name)); + return; + } + assert.equal(entry.isFile(), true); + assert.equal(entry.mode & 0o777, 0o600); + }; + visit(path); +} + +test("inspects the root provider binding, including a quoted provider section", () => { + const bytes = sourceConfig(); + const inspected = inspectCodexProviderBinding(bytes.toString("utf8")); + + assert.deepEqual(inspected, { + providerName: "legacy.provider", + baseUrl: "https://legacy.example/v1", + normalizedBaseUrl: "https://legacy.example/v1" + }); + + const transition = plan(bytes); + assert.deepEqual(transition, { + required: true, + reason: "base-url-changed", + sourceConfigSha256: sha256(bytes), + targetConfigSha256: sha256(targetConfig()) + }); + + for (const dotted of [ + [ + 'model_provider = "legacy.provider"', + 'model_providers."legacy.provider".base_url = "https://legacy.example/v1"', + "" + ].join("\n"), + [ + 'model_provider = "legacy.provider"', + "[model_providers]", + '"legacy.provider".base_url = "https://legacy.example/v1"', + "" + ].join("\n") + ]) { + assert.deepEqual(inspectCodexProviderBinding(dotted), inspected); + assert.equal(plan(Buffer.from(dotted)).reason, "base-url-changed"); + } +}); + +test("binding inspection skips multiline and collection decoys", () => { + const source = [ + 'developer_instructions = """', + 'model_provider = "decoy-basic"', + "[model_providers.decoy-basic]", + 'base_url = "https://decoy-basic.example/v1"', + '"""', + "literal_instructions = '''", + 'model_provider = "decoy-literal"', + "[model_providers.decoy-literal]", + 'base_url = "https://decoy-literal.example/v1"', + "'''", + "examples = [", + " '''[model_providers.decoy-array]''',", + " '''model_provider = \"decoy-array\"''',", + "]", + 'continued_instructions = """', + "continued \\", + ' model_provider = "decoy-continuation"', + " [model_providers.decoy-continuation]", + ' base_url = "https://decoy-continuation.example/v1"', + '"""', + '"model_provider" = "legacy.provider"', + "[ 'model_providers' . \"legacy.provider\" ] # real binding", + "'base_url' = 'https://legacy.example/v1'", + "" + ].join("\n"); + + assert.deepEqual(inspectCodexProviderBinding(source), { + providerName: "legacy.provider", + baseUrl: "https://legacy.example/v1", + normalizedBaseUrl: "https://legacy.example/v1" + }); +}); + +test("keeps node:sqlite behind the actual repair path", () => { + const source = readFileSync(HISTORY_MODULE_PATH, "utf8"); + assert.equal(source.includes('from "node:sqlite"'), false); + assert.equal(source.includes("from 'node:sqlite'"), false); +}); + +test("plans no history scan for an unchanged base URL or first config creation", () => { + const same = sourceConfig({ + provider: "custom.proxy", + baseUrl: TARGET_BASE_URL + }); + assert.deepEqual(plan(same), { + required: false, + reason: "base-url-unchanged", + sourceConfigSha256: sha256(same), + targetConfigSha256: sha256(targetConfig()) + }); + + const missing = Buffer.alloc(0); + assert.deepEqual(plan(missing, { sourceExists: false }), { + required: false, + reason: "source-missing", + sourceConfigSha256: null, + targetConfigSha256: sha256(targetConfig()) + }); +}); + +test("treats missing existing bindings as a transition while duplicate or invalid bindings fail closed", () => { + const missingBindings = [ + { + source: '[model_providers.OpenAI]\nbase_url = "https://example.test/v1"\n', + inspected: { providerName: null, baseUrl: null, normalizedBaseUrl: null } + }, + { + source: 'model_provider = "legacy"\n', + inspected: { providerName: "legacy", baseUrl: null, normalizedBaseUrl: null } + }, + { + source: 'model_provider = "legacy"\n[model_providers.legacy]\nname = "Legacy"\n', + inspected: { providerName: "legacy", baseUrl: null, normalizedBaseUrl: null } + } + ]; + for (const { source, inspected } of missingBindings) { + const bytes = Buffer.from(source); + assert.deepEqual(inspectCodexProviderBinding(source), inspected); + assert.deepEqual(plan(bytes), { + required: true, + reason: "source-binding-missing", + sourceConfigSha256: sha256(bytes), + targetConfigSha256: sha256(targetConfig()) + }); + } + + const invalidSources = [ + [ + 'model_provider = "one"', + 'model_provider = "two"', + '[model_providers.one]', + 'base_url = "https://one.example/v1"', + '[model_providers.two]', + 'base_url = "https://two.example/v1"' + ].join("\n"), + [ + 'model_provider = "legacy"', + '[model_providers.legacy]', + 'base_url = "https://one.example/v1"', + '[model_providers."legacy"]', + 'base_url = "https://two.example/v1"' + ].join("\n"), + [ + 'model_provider = "legacy"', + '[model_providers.legacy]', + 'base_url = "https://one.example/v1"', + 'base_url = "https://two.example/v1"' + ].join("\n"), + [ + 'model_provider = "legacy"', + '[model_providers.legacy]', + 'base_url = "not a URL"' + ].join("\n"), + [ + 'model_provider = "legacy"', + '[model_providers.legacy]', + `base_url = "https://${SECRET}@legacy.example/v1"` + ].join("\n"), + [ + 'model_provider = "legacy"', + 'model_providers.legacy.base_url = "https://legacy.example/v1"', + "[model_providers.legacy]", + 'name = "Legacy"' + ].join("\n"), + 'model_provider = "unterminated\n', + [ + 'model_provider = "legacy"', + "this is invalid toml", + "[model_providers.legacy]", + 'base_url = "https://legacy.example/v1"' + ].join("\n") + ]; + + for (const source of invalidSources) { + assert.throws( + () => inspectCodexProviderBinding(source), + (error) => { + const serialized = publicErrorText(error); + assert.equal(serialized.includes("https://one.example"), false); + assert.equal(serialized.includes("https://two.example"), false); + assert.equal(serialized.includes(SECRET), false); + assert.equal(error?.code, "CODEX_HISTORY_REPAIR_INVALID"); + return true; + } + ); + } +}); + +test("repairs active and archived rollouts plus every eligible SQLite store without changing unrelated state", async (t) => { + const harness = makeHarness("complete"); + t.after(() => harness.cleanup()); + const sourceBytes = sourceConfig(); + writePrivate(harness.configPath, sourceBytes); + + const activePath = join(harness.codexRoot, "sessions", "2026", "07", "rollout-active.jsonl"); + const archivedPath = join(harness.codexRoot, "archived_sessions", "rollout-archived.jsonl"); + const badJson = `{bad-json-${SECRET}`; + const eventLine = JSON.stringify({ + type: "event_msg", + payload: { type: "user_message", message: `message-${SECRET}` } + }); + const encryptedLine = JSON.stringify({ + type: "response_item", + payload: { encrypted_content: `encrypted-${SECRET}`, untouched: true } + }); + writeRollout(activePath, Buffer.from([ + rolloutLine("legacy.provider", "thread-active-1"), + eventLine, + rolloutLine("second-provider", "thread-active-2"), + badJson, + encryptedLine, + "" + ].join("\n"), "utf8")); + writeRollout(archivedPath, Buffer.from([ + rolloutLine("archived-provider", "thread-archived"), + eventLine + ].join("\r\n"), "utf8"), { mode: 0o600 }); + const activeBefore = captureFile(activePath); + const archivedBefore = captureFile(archivedPath); + + const globalStatePath = join(harness.codexRoot, ".codex-global-state.json"); + const sessionIndexPath = join(harness.codexRoot, "session_index.jsonl"); + writePrivate(globalStatePath, `${JSON.stringify({ + "projectless-thread-ids": ["thread-active-1"], + secret: SECRET + })}\n`); + writePrivate(sessionIndexPath, `${JSON.stringify({ + id: "index-only", + thread_name: `title-${SECRET}` + })}\n`); + utimesSync(globalStatePath, FIXED_TIME, FIXED_TIME); + utimesSync(sessionIndexPath, FIXED_TIME, FIXED_TIME); + const globalBefore = captureFile(globalStatePath); + const indexBefore = captureFile(sessionIndexPath); + + const legacyDb = join(harness.codexRoot, "state_5.sqlite"); + const currentDb = join(harness.codexRoot, "sqlite", "codex-dev.db"); + const secondDb = join(harness.codexRoot, "sqlite", "sessions.sqlite"); + const thirdDb = join(harness.codexRoot, "sqlite", "more.sqlite3"); + const unrelatedDb = join(harness.codexRoot, "sqlite", "unrelated.db"); + const missingColumnDb = join(harness.codexRoot, "sqlite", "old-shape.sqlite"); + const originalRows = [ + { id: "index-only", provider: "legacy.provider", cwd: `/db/${SECRET}/one`, hasUserEvent: 0 }, + { id: "thread-active-1", provider: "already-other", cwd: "/db/two", hasUserEvent: 1 } + ]; + createThreadsDatabase(legacyDb, originalRows); + createThreadsDatabase(currentDb, [ + { id: "thread-current", provider: "legacy.provider", cwd: "/db/current", hasUserEvent: 0 } + ]); + createThreadsDatabase(secondDb, [ + { id: "thread-second", provider: "x", cwd: "/db/second", hasUserEvent: 1 } + ]); + createThreadsDatabase(thirdDb, [ + { id: "thread-third", provider: TARGET_PROVIDER, cwd: "/db/third", hasUserEvent: 0 } + ]); + createThreadsDatabase(unrelatedDb, [], { table: false }); + createThreadsDatabase(missingColumnDb, [ + { id: "thread-old", cwd: "/db/old", hasUserEvent: 1 } + ], { columns: false }); + const unrelatedBefore = readFileSync(unrelatedDb); + const missingColumnBefore = readFileSync(missingColumnDb); + + let publishCalls = 0; + const result = await executeTransition(harness, sourceBytes, { + publishConfig(bytes) { + publishCalls += 1; + assert.equal(hasPendingCodexHistoryRepair({ codexRoot: harness.codexRoot }), true); + assert.equal(existsSync(backupPath(harness.codexRoot)), true); + writeFileSync(harness.configPath, bytes); + return { changed: true, backupCreated: true }; + } + }); + + assert.equal(publishCalls, 1); + assert.deepEqual(result, { + handled: true, + configPublished: true, + publishResult: { changed: true, backupCreated: true }, + historyRepair: { + required: true, + completed: true, + resumed: false, + rolloutFiles: 2, + rolloutRecords: 3, + sqliteFiles: 3, + sqliteRows: 4, + encryptedContentDetected: true, + backupCreated: true + } + }); + const serializedResult = JSON.stringify(result); + assert.equal(serializedResult.includes(SECRET), false); + assert.equal(serializedResult.includes(harness.codexRoot), false); + assert.equal(serializedResult.includes("thread-active"), false); + assert.equal(hasPendingCodexHistoryRepair({ codexRoot: harness.codexRoot }), false); + + const activeText = readFileSync(activePath, "utf8"); + const activeLines = activeText.split("\n"); + assert.equal(activeText.endsWith("\n"), true); + assert.equal(JSON.parse(activeLines[0]).payload.model_provider, TARGET_PROVIDER); + assert.equal(activeLines[1], eventLine); + assert.equal(JSON.parse(activeLines[2]).payload.model_provider, TARGET_PROVIDER); + assert.equal(activeLines[3], badJson); + assert.equal(activeLines[4], encryptedLine); + assert.equal(activeLines[4].includes(`encrypted-${SECRET}`), true); + const archivedText = readFileSync(archivedPath, "utf8"); + assert.equal(archivedText.endsWith("\r\n"), false); + assert.equal(archivedText.replaceAll("\r\n", "").includes("\n"), false); + assert.equal(JSON.parse(archivedText.split("\r\n")[0]).payload.model_provider, TARGET_PROVIDER); + assert.equal(archivedText.split("\r\n")[1], eventLine); + assert.equal(mtimeNs(activePath), activeBefore.mtime); + assert.equal(mtimeNs(archivedPath), archivedBefore.mtime); + if (process.platform !== "win32") { + assert.equal(statSync(activePath).mode & 0o777, activeBefore.mode); + assert.equal(statSync(archivedPath).mode & 0o777, archivedBefore.mode); + } + + assert.deepEqual(readThreads(legacyDb), originalRows.map((row) => ({ + id: row.id, + model_provider: TARGET_PROVIDER, + cwd: row.cwd, + has_user_event: row.hasUserEvent, + title: `title-${SECRET}` + }))); + assert.equal(readThreads(currentDb)[0].model_provider, TARGET_PROVIDER); + assert.equal(readThreads(currentDb)[0].cwd, "/db/current"); + assert.equal(readThreads(currentDb)[0].has_user_event, 0); + assert.equal(readThreads(secondDb)[0].model_provider, TARGET_PROVIDER); + assert.equal(readThreads(thirdDb)[0].model_provider, TARGET_PROVIDER); + assert.deepEqual(readFileSync(unrelatedDb), unrelatedBefore); + assert.deepEqual(readFileSync(missingColumnDb), missingColumnBefore); + assertUnchanged(globalStatePath, globalBefore); + assertUnchanged(sessionIndexPath, indexBefore); + assertPrivateTree(join(harness.codexRoot, ".crp-history-repair")); +}); + +test("writes a private bounded journal and backup before publishing config", async (t) => { + const harness = makeHarness("private"); + t.after(() => harness.cleanup()); + const sourceBytes = sourceConfig(); + writePrivate(harness.configPath, sourceBytes); + const rolloutPath = join(harness.codexRoot, "sessions", "rollout-private.jsonl"); + writeRollout(rolloutPath, `${rolloutLine("legacy.provider", "thread-private")}\n${JSON.stringify({ + type: "event_msg", + payload: { message: SECRET } + })}\n`); + const dbPath = join(harness.codexRoot, "state_5.sqlite"); + createThreadsDatabase(dbPath, [ + { id: "thread-private", provider: "legacy.provider", cwd: `/cwd/${SECRET}`, hasUserEvent: 1 } + ]); + + let inspected = false; + await executeTransition(harness, sourceBytes, { + publishConfig(bytes) { + const journalPath = pendingPath(harness.codexRoot); + const operationBackup = backupPath(harness.codexRoot); + assert.equal(existsSync(journalPath), true); + assert.equal(existsSync(operationBackup), true); + assertPrivateTree(join(harness.codexRoot, ".crp-history-repair")); + const journalBytes = readFileSync(journalPath, "utf8"); + const metadataBytes = readFileSync(join(operationBackup, "metadata.json"), "utf8"); + for (const managedBytes of [journalBytes, metadataBytes]) { + assert.equal(managedBytes.includes(harness.codexRoot), false); + assert.equal(managedBytes.includes(harness.sandbox), false); + assert.equal(managedBytes.includes(TARGET_BASE_URL), false); + assert.equal(managedBytes.includes("legacy.example"), false); + assert.equal(managedBytes.includes(SECRET), false); + assert.equal(managedBytes.includes("thread-private"), false); + assert.equal(managedBytes.includes("rollout-private"), false); + assert.equal(managedBytes.includes("/cwd/"), false); + } + const journal = JSON.parse(journalBytes); + assert.equal(journal.sourceConfigSha256, sha256(sourceBytes)); + assert.equal(journal.targetConfigSha256, sha256(targetConfig())); + assert.equal(journal.targetProvider, TARGET_PROVIDER); + assert.equal(journal.operationId, "op-test-001"); + inspected = true; + writeFileSync(harness.configPath, bytes); + return null; + } + }); + assert.equal(inspected, true); +}); + +test("does not scan, publish, back up, or change mtimes when transition repair is unnecessary", async (t) => { + const harness = makeHarness("noop"); + t.after(() => harness.cleanup()); + const sourceBytes = sourceConfig({ baseUrl: TARGET_BASE_URL }); + writePrivate(harness.configPath, sourceBytes); + const rolloutPath = join(harness.codexRoot, "sessions", "rollout-noop.jsonl"); + writeRollout(rolloutPath, `${rolloutLine("legacy.provider", "thread-noop")}\n`); + const before = captureFile(rolloutPath); + let publishCalls = 0; + + const result = await executeTransition(harness, sourceBytes, { + transition: plan(sourceBytes), + publishConfig() { + publishCalls += 1; + throw new Error("must not publish"); + } + }); + + assert.equal(publishCalls, 0); + assert.deepEqual(result, { + handled: false, + configPublished: false, + publishResult: undefined, + historyRepair: { + required: false, + completed: true, + resumed: false, + rolloutFiles: 0, + rolloutRecords: 0, + sqliteFiles: 0, + sqliteRows: 0, + encryptedContentDetected: false, + backupCreated: false + } + }); + assertUnchanged(rolloutPath, before); + assert.equal(existsSync(join(harness.codexRoot, ".crp-history-repair")), false); +}); + +test("does not inspect existing history during first-time config creation", async (t) => { + const harness = makeHarness("first-config"); + t.after(() => harness.cleanup()); + const missingConfig = Buffer.alloc(0); + const rolloutPath = join(harness.codexRoot, "sessions", "rollout-before-config.jsonl"); + writeRollout(rolloutPath, `${rolloutLine("legacy.provider", "thread-before-config")}\n`); + const before = captureFile(rolloutPath); + let publishCalls = 0; + + const result = await executeTransition(harness, missingConfig, { + currentConfigBytes: missingConfig, + transition: plan(missingConfig, { sourceExists: false }), + publishConfig() { + publishCalls += 1; + throw new Error("first config publication belongs to bootstrap"); + } + }); + + assert.equal(result.handled, false); + assert.equal(result.historyRepair.required, false); + assert.equal(publishCalls, 0); + assert.equal(existsSync(harness.configPath), false); + assertUnchanged(rolloutPath, before); + assert.equal(existsSync(join(harness.codexRoot, ".crp-history-repair")), false); +}); + +test("publishes a changed binding without journal or backup when history is already aligned", async (t) => { + const harness = makeHarness("empty-repair"); + t.after(() => harness.cleanup()); + const sourceBytes = sourceConfig(); + writePrivate(harness.configPath, sourceBytes); + const rolloutPath = join(harness.codexRoot, "sessions", "rollout-aligned.jsonl"); + writeRollout(rolloutPath, `${rolloutLine(TARGET_PROVIDER, "thread-aligned")}\n`); + const databasePath = join(harness.codexRoot, "state_5.sqlite"); + createThreadsDatabase(databasePath, [ + { id: "thread-aligned", provider: TARGET_PROVIDER, cwd: "/already/aligned", hasUserEvent: 1 } + ]); + const rolloutBefore = captureFile(rolloutPath); + const databaseBefore = readFileSync(databasePath); + let publishCalls = 0; + + const result = await executeTransition(harness, sourceBytes, { + publishConfig(bytes) { + publishCalls += 1; + assert.equal(existsSync(join(harness.codexRoot, ".crp-history-repair")), false); + writeFileSync(harness.configPath, bytes); + return { changed: true }; + } + }); + + assert.equal(publishCalls, 1); + assert.deepEqual(result, { + handled: true, + configPublished: true, + publishResult: { changed: true }, + historyRepair: { + required: true, + completed: true, + resumed: false, + backupCreated: false, + rolloutFiles: 0, + rolloutRecords: 0, + sqliteFiles: 0, + sqliteRows: 0, + encryptedContentDetected: false + } + }); + assert.deepEqual(readFileSync(harness.configPath), targetConfig()); + assertUnchanged(rolloutPath, rolloutBefore); + assert.deepEqual(readFileSync(databasePath), databaseBefore); + assert.equal(existsSync(join(harness.codexRoot, ".crp-history-repair")), false); +}); + +test("reports config-only committed degradation without a pending journal", async (t) => { + const harness = makeHarness("config-only-callback-degraded"); + t.after(() => harness.cleanup()); + const sourceBytes = sourceConfig(); + writePrivate(harness.configPath, sourceBytes); + const rolloutPath = join(harness.codexRoot, "sessions", "rollout-aligned-degraded.jsonl"); + writeRollout(rolloutPath, `${rolloutLine(TARGET_PROVIDER, "aligned-degraded")}\n`); + + await assert.rejects( + () => executeTransition(harness, sourceBytes, { + publishConfig(bytes) { + writeFileSync(harness.configPath, bytes); + throw new Error(`post-commit ${SECRET}`); + } + }), + (error) => assertSafeError(error, "CODEX_CONFIG_COMMITTED_DEGRADED", harness) + ); + + assert.deepEqual(readFileSync(harness.configPath), targetConfig()); + assert.equal(existsSync(pendingPath(harness.codexRoot)), false); + assert.equal(hasPendingCodexHistoryRepair({ codexRoot: harness.codexRoot }), false); +}); + +test("rejects config-only completion when the published config changes before return", async (t) => { + const harness = makeHarness("config-only-final-config-change"); + t.after(() => harness.cleanup()); + const sourceBytes = sourceConfig(); + writePrivate(harness.configPath, sourceBytes); + const foreignBytes = Buffer.from('model_provider = "foreign"\n', "utf8"); + + await assert.rejects( + () => executeTransition(harness, sourceBytes, { + beforePendingClear() { + writeFileSync(harness.configPath, foreignBytes); + } + }), + (error) => assertSafeError(error, "CODEX_CONFIG_COMMITTED_DEGRADED", harness) + ); + + assert.deepEqual(readFileSync(harness.configPath), foreignBytes); + assert.equal(existsSync(pendingPath(harness.codexRoot)), false); + assert.equal(hasPendingCodexHistoryRepair({ codexRoot: harness.codexRoot }), false); +}); + +test("maps a config-only post-publication read failure to committed degradation", async (t) => { + const harness = makeHarness("config-only-post-read"); + t.after(() => harness.cleanup()); + const sourceBytes = sourceConfig(); + writePrivate(harness.configPath, sourceBytes); + let published = false; + + await assert.rejects( + () => executeTransition(harness, sourceBytes, { + fileOperations: { + ...realFileOperations, + lstatSync(path, ...args) { + if (published && path === harness.configPath) { + throw new Error(`post-publication read ${SECRET}`); + } + return realFileOperations.lstatSync(path, ...args); + } + }, + publishConfig(bytes) { + writeFileSync(harness.configPath, bytes); + published = true; + return { changed: true }; + } + }), + (error) => assertSafeError(error, "CODEX_CONFIG_COMMITTED_DEGRADED", harness) + ); + + assert.equal(published, true); + assert.deepEqual(readFileSync(harness.configPath), targetConfig()); + assert.equal(existsSync(pendingPath(harness.codexRoot)), false); +}); + +test("maps a journaled post-publication read failure to pending degradation", async (t) => { + const harness = makeHarness("history-post-read"); + t.after(() => harness.cleanup()); + const sourceBytes = sourceConfig(); + writePrivate(harness.configPath, sourceBytes); + const rolloutPath = join(harness.codexRoot, "sessions", "rollout-history-post-read.jsonl"); + writeRollout(rolloutPath, `${rolloutLine("legacy.provider", "history-post-read")}\n`); + const rolloutBefore = captureFile(rolloutPath); + let published = false; + + await assert.rejects( + () => executeTransition(harness, sourceBytes, { + fileOperations: { + ...realFileOperations, + lstatSync(path, ...args) { + if (published && path === harness.configPath) { + throw new Error(`post-publication history read ${SECRET}`); + } + return realFileOperations.lstatSync(path, ...args); + } + }, + publishConfig(bytes) { + writeFileSync(harness.configPath, bytes); + published = true; + return { changed: true }; + } + }), + (error) => assertSafeError( + error, + "CODEX_HISTORY_REPAIR_COMMITTED_DEGRADED", + harness + ) + ); + + assert.equal(published, true); + assert.deepEqual(readFileSync(harness.configPath), targetConfig()); + assertUnchanged(rolloutPath, rolloutBefore); + assert.equal(hasPendingCodexHistoryRepair({ codexRoot: harness.codexRoot }), true); +}); + +test("resumes from the source hash after config publication fails", async (t) => { + const harness = makeHarness("source-resume"); + t.after(() => harness.cleanup()); + const sourceBytes = sourceConfig(); + writePrivate(harness.configPath, sourceBytes); + const rolloutPath = join(harness.codexRoot, "sessions", "rollout-source-resume.jsonl"); + writeRollout(rolloutPath, `${rolloutLine("legacy.provider", "thread-source-resume")}\n`); + const before = captureFile(rolloutPath); + + await assert.rejects( + () => executeTransition(harness, sourceBytes, { + publishConfig() { + throw new Error(`publish failed ${SECRET} ${harness.codexRoot}`); + } + }), + (error) => assertSafeError(error, "CODEX_HISTORY_REPAIR_FAILED", harness) + ); + assert.deepEqual(readFileSync(harness.configPath), sourceBytes); + assertUnchanged(rolloutPath, before); + assert.equal(hasPendingCodexHistoryRepair({ codexRoot: harness.codexRoot }), true); + + let publishCalls = 0; + const resumed = await executeTransition(harness, sourceBytes, { + publishConfig(bytes) { + publishCalls += 1; + writeFileSync(harness.configPath, bytes); + return "published-on-retry"; + } + }); + assert.equal(publishCalls, 1); + assert.equal(resumed.configPublished, true); + assert.equal(resumed.publishResult, "published-on-retry"); + assert.equal(resumed.historyRepair.resumed, true); + assert.equal(resumed.historyRepair.completed, true); + assert.equal(JSON.parse(readFileSync(rolloutPath, "utf8")).payload.model_provider, TARGET_PROVIDER); + assert.equal(hasPendingCodexHistoryRepair({ codexRoot: harness.codexRoot }), false); +}); + +test("discovers and resumes a clearing-only pending journal", async (t) => { + const harness = makeHarness("clearing-resume"); + t.after(() => harness.cleanup()); + const sourceBytes = sourceConfig(); + const targetBytes = targetConfig(); + writePrivate(harness.configPath, sourceBytes); + const rolloutPath = join(harness.codexRoot, "sessions", "rollout-clearing.jsonl"); + writeRollout(rolloutPath, `${rolloutLine("legacy.provider", "clearing")}\n`); + let deletionBlocked = false; + + await assert.rejects( + () => executeTransition(harness, sourceBytes, { + id: "op-clearing", + fileOperations: { + ...realFileOperations, + rmSync(path, ...args) { + if (path === clearingPath(harness.codexRoot) && !deletionBlocked) { + deletionBlocked = true; + throw new Error("simulated clearing interruption"); + } + return realFileOperations.rmSync(path, ...args); + } + } + }), + (error) => assertSafeError( + error, + "CODEX_HISTORY_REPAIR_COMMITTED_DEGRADED", + harness + ) + ); + + assert.equal(deletionBlocked, true); + assert.equal(existsSync(pendingPath(harness.codexRoot)), false); + assert.equal(existsSync(clearingPath(harness.codexRoot)), true); + assert.equal(hasPendingCodexHistoryRepair({ codexRoot: harness.codexRoot }), true); + + const resumed = await executeTransition(harness, sourceBytes, { + currentConfigBytes: targetBytes, + transition: plan(targetBytes), + id: "op-clearing-resume", + publishConfig() { + assert.fail("A clearing-only target transition must not republish config"); + } + }); + assert.equal(resumed.historyRepair.resumed, true); + assert.equal(resumed.historyRepair.completed, true); + assert.equal(existsSync(clearingPath(harness.codexRoot)), false); + assert.equal(hasPendingCodexHistoryRepair({ codexRoot: harness.codexRoot }), false); +}); + +test("rebuilds a discoverable pending marker when post-delete durability fails", async (t) => { + const harness = makeHarness("clear-durability"); + t.after(() => harness.cleanup()); + const sourceBytes = sourceConfig(); + writePrivate(harness.configPath, sourceBytes); + const rolloutPath = join(harness.codexRoot, "sessions", "rollout-clear-durability.jsonl"); + writeRollout(rolloutPath, `${rolloutLine("legacy.provider", "clear-durability")}\n`); + const managed = dirname(pendingPath(harness.codexRoot)); + let clearStarted = false; + let durabilityFailed = false; + + await assert.rejects( + () => executeTransition(harness, sourceBytes, { + id: "op-clear-durability", + fileOperations: { + ...realFileOperations, + fsyncDirectorySync(path) { + if (path !== managed) return; + if (existsSync(clearingPath(harness.codexRoot))) clearStarted = true; + if (clearStarted && !durabilityFailed + && !existsSync(pendingPath(harness.codexRoot)) + && !existsSync(clearingPath(harness.codexRoot))) { + durabilityFailed = true; + throw new Error("simulated directory fsync failure"); + } + } + } + }), + (error) => assertSafeError( + error, + "CODEX_HISTORY_REPAIR_COMMITTED_DEGRADED", + harness + ) + ); + + assert.equal(durabilityFailed, true); + assert.equal(existsSync(clearingPath(harness.codexRoot)), false); + assert.equal(existsSync(pendingPath(harness.codexRoot)), true); + assert.equal(hasPendingCodexHistoryRepair({ codexRoot: harness.codexRoot }), true); +}); + +test("requests config-lock retention when pending restoration also fails", async (t) => { + const harness = makeHarness("clear-retain-lock"); + t.after(() => harness.cleanup()); + const sourceBytes = sourceConfig(); + writePrivate(harness.configPath, sourceBytes); + const rolloutPath = join(harness.codexRoot, "sessions", "rollout-retain-lock.jsonl"); + writeRollout(rolloutPath, `${rolloutLine("legacy.provider", "retain-lock")}\n`); + const managed = dirname(pendingPath(harness.codexRoot)); + let clearStarted = false; + let durabilityFailed = false; + let restorationBlocked = false; + + await assert.rejects( + () => executeTransition(harness, sourceBytes, { + id: "op-retain-lock", + fileOperations: { + ...realFileOperations, + fsyncDirectorySync(path) { + if (path !== managed) return; + if (existsSync(clearingPath(harness.codexRoot))) clearStarted = true; + if (clearStarted && !durabilityFailed + && !existsSync(pendingPath(harness.codexRoot)) + && !existsSync(clearingPath(harness.codexRoot))) { + durabilityFailed = true; + throw new Error("simulated clear durability failure"); + } + }, + linkSync(source, destination, ...args) { + if (durabilityFailed && destination === pendingPath(harness.codexRoot)) { + restorationBlocked = true; + const error = new Error("simulated pending restoration failure"); + error.code = "EIO"; + throw error; + } + return realFileOperations.linkSync(source, destination, ...args); + } + } + }), + (error) => { + assertSafeError(error, "CODEX_HISTORY_REPAIR_COMMITTED_DEGRADED", harness); + assert.equal(error.retainConfigLock, true); + assert.equal(Object.keys(error).includes("retainConfigLock"), false); + return true; + } + ); + + assert.equal(durabilityFailed, true); + assert.equal(restorationBlocked, true); + assert.equal(existsSync(pendingPath(harness.codexRoot)), false); + assert.equal(existsSync(clearingPath(harness.codexRoot)), false); +}); + +test("resumes partial SQLite completion from the target hash and is idempotent after final verification", async (t) => { + const harness = makeHarness("target-resume"); + t.after(() => harness.cleanup()); + const sourceBytes = sourceConfig(); + const targetBytes = targetConfig(); + writePrivate(harness.configPath, sourceBytes); + const rolloutPath = join(harness.codexRoot, "sessions", "rollout-target-resume.jsonl"); + writeRollout(rolloutPath, `${rolloutLine("legacy.provider", "thread-target-resume")}\n`); + const firstDb = join(harness.codexRoot, "sqlite", "a-first.sqlite"); + const failingDb = join(harness.codexRoot, "sqlite", "z-failing.sqlite"); + createThreadsDatabase(firstDb, [ + { id: "first-index-only", provider: "legacy.provider", cwd: "/one", hasUserEvent: 0 } + ]); + createThreadsDatabase(failingDb, [ + { id: "second-index-only", provider: "legacy.provider", cwd: "/two", hasUserEvent: 1 } + ], { triggerIgnore: true }); + + await assert.rejects( + () => executeTransition(harness, sourceBytes), + (error) => assertSafeError(error, "CODEX_HISTORY_REPAIR_COMMITTED_DEGRADED", harness) + ); + assert.deepEqual(readFileSync(harness.configPath), targetBytes); + assert.equal(hasPendingCodexHistoryRepair({ codexRoot: harness.codexRoot }), true); + assert.equal(readThreads(firstDb)[0].model_provider, TARGET_PROVIDER); + assert.equal(readThreads(failingDb)[0].model_provider, "legacy.provider"); + removeTrigger(failingDb); + + let publishCalls = 0; + const resumed = await executeTransition(harness, sourceBytes, { + currentConfigBytes: targetBytes, + transition: plan(targetBytes), + publishConfig() { + publishCalls += 1; + throw new Error("must not republish a target-hash config"); + } + }); + assert.equal(publishCalls, 0); + assert.equal(resumed.configPublished, true); + assert.equal(resumed.historyRepair.resumed, true); + assert.equal(resumed.historyRepair.completed, true); + assert.equal(readThreads(firstDb)[0].model_provider, TARGET_PROVIDER); + assert.equal(readThreads(failingDb)[0].model_provider, TARGET_PROVIDER); + assert.equal(hasPendingCodexHistoryRepair({ codexRoot: harness.codexRoot }), false); + + const rolloutAfter = captureFile(rolloutPath); + const firstDbAfter = captureFile(firstDb); + const backupNames = readdirSync(join(harness.codexRoot, ".crp-history-repair", "backups")); + const noOpTransition = plan(targetBytes); + const noOp = await executeTransition(harness, targetBytes, { + currentConfigBytes: targetBytes, + transition: noOpTransition, + id: "must-not-be-created", + publishConfig() { + throw new Error("must remain idempotent"); + } + }); + assert.equal(noOp.handled, false); + assertUnchanged(rolloutPath, rolloutAfter); + assert.deepEqual(readFileSync(firstDb), firstDbAfter.bytes); + assert.deepEqual( + readdirSync(join(harness.codexRoot, ".crp-history-repair", "backups")), + backupNames + ); +}); + +test("a conflicting pending transition fails with zero writes and no sensitive error data", async (t) => { + const harness = makeHarness("conflict"); + t.after(() => harness.cleanup()); + const sourceBytes = sourceConfig(); + writePrivate(harness.configPath, sourceBytes); + const rolloutPath = join(harness.codexRoot, "sessions", "rollout-conflict.jsonl"); + writeRollout(rolloutPath, `${rolloutLine("legacy.provider", "thread-conflict")}\n`); + + await assert.rejects( + () => executeTransition(harness, sourceBytes, { + publishConfig() { + throw new Error("leave pending"); + } + }), + (error) => assertSafeError(error, "CODEX_HISTORY_REPAIR_FAILED", harness) + ); + + const foreignConfig = Buffer.from([ + 'model_provider = "foreign"', + '[model_providers.foreign]', + `base_url = "https://${SECRET}@foreign.example/v1"`, + "" + ].join("\n")); + writeFileSync(harness.configPath, foreignConfig); + const rolloutBefore = captureFile(rolloutPath); + const pendingBefore = captureFile(pendingPath(harness.codexRoot)); + const backupBefore = readdirSync(join(harness.codexRoot, ".crp-history-repair", "backups")); + let publishCalls = 0; + + await assert.rejects( + () => executeTransition(harness, sourceBytes, { + currentConfigBytes: foreignConfig, + publishConfig() { + publishCalls += 1; + } + }), + (error) => assertSafeError(error, "CODEX_HISTORY_REPAIR_CONFLICT", harness) + ); + assert.equal(publishCalls, 0); + assert.deepEqual(readFileSync(harness.configPath), foreignConfig); + assertUnchanged(rolloutPath, rolloutBefore); + assertUnchanged(pendingPath(harness.codexRoot), pendingBefore); + assert.deepEqual( + readdirSync(join(harness.codexRoot, ".crp-history-repair", "backups")), + backupBefore + ); +}); + +test("fails closed when canonical and clearing pending markers coexist", async (t) => { + const harness = makeHarness("dual-pending-marker"); + t.after(() => harness.cleanup()); + const sourceBytes = sourceConfig(); + writePrivate(harness.configPath, sourceBytes); + const rolloutPath = join(harness.codexRoot, "sessions", "rollout-dual-marker.jsonl"); + writeRollout(rolloutPath, `${rolloutLine("legacy.provider", "dual-marker")}\n`); + + await assert.rejects( + () => executeTransition(harness, sourceBytes, { + id: "op-dual-marker", + publishConfig() { + throw new Error("leave pending"); + } + }), + (error) => assertSafeError(error, "CODEX_HISTORY_REPAIR_FAILED", harness) + ); + writePrivate(clearingPath(harness.codexRoot), readFileSync(pendingPath(harness.codexRoot))); + const rolloutBefore = captureFile(rolloutPath); + + assert.throws( + () => hasPendingCodexHistoryRepair({ codexRoot: harness.codexRoot }), + (error) => assertSafeError(error, "CODEX_HISTORY_REPAIR_CONFLICT", harness) + ); + await assert.rejects( + () => executeTransition(harness, sourceBytes, { + publishConfig() { + assert.fail("Conflicting pending markers must block config publication"); + } + }), + (error) => assertSafeError(error, "CODEX_HISTORY_REPAIR_CONFLICT", harness) + ); + assertUnchanged(rolloutPath, rolloutBefore); + assert.deepEqual(readFileSync(harness.configPath), sourceBytes); +}); + +test("rejects unsafe rollout symlinks and backup failures before config or history writes", async (t) => { + const harness = makeHarness("unsafe"); + t.after(() => harness.cleanup()); + const sourceBytes = sourceConfig(); + writePrivate(harness.configPath, sourceBytes); + const externalPath = join(harness.sandbox, "external-rollout.jsonl"); + writePrivate(externalPath, `${rolloutLine("legacy.provider", "thread-external")}\n`); + const symlinkPath = join(harness.codexRoot, "sessions", "rollout-link.jsonl"); + mkdirSync(dirname(symlinkPath), { recursive: true }); + try { + symlinkSync(externalPath, symlinkPath); + } catch (error) { + if (error?.code === "EPERM" || error?.code === "EACCES") { + t.skip("symlink creation is unavailable on this platform"); + return; + } + throw error; + } + const externalBefore = captureFile(externalPath); + let publishCalls = 0; + await assert.rejects( + () => executeTransition(harness, sourceBytes, { + publishConfig() { + publishCalls += 1; + } + }), + (error) => assertSafeError(error, "CODEX_HISTORY_REPAIR_INVALID", harness) + ); + assert.equal(publishCalls, 0); + assertUnchanged(externalPath, externalBefore); + assert.deepEqual(readFileSync(harness.configPath), sourceBytes); + assert.equal(existsSync(pendingPath(harness.codexRoot)), false); + + unlinkSync(symlinkPath); + const rolloutPath = join(harness.codexRoot, "sessions", "rollout-backup-failure.jsonl"); + writeRollout(rolloutPath, `${rolloutLine("legacy.provider", "thread-backup-failure")}\n`); + const dbPath = join(harness.codexRoot, "state_5.sqlite"); + createThreadsDatabase(dbPath, [ + { id: "thread-backup-failure", provider: "legacy.provider", cwd: "/safe", hasUserEvent: 0 } + ]); + const rolloutBefore = captureFile(rolloutPath); + const databaseBefore = readFileSync(dbPath); + await assert.rejects( + () => executeTransition(harness, sourceBytes, { + id: "op-backup-failure", + databaseOperations: { + open(path) { + return new DatabaseSync(path); + }, + async backup(_database, destination) { + writeFileSync(destination, "partial sqlite backup", "utf8"); + throw new Error(`backup ${SECRET} ${harness.codexRoot}`); + } + }, + publishConfig() { + publishCalls += 1; + } + }), + (error) => assertSafeError(error, "CODEX_HISTORY_REPAIR_FAILED", harness) + ); + assert.equal(publishCalls, 0); + assert.deepEqual(readFileSync(harness.configPath), sourceBytes); + assertUnchanged(rolloutPath, rolloutBefore); + assert.deepEqual(readFileSync(dbPath), databaseBefore); + assert.equal(existsSync(pendingPath(harness.codexRoot)), false); + assert.deepEqual( + readdirSync(backupPath(harness.codexRoot, "op-backup-failure"), { recursive: true }) + .map(String) + .filter((path) => path.endsWith(".tmp") || path.endsWith(".claim")), + [] + ); +}); + +test("publishes SQLite backups exclusively without overwriting a raced destination", async (t) => { + const harness = makeHarness("sqlite-backup-race"); + t.after(() => harness.cleanup()); + const sourceBytes = sourceConfig(); + writePrivate(harness.configPath, sourceBytes); + const databasePath = join(harness.codexRoot, "state_5.sqlite"); + createThreadsDatabase(databasePath, [ + { id: "sqlite-race", provider: "legacy.provider", cwd: "/race", hasUserEvent: 1 } + ]); + const databaseBefore = readFileSync(databasePath); + const racedBytes = Buffer.from("foreign raced sqlite backup\n", "utf8"); + let racedDestination = null; + + await assert.rejects( + () => executeTransition(harness, sourceBytes, { + id: "op-sqlite-backup-race", + fileOperations: { + ...realFileOperations, + linkSync(source, destination, ...args) { + if (racedDestination === null && destination.endsWith(".bak") + && basename(destination).startsWith("state_5.sqlite.")) { + racedDestination = destination; + writePrivate(destination, racedBytes); + } + return realFileOperations.linkSync(source, destination, ...args); + } + }, + publishConfig() { + assert.fail("A raced backup destination must block config publication"); + } + }), + (error) => assertSafeError(error, "CODEX_HISTORY_REPAIR_CONFLICT", harness) + ); + + assert.notEqual(racedDestination, null); + assert.deepEqual(readFileSync(racedDestination), racedBytes); + assert.deepEqual(readFileSync(databasePath), databaseBefore); + assert.deepEqual(readFileSync(harness.configPath), sourceBytes); + assert.deepEqual( + readdirSync(backupPath(harness.codexRoot, "op-sqlite-backup-race"), { recursive: true }) + .map(String) + .filter((path) => path.endsWith(".tmp") || path.endsWith(".claim")), + [] + ); +}); + +test("falls back to an exclusive SQLite backup copy when hard links are unavailable", async (t) => { + const harness = makeHarness("sqlite-backup-copy-fallback"); + t.after(() => harness.cleanup()); + const sourceBytes = sourceConfig(); + writePrivate(harness.configPath, sourceBytes); + const databasePath = join(harness.codexRoot, "state_5.sqlite"); + createThreadsDatabase(databasePath, [ + { id: "sqlite-copy", provider: "legacy.provider", cwd: "/copy", hasUserEvent: 1 } + ]); + let hardLinkRejected = false; + + const result = await executeTransition(harness, sourceBytes, { + id: "op-sqlite-backup-copy-fallback", + fileOperations: { + ...realFileOperations, + linkSync(source, destination, ...args) { + if (destination.endsWith(".bak") + && basename(destination).startsWith("state_5.sqlite.")) { + hardLinkRejected = true; + const error = new Error("hard links unavailable"); + error.code = "EPERM"; + throw error; + } + return realFileOperations.linkSync(source, destination, ...args); + } + }, + publishConfig(bytes) { + writeFileSync(harness.configPath, bytes); + return { changed: true }; + } + }); + + assert.equal(hardLinkRejected, true); + assert.equal(result.historyRepair.completed, true); + assert.equal(result.historyRepair.sqliteFiles, 1); + assert.equal(hasPendingCodexHistoryRepair({ codexRoot: harness.codexRoot }), false); +}); + +test("rejects a SQLite backup destination replaced during temp-link cleanup", async (t) => { + const harness = makeHarness("sqlite-backup-final-race"); + t.after(() => harness.cleanup()); + const sourceBytes = sourceConfig(); + writePrivate(harness.configPath, sourceBytes); + const databasePath = join(harness.codexRoot, "state_5.sqlite"); + createThreadsDatabase(databasePath, [ + { id: "sqlite-final-race", provider: "legacy.provider", cwd: "/race", hasUserEvent: 1 } + ]); + const databaseBefore = readFileSync(databasePath); + const foreignBytes = Buffer.from("foreign replacement sqlite backup\n", "utf8"); + let destinationPath = null; + let replaced = false; + + await assert.rejects( + () => executeTransition(harness, sourceBytes, { + id: "op-sqlite-backup-final-race", + fileOperations: { + ...realFileOperations, + linkSync(source, destination, ...args) { + if (destination.endsWith(".bak") + && basename(destination).startsWith("state_5.sqlite.")) { + destinationPath = destination; + } + return realFileOperations.linkSync(source, destination, ...args); + }, + rmSync(path, ...args) { + const result = realFileOperations.rmSync(path, ...args); + if (!replaced && destinationPath !== null && path.endsWith(".claim")) { + replaced = true; + realFileOperations.rmSync(destinationPath); + writePrivate(destinationPath, foreignBytes); + } + return result; + } + }, + publishConfig() { + assert.fail("A replaced backup destination must block config publication"); + } + }), + (error) => assertSafeError(error, "CODEX_HISTORY_REPAIR_CONFLICT", harness) + ); + + assert.equal(replaced, true); + assert.deepEqual(readFileSync(destinationPath), foreignBytes); + assert.deepEqual(readFileSync(databasePath), databaseBefore); + assert.deepEqual(readFileSync(harness.configPath), sourceBytes); + assert.equal(existsSync(pendingPath(harness.codexRoot)), false); +}); + +test("rejects hard-linked SQLite files before config or history writes", async (t) => { + const harness = makeHarness("sqlite-hardlink"); + t.after(() => harness.cleanup()); + const sourceBytes = sourceConfig(); + writePrivate(harness.configPath, sourceBytes); + const databasePath = join(harness.codexRoot, "state_5.sqlite"); + const externalPath = join(harness.sandbox, "external-state.sqlite"); + createThreadsDatabase(databasePath, [ + { id: "hardlink", provider: "legacy.provider", cwd: "/hardlink", hasUserEvent: 1 } + ]); + try { + realFileOperations.linkSync(databasePath, externalPath); + } catch (error) { + if (error?.code === "EPERM" || error?.code === "EACCES" || error?.code === "ENOTSUP") { + t.skip("hard-link creation is unavailable on this platform"); + return; + } + throw error; + } + const externalBefore = readFileSync(externalPath); + + await assert.rejects( + () => executeTransition(harness, sourceBytes, { + publishConfig() { + assert.fail("A hard-linked database must block config publication"); + } + }), + (error) => assertSafeError(error, "CODEX_HISTORY_REPAIR_INVALID", harness) + ); + + assert.deepEqual(readFileSync(databasePath), externalBefore); + assert.deepEqual(readFileSync(externalPath), externalBefore); + assert.deepEqual(readFileSync(harness.configPath), sourceBytes); + assert.equal(existsSync(join(harness.codexRoot, ".crp-history-repair")), false); +}); + +test("rejects a symbolic-link SQLite sidecar before opening the database", async (t) => { + const harness = makeHarness("sqlite-sidecar-symlink"); + t.after(() => harness.cleanup()); + const sourceBytes = sourceConfig(); + writePrivate(harness.configPath, sourceBytes); + const databasePath = join(harness.codexRoot, "state_5.sqlite"); + const externalPath = join(harness.sandbox, "external-wal"); + const sidecarPath = `${databasePath}-wal`; + createThreadsDatabase(databasePath, [ + { id: "sidecar-link", provider: "legacy.provider", cwd: "/sidecar", hasUserEvent: 1 } + ]); + writePrivate(externalPath, "external wal bytes\n"); + try { + symlinkSync(externalPath, sidecarPath); + } catch (error) { + if (error?.code === "EPERM" || error?.code === "EACCES") { + t.skip("symlink creation is unavailable on this platform"); + return; + } + throw error; + } + const externalBefore = readFileSync(externalPath); + + await assert.rejects( + () => executeTransition(harness, sourceBytes, { + publishConfig() { + assert.fail("An unsafe SQLite sidecar must block config publication"); + } + }), + (error) => assertSafeError(error, "CODEX_HISTORY_REPAIR_INVALID", harness) + ); + + assert.deepEqual(readFileSync(externalPath), externalBefore); + assert.deepEqual(readFileSync(harness.configPath), sourceBytes); + assert.equal(existsSync(join(harness.codexRoot, ".crp-history-repair")), false); +}); + +test("rejects a hard-linked SQLite sidecar before opening the database", async (t) => { + const harness = makeHarness("sqlite-sidecar-hardlink"); + t.after(() => harness.cleanup()); + const sourceBytes = sourceConfig(); + writePrivate(harness.configPath, sourceBytes); + const databasePath = join(harness.codexRoot, "state_5.sqlite"); + const sidecarPath = `${databasePath}-journal`; + const externalPath = join(harness.sandbox, "external-journal"); + createThreadsDatabase(databasePath, [ + { id: "sidecar-hardlink", provider: "legacy.provider", cwd: "/sidecar", hasUserEvent: 1 } + ]); + writePrivate(sidecarPath, "journal bytes\n"); + try { + realFileOperations.linkSync(sidecarPath, externalPath); + } catch (error) { + if (error?.code === "EPERM" || error?.code === "EACCES" || error?.code === "ENOTSUP") { + t.skip("hard-link creation is unavailable on this platform"); + return; + } + throw error; + } + const externalBefore = readFileSync(externalPath); + + await assert.rejects( + () => executeTransition(harness, sourceBytes, { + publishConfig() { + assert.fail("A hard-linked SQLite sidecar must block config publication"); + } + }), + (error) => assertSafeError(error, "CODEX_HISTORY_REPAIR_INVALID", harness) + ); + + assert.deepEqual(readFileSync(sidecarPath), externalBefore); + assert.deepEqual(readFileSync(externalPath), externalBefore); + assert.deepEqual(readFileSync(harness.configPath), sourceBytes); + assert.equal(existsSync(join(harness.codexRoot, ".crp-history-repair")), false); +}); + +test("rejects a corrupt existing rollout backup before config or history writes", async (t) => { + const harness = makeHarness("corrupt-rollout-backup"); + t.after(() => harness.cleanup()); + const sourceBytes = sourceConfig(); + writePrivate(harness.configPath, sourceBytes); + const rolloutBytes = Buffer.from(`${rolloutLine("legacy.provider", "corrupt-backup")}\n`); + const rolloutPath = join(harness.codexRoot, "sessions", "rollout-corrupt.jsonl"); + writeRollout(rolloutPath, rolloutBytes); + + await assert.rejects( + () => executeTransition(harness, sourceBytes, { + id: "op-corrupt-backup", + publishConfig() { + throw new Error("leave source-hash pending"); + } + }), + (error) => assertSafeError(error, "CODEX_HISTORY_REPAIR_FAILED", harness) + ); + const destination = join( + backupPath(harness.codexRoot, "op-corrupt-backup"), + "rollouts", + "sessions", + `rollout-corrupt.jsonl.${sha256(rolloutBytes)}.bak` + ); + writeFileSync(destination, "corrupt", { mode: 0o600 }); + const rolloutBefore = captureFile(rolloutPath); + let publishCalls = 0; + + await assert.rejects( + () => executeTransition(harness, sourceBytes, { + id: "op-corrupt-backup-resume", + publishConfig() { + publishCalls += 1; + } + }), + (error) => assertSafeError(error, "CODEX_HISTORY_REPAIR_CONFLICT", harness) + ); + assert.equal(publishCalls, 0); + assertUnchanged(rolloutPath, rolloutBefore); + assert.deepEqual(readFileSync(harness.configPath), sourceBytes); + assert.equal(hasPendingCodexHistoryRepair({ codexRoot: harness.codexRoot }), true); +}); + +test("does not expose a final rollout backup when atomic publication fails", async (t) => { + const harness = makeHarness("atomic-rollout-backup"); + t.after(() => harness.cleanup()); + const sourceBytes = sourceConfig(); + writePrivate(harness.configPath, sourceBytes); + const rolloutBytes = Buffer.from(`${rolloutLine("legacy.provider", "atomic-backup")}\n`); + const rolloutPath = join(harness.codexRoot, "sessions", "rollout-atomic.jsonl"); + writeRollout(rolloutPath, rolloutBytes); + const suffix = `rollout-atomic.jsonl.${sha256(rolloutBytes)}.bak`; + let publicationBlocked = false; + + await assert.rejects( + () => executeTransition(harness, sourceBytes, { + id: "op-atomic-backup", + fileOperations: { + ...realFileOperations, + linkSync(source, destination, ...args) { + if (destination.endsWith(suffix)) { + publicationBlocked = true; + const error = new Error("simulated backup publication failure"); + error.code = "EIO"; + throw error; + } + return realFileOperations.linkSync(source, destination, ...args); + } + }, + publishConfig() { + assert.fail("A backup publication failure must precede config publication"); + } + }), + (error) => assertSafeError(error, "CODEX_HISTORY_REPAIR_FAILED", harness) + ); + + assert.equal(publicationBlocked, true); + const operationRoot = backupPath(harness.codexRoot, "op-atomic-backup"); + const names = existsSync(operationRoot) + ? readdirSync(operationRoot, { recursive: true }).map(String) + : []; + assert.equal(names.some((name) => name.endsWith(suffix)), false); + assert.equal(names.some((name) => name.includes(".tmp") || name.includes(".claim")), false); + assert.equal(existsSync(pendingPath(harness.codexRoot)), false); + assert.deepEqual(readFileSync(harness.configPath), sourceBytes); +}); + +test("validates rollout backups published by an EEXIST race", async (t) => { + for (const [label, racedBytes, expectedCode] of [ + ["matching", null, null], + ["conflicting", Buffer.from("conflicting-backup"), "CODEX_HISTORY_REPAIR_CONFLICT"] + ]) { + await t.test(label, async (subtest) => { + const harness = makeHarness(`backup-race-${label}`); + subtest.after(() => harness.cleanup()); + const sourceBytes = sourceConfig(); + writePrivate(harness.configPath, sourceBytes); + const rolloutBytes = Buffer.from(`${rolloutLine("legacy.provider", `race-${label}`)}\n`); + const rolloutPath = join(harness.codexRoot, "sessions", `rollout-race-${label}.jsonl`); + writeRollout(rolloutPath, rolloutBytes); + const suffix = `rollout-race-${label}.jsonl.${sha256(rolloutBytes)}.bak`; + let raced = false; + let publishCalls = 0; + const options = { + id: `op-backup-race-${label}`, + fileOperations: { + ...realFileOperations, + linkSync(source, destination, ...args) { + if (!raced && destination.endsWith(suffix)) { + raced = true; + writePrivate(destination, racedBytes ?? rolloutBytes); + const error = new Error("simulated EEXIST race"); + error.code = "EEXIST"; + throw error; + } + return realFileOperations.linkSync(source, destination, ...args); + } + }, + publishConfig(bytes) { + publishCalls += 1; + writeFileSync(harness.configPath, bytes); + return { changed: true }; + } + }; + + if (expectedCode === null) { + const result = await executeTransition(harness, sourceBytes, options); + assert.equal(result.historyRepair.completed, true); + assert.equal(result.historyRepair.backupCreated, true); + assert.equal(publishCalls, 1); + } else { + await assert.rejects( + () => executeTransition(harness, sourceBytes, options), + (error) => assertSafeError(error, expectedCode, harness) + ); + assert.equal(publishCalls, 0); + assert.deepEqual(readFileSync(harness.configPath), sourceBytes); + } + assert.equal(raced, true); + }); + } +}); + +test("preserves a discoverable replacement when pending changes during clear", async (t) => { + const harness = makeHarness("clear-replacement"); + t.after(() => harness.cleanup()); + const sourceBytes = sourceConfig(); + const targetBytes = targetConfig(); + writePrivate(harness.configPath, sourceBytes); + const rolloutPath = join(harness.codexRoot, "sessions", "rollout-clear-replacement.jsonl"); + writeRollout(rolloutPath, `${rolloutLine("legacy.provider", "clear-replacement")}\n`); + let replaced = false; + + await assert.rejects( + () => executeTransition(harness, sourceBytes, { + id: "op-clear-replacement", + fileOperations: { + ...realFileOperations, + renameSync(source, destination, ...args) { + const result = realFileOperations.renameSync(source, destination, ...args); + if (!replaced && source === pendingPath(harness.codexRoot) + && destination === clearingPath(harness.codexRoot)) { + const bytes = readFileSync(destination); + unlinkSync(destination); + writePrivate(destination, bytes); + replaced = true; + } + return result; + } + } + }), + (error) => assertSafeError( + error, + "CODEX_HISTORY_REPAIR_COMMITTED_DEGRADED", + harness + ) + ); + + assert.equal(replaced, true); + assert.equal(existsSync(pendingPath(harness.codexRoot)), false); + assert.equal(existsSync(clearingPath(harness.codexRoot)), true); + assert.equal(hasPendingCodexHistoryRepair({ codexRoot: harness.codexRoot }), true); + const resumed = await executeTransition(harness, sourceBytes, { + currentConfigBytes: targetBytes, + transition: plan(targetBytes), + id: "op-clear-replacement-resume", + publishConfig() { + assert.fail("A target-hash clear recovery must not republish config"); + } + }); + assert.equal(resumed.historyRepair.resumed, true); + assert.equal(hasPendingCodexHistoryRepair({ codexRoot: harness.codexRoot }), false); +}); + +test("rejects a symlinked rollout backup without reading or changing its target", async (t) => { + if (process.platform === "win32") { + t.skip("symlink behavior requires a POSIX test host"); + return; + } + const harness = makeHarness("symlink-rollout-backup"); + t.after(() => harness.cleanup()); + const sourceBytes = sourceConfig(); + writePrivate(harness.configPath, sourceBytes); + const rolloutBytes = Buffer.from(`${rolloutLine("legacy.provider", "symlink-backup")}\n`); + const rolloutPath = join(harness.codexRoot, "sessions", "rollout-symlink-backup.jsonl"); + writeRollout(rolloutPath, rolloutBytes); + + await assert.rejects( + () => executeTransition(harness, sourceBytes, { + id: "op-symlink-backup", + publishConfig() { + throw new Error("leave source-hash pending"); + } + }), + (error) => assertSafeError(error, "CODEX_HISTORY_REPAIR_FAILED", harness) + ); + const destination = join( + backupPath(harness.codexRoot, "op-symlink-backup"), + "rollouts", + "sessions", + `rollout-symlink-backup.jsonl.${sha256(rolloutBytes)}.bak` + ); + const external = join(harness.sandbox, "external-backup-target"); + writePrivate(external, `external-${SECRET}`); + unlinkSync(destination); + symlinkSync(external, destination); + const externalBefore = captureFile(external); + + await assert.rejects( + () => executeTransition(harness, sourceBytes, { + id: "op-symlink-backup-resume", + publishConfig() { + assert.fail("A symlinked backup must block config publication"); + } + }), + (error) => assertSafeError(error, "CODEX_HISTORY_REPAIR_CONFLICT", harness) + ); + assertUnchanged(external, externalBefore); + assert.deepEqual(readFileSync(harness.configPath), sourceBytes); +}); + +test("rejects a symlinked backups ancestor before resume reads external state", async (t) => { + if (process.platform === "win32") { + t.skip("symlink behavior requires a POSIX test host"); + return; + } + const harness = makeHarness("symlink-backups-parent"); + t.after(() => harness.cleanup()); + const sourceBytes = sourceConfig(); + writePrivate(harness.configPath, sourceBytes); + const rolloutPath = join(harness.codexRoot, "sessions", "rollout-backups-parent.jsonl"); + writeRollout(rolloutPath, `${rolloutLine("legacy.provider", "backups-parent")}\n`); + + await assert.rejects( + () => executeTransition(harness, sourceBytes, { + id: "op-backups-parent", + publishConfig() { + throw new Error("leave source-hash pending"); + } + }), + (error) => assertSafeError(error, "CODEX_HISTORY_REPAIR_FAILED", harness) + ); + const backups = join(harness.codexRoot, ".crp-history-repair", "backups"); + const external = join(harness.sandbox, "external-backups"); + realFileOperations.renameSync(backups, external); + symlinkSync(external, backups); + const externalMetadata = join(external, "op-backups-parent", "metadata.json"); + const metadataBefore = captureFile(externalMetadata); + const rolloutBefore = captureFile(rolloutPath); + + await assert.rejects( + () => executeTransition(harness, sourceBytes, { + id: "op-backups-parent-resume", + publishConfig() { + assert.fail("A symlinked backups parent must block config publication"); + } + }), + (error) => assertSafeError(error, "CODEX_HISTORY_REPAIR_CONFLICT", harness) + ); + assertUnchanged(externalMetadata, metadataBefore); + assertUnchanged(rolloutPath, rolloutBefore); + assert.deepEqual(readFileSync(harness.configPath), sourceBytes); +}); + +test("rejects rollout identity replacement without overwriting either identity", async (t) => { + const harness = makeHarness("identity"); + t.after(() => harness.cleanup()); + const sourceBytes = sourceConfig(); + writePrivate(harness.configPath, sourceBytes); + const rolloutPath = join(harness.codexRoot, "sessions", "rollout-identity.jsonl"); + const originalBytes = Buffer.from(`${rolloutLine("legacy.provider", "thread-original")}\n`); + const replacementBytes = Buffer.from(`${rolloutLine("foreign", "thread-replacement", { + note: SECRET + })}\n`); + writeRollout(rolloutPath, originalBytes); + let rolloutDescriptor; + let descriptorRead = false; + let replaced = false; + let publishCalls = 0; + + await assert.rejects( + () => executeTransition(harness, sourceBytes, { + fileOperations: { + ...realFileOperations, + openSync(path, ...args) { + const descriptor = realFileOperations.openSync(path, ...args); + if (path === rolloutPath) rolloutDescriptor = descriptor; + return descriptor; + }, + readFileSync(pathOrDescriptor, ...args) { + const value = realFileOperations.readFileSync(pathOrDescriptor, ...args); + if (pathOrDescriptor === rolloutDescriptor && !descriptorRead) { + descriptorRead = true; + realFileOperations.unlinkSync(rolloutPath); + realFileOperations.writeFileSync(rolloutPath, replacementBytes, { mode: 0o600 }); + replaced = true; + } + return value; + } + }, + publishConfig() { + publishCalls += 1; + } + }), + (error) => assertSafeError(error, "CODEX_HISTORY_REPAIR_CONFLICT", harness) + ); + assert.equal(descriptorRead, true); + assert.equal(replaced, true); + assert.equal(publishCalls, 0); + assert.deepEqual(readFileSync(rolloutPath), replacementBytes); + assert.deepEqual(readFileSync(harness.configPath), sourceBytes); +}); + +test("applies rollout mode and timestamps before publishing the replacement inode", async (t) => { + const harness = makeHarness("rollout-metadata-before-publish"); + t.after(() => harness.cleanup()); + const sourceBytes = sourceConfig(); + writePrivate(harness.configPath, sourceBytes); + const rolloutPath = join(harness.codexRoot, "sessions", "rollout-metadata.jsonl"); + writeRollout(rolloutPath, `${rolloutLine("legacy.provider", "metadata")}\n`, { + mode: 0o640, + mtime: FIXED_TIME + }); + const before = captureFile(rolloutPath); + let metadataBlocked = false; + + await assert.rejects( + () => executeTransition(harness, sourceBytes, { + fileOperations: { + ...realFileOperations, + futimesSync(...args) { + if (!metadataBlocked) { + metadataBlocked = true; + throw new Error("simulated metadata failure"); + } + return realFileOperations.futimesSync(...args); + } + } + }), + (error) => assertSafeError( + error, + "CODEX_HISTORY_REPAIR_COMMITTED_DEGRADED", + harness + ) + ); + + assert.equal(metadataBlocked, true); + assertUnchanged(rolloutPath, before); + assert.equal(hasPendingCodexHistoryRepair({ codexRoot: harness.codexRoot }), true); +}); + +test("retry fsyncs a previously published rollout directory before clearing pending", async (t) => { + const harness = makeHarness("rollout-directory-retry"); + t.after(() => harness.cleanup()); + const sourceBytes = sourceConfig(); + const targetBytes = targetConfig(); + writePrivate(harness.configPath, sourceBytes); + const rolloutPath = join(harness.codexRoot, "sessions", "rollout-directory.jsonl"); + const rolloutDirectory = dirname(rolloutPath); + writeRollout(rolloutPath, `${rolloutLine("legacy.provider", "directory-retry")}\n`, { + mode: 0o640, + mtime: FIXED_TIME + }); + const before = captureFile(rolloutPath); + let directoryFsyncFailed = false; + + await assert.rejects( + () => executeTransition(harness, sourceBytes, { + fileOperations: { + ...realFileOperations, + fsyncDirectorySync(path) { + if (path === rolloutDirectory && !directoryFsyncFailed) { + directoryFsyncFailed = true; + throw new Error("simulated rollout directory fsync failure"); + } + } + } + }), + (error) => assertSafeError( + error, + "CODEX_HISTORY_REPAIR_COMMITTED_DEGRADED", + harness + ) + ); + assert.equal(directoryFsyncFailed, true); + assert.equal(JSON.parse(readFileSync(rolloutPath, "utf8")).payload.model_provider, + TARGET_PROVIDER); + assert.equal(mtimeNs(rolloutPath), before.mtime); + if (process.platform !== "win32") { + assert.equal(statSync(rolloutPath).mode & 0o777, before.mode); + } + + const fsynced = []; + const resumed = await executeTransition(harness, sourceBytes, { + currentConfigBytes: targetBytes, + transition: plan(targetBytes), + id: "op-rollout-directory-resume", + fileOperations: { + ...realFileOperations, + fsyncDirectorySync(path) { fsynced.push(path); } + }, + publishConfig() { + assert.fail("A target-hash directory recovery must not republish config"); + } + }); + assert.equal(resumed.historyRepair.resumed, true); + assert.equal(fsynced.includes(rolloutDirectory), true); + assert.equal(hasPendingCodexHistoryRepair({ codexRoot: harness.codexRoot }), false); +}); + +test("rejects asynchronous config publication so completion cannot outrun the config commit", async (t) => { + const harness = makeHarness("async-publish"); + t.after(() => harness.cleanup()); + const sourceBytes = sourceConfig(); + writePrivate(harness.configPath, sourceBytes); + const rolloutPath = join(harness.codexRoot, "sessions", "rollout-async.jsonl"); + writeRollout(rolloutPath, `${rolloutLine("legacy.provider", "thread-async")}\n`); + const before = captureFile(rolloutPath); + + await assert.rejects( + () => executeTransition(harness, sourceBytes, { + publishConfig() { + return Promise.resolve({ changed: true }); + } + }), + (error) => assertSafeError(error, "CODEX_HISTORY_REPAIR_INVALID", harness) + ); + assertUnchanged(rolloutPath, before); + assert.deepEqual(readFileSync(harness.configPath), sourceBytes); +}); + +test("repairs only rollout snapshots backed before config publication and defers later files", async (t) => { + const harness = makeHarness("exact-rollout-set"); + t.after(() => harness.cleanup()); + const sourceBytes = sourceConfig(); + const targetBytes = targetConfig(); + writePrivate(harness.configPath, sourceBytes); + const changedPath = join(harness.codexRoot, "sessions", "rollout-changed.jsonl"); + const addedPath = join(harness.codexRoot, "sessions", "rollout-added.jsonl"); + writeRollout(changedPath, `${rolloutLine("legacy.provider", "before-publish")}\n`); + + await assert.rejects( + () => executeTransition(harness, sourceBytes, { + id: "op-exact-rollout", + publishConfig(bytes) { + writeFileSync(harness.configPath, bytes); + writeRollout(changedPath, `${rolloutLine("changed-after-backup", "after-publish")}\n`); + writeRollout(addedPath, `${rolloutLine("added-after-backup", "new-after-publish")}\n`); + return { changed: true }; + } + }), + (error) => assertSafeError( + error, + "CODEX_HISTORY_REPAIR_COMMITTED_DEGRADED", + harness + ) + ); + + assert.equal(JSON.parse(readFileSync(changedPath, "utf8")).payload.model_provider, + "changed-after-backup"); + assert.equal(JSON.parse(readFileSync(addedPath, "utf8")).payload.model_provider, + "added-after-backup"); + assert.equal(hasPendingCodexHistoryRepair({ codexRoot: harness.codexRoot }), true); + + const resumed = await executeTransition(harness, sourceBytes, { + currentConfigBytes: targetBytes, + transition: plan(targetBytes), + id: "op-exact-rollout-retry", + publishConfig() { + assert.fail("A target-hash recovery must not republish config"); + } + }); + assert.equal(resumed.historyRepair.resumed, true); + assert.equal(resumed.historyRepair.rolloutFiles, 2); + assert.equal(JSON.parse(readFileSync(changedPath, "utf8")).payload.model_provider, + TARGET_PROVIDER); + assert.equal(JSON.parse(readFileSync(addedPath, "utf8")).payload.model_provider, + TARGET_PROVIDER); +}); + +test("updates only SQLite rows included in the completed online backup snapshot", async (t) => { + const harness = makeHarness("exact-sqlite-set"); + t.after(() => harness.cleanup()); + const sourceBytes = sourceConfig(); + const targetBytes = targetConfig(); + writePrivate(harness.configPath, sourceBytes); + const databasePath = join(harness.codexRoot, "state_5.sqlite"); + createThreadsDatabase(databasePath, [ + { id: "backed-row", provider: "legacy.provider", cwd: "/backed", hasUserEvent: 1 } + ]); + let injected = false; + let openCalls = 0; + + await assert.rejects( + () => executeTransition(harness, sourceBytes, { + id: "op-exact-sqlite", + databaseOperations: { + open(path) { + openCalls += 1; + const database = new DatabaseSync(path); + if (openCalls !== 2) return database; + return { + prepare(sql) { return database.prepare(sql); }, + exec(sql) { + const result = database.exec(sql); + if (sql === "COMMIT" && !injected) { + injected = true; + const writer = new DatabaseSync(databasePath); + try { + writer.prepare("INSERT INTO threads VALUES (?, ?, ?, ?, ?)").run( + "post-backup-row", + "legacy.provider", + "/post-backup", + 0, + "post-backup" + ); + } finally { + writer.close(); + } + } + return result; + }, + close() { return database.close(); } + }; + }, + async backup(database, destination) { + await sqliteBackup(database, destination); + } + } + }), + (error) => assertSafeError( + error, + "CODEX_HISTORY_REPAIR_COMMITTED_DEGRADED", + harness + ) + ); + + assert.equal(injected, true); + const afterFirstAttempt = readThreads(databasePath); + assert.equal(afterFirstAttempt.find((row) => row.id === "backed-row").model_provider, + TARGET_PROVIDER); + assert.equal(afterFirstAttempt.find((row) => row.id === "post-backup-row").model_provider, + "legacy.provider"); + + const resumed = await executeTransition(harness, sourceBytes, { + currentConfigBytes: targetBytes, + transition: plan(targetBytes), + id: "op-exact-sqlite-retry", + publishConfig() { + assert.fail("A target-hash recovery must not republish config"); + } + }); + assert.equal(resumed.historyRepair.resumed, true); + assert.equal(resumed.historyRepair.sqliteRows, 1); + assert.equal(readThreads(databasePath).every( + (row) => row.model_provider === TARGET_PROVIDER + ), true); +}); + +test("rejects a canonical SQLite inode replacement after open and before backup", async (t) => { + if (process.platform === "win32") { + t.skip("Windows prevents replacing an open SQLite database"); + return; + } + const harness = makeHarness("sqlite-open-identity"); + t.after(() => harness.cleanup()); + const sourceBytes = sourceConfig(); + writePrivate(harness.configPath, sourceBytes); + const databasePath = join(harness.codexRoot, "state_5.sqlite"); + const displacedPath = join(harness.codexRoot, "displaced.sqlite"); + createThreadsDatabase(databasePath, [ + { id: "original", provider: "legacy.provider", cwd: "/original", hasUserEvent: 1 } + ]); + let openCalls = 0; + let replaced = false; + + await assert.rejects( + () => executeTransition(harness, sourceBytes, { + id: "op-sqlite-identity", + databaseOperations: { + open(path) { + openCalls += 1; + const database = new DatabaseSync(path); + if (openCalls === 2) { + realFileOperations.renameSync(databasePath, displacedPath); + createThreadsDatabase(databasePath, [ + { id: "foreign", provider: "foreign", cwd: "/foreign", hasUserEvent: 0 } + ]); + replaced = true; + } + return database; + }, + async backup(database, destination) { + await sqliteBackup(database, destination); + } + }, + publishConfig() { + assert.fail("An identity conflict must be rejected before config publication"); + } + }), + (error) => assertSafeError(error, "CODEX_HISTORY_REPAIR_CONFLICT", harness) + ); + + assert.equal(replaced, true); + assert.deepEqual(readFileSync(harness.configPath), sourceBytes); + assert.equal(readThreads(databasePath)[0].id, "foreign"); + assert.equal(readThreads(displacedPath)[0].id, "original"); + assert.equal(existsSync(pendingPath(harness.codexRoot)), false); +}); + +test("maps target-hash discovery failures to committed degradation and retains pending", async (t) => { + const harness = makeHarness("target-discovery-failure"); + t.after(() => harness.cleanup()); + const sourceBytes = sourceConfig(); + const targetBytes = targetConfig(); + writePrivate(harness.configPath, sourceBytes); + const rolloutPath = join(harness.codexRoot, "sessions", "rollout-initial.jsonl"); + writeRollout(rolloutPath, `${rolloutLine("legacy.provider", "initial")}\n`); + + await assert.rejects( + () => executeTransition(harness, sourceBytes, { + id: "op-target-discovery", + publishConfig(bytes) { + writeFileSync(harness.configPath, bytes); + throw new Error("post-commit callback failure"); + } + }), + (error) => assertSafeError( + error, + "CODEX_HISTORY_REPAIR_COMMITTED_DEGRADED", + harness + ) + ); + const external = join(harness.sandbox, "external.jsonl"); + writePrivate(external, `${rolloutLine("foreign", "external")}\n`); + const unsafeLink = join(harness.codexRoot, "sessions", "rollout-link.jsonl"); + try { + symlinkSync(external, unsafeLink); + } catch (error) { + if (error?.code === "EPERM" || error?.code === "EACCES") { + t.skip("symlink creation is unavailable on this platform"); + return; + } + throw error; + } + + await assert.rejects( + () => executeTransition(harness, sourceBytes, { + currentConfigBytes: targetBytes, + transition: plan(targetBytes), + id: "op-target-discovery-retry", + publishConfig() { + assert.fail("A target-hash recovery must not republish config"); + } + }), + (error) => assertSafeError( + error, + "CODEX_HISTORY_REPAIR_COMMITTED_DEGRADED", + harness + ) + ); + assert.equal(hasPendingCodexHistoryRepair({ codexRoot: harness.codexRoot }), true); +}); + +test("fsyncs backup and pending directory entries before invoking config publication", async (t) => { + const harness = makeHarness("directory-fsync-order"); + t.after(() => harness.cleanup()); + const sourceBytes = sourceConfig(); + writePrivate(harness.configPath, sourceBytes); + const rolloutPath = join(harness.codexRoot, "sessions", "rollout-fsync.jsonl"); + writeRollout(rolloutPath, `${rolloutLine("legacy.provider", "fsync-order")}\n`); + const events = []; + + await executeTransition(harness, sourceBytes, { + id: "op-directory-fsync", + fileOperations: { + ...realFileOperations, + fsyncDirectorySync(path) { + events.push(["directory-fsync", path]); + } + }, + publishConfig(bytes) { + events.push(["publish-config", harness.configPath]); + writeFileSync(harness.configPath, bytes); + return { changed: true }; + } + }); + + const publishIndex = events.findIndex(([name]) => name === "publish-config"); + const pendingDirectory = dirname(pendingPath(harness.codexRoot)); + const pendingFsyncIndex = events.findLastIndex(([name, path], index) => ( + index < publishIndex && name === "directory-fsync" && path === pendingDirectory + )); + const backupFsyncIndex = events.findIndex(([name, path]) => ( + name === "directory-fsync" && path.includes(`${sep}backups${sep}`) + )); + assert.ok(backupFsyncIndex >= 0 && backupFsyncIndex < pendingFsyncIndex); + assert.ok(pendingFsyncIndex >= 0 && pendingFsyncIndex < publishIndex); +}); + +test("stops before history writes when config-lock ownership changes after publication", async (t) => { + const harness = makeHarness("post-publish-lock"); + t.after(() => harness.cleanup()); + const sourceBytes = sourceConfig(); + writePrivate(harness.configPath, sourceBytes); + const rolloutPath = join(harness.codexRoot, "sessions", "rollout-lock.jsonl"); + writeRollout(rolloutPath, `${rolloutLine("legacy.provider", "thread-lock")}\n`); + const before = captureFile(rolloutPath); + let ownershipChecks = 0; + + await assert.rejects( + () => executeTransition(harness, sourceBytes, { + assertConfigLock() { + ownershipChecks += 1; + if (ownershipChecks === 2) throw new Error("lock ownership changed"); + } + }), + (error) => assertSafeError( + error, + "CODEX_HISTORY_REPAIR_COMMITTED_DEGRADED", + harness + ) + ); + + assert.equal(ownershipChecks, 2); + assert.deepEqual(readFileSync(harness.configPath), targetConfig()); + assertUnchanged(rolloutPath, before); + assert.equal(hasPendingCodexHistoryRepair({ codexRoot: harness.codexRoot }), true); +}); + +test("preserves pending when config-lock ownership changes after final verification", async (t) => { + const harness = makeHarness("final-verify-lock"); + t.after(() => harness.cleanup()); + const sourceBytes = sourceConfig(); + writePrivate(harness.configPath, sourceBytes); + const rolloutPath = join(harness.codexRoot, "sessions", "rollout-final-lock.jsonl"); + writeRollout(rolloutPath, `${rolloutLine("legacy.provider", "final-lock")}\n`); + let ownershipChecks = 0; + + await assert.rejects( + () => executeTransition(harness, sourceBytes, { + assertConfigLock() { + ownershipChecks += 1; + if (ownershipChecks === 4) throw new Error("lock changed after final verify"); + } + }), + (error) => assertSafeError( + error, + "CODEX_HISTORY_REPAIR_COMMITTED_DEGRADED", + harness + ) + ); + + assert.equal(ownershipChecks, 4); + assert.equal(JSON.parse(readFileSync(rolloutPath, "utf8")).payload.model_provider, + TARGET_PROVIDER); + assert.equal(hasPendingCodexHistoryRepair({ codexRoot: harness.codexRoot }), true); +}); + +test("stops before history writes when config changes after publication verification", async (t) => { + const harness = makeHarness("post-publish-config-change"); + t.after(() => harness.cleanup()); + const sourceBytes = sourceConfig(); + writePrivate(harness.configPath, sourceBytes); + const rolloutPath = join(harness.codexRoot, "sessions", "rollout-config-change.jsonl"); + writeRollout(rolloutPath, `${rolloutLine("legacy.provider", "config-change")}\n`); + const rolloutBefore = captureFile(rolloutPath); + let ownershipChecks = 0; + + await assert.rejects( + () => executeTransition(harness, sourceBytes, { + assertConfigLock() { + ownershipChecks += 1; + if (ownershipChecks === 2) writeFileSync(harness.configPath, sourceBytes); + } + }), + (error) => assertSafeError( + error, + "CODEX_HISTORY_REPAIR_COMMITTED_DEGRADED", + harness + ) + ); + + assert.equal(ownershipChecks, 2); + assertUnchanged(rolloutPath, rolloutBefore); + assert.deepEqual(readFileSync(harness.configPath), sourceBytes); + assert.equal(hasPendingCodexHistoryRepair({ codexRoot: harness.codexRoot }), true); +}); + +test("preserves pending when config changes after final history verification", async (t) => { + const harness = makeHarness("final-config-change"); + t.after(() => harness.cleanup()); + const sourceBytes = sourceConfig(); + writePrivate(harness.configPath, sourceBytes); + const rolloutPath = join(harness.codexRoot, "sessions", "rollout-final-config.jsonl"); + writeRollout(rolloutPath, `${rolloutLine("legacy.provider", "final-config")}\n`); + let ownershipChecks = 0; + + await assert.rejects( + () => executeTransition(harness, sourceBytes, { + assertConfigLock() { + ownershipChecks += 1; + if (ownershipChecks === 4) writeFileSync(harness.configPath, sourceBytes); + } + }), + (error) => assertSafeError( + error, + "CODEX_HISTORY_REPAIR_COMMITTED_DEGRADED", + harness + ) + ); + + assert.equal(ownershipChecks, 4); + assert.equal(JSON.parse(readFileSync(rolloutPath, "utf8")).payload.model_provider, + TARGET_PROVIDER); + assert.deepEqual(readFileSync(harness.configPath), sourceBytes); + assert.equal(hasPendingCodexHistoryRepair({ codexRoot: harness.codexRoot }), true); +}); diff --git a/node/test/credential-store.test.mjs b/node/test/credential-store.test.mjs new file mode 100644 index 0000000..1568ee5 --- /dev/null +++ b/node/test/credential-store.test.mjs @@ -0,0 +1,1348 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import * as realFileOperations from "node:fs"; +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + symlinkSync, + writeFileSync +} from "node:fs"; +import os from "node:os"; +import { basename, dirname, join } from "node:path"; + +import { FileCredentialStore } from "../src/credentials/file-credential-store.mjs"; +import { NativeKeyringStore } from "../src/credentials/native-keyring.mjs"; +import { createCredentialStore } from "../src/credentials/credential-store.mjs"; +import { + normalizeProvider, + toPublicProvider +} from "../src/providers/provider-schema.mjs"; +import { CrpError, toPublicError } from "../src/shared/errors.mjs"; + +const NATIVE_SERVICE = "org.cluic.codex-remote-proxy"; + +function makeRef(label = "provider") { + return `${label}-${randomUUID()}`; +} + +function makeSecret() { + return ["test", "credential", randomUUID()].join("-"); +} + +function makeFileError(message, code) { + const error = new Error(message); + error.code = code; + return error; +} + +function assertCrpError(expectedCode, expectedStatus) { + return (error) => { + assert.ok(error instanceof CrpError); + assert.equal(error.code, expectedCode); + assert.equal(error.status, expectedStatus); + assert.equal(typeof error.action, "string"); + assert.notEqual(error.action.length, 0); + return true; + }; +} + +function assertSafePublicError(error, prohibited) { + const serialized = JSON.stringify(toPublicError(error, "request-test")); + for (const value of prohibited) { + assert.equal(serialized.includes(value), false); + } +} + +function makeTempCredentialPath(t, prefix = "crp-credentials-") { + const tempRoot = mkdtempSync(join(os.tmpdir(), prefix)); + const parent = join(tempRoot, "private"); + const path = join(parent, "secrets.json"); + t.after(() => rmSync(tempRoot, { recursive: true, force: true })); + return { tempRoot, parent, path, lockPath: `${path}.crp.lock` }; +} + +function writeCredentialBytes(path, bytes, mode = 0o600) { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + writeFileSync(path, bytes, { mode }); + chmodSync(path, mode); +} + +function credentialDocument(credentials = {}) { + return { schemaVersion: 1, credentials }; +} + +function listTempFiles(path) { + if (!existsSync(dirname(path))) return []; + const fileName = basename(path); + return readdirSync(dirname(path)).filter((name) => ( + name.startsWith(`.${fileName}.`) && name.endsWith(".tmp") + )); +} + +function isLockReleasePath(filePath, lockPath) { + if (typeof filePath !== "string" || dirname(filePath) !== dirname(lockPath)) { + return false; + } + const name = basename(filePath); + return name.startsWith(`.${basename(lockPath)}.`) && name.endsWith(".release"); +} + +function listLockReleaseFiles(lockPath) { + if (!existsSync(dirname(lockPath))) return []; + return readdirSync(dirname(lockPath)) + .map((name) => join(dirname(lockPath), name)) + .filter((filePath) => isLockReleasePath(filePath, lockPath)); +} + +function isGateClaimPath(filePath, gatePath) { + if (typeof filePath !== "string" || dirname(filePath) !== dirname(gatePath)) { + return false; + } + const name = basename(filePath); + return name.startsWith(`.${basename(gatePath)}.`) && name.endsWith(".claim"); +} + +function listGateClaimPaths(gatePath) { + if (!existsSync(dirname(gatePath))) return []; + return readdirSync(dirname(gatePath)) + .map((name) => join(dirname(gatePath), name)) + .filter((filePath) => isGateClaimPath(filePath, gatePath)); +} + +class MemoryCredentialStore { + constructor() { + this.values = new Map(); + this.backend = "memory"; + } + + async set(ref, secret) { + this.values.set(ref, secret); + } + + async get(ref) { + if (!this.values.has(ref)) { + throw new CrpError( + "CREDENTIAL_NOT_FOUND", + "The credential does not exist.", + "Save the provider credential and try again.", + { status: 404 } + ); + } + return this.values.get(ref); + } + + async has(ref) { + return this.values.has(ref); + } + + async delete(ref) { + return this.values.delete(ref); + } +} + +async function assertCredentialContract(store, { afterSet } = {}) { + const ref = makeRef(); + const secret = makeSecret(); + + assert.equal(typeof store.list, "undefined"); + await store.set(ref, secret); + assert.equal(await store.has(ref), true); + assert.equal(await store.get(ref), secret); + await afterSet?.({ ref, secret }); + assert.equal(await store.delete(ref), true); + assert.equal(await store.has(ref), false); + await assert.rejects( + () => store.get(ref), + assertCrpError("CREDENTIAL_NOT_FOUND", 404) + ); + assert.equal(await store.delete(ref), false); +} + +function createFakeEntryClass() { + const values = new Map(); + const constructions = []; + class FakeEntry { + constructor(service, ref) { + constructions.push({ service, ref }); + this.ref = ref; + } + + setPassword(secret) { + values.set(this.ref, secret); + } + + getPassword() { + return values.get(this.ref) ?? null; + } + + deletePassword() { + return values.delete(this.ref); + } + } + return { FakeEntry, values, constructions }; +} + +test("shared async credential contract works for an in-memory test double", async () => { + await assertCredentialContract(new MemoryCredentialStore()); +}); + +test("shared async credential contract persists the exact file schema and reloads", async (t) => { + const { parent, path } = makeTempCredentialPath(t); + const store = new FileCredentialStore({ path }); + + await assertCredentialContract(store, { + afterSet: async ({ ref, secret }) => { + assert.deepEqual( + JSON.parse(readFileSync(path, "utf8")), + credentialDocument({ [ref]: secret }) + ); + assert.equal(readFileSync(path, "utf8").includes("providerMetadata"), false); + assert.equal(readFileSync(path, "utf8").includes("baseUrl"), false); + const reloaded = new FileCredentialStore({ path }); + assert.equal(await reloaded.get(ref), secret); + assert.equal(typeof reloaded.list, "undefined"); + } + }); + + if (process.platform !== "win32") { + assert.equal(lstatSync(parent).mode & 0o777, 0o700); + assert.equal(lstatSync(path).mode & 0o777, 0o600); + } +}); + +test("file mutations avoid lost updates and reads refresh committed disk state", async (t) => { + const { path } = makeTempCredentialPath(t, "crp-credentials-multi-"); + const first = new FileCredentialStore({ path }); + const second = new FileCredentialStore({ path }); + const firstRef = makeRef("first"); + const secondRef = makeRef("second"); + const firstSecret = makeSecret(); + const secondSecret = makeSecret(); + + await first.set(firstRef, firstSecret); + await second.set(secondRef, secondSecret); + + assert.equal(await first.get(secondRef), secondSecret); + assert.equal(await second.get(firstRef), firstSecret); + assert.deepEqual( + JSON.parse(readFileSync(path, "utf8")), + credentialDocument({ + [firstRef]: firstSecret, + [secondRef]: secondSecret + }) + ); + + await first.delete(firstRef); + assert.equal(await second.has(firstRef), false); + assert.equal(await second.get(secondRef), secondSecret); +}); + +test("file persistence uses exclusive 0600 lock and temp files in atomic order", async (t) => { + const { parent, path, lockPath } = makeTempCredentialPath(t, "crp-credentials-atomic-"); + const gatePath = `${lockPath}.gate`; + const events = []; + const descriptorPaths = new Map(); + const fileOperations = { + ...realFileOperations, + openSync(filePath, flags, mode) { + const descriptor = realFileOperations.openSync(filePath, flags, mode); + descriptorPaths.set(descriptor, filePath); + events.push({ operation: "open", path: filePath, flags, mode }); + return descriptor; + }, + fsyncSync(descriptor) { + events.push({ operation: "fsync", path: descriptorPaths.get(descriptor) }); + return realFileOperations.fsyncSync(descriptor); + }, + closeSync(descriptor) { + events.push({ operation: "close", path: descriptorPaths.get(descriptor) }); + return realFileOperations.closeSync(descriptor); + }, + chmodSync(filePath, mode) { + events.push({ operation: "chmod", path: filePath, mode }); + return realFileOperations.chmodSync(filePath, mode); + }, + renameSync(source, target) { + events.push({ operation: "rename", source, target }); + return realFileOperations.renameSync(source, target); + } + }; + const store = new FileCredentialStore({ path, fileOperations }); + + await store.set(makeRef(), makeSecret()); + + const lockOpen = events.find((event) => event.operation === "open" && event.path === lockPath); + const tempOpen = events.find((event) => ( + event.operation === "open" + && event.path !== lockPath + && event.path.endsWith(".tmp") + )); + assert.deepEqual( + { flags: lockOpen.flags, mode: lockOpen.mode }, + { flags: "wx", mode: 0o600 } + ); + assert.deepEqual( + { flags: tempOpen.flags, mode: tempOpen.mode }, + { flags: "wx", mode: 0o600 } + ); + assert.equal(dirname(tempOpen.path), dirname(path)); + + const fsyncIndex = events.findIndex((event) => ( + event.operation === "fsync" && event.path === tempOpen.path + )); + const closeIndex = events.findIndex((event) => ( + event.operation === "close" && event.path === tempOpen.path + )); + const chmodIndex = events.findIndex((event) => ( + event.operation === "chmod" && event.path === tempOpen.path && event.mode === 0o600 + )); + const renameIndex = events.findIndex((event) => ( + event.operation === "rename" + && event.source === tempOpen.path + && event.target === path + )); + assert.ok(fsyncIndex < closeIndex); + assert.ok(closeIndex < chmodIndex); + assert.ok(chmodIndex < renameIndex); + assert.equal(existsSync(lockPath), false); + assert.equal(existsSync(gatePath), false); + assert.deepEqual(listGateClaimPaths(gatePath), []); + assert.deepEqual(listTempFiles(path), []); + + await store.set(makeRef("later"), makeSecret()); + assert.equal(existsSync(lockPath), false); + assert.equal(existsSync(gatePath), false); + assert.deepEqual(listGateClaimPaths(gatePath), []); + + if (process.platform !== "win32") { + assert.equal(lstatSync(parent).mode & 0o777, 0o700); + assert.equal(lstatSync(path).mode & 0o777, 0o600); + } +}); + +test("malformed and schema-invalid files fail closed without overwrite", async (t) => { + const secret = makeSecret(); + const ref = makeRef(); + const cases = [ + ["malformed JSON", `{${secret}`], + ["unsupported schema", JSON.stringify({ schemaVersion: 2, credentials: {} })], + ["extra metadata", JSON.stringify({ + schemaVersion: 1, + credentials: {}, + providerMetadata: { ref } + })], + ["array credentials", JSON.stringify({ schemaVersion: 1, credentials: [] })], + ["unsafe reference", JSON.stringify(credentialDocument({ ["__proto__"]: secret }))], + ["empty secret", JSON.stringify(credentialDocument({ [ref]: "" }))], + ["non-string secret", JSON.stringify(credentialDocument({ [ref]: 42 }))] + ]; + + for (const [label, bytes] of cases) { + await t.test(label, async (nested) => { + const { path, lockPath } = makeTempCredentialPath(nested, "crp-credentials-invalid-"); + const store = new FileCredentialStore({ path }); + writeCredentialBytes(path, bytes); + + let caught; + try { + await store.set(makeRef("new"), makeSecret()); + } catch (error) { + caught = error; + } + assertCrpError("CREDENTIAL_FILE_INVALID", 500)(caught); + assert.equal(readFileSync(path, "utf8"), bytes); + assert.equal(existsSync(lockPath), false); + assert.deepEqual(listTempFiles(path), []); + assertSafePublicError(caught, [secret, ref, bytes]); + }); + } +}); + +test("symbolic-link credential files are rejected before reading", (t) => { + if (process.platform === "win32") { + t.skip("Windows symlink creation requires platform privileges"); + return; + } + const { parent, path } = makeTempCredentialPath(t, "crp-credentials-symlink-"); + const target = join(dirname(parent), "target.json"); + const secret = makeSecret(); + const bytes = `${JSON.stringify(credentialDocument({ [makeRef()]: secret }))}\n`; + writeCredentialBytes(target, bytes); + mkdirSync(parent, { recursive: true, mode: 0o700 }); + symlinkSync(target, path); + + let caught; + try { + new FileCredentialStore({ path }); + } catch (error) { + caught = error; + } + assertCrpError("CREDENTIAL_FILE_INSECURE", 500)(caught); + assert.equal(readFileSync(target, "utf8"), bytes); + assertSafePublicError(caught, [secret, target, path]); +}); + +test("group or other accessible credential files are rejected before reading", (t) => { + if (process.platform === "win32") { + t.skip("POSIX permission bits are not authoritative on Windows"); + return; + } + const { path } = makeTempCredentialPath(t, "crp-credentials-mode-"); + const secret = makeSecret(); + const bytes = `${JSON.stringify(credentialDocument({ [makeRef()]: secret }))}\n`; + writeCredentialBytes(path, bytes, 0o644); + + let caught; + try { + new FileCredentialStore({ path }); + } catch (error) { + caught = error; + } + assertCrpError("CREDENTIAL_FILE_INSECURE", 500)(caught); + assertSafePublicError(caught, [secret, bytes, path]); +}); + +test("credential reads fail closed when the path is swapped between lstat and open", async (t) => { + const platforms = process.platform === "win32" + ? ["win32"] + : [process.platform, "win32"]; + + for (const platform of platforms) { + await t.test(`fd identity on ${platform}`, (nested) => { + const { tempRoot, path } = makeTempCredentialPath( + nested, + `crp-credentials-swap-${platform}-` + ); + const originalPath = join(tempRoot, "original.json"); + const targetPath = join(tempRoot, "target.json"); + const originalSecret = makeSecret(); + const targetSecret = makeSecret(); + writeCredentialBytes( + path, + `${JSON.stringify(credentialDocument({ [makeRef("original")]: originalSecret }))}\n` + ); + writeCredentialBytes( + targetPath, + `${JSON.stringify(credentialDocument({ [makeRef("target")]: targetSecret }))}\n` + ); + let swapped = false; + let pathReadAttempts = 0; + const fileOperations = { + ...realFileOperations, + openSync(filePath, flags, mode) { + if (filePath === path && typeof flags === "number" && !swapped) { + realFileOperations.renameSync(path, originalPath); + realFileOperations.renameSync(targetPath, path); + swapped = true; + return realFileOperations.openSync(originalPath, flags, mode); + } + return realFileOperations.openSync(filePath, flags, mode); + }, + readFileSync(fileOrDescriptor, options) { + if (fileOrDescriptor === path || fileOrDescriptor === targetPath) { + pathReadAttempts += 1; + } + return realFileOperations.readFileSync(fileOrDescriptor, options); + } + }; + + let caught; + try { + new FileCredentialStore({ path, platform, fileOperations }); + } catch (error) { + caught = error; + } + assertCrpError("CREDENTIAL_FILE_INSECURE", 500)(caught); + assert.equal(swapped, true); + assert.equal(pathReadAttempts, 0); + assertSafePublicError(caught, [ + originalSecret, + targetSecret, + originalPath, + targetPath, + path + ]); + }); + } +}); + +test("POSIX parent and file modes are validated before reading secret bytes", async (t) => { + if (process.platform === "win32") { + t.skip("POSIX permission bits are not authoritative on Windows"); + return; + } + const cases = [ + { + label: "parent broader than 0700", + setup({ parent, path }, bytes) { + writeCredentialBytes(path, bytes, 0o600); + chmodSync(parent, 0o750); + } + }, + { + label: "file mode is not exactly 0600", + setup({ path }, bytes) { + writeCredentialBytes(path, bytes, 0o400); + } + }, + { + label: "parent is a symbolic link", + setup({ tempRoot, parent }, bytes) { + const realParent = join(tempRoot, "real-private"); + const realPath = join(realParent, "secrets.json"); + writeCredentialBytes(realPath, bytes, 0o600); + symlinkSync(realParent, parent); + } + } + ]; + + for (const { label, setup } of cases) { + await t.test(label, (nested) => { + const paths = makeTempCredentialPath(nested, "crp-credentials-parent-mode-"); + const secret = makeSecret(); + const bytes = `${JSON.stringify(credentialDocument({ [makeRef()]: secret }))}\n`; + setup(paths, bytes); + let pathReadAttempts = 0; + const fileOperations = { + ...realFileOperations, + readFileSync(fileOrDescriptor, options) { + if (fileOrDescriptor === paths.path) pathReadAttempts += 1; + return realFileOperations.readFileSync(fileOrDescriptor, options); + } + }; + + let caught; + try { + new FileCredentialStore({ path: paths.path, fileOperations }); + } catch (error) { + caught = error; + } + assertCrpError("CREDENTIAL_FILE_INSECURE", 500)(caught); + assert.equal(pathReadAttempts, 0); + assertSafePublicError(caught, [secret, bytes, paths.path, paths.parent]); + }); + } +}); + +test("rename failure preserves committed bytes and cleans temporary state", async (t) => { + const { path, lockPath } = makeTempCredentialPath(t, "crp-credentials-rename-"); + const oldRef = makeRef("old"); + const oldSecret = makeSecret(); + const newRef = makeRef("new"); + const newSecret = makeSecret(); + const original = new FileCredentialStore({ path }); + await original.set(oldRef, oldSecret); + const before = readFileSync(path, "utf8"); + const failure = makeFileError(`${newRef}:${newSecret}`, "EIO"); + const store = new FileCredentialStore({ + path, + fileOperations: { + ...realFileOperations, + renameSync(source, target) { + if (target === path) throw failure; + return realFileOperations.renameSync(source, target); + } + } + }); + + let caught; + try { + await store.set(newRef, newSecret); + } catch (error) { + caught = error; + } + assertCrpError("CREDENTIAL_BACKEND_UNAVAILABLE", 500)(caught); + assert.equal(caught.cause, failure); + assert.equal(readFileSync(path, "utf8"), before); + assert.equal(await store.get(oldRef), oldSecret); + assert.equal(await store.has(newRef), false); + assert.equal(existsSync(lockPath), false); + assert.deepEqual(listTempFiles(path), []); + assertSafePublicError(caught, [newRef, newSecret, failure.message, before]); +}); + +test("permanent secret-temp cleanup failure degrades the instance before later opens", async (t) => { + const { path, lockPath } = makeTempCredentialPath(t, "crp-credentials-temp-degraded-"); + const ref = makeRef(); + const secret = makeSecret(); + const persistenceFailure = makeFileError(`${ref}:${secret}`, "EIO"); + const descriptorPaths = new Map(); + let openCalls = 0; + let tempRemovalAttempts = 0; + const store = new FileCredentialStore({ + path, + fileOperations: { + ...realFileOperations, + openSync(filePath, flags, mode) { + openCalls += 1; + const descriptor = realFileOperations.openSync(filePath, flags, mode); + descriptorPaths.set(descriptor, filePath); + return descriptor; + }, + fsyncSync(descriptor) { + if (descriptorPaths.get(descriptor)?.endsWith(".tmp")) { + throw persistenceFailure; + } + return realFileOperations.fsyncSync(descriptor); + }, + rmSync(filePath, options) { + if (filePath.endsWith(".tmp")) { + tempRemovalAttempts += 1; + throw makeFileError("permanent temp removal", "EPERM"); + } + return realFileOperations.rmSync(filePath, options); + } + } + }); + + let caught; + try { + await store.set(ref, secret); + } catch (error) { + caught = error; + } + assertCrpError("CREDENTIAL_STORE_TEMP_DEGRADED", 500)(caught); + assert.deepEqual(caught.details, { committed: false }); + assert.equal(caught.cause, persistenceFailure); + assert.match(caught.action, /explicitly remove/i); + assert.equal(tempRemovalAttempts, 2); + assert.equal(listTempFiles(path).length, 1); + assert.equal(existsSync(lockPath), false); + assertSafePublicError(caught, [ref, secret, persistenceFailure.message, path]); + const opensAfterFailure = openCalls; + + await assert.rejects( + () => store.set(makeRef("blocked"), makeSecret()), + assertCrpError("CREDENTIAL_STORE_TEMP_DEGRADED", 500) + ); + assert.equal(openCalls, opensAfterFailure); + assert.equal(tempRemovalAttempts, 2); + assert.equal(listTempFiles(path).length, 1); +}); + +test("one-shot secret-temp removal failure is retried without degrading later writes", async (t) => { + const { path, lockPath } = makeTempCredentialPath(t, "crp-credentials-temp-retry-"); + const failure = makeFileError("forced pre-rename failure", "EIO"); + const descriptorPaths = new Map(); + let persistenceFailures = 0; + let tempRemovalAttempts = 0; + const store = new FileCredentialStore({ + path, + fileOperations: { + ...realFileOperations, + openSync(filePath, flags, mode) { + const descriptor = realFileOperations.openSync(filePath, flags, mode); + descriptorPaths.set(descriptor, filePath); + return descriptor; + }, + fsyncSync(descriptor) { + if ( + descriptorPaths.get(descriptor)?.endsWith(".tmp") + && persistenceFailures === 0 + ) { + persistenceFailures += 1; + throw failure; + } + return realFileOperations.fsyncSync(descriptor); + }, + rmSync(filePath, options) { + if (filePath.endsWith(".tmp")) { + tempRemovalAttempts += 1; + if (tempRemovalAttempts === 1) { + throw makeFileError("transient temp removal", "EBUSY"); + } + } + return realFileOperations.rmSync(filePath, options); + } + } + }); + + await assert.rejects( + () => store.set(makeRef("first"), makeSecret()), + (error) => { + assertCrpError("CREDENTIAL_BACKEND_UNAVAILABLE", 500)(error); + assert.equal(error.cause, failure); + return true; + } + ); + assert.equal(tempRemovalAttempts, 2); + assert.deepEqual(listTempFiles(path), []); + assert.equal(existsSync(lockPath), false); + + const ref = makeRef("second"); + const secret = makeSecret(); + await store.set(ref, secret); + assert.equal(await store.get(ref), secret); + assert.deepEqual(listTempFiles(path), []); +}); + +test("transient lock close and removal failures are retried", async (t) => { + const { path, lockPath } = makeTempCredentialPath(t, "crp-credentials-retry-"); + const descriptorPaths = new Map(); + let closeFailures = 0; + let removalFailures = 0; + const store = new FileCredentialStore({ + path, + fileOperations: { + ...realFileOperations, + openSync(filePath, flags, mode) { + const descriptor = realFileOperations.openSync(filePath, flags, mode); + descriptorPaths.set(descriptor, filePath); + return descriptor; + }, + closeSync(descriptor) { + if (descriptorPaths.get(descriptor) === lockPath && closeFailures === 0) { + closeFailures += 1; + throw makeFileError("transient close", "EINTR"); + } + return realFileOperations.closeSync(descriptor); + }, + rmSync(filePath, options) { + if (isLockReleasePath(filePath, lockPath) && removalFailures === 0) { + removalFailures += 1; + throw makeFileError("transient removal", "EBUSY"); + } + return realFileOperations.rmSync(filePath, options); + } + } + }); + + await store.set(makeRef(), makeSecret()); + + assert.equal(closeFailures, 1); + assert.equal(removalFailures, 1); + assert.equal(existsSync(lockPath), false); + assert.deepEqual(listLockReleaseFiles(lockPath), []); +}); + +test("permanent residual lock reports committed degradation and is never auto-removed", async (t) => { + const { path, lockPath } = makeTempCredentialPath(t, "crp-credentials-degraded-"); + const gatePath = `${lockPath}.gate`; + let lockReads = 0; + let lockRemovals = 0; + const ref = makeRef(); + const secret = makeSecret(); + const store = new FileCredentialStore({ + path, + fileOperations: { + ...realFileOperations, + readFileSync(filePath, options) { + if (isLockReleasePath(filePath, lockPath)) lockReads += 1; + return realFileOperations.readFileSync(filePath, options); + }, + rmSync(filePath, options) { + if (isLockReleasePath(filePath, lockPath)) { + lockRemovals += 1; + throw makeFileError("permanent removal", "EPERM"); + } + return realFileOperations.rmSync(filePath, options); + } + } + }); + + await assert.rejects( + () => store.set(ref, secret), + (error) => { + assertCrpError("CREDENTIAL_STORE_COMMITTED_LOCK_DEGRADED", 500)(error); + assert.deepEqual(error.details, { committed: true }); + return true; + } + ); + assert.equal(await store.get(ref), secret); + assert.equal(existsSync(lockPath), true); + assert.notEqual(readFileSync(lockPath, "utf8").length, 0); + assert.equal(listLockReleaseFiles(lockPath).length, 1); + assert.equal(existsSync(gatePath), false); + const readsAfterCommit = lockReads; + const removalsAfterCommit = lockRemovals; + + await assert.rejects( + () => store.set(makeRef("blocked"), makeSecret()), + assertCrpError("CREDENTIAL_STORE_LOCK_DEGRADED", 500) + ); + assert.equal(lockReads, readsAfterCommit); + assert.equal(lockRemovals, removalsAfterCommit); + assert.equal(existsSync(lockPath), true); + assert.equal(listLockReleaseFiles(lockPath).length, 1); + + const second = new FileCredentialStore({ path }); + await assert.rejects( + () => second.set(makeRef("blocked-fresh"), makeSecret()), + assertCrpError("CREDENTIAL_STORE_BUSY", 409) + ); + assert.equal(existsSync(lockPath), true); + assert.equal(existsSync(gatePath), false); +}); + +test("a preexisting foreign lock remains in place and reports busy", async (t) => { + const { parent, path, lockPath } = makeTempCredentialPath( + t, + "crp-credentials-preexisting-lock-" + ); + const foreignToken = `${randomUUID()}\n`; + const store = new FileCredentialStore({ path }); + mkdirSync(parent, { recursive: true, mode: 0o700 }); + chmodSync(parent, 0o700); + writeFileSync(lockPath, foreignToken, { mode: 0o600 }); + + await assert.rejects( + () => store.set(makeRef(), makeSecret()), + assertCrpError("CREDENTIAL_STORE_BUSY", 409) + ); + assert.equal(readFileSync(lockPath, "utf8"), foreignToken); + assert.deepEqual(listLockReleaseFiles(lockPath), []); +}); + +test("a preexisting foreign gate remains intact and blocks mutation", async (t) => { + const { parent, path, lockPath } = makeTempCredentialPath( + t, + "crp-credentials-preexisting-gate-" + ); + const gatePath = `${lockPath}.gate`; + const markerPath = join(gatePath, "foreign-marker"); + const marker = `${randomUUID()}\n`; + const store = new FileCredentialStore({ path }); + mkdirSync(parent, { recursive: true, mode: 0o700 }); + chmodSync(parent, 0o700); + mkdirSync(gatePath, { mode: 0o700 }); + writeFileSync(markerPath, marker, { mode: 0o600 }); + + await assert.rejects( + () => store.set(makeRef(), makeSecret()), + assertCrpError("CREDENTIAL_STORE_BUSY", 409) + ); + assert.equal(existsSync(path), false); + assert.equal(readFileSync(markerPath, "utf8"), marker); + assert.equal(existsSync(gatePath), true); +}); + +test("gate release claims before delete and preserves an immediate foreign replacement", async (t) => { + const { path, lockPath } = makeTempCredentialPath(t, "crp-credentials-gate-claim-"); + const gatePath = `${lockPath}.gate`; + const displacedOwnedGatePath = `${gatePath}.${randomUUID()}.displaced`; + const ref = makeRef(); + const secret = makeSecret(); + let injected = false; + let foreignDeletedByCanonicalRmdir = false; + let foreignClaimPath = null; + + function replaceCanonicalGate(targetPath = null) { + realFileOperations.renameSync(gatePath, displacedOwnedGatePath); + realFileOperations.mkdirSync(gatePath, { mode: 0o700 }); + if (isGateClaimPath(targetPath, gatePath)) foreignClaimPath = targetPath; + injected = true; + } + + const store = new FileCredentialStore({ + path, + fileOperations: { + ...realFileOperations, + renameSync(source, target) { + if (!injected && source === gatePath) replaceCanonicalGate(target); + return realFileOperations.renameSync(source, target); + }, + rmdirSync(directoryPath) { + if (!injected && directoryPath === gatePath) replaceCanonicalGate(); + const result = realFileOperations.rmdirSync(directoryPath); + if (directoryPath === gatePath && injected) { + foreignDeletedByCanonicalRmdir = !existsSync(gatePath); + } + return result; + } + } + }); + + await assert.rejects( + () => store.set(ref, secret), + assertCrpError("CREDENTIAL_STORE_COMMITTED_LOCK_DEGRADED", 500) + ); + assert.equal(injected, true); + assert.equal(foreignDeletedByCanonicalRmdir, false); + assert.equal(await store.get(ref), secret); + assert.equal(typeof foreignClaimPath, "string"); + assert.equal(existsSync(foreignClaimPath), true); + assert.deepEqual(listGateClaimPaths(gatePath), [foreignClaimPath]); + assert.equal(existsSync(gatePath), true); + assert.equal(lstatSync(gatePath).isDirectory(), true); + + const committedBytes = readFileSync(path, "utf8"); + const second = new FileCredentialStore({ path }); + await assert.rejects( + () => second.set(makeRef("blocked-after-gate-swap"), makeSecret()), + assertCrpError("CREDENTIAL_STORE_BUSY", 409) + ); + assert.equal(readFileSync(path, "utf8"), committedBytes); + assert.equal(existsSync(foreignClaimPath), true); + assert.equal(existsSync(gatePath), true); +}); + +test("primary lock blocks a synchronous second mutation during gate claim validation", async (t) => { + const { path, lockPath } = makeTempCredentialPath(t, "crp-credentials-gate-gap-"); + const gatePath = `${lockPath}.gate`; + const firstRef = makeRef("first-gap"); + const firstSecret = makeSecret(); + const secondRef = makeRef("second-gap"); + const secondSecret = makeSecret(); + const second = new FileCredentialStore({ path }); + let injected = false; + let bytesBeforeSecond; + let secondAttempt; + const first = new FileCredentialStore({ + path, + fileOperations: { + ...realFileOperations, + lstatSync(filePath) { + if (!injected && isGateClaimPath(filePath, gatePath)) { + injected = true; + bytesBeforeSecond = realFileOperations.readFileSync(path, "utf8"); + secondAttempt = second.set(secondRef, secondSecret); + secondAttempt.catch(() => {}); + } + return realFileOperations.lstatSync(filePath); + } + } + }); + + await first.set(firstRef, firstSecret); + + assert.equal(injected, true); + assert.ok(secondAttempt instanceof Promise); + await assert.rejects( + secondAttempt, + assertCrpError("CREDENTIAL_STORE_BUSY", 409) + ); + assert.equal(readFileSync(path, "utf8"), bytesBeforeSecond); + assert.equal(await first.get(firstRef), firstSecret); + assert.equal(await first.has(secondRef), false); + assert.equal(existsSync(lockPath), false); + assert.equal(existsSync(gatePath), false); + assert.deepEqual(listGateClaimPaths(gatePath), []); +}); + +test("an immediate pre-claim lock swap preserves foreign bytes", async (t) => { + const { path, lockPath } = makeTempCredentialPath(t, "crp-credentials-foreign-"); + const gatePath = `${lockPath}.gate`; + const foreignToken = `${randomUUID()}\n`; + const displacedPath = `${lockPath}.${randomUUID()}.displaced`; + const ref = makeRef(); + const secret = makeSecret(); + let swapTriggered = false; + const store = new FileCredentialStore({ + path, + fileOperations: { + ...realFileOperations, + renameSync(source, target) { + if (source === lockPath && isLockReleasePath(target, lockPath)) { + realFileOperations.renameSync(lockPath, displacedPath); + realFileOperations.writeFileSync(lockPath, foreignToken, { mode: 0o600 }); + swapTriggered = true; + } + return realFileOperations.renameSync(source, target); + } + } + }); + + await assert.rejects( + () => store.set(ref, secret), + assertCrpError("CREDENTIAL_STORE_COMMITTED_LOCK_DEGRADED", 500) + ); + assert.equal(swapTriggered, true); + assert.equal(await store.get(ref), secret); + const survivingPaths = [lockPath, ...listLockReleaseFiles(lockPath)] + .filter((filePath) => existsSync(filePath)); + assert.equal( + survivingPaths.some((filePath) => readFileSync(filePath, "utf8") === foreignToken), + true + ); + assert.equal(existsSync(lockPath), true); + assert.notEqual(readFileSync(lockPath, "utf8").length, 0); + assert.equal(existsSync(gatePath), false); + + const before = readFileSync(path, "utf8"); + const second = new FileCredentialStore({ path }); + await assert.rejects( + () => second.set(makeRef("blocked-second"), makeSecret()), + assertCrpError("CREDENTIAL_STORE_BUSY", 409) + ); + assert.equal(readFileSync(path, "utf8"), before); + assert.equal(existsSync(lockPath), true); + assert.equal(existsSync(gatePath), false); +}); + +test("native adapter uses the injected Entry class and exact service contract", async () => { + const { FakeEntry, constructions } = createFakeEntryClass(); + const store = new NativeKeyringStore({ entryLoader: () => FakeEntry }); + + await assertCredentialContract(store); + + assert.equal(store.backend, "native"); + assert.equal(typeof store.list, "undefined"); + assert.ok(constructions.length > 0); + assert.equal(constructions.every(({ service }) => service === NATIVE_SERVICE), true); +}); + +test("native adapter distinguishes missing credentials from backend outages", async () => { + const ref = makeRef(); + const secret = makeSecret(); + const backendFailure = new Error(`${ref}:${secret}`); + class MissingEntry { + getPassword() { return null; } + deletePassword() { return false; } + } + const missing = new NativeKeyringStore({ entryLoader: () => MissingEntry }); + await assert.rejects( + () => missing.get(ref), + assertCrpError("CREDENTIAL_NOT_FOUND", 404) + ); + assert.equal(await missing.has(ref), false); + assert.equal(await missing.delete(ref), false); + + class FailingEntry { + setPassword() { throw backendFailure; } + getPassword() { throw backendFailure; } + deletePassword() { throw backendFailure; } + } + const failing = new NativeKeyringStore({ entryLoader: () => FailingEntry }); + for (const operation of [ + () => failing.set(ref, secret), + () => failing.get(ref), + () => failing.has(ref), + () => failing.delete(ref) + ]) { + let caught; + try { + await operation(); + } catch (error) { + caught = error; + } + assertCrpError("CREDENTIAL_BACKEND_UNAVAILABLE", 500)(caught); + assert.equal(caught.cause, backendFailure); + assertSafePublicError(caught, [ref, secret, backendFailure.message]); + } +}); + +test("credential adapters reject unsafe references and empty secrets", async (t) => { + const { path } = makeTempCredentialPath(t, "crp-credentials-input-"); + const { FakeEntry } = createFakeEntryClass(); + const stores = [ + new FileCredentialStore({ path }), + new NativeKeyringStore({ entryLoader: () => FakeEntry }) + ]; + const unsafeReferences = ["", " ", "__proto__", "constructor", "prototype", null, {}]; + + for (const store of stores) { + for (const ref of unsafeReferences) { + await assert.rejects( + () => store.get(ref), + assertCrpError("CREDENTIAL_INPUT_INVALID", 400) + ); + } + await assert.rejects( + () => store.set(makeRef(), ""), + assertCrpError("CREDENTIAL_INPUT_INVALID", 400) + ); + await assert.rejects( + () => store.set(makeRef(), null), + assertCrpError("CREDENTIAL_INPUT_INVALID", 400) + ); + } +}); + +test("credential store selection requires explicit file fallback consent", () => { + const nativeMarker = { backend: "native-marker" }; + const fileMarker = { backend: "file-marker" }; + const paths = { secretFallbackPath: "/unused/test/secrets.json" }; + let nativeCalls = 0; + let fileCalls = 0; + const nativeStoreFactory = () => { + nativeCalls += 1; + return nativeMarker; + }; + const fileStoreFactory = () => { + fileCalls += 1; + return fileMarker; + }; + + assert.equal(createCredentialStore({ paths, nativeStoreFactory, fileStoreFactory }), nativeMarker); + assert.equal(nativeCalls, 1); + assert.equal(fileCalls, 0); + + assert.throws( + () => createCredentialStore({ + backend: "file", + fallbackConsent: false, + paths, + nativeStoreFactory, + fileStoreFactory + }), + assertCrpError("CREDENTIAL_FALLBACK_CONSENT_REQUIRED", 400) + ); + assert.equal(fileCalls, 0); + + assert.equal( + createCredentialStore({ + backend: "file", + fallbackConsent: true, + paths, + nativeStoreFactory, + fileStoreFactory + }), + fileMarker + ); + assert.equal(nativeCalls, 1); + assert.equal(fileCalls, 1); + + assert.throws( + () => createCredentialStore({ + backend: "unknown", + fallbackConsent: true, + paths, + nativeStoreFactory, + fileStoreFactory + }), + assertCrpError("CREDENTIAL_BACKEND_INVALID", 400) + ); + assert.equal(nativeCalls, 1); + assert.equal(fileCalls, 1); +}); + +test("native factory failure is safe and falls back only with consent", () => { + const ref = makeRef(); + const secret = makeSecret(); + const factoryFailure = new Error(`${ref}:${secret}`); + const fileMarker = { backend: "file-marker" }; + let fileCalls = 0; + const options = { + paths: { secretFallbackPath: "/unused/test/secrets.json" }, + nativeStoreFactory: () => { throw factoryFailure; }, + fileStoreFactory: () => { + fileCalls += 1; + return fileMarker; + } + }; + + let caught; + try { + createCredentialStore(options); + } catch (error) { + caught = error; + } + assertCrpError("CREDENTIAL_BACKEND_UNAVAILABLE", 500)(caught); + assert.equal(caught.cause, factoryFailure); + assert.equal(fileCalls, 0); + assertSafePublicError(caught, [ref, secret, factoryFailure.message]); + + assert.equal( + createCredentialStore({ ...options, fallbackConsent: true }), + fileMarker + ); + assert.equal(fileCalls, 1); +}); + +test("native addon loader failure is caught before any Entry construction", () => { + const ref = makeRef(); + const secret = makeSecret(); + const loaderFailure = new Error(`${ref}:${secret}`); + const fileMarker = { backend: "file" }; + let fileCalls = 0; + let entryConstructions = 0; + class NeverConstructedEntry { + constructor() { + entryConstructions += 1; + } + } + const nativeStoreFactory = () => new NativeKeyringStore({ + entryLoader: () => { + void NeverConstructedEntry; + throw loaderFailure; + } + }); + const fileStoreFactory = () => { + fileCalls += 1; + return fileMarker; + }; + const options = { + paths: { secretFallbackPath: "/unused/test/secrets.json" }, + nativeStoreFactory, + fileStoreFactory + }; + + let caught; + try { + createCredentialStore(options); + } catch (error) { + caught = error; + } + assertCrpError("CREDENTIAL_BACKEND_UNAVAILABLE", 500)(caught); + assert.equal(caught.cause, loaderFailure); + assert.equal(fileCalls, 0); + assert.equal(entryConstructions, 0); + assertSafePublicError(caught, [ref, secret, loaderFailure.message]); + + assert.equal( + createCredentialStore({ ...options, fallbackConsent: true }), + fileMarker + ); + assert.equal(fileCalls, 1); + assert.equal(entryConstructions, 0); +}); + +test("native operation outages never replay into file after native selection", async () => { + const ref = makeRef(); + const secret = makeSecret(); + const backendFailure = new CrpError( + "CREDENTIAL_BACKEND_UNAVAILABLE", + "The credential backend is unavailable.", + "Check the credential backend and try again.", + { status: 500 } + ); + const nativeCalls = { set: 0, get: 0, has: 0, delete: 0 }; + const fileCalls = { factory: 0, set: 0, get: 0, has: 0, delete: 0 }; + const nativeStore = { + backend: "native", + async set() { nativeCalls.set += 1; throw backendFailure; }, + async get() { nativeCalls.get += 1; throw backendFailure; }, + async has() { nativeCalls.has += 1; throw backendFailure; }, + async delete() { nativeCalls.delete += 1; throw backendFailure; } + }; + const fileStore = { + backend: "file", + async set() { fileCalls.set += 1; }, + async get() { fileCalls.get += 1; }, + async has() { fileCalls.has += 1; }, + async delete() { fileCalls.delete += 1; } + }; + const store = createCredentialStore({ + fallbackConsent: true, + paths: { secretFallbackPath: "/unused/test/secrets.json" }, + nativeStoreFactory: () => nativeStore, + fileStoreFactory: () => { + fileCalls.factory += 1; + return fileStore; + } + }); + + assert.equal(store.backend, "native"); + for (const operation of [ + () => store.set(ref, secret), + () => store.get(ref), + () => store.has(ref), + () => store.delete(ref) + ]) { + await assert.rejects(operation, (error) => error === backendFailure); + } + assert.equal(store.backend, "native"); + assert.deepEqual(nativeCalls, { set: 1, get: 1, has: 1, delete: 1 }); + assert.deepEqual(fileCalls, { factory: 0, set: 0, get: 0, has: 0, delete: 0 }); +}); + +test("construction fallback stays explicit across restart without credential migration", async () => { + const ref = makeRef(); + const secret = makeSecret(); + const values = new Map(); + let nativeFactoryCalls = 0; + let fileFactoryCalls = 0; + const fileStoreFactory = () => { + fileFactoryCalls += 1; + return { + backend: "file", + async set(key, value) { values.set(key, value); }, + async get(key) { return values.get(key); }, + async has(key) { return values.has(key); }, + async delete(key) { return values.delete(key); } + }; + }; + const first = createCredentialStore({ + fallbackConsent: true, + paths: { secretFallbackPath: "/unused/test/secrets.json" }, + nativeStoreFactory: () => { + nativeFactoryCalls += 1; + throw new Error("native unavailable before selection"); + }, + fileStoreFactory + }); + await first.set(ref, secret); + assert.equal(first.backend, "file"); + + const restarted = createCredentialStore({ + backend: first.backend, + fallbackConsent: true, + paths: { secretFallbackPath: "/unused/test/secrets.json" }, + nativeStoreFactory: () => { + nativeFactoryCalls += 1; + return { backend: "native" }; + }, + fileStoreFactory + }); + assert.equal(restarted.backend, "file"); + assert.equal(await restarted.get(ref), secret); + assert.equal(nativeFactoryCalls, 1); + assert.equal(fileFactoryCalls, 2); +}); + +test("consented native selection does not fall back for input or not-found errors", async () => { + const errors = [ + new CrpError( + "CREDENTIAL_INPUT_INVALID", + "The credential input is invalid.", + "Use a valid credential input.", + { status: 400 } + ), + new CrpError( + "CREDENTIAL_NOT_FOUND", + "The credential does not exist.", + "Save the credential and try again.", + { status: 404 } + ) + ]; + + for (const expected of errors) { + let fileCalls = 0; + const store = createCredentialStore({ + fallbackConsent: true, + paths: { secretFallbackPath: "/unused/test/secrets.json" }, + nativeStoreFactory: () => ({ + backend: "native", + async get() { throw expected; } + }), + fileStoreFactory: () => { + fileCalls += 1; + return { backend: "file" }; + } + }); + + await assert.rejects(() => store.get(makeRef()), (error) => error === expected); + assert.equal(store.backend, "native"); + assert.equal(fileCalls, 0); + } +}); + +test("public provider projection never exposes the credential reference or secret", async (t) => { + const { path } = makeTempCredentialPath(t, "crp-credentials-public-"); + const store = new FileCredentialStore({ path }); + const ref = makeRef(); + const secret = makeSecret(); + await store.set(ref, secret); + const profile = normalizeProvider({ + name: "Public Test", + baseUrl: "https://public-test.example/v1", + credentialRef: ref + }, { + id: makeRef("id"), + now: "2026-07-12T00:00:00.000Z" + }); + + const publicProfile = toPublicProvider(profile, true); + const serialized = JSON.stringify(publicProfile); + assert.equal(Object.hasOwn(publicProfile, "credentialRef"), false); + assert.equal(serialized.includes(ref), false); + assert.equal(serialized.includes(secret), false); + assert.equal(publicProfile.credentialConfigured, true); +}); diff --git a/node/test/crp.test.mjs b/node/test/crp.test.mjs index 4fa57b9..890ad62 100644 --- a/node/test/crp.test.mjs +++ b/node/test/crp.test.mjs @@ -1,12 +1,115 @@ import test from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { mkdtempSync, rmSync } from "node:fs"; +import { EventEmitter } from "node:events"; +import * as realFileOperations from "node:fs"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync +} from "node:fs"; import os from "node:os"; import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { getPaths } from "../src/shared/paths.mjs"; +import { CrpError } from "../src/shared/errors.mjs"; +import { + discoverSupervisor, + SupervisorClient, + ensureSupervisor, + readControlToken, + readSupervisorState, + readSupervisorStateSnapshot, + removeStaleSupervisorState, + spawnDetachedSupervisor +} from "../src/supervisor/supervisor-client.mjs"; const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const CLI_URL = pathToFileURL(join(PACKAGE_ROOT, "bin", "crp.mjs")).href; +const CONTROL_TOKEN = Buffer.alloc(32, 0x41).toString("base64url"); +const OTHER_CONTROL_TOKEN = Buffer.alloc(32, 0x42).toString("base64url"); + +function supervisorState(pid = 4242, adminPort = 15101) { + return { + schemaVersion: 1, + supervisorPid: pid, + startedAt: "2026-07-13T08:00:00.000Z", + admin: { + host: "127.0.0.1", + port: adminPort, + authority: `127.0.0.1:${adminPort}`, + origin: `http://127.0.0.1:${adminPort}` + }, + worker: { + phase: "stopped", + pid: null, + generation: 0, + state: null, + restartCount: 0, + startedAt: null, + error: null + } + }; +} + +function prepareSupervisorFiles(homeDir, { state = supervisorState(), token = CONTROL_TOKEN } = {}) { + const paths = getPaths(homeDir); + mkdirSync(paths.globalHome, { recursive: true, mode: 0o700 }); + chmodSync(paths.globalHome, 0o700); + writeFileSync(paths.statePath, `${JSON.stringify(state)}\n`, { mode: 0o600 }); + writeFileSync(paths.controlTokenPath, `${token}\n`, { mode: 0o600 }); + chmodSync(paths.statePath, 0o600); + chmodSync(paths.controlTokenPath, 0o600); + return paths; +} + +function trackControlTokenReads(paths) { + const descriptorPaths = new Map(); + let tokenReads = 0; + return { + fileOperations: { + ...realFileOperations, + openSync(path, ...args) { + const descriptor = realFileOperations.openSync(path, ...args); + descriptorPaths.set(descriptor, path); + return descriptor; + }, + readFileSync(pathOrDescriptor, ...args) { + if (descriptorPaths.get(pathOrDescriptor) === paths.controlTokenPath) tokenReads += 1; + return realFileOperations.readFileSync(pathOrDescriptor, ...args); + }, + closeSync(descriptor) { + descriptorPaths.delete(descriptor); + return realFileOperations.closeSync(descriptor); + } + }, + tokenReads: () => tokenReads + }; +} + +function delayedJsonResponse(payload, { delayMs, signal }) { + return new Promise((resolve, reject) => { + const onAbort = () => { + clearTimeout(timer); + reject(signal.reason); + }; + const timer = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(new Response(JSON.stringify(payload), { + status: 200, + headers: { "content-type": "application/json" } + })); + }, delayMs); + if (signal.aborted) onAbort(); + else signal.addEventListener("abort", onAbort, { once: true }); + }); +} function makeTempHome() { return mkdtempSync(join(os.tmpdir(), "crp-home-")); @@ -15,6 +118,7 @@ function makeTempHome() { function makeHomeEnv(homeDir) { return { ...process.env, + CRP_LOCALE: "en", HOME: homeDir, USERPROFILE: homeDir }; @@ -28,6 +132,128 @@ function runCrp(args, env) { }); } +function containsSecret(value, secret, seen = new Set()) { + if (typeof value === "string" || Buffer.isBuffer(value)) return value.includes(secret); + if (value === null || typeof value !== "object" || seen.has(value)) return false; + seen.add(value); + return Reflect.ownKeys(value).some((key) => ( + String(key).includes(secret) || containsSecret(value[key], secret, seen) + )); +} + +function assertSecretAbsent(secret, { + stdout = "", + stderr = "", + files = [], + objects = [] +} = {}) { + assert.equal(containsSecret(stdout, secret), false); + assert.equal(containsSecret(stderr, secret), false); + for (const path of files) { + if (existsSync(path)) { + assert.equal(containsSecret(readFileSync(path), secret), false); + } + } + for (const value of objects) { + assert.equal(containsSecret(value, secret), false); + } +} + +function invokeCliInTempHome(args, homeDir, secrets = []) { + const marker = "__CRP_RESULT__"; + const source = ` + const stdout = []; + const stderr = []; + let ensureCalls = 0; + let discoverCalls = 0; + let openCalls = 0; + const { runCli } = await import(${JSON.stringify(CLI_URL)}); + const status = await runCli(${JSON.stringify(args)}, { + stdout: (text) => stdout.push(text), + stderr: (text) => stderr.push(text), + ensureSupervisorImpl: async () => { + ensureCalls += 1; + return { + origin: "http://127.0.0.1:15101", + state: { supervisorPid: 4242 }, + status: {}, + client: { request: async () => { throw new Error("unexpected Admin request"); } }, + spawned: false + }; + }, + discoverSupervisorImpl: async () => { + discoverCalls += 1; + return null; + }, + readControlTokenImpl: () => ${JSON.stringify(CONTROL_TOKEN)}, + openManagementUrlImpl: () => { openCalls += 1; } + }); + process.stdout.write(${JSON.stringify(marker)} + JSON.stringify({ + status, + stdout: stdout.join(""), + stderr: stderr.join(""), + ensureCalls, + discoverCalls, + openCalls + })); + `; + const result = spawnSync(process.execPath, ["--input-type=module", "--eval", source], { + cwd: PACKAGE_ROOT, + env: { + ...makeHomeEnv(homeDir), + CRP_UPSTREAM_BASE_URL: "", + CRP_UPSTREAM_API_KEY: "" + }, + encoding: "utf8" + }); + for (const secret of secrets) { + assertSecretAbsent(secret, { stdout: result.stdout, stderr: result.stderr }); + } + assert.equal(result.status, 0); + const markerIndex = result.stdout.lastIndexOf(marker); + assert.notEqual(markerIndex, -1); + return JSON.parse(result.stdout.slice(markerIndex + marker.length)); +} + +async function invokeCli(args, overrides = {}) { + const stdout = []; + const stderr = []; + const { runCli } = await import(CLI_URL); + const status = await runCli(args, { + stdout: (text) => stdout.push(text), + stderr: (text) => stderr.push(text), + environment: { CRP_LOCALE: "en" }, + ...overrides + }); + return { status, stdout: stdout.join(""), stderr: stderr.join("") }; +} + +function adminStatus(pid = 4242, worker = { phase: "stopped", pid: null, generation: 0 }) { + return { + supervisor: { pid, startedAt: "2026-07-13T08:00:00.000Z" }, + activeProviderId: "provider-1", + activeProvider: { id: "provider-1", name: "Primary", credentialConfigured: true }, + generation: worker.generation, + worker, + codex: { + configured: false, + modelProvider: "OpenAI", + proxyUrl: "http://127.0.0.1:15100", + historyRepairPending: false + } + }; +} + +function discoveredContext(client, status = adminStatus()) { + return { + origin: "http://127.0.0.1:15101", + state: supervisorState(status.supervisor.pid), + status, + client, + spawned: false + }; +} + test("check does not emit sqlite experimental warnings", () => { const homeDir = makeTempHome(); try { @@ -41,30 +267,2218 @@ test("check does not emit sqlite experimental warnings", () => { } }); -test("init without config fails cleanly without sqlite warnings", () => { +test("removed aliases return exact migration guidance without discovery", async () => { + let ensureCalls = 0; + let discoverCalls = 0; + let openCalls = 0; + const dependencies = { + ensureSupervisorImpl: async () => { ensureCalls += 1; }, + discoverSupervisorImpl: async () => { discoverCalls += 1; }, + openManagementUrlImpl: () => { openCalls += 1; } + }; + for (const [command, replacement] of [ + ["init", "crp ui"], + ["install", "crp start"], + ["setup", "crp start"] + ]) { + const result = await invokeCli([command, "--json", "--locale", "en"], dependencies); + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); + assert.deepEqual(JSON.parse(result.stderr), { + ok: false, + command, + stage: null, + error: { + code: "CLI_COMMAND_REMOVED", + message: "This CLI command has been removed.", + action: `Use \`${replacement}\` instead.`, + details: {} + } + }); + } + assert.equal(ensureCalls, 0); + assert.equal(discoverCalls, 0); + assert.equal(openCalls, 0); +}); + +test("start reports a safe supervisor failure and exit code without real spawn", async () => { + const result = await invokeCli(["start", "--json"], { + ensureSupervisorImpl: async () => { + throw new Error("The local supervisor could not be started."); + } + }); + + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); + assert.deepEqual(JSON.parse(result.stderr), { + ok: false, + command: "start", + stage: "supervisor_start", + error: { + code: "CLI_COMMAND_FAILED", + message: "CRP could not complete the command.", + action: "Review CRP activity and try again.", + details: {} + } + }); +}); + +test("imports the CLI module without executing a command", () => { + const cliUrl = pathToFileURL(join(PACKAGE_ROOT, "bin", "crp.mjs")).href; + const result = spawnSync(process.execPath, [ + "--input-type=module", + "--eval", + `await import(${JSON.stringify(cliUrl)}); process.stdout.write("imported\\n");` + ], { cwd: PACKAGE_ROOT, encoding: "utf8" }); + + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, "imported\n"); +}); + +test("prints mature English-default CLI help without discovery", async () => { + let discovered = false; + const result = await invokeCli(["--help"], { + environment: { CRP_LOCALE: "zh-CN", LANG: "zh_CN.UTF-8" }, + discoverSupervisorImpl: async () => { discovered = true; }, + ensureSupervisorImpl: async () => { discovered = true; } + }); + + assert.equal(result.status, 0); + assert.equal(result.stderr, ""); + for (const heading of ["Usage:", "Commands:", "Options:", "Examples:"]) { + assert.equal(result.stdout.includes(heading), true); + } + for (const line of [ + "crp ui [--no-open] [--json]", + "crp restart [--json]", + "crp shutdown [--json]", + "crp provider" + ]) { + assert.match(result.stdout, new RegExp(line.replace(/[|[\]]/g, "\\$&"))); + } + assert.doesNotMatch(result.stdout, /^\s*(?:crp\s+)?(?:init|install|setup)(?:\s|$)/m); + assert.equal(discovered, false); +}); + +test("positional argument errors never echo the original value", async () => { + const secret = "positional-complete-secret-sentinel"; + let ensureCalls = 0; + let discoverCalls = 0; + for (const args of [ + ["status", secret], + ["status", "--json", secret], + ["provider", "list", secret], + ["provider", "list", "--json", secret], + ["capture", "status", secret], + ["capture", "status", "--json", secret] + ]) { + const result = await invokeCli(args, { + ensureSupervisorImpl: async () => { ensureCalls += 1; }, + discoverSupervisorImpl: async () => { discoverCalls += 1; } + }); + assertSecretAbsent(secret, { stdout: result.stdout, stderr: result.stderr }); + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); + if (args.includes("--json")) { + const payload = JSON.parse(result.stderr); + assert.equal(payload.ok, false); + assert.equal(payload.command, args[0]); + assert.equal(payload.stage, null); + assert.equal(payload.error.code, "CLI_INPUT_INVALID"); + } else { + assert.equal(result.stderr, "Error: Unexpected positional argument.\n"); + } + } + assert.equal(ensureCalls, 0); + assert.equal(discoverCalls, 0); + + for (const [args, expectedError] of [ + [[secret], "Error: CRP could not complete the command. Review CRP activity and try again.\n"], + [["capture", secret], "Error: Unknown capture action.\n"] + ]) { + const result = await invokeCli(args); + assertSecretAbsent(secret, { stdout: result.stdout, stderr: result.stderr }); + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); + assert.equal(result.stderr, expectedError); + } +}); + +test("guide JSON describes the V1 supervisor flow without legacy mutations", () => { const homeDir = makeTempHome(); try { - const result = runCrp(["init"], makeHomeEnv(homeDir)); - const output = `${result.stdout}\n${result.stderr}`; + const result = runCrp(["guide", "--json"], makeHomeEnv(homeDir)); - assert.equal(result.status, 1); - assert.match(output, /Error: Upstream base URL is required/); - assert.doesNotMatch(output, /ExperimentalWarning: SQLite/); + assert.equal(result.status, 0, result.stderr); + const guide = JSON.parse(result.stdout); + const flowCommands = [ + ["providerAdd", "crp provider add --name --base-url --api-key --model --json"], + ["start", "crp start --json"], + ["status", "crp status --json"], + ["ui", "crp ui --json"], + ["shutdown", "crp shutdown --json"] + ]; + assert.equal( + guide.commands.providerModels, + "crp provider models --name --json" + ); + assert.equal( + guide.commands.providerTest, + "crp provider test --name --model --json" + ); + assert.equal( + guide.commands.providerActivate, + "crp provider activate --name --json" + ); + const expectedFlow = guide.expectedFlow.join("\n"); + let previousIndex = -1; + for (const [name, command] of flowCommands) { + assert.equal(guide.commands[name], command); + const commandIndex = expectedFlow.indexOf(command); + assert.ok(commandIndex > previousIndex, `${command} must appear in V1 flow order`); + previousIndex = commandIndex; + } + + const serializedGuide = JSON.stringify(guide); + assert.doesNotMatch(serializedGuide, /crp start[^"\n]*--(?:upstream-base-url|api-key|capture)/); + assert.doesNotMatch(serializedGuide, /crp capture (?:on|off)\b/); + assert.match(serializedGuide, /credential[^"\n]*write-only/i); + assert.match(serializedGuide, /credential[^"\n]*(?:not echoed|never echoed)/i); } finally { rmSync(homeDir, { recursive: true, force: true }); } }); -test("start without config fails cleanly without sqlite warnings", () => { +test("guide human output presents the V1 flow without stale fields", () => { const homeDir = makeTempHome(); try { - const result = runCrp(["start"], makeHomeEnv(homeDir)); - const output = `${result.stdout}\n${result.stderr}`; + const result = runCrp(["guide"], makeHomeEnv(homeDir)); + + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ""); + assert.doesNotMatch(result.stdout, /undefined/); + for (const command of [ + "crp provider add --name --base-url --api-key --model --json", + "crp provider models --name --json", + "crp provider test --name --model --json", + "crp provider activate --name --json", + "crp start --json", + "crp ui --json" + ]) { + assert.match(result.stdout, new RegExp(command.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + } + assert.doesNotMatch(result.stdout, /crp start[^\n]*--(?:upstream-base-url|api-key|capture)/); + assert.doesNotMatch(result.stdout, /crp capture (?:on|off)\b/); + } finally { + rmSync(homeDir, { recursive: true, force: true }); + } +}); + +test("legacy human commands render English and Chinese without changing technical literals", () => { + for (const [args, englishSignal, chineseSignal, literal] of [ + [["check"], "Codex config path:", "Codex 配置路径:", "model_providers.OpenAI"], + [["guide"], "CRP V1 guide:", "CRP V1 指南:", "crp start --json"], + [["capture", "status"], "Capture running:", "抓取功能运行中:", ".codex-remote-proxy"], + [["install-cli"], "Legacy local shim installed.", "旧版本地命令入口已安装。", "npm install -g @cluic/codex-remote-proxy"] + ]) { + const homeDir = makeTempHome(); + try { + const english = runCrp([...args, "--locale", "en"], makeHomeEnv(homeDir)); + const chinese = runCrp([...args, "--locale", "zh-CN"], makeHomeEnv(homeDir)); + assert.equal(english.status, 0, `${args.join(" ")}: ${english.stderr}`); + assert.equal(chinese.status, 0, `${args.join(" ")}: ${chinese.stderr}`); + assert.equal(english.stdout.includes(englishSignal), true); + assert.equal(chinese.stdout.includes(chineseSignal), true); + assert.equal(english.stdout.includes(literal), true); + assert.equal(chinese.stdout.includes(literal), true); + } finally { + rmSync(homeDir, { recursive: true, force: true }); + } + } +}); + +test("capture rejects invalid action, positional input, and unsupported options before mutation", () => { + const cases = [ + ["capture", "unknown", "--json"], + ["capture", "on", "unexpected-value", "--json"], + ["capture", "on", "--unsupported", "value", "--json"] + ]; + for (const args of cases) { + const homeDir = makeTempHome(); + try { + const paths = getPaths(homeDir); + const result = runCrp(args, makeHomeEnv(homeDir)); + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); + const failure = JSON.parse(result.stderr); + assert.equal(failure.command, "capture"); + assert.equal(failure.stage, null); + assert.equal(failure.error.code, "CLI_INPUT_INVALID"); + assert.equal(existsSync(join(paths.globalHome, "config.json")), false); + assert.equal(existsSync(paths.statePath), false); + } finally { + rmSync(homeDir, { recursive: true, force: true }); + } + } +}); + +test("status never starts a missing supervisor", async () => { + let ensureCalls = 0; + const result = await invokeCli(["status", "--json"], { + discoverSupervisorImpl: async () => null, + ensureSupervisorImpl: async () => { + ensureCalls += 1; + throw new Error("must not start"); + } + }); + + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout), { + ok: true, + running: false, + reason: "supervisor_not_running" + }); + assert.equal(ensureCalls, 0); +}); + +test("rejects legacy start options before supervisor discovery or mutation", async () => { + const secret = "legacy-complete-secret"; + const clientCalls = []; + let ensureCalls = 0; + let discoverCalls = 0; + const result = await invokeCli([ + "start", + "--upstream-base-url", "https://provider.example/v1", + "--api-key", secret, + "--json" + ], { + ensureSupervisorImpl: async () => { + ensureCalls += 1; + return discoveredContext({ + async request(...args) { + clientCalls.push(args); + return { worker: { phase: "running", pid: 8001, generation: 1 } }; + } + }); + }, + discoverSupervisorImpl: async () => { + discoverCalls += 1; + return null; + } + }); + + assertSecretAbsent(secret, { + stdout: result.stdout, + stderr: result.stderr, + objects: [clientCalls] + }); + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); + assert.equal(JSON.parse(result.stderr).error.code, "CLI_INPUT_INVALID"); + assert.equal(ensureCalls, 0); + assert.equal(discoverCalls, 0); + assert.deepEqual(clientCalls, []); +}); + +test("rejects misspelled restart options before supervisor discovery or mutation", async () => { + const clientCalls = []; + let ensureCalls = 0; + let discoverCalls = 0; + const result = await invokeCli(["restart", "--jsno"], { + ensureSupervisorImpl: async () => { + ensureCalls += 1; + return discoveredContext({ + async request(...args) { + clientCalls.push(args); + return { worker: { phase: "running", pid: 8002, generation: 1 } }; + } + }); + }, + discoverSupervisorImpl: async () => { + discoverCalls += 1; + return null; + } + }); + + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); + assert.equal(result.stderr, "Error: The restart command contains an unsupported option.\n"); + assert.equal(ensureCalls, 0); + assert.equal(discoverCalls, 0); + assert.deepEqual(clientCalls, []); +}); + +test("routes start, stop, and restart through exact Admin methods with empty bodies", async () => { + const calls = []; + const historyRepair = { + required: false, + completed: false, + resumed: false, + backupCreated: false, + rolloutFiles: 0, + rolloutRecords: 0, + sqliteFiles: 0, + sqliteRows: 0, + encryptedContentDetected: false + }; + const client = { + async request(method, path, body, options) { + calls.push([method, path, body, options]); + if (path === "/codex/bootstrap") { + return { result: { changed: true, backupCreated: true, historyRepair } }; + } + if (path === "/proxy/stop") { + return { worker: { phase: "stopped", pid: null, generation: 1 } }; + } + return { worker: { phase: "running", pid: path.endsWith("restart") ? 8002 : 8001, generation: 1 } }; + } + }; + const status = adminStatus(); + status.codex.configured = true; + status.codex.historyRepairPending = true; + const context = discoveredContext(client, status); + const dependencies = { + ensureSupervisorImpl: async () => context, + discoverSupervisorImpl: async () => context + }; + + const started = await invokeCli(["start", "--json"], dependencies); + assert.equal(started.status, 0, started.stderr); + assert.deepEqual(calls.splice(0), [ + ["POST", "/codex/bootstrap", undefined, { requestTimeoutMs: 300_000 }], + ["POST", "/proxy/start", undefined, undefined] + ]); + assert.deepEqual(JSON.parse(started.stdout).codexBootstrap, { + changed: true, + backupCreated: true, + historyRepair + }); + assert.equal(JSON.parse(started.stdout).worker.pid, 8001); + + const stopped = await invokeCli(["stop", "--json"], dependencies); + assert.equal(stopped.status, 0, stopped.stderr); + assert.deepEqual(calls.splice(0), [["POST", "/proxy/stop", undefined, undefined]]); + assert.equal(JSON.parse(stopped.stdout).stopped, true); + + const restarted = await invokeCli(["restart", "--json"], dependencies); + assert.equal(restarted.status, 0, restarted.stderr); + assert.deepEqual(calls.splice(0), [ + ["POST", "/codex/bootstrap", undefined, { requestTimeoutMs: 300_000 }], + ["POST", "/proxy/restart", undefined, undefined] + ]); + assert.deepEqual(JSON.parse(restarted.stdout).codexBootstrap, { + changed: true, + backupCreated: true, + historyRepair + }); + assert.equal(JSON.parse(restarted.stdout).worker.pid, 8002); +}); + +test("start blocks Worker startup when the mandatory bootstrap fails", async () => { + const secret = "bootstrap-failure-complete-secret-sentinel"; + const calls = []; + const failure = new CrpError( + "CODEX_HISTORY_REPAIR_COMMITTED_DEGRADED", + "Codex configuration was updated, but history repair remains pending.", + "Retry crp start to resume Codex history repair before using the proxy.", + { + status: 500, + details: { committed: true, degraded: true, pending: true }, + cause: new Error(secret) + } + ); + const client = { + async request(method, path, body) { + calls.push([method, path, body]); + if (path === "/codex/bootstrap") throw failure; + return assert.fail("Worker start must not run after bootstrap failure"); + } + }; + const status = adminStatus(); + status.codex.configured = true; + status.codex.historyRepairPending = true; + + const result = await invokeCli(["start", "--json"], { + ensureSupervisorImpl: async () => discoveredContext(client, status) + }); + + assert.equal(result.stdout.includes(secret), false); + assert.equal(result.stderr.includes(secret), false); + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); + assert.equal(JSON.parse(result.stderr).stage, "codex_bootstrap"); + assert.deepEqual(calls, [["POST", "/codex/bootstrap", undefined]]); +}); + +test("start preserves config-only committed degradation and never starts Worker", async () => { + const secret = "config-only-bootstrap-secret-sentinel"; + const calls = []; + const failure = new CrpError( + "CODEX_CONFIG_COMMITTED_DEGRADED", + "The Codex configuration was updated, but completion could not be confirmed.", + "Review the Codex configuration and retry before starting the proxy.", + { + status: 500, + details: { committed: true, degraded: true, pending: false }, + cause: new Error(secret) + } + ); + const client = { + async request(method, path, body) { + calls.push([method, path, body]); + if (path === "/codex/bootstrap") throw failure; + return assert.fail("Worker start must not run after config-only degradation"); + } + }; + const status = adminStatus(); + status.codex.configured = false; + + const result = await invokeCli(["start", "--json"], { + ensureSupervisorImpl: async () => discoveredContext(client, status) + }); + + assert.equal(result.stdout.includes(secret), false); + assert.equal(result.stderr.includes(secret), false); + assert.equal(result.status, 1); + const payload = JSON.parse(result.stderr); + assert.equal(payload.stage, "codex_bootstrap"); + assert.equal(payload.error.code, "CODEX_CONFIG_COMMITTED_DEGRADED"); + assert.deepEqual(payload.error.details, { + committed: true, + degraded: true, + pending: false + }); + assert.deepEqual(calls, [["POST", "/codex/bootstrap", undefined]]); +}); + +test("restart reports codex_bootstrap and never restarts Worker after bootstrap failure", async () => { + const secret = "restart-bootstrap-failure-complete-secret-sentinel"; + const calls = []; + const failure = new CrpError( + "CODEX_HISTORY_REPAIR_COMMITTED_DEGRADED", + "Codex configuration was updated, but history repair remains pending.", + "Retry crp start to resume Codex history repair before using the proxy.", + { + status: 500, + details: { committed: true, degraded: true, pending: true }, + cause: new Error(secret) + } + ); + const client = { + async request(method, path, body) { + calls.push([method, path, body]); + if (path === "/codex/bootstrap") throw failure; + return assert.fail("Worker restart must not run after bootstrap failure"); + } + }; + const status = adminStatus(); + status.codex.configured = false; + status.codex.historyRepairPending = true; + + const result = await invokeCli(["restart", "--json"], { + ensureSupervisorImpl: async () => discoveredContext(client, status) + }); + + assert.equal(result.stdout.includes(secret), false); + assert.equal(result.stderr.includes(secret), false); + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); + assert.equal(JSON.parse(result.stderr).stage, "codex_bootstrap"); + assert.deepEqual(calls, [["POST", "/codex/bootstrap", undefined]]); +}); + +test("human start prints only a static encrypted-history warning after repair", async () => { + const secret = "history-warning-private-complete-secret"; + const historyRepair = { + required: true, + completed: true, + resumed: false, + backupCreated: true, + rolloutFiles: 1, + rolloutRecords: 2, + sqliteFiles: 1, + sqliteRows: 3, + encryptedContentDetected: true, + privatePath: `/private/${secret}`, + sessionBody: secret + }; + const calls = []; + const client = { + async request(method, path, body) { + calls.push([method, path, body]); + if (path === "/codex/bootstrap") { + return { result: { changed: true, backupCreated: true, historyRepair } }; + } + return { worker: { phase: "running", pid: 8001, generation: 1 } }; + } + }; + const status = adminStatus(); + status.codex.configured = true; + status.codex.historyRepairPending = true; + const dependencies = { + ensureSupervisorImpl: async () => discoveredContext(client, status) + }; + + const english = await invokeCli(["start", "--locale", "en"], dependencies); + const chinese = await invokeCli(["start", "--locale", "zh-CN"], dependencies); + + for (const result of [english, chinese]) { + assert.equal(result.stdout.includes(secret), false); + assert.equal(result.stderr.includes(secret), false); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ""); + } + assert.equal(english.stdout, [ + "Codex Remote Proxy is ready.", + "Warning: Some historical sessions contain encrypted content. Their provider metadata was repaired, but some messages may remain unavailable.", + "" + ].join("\n")); + assert.equal(chinese.stdout, [ + "Codex Remote Proxy 已就绪。", + "警告:部分历史会话包含加密内容。提供商元数据已修复,但部分消息可能仍不可用。", + "" + ].join("\n")); + assert.deepEqual(calls, [ + ["POST", "/codex/bootstrap", undefined], + ["POST", "/proxy/start", undefined], + ["POST", "/codex/bootstrap", undefined], + ["POST", "/proxy/start", undefined] + ]); +}); + +test("shutdown requests an identity-bound graceful close without signalling", async (t) => { + const homeDir = makeTempHome(); + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + const paths = prepareSupervisorFiles(homeDir); + const calls = []; + const signals = []; + let alive = true; + const client = { + async request(method, path, body) { + calls.push([method, path, body]); + assert.equal(path, "/supervisor/shutdown"); + alive = false; + rmSync(paths.statePath, { force: true }); + return { + shutdown: { + accepted: true, + supervisorPid: 4242, + startedAt: "2026-07-13T08:00:00.000Z" + } + }; + } + }; + const result = await invokeCli(["shutdown", "--json"], { + paths, + discoverSupervisorImpl: async () => discoveredContext(client), + killProcess(pid, signal) { + signals.push([pid, signal]); + }, + isProcessAlive: (pid) => pid === 4242 && alive + }); + + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(calls, [["POST", "/supervisor/shutdown", { + supervisorPid: 4242, + startedAt: "2026-07-13T08:00:00.000Z" + }]]); + assert.deepEqual(signals, []); + assert.deepEqual(JSON.parse(result.stdout), { + ok: true, + shutdown: true, + graceful: true, + forced: false, + degraded: false, + supervisorPid: 4242, + workerStopped: true, + stateRemoved: true + }); + assert.equal(existsSync(paths.statePath), false); +}); + +test("shutdown uses a verified forced fallback and safely removes matching stale state", async (t) => { + const homeDir = makeTempHome(); + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + const paths = prepareSupervisorFiles(homeDir); + const calls = []; + const signals = []; + let supervisorAlive = true; + let workerAlive = true; + const client = { + async request(method, path, body) { + calls.push([method, path, body]); + throw new CrpError( + "API_NOT_FOUND", + "The requested endpoint does not exist.", + "Upgrade the local supervisor and retry.", + { status: 404 } + ); + } + }; + const status = adminStatus(4242, { phase: "running", pid: 5353, generation: 1 }); + const result = await invokeCli(["shutdown", "--json"], { + paths, + discoverSupervisorImpl: async () => discoveredContext(client, status), + killProcess(pid, signal) { + signals.push([pid, signal]); + supervisorAlive = false; + }, + isProcessAlive: (pid) => pid === 4242 ? supervisorAlive : pid === 5353 && workerAlive, + wait: async () => { workerAlive = false; } + }); + + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(calls, [["POST", "/supervisor/shutdown", { + supervisorPid: 4242, + startedAt: "2026-07-13T08:00:00.000Z" + }]]); + assert.deepEqual(signals, [[4242, "SIGTERM"]]); + assert.deepEqual(JSON.parse(result.stdout), { + ok: true, + shutdown: true, + graceful: false, + forced: true, + degraded: false, + supervisorPid: 4242, + workerStopped: true, + stateRemoved: true + }); + assert.equal(existsSync(paths.statePath), false); + assert.equal(existsSync(`${paths.statePath}.stale`), false); +}); + +test("shutdown fails closed when the authenticated live Worker survives forced fallback", async (t) => { + const homeDir = makeTempHome(); + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + const paths = prepareSupervisorFiles(homeDir); + let supervisorAlive = true; + let clock = 0; + const client = { + async request() { + throw new CrpError( + "SUPERVISOR_UNAVAILABLE", + "The local supervisor is unavailable.", + "Retry the operation.", + { status: 503 } + ); + } + }; + const status = adminStatus(4242, { phase: "running", pid: 5353, generation: 1 }); + + const result = await invokeCli(["shutdown", "--json"], { + paths, + discoverSupervisorImpl: async () => discoveredContext(client, status), + killProcess() { supervisorAlive = false; }, + isProcessAlive: (pid) => pid === 4242 ? supervisorAlive : pid === 5353, + now: () => clock, + wait: async (milliseconds) => { clock += milliseconds; }, + shutdownTimeoutMs: 200 + }); + + assert.equal(result.status, 1); + const error = JSON.parse(result.stderr).error; + assert.equal(error.code, "SUPERVISOR_SHUTDOWN_TIMEOUT"); + assert.deepEqual(error.details, { + forced: true, + graceful: false, + processStopped: false, + stateRemoved: false + }); + assert.equal(existsSync(paths.statePath), true); +}); + +test("shutdown cleans exact dead state while remaining idempotently stopped", async (t) => { + const homeDir = makeTempHome(); + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + const paths = prepareSupervisorFiles(homeDir); + + const result = await invokeCli(["shutdown", "--json"], { + paths, + discoverSupervisorImpl: async () => null, + isProcessAlive: () => false + }); + + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout), { + ok: true, + shutdown: false, + reason: "supervisor_not_running", + staleStateRemoved: true + }); + assert.equal(existsSync(paths.statePath), false); + assert.equal(existsSync(`${paths.statePath}.stale`), false); +}); + +test("shutdown recovers a lone fixed stale-state claim from an interrupted cleanup", async (t) => { + const homeDir = makeTempHome(); + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + const paths = prepareSupervisorFiles(homeDir); + realFileOperations.renameSync(paths.statePath, `${paths.statePath}.stale`); + + const result = await invokeCli(["shutdown", "--json"], { + paths, + discoverSupervisorImpl: async () => null, + isProcessAlive: () => false + }); + + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout), { + ok: true, + shutdown: false, + reason: "supervisor_not_running", + staleStateRemoved: true + }); + assert.equal(existsSync(paths.statePath), false); + assert.equal(existsSync(`${paths.statePath}.stale`), false); +}); + +test("shutdown preserves stale state while its recorded Worker is still alive", async (t) => { + const homeDir = makeTempHome(); + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + const state = supervisorState(); + state.worker = { + ...state.worker, + phase: "running", + pid: 5353, + generation: 1, + startedAt: "2026-07-13T08:00:01.000Z" + }; + const paths = prepareSupervisorFiles(homeDir, { state }); + + const result = await invokeCli(["shutdown", "--json"], { + paths, + discoverSupervisorImpl: async () => null, + isProcessAlive: (pid) => pid === 5353 + }); + + assert.equal(result.status, 1); + const error = JSON.parse(result.stderr).error; + assert.equal(error.code, "SUPERVISOR_SHUTDOWN_UNAVAILABLE"); + assert.deepEqual(error.details, { + processStopped: false, + stateRemoved: false + }); + assert.deepEqual(JSON.parse(readFileSync(paths.statePath, "utf8")), state); + assert.equal(existsSync(`${paths.statePath}.stale`), false); +}); + +test("shutdown refuses a same-PID replacement without mutating or signalling it", async (t) => { + const homeDir = makeTempHome(); + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + const paths = prepareSupervisorFiles(homeDir); + const calls = []; + const signals = []; + const client = { + async request(method, path, body) { + calls.push([method, path, body]); + throw new CrpError( + "SUPERVISOR_IDENTITY_CHANGED", + "The local supervisor identity changed.", + "Refresh status and retry.", + { status: 409 } + ); + } + }; + + const result = await invokeCli(["shutdown", "--json"], { + paths, + discoverSupervisorImpl: async () => discoveredContext(client), + killProcess(pid, signal) { + signals.push([pid, signal]); + }, + isProcessAlive: () => true + }); + + assert.equal(result.status, 1); + assert.equal(JSON.parse(result.stderr).error.code, "SUPERVISOR_IDENTITY_CHANGED"); + assert.deepEqual(calls, [["POST", "/supervisor/shutdown", { + supervisorPid: 4242, + startedAt: "2026-07-13T08:00:00.000Z" + }]]); + assert.deepEqual(signals, []); + assert.equal(existsSync(paths.statePath), true); +}); + +test("shutdown revalidates identity before the legacy signal fallback", async (t) => { + const homeDir = makeTempHome(); + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + const paths = prepareSupervisorFiles(homeDir); + const calls = []; + const signals = []; + const replacement = supervisorState(5252); + const client = { + async request(method, path, body) { + calls.push([method, path, body]); + if (path === "/supervisor/shutdown") { + writeFileSync(paths.statePath, `${JSON.stringify(replacement)}\n`, { mode: 0o600 }); + throw new CrpError( + "API_NOT_FOUND", + "The requested endpoint does not exist.", + "Upgrade the local supervisor and retry.", + { status: 404 } + ); + } + assert.fail("replacement Supervisor must not receive another request"); + } + }; + + const result = await invokeCli(["shutdown", "--json"], { + paths, + discoverSupervisorImpl: async () => discoveredContext(client), + killProcess(pid, signal) { signals.push([pid, signal]); }, + isProcessAlive: () => true + }); + + assert.equal(result.status, 1); + assert.equal(JSON.parse(result.stderr).error.code, "SUPERVISOR_IDENTITY_CHANGED"); + assert.deepEqual(calls, [["POST", "/supervisor/shutdown", { + supervisorPid: 4242, + startedAt: "2026-07-13T08:00:00.000Z" + }]]); + assert.deepEqual(signals, []); + assert.deepEqual(JSON.parse(readFileSync(paths.statePath, "utf8")), replacement); +}); + +test("stale-state cleanup preserves a canonical replacement", (t) => { + const homeDir = makeTempHome(); + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + const paths = prepareSupervisorFiles(homeDir); + const snapshot = readSupervisorStateSnapshot({ path: paths.statePath }); + const replacement = supervisorState(5252); + writeFileSync(paths.statePath, `${JSON.stringify(replacement)}\n`, { mode: 0o600 }); + + const cleanup = removeStaleSupervisorState({ + path: paths.statePath, + expectedSnapshot: snapshot, + isProcessAlive: () => false + }); + + assert.deepEqual(cleanup, { removed: false, reason: "state_changed" }); + assert.deepEqual(JSON.parse(readFileSync(paths.statePath, "utf8")), replacement); + assert.equal(existsSync(`${paths.statePath}.stale`), false); +}); + +test("stale-state cleanup restores canonical state without leaving a marker after a liveness race", (t) => { + const homeDir = makeTempHome(); + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + const paths = prepareSupervisorFiles(homeDir); + const snapshot = readSupervisorStateSnapshot({ path: paths.statePath }); + let livenessChecks = 0; + + const cleanup = removeStaleSupervisorState({ + path: paths.statePath, + expectedSnapshot: snapshot, + isProcessAlive: () => { + livenessChecks += 1; + return livenessChecks > 1; + } + }); + + assert.deepEqual(cleanup, { removed: false, reason: "process_running" }); + assert.deepEqual(JSON.parse(readFileSync(paths.statePath, "utf8")), supervisorState()); + assert.equal(existsSync(`${paths.statePath}.stale`), false); +}); + +test("stale-state cleanup removes a same-inode residual claim before deleting canonical state", (t) => { + const homeDir = makeTempHome(); + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + const paths = prepareSupervisorFiles(homeDir); + const snapshot = readSupervisorStateSnapshot({ path: paths.statePath }); + realFileOperations.linkSync(paths.statePath, `${paths.statePath}.stale`); + + const cleanup = removeStaleSupervisorState({ + path: paths.statePath, + expectedSnapshot: snapshot, + isProcessAlive: () => false + }); + + assert.deepEqual(cleanup, { removed: true, reason: "removed" }); + assert.equal(existsSync(paths.statePath), false); + assert.equal(existsSync(`${paths.statePath}.stale`), false); +}); + +test("stale-state cleanup preserves state while its recorded Worker is alive", (t) => { + const homeDir = makeTempHome(); + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + const state = supervisorState(); + state.worker = { + ...state.worker, + phase: "running", + pid: 5353, + generation: 1, + startedAt: "2026-07-13T08:00:01.000Z" + }; + const paths = prepareSupervisorFiles(homeDir, { state }); + const snapshot = readSupervisorStateSnapshot({ path: paths.statePath }); + + const cleanup = removeStaleSupervisorState({ + path: paths.statePath, + expectedSnapshot: snapshot, + isProcessAlive: (pid) => pid === 5353 + }); + + assert.deepEqual(cleanup, { removed: false, reason: "process_running" }); + assert.deepEqual(JSON.parse(readFileSync(paths.statePath, "utf8")), state); + assert.equal(existsSync(`${paths.statePath}.stale`), false); +}); + +test("ui keeps the control token in the fragment and honors no-open", async () => { + const client = { request: async () => assert.fail("ui must not call an Admin mutation") }; + const context = discoveredContext(client); + let openCalls = 0; + const dependencies = { + ensureSupervisorImpl: async () => context, + readControlTokenImpl: () => CONTROL_TOKEN, + openManagementUrlImpl: () => { openCalls += 1; } + }; + + const noOpen = await invokeCli(["ui", "--no-open", "--json"], dependencies); + assert.equal(noOpen.status, 0, noOpen.stderr); + assert.deepEqual(JSON.parse(noOpen.stdout), { + ok: true, + opened: false, + origin: "http://127.0.0.1:15101", + supervisorPid: 4242, + url: `http://127.0.0.1:15101/#token=${CONTROL_TOKEN}` + }); + assert.equal(openCalls, 0); + + const opened = await invokeCli(["ui", "--json"], dependencies); + assert.equal(opened.status, 0, opened.stderr); + assert.equal(JSON.parse(opened.stdout).opened, true); + assert.equal(openCalls, 1); +}); + +test("opens management URLs with platform argv and never a shell", async () => { + const { openManagementUrl } = await import(CLI_URL); + const url = `http://127.0.0.1:15101/#token=${CONTROL_TOKEN}`; + const cases = [ + ["darwin", "open", [url]], + ["linux", "xdg-open", [url]], + ["win32", "cmd", ["/d", "/s", "/c", "start", "", url]] + ]; + for (const [platform, expectedCommand, expectedArgs] of cases) { + let received; + let unrefCalls = 0; + openManagementUrl(url, { + platform, + spawnImpl(command, args, options) { + received = { command, args, options }; + return { once() {}, unref() { unrefCalls += 1; } }; + } + }); + assert.equal(received.command, expectedCommand); + assert.deepEqual(received.args, expectedArgs); + assert.equal(received.options.shell, false); + assert.equal(received.options.detached, true); + assert.equal(unrefCalls, 1); + } +}); + +test("routes automation-safe provider commands without returning the write-only credential", async () => { + const secret = "provider-write-only-complete-secret"; + const calls = []; + const client = { + async request(method, path, body) { + calls.push([method, path, body]); + if (path === "/providers") { + return method === "GET" + ? { providers: [{ id: "provider-1", name: "Primary", credentialConfigured: true }] } + : { provider: { id: "provider-1", name: "Primary", credentialConfigured: true } }; + } + if (path.endsWith("/test")) return { result: { ok: true, code: null } }; + if (path.endsWith("/activate")) { + return { activation: { activeProviderId: "provider/1", generation: 1 } }; + } + return { provider: { id: "provider/1", name: "Primary", credentialConfigured: true } }; + } + }; + const dependencies = { ensureSupervisorImpl: async () => discoveredContext(client) }; + const commands = [ + ["list", [], ["GET", "/providers", undefined]], + ["add", [ + "--name", "Primary", + "--base-url", "https://provider.example/v1", + "--api-key", secret + ], ["POST", "/providers", { + provider: { name: "Primary", baseUrl: "https://provider.example/v1" }, + credential: secret + }]], + ["test", ["--id", "provider/1", "--model", "test-model"], [ + "POST", "/providers/provider%2F1/test", { model: "test-model", activateIfNone: true } + ]], + ["activate", ["--id", "provider/1"], [ + "POST", "/providers/provider%2F1/activate", undefined + ]], + ["delete", ["--id", "provider/1"], [ + "DELETE", "/providers/provider%2F1", undefined + ]] + ]; + + for (const [action, args, expectedCall] of commands) { + const result = await invokeCli(["provider", action, ...args, "--json"], dependencies); + assertSecretAbsent(secret, { stdout: result.stdout, stderr: result.stderr }); + assert.equal(result.status, 0, `${action}: ${result.stderr}`); + const actualCall = calls.shift(); + if (action === "add") { + assert.deepEqual(Object.keys(actualCall[2]).sort(), ["credential", "provider"]); + assert.deepEqual(actualCall[2].provider, expectedCall[2].provider); + assert.equal(actualCall[2].credential, secret); + } else { + assert.deepEqual(actualCall, expectedCall); + } + } + assert.deepEqual(calls, []); +}); + +test("provider add without a model preserves its one-request JSON contract", async () => { + const secret = "provider-add-old-json-complete-secret"; + const provider = { id: "provider-1", name: "Primary", credentialConfigured: true }; + const calls = []; + const client = { + async request(method, path, body) { + calls.push([method, path, body]); + return { provider }; + } + }; + const result = await invokeCli([ + "provider", "add", + "--name", "Primary", + "--base-url", "https://provider.example/v1", + "--api-key", secret, + "--json", + "--locale", "en" + ], { ensureSupervisorImpl: async () => discoveredContext(client) }); + + assertSecretAbsent(secret, { stdout: result.stdout, stderr: result.stderr }); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(calls, [["POST", "/providers", { + provider: { name: "Primary", baseUrl: "https://provider.example/v1" }, + credential: secret + }]]); + assert.deepEqual(JSON.parse(result.stdout), { + ok: true, + action: "add", + provider + }); +}); + +test("provider add with a model creates first and reports the automatic test result", async () => { + const secret = "provider-add-and-test-complete-secret"; + const provider = { id: "provider/1", name: "Primary", credentialConfigured: true }; + const calls = []; + const client = { + async request(method, path, body) { + calls.push([method, path, body]); + if (path === "/providers") return { provider }; + return { result: { ok: false, code: "PROVIDER_TEST_AUTH" } }; + } + }; + const result = await invokeCli([ + "provider", "add", + "--name", "Primary", + "--base-url", "https://provider.example/v1", + "--api-key", secret, + "--model", "test-model", + "--json", + "--locale", "en" + ], { ensureSupervisorImpl: async () => discoveredContext(client) }); + + assertSecretAbsent(secret, { stdout: result.stdout, stderr: result.stderr }); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(calls, [ + ["POST", "/providers", { + provider: { name: "Primary", baseUrl: "https://provider.example/v1" }, + credential: secret + }], + ["POST", "/providers/provider%2F1/test", { model: "test-model", activateIfNone: true }] + ]); + assert.deepEqual(JSON.parse(result.stdout), { + ok: true, + action: "add", + provider, + test: { ok: false, code: "PROVIDER_TEST_AUTH" } + }); +}); + +test("provider add keeps the created provider when automatic test transport fails", async () => { + const secret = "provider-add-test-transport-complete-secret"; + const provider = { id: "provider-1", name: "Primary", credentialConfigured: true }; + const calls = []; + const client = { + async request(method, path, body) { + calls.push([method, path, body]); + if (path === "/providers") return { provider }; + throw new Error(`transport failed: ${secret}`); + } + }; + const result = await invokeCli([ + "provider", "add", + "--name", "Primary", + "--base-url", "https://provider.example/v1", + "--api-key", secret, + "--model", "test-model", + "--json", + "--locale", "en" + ], { ensureSupervisorImpl: async () => discoveredContext(client) }); + + assertSecretAbsent(secret, { stdout: result.stdout, stderr: result.stderr }); + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); + assert.deepEqual(JSON.parse(result.stderr), { + ok: false, + command: "provider", + stage: null, + error: { + code: "PROVIDER_ADD_TEST_FAILED", + message: "The provider was added, but its automatic compatibility test could not be completed.", + action: "Run provider list, then retry provider test for the saved provider.", + details: { committed: true } + } + }); + assert.deepEqual(calls.map(([method, path]) => [method, path]), [ + ["POST", "/providers"], + ["POST", "/providers/provider-1/test"] + ]); +}); + +test("provider add preserves committed-degraded automatic test semantics", async () => { + const secret = "provider-add-test-committed-complete-secret"; + const provider = { id: "provider-1", name: "Primary", credentialConfigured: true }; + const safeAction = "Repair Activity persistence before retrying the provider test."; + const failure = new CrpError( + "PROVIDER_TEST_COMMITTED_DEGRADED", + "The provider test result was saved, but its Activity record degraded.", + safeAction, + { + status: 500, + details: { committed: true, degraded: true } + } + ); + failure.requestId = "request-add-test-safe"; + const calls = []; + const client = { + async request(method, path, body) { + calls.push([method, path, body]); + if (path === "/providers") return { provider }; + throw failure; + } + }; + const result = await invokeCli([ + "provider", "add", + "--name", "Primary", + "--base-url", "https://provider.example/v1", + "--api-key", secret, + "--model", "test-model", + "--json", + "--locale", "en" + ], { ensureSupervisorImpl: async () => discoveredContext(client) }); + const payload = JSON.parse(result.stderr); + assertSecretAbsent(secret, { stdout: result.stdout, stderr: result.stderr, objects: [payload] }); + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); + assert.equal(payload.error.code, "PROVIDER_ADD_TEST_COMMITTED_DEGRADED"); + assert.equal(payload.error.action, safeAction); + assert.equal(payload.error.requestId, "request-add-test-safe"); + assert.deepEqual(payload.error.details, { committed: true, degraded: true }); + assert.notEqual( + payload.error.action, + "Run provider list, then retry provider test for the saved provider." + ); + assert.deepEqual(calls.map(([method, path]) => [method, path]), [ + ["POST", "/providers"], + ["POST", "/providers/provider-1/test"] + ]); +}); + +test("provider actions resolve names case-insensitively before the selected operation", async () => { + const provider = { id: "provider/1", name: "Primary", credentialConfigured: true }; + const cases = [ + [ + "test", + ["--model", "test-model"], + "POST", + "/providers/provider%2F1/test", + { model: "test-model", activateIfNone: true } + ], + ["activate", [], "POST", "/providers/provider%2F1/activate", undefined], + ["delete", [], "DELETE", "/providers/provider%2F1", undefined], + ["models", [], "POST", "/providers/provider%2F1/models", undefined] + ]; + + for (const [action, extraArgs, expectedMethod, expectedPath, expectedBody] of cases) { + const calls = []; + const client = { + async request(method, path, body) { + calls.push([method, path, body]); + if (method === "GET" && path === "/providers") return { providers: [provider] }; + if (method === "GET" && path === "/providers/provider%2F1") return { provider }; + if (action === "test") return { result: { ok: true, code: null } }; + if (action === "activate") return { activation: { activeProviderId: provider.id, generation: 1 } }; + if (action === "models") { + return { + modelCatalog: { + providerId: provider.id, + state: "fresh", + fetchedAt: "2026-07-16T00:00:00.000Z", + expiresAt: "2026-07-17T00:00:00.000Z", + models: ["model-a"] + } + }; + } + return { provider }; + } + }; + const result = await invokeCli([ + "provider", action, + "--name", "pRiMaRy", + ...extraArgs, + "--json", + "--locale", "en" + ], { ensureSupervisorImpl: async () => discoveredContext(client) }); + + assert.equal(result.status, 0, `${action}: ${result.stderr}`); + assert.deepEqual(calls, [ + ["GET", "/providers", undefined], + ["GET", "/providers/provider%2F1", undefined], + [expectedMethod, expectedPath, expectedBody] + ]); + } +}); + +test("name selectors revalidate the resolved provider snapshot before mutation", async () => { + const listedProvider = { id: "provider/1", name: "Primary", credentialConfigured: true }; + const renamedProvider = { ...listedProvider, name: "Renamed" }; + const cases = [ + ["test", ["--model", "test-model"]], + ["activate", []], + ["delete", []], + ["models", []] + ]; + + for (const [action, extraArgs] of cases) { + const calls = []; + const client = { + async request(method, path, body) { + calls.push([method, path, body]); + if (method === "GET" && path === "/providers") { + return { providers: [listedProvider] }; + } + if (method === "GET" && path === "/providers/provider%2F1") { + return { provider: renamedProvider }; + } + if (action === "test") return { result: { ok: true, code: null } }; + if (action === "activate") { + return { activation: { activeProviderId: listedProvider.id, generation: 1 } }; + } + if (action === "models") { + return { modelCatalog: { providerId: listedProvider.id, models: [] } }; + } + return { provider: listedProvider }; + } + }; + const result = await invokeCli([ + "provider", action, + "--name", "pRiMaRy", + ...extraArgs, + "--json", + "--locale", "en" + ], { ensureSupervisorImpl: async () => discoveredContext(client) }); + + assert.equal(result.status, 1, `${action}: ${result.stdout}`); + assert.equal(result.stdout, ""); + const payload = JSON.parse(result.stderr); + assert.equal(payload.error.code, "PROVIDER_NOT_FOUND"); + assert.deepEqual(calls, [ + ["GET", "/providers", undefined], + ["GET", "/providers/provider%2F1", undefined] + ]); + } +}); + +test("provider selectors reject missing or conflicting id/name before discovery", async () => { + let ensureCalls = 0; + for (const [action, requiredArgs] of [ + ["test", ["--model", "test-model"]], + ["activate", []], + ["delete", []], + ["models", []] + ]) { + for (const selectorArgs of [ + [], + ["--id", "provider-1", "--name", "Primary"] + ]) { + const result = await invokeCli([ + "provider", action, + ...selectorArgs, + ...requiredArgs, + "--json", + "--locale", "en" + ], { ensureSupervisorImpl: async () => { ensureCalls += 1; } }); + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); + assert.equal(JSON.parse(result.stderr).error.code, "CLI_INPUT_INVALID"); + } + } + assert.equal(ensureCalls, 0); +}); + +test("a missing provider name performs only the public lookup and never mutates", async () => { + const selector = "missing-provider-name-secret-sentinel"; + const calls = []; + const client = { + async request(method, path, body) { + calls.push([method, path, body]); + return { providers: [{ id: "provider-1", name: "Primary" }] }; + } + }; + const result = await invokeCli([ + "provider", "delete", + "--name", selector, + "--json", + "--locale", "en" + ], { ensureSupervisorImpl: async () => discoveredContext(client) }); + + assertSecretAbsent(selector, { stdout: result.stdout, stderr: result.stderr }); + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); + assert.equal(JSON.parse(result.stderr).error.code, "PROVIDER_NOT_FOUND"); + assert.deepEqual(calls, [["GET", "/providers", undefined]]); +}); + +test("provider add rejects public fallback consent before supervisor discovery", async () => { + const secret = "fallback-option-complete-secret-sentinel"; + let ensureCalls = 0; + const result = await invokeCli([ + "provider", "add", + "--name", "Primary", + "--base-url", "https://provider.example/v1", + "--api-key", secret, + "--fallback-consent", + "--json" + ], { + ensureSupervisorImpl: async () => { ensureCalls += 1; } + }); + + assertSecretAbsent(secret, { stdout: result.stdout, stderr: result.stderr }); + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); + assert.equal(JSON.parse(result.stderr).error.code, "CLI_INPUT_INVALID"); + assert.equal(ensureCalls, 0); +}); + +test("check and capture JSON positively project legacy config without complete keys", () => { + const homeDir = makeTempHome(); + const secret = "legacy-complete-secret-sentinel"; + try { + const paths = getPaths(homeDir); + const nodeDir = join(paths.globalHome, "node"); + const codexDir = join(homeDir, ".codex"); + mkdirSync(nodeDir, { recursive: true, mode: 0o700 }); + mkdirSync(codexDir, { recursive: true }); + writeFileSync(join(paths.globalHome, "config.json"), JSON.stringify({ + upstreamBaseUrl: "https://provider.example/v1", + apiKey: secret, + captureEnabled: true, + captureDbPath: join(paths.globalHome, "traffic.sqlite3") + }), { mode: 0o600 }); + writeFileSync(join(nodeDir, "proxy-config.json"), JSON.stringify({ + server: { host: "127.0.0.1", port: 15100 }, + upstream: { baseUrl: "https://provider.example/v1", apiKey: secret }, + capture: { enabled: true, dbPath: join(paths.globalHome, "traffic.sqlite3") } + }), { mode: 0o600 }); + writeFileSync(join(codexDir, "config.toml"), [ + "[codex_remote_proxy]", + 'upstream_base_url = "https://provider.example/v1"', + `upstream_api_key = "${secret}"`, + "" + ].join("\n")); + writeFileSync(join(codexDir, "auth.json"), JSON.stringify({ + auth_mode: "chatgpt", + OPENAI_API_KEY: secret, + tokens: { access_token: secret } + })); + const env = makeHomeEnv(homeDir); + + for (const args of [["check", "--json"], ["capture", "status", "--json"]]) { + const result = runCrp(args, env); + const payload = JSON.parse(result.stdout); + assertSecretAbsent(secret, { + stdout: result.stdout, + stderr: result.stderr, + objects: [payload] + }); + assert.equal(result.status, 0, result.stderr); + } + } finally { + rmSync(homeDir, { recursive: true, force: true }); + } +}); + +test("capture mutation refuses a V1 supervisor state without modifying legacy config", () => { + const homeDir = makeTempHome(); + try { + const paths = prepareSupervisorFiles(homeDir, { state: supervisorState(process.pid) }); + const configPath = join(paths.globalHome, "config.json"); + const original = '{"captureEnabled":false}\n'; + writeFileSync(configPath, original, { mode: 0o600 }); + + const result = runCrp(["capture", "on", "--json"], makeHomeEnv(homeDir)); assert.equal(result.status, 1); - assert.match(output, /Error: Upstream base URL is required/); - assert.doesNotMatch(output, /ExperimentalWarning: SQLite/); + assert.equal(JSON.parse(result.stderr).error.code, "CLI_COMMAND_FAILED"); + assert.equal(readFileSync(configPath, "utf8"), original); } finally { rmSync(homeDir, { recursive: true, force: true }); } }); + +test("reads only the exact fixed-loopback supervisor state and private canonical token", (t) => { + const homeDir = makeTempHome(); + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + const paths = prepareSupervisorFiles(homeDir); + + assert.deepEqual(readSupervisorState({ path: paths.statePath, adminPort: 15101 }), supervisorState()); + assert.equal(readControlToken({ path: paths.controlTokenPath }), CONTROL_TOKEN); + + writeFileSync(paths.statePath, `${JSON.stringify({ + ...supervisorState(), + admin: { ...supervisorState().admin, origin: "http://attacker.example:15101" } + })}\n`, { mode: 0o600 }); + assert.equal(readSupervisorState({ path: paths.statePath, adminPort: 15101 }), null); + + writeFileSync(paths.statePath, `${JSON.stringify({ + ...supervisorState(), + supervisorPid: Number.MAX_SAFE_INTEGER + 1 + })}\n`, { mode: 0o600 }); + assert.equal(readSupervisorState({ path: paths.statePath, adminPort: 15101 }), null); + + if (process.platform !== "win32") { + chmodSync(paths.controlTokenPath, 0o644); + assert.throws( + () => readControlToken({ path: paths.controlTokenPath }), + (error) => error?.code === "SUPERVISOR_TOKEN_INVALID" + ); + } +}); + +test("rejects a control-token path swapped after its descriptor is opened", (t) => { + const homeDir = makeTempHome(); + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + const paths = prepareSupervisorFiles(homeDir); + const originalPath = `${paths.controlTokenPath}.original`; + let swapped = false; + const fileOperations = { + ...realFileOperations, + openSync(path, flags, mode) { + const descriptor = realFileOperations.openSync(path, flags, mode); + if (path === paths.controlTokenPath && !swapped) { + swapped = true; + realFileOperations.renameSync(path, originalPath); + realFileOperations.writeFileSync(path, `${OTHER_CONTROL_TOKEN}\n`, { mode: 0o600 }); + } + return descriptor; + } + }; + + assert.throws( + () => readControlToken({ path: paths.controlTokenPath, fileOperations }), + (error) => error?.code === "SUPERVISOR_TOKEN_INVALID" + ); + assert.equal(readFileSync(paths.controlTokenPath, "utf8"), `${OTHER_CONTROL_TOKEN}\n`); +}); + +test("SupervisorClient pins the Admin origin, bearer, JSON bodies, and safe public errors", async (t) => { + const homeDir = makeTempHome(); + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + const paths = prepareSupervisorFiles(homeDir); + const calls = []; + let response = new Response(JSON.stringify({ supervisor: { pid: 4242 } }), { + status: 200, + headers: { "content-type": "application/json" } + }); + const client = new SupervisorClient({ + origin: "http://127.0.0.1:15101", + controlTokenPath: paths.controlTokenPath, + fetchImpl: async (...args) => { + calls.push(args); + return response; + } + }); + + assert.deepEqual(await client.request("GET", "/status"), { supervisor: { pid: 4242 } }); + assert.equal(calls[0][0], "http://127.0.0.1:15101/api/v1/status"); + assert.equal(calls[0][1].headers.authorization, `Bearer ${CONTROL_TOKEN}`); + assert.equal("content-type" in calls[0][1].headers, false); + assert.equal("body" in calls[0][1], false); + + response = new Response(JSON.stringify({ worker: { phase: "running" } }), { + status: 200, + headers: { "content-type": "application/json" } + }); + await client.request("POST", "/proxy/start"); + assert.equal("content-type" in calls[1][1].headers, false); + assert.equal("body" in calls[1][1], false); + + response = new Response(JSON.stringify({ provider: { id: "provider-1" } }), { + status: 201, + headers: { "content-type": "application/json" } + }); + await client.request("POST", "/providers", { provider: { name: "Primary" }, credential: "write-only" }); + assert.equal(calls[2][1].headers["content-type"], "application/json; charset=utf-8"); + assert.equal(calls[2][1].body, JSON.stringify({ + provider: { name: "Primary" }, + credential: "write-only" + })); + + const secret = "must-not-escape-public-error"; + response = new Response(JSON.stringify({ + error: { + code: "PROVIDER_INPUT_INVALID", + message: "Provider settings are invalid.", + action: "Review the provider settings and try again.", + requestId: "request-1", + details: { field: "name", unknown: secret, authorization: "[REDACTED]" } + } + }), { status: 400, headers: { "content-type": "application/json" } }); + const publicError = await client.request("POST", "/providers", {}).then( + () => null, + (error) => error + ); + assertSecretAbsent(secret, { objects: [publicError] }); + assert.equal(publicError?.code, "PROVIDER_INPUT_INVALID"); + assert.equal(publicError.status, 400); + assert.equal(publicError.details.field, "name"); + assert.equal(publicError.details.authorization, "[REDACTED]"); + + const shutdown = { + shutdown: { + accepted: true, + supervisorPid: 4242, + startedAt: "2026-07-13T08:00:00.000Z" + } + }; + response = new Response(JSON.stringify(shutdown), { + status: 200, + headers: { "content-type": "application/json" } + }); + await assert.rejects( + () => client.request("POST", "/supervisor/shutdown", {}, { expectedStatus: 202 }), + (error) => error?.code === "SUPERVISOR_RESPONSE_INVALID" + ); + response = new Response(JSON.stringify(shutdown), { + status: 202, + headers: { "content-type": "application/json" } + }); + assert.deepEqual( + await client.request("POST", "/supervisor/shutdown", {}, { expectedStatus: 202 }), + shutdown + ); +}); + +test("SupervisorClient rejects malformed per-call timeout overrides before fetch", async (t) => { + const homeDir = makeTempHome(); + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + const paths = prepareSupervisorFiles(homeDir); + let fetchCalls = 0; + const client = new SupervisorClient({ + origin: "http://127.0.0.1:15101", + controlTokenPath: paths.controlTokenPath, + fetchImpl: async () => { + fetchCalls += 1; + return new Response("{}", { + status: 200, + headers: { "content-type": "application/json" } + }); + } + }); + + for (const override of [ + null, + {}, + { requestTimeoutMs: 0 }, + { requestTimeoutMs: 1.5 }, + { requestTimeoutMs: 1, unknown: true }, + { expectedStatus: 99 }, + { expectedStatus: 600 }, + { expectedStatus: 202.5 } + ]) { + await assert.rejects( + async () => client.request("GET", "/status", undefined, override), + (error) => error?.code === "SUPERVISOR_CLIENT_INPUT_INVALID" + ); + } + assert.equal(fetchCalls, 0); +}); + +test("spawns one detached supervisor and shares concurrent bounded discovery", async (t) => { + const homeDir = makeTempHome(); + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + const paths = getPaths(homeDir); + const tokenTracker = trackControlTokenReads(paths); + let spawnCalls = 0; + let clock = 0; + const calls = []; + const spawnSupervisor = () => { + spawnCalls += 1; + prepareSupervisorFiles(homeDir); + return { pid: 4242 }; + }; + const options = { + paths, + adminPort: 15101, + spawnSupervisor, + isProcessAlive: () => true, + fileOperations: tokenTracker.fileOperations, + probeTimeoutMs: 20, + requestTimeoutMs: 250, + fetchImpl: async (url, { signal }) => { + const path = new URL(url).pathname; + calls.push(path); + if (path.endsWith("/status")) { + return new Response(JSON.stringify({ + supervisor: { pid: 4242, startedAt: "2026-07-13T08:00:00.000Z" }, + worker: { phase: "stopped", pid: null } + }), { status: 200, headers: { "content-type": "application/json" } }); + } + return delayedJsonResponse({ result: { ok: true } }, { delayMs: 80, signal }); + }, + now: () => clock, + wait: async (milliseconds) => { clock += milliseconds; }, + timeoutMs: 8_000, + pollIntervalMs: 100 + }; + + const first = ensureSupervisor(options); + const second = ensureSupervisor(options); + assert.equal(first, second); + const [left, right] = await Promise.all([first, second]); + assert.equal(left, right); + assert.equal(left.state.supervisorPid, 4242); + assert.equal(left.spawned, true); + assert.equal(spawnCalls, 1); + assert.deepEqual( + await left.client.request("POST", "/providers/provider-1/test", { model: "test-model" }), + { result: { ok: true } } + ); + assert.equal(tokenTracker.tokenReads(), 1); + + const reused = await ensureSupervisor(options); + assert.equal(reused.spawned, false); + assert.equal(spawnCalls, 1); + assert.equal(tokenTracker.tokenReads(), 2); + assert.deepEqual(calls, [ + "/api/v1/status", + "/api/v1/providers/provider-1/test", + "/api/v1/status" + ]); +}); + +test("ensureSupervisor removes a dead lone cleanup claim before spawning", async (t) => { + const homeDir = makeTempHome(); + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + const paths = prepareSupervisorFiles(homeDir); + const claimPath = `${paths.statePath}.stale`; + realFileOperations.renameSync(paths.statePath, claimPath); + let spawned = false; + + const context = await ensureSupervisor({ + paths, + adminPort: 15101, + spawnSupervisor: () => { + assert.equal(existsSync(paths.statePath), false); + assert.equal(existsSync(claimPath), false); + spawned = true; + prepareSupervisorFiles(homeDir); + return { pid: 4242 }; + }, + isProcessAlive: () => spawned, + fetchImpl: async () => new Response(JSON.stringify(adminStatus(4242)), { + status: 200, + headers: { "content-type": "application/json" } + }), + wait: async () => {}, + timeoutMs: 100, + pollIntervalMs: 10 + }); + + assert.equal(context.spawned, true); + assert.equal(spawned, true); + assert.equal(existsSync(claimPath), false); +}); + +test("ensureSupervisor preserves a lone cleanup claim while its Supervisor is alive", async (t) => { + const homeDir = makeTempHome(); + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + const paths = prepareSupervisorFiles(homeDir); + const claimPath = `${paths.statePath}.stale`; + realFileOperations.renameSync(paths.statePath, claimPath); + let spawnCalls = 0; + + await assert.rejects( + () => ensureSupervisor({ + paths, + spawnSupervisor: () => { spawnCalls += 1; }, + isProcessAlive: () => true + }), + (error) => error?.code === "SUPERVISOR_START_FAILED" + ); + assert.equal(spawnCalls, 0); + assert.equal(existsSync(paths.statePath), false); + assert.equal(existsSync(claimPath), true); +}); + +test("ensureSupervisor reads one token and keeps probe timeout separate from later operations", async (t) => { + const homeDir = makeTempHome(); + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + const paths = prepareSupervisorFiles(homeDir); + const tokenTracker = trackControlTokenReads(paths); + const calls = []; + const status = adminStatus(4242); + + const context = await ensureSupervisor({ + paths, + adminPort: 15101, + spawnSupervisor: () => assert.fail("a ready supervisor must not be spawned again"), + isProcessAlive: () => true, + fileOperations: tokenTracker.fileOperations, + probeTimeoutMs: 20, + requestTimeoutMs: 250, + fetchImpl: async (url, { signal }) => { + const path = new URL(url).pathname; + calls.push(path); + if (path.endsWith("/status")) { + return new Response(JSON.stringify(status), { + status: 200, + headers: { "content-type": "application/json" } + }); + } + return delayedJsonResponse({ result: { ok: true } }, { delayMs: 80, signal }); + } + }); + + assert.deepEqual( + await context.client.request("POST", "/providers/provider-1/test", { model: "test-model" }), + { result: { ok: true } } + ); + assert.deepEqual(calls, [ + "/api/v1/status", + "/api/v1/providers/provider-1/test" + ]); + assert.equal(tokenTracker.tokenReads(), 1); +}); + +test("discoverSupervisor times out a slow identity probe before the request timeout", async (t) => { + const homeDir = makeTempHome(); + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + const paths = prepareSupervisorFiles(homeDir); + + const context = await discoverSupervisor({ + paths, + adminPort: 15101, + isProcessAlive: () => true, + probeTimeoutMs: 20, + requestTimeoutMs: 250, + fetchImpl: async (_url, { signal }) => delayedJsonResponse(adminStatus(4242), { + delayMs: 80, + signal + }) + }); + + assert.equal(context, null); +}); + +test("ensureSupervisor rejects invalid probe and request timeouts before spawning", async (t) => { + for (const [field, value] of [ + ["probeTimeoutMs", 0], + ["probeTimeoutMs", 1.5], + ["requestTimeoutMs", 0], + ["requestTimeoutMs", Number.MAX_SAFE_INTEGER + 1] + ]) { + const homeDir = makeTempHome(); + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + let spawnCalls = 0; + let clock = 0; + + await assert.rejects( + () => ensureSupervisor({ + paths: getPaths(homeDir), + adminPort: 15101, + spawnSupervisor: () => { + spawnCalls += 1; + return { pid: 4242 }; + }, + isProcessAlive: () => true, + fetchImpl: async () => assert.fail("invalid timeouts must not reach Admin"), + timeoutMs: 1, + pollIntervalMs: 1, + now: () => clock, + wait: async (milliseconds) => { clock += milliseconds; }, + [field]: value + }), + (error) => error?.code === "SUPERVISOR_CLIENT_INPUT_INVALID", + `${field}=${value}` + ); + assert.equal(spawnCalls, 0, `${field}=${value}`); + } +}); + +test("discovery rejects a status with the same PID but a different startedAt", async (t) => { + const homeDir = makeTempHome(); + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + const paths = prepareSupervisorFiles(homeDir); + const status = adminStatus(4242); + status.supervisor.startedAt = "2026-07-13T08:01:00.000Z"; + + const discovered = await discoverSupervisor({ + paths, + adminPort: 15101, + isProcessAlive: () => true, + fetchImpl: async () => new Response(JSON.stringify(status), { + status: 200, + headers: { "content-type": "application/json" } + }) + }); + + assert.equal(discovered, null); +}); + +test("detached spawn redirects logs, sets CRP_HOME, and unrefs without a shell", (t) => { + const homeDir = makeTempHome(); + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + const paths = getPaths(homeDir); + let received = null; + let unrefCalls = 0; + let channelUnrefCalls = 0; + const child = spawnDetachedSupervisor({ + paths, + home: homeDir, + spawnImpl(command, args, options) { + received = { command, args, options }; + const spawned = new EventEmitter(); + spawned.pid = 4242; + spawned.channel = { unref() { channelUnrefCalls += 1; } }; + spawned.unref = () => { unrefCalls += 1; }; + return spawned; + } + }); + + assert.equal(child.pid, 4242); + assert.equal(received.command, process.execPath); + assert.match(received.args[0], /supervisor-entry\.mjs$/); + assert.equal(received.options.detached, true); + assert.equal(received.options.stdio.length, 4); + assert.deepEqual(received.options.stdio.slice(0, 1), ["ignore"]); + assert.equal(received.options.stdio[1], received.options.stdio[2]); + assert.equal(received.options.stdio[3], "ipc"); + assert.equal(received.options.serialization, "json"); + assert.equal(received.options.env.CRP_HOME, homeDir); + assert.equal(received.options.shell, false); + assert.equal(received.options.windowsHide, true); + assert.equal(unrefCalls, 1); + assert.equal(channelUnrefCalls, 1); + assert.equal(typeof child.startupFailure?.then, "function"); + assert.equal(typeof child.disposeStartupMonitor, "function"); + child.disposeStartupMonitor(); + assert.equal(child.listenerCount("message"), 0); + assert.equal(child.listenerCount("error"), 1); + assert.equal(child.listenerCount("close"), 0); + if (process.platform !== "win32") { + assert.equal(statSync(paths.logPath).mode & 0o777, 0o600); + } +}); + +test("detached spawn tears down a child when post-spawn setup fails", (t) => { + const homeDir = makeTempHome(); + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + const paths = getPaths(homeDir); + const child = new EventEmitter(); + child.pid = 4243; + child.connected = true; + child.channel = { unref() {} }; + const signals = []; + let disconnectCalls = 0; + child.unref = () => { throw new Error("post-spawn setup failed"); }; + child.kill = (signal) => { signals.push(signal); return true; }; + child.disconnect = () => { + disconnectCalls += 1; + child.connected = false; + }; + + assert.throws( + () => spawnDetachedSupervisor({ + paths, + home: homeDir, + spawnImpl: () => child + }), + (error) => error?.code === "SUPERVISOR_START_FAILED" + ); + assert.deepEqual(signals, ["SIGTERM"]); + assert.equal(disconnectCalls, 1); + assert.equal(child.listenerCount("message"), 0); + assert.equal(child.listenerCount("close"), 0); + assert.equal(child.listenerCount("error"), 1); + assert.doesNotThrow(() => child.emit("error", new Error("late teardown error"))); +}); + +test("detached startup failure interrupts CLI discovery with the safe child error", async (t) => { + const homeDir = makeTempHome(); + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + const paths = getPaths(homeDir); + let child; + let spawnCalls = 0; + let childUnrefCalls = 0; + let channelUnrefCalls = 0; + const signals = []; + let clock = 0; + let waitCalls = 0; + + const result = await invokeCli(["provider", "list", "--json"], { + ensureSupervisorImpl: () => ensureSupervisor({ + paths, + adminPort: 15101, + spawnSupervisor: () => spawnDetachedSupervisor({ + paths, + home: homeDir, + spawnImpl(_command, _args, options) { + spawnCalls += 1; + assert.equal(options.stdio[3], "ipc"); + child = new EventEmitter(); + child.pid = 5000; + child.channel = { unref() { channelUnrefCalls += 1; } }; + child.unref = () => { childUnrefCalls += 1; }; + child.kill = (signal) => { + signals.push(signal); + queueMicrotask(() => child.emit("close", 1, signal)); + return true; + }; + queueMicrotask(() => { + child.emit("close", 1, null); + child.emit("message", { + version: 1, + type: "startup-failed", + error: { + code: "MIGRATION_INPUT_INVALID", + message: "The legacy provider configuration is invalid.", + action: "Restore a complete legacy provider URL and credential before migrating.", + status: 400, + details: {} + } + }); + }); + return child; + } + }), + isProcessAlive: () => true, + fetchImpl: async () => assert.fail("startup failure must not reach Admin"), + now: () => clock, + wait: async (milliseconds) => { + waitCalls += 1; + clock += milliseconds; + }, + timeoutMs: 8_000, + pollIntervalMs: 100 + }) + }); + + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); + assert.deepEqual(JSON.parse(result.stderr), { + ok: false, + command: "provider", + stage: null, + error: { + code: "MIGRATION_INPUT_INVALID", + message: "The legacy provider configuration is invalid.", + action: "Restore a complete legacy provider URL and credential before migrating.", + details: {} + } + }); + assert.equal(spawnCalls, 1); + assert.equal(childUnrefCalls, 1); + assert.equal(channelUnrefCalls, 1); + assert.deepEqual(signals, ["SIGTERM"]); + assert.ok(clock <= 100, `startup failure consumed ${clock} ms`); + assert.ok(waitCalls <= 1, `startup failure waited ${waitCalls} times`); + assert.equal(child.listenerCount("message"), 0); + assert.equal(child.listenerCount("error"), 1); + assert.equal(child.listenerCount("close"), 0); + assert.doesNotThrow(() => child.emit("error", new Error("late teardown error"))); +}); + +test("detached startup monitor contains malformed child messages", async (t) => { + const secret = "malformed-startup-message-must-not-escape"; + const homeDir = makeTempHome(); + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + const paths = getPaths(homeDir); + let child; + let clock = 0; + + const failure = await ensureSupervisor({ + paths, + adminPort: 15101, + spawnSupervisor: () => spawnDetachedSupervisor({ + paths, + home: homeDir, + spawnImpl() { + child = new EventEmitter(); + child.pid = 5001; + child.channel = { unref() {} }; + child.unref = () => {}; + child.kill = () => true; + queueMicrotask(() => child.emit("message", { + version: 1, + type: "startup-failed", + error: { + code: "MIGRATION_INPUT_INVALID", + message: secret, + action: "Restore a complete legacy provider URL and credential before migrating.", + status: 400, + details: {} + } + })); + return child; + } + }), + isProcessAlive: () => true, + fetchImpl: async () => assert.fail("malformed startup message must not reach Admin"), + now: () => clock, + wait: async (milliseconds) => { clock += milliseconds; }, + timeoutMs: 8_000, + pollIntervalMs: 100 + }).then( + () => null, + (error) => error + ); + + assertSecretAbsent(secret, { objects: [failure] }); + assert.equal(failure?.code, "SUPERVISOR_START_FAILED"); + assert.ok(clock <= 100, `malformed startup message consumed ${clock} ms`); + assert.equal(child.listenerCount("message"), 0); + assert.equal(child.listenerCount("error"), 1); + assert.equal(child.listenerCount("close"), 0); +}); + +test("detached child close before readiness returns a start failure", async (t) => { + const homeDir = makeTempHome(); + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + const paths = getPaths(homeDir); + let child; + let clock = 0; + + const failure = await ensureSupervisor({ + paths, + adminPort: 15101, + spawnSupervisor: () => spawnDetachedSupervisor({ + paths, + home: homeDir, + spawnImpl() { + child = new EventEmitter(); + child.pid = 5002; + child.channel = { unref() {} }; + child.unref = () => {}; + child.kill = () => true; + queueMicrotask(() => child.emit("close", 1, null)); + return child; + } + }), + isProcessAlive: () => true, + fetchImpl: async () => assert.fail("closed startup child must not reach Admin"), + now: () => clock, + wait: (milliseconds) => new Promise((resolvePromise) => { + setImmediate(() => { + clock += milliseconds; + resolvePromise(); + }); + }), + timeoutMs: 8_000, + pollIntervalMs: 100 + }).then( + () => null, + (error) => error + ); + + assert.equal(failure?.code, "SUPERVISOR_START_FAILED"); + assert.ok(clock <= 100, `closed startup child consumed ${clock} ms`); + assert.equal(child.listenerCount("message"), 0); + assert.equal(child.listenerCount("error"), 1); + assert.equal(child.listenerCount("close"), 0); +}); + +test("successful monitored supervisor discovery disposes startup IPC without killing child", async (t) => { + const homeDir = makeTempHome(); + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + const paths = getPaths(homeDir); + let child; + let killCalls = 0; + let disconnectCalls = 0; + + const context = await ensureSupervisor({ + paths, + adminPort: 15101, + spawnSupervisor: () => spawnDetachedSupervisor({ + paths, + home: homeDir, + spawnImpl() { + prepareSupervisorFiles(homeDir, { state: supervisorState(5003) }); + child = new EventEmitter(); + child.pid = 5003; + child.connected = true; + child.channel = { unref() {} }; + child.unref = () => {}; + child.disconnect = () => { + disconnectCalls += 1; + child.connected = false; + }; + child.kill = () => { killCalls += 1; return true; }; + return child; + } + }), + isProcessAlive: () => true, + fetchImpl: async () => new Response(JSON.stringify(adminStatus(5003)), { + status: 200, + headers: { "content-type": "application/json" } + }) + }); + + assert.equal(context.spawned, true); + assert.equal(context.state.supervisorPid, 5003); + assert.equal(killCalls, 0); + assert.equal(disconnectCalls, 1); + assert.equal(child.listenerCount("message"), 0); + assert.equal(child.listenerCount("error"), 1); + assert.equal(child.listenerCount("close"), 0); +}); + +test("discovery timeout preserves an invalid state file", async (t) => { + const homeDir = makeTempHome(); + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + const paths = prepareSupervisorFiles(homeDir); + const invalidBytes = '{"legacy":true}\n'; + writeFileSync(paths.statePath, invalidBytes, { mode: 0o600 }); + let clock = 0; + const signals = []; + + await assert.rejects( + () => ensureSupervisor({ + paths, + adminPort: 15101, + spawnSupervisor: () => ({ + pid: 5000, + kill(signal) { + signals.push(signal); + throw new Error("cleanup failed"); + } + }), + isProcessAlive: () => true, + fetchImpl: async () => assert.fail("invalid state must not receive the bearer"), + now: () => clock, + wait: async (milliseconds) => { clock += milliseconds; }, + timeoutMs: 200, + pollIntervalMs: 50 + }), + (error) => error?.code === "SUPERVISOR_START_TIMEOUT" + ); + assert.deepEqual(signals, ["SIGTERM"]); + assert.equal(readFileSync(paths.statePath, "utf8"), invalidBytes); +}); diff --git a/node/test/e2e/crp-ui-fixture.mjs b/node/test/e2e/crp-ui-fixture.mjs new file mode 100644 index 0000000..9217631 --- /dev/null +++ b/node/test/e2e/crp-ui-fixture.mjs @@ -0,0 +1,1287 @@ +import { test as base, expect } from "@playwright/test"; +import assert from "node:assert/strict"; +import { randomBytes } from "node:crypto"; +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import http from "node:http"; +import os from "node:os"; +import { join, resolve } from "node:path"; + +import { createAdminServer } from "../../src/supervisor/admin-server.mjs"; +import { SessionAuth } from "../../src/supervisor/session-auth.mjs"; +import { CrpError } from "../../src/shared/errors.mjs"; + +const REPO_UI_ROOT = resolve(import.meta.dirname, "../../ui"); +const STARTED_AT = "2026-07-13T08:00:00.000Z"; +const MODEL_CATALOG_FETCHED_AT = "2026-07-13T08:15:00.000Z"; +const MODEL_CATALOG_EXPIRES_AT = "2026-07-14T08:15:00.000Z"; + +function emptyMetrics(window = "24h") { + return { + window, + bucketMinutes: 60, + storageState: "ready", + summary: { + requests: 0, + results: { + success: 0, + upstreamRejected: 0, + upstreamError: 0, + timeout: 0, + networkError: 0, + clientAbort: 0 + }, + tokens: { input: 0, output: 0, observedRequests: 0 }, + latency: { p50UpperBoundMs: null, p95UpperBoundMs: null, overflowRequests: 0 }, + responseStart: { p50UpperBoundMs: null, p95UpperBoundMs: null, overflowRequests: 0 } + }, + series: [], + providers: [], + providerOtherRequests: 0, + models: [], + modelOtherRequests: 0, + dataQuality: { + unknownModelRequests: 0, + modelOverflowRequests: 0, + providerOverflowRequests: 0, + droppedObservations: 0 + } + }; +} + +function publicProvider(input = {}, index = 0) { + const now = `2026-07-13T08:0${index}:00.000Z`; + return { + id: input.id ?? `provider-${index + 1}`, + name: input.name ?? `Provider ${index + 1}`, + baseUrl: input.baseUrl ?? `https://provider-${index + 1}.example/v1`, + authHeader: input.authHeader ?? "authorization", + authScheme: input.authScheme ?? "Bearer", + extraHeaders: input.extraHeaders ?? {}, + modelMode: input.modelMode ?? "passthrough", + modelOverride: input.modelOverride ?? null, + lastTestAt: Object.hasOwn(input, "lastTestAt") ? input.lastTestAt : now, + lastTestStatus: input.lastTestStatus ?? "passed", + lastTestCode: Object.hasOwn(input, "lastTestCode") ? input.lastTestCode : null, + createdAt: input.createdAt ?? now, + updatedAt: input.updatedAt ?? now, + credentialConfigured: input.credentialConfigured ?? true + }; +} + +function stoppedWorker(generation = 0) { + return { + phase: "stopped", + pid: null, + generation, + state: null, + restartCount: 0, + startedAt: null, + error: null + }; +} + +function createServices({ upstream }) { + const calls = []; + const credentials = new Map(); + const modelCatalogs = new Map(); + const state = { + providers: [], + activeProviderId: null, + generation: 0, + worker: stoppedWorker(), + supervisorPid: 7001, + supervisorStartedAt: STARTED_AT, + supervisorShutdownAccepted: false, + nextWorkerPid: 4201, + testFailureCode: null, + nextMutationError: null, + codex: { + configured: false, + modelProvider: null, + proxyUrl: null, + historyRepairPending: false + }, + bootstrapCount: 0, + activities: [], + diagnostics: null, + metrics: emptyMetrics(), + metricsByWindow: new Map() + }; + + function addActivity(category, action, providerId, result = "success", errorCode = null) { + state.activities.unshift({ + timestamp: new Date(Date.parse(STARTED_AT) + state.activities.length * 1_000).toISOString(), + category, + action, + providerId, + result, + errorCode, + details: result === "success" ? { generation: state.generation } : {} + }); + } + + function rejectNextMutation(operation) { + const failure = state.nextMutationError; + if (failure === null) return; + state.nextMutationError = null; + calls.push({ operation: "mutationRejected", target: operation, code: failure.code }); + throw new CrpError( + failure.code, + "Injected fixture mutation failure.", + "Use the stable error code.", + { status: failure.status, details: failure.details ?? {} } + ); + } + + function currentProvider() { + return state.providers.find((provider) => provider.id === state.activeProviderId) ?? null; + } + + const providerService = { + async listProviders() { + calls.push({ operation: "listProviders" }); + return structuredClone(state.providers); + }, + async createProvider(input, credential) { + rejectNextMutation("createProvider"); + assert.equal(typeof credential, "string"); + assert.ok(credential.length > 0); + calls.push({ + operation: "createProvider", + input: structuredClone(input), + credentialLength: credential.length + }); + const provider = publicProvider({ + ...input, + id: `provider-${state.providers.length + 1}`, + lastTestAt: null, + lastTestStatus: "untested", + lastTestCode: null + }, state.providers.length); + state.providers.push(provider); + credentials.set(provider.id, credential); + addActivity("provider", "create", provider.id); + return structuredClone(provider); + }, + async updateProvider(id, patch, replacementCredential) { + rejectNextMutation("updateProvider"); + if (id === state.activeProviderId) { + throw new CrpError( + "PROVIDER_ACTIVE", + "The active provider cannot be edited.", + "Activate another provider first.", + { status: 409 } + ); + } + const provider = state.providers.find((item) => item.id === id); + if (!provider) throw new CrpError("PROVIDER_NOT_FOUND", "Missing provider.", "Refresh.", { status: 404 }); + if (replacementCredential !== undefined) { + assert.ok(replacementCredential.length > 0); + credentials.set(id, replacementCredential); + } + calls.push({ + operation: "updateProvider", + id, + replacedCredential: replacementCredential !== undefined, + replacementLength: replacementCredential?.length ?? 0 + }); + const invalidatingFields = [ + "baseUrl", + "authHeader", + "authScheme", + "extraHeaders", + "modelMode", + "modelOverride" + ]; + const operationalChange = replacementCredential !== undefined + || invalidatingFields.some((field) => JSON.stringify(provider[field]) !== JSON.stringify(patch[field])); + if (operationalChange) modelCatalogs.delete(id); + Object.assign(provider, patch, { + updatedAt: "2026-07-13T08:30:00.000Z", + ...(!operationalChange ? {} : { + lastTestAt: null, + lastTestStatus: "untested", + lastTestCode: null + }) + }); + addActivity("provider", "update", id); + return structuredClone(provider); + }, + async deleteProvider(id) { + rejectNextMutation("deleteProvider"); + if (id === state.activeProviderId) { + throw new CrpError( + "PROVIDER_ACTIVE", + "The active provider cannot be deleted.", + "Activate another provider first.", + { status: 409 } + ); + } + const index = state.providers.findIndex((provider) => provider.id === id); + if (index === -1) throw new CrpError("PROVIDER_NOT_FOUND", "Missing provider.", "Refresh.", { status: 404 }); + const [deleted] = state.providers.splice(index, 1); + credentials.delete(id); + modelCatalogs.delete(id); + calls.push({ operation: "deleteProvider", id }); + addActivity("provider", "delete", id); + return structuredClone(deleted); + }, + async getProviderModels(id) { + const provider = state.providers.find((item) => item.id === id); + if (!provider) throw new CrpError("PROVIDER_NOT_FOUND", "Missing provider.", "Refresh.", { status: 404 }); + calls.push({ operation: "getProviderModels", id }); + return structuredClone(modelCatalogs.get(id) ?? { + providerId: id, + state: "missing", + fetchedAt: null, + expiresAt: null, + models: [] + }); + }, + async refreshProviderModels(id) { + rejectNextMutation("refreshProviderModels"); + const provider = state.providers.find((item) => item.id === id); + if (!provider) throw new CrpError("PROVIDER_NOT_FOUND", "Missing provider.", "Refresh.", { status: 404 }); + const modelCatalog = { + providerId: id, + state: "fresh", + fetchedAt: MODEL_CATALOG_FETCHED_AT, + expiresAt: MODEL_CATALOG_EXPIRES_AT, + models: ["gpt-5.1-codex-mini", "fixture-model"] + }; + modelCatalogs.set(id, modelCatalog); + calls.push({ operation: "refreshProviderModels", id }); + addActivity("provider", "models", id); + return structuredClone(modelCatalog); + }, + async testProvider(id, model, { activateIfNone = false } = {}) { + assert.ok(model.trim().length > 0); + assert.equal(typeof activateIfNone, "boolean"); + const provider = state.providers.find((item) => item.id === id); + if (!provider) throw new CrpError("PROVIDER_NOT_FOUND", "Missing provider.", "Refresh.", { status: 404 }); + if (activateIfNone && state.activeProviderId === null && state.worker.phase !== "stopped") { + throw new CrpError( + "PROVIDER_INITIAL_ACTIVATION_UNSAFE", + "The initial provider cannot be selected while the worker is running.", + "Stop the worker and try again.", + { status: 409 } + ); + } + calls.push({ operation: "testProvider", id, model, activateIfNone }); + const target = new URL("responses", provider.baseUrl.endsWith("/") + ? provider.baseUrl + : `${provider.baseUrl}/`); + if (target.origin !== upstream.origin) { + throw new Error(`external provider access blocked: ${target.origin}`); + } + const credential = credentials.get(id) ?? "seed-credential"; + upstream.expectRequest({ + path: target.pathname, + model, + authHeader: provider.authHeader, + authScheme: provider.authScheme, + credential, + extraHeaders: provider.extraHeaders + }); + const response = await fetch(target, { + method: "POST", + headers: { + "content-type": "application/json", + ...provider.extraHeaders, + [provider.authHeader]: provider.authScheme + ? `${provider.authScheme} ${credential}` + : credential + }, + body: JSON.stringify({ model, input: "Reply with OK.", stream: false }) + }); + let responsePayload = null; + try { + responsePayload = await response.json(); + } catch { + responsePayload = null; + } + const validResponse = response.ok + && typeof responsePayload?.id === "string" + && responsePayload.id.length > 0 + && responsePayload?.object === "response" + && Array.isArray(responsePayload?.output); + const code = !response.ok + ? response.status === 401 || response.status === 403 + ? "PROVIDER_TEST_AUTH" + : "PROVIDER_TEST_HTTP" + : validResponse + ? null + : "PROVIDER_TEST_INVALID_RESPONSES"; + provider.lastTestAt = "2026-07-13T08:20:00.000Z"; + provider.lastTestStatus = code === null ? "passed" : "failed"; + provider.lastTestCode = code; + addActivity("provider", "test", id, code === null ? "success" : "failed", code); + let initialActivation = null; + if (code === null && activateIfNone && state.activeProviderId === null) { + state.activeProviderId = id; + initialActivation = { automatic: true, activeProviderId: id, workerStarted: false }; + addActivity("provider", "activate", id); + } + return { ok: code === null, code, initialActivation }; + }, + async activate(id) { + rejectNextMutation("activate"); + const provider = state.providers.find((item) => item.id === id); + if (!provider) throw new CrpError("PROVIDER_NOT_FOUND", "Missing provider.", "Refresh.", { status: 404 }); + if (provider.lastTestStatus !== "passed") { + throw new CrpError( + "PROVIDER_NOT_READY", + "Provider is not compatible.", + "Run a successful compatibility test.", + { status: 409 } + ); + } + state.activeProviderId = id; + state.generation += 1; + const workerStarted = state.worker.phase === "stopped"; + if (workerStarted) { + const pid = state.nextWorkerPid++; + state.worker = { + phase: "running", + pid, + generation: state.generation, + state: { + phase: "running", + configured: true, + generation: state.generation, + listening: true, + listenHost: "127.0.0.1", + listenPort: 15100, + inFlight: 0 + }, + restartCount: 0, + startedAt: "2026-07-13T08:25:00.000Z", + error: null + }; + } else { + state.worker.generation = state.generation; + if (state.worker.state) state.worker.state.generation = state.generation; + } + calls.push({ operation: "activate", id, generation: state.generation, workerStarted }); + addActivity("provider", "activate", id); + return { + activeProviderId: id, + activeProvider: structuredClone(provider), + generation: state.generation, + worker: structuredClone(state.worker) + }; + }, + async getStatus() { + calls.push({ operation: "getStatus" }); + return { + activeProviderId: state.activeProviderId, + activeProvider: structuredClone(currentProvider()), + generation: state.generation, + worker: structuredClone(state.worker) + }; + }, + async startProxy() { + rejectNextMutation("startProxy"); + if (!state.activeProviderId) { + throw new CrpError("PROXY_NOT_CONFIGURED", "No provider.", "Activate a provider.", { status: 409 }); + } + const pid = state.nextWorkerPid++; + state.worker = { + phase: "running", + pid, + generation: state.generation, + state: { + phase: "running", + configured: true, + generation: state.generation, + listening: true, + listenHost: "127.0.0.1", + listenPort: 15100, + inFlight: 0 + }, + restartCount: state.worker.restartCount ?? 0, + startedAt: "2026-07-13T08:25:00.000Z", + error: null + }; + calls.push({ operation: "startProxy", pid }); + addActivity("proxy", "start", state.activeProviderId); + return structuredClone(state.worker); + }, + async stopProxy() { + rejectNextMutation("stopProxy"); + state.worker = stoppedWorker(state.generation); + calls.push({ operation: "stopProxy" }); + addActivity("proxy", "stop", state.activeProviderId); + return structuredClone(state.worker); + }, + async restartProxy() { + rejectNextMutation("restartProxy"); + const priorPid = state.worker.pid; + const inFlight = state.worker.state?.inFlight ?? 0; + const restartCount = (state.worker.restartCount ?? 0) + 1; + const pid = state.nextWorkerPid++; + state.worker = { + ...state.worker, + phase: "running", + pid, + restartCount, + startedAt: "2026-07-13T08:40:00.000Z", + state: { + ...(state.worker.state ?? {}), + phase: "running", + configured: true, + generation: state.generation, + listening: true, + listenHost: "127.0.0.1", + listenPort: 15100, + inFlight: 0 + } + }; + calls.push({ operation: "restartProxy", priorPid, pid, inFlight }); + addActivity("proxy", "restart", state.activeProviderId); + return structuredClone(state.worker); + } + }; + + return { + state, + calls, + seedCredential(id, credential) { + credentials.set(id, credential); + }, + resetModelCatalogs() { + modelCatalogs.clear(); + }, + seedModelCatalog(id, input = {}) { + modelCatalogs.set(id, { + providerId: id, + state: input.state ?? "fresh", + fetchedAt: Object.hasOwn(input, "fetchedAt") ? input.fetchedAt : MODEL_CATALOG_FETCHED_AT, + expiresAt: Object.hasOwn(input, "expiresAt") ? input.expiresAt : MODEL_CATALOG_EXPIRES_AT, + models: structuredClone(input.models ?? ["gpt-5.1-codex-mini", "fixture-model"]) + }); + }, + providerService, + activityStore: { + list({ limit }) { + return structuredClone(state.activities.slice(0, limit)); + } + }, + metricsService: { + getOverview({ window }) { + calls.push({ operation: "getMetrics", window }); + const metrics = state.metricsByWindow.get(window) ?? state.metrics; + return structuredClone({ ...metrics, window }); + } + }, + requestSupervisorShutdown() { + calls.push({ + operation: "shutdownSupervisor", + supervisorPid: state.supervisorPid, + startedAt: state.supervisorStartedAt + }); + state.supervisorShutdownAccepted = true; + }, + settingsService: { + async getSettings() { + return { + proxyHost: "127.0.0.1", + proxyPort: 15100, + adminHost: "127.0.0.1", + adminPort: 15101, + captureEnabled: false, + credentialBackend: "native" + }; + } + }, + codexService: { + async bootstrap() { + state.bootstrapCount += 1; + state.codex = { + configured: true, + modelProvider: "OpenAI", + proxyUrl: "http://127.0.0.1:15100", + historyRepairPending: false + }; + calls.push({ operation: "bootstrap", count: state.bootstrapCount }); + return { + changed: state.bootstrapCount === 1, + backupPath: "/sanitized/backup", + historyRepair: { + required: false, + completed: false, + resumed: false, + backupCreated: false, + rolloutFiles: 0, + rolloutRecords: 0, + sqliteFiles: 0, + sqliteRows: 0, + encryptedContentDetected: false + } + }; + }, + async getStatus() { + return structuredClone(state.codex); + } + }, + diagnosticsService: { + async exportDiagnostics() { + rejectNextMutation("diagnostics"); + calls.push({ operation: "diagnostics" }); + state.diagnostics = { + created: true, + generatedAt: "2026-07-13T08:50:00.000Z", + eventCount: state.activities.length + }; + return structuredClone(state.diagnostics); + } + } + }; +} + +async function proveBackendHealth(origin, controlToken) { + const session = await fetch(`${origin}/api/v1/session`, { + method: "POST", + headers: { + authorization: `Bearer ${controlToken}`, + origin + } + }); + assert.equal(session.status, 200, "the injected session endpoint must be healthy"); + const cookie = session.headers.get("set-cookie")?.split(";", 1)[0]; + assert.ok(cookie, "the injected session endpoint must issue a cookie"); + const status = await fetch(`${origin}/api/v1/status`, { + headers: { cookie, origin } + }); + assert.equal(status.status, 200, "the injected Admin /api/v1/status must be healthy"); + const payload = await status.json(); + assert.equal(payload.supervisor.pid, 7001); + return payload; +} + +async function createMockUpstream() { + let status = 200; + let expectedRequest = null; + let responsePayload = { + id: "resp_fixture", + object: "response", + status: "completed", + output: [] + }; + const requests = []; + const server = http.createServer(async (request, response) => { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + const bodyBytes = Buffer.concat(chunks); + let body = null; + try { + body = JSON.parse(bodyBytes.toString("utf8")); + } catch { + body = null; + } + const expectation = expectedRequest; + expectedRequest = null; + const authHeader = expectation?.authHeader?.toLowerCase() ?? null; + const expectedAuthorization = expectation + ? expectation.authScheme + ? `${expectation.authScheme} ${expectation.credential}` + : expectation.credential + : null; + const credentialMatched = authHeader !== null + && request.headers[authHeader] === expectedAuthorization; + const extraHeadersMatched = expectation !== null + && Object.entries(expectation.extraHeaders ?? {}).every( + ([name, value]) => request.headers[name.toLowerCase()] === value + ); + const requestValid = expectation !== null + && request.method === "POST" + && request.url === expectation.path + && request.headers["content-type"] === "application/json" + && body?.model === expectation.model + && body?.input === "Reply with OK." + && body?.stream === false + && Object.keys(body ?? {}).sort().join(",") === "input,model,stream" + && credentialMatched + && extraHeadersMatched; + const successPayload = structuredClone(responsePayload); + const responseShapeValid = typeof successPayload?.id === "string" + && successPayload.id.length > 0 + && successPayload?.object === "response" + && Array.isArray(successPayload?.output); + requests.push({ + method: request.method, + path: request.url, + contentType: request.headers["content-type"] ?? null, + model: body?.model ?? null, + input: body?.input ?? null, + stream: body?.stream ?? null, + authHeader: expectation?.authHeader ?? null, + authScheme: expectation?.authScheme ?? null, + credentialMatched, + extraHeadersMatched, + requestValid, + responseShapeValid, + bodyLength: bodyBytes.length + }); + const responseStatus = requestValid ? status : 400; + const payload = responseStatus === 200 + ? successPayload + : { error: { code: "fixture_auth" } }; + const bytes = Buffer.from(JSON.stringify(payload)); + response.writeHead(responseStatus, { + "content-type": "application/json", + "content-length": bytes.length + }); + response.end(bytes); + }); + await new Promise((resolvePromise, rejectPromise) => { + server.once("error", rejectPromise); + server.listen(0, "127.0.0.1", () => { + server.off("error", rejectPromise); + resolvePromise(); + }); + }); + const address = server.address(); + const origin = `http://127.0.0.1:${address.port}`; + return { + origin, + requests, + expectRequest(next) { + assert.equal(expectedRequest, null, "the prior upstream expectation must be consumed"); + expectedRequest = structuredClone(next); + }, + getStatus: () => status, + setStatus: (next) => { status = next; }, + setResponsePayload: (next) => { responsePayload = structuredClone(next); }, + close: async () => await new Promise((resolvePromise, rejectPromise) => { + server.close((error) => error ? rejectPromise(error) : resolvePromise()); + }) + }; +} + +async function cleanupFixtureResources(resources, { throwOnError = true } = {}) { + const operations = [ + ["admin", async () => await resources.admin?.close()], + ["upstream", async () => await resources.upstream?.close()], + ["session", async () => resources.auth?.close()], + ["temp", async () => { + if (resources.tempRoot) rmSync(resources.tempRoot, { recursive: true, force: true }); + }] + ]; + const settled = await Promise.allSettled( + operations.map(([, operation]) => Promise.resolve().then(operation)) + ); + const errors = settled.flatMap((result, index) => result.status === "rejected" + ? [new Error(`${operations[index][0]} cleanup failed`, { cause: result.reason })] + : []); + resources.admin = null; + resources.upstream = null; + resources.auth = null; + if (throwOnError && errors.length > 0) throw new AggregateError(errors, "CRP fixture cleanup failed"); + return errors; +} + +export async function createFixtureHarness({ failAt = null, onResource = () => {} } = {}) { + const resources = { tempRoot: null, upstream: null, auth: null, admin: null }; + try { + resources.tempRoot = mkdtempSync(join(os.tmpdir(), "crp-ui-e2e-")); + onResource("tempRoot", resources.tempRoot); + const uiRoot = join(resources.tempRoot, "ui"); + mkdirSync(uiRoot, { recursive: true }); + for (const asset of ["index.html", "styles.css", "app.js"]) { + copyFileSync(join(REPO_UI_ROOT, asset), join(uiRoot, asset)); + } + const controlTokenPath = join(resources.tempRoot, "control-token"); + resources.upstream = await createMockUpstream(); + onResource("upstreamOrigin", resources.upstream.origin); + const credential = `crp-e2e-secret-${randomBytes(18).toString("base64url")}`; + let nowMs = Date.parse(STARTED_AT); + resources.auth = new SessionAuth({ + controlTokenPath, + now: () => nowMs, + sessionTtlMs: 60 * 60 * 1_000 + }); + const services = createServices({ upstream: resources.upstream }); + resources.admin = createAdminServer({ + auth: resources.auth, + ...services, + getSupervisorState: () => ({ + pid: services.state.supervisorPid, + startedAt: services.state.supervisorStartedAt + }), + uiDir: uiRoot, + host: "127.0.0.1", + port: 0 + }); + const address = await resources.admin.listen(); + onResource("adminOrigin", address.origin); + if (failAt === "after-admin-listen") { + const error = new Error("controlled fixture setup failure"); + error.code = "FIXTURE_SETUP_INJECTED"; + throw error; + } + const controlToken = readFileSync(controlTokenPath, "utf8").trim(); + const backendStatus = await proveBackendHealth(address.origin, controlToken); + const secrets = new Set([credential, controlToken]); + const attachmentPaths = new Set(); + const harness = { + origin: address.origin, + uiRoot, + upstreamOrigin: resources.upstream.origin, + upstreamBaseUrl: `${resources.upstream.origin}/v1`, + upstreamRequests: resources.upstream.requests, + credential, + controlToken, + backendStatus, + calls: services.calls, + state: services.state, + secrets, + attachmentPaths, + collectors: null, + registerSecret(secret) { + secrets.add(secret); + return secret; + }, + registerAttachment(path) { + attachmentPaths.add(path); + }, + fixtureSnapshot() { + return { + state: services.state, + calls: services.calls, + upstreamRequests: resources.upstream.requests, + diagnostics: services.state.diagnostics + }; + }, + failNextMutation({ code, status, details = {} }) { + services.state.nextMutationError = { code, status, details }; + }, + replaceSupervisorIdentity({ pid, startedAt }) { + services.state.supervisorPid = pid; + services.state.supervisorStartedAt = startedAt; + }, + seedProviders({ providers, activeProviderId = null, generation = 4 } = {}) { + services.resetModelCatalogs(); + services.state.providers = providers.map((provider, index) => publicProvider({ + baseUrl: `${resources.upstream.origin}/v1`, + ...provider + }, index)); + for (const provider of services.state.providers) { + if (provider.credentialConfigured) services.seedCredential(provider.id, "seed-credential"); + } + services.state.activeProviderId = activeProviderId; + services.state.generation = generation; + services.state.codex = { + configured: true, + modelProvider: "OpenAI", + proxyUrl: "http://127.0.0.1:15100", + historyRepairPending: false + }; + services.state.worker = stoppedWorker(generation); + if (activeProviderId) { + services.state.worker = { + phase: "running", + pid: services.state.nextWorkerPid++, + generation, + state: { + phase: "running", + configured: true, + generation, + listening: true, + listenHost: "127.0.0.1", + listenPort: 15100, + inFlight: 0 + }, + restartCount: 0, + startedAt: STARTED_AT, + error: null + }; + } + const primary = services.state.providers[0]?.id ?? "unknown"; + const secondary = services.state.providers[1]?.id ?? null; + const providerMetrics = [ + { + providerId: primary, + requests: secondary ? 82 : 128, + successfulRequests: secondary ? 78 : 119, + tokens: { + input: secondary ? 530000 : 842000, + output: secondary ? 142000 : 214000, + observedRequests: secondary ? 64 : 96 + }, + latency: { p50UpperBoundMs: 1000, p95UpperBoundMs: 2500, overflowRequests: 0 } + }, + ...(secondary ? [{ + providerId: secondary, + requests: 46, + successfulRequests: 41, + tokens: { input: 312000, output: 72000, observedRequests: 32 }, + latency: { p50UpperBoundMs: 1000, p95UpperBoundMs: 5000, overflowRequests: 0 } + }] : []) + ]; + services.state.metrics = { + ...emptyMetrics(), + summary: { + requests: 128, + results: { + success: 119, + upstreamRejected: 3, + upstreamError: 2, + timeout: 1, + networkError: 1, + clientAbort: 2 + }, + tokens: { input: 842000, output: 214000, observedRequests: 96 }, + latency: { p50UpperBoundMs: 1000, p95UpperBoundMs: 5000, overflowRequests: 0 }, + responseStart: { p50UpperBoundMs: 250, p95UpperBoundMs: 1000, overflowRequests: 0 } + }, + series: Array.from({ length: 24 }, (_, index) => ({ + start: new Date(Date.parse(STARTED_AT) + index * 60 * 60 * 1_000).toISOString(), + requests: 3 + (index % 7), + results: { + success: 3 + (index % 5), + upstreamRejected: index % 6 === 0 ? 1 : 0, + upstreamError: index % 8 === 0 ? 1 : 0, + timeout: 0, + networkError: 0, + clientAbort: index % 11 === 0 ? 1 : 0 + }, + tokens: { + input: 12000 + index * 900, + output: 4000 + index * 300, + observedRequests: 3 + (index % 4) + } + })), + providers: providerMetrics, + models: [ + { + model: "gpt-5.1-codex-mini", + requests: 88, + tokens: { input: 588000, output: 151000, observedRequests: 67 } + }, + { + model: "gpt-4.1", + requests: 40, + tokens: { input: 254000, output: 63000, observedRequests: 29 } + } + ] + }; + services.state.metricsByWindow.clear(); + }, + emptyMetrics(window = "24h") { + return structuredClone(emptyMetrics(window)); + }, + setMetrics(metrics, { window = null } = {}) { + const next = structuredClone(metrics); + if (window === null) { + services.state.metrics = next; + services.state.metricsByWindow.clear(); + return; + } + assert.ok(window === "24h" || window === "7d", "metrics window must be supported"); + services.state.metricsByWindow.set(window, next); + }, + seedProviderModels(id, input = {}) { + assert.ok(services.state.providers.some((provider) => provider.id === id), "provider must exist"); + services.seedModelCatalog(id, input); + }, + setInFlight(count) { + assert.ok(services.state.worker.state, "a running worker is required"); + services.state.worker.state.inFlight = count; + }, + failProviderTestsWith(code) { + resources.upstream.setStatus(code === "PROVIDER_TEST_AUTH" ? 401 : 503); + }, + passProviderTests() { + resources.upstream.setStatus(200); + }, + setUpstreamResponsePayload(payload) { + resources.upstream.setResponsePayload(payload); + }, + async rotateBrowserSession(page) { + return await page.evaluate(async (token) => { + const response = await fetch("/api/v1/session", { + method: "POST", + headers: { authorization: `Bearer ${token}` }, + credentials: "same-origin" + }); + const payload = await response.json(); + return { + status: response.status, + csrfTokenLength: typeof payload.csrfToken === "string" ? payload.csrfToken.length : 0 + }; + }, controlToken); + }, + seedActivity(count) { + const templates = [ + { category: "proxy", action: "start" }, + { category: "provider", action: "test" }, + { category: "migration", action: "legacy-config" }, + { category: "proxy", action: "stop" }, + { category: "proxy", action: "restart" } + ]; + services.state.activities = Array.from({ length: count }, (_, index) => { + const template = templates[index % templates.length]; + const result = template.category === "migration" + ? "failed" + : ["success", "failed", "degraded"][index % 3]; + const rollbackDegraded = template.category === "migration"; + return { + timestamp: new Date(Date.parse(STARTED_AT) + index * 1_000).toISOString(), + ...template, + providerId: services.state.providers[index % Math.max(1, services.state.providers.length)]?.id ?? null, + result, + errorCode: rollbackDegraded + ? "MIGRATION_ROLLBACK_DEGRADED" + : result === "success" + ? null + : template.category === "provider" + ? "PROVIDER_TEST_TIMEOUT" + : `${template.category.toUpperCase()}_${template.action.toUpperCase()}_${result.toUpperCase()}`, + details: rollbackDegraded ? { index, rollbackDegraded: true } : { index } + }; + }).reverse(); + }, + expireSession() { + nowMs += 60 * 60 * 1_000 + 1; + } + }; + return { + harness, + cleanup: async () => await cleanupFixtureResources(resources) + }; + } catch (error) { + const cleanupErrors = await cleanupFixtureResources(resources, { throwOnError: false }); + error.cleanupErrors = cleanupErrors; + if (cleanupErrors.length > 0) { + throw new AggregateError([error, ...cleanupErrors], "CRP fixture setup and cleanup failed"); + } + throw error; + } +} + +function redactCollectedValue(value, key = "") { + if (/^(authorization|apiKey|credential|replacementCredential|csrfToken)$/i.test(key)) return "[redacted]"; + if (Array.isArray(value)) return value.map((item) => redactCollectedValue(item)); + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value).map( + ([childKey, childValue]) => [childKey, redactCollectedValue(childValue, childKey)] + )); + } + return value; +} + +function sanitizeCollectedBody(body) { + if (body === null || body === undefined || body.length === 0) return null; + try { + return JSON.stringify(redactCollectedValue(JSON.parse(body))); + } catch { + return body; + } +} + +export const test = base.extend({ + crp: async ({}, use) => { + const fixture = await createFixtureHarness(); + try { + await use(fixture.harness); + } finally { + await fixture.cleanup(); + } + }, + _secretCollectors: [async ({ page, crp }, use) => { + const records = []; + const rawResponses = []; + const sessionSecrets = new Set(); + const pending = new Set(); + const track = (promise) => { + pending.add(promise); + void promise.finally(() => pending.delete(promise)); + }; + const onConsole = (message) => records.push({ + type: "console", + level: message.type(), + text: message.text() + }); + const onPageError = (error) => records.push({ type: "pageerror", text: error.stack ?? error.message }); + const onRequest = (request) => { + const url = new URL(request.url()); + if (!url.pathname.startsWith("/api/v1/")) return; + records.push({ + type: "request", + method: request.method(), + url: `${url.pathname}${url.search}`, + body: sanitizeCollectedBody(request.postData()) + }); + }; + const onResponse = (response) => { + const url = new URL(response.url()); + if (!url.pathname.startsWith("/api/v1/")) return; + const task = response.body().then((bytes) => { + const rawBytes = Buffer.from(bytes); + const allowedSecrets = new Set(); + if ((url.pathname === "/api/v1/session" || url.pathname === "/api/v1/session/resume") + && response.status() === 200) { + try { + const payload = JSON.parse(rawBytes.toString("utf8")); + if (typeof payload?.csrfToken === "string" && payload.csrfToken.length > 0) { + allowedSecrets.add(payload.csrfToken); + sessionSecrets.add(payload.csrfToken); + } + } catch { + // Invalid session JSON is not an expected secret-bearing response. + } + } + rawResponses.push({ bytes: rawBytes, allowedSecrets }); + records.push({ + type: "response", + status: response.status(), + url: `${url.pathname}${url.search}`, + body: sanitizeCollectedBody(rawBytes.toString("utf8")) + }); + }).catch(() => { + records.push({ type: "response", status: response.status(), url: url.pathname, body: null }); + }); + track(task); + }; + page.on("console", onConsole); + page.on("pageerror", onPageError); + page.on("request", onRequest); + page.on("response", onResponse); + crp.collectors = { + records, + sensitiveSecrets() { + return [...sessionSecrets]; + }, + hasUnexpectedRawResponseSecret(secret) { + const bytes = Buffer.from(secret, "utf8"); + return rawResponses.some((response) => ( + !response.allowedSecrets.has(secret) && response.bytes.includes(bytes) + )); + }, + async flush() { + while (pending.size > 0) await Promise.allSettled([...pending]); + }, + unexpectedClientErrors() { + return records.filter((record) => record.type === "pageerror" && record.text.trim().length > 0 + || record.type === "console" && record.level === "error" + && record.text.trim().length > 0 + && !record.text.startsWith("Failed to load resource:")); + } + }; + try { + await use(); + } finally { + await crp.collectors.flush(); + await assertNoSecrets(page, crp); + expect(crp.collectors.unexpectedClientErrors()).toEqual([]); + page.off("console", onConsole); + page.off("pageerror", onPageError); + page.off("request", onRequest); + page.off("response", onResponse); + } + }, { auto: true }] +}); + +async function endpointReachable(origin) { + if (!origin) return false; + try { + await fetch(origin, { signal: AbortSignal.timeout(500) }); + return true; + } catch { + return false; + } +} + +export async function probeFixtureSetupCleanup() { + const observed = {}; + let cleanupErrors = []; + try { + await createFixtureHarness({ + failAt: "after-admin-listen", + onResource: (name, value) => { observed[name] = value; } + }); + assert.fail("the controlled setup failure must throw"); + } catch (error) { + assert.equal(error.code, "FIXTURE_SETUP_INJECTED"); + cleanupErrors = error.cleanupErrors ?? []; + } + return { + tempRootExists: existsSync(observed.tempRoot), + adminReachable: await endpointReachable(observed.adminOrigin), + upstreamReachable: await endpointReachable(observed.upstreamOrigin), + cleanupErrors: cleanupErrors.length + }; +} + +export { expect }; + +export async function openCrp(page, crp) { + await page.goto(`${crp.origin}/#token=${crp.controlToken}`); + await expect(page.locator("html")).toHaveAttribute("aria-busy", "false"); +} + +export async function assertNoSecrets(page, crp, extra = []) { + await crp.collectors?.flush(); + const snapshot = await page.locator("html").evaluate(async (element) => { + const readStorage = (storageName) => { + try { + const storage = window[storageName]; + return Object.fromEntries( + Array.from({ length: storage.length }, (_, index) => { + const key = storage.key(index); + return [key, storage.getItem(key)]; + }) + ); + } catch { + return null; + } + }; + let indexedDbNames = null; + try { + indexedDbNames = typeof indexedDB.databases === "function" + ? (await indexedDB.databases()).map((database) => database.name) + : []; + } catch { + indexedDbNames = null; + } + return { + html: element.outerHTML, + text: element.textContent, + inputValues: Array.from(document.querySelectorAll("input, textarea, select"), (input) => input.value), + localStorage: readStorage("localStorage"), + sessionStorage: readStorage("sessionStorage"), + indexedDbNames, + url: location.href, + historyState: history.state, + resources: performance.getEntriesByType("resource").map((entry) => entry.name), + historyLength: history.length, + canvasCount: document.querySelectorAll("canvas").length + }; + }); + const deepSnapshot = { + browser: snapshot, + collectors: crp.collectors?.records ?? [], + fixture: crp.fixtureSnapshot() + }; + const serialized = JSON.stringify(deepSnapshot); + const secrets = new Set([ + ...crp.secrets, + ...extra, + ...(crp.collectors?.sensitiveSecrets() ?? []) + ].filter(Boolean)); + if ([...secrets].some((secret) => crp.collectors?.hasUnexpectedRawResponseSecret(secret))) { + throw new Error("Raw API response contained sensitive data outside the session exchange."); + } + for (const secret of secrets) { + if (!secret) continue; + if (serialized.includes(secret)) { + throw new Error("Rendered or recorded state contained sensitive data."); + } + for (const attachmentPath of crp.attachmentPaths) { + if (readFileSync(attachmentPath).includes(Buffer.from(secret))) { + throw new Error("A test attachment contained sensitive data."); + } + } + } + if (snapshot.localStorage !== null) { + expect(Object.keys(snapshot.localStorage).every((key) => key === "crp.locale")).toBe(true); + expect(Object.keys(snapshot.localStorage).length).toBeLessThanOrEqual(1); + } + if (snapshot.sessionStorage !== null) expect(Object.keys(snapshot.sessionStorage)).toHaveLength(0); + if (snapshot.indexedDbNames !== null) expect(snapshot.indexedDbNames).toHaveLength(0); + expect(snapshot.canvasCount).toBe(0); +} + +export async function assertLayoutIntegrity(page) { + const result = await page.evaluate(() => { + const viewport = { width: innerWidth, height: innerHeight }; + const candidates = Array.from(document.querySelectorAll( + "h1, h2, h3, button, input, select, textarea, label, .setup-eyebrow, " + + ".page-header > *, .runtime-facts > *, .metric-card, .section-heading > *, " + + ".provider-card-header > *, .provider-card-actions > *, .activity-event summary > *, " + + ".setup-progress li, .setup-stage-actions > *, .pagination > *, .topbar-actions > *, " + + ".modal-footer > *" + )).filter((element) => { + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + const sidebar = element.closest(".sidebar"); + return !element.closest(".visually-hidden") + && !element.closest("[hidden]") + && !element.closest("dialog:not([open])") + && !(innerWidth <= 840 && sidebar && !sidebar.classList.contains("sidebar-open")) + && style.visibility !== "hidden" && style.display !== "none" + && rect.width > 0 && rect.height > 0; + }); + const clipped = candidates.filter((element) => { + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + const overflowSensitive = ["BUTTON", "INPUT", "SELECT", "TEXTAREA"].includes(element.tagName) + || [style.overflowX, style.overflowY].some((value) => value === "hidden" || value === "clip"); + return rect.left < -0.5 || rect.right > viewport.width + 0.5 + || overflowSensitive && ( + element.scrollWidth > element.clientWidth + 1 + || element.scrollHeight > element.clientHeight + 1 + ); + }).map((element) => { + const rect = element.getBoundingClientRect(); + return { + text: element.textContent.trim().slice(0, 80), + tag: element.tagName, + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + clientHeight: element.clientHeight, + scrollHeight: element.scrollHeight, + left: Math.round(rect.left), + right: Math.round(rect.right) + }; + }); + const overlaps = []; + for (let leftIndex = 0; leftIndex < candidates.length; leftIndex += 1) { + for (let rightIndex = leftIndex + 1; rightIndex < candidates.length; rightIndex += 1) { + const left = candidates[leftIndex]; + const right = candidates[rightIndex]; + if (left.contains(right) || right.contains(left) || left.parentElement !== right.parentElement) continue; + const a = left.getBoundingClientRect(); + const b = right.getBoundingClientRect(); + if (Math.min(a.right, b.right) - Math.max(a.left, b.left) > 1 + && Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top) > 1) { + overlaps.push(`${left.textContent.trim().slice(0, 40)} <> ${right.textContent.trim().slice(0, 40)}`); + } + } + } + const overflowSources = Array.from(document.querySelectorAll("body *")).filter((element) => { + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + const sidebar = element.closest(".sidebar"); + return style.display !== "none" && style.visibility !== "hidden" + && !element.closest(".visually-hidden") + && !element.closest("dialog:not([open])") + && !element.closest(".table-scroll") + && !(innerWidth <= 840 && sidebar && !sidebar.classList.contains("sidebar-open")) + && rect.width > 0 && (rect.left < -0.5 || rect.right > innerWidth + 0.5); + }).slice(0, 12).map((element) => { + const rect = element.getBoundingClientRect(); + return { + tag: element.tagName, + className: typeof element.className === "string" ? element.className : "", + text: element.textContent.trim().slice(0, 60), + left: Math.round(rect.left), + right: Math.round(rect.right), + width: Math.round(rect.width) + }; + }); + return { + documentOverflow: document.documentElement.scrollWidth > innerWidth, + clipped, + overlaps, + overflowSources + }; + }); + expect(result).toEqual({ + documentOverflow: false, + clipped: [], + overlaps: [], + overflowSources: [] + }); +} diff --git a/node/test/e2e/onboarding.spec.mjs b/node/test/e2e/onboarding.spec.mjs new file mode 100644 index 0000000..f8eb9b9 --- /dev/null +++ b/node/test/e2e/onboarding.spec.mjs @@ -0,0 +1,389 @@ +import { randomBytes } from "node:crypto"; + +import { + assertNoSecrets, + expect, + openCrp, + probeFixtureSetupCleanup, + test +} from "./crp-ui-fixture.mjs"; + +const EN = { + pageTitle: "Set up Codex Remote Proxy", + connect: "Connect a provider", + name: "Provider name", + url: "Base URL", + key: "API key", + save: "Save provider", + testStage: "Verify the Responses API", + model: "Test model", + runTest: "Run test", + codexStage: "Prepare the fixed Codex connection", + prepare: "Prepare Codex", + prepareDialog: "Prepare Codex?", + workerStage: "Start the proxy worker", + start: "Start proxy", + complete: "Setup complete", + overview: "Open overview", + overviewTitle: "Overview", + ready: "Codex is securely connected" +}; + +const ZH = { + pageTitle: "设置 Codex Remote Proxy", + connect: "连接提供商", + name: "提供商名称", + url: "基础地址", + key: "API 密钥", + save: "保存提供商", + testStage: "验证 Responses API", + model: "测试模型", + runTest: "运行测试", + codexStage: "配置固定的 Codex 连接", + prepare: "配置 Codex", + prepareDialog: "配置 Codex?", + workerStage: "启动代理工作进程", + start: "启动代理", + complete: "设置完成", + overview: "打开概览", + overviewTitle: "概览", + ready: "Codex 已安全接入本地代理" +}; + +function observePasswordValues(page) { + return page.evaluate(() => { + window.__passwordSnapshots = []; + const capture = () => { + window.__passwordSnapshots.push( + Array.from(document.querySelectorAll("input[type=password]"), (input) => input.value) + ); + }; + const observer = new MutationObserver(capture); + observer.observe(document, { childList: true, subtree: true, attributes: true }); + window.__stopPasswordSnapshots = () => { + observer.disconnect(); + return window.__passwordSnapshots; + }; + }); +} + +async function stopPasswordObserver(page) { + return await page.evaluate(() => window.__stopPasswordSnapshots?.() ?? []); +} + +async function completeSetup(page, crp, copy, providerName) { + await expect(page.getByRole("heading", { name: copy.pageTitle, level: 1 })).toBeVisible(); + await expect(page.getByRole("heading", { name: copy.connect, level: 2 })).toBeVisible(); + await page.getByLabel(copy.name).fill(providerName); + await page.getByLabel(copy.url).fill(crp.upstreamBaseUrl); + await page.getByLabel(copy.key).fill(crp.credential); + await page.getByRole("button", { name: copy.save }).click(); + + await expect(page.getByRole("heading", { name: copy.testStage })).toBeVisible(); + await page.getByLabel(copy.model).fill("gpt-5.1-codex-mini"); + await page.getByRole("button", { name: copy.runTest, exact: true }).click(); + + await expect(page.getByRole("heading", { name: copy.codexStage })).toBeVisible(); + await page.getByRole("button", { name: copy.prepare, exact: true }).click(); + const prepareDialog = page.getByRole("dialog", { name: copy.prepareDialog }); + await expect(prepareDialog).toBeVisible(); + await prepareDialog.getByRole("button", { name: copy.prepare, exact: true }).click(); + + await expect(page.getByRole("heading", { name: copy.workerStage })).toBeVisible(); + await page.getByTestId("page-setup").getByRole("button", { name: copy.start, exact: true }).click(); + + await expect(page.getByRole("heading", { name: copy.complete })).toBeVisible(); + await page.getByRole("button", { name: copy.overview }).click(); + await expect(page.getByRole("heading", { name: copy.overviewTitle, level: 1 })).toBeVisible(); + await expect(page.getByRole("heading", { name: copy.ready })).toBeVisible(); +} + +test("the injected Admin status is healthy before the UI loads", async ({ crp }) => { + expect(crp.backendStatus.supervisor.pid).toBe(7001); + expect(crp.backendStatus.activeProviderId).toBeNull(); +}); + +test("shows every refreshed Setup model and tests a non-first selection", async ({ page, crp }) => { + await openCrp(page, crp); + await page.getByLabel(EN.name).fill("Catalog Provider"); + await page.getByLabel(EN.url).fill(crp.upstreamBaseUrl); + await page.getByLabel(EN.key).fill(crp.credential); + await page.getByRole("button", { name: EN.save }).click(); + + const refreshButton = page.getByRole("button", { name: "Refresh models" }); + await refreshButton.click(); + const modelSelect = page.getByLabel(EN.model); + await expect(modelSelect).toHaveJSProperty("tagName", "SELECT"); + await expect(modelSelect.locator("option")).toHaveCount(3); + await expect(modelSelect.locator("option").nth(0)).toHaveText("gpt-5.1-codex-mini"); + await expect(modelSelect.locator("option").nth(1)).toHaveText("fixture-model"); + const [modelBox, refreshBox] = await Promise.all([modelSelect.boundingBox(), refreshButton.boundingBox()]); + expect(modelBox).not.toBeNull(); + expect(refreshBox).not.toBeNull(); + expect(Math.abs(modelBox.y - refreshBox.y)).toBeLessThanOrEqual(1); + await modelSelect.selectOption("fixture-model"); + await page.getByRole("button", { name: EN.runTest, exact: true }).click(); + + expect(crp.calls.findLast((call) => call.operation === "testProvider")).toMatchObject({ + model: "fixture-model", + activateIfNone: true + }); + expect(crp.upstreamRequests.at(-1)).toMatchObject({ model: "fixture-model", requestValid: true }); + await assertNoSecrets(page, crp); +}); + +test("completes a clean English install with the exact safe request order", async ({ page, crp }) => { + const requests = []; + page.on("request", (request) => { + const url = new URL(request.url()); + if (!url.pathname.startsWith("/api/v1/")) return; + requests.push({ + path: url.pathname, + search: url.search, + method: request.method(), + body: request.postData(), + headers: request.headers() + }); + }); + + await openCrp(page, crp); + await expect(page).not.toHaveURL(/#token=/); + await expect(page).toHaveURL(`${crp.origin}/setup`); + await expect(page).toHaveTitle("Set up Codex Remote Proxy | CRP"); + await expect(page.locator("input[name='fallbackConsent']")).toHaveCount(0); + + await completeSetup(page, crp, EN, "Primary OpenAI"); + + const operationOrder = crp.calls + .map((call) => call.operation) + .filter((operation) => ["createProvider", "testProvider", "bootstrap", "activate", "startProxy"].includes(operation)); + expect(operationOrder).toEqual([ + "createProvider", + "testProvider", + "bootstrap", + "startProxy" + ]); + + const create = requests.find((request) => request.path === "/api/v1/providers" && request.method === "POST"); + expect(create).toBeDefined(); + const createBody = JSON.parse(create.body); + const submittedCredential = createBody.credential; + delete createBody.credential; + expect(typeof submittedCredential).toBe("string"); + expect(submittedCredential.length).toBe(crp.credential.length); + expect(createBody).toEqual({ + provider: { + name: "Primary OpenAI", + baseUrl: crp.upstreamBaseUrl, + authHeader: "authorization", + authScheme: "Bearer", + extraHeaders: {}, + modelMode: "passthrough", + modelOverride: null + } + }); + + const providerTest = requests.find((request) => request.path === "/api/v1/providers/provider-1/test"); + expect(JSON.parse(providerTest.body)).toEqual({ + model: "gpt-5.1-codex-mini", + activateIfNone: true + }); + const orderedPaths = requests.filter((request) => request.method !== "GET" && [ + "/api/v1/providers", + "/api/v1/providers/provider-1/test", + "/api/v1/codex/bootstrap", + "/api/v1/proxy/start" + ].includes(request.path)).map((request) => request.path); + expect(orderedPaths).toEqual([ + "/api/v1/providers", + "/api/v1/providers/provider-1/test", + "/api/v1/codex/bootstrap", + "/api/v1/proxy/start" + ]); + + for (const path of [ + "/api/v1/codex/bootstrap", + "/api/v1/proxy/start" + ]) { + const request = requests.find((candidate) => candidate.path === path); + expect(request.body, `${path} must remain empty`).toBeNull(); + expect(request.headers["content-type"]).toBeUndefined(); + } + + const sessions = requests.filter((request) => request.path === "/api/v1/session"); + expect(sessions).toHaveLength(1); + expect(sessions[0].body).toBeNull(); + expect(typeof sessions[0].headers.authorization).toBe("string"); + expect(sessions[0].headers.authorization.length).toBe("Bearer ".length + 43); + expect(requests.filter((request) => request.path !== "/api/v1/session") + .every((request) => request.headers.authorization === undefined)).toBe(true); + expect(crp.state.activeProviderId).toBe("provider-1"); + expect(crp.calls.filter((call) => call.operation === "activate")).toHaveLength(0); + expect(crp.state.bootstrapCount).toBe(1); + expect(crp.state.worker.phase).toBe("running"); + expect(crp.upstreamRequests).toHaveLength(1); + expect(crp.upstreamRequests[0]).toMatchObject({ + path: "/v1/responses", + model: "gpt-5.1-codex-mini", + requestValid: true, + credentialMatched: true, + responseShapeValid: true + }); + await assertNoSecrets(page, crp); +}); + +test("completes the entire clean-install flow in Simplified Chinese", async ({ page, crp }) => { + await openCrp(page, crp); + await page.locator("#locale-select").selectOption("zh-CN"); + await expect(page.locator("html")).toHaveAttribute("lang", "zh-CN"); + await completeSetup(page, crp, ZH, "中文提供商"); + expect(crp.calls.map((call) => call.operation).filter((operation) => ( + ["createProvider", "testProvider", "bootstrap", "activate", "startProxy"].includes(operation) + ))).toEqual(["createProvider", "testProvider", "bootstrap", "startProxy"]); + expect(await page.evaluate(() => localStorage.getItem("crp.locale"))).toBe("zh-CN"); + await assertNoSecrets(page, crp); +}); + +test("retests and selects an existing eligible provider through compare-and-set", async ({ page, crp }) => { + crp.seedProviders({ + providers: [{ + id: "provider-existing", + name: "Existing Provider", + baseUrl: crp.upstreamBaseUrl, + lastTestStatus: "passed" + }], + activeProviderId: null + }); + const testRequests = []; + page.on("request", (request) => { + const path = new URL(request.url()).pathname; + if (path.endsWith("/test")) testRequests.push(request.postDataJSON()); + }); + await openCrp(page, crp); + await expect(page.getByRole("heading", { name: "Confirm the current provider" })).toBeVisible(); + await page.getByLabel("Test model").fill("gpt-5.1-codex-mini"); + await page.getByRole("button", { name: "Test and select" }).click(); + await expect(page.getByRole("heading", { name: "Start the proxy worker" })).toBeVisible(); + expect(testRequests).toEqual([{ model: "gpt-5.1-codex-mini", activateIfNone: true }]); + expect(crp.state.activeProviderId).toBe("provider-existing"); + expect(crp.state.worker.phase).toBe("stopped"); + expect(crp.calls.filter((call) => call.operation === "activate")).toHaveLength(0); + await assertNoSecrets(page, crp); +}); + +test("clears provider secrets before both local validation and backend failure rendering", async ({ page, crp }) => { + const localSecret = crp.registerSecret(`local-${randomBytes(18).toString("base64url")}`); + const backendSecret = crp.registerSecret(`backend-${randomBytes(18).toString("base64url")}`); + await openCrp(page, crp); + await page.getByLabel("Provider name").fill("Rejected Provider"); + await page.getByLabel("Base URL").fill(crp.upstreamBaseUrl); + await page.getByLabel("Advanced provider settings").check(); + await page.getByLabel("Extra headers (JSON)").fill("{invalid-json"); + await page.getByLabel("API key").fill(localSecret); + await observePasswordValues(page); + await page.getByRole("button", { name: "Save provider" }).click(); + await expect(page.getByRole("alert")).toBeVisible(); + await expect(page.getByLabel("API key")).toHaveValue(""); + const localSnapshots = await stopPasswordObserver(page); + expect(JSON.stringify(localSnapshots)).not.toContain(localSecret); + expect(crp.calls.filter((call) => call.operation === "createProvider")).toHaveLength(0); + + crp.failNextMutation({ code: "PROVIDER_DUPLICATE", status: 409 }); + await page.getByLabel("Extra headers (JSON)").fill("{}"); + await page.getByLabel("API key").fill(backendSecret); + await observePasswordValues(page); + await page.getByRole("button", { name: "Save provider" }).click(); + await expect(page.getByRole("alert")).toBeVisible(); + await expect(page.getByLabel("API key")).toHaveValue(""); + const backendSnapshots = await stopPasswordObserver(page); + expect(JSON.stringify(backendSnapshots)).not.toContain(backendSecret); + expect(crp.state.providers).toHaveLength(0); + await assertNoSecrets(page, crp, [localSecret, backendSecret]); +}); + +test("defaults to English even when the browser prefers Chinese", async ({ page, crp }) => { + await page.addInitScript(() => { + Object.defineProperty(navigator, "languages", { + configurable: true, + get: () => ["fr-FR", "zh-CN", "en-US"] + }); + }); + await openCrp(page, crp); + await expect(page.locator("html")).toHaveAttribute("lang", "en"); + await expect(page.getByRole("heading", { name: "Set up Codex Remote Proxy", level: 1 })).toBeVisible(); + expect(await page.evaluate(() => localStorage.length)).toBe(0); + const noscriptCopy = await page.locator("noscript").evaluate((element) => element.innerHTML); + expect(noscriptCopy).toContain("CRP requires JavaScript"); + expect(noscriptCopy).toContain("CRP 需要启用 JavaScript"); +}); + +test("falls back to English for unsupported locales without persistence", async ({ page, crp }) => { + await page.addInitScript(() => { + Object.defineProperty(navigator, "languages", { + configurable: true, + get: () => ["fr-FR", "de-DE", "ja-JP"] + }); + Object.defineProperty(navigator, "language", { + configurable: true, + get: () => "fr-FR" + }); + }); + await openCrp(page, crp); + await expect(page.locator("html")).toHaveAttribute("lang", "en"); + await expect(page.getByRole("heading", { name: "Set up Codex Remote Proxy", level: 1 })).toBeVisible(); + await expect(page.locator(".setup-eyebrow")).toHaveText("First run"); + expect(await page.evaluate(() => localStorage.length)).toBe(0); +}); + +test("persists an explicit Chinese selection across a read-only reload", async ({ page, crp }) => { + await page.addInitScript(() => { + Object.defineProperty(navigator, "languages", { + configurable: true, + get: () => ["zh-CN", "en-US"] + }); + }); + await openCrp(page, crp); + await expect(page.locator("html")).toHaveAttribute("lang", "en"); + await page.locator("#locale-select").selectOption("zh-CN"); + await expect(page.locator("html")).toHaveAttribute("lang", "zh-CN"); + expect(await page.evaluate(() => localStorage.getItem("crp.locale"))).toBe("zh-CN"); + await page.reload(); + await expect(page.locator("html")).toHaveAttribute("aria-busy", "false"); + await expect(page.locator("html")).toHaveAttribute("lang", "zh-CN"); + await expect(page.locator("#session-banner")).toContainText("只读会话"); + expect(await page.evaluate(() => localStorage.getItem("crp.locale"))).toBe("zh-CN"); +}); + +test("removes an invalid locale and keeps a fragmentless cookie session GET-only", async ({ page, crp }) => { + await page.addInitScript(() => localStorage.setItem("crp.locale", "invalid-locale")); + await openCrp(page, crp); + expect(await page.evaluate(() => localStorage.length)).toBe(0); + + await page.reload(); + await expect(page.locator("html")).toHaveAttribute("aria-busy", "false"); + await expect(page.locator("#app-root")).toBeVisible(); + await expect(page.locator("#session-banner")).toContainText("Read-only session"); + await expect(page.getByLabel("Provider name")).toBeDisabled(); + await expect(page.getByLabel("Base URL")).toBeDisabled(); + await expect(page.getByLabel("API key")).toBeDisabled(); + await expect(page.getByLabel("Advanced provider settings")).toBeDisabled(); + await expect(page.getByRole("button", { name: "Save provider" })).toBeDisabled(); + const mutations = []; + page.on("request", (request) => { + if (new URL(request.url()).pathname.startsWith("/api/v1/") + && !["GET", "HEAD"].includes(request.method())) mutations.push(request.method()); + }); + await page.getByRole("button", { name: "Refresh status" }).click(); + expect(mutations).toEqual([]); + expect(crp.state.providers).toHaveLength(0); + await assertNoSecrets(page, crp); +}); + +test("cleans every fixture resource after a controlled post-listen setup failure", async () => { + expect(await probeFixtureSetupCleanup()).toEqual({ + tempRootExists: false, + adminReachable: false, + upstreamReachable: false, + cleanupErrors: 0 + }); +}); diff --git a/node/test/e2e/provider-switch.spec.mjs b/node/test/e2e/provider-switch.spec.mjs new file mode 100644 index 0000000..9c7f597 --- /dev/null +++ b/node/test/e2e/provider-switch.spec.mjs @@ -0,0 +1,483 @@ +import { randomBytes } from "node:crypto"; + +import { + assertLayoutIntegrity, + assertNoSecrets, + expect, + openCrp, + test +} from "./crp-ui-fixture.mjs"; + +async function collectSubmittedPasswordSnapshots(page) { + await page.evaluate(() => { + window.__passwordSnapshots = []; + const capture = () => { + window.__passwordSnapshots.push( + Array.from(document.querySelectorAll("input[type=password]"), (input) => input.value) + ); + }; + const observer = new MutationObserver(capture); + observer.observe(document, { childList: true, subtree: true, attributes: true }); + window.__stopPasswordSnapshots = () => { + observer.disconnect(); + return window.__passwordSnapshots; + }; + }); +} + +async function openProviderDetails(page, name) { + await page.getByRole("button", { name: `View ${name} details` }).click(); + const dialog = page.getByRole("dialog", { name }); + await expect(dialog).toBeVisible(); + return dialog; +} + +async function navigate(page, name, mobile = false) { + if (mobile) { + await page.getByRole("button", { name: /Open navigation|打开导航/ }).click(); + await expect(page.getByRole("dialog", { name: /Primary navigation|主导航/ })).toBeVisible(); + } + await page.getByRole("link", { name, exact: true }).click(); +} + +test.beforeEach(async ({ crp }) => { + crp.seedProviders({ + providers: [ + { id: "provider-a", name: "Provider Alpha", baseUrl: crp.upstreamBaseUrl }, + { id: "provider-b", name: "Provider Beta", baseUrl: crp.upstreamBaseUrl } + ], + activeProviderId: "provider-a" + }); +}); + +test("switches from the sidebar route control and keeps Forwarding Records inert", async ({ page, crp }) => { + const mutations = []; + page.on("request", (request) => { + const url = new URL(request.url()); + if (url.pathname.startsWith("/api/v1/") && !["GET", "HEAD"].includes(request.method())) { + mutations.push({ path: url.pathname, body: request.postData(), contentType: request.headers()["content-type"] }); + } + }); + + await openCrp(page, crp); + await expect(page.getByRole("heading", { name: "Codex is securely connected" })).toBeVisible(); + await page.getByRole("button", { name: "Stop proxy" }).click(); + await expect.poll(() => crp.state.worker.phase).toBe("stopped"); + const routeSelect = page.getByLabel("Current route"); + await expect(routeSelect).toHaveAttribute("title", /Provider Alpha · Switch and start/); + await routeSelect.selectOption("provider-b"); + await expect(routeSelect).toHaveValue("provider-b"); + expect(crp.state.activeProviderId).toBe("provider-b"); + expect(crp.state.generation).toBe(5); + expect(crp.state.worker.phase).toBe("running"); + expect(crp.calls.filter((call) => call.operation === "startProxy")).toHaveLength(0); + expect(crp.calls.find((call) => call.operation === "activate")).toMatchObject({ workerStarted: true }); + + const activation = mutations.find((request) => request.path === "/api/v1/providers/provider-b/activate"); + expect(activation.body).toBeNull(); + expect(activation.contentType).toBeUndefined(); + + await navigate(page, "Providers"); + + const before = page.url(); + const requestCount = crp.collectors.records.filter((record) => record.type === "request").length; + const forwarding = page.getByTestId("nav-forwarding-records"); + await expect(forwarding).toHaveAttribute("aria-disabled", "true"); + await expect(forwarding).toContainText("Coming soon"); + await forwarding.dispatchEvent("click"); + await expect(page).toHaveURL(before); + expect(crp.collectors.records.filter((record) => record.type === "request")).toHaveLength(requestCount); + + await navigate(page, "Activity"); + await expect(page.getByRole("heading", { name: "Activity", level: 1 })).toBeVisible(); + await navigate(page, "System"); + await expect(page.getByRole("heading", { name: "System", level: 1 })).toBeVisible(); + await navigate(page, "Overview"); + await expect(page.getByText("Generation 5")).toBeVisible(); + await assertNoSecrets(page, crp); +}); + +test("closes the mobile drawer after a sidebar mutation so feedback stays operable", async ({ page, crp }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await openCrp(page, crp); + await page.getByRole("button", { name: "Open navigation" }).click(); + const drawer = page.getByRole("dialog", { name: "Primary navigation" }); + await expect(drawer).toBeVisible(); + await drawer.getByLabel("Current route").selectOption("provider-b"); + await expect(drawer).toBeHidden(); + const message = page.getByTestId("global-message"); + await expect(message).toContainText("Provider switched"); + await expect(message.getByRole("button", { name: "Close" })).toBeVisible(); + await message.getByRole("button", { name: "Close" }).click(); + await expect(message).toHaveCount(0); + expect(crp.state.activeProviderId).toBe("provider-b"); + await assertNoSecrets(page, crp); +}); + +test("does not expose an untested provider as a sidebar route", async ({ page, crp }) => { + crp.seedProviders({ + providers: [ + { id: "provider-a", name: "Provider Alpha", baseUrl: crp.upstreamBaseUrl }, + { + id: "provider-b", + name: "Provider Beta", + baseUrl: crp.upstreamBaseUrl, + lastTestAt: null, + lastTestStatus: "untested", + lastTestCode: null + } + ], + activeProviderId: "provider-a" + }); + await openCrp(page, crp); + const option = page.getByLabel("Current route").locator('option[value="provider-b"]'); + await expect(option).toHaveAttribute("disabled", ""); + await expect(option).toHaveText("Provider Beta · Untested"); + expect(crp.calls.filter((call) => call.operation === "activate")).toHaveLength(0); +}); + +test("renders populated Metrics and changes the aggregate window without stale data", async ({ page, crp }) => { + crp.state.metrics.summary.responseStart = { + p50UpperBoundMs: 250, + p95UpperBoundMs: null, + overflowRequests: 2 + }; + const sevenDay = structuredClone(crp.state.metrics); + sevenDay.window = "7d"; + sevenDay.summary.requests = 777; + sevenDay.summary.results.success = 700; + sevenDay.summary.tokens.observedRequests = 650; + crp.setMetrics(sevenDay, { window: "7d" }); + + await openCrp(page, crp); + await expect(page.getByTestId("metrics-loaded")).toBeVisible(); + const requests = page.locator(".metric-card").filter({ hasText: "Requests" }); + await expect(requests.locator("strong")).toHaveText("128"); + const responseStart = page.locator(".metric-card").filter({ hasText: "P95 response start" }); + await expect(responseStart.locator("strong")).toHaveText("> 300 s"); + await expect(page.getByRole("img", { name: "Request results" })).toBeVisible(); + await expect(page.getByRole("img", { name: "Token trend" })).toBeVisible(); + await expect(page.getByRole("img", { name: "Model distribution" })).toBeVisible(); + await expect(page.getByRole("table").filter({ hasText: "Provider Alpha" })).toBeVisible(); + + await page.getByRole("button", { name: "7 days" }).click(); + await expect(page.getByRole("button", { name: "7 days" })).toHaveAttribute("aria-pressed", "true"); + await expect(requests.locator("strong")).toHaveText("777"); + await expect.poll(() => crp.calls.filter((call) => call.operation === "getMetrics" && call.window === "7d").length).toBe(1); + + await page.getByRole("button", { name: "24 hours" }).click(); + await expect(requests.locator("strong")).toHaveText("128"); + await assertNoSecrets(page, crp); +}); + +test("shows empty and degraded Metrics without affecting proxy readiness", async ({ page, crp }) => { + crp.setMetrics(crp.emptyMetrics()); + await openCrp(page, crp); + await expect(page.getByTestId("metrics-empty")).toBeVisible(); + await expect(page.getByRole("heading", { name: "No proxy traffic in this window" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Codex is securely connected" })).toBeVisible(); + + const degraded = crp.emptyMetrics(); + degraded.storageState = "degraded"; + crp.setMetrics(degraded); + await page.getByRole("button", { name: "Refresh status" }).click(); + await expect(page.getByText("Metrics storage is degraded. Proxy traffic is not affected.")).toBeVisible(); + await expect(page.getByRole("heading", { name: "No proxy traffic in this window" })).toBeVisible(); +}); + +test("isolates a Metrics API failure from the rest of the workspace", async ({ page, crp }) => { + await page.route("**/api/v1/metrics/overview?window=24h", async (route) => { + await route.fulfill({ + status: 503, + contentType: "application/json", + body: JSON.stringify({ error: { code: "METRICS_UNAVAILABLE", details: {} } }) + }); + }); + await openCrp(page, crp); + await expect(page.getByRole("heading", { name: "Overview", level: 1 })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Codex is securely connected" })).toBeVisible(); + await expect(page.getByText("Metrics are currently unavailable").first()).toBeVisible(); + await expect(page.getByTestId("metrics-empty")).toHaveCount(0); + await expect(page.locator(".runtime-facts").getByText("Provider Alpha", { exact: true })).toBeVisible(); + expect(crp.calls.filter((call) => call.operation === "getStatus").length).toBeGreaterThan(0); +}); + +test("creates, discovers models, tests, switches, edits, and deletes a provider safely", async ({ page, crp }) => { + const replacement = crp.registerSecret(`replacement-${randomBytes(18).toString("base64url")}`); + await openCrp(page, crp); + await navigate(page, "Providers"); + + await page.getByRole("button", { name: "Add provider" }).click(); + const createDialog = page.getByRole("dialog", { name: "Add provider" }); + await createDialog.getByLabel("Provider name").fill("Provider Gamma"); + await createDialog.getByLabel("Base URL").fill(crp.upstreamBaseUrl); + await createDialog.getByLabel("API key").fill(crp.credential); + await collectSubmittedPasswordSnapshots(page); + await createDialog.getByRole("button", { name: "Save provider" }).click(); + const createSnapshots = await page.evaluate(() => window.__stopPasswordSnapshots()); + expect(JSON.stringify(createSnapshots)).not.toContain(crp.credential); + const gamma = page.getByTestId("provider-card-provider-3"); + await expect(gamma).toContainText("Untested"); + + await gamma.getByRole("button", { name: "Test and switch" }).click(); + const testDialog = page.getByRole("dialog", { name: "Test provider" }); + await testDialog.getByRole("button", { name: "Refresh models" }).click(); + const modelSelect = testDialog.getByLabel("Test model"); + await expect(modelSelect.locator("option")).toHaveCount(3); + await modelSelect.selectOption({ label: "Enter a model manually" }); + const manualModel = testDialog.locator("#test-model-manual"); + await manualModel.fill("manual-fixture-model"); + await testDialog.getByRole("button", { name: "Refresh models" }).click(); + await expect(manualModel).toHaveValue("manual-fixture-model"); + await modelSelect.selectOption("fixture-model"); + await testDialog.getByRole("button", { name: "Test and switch" }).click(); + await expect(gamma.getByText("Current", { exact: true })).toBeVisible(); + expect(crp.state.activeProviderId).toBe("provider-3"); + expect(crp.upstreamRequests.at(-1)).toMatchObject({ + path: "/v1/responses", + model: "fixture-model", + requestValid: true, + credentialMatched: true + }); + + await page.getByTestId("provider-card-provider-a").getByRole("button", { name: "Switch" }).click(); + const detailsTrigger = page.getByRole("button", { name: "View Provider Gamma details" }); + await detailsTrigger.focus(); + await detailsTrigger.click(); + let details = page.getByRole("dialog", { name: "Provider Gamma" }); + await expect(details.getByText("Fresh catalog")).toBeVisible(); + await expect(details.getByText("gpt-5.1-codex-mini", { exact: true })).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(detailsTrigger).toBeFocused(); + + details = await openProviderDetails(page, "Provider Gamma"); + await details.getByRole("button", { name: "Edit Provider Gamma" }).click(); + let editDialog = page.getByRole("dialog", { name: "Edit provider" }); + await editDialog.getByLabel("Provider name").fill("Provider Gamma Renamed"); + await editDialog.getByRole("button", { name: "Save changes" }).click(); + const renamed = page.getByTestId("provider-card-provider-3"); + await expect(renamed).toContainText("Passed"); + + details = await openProviderDetails(page, "Provider Gamma Renamed"); + await details.getByRole("button", { name: "Edit Provider Gamma Renamed" }).click(); + editDialog = page.getByRole("dialog", { name: "Edit provider" }); + await editDialog.getByLabel("Advanced provider settings").check(); + await editDialog.getByLabel("Authentication header").fill("x-api-key"); + await editDialog.getByLabel("Extra headers (JSON)").fill('{"x-region":"cn"}'); + await editDialog.getByLabel("Replacement API key").fill(replacement); + await collectSubmittedPasswordSnapshots(page); + await editDialog.getByRole("button", { name: "Save changes" }).click(); + const editSnapshots = await page.evaluate(() => window.__stopPasswordSnapshots()); + expect(JSON.stringify(editSnapshots)).not.toContain(replacement); + await expect(renamed).toContainText("Untested"); + expect(crp.calls.findLast((call) => call.operation === "updateProvider")).toMatchObject({ + id: "provider-3", + replacedCredential: true, + replacementLength: replacement.length + }); + + details = await openProviderDetails(page, "Provider Gamma Renamed"); + await details.getByRole("button", { name: "Delete Provider Gamma Renamed" }).click(); + const deleteDialog = page.getByRole("dialog", { name: "Delete provider?" }); + await deleteDialog.getByRole("button", { name: "Delete provider" }).click(); + await expect(page.getByTestId("provider-card-provider-3")).toHaveCount(0); + expect(crp.calls.filter((call) => call.operation === "deleteProvider")).toHaveLength(1); + await assertNoSecrets(page, crp, [replacement]); +}); + +test("duplicates a provider configuration without copying credentials or state", async ({ page, crp }) => { + const duplicateSecret = crp.registerSecret(`duplicate-${randomBytes(18).toString("base64url")}`); + crp.seedProviders({ + providers: [ + { id: "provider-a", name: "Provider Alpha", baseUrl: crp.upstreamBaseUrl }, + { + id: "provider-b", + name: "Provider Beta", + baseUrl: crp.upstreamBaseUrl, + authHeader: "x-api-key", + authScheme: "", + extraHeaders: { "x-region": "cn" }, + modelMode: "override", + modelOverride: "beta-model" + } + ], + activeProviderId: "provider-a" + }); + await openCrp(page, crp); + await navigate(page, "Providers"); + + await page.getByTestId("provider-card-provider-b") + .getByRole("button", { name: "Duplicate Provider Beta" }) + .click(); + const dialog = page.getByRole("dialog", { name: "Duplicate provider" }); + await expect(dialog.getByLabel("Provider name")).toHaveValue("Provider Beta copy"); + await expect(dialog.getByLabel("Base URL")).toHaveValue(crp.upstreamBaseUrl); + await expect(dialog.getByLabel("API key")).toHaveValue(""); + await expect(dialog.getByLabel("API key")).toHaveAttribute("required", ""); + await dialog.getByLabel("Advanced provider settings").check(); + await expect(dialog.getByLabel("Authentication header")).toHaveValue("x-api-key"); + await expect(dialog.getByLabel("Authentication scheme")).toHaveValue(""); + await expect(dialog.getByLabel("Extra headers (JSON)")).toHaveValue('{\n "x-region": "cn"\n}'); + await expect(dialog.getByLabel("Model routing")).toHaveValue("override"); + await expect(dialog.getByLabel("Override model")).toHaveValue("beta-model"); + + await dialog.getByRole("button", { name: "Save duplicate" }).click(); + expect(crp.calls.filter((call) => call.operation === "createProvider")).toHaveLength(0); + await dialog.getByLabel("API key").fill(duplicateSecret); + await collectSubmittedPasswordSnapshots(page); + await dialog.getByRole("button", { name: "Save duplicate" }).click(); + const snapshots = await page.evaluate(() => window.__stopPasswordSnapshots()); + expect(JSON.stringify(snapshots)).not.toContain(duplicateSecret); + + const duplicated = page.getByTestId("provider-card-provider-3"); + await expect(duplicated).toContainText("Provider Beta copy"); + await expect(duplicated).toContainText("Untested"); + await expect(page.getByTestId("provider-card-provider-b")).toContainText("Provider Beta"); + expect(crp.calls.findLast((call) => call.operation === "createProvider")).toMatchObject({ + input: { + name: "Provider Beta copy", + baseUrl: crp.upstreamBaseUrl, + authHeader: "x-api-key", + authScheme: "", + extraHeaders: { "x-region": "cn" }, + modelMode: "override", + modelOverride: "beta-model" + }, + credentialLength: duplicateSecret.length + }); + await assertNoSecrets(page, crp, [duplicateSecret]); +}); + +test("paginates sanitized Activity and exposes diagnostics on System", async ({ page, crp }) => { + crp.seedActivity(105); + const activityRequests = []; + page.on("request", (request) => { + const url = new URL(request.url()); + if (url.pathname === "/api/v1/activity") activityRequests.push(url.search); + }); + await openCrp(page, crp); + await navigate(page, "Activity"); + await expect(page.locator(".activity-event")).toHaveCount(50); + await expect(page.getByRole("button", { name: "Previous" })).toBeDisabled(); + await page.getByRole("button", { name: "Next" }).dblclick(); + await expect(page.locator(".activity-event")).toHaveCount(50); + await expect.poll(() => activityRequests.filter((search) => search.includes("offset=50")).length).toBe(1); + await page.getByRole("button", { name: "Next" }).click(); + await expect(page.locator(".activity-event")).toHaveCount(5); + await expect(page.getByRole("button", { name: "Next" })).toBeDisabled(); + await page.getByRole("button", { name: "Previous" }).click(); + await expect(page.locator(".activity-event")).toHaveCount(50); + await page.locator(".activity-event summary").first().click(); + await expect(page.locator(".activity-event[open] .activity-detail")).toBeVisible(); + await expect(page.locator(".activity-event[open]")).toContainText(/Provider ID: provider-/); + + await navigate(page, "System"); + await expect(page.getByText("Native keyring")).toBeVisible(); + await expect(page.getByText("http://127.0.0.1:15100", { exact: true }).first()).toBeVisible(); + await page.getByRole("button", { name: "Generate diagnostic summary" }).click(); + const diagnostics = page.locator(".diagnostic-result"); + await expect(diagnostics).toContainText("Summary ready"); + await expect(diagnostics).toContainText("105 sanitized events"); + + await page.locator("#locale-select").selectOption("zh-CN"); + await expect(page.getByRole("heading", { name: "系统", level: 1 })).toBeVisible(); + await expect(diagnostics).toContainText("摘要已生成"); + await assertNoSecrets(page, crp); +}); + +test("keeps fragmentless reload GET-only until explicit same-origin management recovery", async ({ page, crp }) => { + crp.seedActivity(55); + await openCrp(page, crp); + const mutations = []; + page.on("request", (request) => { + if (new URL(request.url()).pathname.startsWith("/api/v1/") + && !["GET", "HEAD"].includes(request.method())) mutations.push({ + method: request.method(), + path: new URL(request.url()).pathname, + resumeHeader: request.headers()["x-crp-session-resume"] + }); + }); + await page.reload(); + await expect(page.locator("html")).toHaveAttribute("aria-busy", "false"); + await expect(page.locator("#session-banner")).toContainText("Read-only session"); + await expect(page.getByRole("button", { name: "Stop proxy" })).toBeDisabled(); + await expect(page.getByRole("button", { name: "Restart worker" })).toBeDisabled(); + await expect(page.getByLabel("Current route")).toBeDisabled(); + await page.getByRole("button", { name: "Refresh status" }).click(); + await navigate(page, "Providers"); + await expect(page.getByRole("button", { name: "Add provider" })).toBeDisabled(); + await expect(page.getByRole("button", { name: "Duplicate Provider Beta" })).toBeDisabled(); + await expect(page.getByTestId("provider-card-provider-b").getByRole("button", { name: "Switch" })).toBeDisabled(); + await navigate(page, "Activity"); + await page.getByRole("button", { name: "Next" }).click(); + await expect(page.locator(".activity-event")).toHaveCount(5); + await navigate(page, "System"); + await expect(page.getByRole("button", { name: "Generate diagnostic summary" })).toBeDisabled(); + expect(mutations).toEqual([]); + + await page.getByRole("button", { name: "Restore management" }).click(); + await expect(page.locator("#session-banner")).toHaveCount(0); + await expect(page.getByLabel("Current route")).toBeEnabled(); + await page.getByLabel("Current route").selectOption("provider-b"); + await expect(page.getByLabel("Current route")).toHaveValue("provider-b"); + expect(crp.state.activeProviderId).toBe("provider-b"); + expect(mutations).toEqual([ + { method: "POST", path: "/api/v1/session/resume", resumeHeader: "1" }, + { method: "POST", path: "/api/v1/providers/provider-b/activate", resumeHeader: undefined } + ]); + await assertNoSecrets(page, crp); +}); + +test("keeps every V8 page unclipped in both locales at desktop, tablet, and mobile widths", async ({ page, crp }) => { + crp.seedActivity(5); + await openCrp(page, crp); + const matrices = [ + { + locale: "en", + pages: [["Overview", "Overview"], ["Providers", "Providers"], ["Activity", "Activity"], ["System", "System"]] + }, + { + locale: "zh-CN", + pages: [["概览", "概览"], ["提供商", "提供商"], ["活动", "活动"], ["系统", "系统"]] + } + ]; + for (const viewport of [ + { width: 1440, height: 900 }, + { width: 1024, height: 768 }, + { width: 390, height: 844 } + ]) { + await page.setViewportSize(viewport); + const mobile = viewport.width <= 840; + if (mobile) { + const menu = page.getByRole("button", { name: /Open navigation|打开导航/ }); + await menu.click(); + await expect(page.locator(".sidebar-close")).toBeFocused(); + await page.keyboard.press("Escape"); + await expect(menu).toBeFocused(); + } + for (const matrix of matrices) { + if (mobile) { + await page.getByRole("button", { name: /Open navigation|打开导航/ }).click(); + await page.locator("#locale-select").selectOption(matrix.locale); + await page.locator(".sidebar-close").click(); + } else { + await page.locator("#locale-select").selectOption(matrix.locale); + } + for (const [navigation, heading] of matrix.pages) { + await test.step(`${viewport.width}x${viewport.height} ${matrix.locale} ${heading}`, async () => { + await navigate(page, navigation, mobile); + await expect(page.getByRole("heading", { name: heading, level: 1 })).toBeVisible(); + await assertLayoutIntegrity(page); + }); + } + await test.step(`${viewport.width}x${viewport.height} ${matrix.locale} setup`, async () => { + await page.evaluate(() => { + history.pushState(null, "", "/setup"); + dispatchEvent(new PopStateEvent("popstate")); + }); + await expect(page.getByTestId("page-setup")).toBeVisible(); + await assertLayoutIntegrity(page); + }); + } + } +}); diff --git a/node/test/e2e/restart-and-errors.spec.mjs b/node/test/e2e/restart-and-errors.spec.mjs new file mode 100644 index 0000000..91d3552 --- /dev/null +++ b/node/test/e2e/restart-and-errors.spec.mjs @@ -0,0 +1,491 @@ +import { randomBytes } from "node:crypto"; + +import { assertLayoutIntegrity, assertNoSecrets, expect, openCrp, test } from "./crp-ui-fixture.mjs"; + +async function openProviderTest(page, name) { + await page.getByRole("button", { name: `Test ${name}` }).click(); + const dialog = page.getByRole("dialog", { name: "Test provider" }); + await expect(dialog).toBeVisible(); + return dialog; +} + +async function openInactiveProviderEditor(page, name) { + await page.getByRole("button", { name: `View ${name} details` }).click(); + const details = page.getByRole("dialog", { name }); + await details.getByRole("button", { name: `Edit ${name}` }).click(); + const editor = page.getByRole("dialog", { name: "Edit provider" }); + await expect(editor).toBeVisible(); + return editor; +} + +test.beforeEach(async ({ crp }) => { + crp.seedProviders({ + providers: [ + { id: "provider-a", name: "Provider Alpha", baseUrl: crp.upstreamBaseUrl }, + { id: "provider-b", name: "Provider Beta", baseUrl: crp.upstreamBaseUrl } + ], + activeProviderId: "provider-a" + }); +}); + +test("restarts immediately at zero in-flight requests and keeps the supervisor stable", async ({ page, crp }) => { + await openCrp(page, crp); + const originalWorkerPid = crp.state.worker.pid; + const supervisorPid = crp.state.supervisorPid; + const headerY = await page.locator(".page-header").evaluate((element) => element.getBoundingClientRect().y); + await page.getByRole("button", { name: "Restart worker" }).click(); + await expect(page.getByRole("status")).toContainText("Worker restarted"); + await expect(page.getByTestId("global-message")).toHaveCSS("position", "fixed"); + expect(await page.locator(".page-header").evaluate((element) => element.getBoundingClientRect().y)).toBe(headerY); + expect(crp.state.worker.pid).not.toBe(originalWorkerPid); + expect(crp.state.supervisorPid).toBe(supervisorPid); + expect(crp.calls.filter((call) => call.operation === "restartProxy")).toHaveLength(1); +}); + +test("warns before restarting in-flight work and restores the trigger focus", async ({ page, crp }) => { + crp.setInFlight(3); + await openCrp(page, crp); + const restart = page.getByRole("button", { name: "Restart worker" }); + await restart.click(); + const dialog = page.getByRole("dialog", { name: "Restart worker?" }); + await expect(dialog).toContainText("3 requests are currently in flight"); + await page.keyboard.press("Escape"); + await expect(dialog).toBeHidden(); + await expect(restart).toBeFocused(); + expect(crp.calls.filter((call) => call.operation === "restartProxy")).toHaveLength(0); + + await restart.click(); + await dialog.getByRole("button", { name: "Restart anyway" }).click(); + await expect(page.getByRole("status")).toContainText("Worker restarted"); + expect(crp.calls.filter((call) => call.operation === "restartProxy")).toHaveLength(1); +}); + +test("returns focus to the mobile menu after cancelling an in-flight restart", async ({ page, crp }) => { + crp.setInFlight(3); + await page.setViewportSize({ width: 390, height: 844 }); + await openCrp(page, crp); + const menu = page.getByRole("button", { name: "Open navigation" }); + await menu.click(); + const drawer = page.getByRole("dialog", { name: "Primary navigation" }); + await drawer.getByRole("button", { name: "Restart worker" }).click(); + await expect(drawer).toBeHidden(); + const confirmation = page.getByRole("dialog", { name: "Restart worker?" }); + await expect(confirmation).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(confirmation).toBeHidden(); + await expect(menu).toBeFocused(); + expect(crp.calls.filter((call) => call.operation === "restartProxy")).toHaveLength(0); +}); + +test("stops and starts only the worker with exact empty request bodies", async ({ page, crp }) => { + const requests = []; + page.on("request", (request) => { + const path = new URL(request.url()).pathname; + if (path === "/api/v1/proxy/stop" || path === "/api/v1/proxy/start") { + requests.push({ path, body: request.postData(), contentType: request.headers()["content-type"] }); + } + }); + await openCrp(page, crp); + await page.getByRole("button", { name: "Stop proxy" }).click(); + const start = page.locator(".sidebar-worker-actions").getByRole("button", { name: "Start proxy" }); + await expect(start).toBeEnabled(); + await start.click(); + await expect(page.locator(".sidebar-worker-actions").getByRole("button", { name: "Stop proxy" })).toBeEnabled(); + expect(requests.map((request) => request.path)).toEqual(["/api/v1/proxy/stop", "/api/v1/proxy/start"]); + for (const request of requests) { + expect(request.body).toBeNull(); + expect(request.contentType).toBeUndefined(); + } + expect(crp.state.supervisorPid).toBe(7001); +}); + +test("keeps Exit CRP separate from worker controls and cancels safely on mobile", async ({ page, crp }) => { + const shutdownRequests = []; + page.on("request", (request) => { + if (new URL(request.url()).pathname === "/api/v1/supervisor/shutdown") { + shutdownRequests.push(request.method()); + } + }); + await page.setViewportSize({ width: 390, height: 844 }); + await openCrp(page, crp); + const menu = page.getByRole("button", { name: "Open navigation" }); + await menu.click(); + const drawer = page.getByRole("dialog", { name: "Primary navigation" }); + const exit = drawer.getByRole("button", { name: "Exit CRP" }); + expect(await exit.evaluate((element) => element.closest(".sidebar-worker-actions") === null)).toBe(true); + await exit.click(); + await expect(drawer).toBeHidden(); + const confirmation = page.getByRole("dialog", { name: "Exit CRP?" }); + await expect(confirmation).toContainText("The local console will go offline"); + await assertLayoutIntegrity(page); + await confirmation.getByRole("button", { name: "Cancel" }).click(); + await expect(confirmation).toBeHidden(); + await expect(menu).toBeFocused(); + expect(shutdownRequests).toEqual([]); + expect(crp.calls.filter((call) => call.operation === "shutdownSupervisor")).toEqual([]); +}); + +test("submits the authenticated supervisor identity and enters a quiet shutdown terminal", async ({ page, crp }) => { + const requests = []; + const responses = []; + page.on("request", (request) => { + if (new URL(request.url()).pathname !== "/api/v1/supervisor/shutdown") return; + requests.push({ + method: request.method(), + body: request.postDataJSON(), + contentType: request.headers()["content-type"], + csrfLength: request.headers()["x-crp-csrf"]?.length ?? 0 + }); + }); + page.on("response", (response) => { + if (new URL(response.url()).pathname === "/api/v1/supervisor/shutdown") { + responses.push(response.status()); + } + }); + await openCrp(page, crp); + await page.getByRole("button", { name: "Exit CRP" }).click(); + const confirmation = page.getByRole("dialog", { name: "Exit CRP?" }); + await confirmation.getByRole("button", { name: "Exit CRP" }).click(); + + const stopped = page.getByTestId("supervisor-stopped"); + await expect(stopped.getByRole("heading", { name: "CRP is shutting down" })).toBeVisible(); + await expect(stopped).toContainText("The shutdown request was accepted"); + await expect(stopped).toContainText("You may close this page."); + await expect(page.locator("#app-root")).toHaveCount(0); + await expect.poll(() => crp.calls.filter((call) => call.operation === "shutdownSupervisor").length).toBe(1); + expect(crp.state.supervisorShutdownAccepted).toBe(true); + expect(requests).toEqual([{ + method: "POST", + body: { + supervisorPid: crp.backendStatus.supervisor.pid, + startedAt: crp.backendStatus.supervisor.startedAt + }, + contentType: "application/json", + csrfLength: 43 + }]); + expect(responses).toEqual([202]); + expect(page.isClosed()).toBe(false); + + const apiRequestCount = requests.length; + await stopped.getByLabel("Language").selectOption("zh-CN"); + await expect(stopped.getByRole("heading", { name: "CRP 正在关闭" })).toBeVisible(); + await expect(stopped).toContainText("关闭请求已被接受"); + await expect(stopped).toContainText("现在可以关闭此页面。"); + expect(requests).toHaveLength(apiRequestCount); + await page.setViewportSize({ width: 390, height: 844 }); + await assertLayoutIntegrity(page); +}); + +test("keeps supervisor shutdown unavailable in a read-only session", async ({ page, crp }) => { + const shutdownRequests = []; + page.on("request", (request) => { + if (new URL(request.url()).pathname === "/api/v1/supervisor/shutdown") { + shutdownRequests.push(request.method()); + } + }); + await openCrp(page, crp); + await page.reload(); + await expect(page.locator("html")).toHaveAttribute("aria-busy", "false"); + await expect(page.locator("#session-banner")).toContainText("Read-only session"); + await expect(page.getByRole("button", { name: "Exit CRP" })).toBeDisabled(); + expect(shutdownRequests).toEqual([]); +}); + +test("does not enter the stopped state when the supervisor identity changed", async ({ page, crp }) => { + await openCrp(page, crp); + crp.replaceSupervisorIdentity({ + pid: crp.backendStatus.supervisor.pid + 1, + startedAt: crp.backendStatus.supervisor.startedAt + }); + await page.getByRole("button", { name: "Exit CRP" }).click(); + const confirmation = page.getByRole("dialog", { name: "Exit CRP?" }); + await confirmation.getByRole("button", { name: "Exit CRP" }).click(); + const alert = page.getByRole("alert"); + await expect(alert).toContainText("CRP could not complete the operation"); + await alert.locator("summary").click(); + await expect(alert).toContainText("SUPERVISOR_IDENTITY_CHANGED"); + await expect(page.locator("#app-root")).toBeVisible(); + await expect(page.getByTestId("supervisor-stopped")).toHaveCount(0); + expect(crp.state.supervisorShutdownAccepted).toBe(false); + expect(crp.calls.filter((call) => call.operation === "shutdownSupervisor")).toEqual([]); +}); + +test("rejects a non-minimal shutdown acceptance without going offline", async ({ page, crp }) => { + await page.route("**/api/v1/supervisor/shutdown", async (route) => { + const identity = route.request().postDataJSON(); + await route.fulfill({ + status: 202, + contentType: "application/json", + body: JSON.stringify({ + shutdown: { + accepted: true, + supervisorPid: identity.supervisorPid, + startedAt: identity.startedAt, + unexpected: true + } + }) + }); + }); + await openCrp(page, crp); + await page.getByRole("button", { name: "Exit CRP" }).click(); + const confirmation = page.getByRole("dialog", { name: "Exit CRP?" }); + await confirmation.getByRole("button", { name: "Exit CRP" }).click(); + const alert = page.getByRole("alert"); + await expect(alert).toContainText("CRP could not complete the operation"); + await alert.locator("summary").click(); + await expect(alert).toContainText("INTERNAL_ERROR"); + await expect(page.locator("#app-root")).toBeVisible(); + await expect(page.getByTestId("supervisor-stopped")).toHaveCount(0); + expect(crp.state.supervisorShutdownAccepted).toBe(false); +}); + +test("projects real transitional and recovery phases in the sidebar", async ({ page, crp }) => { + await openCrp(page, crp); + const runtime = page.locator(".sidebar-runtime"); + for (const [phase, label] of [ + ["draining", "Stopping"], + ["crashed", "Failed"], + ["backoff", "Recovering"] + ]) { + crp.state.worker.phase = phase; + await page.getByRole("button", { name: "Refresh status" }).click(); + await expect(runtime).toContainText(label); + } +}); + +test("maps provider authentication failures to actionable copy in both locales", async ({ page, crp }) => { + crp.failProviderTestsWith("PROVIDER_TEST_AUTH"); + await openCrp(page, crp); + await page.getByRole("link", { name: "Providers" }).click(); + await page.getByRole("button", { name: "View Provider Beta details" }).click(); + let details = page.getByRole("dialog", { name: "Provider Beta" }); + await details.getByRole("button", { name: "Test", exact: true }).click(); + let testDialog = page.getByRole("dialog", { name: "Test provider" }); + await testDialog.getByLabel("Test model").fill("gpt-5.1-codex-mini"); + await testDialog.getByRole("button", { name: "Run test" }).click(); + await expect(page.getByText("Provider authentication failed")).toBeVisible(); + await expect(page.getByText("Check the API key and authorization scheme, then test again.")).toBeVisible(); + await testDialog.getByRole("button", { name: "Cancel" }).click(); + + await page.locator("#locale-select").selectOption("zh-CN"); + await page.getByTestId("provider-card-provider-b").getByRole("button", { name: "测试并切换" }).click(); + testDialog = page.getByRole("dialog", { name: "测试提供商" }); + await testDialog.getByLabel("测试模型").fill("gpt-5.1-codex-mini"); + await testDialog.getByRole("button", { name: "测试并切换" }).click(); + await expect(page.getByText("提供商认证失败")).toBeVisible(); + await expect(page.getByText("请检查 API 密钥和认证方案,然后重新测试。")).toBeVisible(); + expect(crp.state.activeProviderId).toBe("provider-a"); + await assertNoSecrets(page, crp); +}); + +test("accepts the minimal production Responses shape", async ({ page, crp }) => { + crp.setUpstreamResponsePayload({ id: "resp-minimal", object: "response", output: [] }); + await openCrp(page, crp); + await page.getByRole("link", { name: "Providers" }).click(); + const dialog = await openProviderTest(page, "Provider Alpha"); + await dialog.getByLabel("Test model").fill("gpt-5.1-codex-mini"); + await dialog.getByRole("button", { name: "Run test" }).click(); + await expect(page.getByRole("status")).toContainText("Provider is compatible"); + expect(crp.state.providers.find((provider) => provider.id === "provider-a")?.lastTestStatus).toBe("passed"); + expect(crp.upstreamRequests.at(-1)?.responseShapeValid).toBe(true); +}); + +for (const [label, payload] of [ + ["empty response id", { id: "", object: "response", status: "completed", output: [] }], + ["wrong response object", { id: "resp-wrong", object: "chat.completion", status: "completed", output: [] }] +]) { + test(`rejects a Responses payload with ${label}`, async ({ page, crp }) => { + crp.setUpstreamResponsePayload(payload); + await openCrp(page, crp); + await page.getByRole("link", { name: "Providers" }).click(); + const dialog = await openProviderTest(page, "Provider Alpha"); + await dialog.getByLabel("Test model").fill("gpt-5.1-codex-mini"); + await dialog.getByRole("button", { name: "Run test" }).click(); + const alert = page.getByRole("alert"); + await expect(alert).toContainText("CRP could not complete the operation"); + await dialog.getByRole("button", { name: "Cancel" }).click(); + await alert.locator("summary").click(); + await expect(alert).toContainText("PROVIDER_TEST_INVALID_RESPONSES"); + expect(crp.state.providers.find((provider) => provider.id === "provider-a")).toMatchObject({ + lastTestStatus: "failed", + lastTestCode: "PROVIDER_TEST_INVALID_RESPONSES" + }); + expect(crp.upstreamRequests.at(-1)?.responseShapeValid).toBe(false); + }); +} + +test("prepares Codex from System and reports bounded history-repair metadata", async ({ page, crp }) => { + const requests = []; + page.on("request", (request) => { + if (new URL(request.url()).pathname === "/api/v1/codex/bootstrap") { + requests.push({ body: request.postData(), contentType: request.headers()["content-type"] }); + } + }); + await openCrp(page, crp); + await page.getByRole("link", { name: "System" }).click(); + await page.getByRole("button", { name: "Prepare Codex" }).click(); + const dialog = page.getByRole("dialog", { name: "Prepare Codex?" }); + await dialog.getByRole("button", { name: "Prepare Codex" }).click(); + await expect(page.getByText("History repair")).toBeVisible(); + await expect(page.getByText("No provider metadata repair was required")).toBeVisible(); + expect(requests).toEqual([{ body: null, contentType: undefined }]); + expect(crp.state.bootstrapCount).toBe(1); +}); + +test("folds allowlisted technical error details and omits unknown fields", async ({ page, crp }) => { + await page.route("**/api/v1/diagnostics/export", async (route) => { + await route.fulfill({ + status: 409, + contentType: "application/json", + body: JSON.stringify({ + error: { + code: "WORKER_BUSY", + requestId: "req-safe-42", + details: { + field: "proxy", + committed: true, + degraded: false, + generation: 7, + httpStatus: 409, + ignored: "must-not-render" + } + } + }) + }); + }); + await openCrp(page, crp); + await page.getByRole("link", { name: "System" }).click(); + await page.getByRole("button", { name: "Generate diagnostic summary" }).click(); + const alert = page.getByRole("alert"); + const technical = alert.locator("details"); + await expect(technical).not.toHaveAttribute("open", ""); + await technical.locator("summary").click(); + await expect(technical).toContainText("WORKER_BUSY"); + await expect(technical).toContainText("req-safe-42"); + await expect(technical).toContainText("proxy"); + await expect(technical).toContainText("7"); + await expect(technical).toContainText("409"); + await expect(alert).not.toContainText("must-not-render"); + + await page.locator("#locale-select").selectOption("zh-CN"); + await expect(technical.locator("summary")).toHaveText("技术详情"); + await assertNoSecrets(page, crp); +}); + +test("detects a secret in raw API response bytes before display redaction", async ({ page, crp }) => { + const reflected = `response-${randomBytes(18).toString("base64url")}`; + await page.route("**/api/v1/diagnostics/export", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + diagnostics: { created: true, generatedAt: "2026-07-13T08:50:00.000Z", eventCount: 0 }, + credential: reflected + }) + }); + }); + await openCrp(page, crp); + await page.getByRole("link", { name: "System" }).click(); + await page.getByRole("button", { name: "Generate diagnostic summary" }).click(); + await expect(page.locator(".diagnostic-result")).toContainText("Summary ready"); + await crp.collectors.flush(); + const displayRecord = crp.collectors.records.findLast((record) => ( + record.type === "response" && record.url === "/api/v1/diagnostics/export" + )); + expect(displayRecord?.body).not.toContain(reflected); + expect(displayRecord?.body).toContain('"credential":"[redacted]"'); + await expect(assertNoSecrets(page, crp, [reflected])).rejects.toThrow( + "Raw API response contained sensitive data outside the session exchange." + ); +}); + +test("renders committed degraded guidance in both locales", async ({ page, crp }) => { + await page.route("**/api/v1/diagnostics/export", async (route) => { + await route.fulfill({ + status: 500, + contentType: "application/json", + body: JSON.stringify({ + error: { + code: "MIGRATION_ROLLBACK_DEGRADED", + requestId: "req-degraded", + details: { degraded: true, committed: false } + } + }) + }); + }); + await openCrp(page, crp); + await page.getByRole("link", { name: "System" }).click(); + await page.getByRole("button", { name: "Generate diagnostic summary" }).click(); + await expect(page.getByText("CRP state needs repair")).toBeVisible(); + await expect(page.getByText("The primary change may already be committed. Review Activity before another mutation.")).toBeVisible(); + await page.locator("#locale-select").selectOption("zh-CN"); + await expect(page.getByText("CRP 状态需要修复")).toBeVisible(); + await expect(page.getByText("主要更改可能已经提交。执行其他更改前请先查看活动记录。")).toBeVisible(); +}); + +test("terminates after a second session exchange makes the open tab CSRF stale", async ({ page, crp }) => { + const mutationRequests = []; + page.on("request", (request) => { + if (request.method() === "PATCH" && new URL(request.url()).pathname === "/api/v1/providers/provider-b") { + mutationRequests.push(request.method()); + } + }); + await openCrp(page, crp); + await page.getByRole("link", { name: "Providers" }).click(); + const editor = await openInactiveProviderEditor(page, "Provider Beta"); + await editor.getByLabel("Provider name").fill("Stale tab edit"); + await editor.getByLabel("Replacement API key").fill(crp.credential); + expect(await crp.rotateBrowserSession(page)).toEqual({ status: 200, csrfTokenLength: 43 }); + await editor.getByRole("button", { name: "Save changes" }).click(); + await expect(page.locator("#session-root")).toBeVisible(); + await expect(page.locator("#app-root")).toHaveCount(0); + await expect(page.locator("dialog[open]")).toHaveCount(0); + expect(mutationRequests).toEqual(["PATCH"]); + await assertNoSecrets(page, crp); +}); + +for (const status of [500, 403]) { + test(`treats a ${status} session exchange failure as terminal even with a valid cookie`, async ({ page, crp }) => { + await openCrp(page, crp); + const apiRequests = []; + page.on("request", (request) => { + const url = new URL(request.url()); + if (url.pathname.startsWith("/api/v1/")) apiRequests.push({ method: request.method(), path: url.pathname }); + }); + await page.route("**/api/v1/session", async (route) => { + await route.fulfill({ + status, + contentType: "application/json", + body: JSON.stringify({ error: { code: "INTERNAL_ERROR", requestId: `req-exchange-${status}`, details: {} } }) + }); + }, { times: 1 }); + await page.goto(`${crp.origin}/?exchange=${status}#token=${crp.controlToken}`); + await expect(page.locator("html")).toHaveAttribute("aria-busy", "false"); + await expect(page.locator("#session-root")).toBeVisible(); + await expect(page.locator("#app-root")).toHaveCount(0); + expect(apiRequests).toEqual([{ method: "POST", path: "/api/v1/session" }]); + await assertNoSecrets(page, crp); + }); +} + +test("fails closed after session expiry and clears an open replacement credential", async ({ page, crp }) => { + const sessionRequests = []; + page.on("request", (request) => { + if (new URL(request.url()).pathname === "/api/v1/session") sessionRequests.push(request.method()); + }); + await openCrp(page, crp); + expect(sessionRequests).toEqual(["POST"]); + await page.getByRole("link", { name: "Providers" }).click(); + const editor = await openInactiveProviderEditor(page, "Provider Beta"); + await editor.getByLabel("Replacement API key").fill(crp.credential); + crp.expireSession(); + await editor.getByRole("button", { name: "Save changes" }).click(); + + await expect(page.locator("#session-root")).toBeVisible(); + await expect(page.getByRole("heading", { name: "Session expired" })).toBeVisible(); + await expect(page.getByText("Run crp ui again, then close this tab.")).toBeVisible(); + await expect(page.locator("dialog[open]")).toHaveCount(0); + expect(sessionRequests).toEqual(["POST"]); + expect(crp.calls.filter((call) => call.operation === "updateProvider")).toHaveLength(0); + await assertNoSecrets(page, crp); +}); diff --git a/node/test/fixtures/fake-worker.mjs b/node/test/fixtures/fake-worker.mjs new file mode 100644 index 0000000..ed96d94 --- /dev/null +++ b/node/test/fixtures/fake-worker.mjs @@ -0,0 +1,103 @@ +import { + PROTOCOL_VERSION, + validateParentMessage +} from "../../src/worker/protocol.mjs"; + +const modes = new Set((process.env.CRP_FAKE_WORKER_MODES ?? "") + .split(",") + .map((mode) => mode.trim()) + .filter(Boolean)); + +let generation = 0; +let phase = "ready"; +let listening = false; +let listenHost = null; +let listenPort = null; + +function state() { + return { + phase, + configured: generation > 0, + generation, + listening, + listenHost, + listenPort, + inFlight: 0 + }; +} + +function send(message) { + if (process.connected) process.send(message); +} + +function lifecycle(type, requestId) { + send({ version: PROTOCOL_VERSION, type, requestId, state: state() }); +} + +function handleMessage(rawMessage) { + let message; + try { + message = validateParentMessage(rawMessage); + } catch { + send({ + version: PROTOCOL_VERSION, + type: "fatal", + requestId: "worker-fatal", + error: { + code: "WORKER_PROTOCOL_INVALID", + message: "Worker protocol message is invalid." + } + }); + return; + } + + if (message.type === "configure") { + if (modes.has("no-configure")) return; + generation = message.generation; + phase = "running"; + listening = true; + listenHost = message.settings.server.host; + listenPort = message.settings.server.port; + lifecycle("configured", message.requestId); + if (modes.has("exit-after-configure")) process.exit(23); + return; + } + if (message.type === "status") { + lifecycle("status", message.requestId); + return; + } + if (message.type === "drain") { + if (modes.has("no-drain")) return; + phase = "drained"; + listening = false; + listenHost = null; + listenPort = null; + lifecycle("drained", message.requestId); + return; + } + if (!modes.has("no-shutdown")) process.exit(0); +} + +process.on("message", handleMessage); +process.on("SIGTERM", () => { + if (!modes.has("ignore-term")) process.exit(0); +}); + +if (!modes.has("no-ready")) lifecycle("ready", "worker-ready"); + +if (modes.has("malformed-secret")) { + setImmediate(() => { + send({ + version: PROTOCOL_VERSION, + type: "status", + requestId: "malformed-secret", + state: { ...state(), apiKey: "fixture-secret-must-not-pass" } + }); + }); +} + +if (modes.has("late-message")) { + process.once("disconnect", () => { + lifecycle("status", "late-after-disconnect"); + }); +} diff --git a/node/test/integration/admin-server.test.mjs b/node/test/integration/admin-server.test.mjs new file mode 100644 index 0000000..c8f1197 --- /dev/null +++ b/node/test/integration/admin-server.test.mjs @@ -0,0 +1,2534 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import * as realFileOperations from "node:fs"; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { EventEmitter } from "node:events"; +import http from "node:http"; +import os from "node:os"; +import { dirname, join } from "node:path"; + +import { createAdminServer } from "../../src/supervisor/admin-server.mjs"; +import { SessionAuth } from "../../src/supervisor/session-auth.mjs"; +import { createSupervisor } from "../../src/supervisor/supervisor.mjs"; +import { runSupervisor } from "../../src/supervisor/supervisor-entry.mjs"; +import { getPaths } from "../../src/shared/paths.mjs"; +import { CrpError } from "../../src/shared/errors.mjs"; + +const SECRET = "admin-api-complete-secret-sentinel"; +const CREDENTIAL_REF = "credential-ref-must-not-pass"; +const NO_HISTORY_REPAIR = Object.freeze({ + required: false, + completed: false, + resumed: false, + backupCreated: false, + rolloutFiles: 0, + rolloutRecords: 0, + sqliteFiles: 0, + sqliteRows: 0, + encryptedContentDetected: false +}); + +function publicProvider(overrides = {}) { + return { + id: "provider-1", + name: "Primary", + baseUrl: "https://provider.example/v1", + authHeader: "authorization", + authScheme: "Bearer", + extraHeaders: {}, + modelMode: "passthrough", + modelOverride: null, + lastTestAt: null, + lastTestStatus: "untested", + lastTestCode: null, + createdAt: "2026-07-13T00:00:00.000Z", + updatedAt: "2026-07-13T00:00:00.000Z", + credentialConfigured: true, + credentialRef: CREDENTIAL_REF, + apiKey: SECRET, + ...overrides + }; +} + +function workerState(overrides = {}) { + return { + phase: "running", + pid: 12345, + generation: 1, + state: { + phase: "running", + configured: true, + generation: 1, + listening: true, + listenHost: "127.0.0.1", + listenPort: 15100, + inFlight: 0, + apiKey: SECRET + }, + restartCount: 0, + startedAt: "2026-07-13T00:00:00.000Z", + error: null, + settings: { apiKey: SECRET }, + ...overrides + }; +} + +function createServices() { + const calls = []; + const providers = [publicProvider()]; + const providerService = { + async listProviders() { + calls.push(["listProviders"]); + return providers.map((provider) => ({ ...provider })); + }, + async createProvider(input, secret, ...extraArguments) { + calls.push(["createProvider", input, secret, ...extraArguments]); + const created = publicProvider({ id: "provider-2", name: input.name }); + providers.push(created); + return created; + }, + async updateProvider(id, patch, replacementSecret) { + calls.push(["updateProvider", id, patch, replacementSecret]); + const current = providers.find((provider) => provider.id === id); + Object.assign(current, patch, { updatedAt: "2026-07-13T00:01:00.000Z" }); + return { ...current }; + }, + async deleteProvider(id) { + calls.push(["deleteProvider", id]); + if (id === "provider-1") { + throw new CrpError( + "PROVIDER_ACTIVE", + "The active provider cannot be deleted.", + "Activate another provider or stop the proxy first.", + { status: 409, details: { authorization: SECRET, note: SECRET } } + ); + } + const index = providers.findIndex((provider) => provider.id === id); + return providers.splice(index, 1)[0]; + }, + async testProvider(id, model, options) { + calls.push(["testProvider", id, model, options]); + return { + ok: true, + code: null, + initialActivation: options?.activateIfNone === true ? { + automatic: true, + activeProviderId: id, + workerStarted: false, + apiKey: SECRET + } : null, + apiKey: SECRET + }; + }, + async getProviderModels(id) { + calls.push(["getProviderModels", id]); + return { + providerId: id, + state: "stale", + fetchedAt: "2026-07-12T00:00:00.000Z", + expiresAt: "2026-07-13T00:00:00.000Z", + models: ["cached-model"], + apiKey: SECRET + }; + }, + async refreshProviderModels(id) { + calls.push(["refreshProviderModels", id]); + return { + providerId: id, + state: "fresh", + fetchedAt: "2026-07-13T00:00:00.000Z", + expiresAt: "2026-07-14T00:00:00.000Z", + models: ["fresh-model"], + credentialRef: CREDENTIAL_REF + }; + }, + async activate(id) { + calls.push(["activate", id]); + return { + activeProviderId: id, + activeProvider: providers.find((provider) => provider.id === id), + generation: 2, + worker: workerState({ generation: 2 }) + }; + }, + async getStatus() { + calls.push(["getStatus"]); + return { + activeProviderId: "provider-1", + activeProvider: providers[0], + generation: 1, + worker: workerState() + }; + }, + async startProxy() { + calls.push(["startProxy"]); + return workerState(); + }, + async stopProxy() { + calls.push(["stopProxy"]); + return workerState({ phase: "stopped", pid: null, generation: 1, state: null }); + }, + async restartProxy() { + calls.push(["restartProxy"]); + return workerState({ pid: 54321 }); + } + }; + const activityStore = { + list({ limit }) { + calls.push(["activity", limit]); + return Array.from({ length: 8 }, (_, index) => ({ + timestamp: `2026-07-13T00:00:0${index}.000Z`, + category: "provider", + action: "test", + providerId: "provider-1", + result: "success", + errorCode: null, + details: index === 0 ? { authorization: "[REDACTED]" } : { index } + })).reverse(); + } + }; + const settingsService = { + async getSettings() { + calls.push(["getSettings"]); + return { + proxyHost: "127.0.0.1", + proxyPort: 15100, + adminHost: "127.0.0.1", + adminPort: 15101, + captureEnabled: false, + credentialBackend: "native", + apiKey: SECRET + }; + }, + async updateSettings(patch) { + calls.push(["updateSettings", patch]); + return { ...(await this.getSettings()), ...patch }; + } + }; + const codexState = { + configured: true, + modelProvider: "OpenAI", + proxyUrl: "http://127.0.0.1:15100", + historyRepairPending: false + }; + const codexService = { + state: codexState, + async bootstrap() { + calls.push(["bootstrap"]); + Object.assign(codexState, { + configured: true, + historyRepairPending: false + }); + return { + changed: true, + backupPath: `/private/${SECRET}`, + historyRepair: { + required: true, + completed: true, + resumed: false, + backupCreated: true, + rolloutFiles: -1, + rolloutRecords: 1_000_001, + sqliteFiles: 1_000_000, + sqliteRows: 4, + encryptedContentDetected: true, + rolloutPaths: [`/private/${SECRET}/rollout.jsonl`], + sessionBody: `private session body ${SECRET}`, + apiKey: SECRET + } + }; + }, + async getStatus() { + return structuredClone(codexState); + }, + async runWhenReady(operation) { + calls.push(["runWhenReady"]); + const status = await this.getStatus(); + if (status.configured !== true || status.historyRepairPending === true) { + throw new CrpError( + "CODEX_NOT_READY", + "The Codex configuration is not ready.", + "Complete Codex bootstrap before activating a provider or starting or restarting the proxy.", + { status: 409 } + ); + } + return await operation(); + } + }; + const diagnosticsService = { + async exportDiagnostics() { + calls.push(["diagnostics"]); + return { created: true, apiKey: SECRET, credentialRef: CREDENTIAL_REF }; + } + }; + const metricsService = { + async getOverview({ window }) { + calls.push(["metrics", window]); + return { + window, + bucketMinutes: 60, + storageState: "ready", + summary: { + requests: 3, + results: { + success: 2, + upstreamRejected: 1, + upstreamError: 0, + timeout: 0, + networkError: 0, + clientAbort: 0, + rawError: SECRET + }, + tokens: { input: 100, output: 25, observedRequests: 2, body: SECRET }, + latency: { p50UpperBoundMs: 500, p95UpperBoundMs: 2_500, overflowRequests: 0 }, + responseStart: { p50UpperBoundMs: 250, p95UpperBoundMs: 1_000, overflowRequests: 0 } + }, + series: [{ + start: "2026-07-13T00:00:00.000Z", + requests: 3, + results: { + success: 2, + upstreamRejected: 1, + upstreamError: 0, + timeout: 0, + networkError: 0, + clientAbort: 0 + }, + tokens: { input: 100, output: 25, observedRequests: 2 }, + requestId: SECRET + }], + providers: [{ + providerId: "provider-1", + requests: 3, + successfulRequests: 2, + tokens: { input: 100, output: 25, observedRequests: 2 }, + latency: { p50UpperBoundMs: 500, p95UpperBoundMs: 2_500, overflowRequests: 0 }, + url: SECRET + }], + providerOtherRequests: 0, + models: [{ + model: "gpt-5-codex", + requests: 3, + tokens: { input: 100, output: 25, observedRequests: 2 }, + headers: SECRET + }], + modelOtherRequests: 0, + dataQuality: { + unknownModelRequests: 0, + modelOverflowRequests: 0, + providerOverflowRequests: 0, + droppedObservations: 0, + error: SECRET + }, + apiKey: SECRET + }; + } + }; + return { + calls, + providerService, + activityStore, + settingsService, + codexService, + diagnosticsService, + metricsService + }; +} + +async function createHarness(t, overrides = {}) { + const dir = mkdtempSync(join(os.tmpdir(), "crp-admin-server-")); + const uiDir = join(dir, "ui"); + mkdirSync(uiDir, { recursive: true }); + writeFileSync(join(uiDir, "index.html"), "CRP test UI\n"); + writeFileSync(join(uiDir, "styles.css"), "body { color: black; }\n"); + writeFileSync(join(uiDir, "app.js"), "globalThis.__crpTest = true;\n"); + const controlTokenPath = join(dir, "control-token"); + const auth = new SessionAuth({ controlTokenPath }); + const services = createServices(); + const admin = createAdminServer({ + auth, + ...services, + getSupervisorState: () => ({ pid: 9001, startedAt: "2026-07-13T00:00:00.000Z", apiKey: SECRET }), + uiDir, + host: "127.0.0.1", + port: 0, + ...overrides + }); + const address = await admin.listen(); + const controlToken = readFileSync(controlTokenPath, "utf8").trim(); + t.after(async () => { + await admin.close(); + auth.close(); + rmSync(dir, { recursive: true, force: true }); + }); + + async function request(path, options = {}) { + const target = new URL(`${address.origin}${path}`); + const received = await new Promise((resolvePromise, rejectPromise) => { + const outgoing = http.request({ + host: target.hostname, + port: target.port, + path: options.rawPath ?? `${target.pathname}${target.search}`, + method: options.method ?? "GET", + headers: { ...(options.headers ?? {}) } + }, (response) => { + const chunks = []; + response.on("data", (chunk) => chunks.push(chunk)); + response.on("end", () => resolvePromise({ + status: response.statusCode, + headers: new Headers(response.headers), + text: Buffer.concat(chunks).toString("utf8") + })); + }); + outgoing.once("error", rejectPromise); + if (options.body !== undefined) outgoing.write(options.body); + outgoing.end(); + }); + const response = { status: received.status, headers: received.headers }; + const text = received.text; + let json = null; + if (text && response.headers.get("content-type")?.startsWith("application/json")) { + json = JSON.parse(text); + } + return { response, text, json }; + } + + return { + ...services, + admin, + address, + auth, + controlToken, + request + }; +} + +async function browserSession(harness, headers = {}) { + const created = await harness.request("/api/v1/session", { + method: "POST", + headers: { + authorization: `Bearer ${harness.controlToken}`, + origin: harness.address.origin, + ...headers + } + }); + assert.equal(created.response.status, 200); + return { + cookie: created.response.headers.get("set-cookie").split(";")[0], + csrfToken: created.json.csrfToken + }; +} + +function bearer(harness, extra = {}) { + return { authorization: `Bearer ${harness.controlToken}`, ...extra }; +} + +function assertNoSensitiveResponse(result) { + const serialized = `${result.text}\n${JSON.stringify(result.json)}`; + for (const forbidden of [SECRET, CREDENTIAL_REF, "credentialRef", "apiKey", "backupPath"]) { + assert.equal(serialized.includes(forbidden), false, `response leaked ${forbidden}`); + } +} + +test("enforces Host, Origin, disabled CORS, bearer, and browser read sessions", async (t) => { + const harness = await createHarness(t); + + const badHost = await harness.request("/api/v1/session", { + method: "POST", + headers: bearer(harness, { host: "attacker.example" }) + }); + assert.equal(badHost.response.status, 403); + assert.equal(badHost.json.error.code, "API_HOST_INVALID"); + + const badOrigin = await harness.request("/api/v1/session", { + method: "POST", + headers: bearer(harness, { origin: "https://attacker.example" }) + }); + assert.equal(badOrigin.response.status, 403); + assert.equal(badOrigin.json.error.code, "API_ORIGIN_INVALID"); + + const preflight = await harness.request("/api/v1/providers", { + method: "OPTIONS", + headers: { + origin: harness.address.origin, + "access-control-request-method": "POST" + } + }); + assert.equal(preflight.response.status, 403); + assert.equal(preflight.json.error.code, "API_CORS_FORBIDDEN"); + assert.equal(preflight.response.headers.has("access-control-allow-origin"), false); + + const unauthenticated = await harness.request("/api/v1/status"); + assert.equal(unauthenticated.response.status, 401); + assert.equal(unauthenticated.json.error.code, "AUTH_REQUIRED"); + + const session = await browserSession(harness); + const browserRead = await harness.request("/api/v1/status", { + headers: { cookie: session.cookie, origin: harness.address.origin } + }); + assert.equal(browserRead.response.status, 200); + const providers = await harness.request("/api/v1/providers", { + headers: { cookie: session.cookie, origin: harness.address.origin } + }); + assert.equal(providers.response.status, 200); + for (const result of [badHost, badOrigin, preflight, unauthenticated, browserRead, providers]) { + assert.equal(result.response.headers.has("access-control-allow-origin"), false); + assert.equal(result.response.headers.get("cache-control"), "no-store"); + assertNoSensitiveResponse(result); + } +}); + +test("requires CSRF for browser mutations while bearer mutations bypass CSRF", async (t) => { + const harness = await createHarness(t); + const session = await browserSession(harness); + const missingCsrf = await harness.request("/api/v1/proxy/stop", { + method: "POST", + headers: { cookie: session.cookie, origin: harness.address.origin } + }); + assert.equal(missingCsrf.response.status, 403); + assert.equal(missingCsrf.json.error.code, "AUTH_CSRF_INVALID"); + + const browserMutation = await harness.request("/api/v1/proxy/stop", { + method: "POST", + headers: { + cookie: session.cookie, + origin: harness.address.origin, + "x-crp-csrf": session.csrfToken + } + }); + assert.equal(browserMutation.response.status, 200); + + const cliMutation = await harness.request("/api/v1/proxy/start", { + method: "POST", + headers: bearer(harness) + }); + assert.equal(cliMutation.response.status, 200); + for (const result of [missingCsrf, browserMutation, cliMutation]) { + assert.equal(result.response.headers.has("access-control-allow-origin"), false); + assertNoSensitiveResponse(result); + } +}); + +test("accepts identity-bound Supervisor shutdown through bearer or browser CSRF and schedules once", async (t) => { + const shutdownGate = createGate(); + let shutdownCalls = 0; + const harness = await createHarness(t, { + requestSupervisorShutdown() { + shutdownCalls += 1; + return shutdownGate.promise; + } + }); + const identity = { + supervisorPid: 9001, + startedAt: "2026-07-13T00:00:00.000Z" + }; + const body = JSON.stringify(identity); + const session = await browserSession(harness); + + const unauthenticated = await harness.request("/api/v1/supervisor/shutdown", { + method: "POST", + headers: { "content-type": "application/json" }, + body + }); + assert.equal(unauthenticated.response.status, 401); + assert.equal(unauthenticated.json.error.code, "AUTH_REQUIRED"); + + const missingCsrf = await harness.request("/api/v1/supervisor/shutdown", { + method: "POST", + headers: { + cookie: session.cookie, + origin: harness.address.origin, + "content-type": "application/json" + }, + body + }); + assert.equal(missingCsrf.response.status, 403); + assert.equal(missingCsrf.json.error.code, "AUTH_CSRF_INVALID"); + + const [browserResult, bearerResult] = await Promise.all([ + harness.request("/api/v1/supervisor/shutdown", { + method: "POST", + headers: { + cookie: session.cookie, + origin: harness.address.origin, + "x-crp-csrf": session.csrfToken, + "content-type": "application/json" + }, + body + }), + harness.request("/api/v1/supervisor/shutdown", { + method: "POST", + headers: { ...bearer(harness), "content-type": "application/json" }, + body + }) + ]); + + for (const result of [unauthenticated, missingCsrf, browserResult, bearerResult]) { + assertNoSensitiveResponse(result); + } + for (const result of [browserResult, bearerResult]) { + assert.equal(result.response.status, 202, result.text); + assert.deepEqual(result.json, { + shutdown: { accepted: true, ...identity } + }); + } + await new Promise((resolvePromise) => setImmediate(resolvePromise)); + assert.equal(shutdownCalls, 1); + shutdownGate.resolve(); +}); + +test("rejects stale or malformed Supervisor shutdown identities without scheduling close", async (t) => { + let shutdownCalls = 0; + const harness = await createHarness(t, { + requestSupervisorShutdown() { + shutdownCalls += 1; + } + }); + const headers = { ...bearer(harness), "content-type": "application/json" }; + const identity = { + supervisorPid: 9001, + startedAt: "2026-07-13T00:00:00.000Z" + }; + + for (const body of [ + { ...identity, supervisorPid: 9002 }, + { ...identity, startedAt: "2026-07-13T00:00:01.000Z" } + ]) { + const stale = await harness.request("/api/v1/supervisor/shutdown", { + method: "POST", + headers, + body: JSON.stringify(body) + }); + assertNoSensitiveResponse(stale); + assert.equal(stale.response.status, 409, stale.text); + assert.equal(stale.json.error.code, "SUPERVISOR_IDENTITY_CHANGED"); + } + + for (const body of [ + {}, + { ...identity, supervisorPid: 0 }, + { ...identity, supervisorPid: 4_294_967_296 }, + { ...identity, startedAt: "2026-07-13T00:00:00Z" }, + { ...identity, unexpected: true } + ]) { + const malformed = await harness.request("/api/v1/supervisor/shutdown", { + method: "POST", + headers, + body: JSON.stringify(body) + }); + assertNoSensitiveResponse(malformed); + assert.equal(malformed.response.status, 400, malformed.text); + assert.equal(malformed.json.error.code, "API_BODY_INVALID"); + } + + const emptyBody = await harness.request("/api/v1/supervisor/shutdown", { + method: "POST", + headers + }); + assert.equal(emptyBody.response.status, 400, emptyBody.text); + assert.equal(emptyBody.json.error.code, "API_BODY_INVALID"); + + const query = await harness.request("/api/v1/supervisor/shutdown?retry=1", { + method: "POST", + headers, + body: JSON.stringify(identity) + }); + assert.equal(query.response.status, 400, query.text); + assert.equal(query.json.error.code, "API_BODY_INVALID"); + + const wrongMethod = await harness.request("/api/v1/supervisor/shutdown", { + method: "GET", + headers: bearer(harness) + }); + assert.equal(wrongMethod.response.status, 405, wrongMethod.text); + assert.equal(wrongMethod.response.headers.get("allow"), "POST"); + assert.equal(wrongMethod.json.error.code, "API_METHOD_NOT_ALLOWED"); + + await new Promise((resolvePromise) => setImmediate(resolvePromise)); + assert.equal(shutdownCalls, 0); +}); + +test("fails closed when no Supervisor shutdown coordinator is injected", async (t) => { + const harness = await createHarness(t); + const result = await harness.request("/api/v1/supervisor/shutdown", { + method: "POST", + headers: { ...bearer(harness), "content-type": "application/json" }, + body: JSON.stringify({ + supervisorPid: 9001, + startedAt: "2026-07-13T00:00:00.000Z" + }) + }); + assertNoSensitiveResponse(result); + assert.equal(result.response.status, 503, result.text); + assert.equal(result.json.error.code, "SUPERVISOR_SHUTDOWN_UNAVAILABLE"); +}); + +test("finishes the shutdown response before closing the Admin server", async (t) => { + let adminToClose; + let shutdownCalls = 0; + const harness = await createHarness(t, { + requestSupervisorShutdown() { + shutdownCalls += 1; + return adminToClose.close(); + } + }); + adminToClose = harness.admin; + const closed = new Promise((resolvePromise) => { + harness.admin.server.once("close", resolvePromise); + }); + + const result = await harness.request("/api/v1/supervisor/shutdown", { + method: "POST", + headers: { ...bearer(harness), "content-type": "application/json" }, + body: JSON.stringify({ + supervisorPid: 9001, + startedAt: "2026-07-13T00:00:00.000Z" + }) + }); + assert.equal(result.response.status, 202, result.text); + assert.deepEqual(result.json, { + shutdown: { + accepted: true, + supervisorPid: 9001, + startedAt: "2026-07-13T00:00:00.000Z" + } + }); + await closed; + assert.equal(shutdownCalls, 1); +}); + +test("retries the shutdown coordinator after a failed asynchronous close attempt", async (t) => { + let shutdownCalls = 0; + const harness = await createHarness(t, { + requestSupervisorShutdown() { + shutdownCalls += 1; + if (shutdownCalls === 1) return Promise.reject(new Error("private close failure")); + return Promise.resolve(); + } + }); + const request = () => harness.request("/api/v1/supervisor/shutdown", { + method: "POST", + headers: { ...bearer(harness), "content-type": "application/json" }, + body: JSON.stringify({ + supervisorPid: 9001, + startedAt: "2026-07-13T00:00:00.000Z" + }) + }); + + const first = await request(); + assert.equal(first.response.status, 202, first.text); + await new Promise((resolvePromise) => setImmediate(resolvePromise)); + await new Promise((resolvePromise) => setImmediate(resolvePromise)); + const second = await request(); + assert.equal(second.response.status, 202, second.text); + await new Promise((resolvePromise) => setImmediate(resolvePromise)); + assert.equal(shutdownCalls, 2); +}); + +test("resumes a valid cookie session only through the strict same-origin bootstrap", async (t) => { + const harness = await createHarness(t); + const session = await browserSession(harness); + const originalSessionSecrets = [ + harness.controlToken, + session.cookie.split("=")[1], + session.csrfToken + ]; + const assertAuthSecretsAbsent = (result, secrets = originalSessionSecrets) => { + const surface = `${result.text}\n${JSON.stringify([...result.response.headers])}`; + for (const secret of secrets) assert.equal(surface.includes(secret), false); + }; + const resumeHeaders = { + cookie: session.cookie, + origin: harness.address.origin, + "x-crp-session-resume": "1" + }; + + const missingOrigin = await harness.request("/api/v1/session/resume", { + method: "POST", + headers: { cookie: session.cookie, "x-crp-session-resume": "1" } + }); + assertAuthSecretsAbsent(missingOrigin); + assert.equal(missingOrigin.response.status, 403); + assert.equal(missingOrigin.json.error.code, "API_ORIGIN_INVALID"); + + const missingHeader = await harness.request("/api/v1/session/resume", { + method: "POST", + headers: { cookie: session.cookie, origin: harness.address.origin } + }); + assertAuthSecretsAbsent(missingHeader); + assert.equal(missingHeader.response.status, 403); + assert.equal(missingHeader.json.error.code, "AUTH_CSRF_INVALID"); + + const wrongOrigin = await harness.request("/api/v1/session/resume", { + method: "POST", + headers: { ...resumeHeaders, origin: "https://attacker.example" } + }); + assertAuthSecretsAbsent(wrongOrigin); + assert.equal(wrongOrigin.response.status, 403); + assert.equal(wrongOrigin.json.error.code, "API_ORIGIN_INVALID"); + + const wrongHeader = await harness.request("/api/v1/session/resume", { + method: "POST", + headers: { ...resumeHeaders, "x-crp-session-resume": "yes" } + }); + assertAuthSecretsAbsent(wrongHeader); + assert.equal(wrongHeader.response.status, 403); + assert.equal(wrongHeader.json.error.code, "AUTH_CSRF_INVALID"); + + const queryRejected = await harness.request("/api/v1/session/resume?again=1", { + method: "POST", + headers: resumeHeaders + }); + assertAuthSecretsAbsent(queryRejected); + assert.equal(queryRejected.response.status, 400); + assert.equal(queryRejected.json.error.code, "API_BODY_INVALID"); + + const emptyQueryRejected = await harness.request("/api/v1/session/resume?", { + method: "POST", + headers: resumeHeaders, + rawPath: "/api/v1/session/resume?" + }); + assertAuthSecretsAbsent(emptyQueryRejected); + assert.equal(emptyQueryRejected.response.status, 400); + assert.equal(emptyQueryRejected.json.error.code, "API_BODY_INVALID"); + + const normalizedPathRejected = await harness.request("/api/v1/ignored/../session/resume", { + method: "POST", + headers: resumeHeaders, + rawPath: "/api/v1/ignored/../session/resume" + }); + assertAuthSecretsAbsent(normalizedPathRejected); + assert.equal(normalizedPathRejected.response.status, 400); + assert.equal(normalizedPathRejected.json.error.code, "API_BODY_INVALID"); + + const bodyRejected = await harness.request("/api/v1/session/resume", { + method: "POST", + headers: { ...resumeHeaders, "content-type": "application/json" }, + body: "{}" + }); + assertAuthSecretsAbsent(bodyRejected); + assert.equal(bodyRejected.response.status, 400); + assert.equal(bodyRejected.json.error.code, "API_BODY_INVALID"); + + const bearerRejected = await harness.request("/api/v1/session/resume", { + method: "POST", + headers: { ...resumeHeaders, authorization: `Bearer ${harness.controlToken}` } + }); + assertAuthSecretsAbsent(bearerRejected); + assert.equal(bearerRejected.response.status, 401); + assert.equal(bearerRejected.json.error.code, "AUTH_REQUIRED"); + + const wrongMethod = await harness.request("/api/v1/session/resume", { + method: "GET", + headers: resumeHeaders + }); + assertAuthSecretsAbsent(wrongMethod); + assert.equal(wrongMethod.response.status, 405); + assert.equal(wrongMethod.json.error.code, "API_METHOD_NOT_ALLOWED"); + + const resumed = await harness.request("/api/v1/session/resume", { + method: "POST", + headers: resumeHeaders + }); + assertAuthSecretsAbsent(resumed); + assert.equal(resumed.response.status, 200); + assert.deepEqual(Object.keys(resumed.json).sort(), ["csrfToken", "expiresAt"]); + assert.match(resumed.json.csrfToken, /^[A-Za-z0-9_-]{43}$/); + assert.match(resumed.json.expiresAt, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + const resumedCookie = resumed.response.headers.get("set-cookie").split(";")[0]; + const resumedSessionSecrets = [resumedCookie.split("=")[1], resumed.json.csrfToken]; + assert.notEqual(resumedCookie, session.cookie); + + const oldSession = await harness.request("/api/v1/status", { + headers: { cookie: session.cookie, origin: harness.address.origin } + }); + assertAuthSecretsAbsent(oldSession); + assert.equal(oldSession.response.status, 401); + assert.equal(oldSession.json.error.code, "AUTH_REQUIRED"); + + const missingCsrf = await harness.request("/api/v1/proxy/stop", { + method: "POST", + headers: { cookie: resumedCookie, origin: harness.address.origin } + }); + assertAuthSecretsAbsent(missingCsrf, [...originalSessionSecrets, ...resumedSessionSecrets]); + assert.equal(missingCsrf.response.status, 403); + assert.equal(missingCsrf.json.error.code, "AUTH_CSRF_INVALID"); + + const mutation = await harness.request("/api/v1/proxy/stop", { + method: "POST", + headers: { + cookie: resumedCookie, + origin: harness.address.origin, + "x-crp-csrf": resumed.json.csrfToken + } + }); + assertAuthSecretsAbsent(mutation, [...originalSessionSecrets, ...resumedSessionSecrets]); + assert.equal(mutation.response.status, 200); + assert.equal(JSON.stringify(resumed.json).includes(harness.controlToken), false); + for (const result of [ + missingOrigin, + missingHeader, + wrongOrigin, + wrongHeader, + queryRejected, + emptyQueryRejected, + normalizedPathRejected, + bodyRejected, + bearerRejected, + wrongMethod, + oldSession, + missingCsrf, + mutation + ]) { + assertNoSensitiveResponse(result); + } +}); + +test("routes every approved Admin API operation through injected services", async (t) => { + const harness = await createHarness(t); + const authHeaders = bearer(harness); + const requests = [ + ["GET", "/api/v1/status", undefined, 200], + ["GET", "/api/v1/metrics/overview?window=7d", undefined, 200], + ["GET", "/api/v1/providers", undefined, 200], + ["POST", "/api/v1/providers", { + provider: { name: "Backup", baseUrl: "https://backup.example/v1" }, + credential: SECRET + }, 201], + ["GET", "/api/v1/providers/provider-2", undefined, 200], + ["PATCH", "/api/v1/providers/provider-2", { + patch: { name: "Backup Updated" }, + replacementCredential: SECRET + }, 200], + ["POST", "/api/v1/providers/provider-2/test", { + model: "test-model", + activateIfNone: true + }, 200], + ["GET", "/api/v1/providers/provider-2/models", undefined, 200], + ["POST", "/api/v1/providers/provider-2/models", undefined, 200], + ["POST", "/api/v1/providers/provider-2/activate", undefined, 200], + ["POST", "/api/v1/proxy/start", undefined, 200], + ["POST", "/api/v1/proxy/stop", undefined, 200], + ["POST", "/api/v1/proxy/restart", undefined, 200], + ["GET", "/api/v1/activity?limit=3&offset=2", undefined, 200], + ["GET", "/api/v1/settings", undefined, 200], + ["PATCH", "/api/v1/settings", { captureEnabled: true }, 409, "SETTINGS_READ_ONLY"], + ["POST", "/api/v1/codex/bootstrap", undefined, 200], + ["POST", "/api/v1/diagnostics/export", undefined, 200], + ["DELETE", "/api/v1/providers/provider-2", undefined, 200] + ]; + + for (const [method, path, body, expectedStatus, expectedCode] of requests) { + const result = await harness.request(path, { + method, + headers: { ...authHeaders, ...(body === undefined ? {} : { "content-type": "application/json" }) }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }) + }); + assert.equal(result.response.status, expectedStatus, `${method} ${path}: ${result.text}`); + if (expectedCode) assert.equal(result.json.error.code, expectedCode); + assertNoSensitiveResponse(result); + } + + const activityCall = harness.calls.find((call) => call[0] === "activity"); + assert.deepEqual(activityCall, ["activity", 6]); + const createCall = harness.calls.find((call) => call[0] === "createProvider"); + assert.equal(createCall[2], SECRET); + assert.equal(createCall.length, 3); + assert.ok(harness.calls.some((call) => call[0] === "restartProxy")); + assert.ok(harness.calls.some((call) => call[0] === "bootstrap")); + assert.ok(harness.calls.some((call) => call[0] === "diagnostics")); + assert.ok(harness.calls.some((call) => call[0] === "metrics" && call[1] === "7d")); + assert.equal(harness.calls.some((call) => call[0] === "updateSettings"), false); +}); + +test("metrics overview is authenticated read-only, bounded, and positively projected", async (t) => { + const harness = await createHarness(t); + const unauthenticated = await harness.request("/api/v1/metrics/overview"); + assert.equal(unauthenticated.response.status, 401); + + const session = await browserSession(harness); + const result = await harness.request("/api/v1/metrics/overview?window=24h", { + headers: { cookie: session.cookie, origin: harness.address.origin } + }); + assert.equal(result.response.status, 200, result.text); + assertNoSensitiveResponse(result); + for (const forbidden of ["rawError", "requestId", "url", "headers", "body", "metric-secret-sentinel"]) { + assert.equal(result.text.includes(forbidden), false); + } + assert.deepEqual(result.json, { + metrics: { + window: "24h", + bucketMinutes: 60, + storageState: "ready", + summary: { + requests: 3, + results: { + success: 2, + upstreamRejected: 1, + upstreamError: 0, + timeout: 0, + networkError: 0, + clientAbort: 0 + }, + tokens: { input: 100, output: 25, observedRequests: 2 }, + latency: { p50UpperBoundMs: 500, p95UpperBoundMs: 2_500, overflowRequests: 0 }, + responseStart: { p50UpperBoundMs: 250, p95UpperBoundMs: 1_000, overflowRequests: 0 } + }, + series: [{ + start: "2026-07-13T00:00:00.000Z", + requests: 3, + results: { + success: 2, + upstreamRejected: 1, + upstreamError: 0, + timeout: 0, + networkError: 0, + clientAbort: 0 + }, + tokens: { input: 100, output: 25, observedRequests: 2 } + }], + providers: [{ + providerId: "provider-1", + requests: 3, + successfulRequests: 2, + tokens: { input: 100, output: 25, observedRequests: 2 }, + latency: { p50UpperBoundMs: 500, p95UpperBoundMs: 2_500, overflowRequests: 0 } + }], + providerOtherRequests: 0, + models: [{ + model: "gpt-5-codex", + requests: 3, + tokens: { input: 100, output: 25, observedRequests: 2 } + }], + modelOtherRequests: 0, + dataQuality: { + unknownModelRequests: 0, + modelOverflowRequests: 0, + providerOverflowRequests: 0, + droppedObservations: 0 + } + } + }); + + for (const path of [ + "/api/v1/metrics/overview?window=30d", + "/api/v1/metrics/overview?window=24h&window=7d", + "/api/v1/metrics/overview?unknown=1" + ]) { + const invalid = await harness.request(path, { headers: bearer(harness) }); + assert.equal(invalid.response.status, 400); + assert.equal(invalid.json.error.code, "API_BODY_INVALID"); + } + const wrongMethod = await harness.request("/api/v1/metrics/overview", { + method: "POST", + headers: bearer(harness) + }); + assert.equal(wrongMethod.response.status, 405); + assert.equal(wrongMethod.response.headers.get("allow"), "GET"); +}); + +test("projects only the bounded history-repair summary and pending Codex state", async (t) => { + const harness = await createHarness(t); + const bootstrap = await harness.request("/api/v1/codex/bootstrap", { + method: "POST", + headers: bearer(harness) + }); + const status = await harness.request("/api/v1/status", { + headers: bearer(harness) + }); + + for (const result of [bootstrap, status]) assertNoSensitiveResponse(result); + const bootstrapText = `${bootstrap.text}\n${JSON.stringify(bootstrap.json)}`; + for (const forbidden of ["/private/", "rollout.jsonl", "private session body", "rolloutPaths", "sessionBody"]) { + assert.equal(bootstrapText.includes(forbidden), false); + } + assert.equal(bootstrap.response.status, 200, bootstrap.text); + assert.deepEqual(bootstrap.json, { + result: { + changed: true, + backupCreated: true, + historyRepair: { + required: true, + completed: true, + resumed: false, + backupCreated: true, + rolloutFiles: 0, + rolloutRecords: 0, + sqliteFiles: 1_000_000, + sqliteRows: 4, + encryptedContentDetected: true + } + } + }); + assert.equal(status.response.status, 200, status.text); + assert.deepEqual(status.json.codex, { + configured: true, + modelProvider: "OpenAI", + proxyUrl: "http://127.0.0.1:15100", + historyRepairPending: false + }); +}); + +test("Admin rejects activation and Worker start or restart while Codex is pending or unconfigured", async (t) => { + const harness = await createHarness(t); + const before = harness.calls.length; + + for (const state of [ + { configured: true, historyRepairPending: true }, + { configured: false, historyRepairPending: true }, + { configured: false, historyRepairPending: false } + ]) { + Object.assign(harness.codexService.state, state); + for (const path of [ + "/api/v1/providers/provider-1/activate", + "/api/v1/proxy/start", + "/api/v1/proxy/restart" + ]) { + const result = await harness.request(path, { + method: "POST", + headers: bearer(harness) + }); + assertNoSensitiveResponse(result); + assert.equal(result.response.status, 409, result.text); + assert.deepEqual(result.json.error, { + code: "CODEX_NOT_READY", + message: "The Codex configuration is not ready.", + action: "Complete Codex bootstrap before activating a provider or starting or restarting the proxy.", + requestId: result.json.error.requestId, + details: {} + }); + } + } + assert.deepEqual( + harness.calls.slice(before).filter(([operation]) => ( + operation === "activate" || operation === "startProxy" || operation === "restartProxy" + )), + [] + ); +}); + +test("Admin serializes concurrent bootstrap, activation, and Worker start before readiness recheck", async (t) => { + const bootstrapStarted = createGate(); + const releaseBootstrap = createGate(); + const startStarted = createGate(); + const releaseStart = createGate(); + const events = []; + const state = { + configured: false, + modelProvider: "OpenAI", + proxyUrl: "http://127.0.0.1:15100", + historyRepairPending: true + }; + const codexService = { + async bootstrap() { + events.push("bootstrap-start"); + bootstrapStarted.resolve(); + await releaseBootstrap.promise; + Object.assign(state, { configured: true, historyRepairPending: false }); + events.push("bootstrap-complete"); + return { changed: true, backupPath: null, historyRepair: NO_HISTORY_REPAIR }; + }, + async getStatus() { + return structuredClone(state); + } + }; + const providerService = { + async startProxy() { + events.push("start-start"); + startStarted.resolve(); + await releaseStart.promise; + events.push("start-complete"); + return workerState(); + }, + async activate(id) { + events.push("activate"); + return { + activeProviderId: id, + activeProvider: publicProvider(), + generation: 1, + worker: workerState() + }; + } + }; + const harness = await createHarness(t, { codexService, providerService }); + const headers = bearer(harness); + + const bootstrapping = harness.request("/api/v1/codex/bootstrap", { + method: "POST", + headers + }); + await bootstrapStarted.promise; + const starting = harness.request("/api/v1/proxy/start", { + method: "POST", + headers + }); + await new Promise((resolvePromise) => setImmediate(resolvePromise)); + const activating = harness.request("/api/v1/providers/provider-1/activate", { + method: "POST", + headers + }); + await new Promise((resolvePromise) => setImmediate(resolvePromise)); + assert.deepEqual(events, ["bootstrap-start"]); + releaseBootstrap.resolve(); + + await startStarted.promise; + assert.deepEqual(events, ["bootstrap-start", "bootstrap-complete", "start-start"]); + releaseStart.resolve(); + + const [bootstrapResult, startResult, activateResult] = await Promise.all([ + bootstrapping, + starting, + activating + ]); + assert.equal(bootstrapResult.response.status, 200, bootstrapResult.text); + assert.equal(startResult.response.status, 200, startResult.text); + assert.equal(activateResult.response.status, 200, activateResult.text); + assert.deepEqual(events, [ + "bootstrap-start", + "bootstrap-complete", + "start-start", + "start-complete", + "activate" + ]); +}); + +test("provider tests opt into initial selection explicitly and project only safe fields", async (t) => { + const harness = await createHarness(t); + const headers = { ...bearer(harness), "content-type": "application/json" }; + const automatic = await harness.request("/api/v1/providers/provider-1/test", { + method: "POST", + headers, + body: JSON.stringify({ model: "test-model", activateIfNone: true }) + }); + assertNoSensitiveResponse(automatic); + assert.equal(automatic.response.status, 200, automatic.text); + assert.deepEqual(automatic.json, { + result: { + ok: true, + code: null, + initialActivation: { + automatic: true, + activeProviderId: "provider-1", + workerStarted: false + } + } + }); + + const defaulted = await harness.request("/api/v1/providers/provider-1/test", { + method: "POST", + headers, + body: JSON.stringify({ model: "test-model" }) + }); + assertNoSensitiveResponse(defaulted); + assert.equal(defaulted.response.status, 200, defaulted.text); + assert.deepEqual(defaulted.json, { + result: { ok: true, code: null, initialActivation: null } + }); + assert.deepEqual( + harness.calls.filter(([operation]) => operation === "testProvider"), + [ + ["testProvider", "provider-1", "test-model", { activateIfNone: true }], + ["testProvider", "provider-1", "test-model", { activateIfNone: false }] + ] + ); + + for (const body of [ + { model: "test-model", activateIfNone: "true" }, + { model: "test-model", activateIfNone: true, unexpected: true } + ]) { + const invalid = await harness.request("/api/v1/providers/provider-1/test", { + method: "POST", + headers, + body: JSON.stringify(body) + }); + assertNoSensitiveResponse(invalid); + assert.equal(invalid.response.status, 400, invalid.text); + assert.equal(invalid.json.error.code, "API_BODY_INVALID"); + } +}); + +test("model catalog routes distinguish cached reads from refreshes with strict projections", async (t) => { + const harness = await createHarness(t); + const headers = bearer(harness); + const cached = await harness.request("/api/v1/providers/provider-1/models", { headers }); + assertNoSensitiveResponse(cached); + assert.equal(cached.response.status, 200, cached.text); + assert.deepEqual(cached.json, { + modelCatalog: { + providerId: "provider-1", + state: "stale", + fetchedAt: "2026-07-12T00:00:00.000Z", + expiresAt: "2026-07-13T00:00:00.000Z", + models: ["cached-model"] + } + }); + + const refreshed = await harness.request("/api/v1/providers/provider-1/models", { + method: "POST", + headers + }); + assertNoSensitiveResponse(refreshed); + assert.equal(refreshed.response.status, 200, refreshed.text); + assert.deepEqual(refreshed.json, { + modelCatalog: { + providerId: "provider-1", + state: "fresh", + fetchedAt: "2026-07-13T00:00:00.000Z", + expiresAt: "2026-07-14T00:00:00.000Z", + models: ["fresh-model"] + } + }); + assert.deepEqual( + harness.calls.filter(([operation]) => operation.includes("ProviderModels")), + [ + ["getProviderModels", "provider-1"], + ["refreshProviderModels", "provider-1"] + ] + ); + + const bodyRejected = await harness.request("/api/v1/providers/provider-1/models", { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: "{}" + }); + assertNoSensitiveResponse(bodyRejected); + assert.equal(bodyRejected.response.status, 400, bodyRejected.text); + assert.equal(bodyRejected.json.error.code, "API_BODY_INVALID"); + + const methodRejected = await harness.request("/api/v1/providers/provider-1/models", { + method: "DELETE", + headers + }); + assertNoSensitiveResponse(methodRejected); + assert.equal(methodRejected.response.status, 405, methodRejected.text); + assert.equal(methodRejected.response.headers.get("allow"), "GET, POST"); + assert.equal(methodRejected.json.error.code, "API_METHOD_NOT_ALLOWED"); +}); + +test("enforces JSON content type, exact shape, and a bounded request body", async (t) => { + const harness = await createHarness(t, { maxBodyBytes: 256 }); + const headers = bearer(harness); + const valid = await harness.request("/api/v1/providers", { + method: "POST", + headers: { ...headers, "content-type": "application/json; charset=utf-8" }, + body: JSON.stringify({ + provider: { name: "Bounded", baseUrl: "https://bounded.example/v1" }, + credential: "write-only-test-value" + }) + }); + assert.equal(valid.response.status, 201, valid.text); + + const cases = [ + [{ + method: "POST", + headers: { ...headers, "content-type": "text/plain" }, + body: "not-json" + }, 415, "API_CONTENT_TYPE_UNSUPPORTED"], + [{ + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: "{" + }, 400, "API_BODY_INVALID"], + [{ + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ credential: "x".repeat(512) }) + }, 413, "API_BODY_TOO_LARGE"], + [{ + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ provider: {}, credential: "x", unexpected: true }) + }, 400, "API_BODY_INVALID"], + [{ + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ + provider: { name: "Rejected fallback", baseUrl: "https://fallback.example/v1" }, + credential: SECRET, + fallbackConsent: true + }) + }, 400, "API_BODY_INVALID"] + ]; + for (const [options, status, code] of cases) { + const result = await harness.request("/api/v1/providers", options); + assert.equal(result.response.status, status, result.text); + assert.equal(result.json.error.code, code); + assertNoSensitiveResponse(result); + } + for (const [path, options] of [ + ["/api/v1/session", { + method: "POST", + headers: { ...bearer(harness), "content-type": "application/json" }, + body: "{}" + }], + ["/api/v1/proxy/start", { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: "{}" + }] + ]) { + const result = await harness.request(path, options); + assert.equal(result.response.status, 400, result.text); + assert.equal(result.json.error.code, "API_BODY_INVALID"); + } + assertNoSensitiveResponse(valid); +}); + +test("returns stable sanitized errors and strict route, method, and path failures", async (t) => { + const harness = await createHarness(t); + const headers = bearer(harness); + const conflict = await harness.request("/api/v1/providers/provider-1", { + method: "DELETE", + headers + }); + assert.equal(conflict.response.status, 409); + assert.equal(conflict.json.error.code, "PROVIDER_ACTIVE"); + assert.equal(typeof conflict.json.error.requestId, "string"); + assert.deepEqual(conflict.json.error.details, { authorization: "[REDACTED]" }); + + const cases = [ + ["/api/v1", { headers }, 404, "API_NOT_FOUND"], + ["/api/v1/missing", { headers }, 404, "API_NOT_FOUND"], + ["/api/v1/providers", { method: "PUT", headers }, 405, "API_METHOD_NOT_ALLOWED"], + ["/api/v1/providers/provider-2%2Factivate", { headers }, 404, "API_NOT_FOUND"] + ]; + for (const [path, options, status, code] of cases) { + const result = await harness.request(path, options); + assert.equal(result.response.status, status, `${path}: ${result.text}`); + assert.equal(result.json.error.code, code); + assertNoSensitiveResponse(result); + } + assertNoSensitiveResponse(conflict); +}); + +test("serves only explicit static assets with safe headers and an index fallback", async (t) => { + const harness = await createHarness(t); + for (const [path, type, content] of [ + ["/", "text/html; charset=utf-8", "CRP test UI"], + ["/index.html", "text/html; charset=utf-8", "CRP test UI"], + ["/styles.css", "text/css; charset=utf-8", "color: black"], + ["/app.js", "text/javascript; charset=utf-8", "__crpTest"], + ["/providers/provider-1", "text/html; charset=utf-8", "CRP test UI"] + ]) { + const result = await harness.request(path); + assert.equal(result.response.status, 200); + assert.equal(result.response.headers.get("content-type"), type); + assert.equal(result.response.headers.get("cache-control"), "no-store"); + assert.equal(result.response.headers.get("x-content-type-options"), "nosniff"); + assert.match(result.text, new RegExp(content)); + } + + const favicon = await harness.request("/favicon.ico"); + assert.equal(favicon.response.status, 204); + assert.equal(favicon.text, ""); + assert.equal(favicon.response.headers.get("cache-control"), "no-store"); + + const missingAsset = await harness.request("/missing.css"); + assert.equal(missingAsset.response.status, 404); + const postUi = await harness.request("/", { method: "POST" }); + assert.equal(postUi.response.status, 405); +}); + +function createGate() { + let resolve; + let reject; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +function supervisorDependencies(t, { + listenGate = createGate(), + adminCloseGate = null, + workerCloseImpl = null +} = {}) { + const home = mkdtempSync(join(os.tmpdir(), "crp-supervisor-")); + const paths = { + ...getPaths(home), + runtimeConfigPath: join(home, ".codex-remote-proxy", "node", "proxy-config.json"), + capturePath: join(home, ".codex-remote-proxy", "traffic.sqlite3") + }; + const order = []; + const worker = { + getPublicState: () => workerState(), + close: () => { + order.push("worker.close"); + return workerCloseImpl?.() ?? Promise.resolve(); + } + }; + const activity = { append() {}, list: () => [] }; + const credentials = { backend: "native" }; + const registry = { getDocument: () => ({ settings: { + proxyHost: "127.0.0.1", + proxyPort: 15100, + adminHost: "127.0.0.1", + adminPort: 15101, + captureEnabled: false + } }) }; + const provider = { getStatus: async () => ({ worker: workerState() }) }; + const metrics = { + observations: [], + dropped: 0, + record(observation) { + this.observations.push(structuredClone(observation)); + return true; + }, + noteDropped() { + this.dropped += 1; + }, + getOverview() { + return null; + }, + close() { + order.push("metrics.close"); + } + }; + const auth = { + close() { order.push("auth.close"); } + }; + let adminOptions = null; + const admin = { + async listen() { + order.push("admin.listen"); + return await listenGate.promise; + }, + close() { + order.push("admin.close"); + return adminCloseGate?.promise ?? Promise.resolve(); + } + }; + const options = { + home, + paths, + pid: 4242, + now: () => "2026-07-13T03:00:00.000Z", + activityStoreFactory: ({ path }) => { + order.push("activity"); + assert.equal(path, paths.activityPath); + return activity; + }, + credentialStoreFactory: ({ paths: received }) => { + order.push("credential"); + assert.deepEqual(received, paths); + return credentials; + }, + migrate: async (input) => { + order.push("migration"); + assert.equal(input.credentialStore, credentials); + assert.equal(input.activityStore, activity); + return { migrated: false, reason: "no-legacy-config" }; + }, + registryFactory: ({ path }) => { + order.push("registry"); + assert.equal(path, paths.registryPath); + assert.ok(order.includes("migration")); + return registry; + }, + metricsStoreFactory: ({ path, now }) => { + order.push("metrics"); + assert.equal(path, paths.metricsPath); + assert.equal(now(), "2026-07-13T03:00:00.000Z"); + return metrics; + }, + workerManagerFactory: (input) => { + order.push("worker"); + assert.equal(input.recordMetric({ providerId: "provider-test" }), true); + input.noteDroppedMetric(); + return worker; + }, + providerServiceFactory: (input) => { + order.push("provider"); + assert.equal(input.registry, registry); + assert.equal(input.credentialStore, credentials); + assert.equal(input.workerManager, worker); + return provider; + }, + authFactory: ({ controlTokenPath }) => { + order.push("auth"); + assert.equal(controlTokenPath, paths.controlTokenPath); + return auth; + }, + adminServerFactory: (input) => { + order.push("admin"); + adminOptions = input; + assert.equal(input.auth, auth); + assert.equal(input.providerService, provider); + assert.equal(input.metricsService, metrics); + return admin; + } + }; + t.after(() => rmSync(home, { recursive: true, force: true })); + return { + home, + paths, + order, + worker, + admin, + listenGate, + registry, + metrics, + options, + getAdminOptions: () => adminOptions + }; +} + +test("supervisor migrates before registry construction and writes private state only after ready", async (t) => { + const harness = supervisorDependencies(t); + const supervisor = await createSupervisor(harness.options); + assert.deepEqual(harness.order, [ + "activity", + "credential", + "migration", + "registry", + "metrics", + "worker", + "provider", + "auth", + "admin" + ]); + + const listening = supervisor.listen(); + assert.equal(existsSync(harness.paths.statePath), false); + harness.listenGate.resolve({ + host: "127.0.0.1", + port: 15101, + authority: "127.0.0.1:15101", + origin: "http://127.0.0.1:15101" + }); + const address = await listening; + assert.equal(address.port, 15101); + assert.equal(existsSync(harness.paths.statePath), true); + if (process.platform !== "win32") { + assert.equal(statSync(harness.paths.statePath).mode & 0o777, 0o600); + } + const stateBytes = readFileSync(harness.paths.statePath, "utf8"); + const state = JSON.parse(stateBytes); + assert.equal(state.schemaVersion, 1); + assert.equal(state.supervisorPid, 4242); + assert.equal(state.startedAt, "2026-07-13T03:00:00.000Z"); + assert.equal(state.admin.origin, "http://127.0.0.1:15101"); + assert.equal(state.worker.generation, 1); + for (const forbidden of [SECRET, "apiKey", "settings", CREDENTIAL_REF]) { + assert.equal(stateBytes.includes(forbidden), false); + } + + const firstClose = supervisor.close(); + const secondClose = supervisor.close(); + assert.equal(firstClose, secondClose); + await firstClose; + assert.deepEqual(harness.order.slice(-4), [ + "worker.close", "admin.close", "auth.close", "metrics.close" + ]); + assert.equal(existsSync(harness.paths.statePath), false); +}); + +test("supervisor cleans up in reverse order when Admin readiness fails", async (t) => { + const harness = supervisorDependencies(t); + const supervisor = await createSupervisor(harness.options); + const failure = new Error(`private listen failure ${SECRET}`); + harness.listenGate.reject(failure); + await assert.rejects(() => supervisor.listen(), (error) => error === failure); + assert.deepEqual(harness.order.slice(-4), [ + "worker.close", "admin.close", "auth.close", "metrics.close" + ]); + assert.equal(existsSync(harness.paths.statePath), false); +}); + +test("supervisor preserves discoverable state and retries after Worker close fails", async (t) => { + const failure = new Error("private Worker close failure"); + let closeAttempts = 0; + const harness = supervisorDependencies(t, { + workerCloseImpl() { + closeAttempts += 1; + if (closeAttempts === 1) return Promise.reject(failure); + return Promise.resolve(); + } + }); + harness.listenGate.resolve({ + host: "127.0.0.1", + port: 15101, + authority: "127.0.0.1:15101", + origin: "http://127.0.0.1:15101" + }); + const supervisor = await createSupervisor(harness.options); + await supervisor.listen(); + + await assert.rejects(() => supervisor.close(), (error) => error === failure); + assert.equal(existsSync(harness.paths.statePath), true); + assert.equal(harness.order.filter((entry) => entry === "admin.close").length, 0); + await supervisor.close(); + assert.equal(closeAttempts, 2); + assert.equal(harness.order.filter((entry) => entry === "admin.close").length, 1); + assert.equal(existsSync(harness.paths.statePath), false); +}); + +test("supervisor retries an interrupted fixed-marker state deletion", async (t) => { + const harness = supervisorDependencies(t); + const claimPath = `${harness.paths.statePath}.stale`; + let failClaimRemoval = true; + harness.options.stateFileOperations = { + ...realFileOperations, + rmSync(path, ...args) { + if (path === claimPath && failClaimRemoval) { + failClaimRemoval = false; + const error = new Error("private state removal failure"); + error.code = "EIO"; + throw error; + } + return realFileOperations.rmSync(path, ...args); + } + }; + harness.listenGate.resolve({ + host: "127.0.0.1", + port: 15101, + authority: "127.0.0.1:15101", + origin: "http://127.0.0.1:15101" + }); + const supervisor = await createSupervisor(harness.options); + await supervisor.listen(); + + await assert.rejects(() => supervisor.close(), (error) => error?.code === "EIO"); + assert.equal(existsSync(harness.paths.statePath), false); + assert.equal(existsSync(claimPath), true); + await supervisor.close(); + assert.equal(existsSync(harness.paths.statePath), false); + assert.equal(existsSync(claimPath), false); +}); + +test("supervisor cleans constructed resources when composition fails before listen", async (t) => { + const harness = supervisorDependencies(t); + const failure = new Error(`private composition failure ${SECRET}`); + harness.options.adminServerFactory = () => { + harness.order.push("admin"); + throw failure; + }; + await assert.rejects(() => createSupervisor(harness.options), (error) => error === failure); + assert.deepEqual(harness.order.slice(-3), ["auth.close", "worker.close", "metrics.close"]); + assert.equal(existsSync(harness.paths.statePath), false); +}); + +test("supervisor keeps Codex and state filesystem adapters independent", async (t) => { + const harness = supervisorDependencies(t); + const codexFileOperations = { boundary: "codex-only" }; + const historyRepair = { + plan() {}, + hasPending() { return false; }, + async run() {} + }; + let bootstrapInput = null; + let codexService = null; + harness.options.codexFileOperations = codexFileOperations; + harness.options.codexHistoryRepair = historyRepair; + harness.options.bootstrapCodex = async (input) => { + bootstrapInput = input; + return { changed: false, backupPath: null, historyRepair: NO_HISTORY_REPAIR }; + }; + harness.options.adminServerFactory = (input) => { + harness.order.push("admin"); + codexService = input.codexService; + return harness.admin; + }; + const supervisor = await createSupervisor(harness.options); + assert.deepEqual( + await codexService.bootstrap(), + { changed: false, backupPath: null, historyRepair: NO_HISTORY_REPAIR } + ); + assert.equal(bootstrapInput.fileOperations, codexFileOperations); + assert.equal(bootstrapInput.configPath, harness.paths.codexConfigPath); + assert.equal(bootstrapInput.historyRepair, historyRepair); + await supervisor.close(); +}); + +test("Supervisor injects the complete production history-recovery adapter", async (t) => { + const harness = supervisorDependencies(t); + let bootstrapInput; + let codexService; + harness.options.bootstrapCodex = async (input) => { + bootstrapInput = input; + return { changed: false, backupPath: null, historyRepair: NO_HISTORY_REPAIR }; + }; + harness.options.adminServerFactory = (input) => { + harness.order.push("admin"); + codexService = input.codexService; + return harness.admin; + }; + const supervisor = await createSupervisor(harness.options); + + await codexService.bootstrap(); + assert.deepEqual( + Object.keys(bootstrapInput.historyRepair).sort(), + ["hasPending", "inspectPending", "plan", "run"] + ); + for (const operation of Object.values(bootstrapInput.historyRepair)) { + assert.equal(typeof operation, "function"); + } + await supervisor.close(); +}); + +test("Supervisor Codex status is configured only when matching config has no pending repair", async (t) => { + const harness = supervisorDependencies(t); + const codexRoot = join(harness.home, ".codex"); + mkdirSync(codexRoot, { recursive: true }); + writeFileSync(harness.paths.codexConfigPath, [ + 'model_provider = "OpenAI"', + "", + "[model_providers.OpenAI]", + 'name = "OpenAI"', + 'base_url = "http://127.0.0.1:15100"', + 'wire_api = "responses"', + "requires_openai_auth = true", + "" + ].join("\n")); + let pending = true; + const pendingInputs = []; + const historyRepair = { + plan() {}, + hasPending(input) { + pendingInputs.push(input); + return pending; + }, + async run() {} + }; + let codexService; + harness.options.codexHistoryRepair = historyRepair; + harness.options.adminServerFactory = (input) => { + harness.order.push("admin"); + codexService = input.codexService; + return harness.admin; + }; + const supervisor = await createSupervisor(harness.options); + + assert.deepEqual(await codexService.getStatus(), { + configured: false, + modelProvider: "OpenAI", + proxyUrl: "http://127.0.0.1:15100", + historyRepairPending: true + }); + pending = false; + assert.deepEqual(await codexService.getStatus(), { + configured: true, + modelProvider: "OpenAI", + proxyUrl: "http://127.0.0.1:15100", + historyRepairPending: false + }); + writeFileSync(`${harness.paths.codexConfigPath}.crp.lock`, "managed-crash-lock\n"); + assert.deepEqual(await codexService.getStatus(), { + configured: false, + modelProvider: "OpenAI", + proxyUrl: "http://127.0.0.1:15100", + historyRepairPending: false + }); + assert.deepEqual(pendingInputs, [ + { codexRoot }, + { codexRoot }, + { codexRoot }, + { codexRoot }, + { codexRoot }, + { codexRoot } + ]); + await supervisor.close(); +}); + +test("Supervisor Codex status rechecks pending state after reading config", async (t) => { + const harness = supervisorDependencies(t); + const codexRoot = join(harness.home, ".codex"); + mkdirSync(codexRoot, { recursive: true }); + writeFileSync(harness.paths.codexConfigPath, [ + 'model_provider = "OpenAI"', + "[model_providers.OpenAI]", + 'name = "OpenAI"', + 'base_url = "http://127.0.0.1:15100"', + 'wire_api = "responses"', + "requires_openai_auth = true", + "" + ].join("\n")); + let checks = 0; + let codexService; + harness.options.codexHistoryRepair = { + plan() {}, + hasPending() { + checks += 1; + return checks >= 2; + }, + async run() {} + }; + harness.options.adminServerFactory = (input) => { + harness.order.push("admin"); + codexService = input.codexService; + return harness.admin; + }; + const supervisor = await createSupervisor(harness.options); + + assert.deepEqual(await codexService.getStatus(), { + configured: false, + modelProvider: "OpenAI", + proxyUrl: "http://127.0.0.1:15100", + historyRepairPending: true + }); + assert.equal(checks, 2); + await supervisor.close(); +}); + +test("Supervisor Codex status strictly rejects ambiguous, malformed, and symlink configs", async (t) => { + const harness = supervisorDependencies(t); + const secret = "strict-status-private-secret-sentinel"; + const codexRoot = join(harness.home, ".codex"); + const configPath = harness.paths.codexConfigPath; + const valid = [ + 'model_provider = "OpenAI"', + "", + "[model_providers.OpenAI]", + 'name = "OpenAI"', + 'base_url = "http://127.0.0.1:15100"', + 'wire_api = "responses"', + "requires_openai_auth = true", + "" + ].join("\n"); + const historyRepair = { + plan() {}, + hasPending() { return false; }, + async run() {} + }; + let codexService; + harness.options.codexHistoryRepair = historyRepair; + harness.options.adminServerFactory = (input) => { + harness.order.push("admin"); + codexService = input.codexService; + return harness.admin; + }; + mkdirSync(codexRoot, { recursive: true }); + writeFileSync(configPath, valid, "utf8"); + const supervisor = await createSupervisor(harness.options); + + assert.equal((await codexService.getStatus()).configured, true); + writeFileSync(configPath, [ + 'developer_instructions = """', + 'model_provider = "decoy"', + "[model_providers.decoy]", + 'base_url = "https://decoy.example/v1"', + '"""', + valid + ].join("\n"), "utf8"); + assert.equal((await codexService.getStatus()).configured, true); + writeFileSync(configPath, [ + 'model_provider = "OpenAI"', + 'model_providers.OpenAI.name = "OpenAI"', + 'model_providers.OpenAI.base_url = "http://127.0.0.1:15100"', + 'model_providers.OpenAI.wire_api = "responses"', + "model_providers.OpenAI.requires_openai_auth = true", + "" + ].join("\n"), "utf8"); + assert.equal((await codexService.getStatus()).configured, true); + for (const invalid of [ + valid.replace('model_provider = "OpenAI"', [ + 'model_provider = "OpenAI"', + 'model_provider = "Other"' + ].join("\n")), + `${valid}\n[model_providers.OpenAI]\nbase_url = "http://127.0.0.1:15100"\n`, + valid.replace( + "[model_providers.OpenAI]", + `[model_providers.OpenAI ${secret}` + ), + valid.replace("[model_providers.OpenAI]", [ + "this is invalid toml", + "[model_providers.OpenAI]" + ].join("\n")) + ]) { + writeFileSync(configPath, invalid, "utf8"); + const status = await codexService.getStatus(); + assert.equal(JSON.stringify(status).includes(secret), false); + assert.equal(status.configured, false); + } + + writeFileSync(configPath, Buffer.concat([ + Buffer.from(valid, "utf8"), + Buffer.from("# invalid utf8 ", "utf8"), + Buffer.from([0xff]), + Buffer.from("\n", "utf8") + ])); + assert.equal((await codexService.getStatus()).configured, false); + + if (process.platform !== "win32") { + const targetPath = join(harness.home, "outside-config.toml"); + writeFileSync(targetPath, valid, "utf8"); + rmSync(configPath); + realFileOperations.symlinkSync(targetPath, configPath); + assert.equal((await codexService.getStatus()).configured, false); + } + await supervisor.close(); +}); + +test("Supervisor Codex status uses no-follow descriptors and rejects a read identity race", async (t) => { + const harness = supervisorDependencies(t); + const codexRoot = join(harness.home, ".codex"); + const configPath = harness.paths.codexConfigPath; + const displacedPath = join(codexRoot, "config.displaced.toml"); + const valid = [ + 'model_provider = "OpenAI"', + "", + "[model_providers.OpenAI]", + 'name = "OpenAI"', + 'base_url = "http://127.0.0.1:15100"', + 'wire_api = "responses"', + "requires_openai_auth = true", + "" + ].join("\n"); + let configDescriptor; + let replacementInjected = false; + let sawNoFollow = false; + const operations = { + ...realFileOperations, + openSync(path, flags, ...args) { + const descriptor = realFileOperations.openSync(path, flags, ...args); + if (path === configPath) { + configDescriptor = descriptor; + if (typeof realFileOperations.constants.O_NOFOLLOW === "number") { + sawNoFollow = (flags & realFileOperations.constants.O_NOFOLLOW) !== 0; + } + } + return descriptor; + }, + readFileSync(target, ...args) { + const bytes = realFileOperations.readFileSync(target, ...args); + if (target === configDescriptor && !replacementInjected) { + replacementInjected = true; + realFileOperations.renameSync(configPath, displacedPath); + realFileOperations.writeFileSync(configPath, valid, "utf8"); + } + return bytes; + } + }; + const historyRepair = { + plan() {}, + hasPending() { return false; }, + async run() {} + }; + let codexService; + harness.options.codexFileOperations = operations; + harness.options.codexHistoryRepair = historyRepair; + harness.options.adminServerFactory = (input) => { + harness.order.push("admin"); + codexService = input.codexService; + return harness.admin; + }; + mkdirSync(codexRoot, { recursive: true }); + writeFileSync(configPath, valid, "utf8"); + const supervisor = await createSupervisor(harness.options); + + const status = await codexService.getStatus(); + assert.equal(replacementInjected, true); + if (typeof realFileOperations.constants.O_NOFOLLOW === "number") { + assert.equal(sawNoFollow, true); + } + assert.equal(status.configured, false); + await supervisor.close(); +}); + +test("Supervisor Codex status reports no pending repair for a fresh missing Codex root", async (t) => { + const harness = supervisorDependencies(t); + let codexService; + harness.options.codexHistoryRepair = { + plan() {}, + hasPending() { + assert.fail("A missing Codex root cannot contain pending repair state"); + }, + async run() {} + }; + harness.options.adminServerFactory = (input) => { + harness.order.push("admin"); + codexService = input.codexService; + return harness.admin; + }; + const supervisor = await createSupervisor(harness.options); + + assert.deepEqual(await codexService.getStatus(), { + configured: false, + modelProvider: "OpenAI", + proxyUrl: "http://127.0.0.1:15100", + historyRepairPending: false + }); + await supervisor.close(); +}); + +test("Supervisor serializes bootstrap and Worker readiness through one Codex gate", async (t) => { + const harness = supervisorDependencies(t); + const codexRoot = join(harness.home, ".codex"); + const valid = [ + 'model_provider = "OpenAI"', + "", + "[model_providers.OpenAI]", + 'name = "OpenAI"', + 'base_url = "http://127.0.0.1:15100"', + 'wire_api = "responses"', + "requires_openai_auth = true", + "" + ].join("\n"); + const bootstrapStarted = createGate(); + const releaseBootstrap = createGate(); + const events = []; + let codexService; + harness.options.codexHistoryRepair = { + plan() {}, + hasPending() { return false; }, + async run() {} + }; + harness.options.bootstrapCodex = async () => { + events.push("bootstrap-start"); + bootstrapStarted.resolve(); + await releaseBootstrap.promise; + mkdirSync(codexRoot, { recursive: true }); + writeFileSync(harness.paths.codexConfigPath, valid, "utf8"); + events.push("bootstrap-complete"); + return { changed: true, backupPath: null, historyRepair: NO_HISTORY_REPAIR }; + }; + harness.options.adminServerFactory = (input) => { + harness.order.push("admin"); + codexService = input.codexService; + return harness.admin; + }; + const supervisor = await createSupervisor(harness.options); + + const bootstrapping = codexService.bootstrap(); + await bootstrapStarted.promise; + const starting = codexService.runWhenReady(async () => { + events.push("worker-start"); + return "started"; + }); + await Promise.resolve(); + assert.deepEqual(events, ["bootstrap-start"]); + releaseBootstrap.resolve(); + + assert.equal(await starting, "started"); + await bootstrapping; + assert.deepEqual(events, ["bootstrap-start", "bootstrap-complete", "worker-start"]); + await supervisor.close(); +}); + +test("Supervisor injects strict Codex readiness around unexpected-exit recovery", async (t) => { + const harness = supervisorDependencies(t); + let runRecoveryWhenReady; + harness.options.workerManagerFactory = (options) => { + runRecoveryWhenReady = options.runRecoveryWhenReady; + return harness.worker; + }; + const supervisor = await createSupervisor(harness.options); + let recoveryCalls = 0; + + await assert.rejects( + () => runRecoveryWhenReady(async () => { + recoveryCalls += 1; + }), + (error) => error?.code === "CODEX_NOT_READY" + ); + assert.equal(recoveryCalls, 0); + + mkdirSync(dirname(harness.paths.codexConfigPath), { recursive: true }); + writeFileSync(harness.paths.codexConfigPath, [ + 'model_provider = "OpenAI"', + "", + "[model_providers.OpenAI]", + 'name = "OpenAI"', + 'base_url = "http://127.0.0.1:15100"', + 'wire_api = "responses"', + "requires_openai_auth = true", + "" + ].join("\n"), "utf8"); + assert.equal(await runRecoveryWhenReady(async () => { + recoveryCalls += 1; + return "recovered"; + }), "recovered"); + assert.equal(recoveryCalls, 1); + + await supervisor.close(); +}); + +test("Supervisor Codex gate recovers after bootstrap and readiness operation failures", async (t) => { + const harness = supervisorDependencies(t); + const codexRoot = join(harness.home, ".codex"); + const valid = [ + 'model_provider = "OpenAI"', + "", + "[model_providers.OpenAI]", + 'name = "OpenAI"', + 'base_url = "http://127.0.0.1:15100"', + 'wire_api = "responses"', + "requires_openai_auth = true", + "" + ].join("\n"); + const privateFailure = new Error("private bootstrap failure"); + let codexService; + harness.options.codexHistoryRepair = { + plan() {}, + hasPending() { return false; }, + async run() {} + }; + harness.options.bootstrapCodex = async () => { + throw privateFailure; + }; + harness.options.adminServerFactory = (input) => { + harness.order.push("admin"); + codexService = input.codexService; + return harness.admin; + }; + const supervisor = await createSupervisor(harness.options); + + await assert.rejects( + () => codexService.bootstrap(), + (error) => error?.code === "CODEX_CONFIG_WRITE_FAILED" + ); + mkdirSync(codexRoot, { recursive: true }); + writeFileSync(harness.paths.codexConfigPath, valid, "utf8"); + await assert.rejects( + () => codexService.runWhenReady(async () => { + throw new Error("operation failed"); + }), + /operation failed/ + ); + assert.equal(await codexService.runWhenReady(async () => "recovered"), "recovered"); + await supervisor.close(); +}); + +test("real Supervisor and Admin project stable safe Codex bootstrap failures", async (t) => { + const harness = supervisorDependencies(t); + const originalSettings = harness.registry.getDocument().settings; + harness.registry.getDocument = () => ({ settings: { ...originalSettings, adminPort: 0 } }); + let bootstrapFailure; + let requestId = "bootstrap-unset"; + harness.options.bootstrapCodex = () => { + throw bootstrapFailure; + }; + harness.options.authFactory = ({ controlTokenPath }) => { + harness.order.push("auth"); + return new SessionAuth({ controlTokenPath }); + }; + harness.options.adminServerFactory = (input) => { + harness.order.push("admin"); + return createAdminServer({ + ...input, + createRequestId: () => requestId + }); + }; + + const supervisor = await createSupervisor(harness.options); + const address = await supervisor.listen(); + const controlToken = readFileSync(harness.paths.controlTokenPath, "utf8").trim(); + t.after(() => supervisor.close()); + + const cases = [ + [ + "CODEX_CONFIG_PARENT_UNSAFE", + 500, + "The Codex configuration directory is unsafe.", + "Repair the Codex configuration directory and retry." + ], + [ + "CODEX_CONFIG_BUSY", + 409, + "Codex configuration is already being updated.", + "Wait for the current update to finish and retry." + ], + [ + "CODEX_CONFIG_CHANGED", + 409, + "Codex configuration changed during bootstrap.", + "Review the current Codex configuration and retry." + ], + [ + "CODEX_CONFIG_READ_FAILED", + 500, + "Codex configuration could not be read safely.", + "Repair local filesystem access and retry." + ], + [ + "CODEX_CONFIG_COMMITTED_DEGRADED", + 500, + "The Codex configuration was updated, but completion could not be confirmed.", + "Review the Codex configuration and retry before starting the proxy.", + { committed: true, degraded: true, pending: false } + ], + [ + "CODEX_HISTORY_REPAIR_INVALID", + 400, + "The Codex history repair input is invalid.", + "Repair the Codex configuration or history state and try again." + ], + [ + "CODEX_HISTORY_REPAIR_CONFLICT", + 409, + "Another Codex history repair transition is already pending.", + "Complete or recover the pending Codex history repair before retrying." + ], + [ + "CODEX_HISTORY_REPAIR_FAILED", + 500, + "Codex history repair could not be completed.", + "Repair local Codex history storage and retry before starting the proxy." + ], + [ + "CODEX_HISTORY_REPAIR_COMMITTED_DEGRADED", + 500, + "Codex configuration was updated, but history repair remains pending.", + "Retry crp start to resume Codex history repair before using the proxy.", + { committed: true, degraded: true, pending: true } + ], + [ + null, + 500, + "Codex configuration could not be written safely.", + "Repair local filesystem access and retry." + ] + ]; + + for (const [code, status, message, action, expectedDetails = {}] of cases) { + const privateMarker = `private-bootstrap-${code ?? "unknown"}`; + const privateCause = new Error(`private-cause-${privateMarker}`); + bootstrapFailure = code === "CODEX_CONFIG_BUSY" + ? new CrpError(code, privateMarker, privateMarker, { + status, + details: { path: harness.paths.codexConfigPath }, + cause: privateCause + }) + : new Error(`${privateMarker} ${harness.paths.codexConfigPath}`, { + cause: privateCause + }); + if (code !== null && !(bootstrapFailure instanceof CrpError)) { + bootstrapFailure.code = code; + } + requestId = `request-${code ?? "write"}`; + + const received = await new Promise((resolvePromise, rejectPromise) => { + const outgoing = http.request({ + host: address.host, + port: address.port, + path: "/api/v1/codex/bootstrap", + method: "POST", + headers: { authorization: `Bearer ${controlToken}` } + }, (response) => { + const chunks = []; + response.on("data", (chunk) => chunks.push(chunk)); + response.on("end", () => resolvePromise({ + status: response.statusCode, + text: Buffer.concat(chunks).toString("utf8") + })); + }); + outgoing.once("error", rejectPromise); + outgoing.end(); + }); + + const serialized = received.text; + for (const forbidden of [ + privateMarker, + harness.paths.codexConfigPath, + "private-cause", + "cause", + "stack" + ]) { + assert.equal(serialized.includes(forbidden), false); + } + assert.equal(received.status, status); + assert.deepEqual(JSON.parse(serialized), { + error: { + code: code ?? "CODEX_CONFIG_WRITE_FAILED", + message, + action, + requestId, + details: expectedDetails + } + }); + } +}); + +test("supervisor entry shares idempotent signal shutdown without exiting early", async () => { + const processRef = new EventEmitter(); + processRef.exitCode = null; + processRef.connected = true; + let disconnectCalls = 0; + processRef.disconnect = () => { + disconnectCalls += 1; + processRef.connected = false; + }; + const closed = createGate(); + let closeCalls = 0; + const supervisor = { + async listen() { + return { origin: "http://127.0.0.1:15101" }; + }, + close() { + closeCalls += 1; + return closed.promise; + } + }; + await runSupervisor({ + processRef, + createSupervisorImpl: async () => supervisor, + supervisorOptions: { home: "/unused/injected-home" } + }); + assert.equal(disconnectCalls, 1); + assert.equal(processRef.listenerCount("SIGTERM"), 1); + assert.equal(processRef.listenerCount("SIGINT"), 1); + processRef.emit("SIGTERM"); + processRef.emit("SIGINT"); + assert.equal(closeCalls, 1); + assert.equal(processRef.exitCode, null); + closed.resolve(); + await closed.promise; + await new Promise((resolvePromise) => setImmediate(resolvePromise)); + assert.equal(processRef.exitCode, 0); + assert.equal(processRef.listenerCount("SIGTERM"), 0); + assert.equal(processRef.listenerCount("SIGINT"), 0); +}); + +test("Supervisor Admin and signal shutdown share the same close-once coordinator", async (t) => { + const adminCloseGate = createGate(); + const harness = supervisorDependencies(t, { adminCloseGate }); + harness.listenGate.resolve({ + host: "127.0.0.1", + port: 15101, + authority: "127.0.0.1:15101", + origin: "http://127.0.0.1:15101" + }); + const supervisor = await createSupervisor(harness.options); + const processRef = new EventEmitter(); + processRef.exitCode = null; + processRef.connected = false; + await runSupervisor({ + processRef, + createSupervisorImpl: async () => supervisor + }); + + const fromAdmin = harness.getAdminOptions().requestSupervisorShutdown(); + const direct = supervisor.requestShutdown(); + assert.equal(fromAdmin, direct); + processRef.emit("SIGTERM"); + await Promise.resolve(); + assert.equal(harness.order.filter((entry) => entry === "admin.close").length, 1); + assert.equal(processRef.exitCode, null); + + adminCloseGate.resolve(); + await fromAdmin; + await new Promise((resolvePromise) => setImmediate(resolvePromise)); + for (const operation of ["admin.close", "auth.close", "worker.close", "metrics.close"]) { + assert.equal(harness.order.filter((entry) => entry === operation).length, 1); + } + assert.equal(processRef.exitCode, 0); + assert.equal(processRef.listenerCount("SIGTERM"), 0); + assert.equal(processRef.listenerCount("SIGINT"), 0); + assert.equal(existsSync(harness.paths.statePath), false); +}); + +test("supervisor entry reports one sanitized startup failure over IPC", async () => { + const secret = "startup-cause-must-not-cross-ipc"; + const failure = new CrpError( + "MIGRATION_INPUT_INVALID", + secret, + secret, + { + status: 400, + details: { reason: secret, privateValue: secret }, + cause: new Error(secret) + } + ); + const processRef = new EventEmitter(); + processRef.connected = true; + processRef.exitCode = null; + const sent = []; + const events = []; + const sendObserved = createGate(); + let sendCallback = null; + let disconnectCalls = 0; + processRef.send = (message, _handle, _options, callback) => { + sent.push(structuredClone(message)); + events.push("send"); + sendCallback = callback; + sendObserved.resolve(); + return true; + }; + processRef.disconnect = () => { + disconnectCalls += 1; + events.push("disconnect"); + processRef.connected = false; + }; + + const startup = runSupervisor({ + processRef, + createSupervisorImpl: async () => { throw failure; } + }); + await sendObserved.promise; + assert.equal(disconnectCalls, 0); + assert.equal(typeof sendCallback, "function"); + events.push("callback"); + sendCallback(null); + const caught = await startup.then( + () => null, + (error) => error + ); + + const serialized = JSON.stringify(sent); + assert.equal(serialized.includes(secret), false); + assert.equal(caught === failure, true); + assert.deepEqual(sent, [{ + version: 1, + type: "startup-failed", + error: { + code: "MIGRATION_INPUT_INVALID", + message: "The legacy provider configuration is invalid.", + action: "Restore a complete legacy provider URL and credential before migrating.", + status: 400, + details: {} + } + }]); + assert.equal(disconnectCalls, 1); + assert.deepEqual(events, ["send", "callback", "disconnect"]); + assert.equal(processRef.listenerCount("SIGTERM"), 0); + assert.equal(processRef.listenerCount("SIGINT"), 0); +}); + +test("supervisor entry maps an unknown startup error to a static IPC failure", async () => { + const secret = "unknown-startup-error-must-not-cross-ipc"; + const processRef = new EventEmitter(); + processRef.connected = true; + const sent = []; + let closeCalls = 0; + let disconnectCalls = 0; + processRef.send = (message, _handle, _options, callback) => { + sent.push(structuredClone(message)); + callback?.(null); + return true; + }; + processRef.disconnect = () => { + disconnectCalls += 1; + processRef.connected = false; + }; + + const caught = await runSupervisor({ + processRef, + createSupervisorImpl: async () => ({ + async listen() { throw new Error(secret); }, + async close() { closeCalls += 1; } + }) + }).then( + () => null, + (error) => error + ); + + const serialized = JSON.stringify(sent); + assert.equal(serialized.includes(secret), false); + assert.equal(caught?.message === secret, true); + assert.deepEqual(sent, [{ + version: 1, + type: "startup-failed", + error: { + code: "SUPERVISOR_START_FAILED", + message: "The local supervisor could not be started.", + action: "Review the supervisor log and try again.", + status: 500, + details: {} + } + }]); + assert.equal(closeCalls, 1); + assert.equal(disconnectCalls, 1); + assert.equal(processRef.listenerCount("SIGTERM"), 0); + assert.equal(processRef.listenerCount("SIGINT"), 0); +}); diff --git a/node/test/integration/core-real-chain.test.mjs b/node/test/integration/core-real-chain.test.mjs new file mode 100644 index 0000000..28890a1 --- /dev/null +++ b/node/test/integration/core-real-chain.test.mjs @@ -0,0 +1,509 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import { + existsSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync +} from "node:fs"; +import { createServer as createNetServer } from "node:net"; +import os from "node:os"; +import { join } from "node:path"; + +import { runCli } from "../../bin/crp.mjs"; +import { createSupervisor } from "../../src/supervisor/supervisor.mjs"; +import { + discoverSupervisor, + ensureSupervisor +} from "../../src/supervisor/supervisor-client.mjs"; + +const PROXY_PORT = 15100; +const ADMIN_PORT = 15101; +const SECRET_A = "crp-core-chain-provider-a-complete-secret-sentinel"; +const SECRET_B = "crp-core-chain-provider-b-complete-secret-sentinel"; +const SECRETS = [SECRET_A, SECRET_B]; +const EXPECTED_CONFIG = [ + 'model_provider = "OpenAI"', + "", + "[model_providers.OpenAI]", + 'name = "OpenAI"', + 'base_url = "http://127.0.0.1:15100"', + 'wire_api = "responses"', + "requires_openai_auth = true", + "" +].join("\n"); + +class MemoryCredentialStore { + backend = "memory"; + values = new Map(); + + async set(ref, secret) { + this.values.set(ref, secret); + } + + async get(ref) { + return this.values.get(ref) ?? null; + } + + async has(ref) { + return this.values.has(ref); + } + + async delete(ref) { + return this.values.delete(ref); + } + + clear() { + this.values.clear(); + } +} + +function createGate() { + let releasePromise; + let released = false; + const promise = new Promise((resolve) => { + releasePromise = resolve; + }); + return { + promise, + release() { + if (released) return; + released = true; + releasePromise(); + } + }; +} + +function createSignal() { + let resolveSignal; + const promise = new Promise((resolve) => { + resolveSignal = resolve; + }); + return { promise, resolve: resolveSignal }; +} + +async function withDeadline(promise, label, timeoutMs = 8_000) { + let timer; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`Timed out waiting for ${label}.`)), timeoutMs); + timer.unref?.(); + }) + ]); + } finally { + clearTimeout(timer); + } +} + +function listen(server, port = 0) { + return new Promise((resolve, reject) => { + const onError = (error) => reject(error); + server.once("error", onError); + server.listen({ host: "127.0.0.1", port, exclusive: true }, () => { + server.off("error", onError); + resolve(server.address().port); + }); + }); +} + +function closeServer(server) { + if (!server?.listening) return Promise.resolve(); + return new Promise((resolve, reject) => { + server.close((error) => error ? reject(error) : resolve()); + }); +} + +async function confirmPortIdle(port) { + const probe = createNetServer(); + probe.unref(); + await listen(probe, port); + await closeServer(probe); +} + +function assertSecretsAbsent(label, text) { + const inspected = String(text); + for (const secret of SECRETS) { + assert.equal(inspected.includes(secret), false, `${label} contains a complete provider secret`); + } +} + +function readTree(root) { + const files = []; + const visit = (directory) => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) visit(path); + if (entry.isFile()) files.push([path, readFileSync(path, "utf8")]); + } + }; + if (existsSync(root)) visit(root); + return files; +} + +function createResponsesFixture({ label, secret, heldInput = null, holdGate = null }) { + const observations = []; + const heldReceived = createSignal(); + let sequence = 0; + const server = http.createServer((request, response) => { + const chunks = []; + request.on("data", (chunk) => chunks.push(chunk)); + request.on("end", async () => { + try { + const bodyText = Buffer.concat(chunks).toString("utf8"); + const body = JSON.parse(bodyText); + const observation = { + method: request.method, + url: request.url, + headers: { ...request.headers }, + body + }; + observations.push(observation); + if (body.input === heldInput) { + heldReceived.resolve(observation); + await holdGate.promise; + } + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ + id: `response-${label}-${++sequence}`, + object: "response", + output: [], + provider: label, + input: body.input + })); + } catch { + response.writeHead(400, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: "invalid fixture request" })); + } + }); + }); + return { server, observations, heldReceived, label, secret }; +} + +async function postResponses(input) { + const response = await fetch(`http://127.0.0.1:${PROXY_PORT}/responses`, { + method: "POST", + headers: { + authorization: "Bearer client-only-value", + "content-type": "application/json" + }, + body: JSON.stringify({ model: "client-model", input }), + signal: AbortSignal.timeout(8_000) + }); + const body = await response.json(); + return { status: response.status, body }; +} + +function observationForInput(fixture, input) { + return fixture.observations.find((observation) => observation.body.input === input); +} + +test("real CLI core chain switches in-flight traffic and restarts on the fixed port", { + timeout: 30_000 +}, async (t) => { + const home = mkdtempSync(join(os.tmpdir(), "crp-core-real-chain-")); + const credentials = new MemoryCredentialStore(); + const holdA = createGate(); + const fixtureA = createResponsesFixture({ + label: "A", + secret: SECRET_A, + heldInput: "held-A", + holdGate: holdA + }); + const fixtureB = createResponsesFixture({ label: "B", secret: SECRET_B }); + const outputs = []; + let supervisor = null; + let supervisorAlive = false; + let supervisorClosePromise = null; + + t.after(async () => { + holdA.release(); + await supervisorClosePromise?.catch(() => {}); + await supervisor?.close().catch(() => {}); + await closeServer(fixtureA.server).catch(() => {}); + await closeServer(fixtureB.server).catch(() => {}); + credentials.clear(); + fixtureA.observations.length = 0; + fixtureB.observations.length = 0; + rmSync(home, { recursive: true, force: true }); + }); + + await Promise.all([ + confirmPortIdle(PROXY_PORT), + confirmPortIdle(ADMIN_PORT) + ]); + + const [portA, portB] = await Promise.all([ + listen(fixtureA.server), + listen(fixtureB.server) + ]); + supervisor = await createSupervisor({ + home, + credentialStoreFactory: () => credentials + }); + const address = await supervisor.listen(); + supervisorAlive = true; + const paths = supervisor.paths; + const codexDir = join(home, ".codex"); + const configPath = join(codexDir, "config.toml"); + + assert.equal(address.port, ADMIN_PORT); + assert.equal(existsSync(codexDir), false); + + // D1 proves the production component chain with an injected credential adapter. + // D2 owns detached Supervisor entry, native keyring, and OS signal evidence. + const isProcessAlive = (pid) => pid === process.pid && existsSync(paths.statePath); + const ensureSupervisorImpl = (options) => ensureSupervisor({ + ...options, + home, + isProcessAlive, + requestTimeoutMs: 10_000, + spawnSupervisor: () => assert.fail("the in-process Supervisor state was not discovered") + }); + const discoverSupervisorImpl = (options) => discoverSupervisor({ + ...options, + isProcessAlive, + requestTimeoutMs: 10_000 + }); + const invoke = async (args) => { + const stdout = []; + const stderr = []; + const status = await runCli(args, { + paths, + adminPort: ADMIN_PORT, + ensureSupervisorImpl, + discoverSupervisorImpl, + killProcess(pid, signal) { + assert.equal(pid, process.pid); + assert.equal(signal, "SIGTERM"); + supervisorClosePromise ??= supervisor.close().finally(() => { + supervisorAlive = false; + }); + }, + isProcessAlive, + wait: async () => { + if (supervisorClosePromise) await supervisorClosePromise; + else await new Promise((resolvePromise) => setImmediate(resolvePromise)); + }, + shutdownTimeoutMs: 10_000, + stdout: (text) => stdout.push(text), + stderr: (text) => stderr.push(text) + }); + const result = { args: [...args], status, stdout: stdout.join(""), stderr: stderr.join("") }; + outputs.push({ status, stdout: result.stdout, stderr: result.stderr }); + assertSecretsAbsent("CLI output", `${result.stdout}\n${result.stderr}`); + assert.equal(status, 0, `CLI command failed: ${args.slice(0, 2).join(" ")}`); + assert.equal(result.stderr, ""); + return JSON.parse(result.stdout); + }; + + const addedA = await invoke([ + "provider", "add", + "--name", "Provider A", + "--base-url", `http://127.0.0.1:${portA}/v1`, + "--api-key", SECRET_A, + "--json" + ]); + const addedB = await invoke([ + "provider", "add", + "--name", "Provider B", + "--base-url", `http://127.0.0.1:${portB}/v1`, + "--api-key", SECRET_B, + "--json" + ]); + const providerAId = addedA.provider.id; + const providerBId = addedB.provider.id; + assert.equal(typeof providerAId, "string"); + assert.equal(typeof providerBId, "string"); + assert.notEqual(providerAId, providerBId); + + const listed = await invoke(["provider", "list", "--json"]); + assert.deepEqual( + listed.providers.map(({ id, name }) => ({ id, name })), + [ + { id: providerAId, name: "Provider A" }, + { id: providerBId, name: "Provider B" } + ] + ); + const testedA = await invoke([ + "provider", "test", "--id", providerAId, "--model", "fixture-model", "--json" + ]); + assert.equal(testedA.result.ok, true); + assert.deepEqual(testedA.result.initialActivation, { + automatic: true, + activeProviderId: providerAId, + workerStarted: false + }); + const testedB = await invoke([ + "provider", "test", "--id", providerBId, "--model", "fixture-model", "--json" + ]); + assert.equal(testedB.result.ok, true); + assert.equal(testedB.result.initialActivation, null); + + const started = await invoke(["start", "--json"]); + const firstWorkerPid = started.worker.pid; + assert.equal(started.supervisorPid, process.pid); + assert.equal(Number.isSafeInteger(firstWorkerPid), true); + assert.deepEqual(started.codexBootstrap, { + changed: true, + backupCreated: false, + historyRepair: { + required: false, + completed: false, + resumed: false, + backupCreated: false, + rolloutFiles: 0, + rolloutRecords: 0, + sqliteFiles: 0, + sqliteRows: 0, + encryptedContentDetected: false + } + }); + assert.equal(started.proxyUrl, `http://127.0.0.1:${PROXY_PORT}`); + assert.equal(readFileSync(configPath, "utf8"), EXPECTED_CONFIG); + assert.deepEqual( + readdirSync(codexDir).filter((name) => name.endsWith(".bak")), + [] + ); + if (process.platform !== "win32") { + assert.equal(statSync(codexDir).mode & 0o777, 0o700); + assert.equal(statSync(configPath).mode & 0o777, 0o600); + } + + let heldASettled = false; + const heldAResponse = postResponses("held-A").finally(() => { + heldASettled = true; + }); + t.after(() => heldAResponse.catch(() => {})); + await withDeadline(fixtureA.heldReceived.promise, "provider A receiving the held request"); + + const activatedB = await invoke([ + "provider", "activate", "--id", providerBId, "--json" + ]); + assert.equal(activatedB.activation.activeProviderId, providerBId); + assert.equal(activatedB.activation.worker.pid, firstWorkerPid); + assert.equal(activatedB.activation.worker.state.listenPort, PROXY_PORT); + + const responseB = await postResponses("new-B"); + assert.deepEqual(responseB, { + status: 200, + body: { + id: "response-B-2", + object: "response", + output: [], + provider: "B", + input: "new-B" + } + }); + assert.equal(heldASettled, false); + holdA.release(); + const responseA = await withDeadline(heldAResponse, "held provider A response"); + assert.deepEqual(responseA, { + status: 200, + body: { + id: "response-A-2", + object: "response", + output: [], + provider: "A", + input: "held-A" + } + }); + + const compatibilityA = observationForInput(fixtureA, "Reply with OK."); + const compatibilityB = observationForInput(fixtureB, "Reply with OK."); + const proxiedA = observationForInput(fixtureA, "held-A"); + const proxiedB = observationForInput(fixtureB, "new-B"); + for (const [observation, secret] of [ + [compatibilityA, SECRET_A], + [compatibilityB, SECRET_B], + [proxiedA, SECRET_A], + [proxiedB, SECRET_B] + ]) { + assert.equal(observation.method, "POST"); + assert.equal(observation.url, "/v1/responses"); + assert.equal(observation.headers.authorization === `Bearer ${secret}`, true); + } + assert.deepEqual(compatibilityA.body, { + model: "fixture-model", + stream: false, + input: "Reply with OK." + }); + assert.deepEqual(compatibilityB.body, compatibilityA.body); + assert.deepEqual(proxiedA.body, { model: "client-model", input: "held-A" }); + assert.deepEqual(proxiedB.body, { model: "client-model", input: "new-B" }); + + const restarted = await invoke(["restart", "--json"]); + assert.equal(restarted.supervisorPid, process.pid); + assert.equal(restarted.worker.phase, "running"); + assert.notEqual(restarted.worker.pid, firstWorkerPid); + assert.equal(restarted.worker.state.listenPort, PROXY_PORT); + const healthResponse = await fetch(`http://127.0.0.1:${PROXY_PORT}/_proxy/health`, { + signal: AbortSignal.timeout(8_000) + }); + assert.equal(healthResponse.status, 200); + const health = await healthResponse.json(); + assert.equal(health.configured, true); + assert.equal(health.generation, restarted.worker.generation); + assertSecretsAbsent("proxy health", JSON.stringify(health)); + + const afterRestart = await invoke(["status", "--json"]); + assert.equal(afterRestart.running, true); + assert.equal(afterRestart.supervisor.pid, process.pid); + assert.equal(afterRestart.worker.pid, restarted.worker.pid); + assert.equal(afterRestart.activeProviderId, providerBId); + + const stopped = await invoke(["stop", "--json"]); + assert.equal(stopped.stopped, true); + assert.equal(stopped.worker.phase, "stopped"); + assert.equal(stopped.worker.pid, null); + await confirmPortIdle(PROXY_PORT); + const stoppedStatus = await invoke(["status", "--json"]); + assert.equal(stoppedStatus.running, true); + assert.equal(stoppedStatus.supervisor.pid, process.pid); + assert.equal(stoppedStatus.worker.phase, "stopped"); + + const retainedSurfaces = [ + ["CLI outputs", JSON.stringify(outputs)], + ["provider registry", readFileSync(paths.registryPath, "utf8")], + ["supervisor state", readFileSync(paths.statePath, "utf8")], + ["activity store", readFileSync(paths.activityPath, "utf8")], + ["Codex config", readFileSync(configPath, "utf8")], + ...readdirSync(codexDir) + .filter((name) => name.endsWith(".bak")) + .map((name) => ["Codex backup", readFileSync(join(codexDir, name), "utf8")]) + ]; + for (const [label, text] of retainedSurfaces) assertSecretsAbsent(label, text); + + const shutdown = await invoke(["shutdown", "--json"]); + assert.equal(shutdown.shutdown, true); + assert.equal(shutdown.graceful, true); + assert.equal(shutdown.forced, false); + assert.equal(shutdown.supervisorPid, process.pid); + supervisorClosePromise ??= supervisor.close().finally(() => { + supervisorAlive = false; + }); + await supervisorClosePromise; + assert.equal(supervisorAlive, false); + assert.equal(existsSync(paths.statePath), false); + await Promise.all([ + confirmPortIdle(PROXY_PORT), + confirmPortIdle(ADMIN_PORT) + ]); + + for (const [path, text] of readTree(home)) assertSecretsAbsent(path, text); + assert.equal(credentials.values.size, SECRETS.length); + assert.equal( + SECRETS.every((secret) => [...credentials.values.values()].includes(secret)), + true + ); + credentials.clear(); + assert.equal(credentials.values.size, 0); + fixtureA.observations.length = 0; + fixtureB.observations.length = 0; + rmSync(home, { recursive: true, force: true }); + assert.equal(existsSync(home), false); +}); diff --git a/node/test/integration/crp-lifecycle.test.mjs b/node/test/integration/crp-lifecycle.test.mjs new file mode 100644 index 0000000..33f00af --- /dev/null +++ b/node/test/integration/crp-lifecycle.test.mjs @@ -0,0 +1,332 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync +} from "node:fs"; +import { createServer } from "node:net"; +import os from "node:os"; +import { join } from "node:path"; + +import { runCli } from "../../bin/crp.mjs"; +import { getPaths } from "../../src/shared/paths.mjs"; +import { createAdminServer } from "../../src/supervisor/admin-server.mjs"; +import { + discoverSupervisor, + ensureSupervisor, + readControlToken, + readSupervisorState +} from "../../src/supervisor/supervisor-client.mjs"; +import { SessionAuth } from "../../src/supervisor/session-auth.mjs"; + +const SUPERVISOR_PID = 61001; +const STARTED_AT = "2026-07-13T09:00:00.000Z"; + +async function choosePort() { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const port = server.address().port; + await new Promise((resolve, reject) => { + server.close((error) => error ? reject(error) : resolve()); + }); + return port; +} + +function providerProfile(id, name, baseUrl, overrides = {}) { + return { + id, + name, + baseUrl, + authHeader: "authorization", + authScheme: "Bearer", + extraHeaders: {}, + modelMode: "passthrough", + modelOverride: null, + lastTestAt: null, + lastTestStatus: "untested", + lastTestCode: null, + createdAt: STARTED_AT, + updatedAt: STARTED_AT, + credentialConfigured: true, + ...overrides + }; +} + +function createServices() { + const providers = []; + let activeProviderId = null; + let workerPid = null; + let generation = 0; + let nextWorkerPid = 62000; + let codexConfigured = false; + + const worker = () => ({ + phase: workerPid === null ? "stopped" : "running", + pid: workerPid, + generation, + state: workerPid === null ? null : { + phase: "running", + configured: true, + generation, + listening: true, + listenHost: "127.0.0.1", + listenPort: 15100, + inFlight: 0 + }, + restartCount: 0, + startedAt: workerPid === null ? null : STARTED_AT, + error: null + }); + + return { + providerService: { + async listProviders() { + return providers.map((provider) => structuredClone(provider)); + }, + async createProvider(input) { + const provider = providerProfile(`provider-${providers.length + 1}`, input.name, input.baseUrl); + providers.push(provider); + return structuredClone(provider); + }, + async deleteProvider(id) { + const index = providers.findIndex((provider) => provider.id === id); + return structuredClone(providers.splice(index, 1)[0]); + }, + async testProvider(id, _model, { activateIfNone = false } = {}) { + const provider = providers.find((candidate) => candidate.id === id); + Object.assign(provider, { + lastTestAt: STARTED_AT, + lastTestStatus: "passed", + updatedAt: STARTED_AT + }); + let initialActivation = null; + if (activateIfNone && activeProviderId === null && workerPid === null) { + activeProviderId = id; + initialActivation = { + automatic: true, + activeProviderId: id, + workerStarted: false + }; + } + return { ok: true, code: null, initialActivation }; + }, + async activate(id) { + activeProviderId = id; + return { + activeProviderId: id, + activeProvider: providers.find((provider) => provider.id === id), + generation, + worker: worker() + }; + }, + async startProxy() { + generation += 1; + workerPid = ++nextWorkerPid; + return worker(); + }, + async stopProxy() { + workerPid = null; + return worker(); + }, + async restartProxy() { + generation += 1; + workerPid = ++nextWorkerPid; + return worker(); + }, + async getStatus() { + return { + activeProviderId, + activeProvider: providers.find((provider) => provider.id === activeProviderId) ?? null, + generation, + worker: worker() + }; + } + }, + activityStore: { list: () => [] }, + settingsService: { + getSettings: async () => ({ + proxyHost: "127.0.0.1", + proxyPort: 15100, + adminHost: "127.0.0.1", + adminPort: null, + captureEnabled: false, + credentialBackend: "injected" + }) + }, + codexService: { + async bootstrap() { + codexConfigured = true; + return { changed: true, backupPath: "/private/injected-backup" }; + }, + async getStatus() { + return { + configured: codexConfigured, + modelProvider: "OpenAI", + proxyUrl: "http://127.0.0.1:15100" + }; + } + }, + diagnosticsService: { exportDiagnostics: async () => ({ created: true }) }, + getWorkerPid: () => workerPid + }; +} + +test("CLI manages one injected supervisor and replaceable worker through the real Admin client", async (t) => { + const home = mkdtempSync(join(os.tmpdir(), "crp-cli-lifecycle-")); + const paths = getPaths(home); + const adminPort = await choosePort(); + const auth = new SessionAuth({ controlTokenPath: paths.controlTokenPath }); + const services = createServices(); + let supervisorAlive = false; + let startPromise = null; + let closePromise = null; + const admin = createAdminServer({ + auth, + ...services, + getSupervisorState: () => ({ pid: SUPERVISOR_PID, startedAt: STARTED_AT }), + requestSupervisorShutdown() { + closePromise ??= admin.close().then(() => { + services.providerService.stopProxy(); + rmSync(paths.statePath, { force: true }); + supervisorAlive = false; + }); + return closePromise; + }, + host: "127.0.0.1", + port: adminPort + }); + t.after(async () => { + await startPromise?.catch(() => {}); + await closePromise?.catch(() => {}); + await admin.close().catch(() => {}); + auth.close(); + rmSync(home, { recursive: true, force: true }); + }); + + const writeState = async () => { + const address = await admin.listen(); + mkdirSync(paths.globalHome, { recursive: true, mode: 0o700 }); + chmodSync(paths.globalHome, 0o700); + writeFileSync(paths.statePath, `${JSON.stringify({ + schemaVersion: 1, + supervisorPid: SUPERVISOR_PID, + startedAt: STARTED_AT, + admin: address, + worker: { + phase: "stopped", + pid: null, + generation: 0, + state: null, + restartCount: 0, + startedAt: null, + error: null + } + })}\n`, { mode: 0o600 }); + chmodSync(paths.statePath, 0o600); + supervisorAlive = true; + }; + const spawnSupervisor = () => { + startPromise ??= writeState(); + return { pid: SUPERVISOR_PID }; + }; + const discoveryOptions = { + paths, + adminPort, + isProcessAlive: () => supervisorAlive + }; + const ensureSupervisorImpl = () => ensureSupervisor({ + ...discoveryOptions, + spawnSupervisor, + wait: async () => { await startPromise; } + }); + const discoverSupervisorImpl = () => discoverSupervisor(discoveryOptions); + const killProcess = (pid, signal) => { + assert.fail(`graceful shutdown must not signal ${pid} with ${signal}`); + }; + const dependencies = { + paths, + adminPort, + ensureSupervisorImpl, + discoverSupervisorImpl, + readControlTokenImpl: () => readControlToken({ path: paths.controlTokenPath }), + openManagementUrlImpl: () => assert.fail("--no-open must not launch a browser"), + killProcess, + isProcessAlive: () => supervisorAlive, + wait: async () => { + if (closePromise) await closePromise; + else await new Promise((resolvePromise) => setImmediate(resolvePromise)); + }, + now: () => 0 + }; + const outputs = []; + const invoke = async (args) => { + const stdout = []; + const stderr = []; + const status = await runCli(args, { + ...dependencies, + stdout: (text) => stdout.push(text), + stderr: (text) => stderr.push(text) + }); + const result = { status, stdout: stdout.join(""), stderr: stderr.join("") }; + outputs.push(result); + assert.equal(status, 0, result.stderr); + return JSON.parse(result.stdout); + }; + + const ui = await invoke(["ui", "--no-open", "--json"]); + assert.equal(ui.supervisorPid, SUPERVISOR_PID); + assert.equal(supervisorAlive, true); + + const secret = "integration-provider-complete-secret"; + const added = await invoke([ + "provider", "add", + "--name", "Primary", + "--base-url", "https://provider.example/v1", + "--api-key", secret, + "--json" + ]); + assert.equal(added.provider.id, "provider-1"); + assert.equal((await invoke(["provider", "list", "--json"])).providers.length, 1); + const tested = await invoke([ + "provider", "test", "--id", "provider-1", "--model", "test-model", "--json" + ]); + assert.equal(tested.result.ok, true); + assert.deepEqual(tested.result.initialActivation, { + automatic: true, + activeProviderId: "provider-1", + workerStarted: false + }); + + const started = await invoke(["start", "--json"]); + const firstWorkerPid = started.worker.pid; + assert.equal(Number.isSafeInteger(firstWorkerPid), true); + assert.equal((await invoke([ + "provider", "activate", "--id", "provider-1", "--json" + ])).activation.activeProviderId, "provider-1"); + const restarted = await invoke(["restart", "--json"]); + assert.notEqual(restarted.worker.pid, firstWorkerPid); + const statusAfterRestart = await invoke(["status", "--json"]); + assert.equal(statusAfterRestart.supervisor.pid, SUPERVISOR_PID); + assert.equal(statusAfterRestart.worker.pid, restarted.worker.pid); + + const stopped = await invoke(["stop", "--json"]); + assert.equal(stopped.worker.phase, "stopped"); + assert.equal(supervisorAlive, true); + assert.equal((await invoke(["status", "--json"])).running, true); + + const shutdown = await invoke(["shutdown", "--json"]); + assert.equal(shutdown.supervisorPid, SUPERVISOR_PID); + assert.equal(shutdown.graceful, true); + assert.equal(shutdown.forced, false); + assert.equal(supervisorAlive, false); + assert.equal(existsSync(paths.statePath), false); + assert.equal(outputs.some(({ stdout, stderr }) => `${stdout}\n${stderr}`.includes(secret)), false); + assert.equal(services.getWorkerPid(), null); +}); diff --git a/node/test/integration/worker-entry.test.mjs b/node/test/integration/worker-entry.test.mjs new file mode 100644 index 0000000..ed6051e --- /dev/null +++ b/node/test/integration/worker-entry.test.mjs @@ -0,0 +1,953 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { fork } from "node:child_process"; +import { once } from "node:events"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import http from "node:http"; +import net from "node:net"; +import os from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { validateChildMessage } from "../../src/worker/protocol.mjs"; + +const WORKER_ENTRY_PATH = fileURLToPath(new URL("../../src/worker/worker-entry.mjs", import.meta.url)); +const EVENT_DEADLINE_MS = 3000; + +function makeTempDir(prefix) { + return join(os.tmpdir(), `${prefix}-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`); +} + +function createGate() { + let release; + const promise = new Promise((resolvePromise) => { + release = resolvePromise; + }); + return { promise, release }; +} + +function createSignal() { + let resolve; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +function listen(server, host = "127.0.0.1") { + return new Promise((resolvePromise, rejectPromise) => { + server.once("error", rejectPromise); + server.listen(0, host, () => { + server.off("error", rejectPromise); + resolvePromise(server.address().port); + }); + }); +} + +async function closeServer(server) { + if (!server.listening) { + return; + } + const closed = once(server, "close"); + server.close(); + await closed; +} + +function withDeadline(promise, description, timeoutMs = EVENT_DEADLINE_MS) { + let timeout; + const deadline = new Promise((_, rejectPromise) => { + timeout = setTimeout(() => rejectPromise(new Error(`Timed out waiting for ${description}`)), timeoutMs); + }); + return Promise.race([promise, deadline]).finally(() => clearTimeout(timeout)); +} + +async function waitForListenerClose(port, timeoutMs = EVENT_DEADLINE_MS) { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + const connected = await new Promise((resolvePromise) => { + const socket = net.createConnection({ host: "127.0.0.1", port }); + socket.once("connect", () => { + socket.destroy(); + resolvePromise(true); + }); + socket.once("error", () => resolvePromise(false)); + }); + if (!connected) { + return; + } + await new Promise((resolvePromise) => setTimeout(resolvePromise, 10)); + } + throw new Error(`Timed out waiting for listener on port ${port} to close`); +} + +function waitForExit(child, timeoutMs = EVENT_DEADLINE_MS) { + if (child.exitCode !== null || child.signalCode !== null) { + return Promise.resolve({ code: child.exitCode, signal: child.signalCode }); + } + return withDeadline( + once(child, "exit").then(([code, signal]) => ({ code, signal })), + "worker exit", + timeoutMs + ); +} + +function sendMessage(child, message) { + return new Promise((resolvePromise, rejectPromise) => { + child.send(message, (error) => { + if (error) { + rejectPromise(error); + } else { + resolvePromise(); + } + }); + }); +} + +async function cleanupChild(child) { + if (child.exitCode !== null || child.signalCode !== null) { + return; + } + if (child.connected) { + try { + await sendMessage(child, { version: 1, type: "shutdown", requestId: "test-cleanup" }); + await waitForExit(child, 500); + return; + } catch { + // Escalate below. + } + } + child.kill("SIGTERM"); + try { + await waitForExit(child, 500); + } catch { + child.kill("SIGKILL"); + await waitForExit(child, 1000).catch(() => {}); + } +} + +function spawnWorker(t) { + const child = fork(WORKER_ENTRY_PATH, [], { + execPath: process.execPath, + stdio: ["ignore", "pipe", "pipe", "ipc"] + }); + const messages = []; + const waiters = new Set(); + let stdout = ""; + let stderr = ""; + + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { stdout += chunk; }); + child.stderr.on("data", (chunk) => { stderr += chunk; }); + child.on("message", (message) => { + messages.push(message); + for (const waiter of [...waiters]) { + if (waiter.predicate(message)) { + waiters.delete(waiter); + waiter.resolve(message); + } + } + }); + child.on("exit", (code, signal) => { + for (const waiter of [...waiters]) { + waiters.delete(waiter); + waiter.reject(new Error(`Worker exited before ${waiter.description}: code=${code} signal=${signal}`)); + } + }); + + t.after(() => cleanupChild(child)); + + function waitForMessage(predicate, description) { + const existing = messages.find(predicate); + if (existing) { + return Promise.resolve(existing); + } + let waiter; + const pending = new Promise((resolvePromise, rejectPromise) => { + waiter = { predicate, resolve: resolvePromise, reject: rejectPromise, description }; + waiters.add(waiter); + }); + return withDeadline(pending, description).finally(() => waiters.delete(waiter)); + } + + return { + child, + messages: () => [...messages], + output: () => `${stdout}\n${stderr}`, + waitForMessage + }; +} + +function makeSettings({ baseUrl, configPath, port = 0, apiKey = "worker-integration-secret" }) { + return { + configPath, + server: { + host: "127.0.0.1", + port, + logLevel: "info" + }, + upstream: { + baseUrl, + apiKey, + timeoutMs: 5000, + verifySsl: true, + authHeader: "x-provider-auth", + authScheme: "Bearer", + extraHeaders: {} + }, + proxy: { + overrideAuthorization: true, + requestIdHeader: "x-client-request-id" + }, + capture: { + enabled: false, + dbPath: join(configPath, "..", "traffic.sqlite3") + } + }; +} + +test("worker configures once, proxies traffic, reports public state, and shuts down cleanly", async (t) => { + const dir = makeTempDir("crp-worker-entry"); + mkdirSync(dir, { recursive: true }); + t.after(() => rmSync(dir, { recursive: true, force: true })); + + let observedAuthorization = null; + const upstream = http.createServer((req, res) => { + observedAuthorization = req.headers["x-provider-auth"] ?? null; + req.resume(); + req.on("end", () => { + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify({ upstream: true })); + }); + }); + const upstreamPort = await listen(upstream); + t.after(() => closeServer(upstream)); + + const configPath = join(dir, "proxy-config.json"); + const settings = makeSettings({ + baseUrl: `http://127.0.0.1:${upstreamPort}`, + configPath + }); + writeFileSync(configPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8"); + + const worker = spawnWorker(t); + const ready = await worker.waitForMessage( + (message) => message?.type === "ready", + "worker ready" + ); + validateChildMessage(ready); + assert.deepEqual(ready.state, { + phase: "ready", + configured: false, + generation: 0, + listening: false, + listenHost: null, + listenPort: null, + inFlight: 0 + }); + + await sendMessage(worker.child, { + version: 1, + type: "configure", + requestId: "configure-1", + generation: 1, + settings + }); + const configured = await worker.waitForMessage( + (message) => message?.type === "configured" && message.requestId === "configure-1", + "worker configured" + ); + validateChildMessage(configured); + assert.equal(configured.state.phase, "running"); + assert.equal(configured.state.generation, 1); + assert.equal(configured.state.listening, true); + assert.equal(configured.state.listenHost, "127.0.0.1"); + assert.ok(configured.state.listenPort > 0); + + const proxyUrl = `http://127.0.0.1:${configured.state.listenPort}`; + const proxyResponse = await fetch(`${proxyUrl}/responses`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-client-request-id": "proxy-request-1" + }, + body: JSON.stringify({ hello: "worker" }) + }); + assert.equal(proxyResponse.status, 200); + assert.deepEqual(await proxyResponse.json(), { upstream: true }); + assert.equal(observedAuthorization, "Bearer worker-integration-secret"); + + const healthResponse = await fetch(`${proxyUrl}/_proxy/health`); + assert.equal(healthResponse.status, 200); + const health = await healthResponse.json(); + assert.equal(health.configured, true); + assert.equal(health.generation, 1); + assert.equal(JSON.stringify(health).includes(settings.upstream.apiKey), false); + + await sendMessage(worker.child, { version: 1, type: "status", requestId: "status-1" }); + const status = await worker.waitForMessage( + (message) => message?.type === "status" && message.requestId === "status-1", + "worker status" + ); + validateChildMessage(status); + assert.deepEqual(status.state, configured.state); + assert.equal(JSON.stringify(status).includes(settings.upstream.apiKey), false); + + await sendMessage(worker.child, { + version: 1, + type: "configure", + requestId: "configure-2", + generation: 2, + settings + }); + const reconfigured = await worker.waitForMessage( + (message) => message?.type === "configured" && message.requestId === "configure-2", + "worker reconfigured" + ); + assert.equal(reconfigured.state.generation, 2); + assert.equal(reconfigured.state.listenPort, configured.state.listenPort); + const reconfiguredHealth = await fetch(`${proxyUrl}/_proxy/health`).then((response) => response.json()); + assert.equal(reconfiguredHealth.generation, 2); + assert.equal(JSON.stringify(reconfiguredHealth).includes(settings.upstream.apiKey), false); + + await sendMessage(worker.child, { version: 1, type: "shutdown", requestId: "shutdown-1" }); + const exit = await waitForExit(worker.child); + assert.deepEqual(exit, { code: 0, signal: null }); + assert.equal(worker.output().includes(settings.upstream.apiKey), false); +}); + +test("worker drain rejects new traffic and waits for an in-flight request before acknowledging", async (t) => { + const dir = makeTempDir("crp-worker-drain"); + mkdirSync(dir, { recursive: true }); + t.after(() => rmSync(dir, { recursive: true, force: true })); + + const releaseUpstream = createGate(); + const receivedUpstream = createSignal(); + t.after(() => releaseUpstream.release()); + const upstream = http.createServer((req, res) => { + req.resume(); + req.on("end", () => { + receivedUpstream.resolve(); + releaseUpstream.promise.then(() => { + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify({ drained: true })); + }); + }); + }); + const upstreamPort = await listen(upstream); + t.after(async () => { + releaseUpstream.release(); + await closeServer(upstream); + }); + + const configPath = join(dir, "proxy-config.json"); + const settings = makeSettings({ + baseUrl: `http://127.0.0.1:${upstreamPort}`, + configPath, + apiKey: "worker-drain-secret" + }); + writeFileSync(configPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8"); + + const worker = spawnWorker(t); + await worker.waitForMessage((message) => message?.type === "ready", "worker ready"); + await sendMessage(worker.child, { + version: 1, + type: "configure", + requestId: "configure-drain", + generation: 1, + settings + }); + const configured = await worker.waitForMessage( + (message) => message?.type === "configured" && message.requestId === "configure-drain", + "worker configured" + ); + const proxyUrl = `http://127.0.0.1:${configured.state.listenPort}`; + + const firstRequest = fetch(`${proxyUrl}/responses`, { + method: "POST", + body: "in-flight" + }); + t.after(() => firstRequest.catch(() => {})); + await withDeadline(receivedUpstream.promise, "upstream receiving in-flight request"); + + await sendMessage(worker.child, { version: 1, type: "drain", requestId: "drain-1" }); + await sendMessage(worker.child, { version: 1, type: "status", requestId: "status-draining" }); + const draining = await worker.waitForMessage( + (message) => message?.type === "status" && message.requestId === "status-draining", + "draining worker status" + ); + assert.equal(draining.state.phase, "draining"); + assert.equal(draining.state.inFlight, 1); + assert.equal(draining.state.listening, false); + + await assert.rejects(fetch(`${proxyUrl}/responses`, { + method: "POST", + body: "must-not-be-accepted" + })); + + releaseUpstream.release(); + const firstResponse = await firstRequest; + assert.equal(firstResponse.status, 200); + assert.deepEqual(await firstResponse.json(), { drained: true }); + + await sendMessage(worker.child, { version: 1, type: "status", requestId: "status-after-response" }); + const afterResponse = await worker.waitForMessage( + (message) => message?.type === "status" && message.requestId === "status-after-response", + "post-response worker status" + ); + assert.equal(afterResponse.state.phase, "drained"); + assert.equal(afterResponse.state.inFlight, 0); + + const drained = await worker.waitForMessage( + (message) => message?.type === "drained" && message.requestId === "drain-1", + "worker drained" + ); + validateChildMessage(drained); + assert.equal(drained.state.phase, "drained"); + assert.equal(drained.state.inFlight, 0); + assert.equal(drained.state.listening, false); + + await sendMessage(worker.child, { version: 1, type: "shutdown", requestId: "shutdown-drained" }); + assert.deepEqual(await waitForExit(worker.child), { code: 0, signal: null }); + assert.equal(worker.output().includes(settings.upstream.apiKey), false); +}); + +test("worker keeps duplicate drain acknowledgements and status drained", async (t) => { + const dir = makeTempDir("crp-worker-duplicate-drain"); + mkdirSync(dir, { recursive: true }); + t.after(() => rmSync(dir, { recursive: true, force: true })); + + const configPath = join(dir, "proxy-config.json"); + const settings = makeSettings({ + baseUrl: "http://127.0.0.1:9", + configPath, + apiKey: "worker-duplicate-drain-secret" + }); + writeFileSync(configPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8"); + + const worker = spawnWorker(t); + await worker.waitForMessage((message) => message?.type === "ready", "worker ready"); + await sendMessage(worker.child, { + version: 1, + type: "configure", + requestId: "configure-duplicate-drain", + generation: 1, + settings + }); + const configured = await worker.waitForMessage( + (message) => message?.type === "configured" + && message.requestId === "configure-duplicate-drain", + "worker configured" + ); + + await sendMessage(worker.child, { version: 1, type: "drain", requestId: "drain-first" }); + const firstDrained = await worker.waitForMessage( + (message) => message?.type === "drained" && message.requestId === "drain-first", + "first drain acknowledgement" + ); + assert.equal(firstDrained.state.phase, "drained"); + assert.equal(firstDrained.state.listening, false); + + await sendMessage(worker.child, { version: 1, type: "drain", requestId: "drain-second" }); + const secondDrained = await worker.waitForMessage( + (message) => message?.type === "drained" && message.requestId === "drain-second", + "second drain acknowledgement" + ); + assert.equal(secondDrained.state.phase, "drained"); + assert.equal(secondDrained.state.listening, false); + + await sendMessage(worker.child, { version: 1, type: "status", requestId: "status-after-drains" }); + const status = await worker.waitForMessage( + (message) => message?.type === "status" && message.requestId === "status-after-drains", + "status after duplicate drain" + ); + assert.equal(status.state.phase, "drained"); + assert.equal(status.state.listening, false); + + await sendMessage(worker.child, { version: 1, type: "shutdown", requestId: "shutdown-duplicate-drain" }); + assert.deepEqual(await waitForExit(worker.child), { code: 0, signal: null }); + assert.equal(worker.output().includes(settings.upstream.apiKey), false); + + const portProbe = http.createServer(); + await new Promise((resolvePromise, rejectPromise) => { + portProbe.once("error", rejectPromise); + portProbe.listen(configured.state.listenPort, "127.0.0.1", resolvePromise); + }); + await closeServer(portProbe); +}); + +test("worker rejects configure during drain without losing the in-flight drain", async (t) => { + const dir = makeTempDir("crp-worker-drain-configure"); + mkdirSync(dir, { recursive: true }); + t.after(() => rmSync(dir, { recursive: true, force: true })); + + const releaseUpstream = createGate(); + const receivedUpstream = createSignal(); + t.after(() => releaseUpstream.release()); + const upstream = http.createServer((req, res) => { + req.resume(); + req.on("end", () => { + receivedUpstream.resolve(); + releaseUpstream.promise.then(() => { + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify({ drained: true })); + }); + }); + }); + const upstreamPort = await listen(upstream); + t.after(async () => { + releaseUpstream.release(); + await closeServer(upstream); + }); + + const configPath = join(dir, "proxy-config.json"); + const settingsA = makeSettings({ + baseUrl: `http://127.0.0.1:${upstreamPort}`, + configPath, + apiKey: "worker-drain-race-a-secret" + }); + const settingsB = makeSettings({ + baseUrl: `http://127.0.0.1:${upstreamPort}`, + configPath, + apiKey: "worker-drain-race-b-secret" + }); + writeFileSync(configPath, `${JSON.stringify(settingsA, null, 2)}\n`, "utf8"); + + const worker = spawnWorker(t); + await worker.waitForMessage((message) => message?.type === "ready", "worker ready"); + await sendMessage(worker.child, { + version: 1, + type: "configure", + requestId: "configure-drain-race-a", + generation: 1, + settings: settingsA + }); + const configured = await worker.waitForMessage( + (message) => message?.type === "configured" && message.requestId === "configure-drain-race-a", + "worker configured" + ); + const proxyUrl = `http://127.0.0.1:${configured.state.listenPort}`; + + const firstRequest = fetch(`${proxyUrl}/responses`, { + method: "POST", + body: "in-flight" + }); + t.after(() => firstRequest.catch(() => {})); + await withDeadline(receivedUpstream.promise, "upstream receiving drain-race request"); + + await sendMessage(worker.child, { version: 1, type: "drain", requestId: "drain-race" }); + await sendMessage(worker.child, { + version: 1, + type: "configure", + requestId: "configure-during-drain", + generation: 2, + settings: settingsB + }); + const outcome = await worker.waitForMessage( + (message) => message?.requestId === "configure-during-drain" + && (message.type === "fatal" || message.type === "configured"), + "configure-during-drain outcome" + ); + releaseUpstream.release(); + + assert.equal(outcome.type, "fatal"); + validateChildMessage(outcome); + assert.deepEqual(outcome.error, { + code: "WORKER_CONFIGURE_FAILED", + message: "Worker configuration failed." + }); + + const firstResponse = await firstRequest; + assert.equal(firstResponse.status, 200); + assert.deepEqual(await firstResponse.json(), { drained: true }); + const drained = await worker.waitForMessage( + (message) => message?.type === "drained" && message.requestId === "drain-race", + "drain-race acknowledgement" + ); + assert.equal(drained.state.phase, "drained"); + assert.equal(drained.state.listening, false); + assert.equal(drained.state.inFlight, 0); + + assert.deepEqual(await waitForExit(worker.child), { code: 1, signal: null }); + assert.equal( + worker.messages().some((message) => ( + message?.type === "configured" && message.requestId === "configure-during-drain" + )), + false + ); + const output = `${JSON.stringify(worker.messages())}\n${worker.output()}`; + assert.equal(output.includes(settingsA.upstream.apiKey), false); + assert.equal(output.includes(settingsB.upstream.apiKey), false); + + const portProbe = http.createServer(); + await new Promise((resolvePromise, rejectPromise) => { + portProbe.once("error", rejectPromise); + portProbe.listen(configured.state.listenPort, "127.0.0.1", resolvePromise); + }); + await closeServer(portProbe); +}); + +test("worker rejects an invalid configure message before listening without echoing its payload", async (t) => { + const worker = spawnWorker(t); + const ready = await worker.waitForMessage((message) => message?.type === "ready", "worker ready"); + assert.equal(ready.state.listening, false); + + const reflectedRequestId = "sk-secret-sentinel"; + const settings = makeSettings({ + baseUrl: "http://127.0.0.1:9", + configPath: "/tmp/crp-invalid-worker-config.json", + apiKey: "invalid-configure-secret" + }); + await sendMessage(worker.child, { + version: 1, + type: "configure", + requestId: reflectedRequestId, + generation: 1, + settings, + unexpected: "invalid-payload-secret" + }); + + const fatal = await worker.waitForMessage( + (message) => message?.type === "fatal", + "invalid protocol fatal" + ); + validateChildMessage(fatal); + assert.deepEqual(fatal, { + version: 1, + type: "fatal", + requestId: "worker-fatal", + error: { + code: "WORKER_PROTOCOL_INVALID", + message: "Worker protocol message is invalid." + } + }); + const serialized = JSON.stringify(fatal); + assert.equal(serialized.includes("invalid-configure-secret"), false); + assert.equal(serialized.includes("invalid-payload-secret"), false); + assert.equal(serialized.includes(reflectedRequestId), false); + assert.deepEqual(await waitForExit(worker.child), { code: 1, signal: null }); + assert.equal(worker.output().includes("invalid-configure-secret"), false); + assert.equal(worker.output().includes("invalid-payload-secret"), false); + assert.equal(worker.output().includes(reflectedRequestId), false); +}); + +test("worker rejects an unsendable API key without leaking it", async (t) => { + const apiKey = "invalid-key\r\nsk-secret-api-key-sentinel"; + const settings = makeSettings({ + baseUrl: "http://127.0.0.1:9", + configPath: "/tmp/crp-invalid-worker-api-key.json", + apiKey + }); + const worker = spawnWorker(t); + await worker.waitForMessage((message) => message?.type === "ready", "worker ready"); + await sendMessage(worker.child, { + version: 1, + type: "configure", + requestId: "configure-invalid-api-key", + generation: 1, + settings + }); + + const outcome = await worker.waitForMessage( + (message) => message?.type === "fatal" || message?.type === "configured", + "invalid API key outcome" + ); + assert.equal(outcome.type, "fatal"); + validateChildMessage(outcome); + assert.deepEqual(outcome, { + version: 1, + type: "fatal", + requestId: "worker-fatal", + error: { + code: "WORKER_PROTOCOL_INVALID", + message: "Worker protocol message is invalid." + } + }); + assert.deepEqual(await waitForExit(worker.child), { code: 1, signal: null }); + const output = `${JSON.stringify(worker.messages())}\n${worker.output()}`; + assert.equal(output.includes(apiKey), false); + assert.equal(output.includes("sk-secret-api-key-sentinel"), false); +}); + +test("worker rejects a stale generation safely and releases its listening port", async (t) => { + const dir = makeTempDir("crp-worker-stale"); + mkdirSync(dir, { recursive: true }); + t.after(() => rmSync(dir, { recursive: true, force: true })); + + const configPath = join(dir, "proxy-config.json"); + const settingsA = makeSettings({ + baseUrl: "http://127.0.0.1:9", + configPath, + apiKey: "worker-stale-a-secret" + }); + const settingsB = makeSettings({ + baseUrl: "http://127.0.0.1:10", + configPath, + apiKey: "worker-stale-b-secret" + }); + writeFileSync(configPath, `${JSON.stringify(settingsA, null, 2)}\n`, "utf8"); + + const worker = spawnWorker(t); + await worker.waitForMessage((message) => message?.type === "ready", "worker ready"); + await sendMessage(worker.child, { + version: 1, + type: "configure", + requestId: "configure-stale-a", + generation: 1, + settings: settingsA + }); + const configured = await worker.waitForMessage( + (message) => message?.type === "configured" && message.requestId === "configure-stale-a", + "worker configured" + ); + + await sendMessage(worker.child, { + version: 1, + type: "configure", + requestId: "configure-stale-b", + generation: 1, + settings: settingsB + }); + const fatal = await worker.waitForMessage( + (message) => message?.type === "fatal" && message.requestId === "configure-stale-b", + "stale snapshot fatal" + ); + validateChildMessage(fatal); + assert.equal(fatal.error.code, "STALE_SNAPSHOT"); + assert.equal(JSON.stringify(fatal).includes(settingsA.upstream.apiKey), false); + assert.equal(JSON.stringify(fatal).includes(settingsB.upstream.apiKey), false); + assert.deepEqual(await waitForExit(worker.child), { code: 1, signal: null }); + assert.equal(worker.output().includes(settingsA.upstream.apiKey), false); + assert.equal(worker.output().includes(settingsB.upstream.apiKey), false); + + const portProbe = http.createServer(); + await new Promise((resolvePromise, rejectPromise) => { + portProbe.once("error", rejectPromise); + portProbe.listen(configured.state.listenPort, "127.0.0.1", resolvePromise); + }); + await closeServer(portProbe); +}); + +test("worker reports a sanitized startup fatal when the requested port is occupied", async (t) => { + const dir = makeTempDir("crp-worker-port-busy"); + mkdirSync(dir, { recursive: true }); + t.after(() => rmSync(dir, { recursive: true, force: true })); + + const blocker = http.createServer(); + const blockedPort = await listen(blocker); + t.after(() => closeServer(blocker)); + + const configPath = join(dir, "proxy-config.json"); + const settings = makeSettings({ + baseUrl: "http://127.0.0.1:9", + configPath, + port: blockedPort, + apiKey: "worker-port-busy-secret" + }); + writeFileSync(configPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8"); + + const worker = spawnWorker(t); + await worker.waitForMessage((message) => message?.type === "ready", "worker ready"); + await sendMessage(worker.child, { + version: 1, + type: "configure", + requestId: "configure-port-busy", + generation: 1, + settings + }); + const fatal = await worker.waitForMessage( + (message) => message?.type === "fatal" && message.requestId === "configure-port-busy", + "port conflict fatal" + ); + validateChildMessage(fatal); + assert.deepEqual(fatal.error, { + code: "WORKER_START_FAILED", + message: "Worker failed to start." + }); + assert.equal(JSON.stringify(fatal).includes(settings.upstream.apiKey), false); + assert.deepEqual(await waitForExit(worker.child), { code: 1, signal: null }); + assert.equal(worker.output().includes(settings.upstream.apiKey), false); + assert.equal(blocker.listening, true); +}); + +test("worker closes resources when the parent IPC channel disconnects", async (t) => { + const dir = makeTempDir("crp-worker-disconnect"); + mkdirSync(dir, { recursive: true }); + t.after(() => rmSync(dir, { recursive: true, force: true })); + + const configPath = join(dir, "proxy-config.json"); + const settings = makeSettings({ + baseUrl: "http://127.0.0.1:9", + configPath, + apiKey: "worker-disconnect-secret" + }); + writeFileSync(configPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8"); + + const worker = spawnWorker(t); + await worker.waitForMessage((message) => message?.type === "ready", "worker ready"); + await sendMessage(worker.child, { + version: 1, + type: "configure", + requestId: "configure-disconnect", + generation: 1, + settings + }); + const configured = await worker.waitForMessage( + (message) => message?.type === "configured" && message.requestId === "configure-disconnect", + "worker configured" + ); + + worker.child.disconnect(); + assert.deepEqual(await waitForExit(worker.child), { code: 0, signal: null }); + assert.equal(worker.output().includes(settings.upstream.apiKey), false); + + const portProbe = http.createServer(); + await new Promise((resolvePromise, rejectPromise) => { + portProbe.once("error", rejectPromise); + portProbe.listen(configured.state.listenPort, "127.0.0.1", resolvePromise); + }); + await closeServer(portProbe); +}); + +test("worker bounds parent disconnect cleanup with a hanging upstream request", async (t) => { + const dir = makeTempDir("crp-worker-disconnect-hanging"); + mkdirSync(dir, { recursive: true }); + t.after(() => rmSync(dir, { recursive: true, force: true })); + + const receivedUpstream = createSignal(); + const upstream = http.createServer((req) => { + req.resume(); + req.on("end", () => receivedUpstream.resolve()); + }); + const upstreamPort = await listen(upstream); + t.after(async () => { + upstream.closeAllConnections?.(); + await closeServer(upstream); + }); + + const configPath = join(dir, "proxy-config.json"); + const settings = makeSettings({ + baseUrl: `http://127.0.0.1:${upstreamPort}`, + configPath, + apiKey: "worker-disconnect-hanging-secret" + }); + settings.upstream.timeoutMs = 30_000; + writeFileSync(configPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8"); + + const worker = spawnWorker(t); + await worker.waitForMessage((message) => message?.type === "ready", "worker ready"); + await sendMessage(worker.child, { + version: 1, + type: "configure", + requestId: "configure-disconnect-hanging", + generation: 1, + settings + }); + const configured = await worker.waitForMessage( + (message) => message?.type === "configured" + && message.requestId === "configure-disconnect-hanging", + "worker configured" + ); + const proxyRequestOutcome = fetch(`http://127.0.0.1:${configured.state.listenPort}/responses`, { + method: "POST", + body: "hang-until-parent-disconnect" + }).then( + () => ({ status: "resolved" }), + () => ({ status: "rejected" }) + ); + await withDeadline(receivedUpstream.promise, "upstream receiving disconnect request"); + + worker.child.disconnect(); + assert.deepEqual(await waitForExit(worker.child, 1500), { code: 0, signal: null }); + const requestOutcome = await withDeadline( + proxyRequestOutcome, + "proxy request settling after parent disconnect", + 1500 + ); + assert.equal(requestOutcome.status, "rejected"); + + const output = `${JSON.stringify(worker.messages())}\n${worker.output()}`; + assert.equal(output.includes(settings.upstream.apiKey), false); + + const portProbe = http.createServer(); + await new Promise((resolvePromise, rejectPromise) => { + portProbe.once("error", rejectPromise); + portProbe.listen(configured.state.listenPort, "127.0.0.1", resolvePromise); + }); + await closeServer(portProbe); +}); + +test("worker bounds parent disconnect cleanup after shutdown is already waiting", async (t) => { + const dir = makeTempDir("crp-worker-shutdown-disconnect-hanging"); + mkdirSync(dir, { recursive: true }); + t.after(() => rmSync(dir, { recursive: true, force: true })); + + const receivedUpstream = createSignal(); + const upstream = http.createServer((req) => { + req.resume(); + req.on("end", () => receivedUpstream.resolve()); + }); + const upstreamPort = await listen(upstream); + t.after(async () => { + upstream.closeAllConnections?.(); + await closeServer(upstream); + }); + + const configPath = join(dir, "proxy-config.json"); + const settings = makeSettings({ + baseUrl: `http://127.0.0.1:${upstreamPort}`, + configPath, + apiKey: "worker-shutdown-disconnect-hanging-secret" + }); + settings.upstream.timeoutMs = 30_000; + writeFileSync(configPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8"); + + const worker = spawnWorker(t); + await worker.waitForMessage((message) => message?.type === "ready", "worker ready"); + await sendMessage(worker.child, { + version: 1, + type: "configure", + requestId: "configure-shutdown-disconnect-hanging", + generation: 1, + settings + }); + const configured = await worker.waitForMessage( + (message) => message?.type === "configured" + && message.requestId === "configure-shutdown-disconnect-hanging", + "worker configured" + ); + const proxyRequestOutcome = fetch(`http://127.0.0.1:${configured.state.listenPort}/responses`, { + method: "POST", + body: "hang-through-shutdown-until-parent-disconnect" + }).then( + () => ({ status: "resolved" }), + () => ({ status: "rejected" }) + ); + await withDeadline(receivedUpstream.promise, "upstream receiving shutdown-disconnect request"); + + await sendMessage(worker.child, { + version: 1, + type: "shutdown", + requestId: "shutdown-before-parent-disconnect" + }); + await waitForListenerClose(configured.state.listenPort); + worker.child.disconnect(); + + assert.deepEqual(await waitForExit(worker.child, 1500), { code: 0, signal: null }); + const requestOutcome = await withDeadline( + proxyRequestOutcome, + "proxy request settling after shutdown and parent disconnect", + 1500 + ); + assert.equal(requestOutcome.status, "rejected"); + + const output = `${JSON.stringify(worker.messages())}\n${worker.output()}`; + assert.equal(output.includes(settings.upstream.apiKey), false); + + const portProbe = http.createServer(); + await new Promise((resolvePromise, rejectPromise) => { + portProbe.once("error", rejectPromise); + portProbe.listen(configured.state.listenPort, "127.0.0.1", resolvePromise); + }); + await closeServer(portProbe); +}); diff --git a/node/test/integration/worker-restart.test.mjs b/node/test/integration/worker-restart.test.mjs new file mode 100644 index 0000000..e168e76 --- /dev/null +++ b/node/test/integration/worker-restart.test.mjs @@ -0,0 +1,112 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { WorkerManager } from "../../src/supervisor/worker-manager.mjs"; + +const SECRET = "worker-restart-integration-secret"; + +function listen(server, port = 0) { + return new Promise((resolvePromise, rejectPromise) => { + server.once("error", rejectPromise); + server.listen(port, "127.0.0.1", () => { + server.off("error", rejectPromise); + resolvePromise(server.address().port); + }); + }); +} + +function closeServer(server) { + if (!server.listening) return Promise.resolve(); + return new Promise((resolvePromise, rejectPromise) => { + server.close((error) => { + if (error) rejectPromise(error); + else resolvePromise(); + }); + }); +} + +async function reservePort() { + const probe = http.createServer(); + const port = await listen(probe); + await closeServer(probe); + return port; +} + +function makeSnapshot({ generation, port, upstreamPort, dir }) { + return { + generation, + settings: { + configPath: join(dir, "proxy-config.json"), + server: { host: "127.0.0.1", port, logLevel: "info" }, + upstream: { + baseUrl: `http://127.0.0.1:${upstreamPort}`, + apiKey: SECRET, + timeoutMs: 5_000, + verifySsl: true, + authHeader: "x-provider-auth", + authScheme: "Bearer", + extraHeaders: {} + }, + proxy: { overrideAuthorization: true, requestIdHeader: "x-client-request-id" }, + capture: { enabled: false, dbPath: join(dir, "traffic.sqlite3") } + } + }; +} + +test("real worker restart changes PID and restores matching health on the same fixed port", { + timeout: 15_000 +}, async (t) => { + const dir = mkdtempSync(join(tmpdir(), "crp-worker-restart-")); + t.after(() => rmSync(dir, { recursive: true, force: true })); + + const upstream = http.createServer((req, res) => { + req.resume(); + req.on("end", () => { + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify({ ok: true })); + }); + }); + const upstreamPort = await listen(upstream); + t.after(() => closeServer(upstream)); + + const port = await reservePort(); + const manager = new WorkerManager({ + host: "127.0.0.1", + port, + readyTimeoutMs: 3_000, + ackTimeoutMs: 3_000, + healthTimeoutMs: 3_000, + terminateTimeoutMs: 1_000, + killTimeoutMs: 1_000, + runRecoveryWhenReady: (operation) => operation() + }); + t.after(() => manager.close()); + const snapshot = makeSnapshot({ generation: 1, port, upstreamPort, dir }); + + const started = await manager.start(snapshot); + const oldPid = started.pid; + assert.equal(started.phase, "running"); + assert.equal(started.state.listenPort, port); + + const restarted = await manager.restart(snapshot, { drainTimeoutMs: 2_000 }); + assert.equal(restarted.phase, "running"); + assert.notEqual(restarted.pid, oldPid); + assert.equal(restarted.state.listenPort, port); + assert.equal(manager.getPublicState().phase, "running"); + + const healthResponse = await fetch(`http://127.0.0.1:${port}/_proxy/health`); + assert.equal(healthResponse.status, 200); + const health = await healthResponse.json(); + assert.equal(health.configured, true); + assert.equal(health.generation, 1); + assert.equal(JSON.stringify(health).includes(SECRET), false); + + await manager.stop({ drainTimeoutMs: 2_000 }); + const exclusiveProbe = http.createServer(); + await listen(exclusiveProbe, port); + await closeServer(exclusiveProbe); +}); diff --git a/node/test/metrics-store.test.mjs b/node/test/metrics-store.test.mjs new file mode 100644 index 0000000..3d6d12a --- /dev/null +++ b/node/test/metrics-store.test.mjs @@ -0,0 +1,252 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync +} from "node:fs"; +import os from "node:os"; +import { join } from "node:path"; + +import { + METRICS_MAX_FILE_BYTES, + MetricsStore +} from "../src/supervisor/metrics-store.mjs"; + +const HOUR_MS = 60 * 60 * 1_000; + +function observation(overrides = {}) { + return { + providerId: "provider-a", + result: "success", + model: "gpt-5-codex", + inputTokens: 120, + outputTokens: 30, + durationBin: 4, + responseStartBin: 2, + ...overrides + }; +} + +function harness(t, { timestamp = Date.parse("2026-07-16T12:34:56.000Z") } = {}) { + const dir = mkdtempSync(join(os.tmpdir(), "crp-metrics-")); + const path = join(dir, "metrics.json"); + let now = timestamp; + const store = new MetricsStore({ path, now: () => now, flushDelayMs: 60_000 }); + t.after(() => { + store.close(); + rmSync(dir, { recursive: true, force: true }); + }); + return { + dir, + path, + store, + advance(milliseconds) { + now += milliseconds; + } + }; +} + +test("empty metrics return stable zero-filled 24h and 7d chart series", (t) => { + const state = harness(t); + for (const [window, expectedBuckets] of [["24h", 24], ["7d", 168]]) { + const overview = state.store.getOverview({ window }); + assert.equal(overview.window, window); + assert.equal(overview.storageState, "ready"); + assert.equal(overview.series.length, expectedBuckets); + assert.equal(overview.series.every((bucket) => bucket.requests === 0), true); + assert.equal(overview.summary.requests, 0); + assert.deepEqual(overview.summary.tokens, { input: 0, output: 0, observedRequests: 0 }); + assert.deepEqual(overview.summary.latency, { + p50UpperBoundMs: null, + p95UpperBoundMs: null, + overflowRequests: 0 + }); + assert.deepEqual(overview.providers, []); + assert.deepEqual(overview.models, []); + } +}); + +test("metrics store aggregates hourly observations and restores the strict private document", (t) => { + const state = harness(t); + assert.equal(state.store.record(observation()), true); + assert.equal(state.store.record(observation({ + result: "upstreamRejected", + model: null, + inputTokens: null, + outputTokens: null, + durationBin: 6, + responseStartBin: 3 + })), true); + + const overview = state.store.getOverview({ window: "24h" }); + assert.equal(overview.series.length, 24); + assert.equal(overview.series.at(-1).start, "2026-07-16T12:00:00.000Z"); + assert.deepEqual(overview.summary.results, { + success: 1, + upstreamRejected: 1, + upstreamError: 0, + timeout: 0, + networkError: 0, + clientAbort: 0 + }); + assert.deepEqual(overview.summary.tokens, { input: 120, output: 30, observedRequests: 1 }); + assert.deepEqual(overview.summary.latency, { + p50UpperBoundMs: 1_000, + p95UpperBoundMs: 5_000, + overflowRequests: 0 + }); + assert.equal(overview.dataQuality.unknownModelRequests, 1); + assert.deepEqual(overview.providers.map((provider) => ({ + providerId: provider.providerId, + requests: provider.requests, + successfulRequests: provider.successfulRequests + })), [{ providerId: "provider-a", requests: 2, successfulRequests: 1 }]); + assert.deepEqual(overview.models.map((model) => ({ model: model.model, requests: model.requests })), [ + { model: "gpt-5-codex", requests: 1 } + ]); + + assert.equal(state.store.flush(), true); + if (process.platform !== "win32") { + assert.equal(statSync(state.path).mode & 0o777, 0o600); + } + const document = JSON.parse(readFileSync(state.path, "utf8")); + assert.deepEqual(Object.keys(document).sort(), [ + "bucketMinutes", + "buckets", + "retentionBuckets", + "schemaVersion" + ]); + assert.equal(document.schemaVersion, 1); + assert.equal(document.buckets.length, 1); + + const restored = new MetricsStore({ + path: state.path, + now: () => Date.parse("2026-07-16T12:45:00.000Z"), + flushDelayMs: 60_000 + }); + t.after(() => restored.close()); + assert.deepEqual(restored.getOverview({ window: "24h" }), state.store.getOverview({ window: "24h" })); +}); + +test("metrics store bounds dimensions, prunes after seven days, and rejects unsafe observations", (t) => { + const state = harness(t, { timestamp: Date.parse("2026-07-01T00:05:00.000Z") }); + const sentinel = "metrics-private-sentinel"; + assert.equal(state.store.record({ + ...observation(), + requestId: sentinel, + url: `https://${sentinel}.invalid`, + headers: { authorization: sentinel }, + body: sentinel, + error: sentinel + }), false); + assert.equal(state.store.record(observation({ inputTokens: 100_000_001 })), false); + assert.equal(state.store.record(observation({ model: `bad\u0000${sentinel}` })), false); + + for (let index = 0; index < 65; index += 1) { + assert.equal(state.store.record(observation({ model: `model-${index}` })), true); + } + for (let index = 0; index < 33; index += 1) { + assert.equal(state.store.record(observation({ providerId: `provider-${index}`, model: null })), true); + } + let overview = state.store.getOverview({ window: "7d" }); + assert.equal(overview.series.length, 168); + assert.equal(overview.dataQuality.modelOverflowRequests, 1); + assert.equal(overview.dataQuality.providerOverflowRequests, 2); + assert.equal(overview.dataQuality.unknownModelRequests, 33); + + assert.equal(state.store.flush(), true); + const persisted = readFileSync(state.path, "utf8"); + assert.equal(persisted.includes(sentinel), false); + assert.equal(persisted.includes("requestId"), false); + assert.equal(persisted.includes("headers"), false); + assert.equal(persisted.includes("body"), false); + assert.equal(persisted.includes("error"), false); + + state.advance(168 * HOUR_MS); + assert.equal(state.store.record(observation({ providerId: "provider-new", model: "model-new" })), true); + overview = state.store.getOverview({ window: "7d" }); + assert.equal(overview.summary.requests, 1); + assert.equal(overview.providers[0].providerId, "provider-new"); + assert.equal(overview.models[0].model, "model-new"); +}); + +test("maximum valid seven-day dimensions fit the metrics storage limit", (t) => { + const state = harness(t, { timestamp: Date.parse("2026-07-09T00:05:00.000Z") }); + const providerSuffix = "供".repeat(114); + const modelSuffix = "模".repeat(245); + + for (let hour = 0; hour < 168; hour += 1) { + for (let index = 0; index < 64; index += 1) { + assert.equal(state.store.record(observation({ + providerId: `p-${index % 32}-${providerSuffix}`, + model: `m-${index}-${modelSuffix}`, + inputTokens: null, + outputTokens: null + })), true); + } + if (hour < 167) state.advance(HOUR_MS); + } + + assert.equal(state.store.flush(), true); + const persistedSize = statSync(state.path).size; + assert.ok(persistedSize > 4 * 1024 * 1024); + assert.ok(persistedSize <= METRICS_MAX_FILE_BYTES); + const document = JSON.parse(readFileSync(state.path, "utf8")); + assert.equal(document.buckets.length, 168); + assert.equal(document.buckets.every((bucket) => bucket.providers.length === 32), true); + assert.equal(document.buckets.every((bucket) => bucket.models.length === 64), true); + + const restored = new MetricsStore({ + path: state.path, + now: () => Date.parse("2026-07-15T23:05:00.000Z"), + flushDelayMs: 60_000 + }); + t.after(() => restored.close()); + const overview = restored.getOverview({ window: "7d" }); + assert.equal(overview.storageState, "ready"); + assert.equal(overview.summary.requests, 168 * 64); + assert.equal(overview.providers.length, 16); + assert.equal(overview.models.length, 16); + assert.equal(overview.providerOtherRequests, 168 * 32); + assert.equal(overview.modelOtherRequests, 168 * 48); +}); + +test("invalid canonical storage is unavailable and is never overwritten", (t) => { + const dir = mkdtempSync(join(os.tmpdir(), "crp-metrics-invalid-")); + const path = join(dir, "metrics.json"); + const original = `{"schemaVersion":99,"secret":"do-not-overwrite"}\n`; + writeFileSync(path, original, { mode: 0o600 }); + chmodSync(path, 0o600); + const store = new MetricsStore({ + path, + now: () => Date.parse("2026-07-16T12:00:00.000Z"), + flushDelayMs: 60_000 + }); + t.after(() => { + store.close(); + rmSync(dir, { recursive: true, force: true }); + }); + + assert.equal(store.getOverview().storageState, "unavailable"); + assert.equal(store.record(observation()), true); + assert.equal(store.flush(), false); + assert.equal(readFileSync(path, "utf8"), original); + assert.equal(store.getOverview().summary.requests, 1); +}); + +test("persistence failure degrades metrics without losing in-memory aggregates", (t) => { + const state = harness(t); + assert.equal(state.store.record(observation()), true); + mkdirSync(state.path); + + assert.equal(state.store.flush(), false); + const overview = state.store.getOverview(); + assert.equal(overview.storageState, "degraded"); + assert.equal(overview.summary.requests, 1); + assert.equal(overview.summary.tokens.observedRequests, 1); +}); diff --git a/node/test/migration.test.mjs b/node/test/migration.test.mjs new file mode 100644 index 0000000..2bb1ed9 --- /dev/null +++ b/node/test/migration.test.mjs @@ -0,0 +1,701 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import * as realFileOperations from "node:fs"; +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + symlinkSync, + writeFileSync +} from "node:fs"; +import os from "node:os"; +import { join } from "node:path"; + +import { migrateLegacyConfiguration } from "../src/supervisor/migration.mjs"; + +const NOW = "2026-07-13T01:00:00.000Z"; + +function makeSecret() { + return `migration-test-${crypto.randomUUID()}`; +} + +function makeHarness(t, legacyDocument) { + const root = mkdtempSync(join(os.tmpdir(), "crp-migration-")); + const globalHome = join(root, ".codex-remote-proxy"); + const legacyConfigPath = join(globalHome, "config.json"); + const runtimeConfigPath = join(globalHome, "node", "proxy-config.json"); + const registryPath = join(globalHome, "providers.json"); + const legacyBytes = Buffer.from(`${JSON.stringify(legacyDocument, null, 2)}\n`, "utf8"); + mkdirSync(globalHome, { recursive: true, mode: 0o700 }); + writeFileSync(legacyConfigPath, legacyBytes, { mode: 0o600 }); + chmodSync(legacyConfigPath, 0o600); + t.after(() => rmSync(root, { recursive: true, force: true })); + return { + root, + globalHome, + legacyConfigPath, + runtimeConfigPath, + registryPath, + legacyBytes, + paths: { globalHome, legacyConfigPath, runtimeConfigPath, registryPath } + }; +} + +function writeRuntime(harness, document) { + mkdirSync(join(harness.globalHome, "node"), { recursive: true, mode: 0o700 }); + const bytes = Buffer.from(`${JSON.stringify(document, null, 2)}\n`, "utf8"); + writeFileSync(harness.runtimeConfigPath, bytes, { mode: 0o600 }); + chmodSync(harness.runtimeConfigPath, 0o600); + return bytes; +} + +class MemoryCredentialStore { + constructor({ failSet = false, failDelete = false } = {}) { + this.values = new Map(); + this.failSet = failSet; + this.failDelete = failDelete; + this.deleted = []; + } + + async set(ref, secret) { + if (this.failSet) throw new Error(`private credential failure ${secret}`); + this.values.set(ref, secret); + } + + async delete(ref) { + this.deleted.push(ref); + if (this.failDelete) throw new Error("private delete failure"); + return this.values.delete(ref); + } +} + +test("transactionally migrates legacy config to one untested inactive Default provider", async (t) => { + const secret = makeSecret(); + const harness = makeHarness(t, { + upstreamBaseUrl: "https://legacy.example/v1", + apiKey: secret, + captureEnabled: false + }); + const credentials = new MemoryCredentialStore(); + + const result = await migrateLegacyConfiguration({ + paths: harness.paths, + credentialStore: credentials, + now: () => NOW, + createProviderId: () => "provider-default", + createCredentialRef: () => "credential-opaque" + }); + + assert.deepEqual(result, { migrated: true, providerId: "provider-default" }); + assert.equal(credentials.values.get("credential-opaque"), secret); + const registry = JSON.parse(readFileSync(harness.registryPath, "utf8")); + assert.equal(registry.schemaVersion, 2); + assert.equal(registry.activeProviderId, null); + assert.equal(registry.providers.length, 1); + assert.equal(registry.providers[0].name, "Default"); + assert.equal(registry.providers[0].credentialRef, "credential-opaque"); + assert.equal(registry.providers[0].lastTestStatus, "untested"); + assert.equal(registry.providers[0].lastTestAt, null); + assert.equal(registry.providers[0].lastTestCode, null); + assert.equal(readFileSync(harness.registryPath, "utf8").includes(secret), false); + + const scrubbed = JSON.parse(readFileSync(harness.legacyConfigPath, "utf8")); + assert.equal("apiKey" in scrubbed, false); + assert.equal(scrubbed.upstreamBaseUrl, "https://legacy.example/v1"); + const backups = readdirSync(harness.globalHome) + .filter((name) => name.startsWith("config.json.") && name.endsWith(".bak")); + assert.equal(backups.length, 1); + assert.deepEqual(readFileSync(join(harness.globalHome, backups[0])), harness.legacyBytes); + if (process.platform !== "win32") { + assert.equal(lstatSync(join(harness.globalHome, backups[0])).mode & 0o777, 0o600); + } + const serializedResult = JSON.stringify(result); + assert.equal(serializedResult.includes(secret), false); + assert.equal(serializedResult.includes(backups[0]), false); +}); + +test("restores original bytes and removes new state when credential persistence fails", async (t) => { + const secret = makeSecret(); + const harness = makeHarness(t, { + upstreamBaseUrl: "https://legacy.example/v1", + apiKey: secret + }); + const credentials = new MemoryCredentialStore({ failSet: true }); + + await assert.rejects( + () => migrateLegacyConfiguration({ + paths: harness.paths, + credentialStore: credentials, + now: () => NOW, + createProviderId: () => "provider-default", + createCredentialRef: () => "credential-opaque" + }), + (error) => error?.code === "MIGRATION_FAILED" + && !error.message.includes(secret) + && !JSON.stringify(error.details).includes(secret) + ); + + assert.deepEqual(readFileSync(harness.legacyConfigPath), harness.legacyBytes); + assert.equal(existsSync(harness.registryPath), false); + assert.deepEqual(credentials.deleted, ["credential-opaque"]); + const backups = readdirSync(harness.globalHome) + .filter((name) => name.startsWith("config.json.") && name.endsWith(".bak")); + assert.equal(backups.length, 1); + assert.deepEqual(readFileSync(join(harness.globalHome, backups[0])), harness.legacyBytes); +}); + +test("migrates the runtime flat config and scrubs every backed-up secret source", async (t) => { + const secret = makeSecret(); + const harness = makeHarness(t, { captureEnabled: true }); + const runtimeBytes = writeRuntime(harness, { + baseUrl: "https://runtime.example/v1", + apiKey: secret, + authHeader: "x-runtime-auth", + authScheme: "Token", + extraHeaders: { "x-region": "test" } + }); + const credentials = new MemoryCredentialStore(); + + await migrateLegacyConfiguration({ + paths: harness.paths, + credentialStore: credentials, + now: () => NOW, + createProviderId: () => "provider-runtime", + createCredentialRef: () => "credential-runtime" + }); + + const provider = JSON.parse(readFileSync(harness.registryPath, "utf8")).providers[0]; + assert.equal(provider.baseUrl, "https://runtime.example/v1"); + assert.equal(provider.authHeader, "x-runtime-auth"); + assert.equal(provider.authScheme, "Token"); + assert.deepEqual(provider.extraHeaders, { "x-region": "test" }); + assert.equal(provider.lastTestStatus, "untested"); + assert.equal(JSON.stringify(JSON.parse(readFileSync(harness.runtimeConfigPath))).includes(secret), false); + assert.equal(JSON.stringify(JSON.parse(readFileSync(harness.legacyConfigPath))).includes(secret), false); + const runtimeBackups = readdirSync(join(harness.globalHome, "node")) + .filter((name) => name.startsWith("proxy-config.json.") && name.endsWith(".bak")); + assert.equal(runtimeBackups.length, 1); + assert.deepEqual( + readFileSync(join(harness.globalHome, "node", runtimeBackups[0])), + runtimeBytes + ); +}); + +test("rejects divergent legacy credentials before migration side effects", async (t) => { + const legacySecret = makeSecret(); + const runtimeSecret = makeSecret(); + const harness = makeHarness(t, { + upstreamBaseUrl: "https://saved.example/v1", + apiKey: legacySecret, + captureEnabled: false + }); + const runtimeBytes = writeRuntime(harness, { + upstream: { + baseUrl: "https://runtime.example/v1", + apiKey: runtimeSecret + } + }); + const activity = []; + let credentialSetCalls = 0; + let credentialDeleteCalls = 0; + const credentials = { + async set() { credentialSetCalls += 1; }, + async delete() { credentialDeleteCalls += 1; } + }; + + const failure = await migrateLegacyConfiguration({ + paths: harness.paths, + credentialStore: credentials, + activityStore: { + async append(event) { activity.push(structuredClone(event)); } + } + }).then( + () => null, + (error) => error + ); + + const publicFailure = { + code: failure?.code, + message: failure?.message, + action: failure?.action, + status: failure?.status, + details: failure?.details + }; + const serializedPublicState = JSON.stringify({ publicFailure, activity }); + assert.equal(serializedPublicState.includes(legacySecret), false); + assert.equal(serializedPublicState.includes(runtimeSecret), false); + assert.equal(failure?.code, "MIGRATION_INPUT_INVALID"); + assert.equal(failure?.status, 400); + assert.deepEqual(activity, [{ + category: "migration", + action: "legacy-config", + providerId: null, + result: "failed", + errorCode: "MIGRATION_INPUT_INVALID", + details: { rollbackDegraded: false } + }]); + assert.equal(credentialSetCalls, 0); + assert.equal(credentialDeleteCalls, 0); + assert.equal(existsSync(harness.registryPath), false); + assert.equal(existsSync(`${harness.registryPath}.migration.lock`), false); + assert.equal(readFileSync(harness.legacyConfigPath).equals(harness.legacyBytes), true); + assert.equal(readFileSync(harness.runtimeConfigPath).equals(runtimeBytes), true); + assert.equal(readdirSync(harness.globalHome).some((name) => name.endsWith(".bak")), false); + assert.equal( + readdirSync(join(harness.globalHome, "node")).some((name) => name.endsWith(".bak")), + false + ); +}); + +test("does not overwrite an exclusive backup collision and is idempotent after schema 2", async (t) => { + const secret = makeSecret(); + const harness = makeHarness(t, { + upstreamBaseUrl: "https://legacy.example/v1", + apiKey: secret + }); + const collisionPath = `${harness.legacyConfigPath}.collision.bak`; + const collisionBytes = Buffer.from("foreign-backup\n", "utf8"); + writeFileSync(collisionPath, collisionBytes, { mode: 0o600 }); + const backupIds = ["collision", "unique"]; + const credentials = new MemoryCredentialStore(); + + const first = await migrateLegacyConfiguration({ + paths: harness.paths, + credentialStore: credentials, + now: () => NOW, + createProviderId: () => "provider-default", + createCredentialRef: () => "credential-opaque", + createBackupId: () => backupIds.shift() ?? "unexpected" + }); + assert.deepEqual(first, { migrated: true, providerId: "provider-default" }); + assert.deepEqual(readFileSync(collisionPath), collisionBytes); + const beforeRegistry = readFileSync(harness.registryPath); + const beforeBackups = readdirSync(harness.globalHome).filter((name) => name.endsWith(".bak")); + + const second = await migrateLegacyConfiguration({ + paths: harness.paths, + credentialStore: { + async set() { throw new Error("must not write a second credential"); }, + async delete() { throw new Error("must not delete the existing credential"); } + }, + createProviderId: () => { throw new Error("must not create a second provider"); }, + createCredentialRef: () => { throw new Error("must not create a second reference"); } + }); + assert.deepEqual(second, { migrated: false, reason: "already-current" }); + assert.deepEqual(readFileSync(harness.registryPath), beforeRegistry); + assert.deepEqual( + readdirSync(harness.globalHome).filter((name) => name.endsWith(".bak")), + beforeBackups + ); +}); + +test("rolls back scrubbed files, registry, and credential when a later scrub fails", async (t) => { + const secret = makeSecret(); + const harness = makeHarness(t, { + upstreamBaseUrl: "https://legacy.example/v1", + apiKey: secret + }); + const runtimeBytes = writeRuntime(harness, { + upstream: { baseUrl: "https://legacy.example/v1", apiKey: secret } + }); + const credentials = new MemoryCredentialStore(); + + await assert.rejects( + () => migrateLegacyConfiguration({ + paths: harness.paths, + credentialStore: credentials, + now: () => NOW, + createProviderId: () => "provider-default", + createCredentialRef: () => "credential-opaque", + fileOperations: { + ...realFileOperations, + renameSync(from, to) { + if (to === harness.runtimeConfigPath && from.endsWith(".tmp")) { + const error = new Error("private runtime scrub failure"); + error.code = "EIO"; + throw error; + } + return realFileOperations.renameSync(from, to); + } + } + }), + (error) => error?.code === "MIGRATION_FAILED" && error.details.committed === false + ); + + assert.deepEqual(readFileSync(harness.legacyConfigPath), harness.legacyBytes); + assert.deepEqual(readFileSync(harness.runtimeConfigPath), runtimeBytes); + assert.equal(existsSync(harness.registryPath), false); + assert.equal(credentials.values.size, 0); + assert.deepEqual(credentials.deleted, ["credential-opaque"]); +}); + +test("reports stable degraded rollback state and retains backups when compensation fails", async (t) => { + const secret = makeSecret(); + const harness = makeHarness(t, { + upstreamBaseUrl: "https://legacy.example/v1", + apiKey: secret + }); + writeRuntime(harness, { + upstream: { baseUrl: "https://legacy.example/v1", apiKey: secret } + }); + const credentials = new MemoryCredentialStore({ failDelete: true }); + + await assert.rejects( + () => migrateLegacyConfiguration({ + paths: harness.paths, + credentialStore: credentials, + now: () => NOW, + createProviderId: () => "provider-default", + createCredentialRef: () => "credential-opaque", + fileOperations: { + ...realFileOperations, + renameSync(from, to) { + if (to === harness.runtimeConfigPath && from.endsWith(".tmp")) { + const error = new Error("private runtime scrub failure"); + error.code = "EIO"; + throw error; + } + return realFileOperations.renameSync(from, to); + } + } + }), + (error) => error?.code === "MIGRATION_ROLLBACK_DEGRADED" + && error.details.committed === false + && error.details.degraded === true + && !error.message.includes(secret) + ); + + assert.ok(readdirSync(harness.globalHome).some((name) => name.endsWith(".bak"))); + assert.ok(readdirSync(join(harness.globalHome, "node")).some((name) => name.endsWith(".bak"))); +}); + +test("serializes migration transactions with an exclusive preserved lock", async (t) => { + const secret = makeSecret(); + const harness = makeHarness(t, { + upstreamBaseUrl: "https://legacy.example/v1", + apiKey: secret + }); + let releaseSet; + const credentials = new MemoryCredentialStore(); + credentials.set = async (ref, value) => { + await new Promise((resolve) => { releaseSet = resolve; }); + credentials.values.set(ref, value); + }; + + const first = migrateLegacyConfiguration({ + paths: harness.paths, + credentialStore: credentials, + now: () => NOW, + createProviderId: () => "provider-default", + createCredentialRef: () => "credential-opaque" + }); + while (!releaseSet) await Promise.resolve(); + const lockPath = `${harness.registryPath}.migration.lock`; + const lockBytes = readFileSync(lockPath); + await assert.rejects( + () => migrateLegacyConfiguration({ paths: harness.paths, credentialStore: credentials }), + (error) => error?.code === "MIGRATION_BUSY" + ); + assert.deepEqual(readFileSync(lockPath), lockBytes); + releaseSet(); + await first; + assert.equal(existsSync(lockPath), false); +}); + +test("reports rollback degraded when a failed transaction lock cannot be released", async (t) => { + const secret = makeSecret(); + const harness = makeHarness(t, { + upstreamBaseUrl: "https://legacy.example/v1", + apiKey: secret + }); + const credentials = new MemoryCredentialStore({ failSet: true }); + const lockPath = `${harness.registryPath}.migration.lock`; + + await assert.rejects( + () => migrateLegacyConfiguration({ + paths: harness.paths, + credentialStore: credentials, + fileOperations: { + ...realFileOperations, + renameSync(from, to) { + if (from === lockPath && to.endsWith(".release")) { + const error = new Error("private lock release failure"); + error.code = "EIO"; + throw error; + } + return realFileOperations.renameSync(from, to); + } + } + }), + (error) => error?.code === "MIGRATION_ROLLBACK_DEGRADED" + && error.details.committed === false + && error.details.degraded === true + && !error.message.includes(secret) + ); + assert.deepEqual(readFileSync(harness.legacyConfigPath), harness.legacyBytes); + assert.equal(existsSync(harness.registryPath), false); + assert.equal(existsSync(lockPath), true); +}); + +test("rejects symlink legacy and registry paths without reading or replacing their targets", async (t) => { + if (process.platform === "win32") { + t.skip("Windows symlink creation requires Developer Mode or elevated privilege"); + return; + } + const secret = makeSecret(); + const harness = makeHarness(t, { + upstreamBaseUrl: "https://legacy.example/v1", + apiKey: secret + }); + const legacyTarget = join(harness.root, "legacy-target.json"); + const legacyTargetBytes = readFileSync(harness.legacyConfigPath); + writeFileSync(legacyTarget, legacyTargetBytes, { mode: 0o600 }); + rmSync(harness.legacyConfigPath); + symlinkSync(legacyTarget, harness.legacyConfigPath); + const credentials = new MemoryCredentialStore(); + + await assert.rejects( + () => migrateLegacyConfiguration({ paths: harness.paths, credentialStore: credentials }), + (error) => error?.code === "MIGRATION_INPUT_INVALID" + ); + assert.equal(lstatSync(harness.legacyConfigPath).isSymbolicLink(), true); + assert.deepEqual(readFileSync(legacyTarget), legacyTargetBytes); + assert.deepEqual(credentials.values.size, 0); + + rmSync(harness.legacyConfigPath); + writeFileSync(harness.legacyConfigPath, legacyTargetBytes, { mode: 0o600 }); + const registryTarget = join(harness.root, "registry-target.json"); + const registryTargetBytes = Buffer.from(JSON.stringify({ + schemaVersion: 2, + activeProviderId: null, + providers: [], + settings: { + proxyHost: "127.0.0.1", + proxyPort: 15100, + adminHost: "127.0.0.1", + adminPort: 15101, + captureEnabled: false + } + }), "utf8"); + writeFileSync(registryTarget, registryTargetBytes, { mode: 0o600 }); + symlinkSync(registryTarget, harness.registryPath); + await assert.rejects( + () => migrateLegacyConfiguration({ paths: harness.paths, credentialStore: credentials }), + (error) => error?.code === "MIGRATION_INPUT_INVALID" + ); + assert.equal(lstatSync(harness.registryPath).isSymbolicLink(), true); + assert.deepEqual(readFileSync(registryTarget), registryTargetBytes); +}); + +test("rollback preserves a foreign registry replacement it does not own", async (t) => { + const secret = makeSecret(); + const harness = makeHarness(t, { + upstreamBaseUrl: "https://legacy.example/v1", + apiKey: secret + }); + const foreignRegistry = Buffer.from("foreign-registry-bytes\n", "utf8"); + const displacedRegistry = `${harness.registryPath}.owned-displaced`; + const credentials = new MemoryCredentialStore(); + const activityStore = { + append() { + renameSync(harness.registryPath, displacedRegistry); + writeFileSync(harness.registryPath, foreignRegistry, { mode: 0o600 }); + throw new Error("private activity failure"); + } + }; + + await assert.rejects( + () => migrateLegacyConfiguration({ + paths: harness.paths, + credentialStore: credentials, + activityStore, + now: () => NOW, + createProviderId: () => "provider-default", + createCredentialRef: () => "credential-opaque" + }), + (error) => error?.code === "MIGRATION_ROLLBACK_DEGRADED" + && error.details.degraded === true + ); + assert.deepEqual(readFileSync(harness.registryPath), foreignRegistry); + assert.deepEqual(readFileSync(harness.legacyConfigPath), harness.legacyBytes); + assert.equal(credentials.values.size, 0); +}); + +test("lock initialization failure preserves a foreign canonical replacement and blocks retry", async (t) => { + const secret = makeSecret(); + const harness = makeHarness(t, { + upstreamBaseUrl: "https://legacy.example/v1", + apiKey: secret + }); + const lockPath = `${harness.registryPath}.migration.lock`; + const displacedLock = `${lockPath}.owned-displaced`; + const foreign = Buffer.from("foreign-migration-owner\n", "utf8"); + let swapped = false; + const credentials = new MemoryCredentialStore(); + + await assert.rejects( + () => migrateLegacyConfiguration({ + paths: harness.paths, + credentialStore: credentials, + fileOperations: { + ...realFileOperations, + writeFileSync(target, bytes, options) { + if (!swapped && typeof target === "number") { + swapped = true; + realFileOperations.renameSync(lockPath, displacedLock); + realFileOperations.writeFileSync(lockPath, foreign, { mode: 0o600 }); + const error = new Error("private lock init failure"); + error.code = "EIO"; + throw error; + } + return realFileOperations.writeFileSync(target, bytes, options); + } + } + }), + (error) => error?.code === "MIGRATION_ROLLBACK_DEGRADED" + && error.details.committed === false + ); + assert.deepEqual(readFileSync(lockPath), foreign); + await assert.rejects( + () => migrateLegacyConfiguration({ paths: harness.paths, credentialStore: credentials }), + (error) => error?.code === "MIGRATION_BUSY" + ); + assert.deepEqual(readFileSync(lockPath), foreign); +}); + +test("release token mismatch restores a canonical blocker before the next transaction", async (t) => { + const secret = makeSecret(); + const harness = makeHarness(t, { + upstreamBaseUrl: "https://legacy.example/v1", + apiKey: secret + }); + const lockPath = `${harness.registryPath}.migration.lock`; + const credentials = new MemoryCredentialStore(); + const releaseDescriptors = new Set(); + + await assert.rejects( + () => migrateLegacyConfiguration({ + paths: harness.paths, + credentialStore: credentials, + now: () => NOW, + createProviderId: () => "provider-default", + createCredentialRef: () => "credential-opaque", + fileOperations: { + ...realFileOperations, + openSync(path, flags, mode) { + const descriptor = realFileOperations.openSync(path, flags, mode); + if (typeof path === "string" && path.endsWith(".release")) { + releaseDescriptors.add(descriptor); + } + return descriptor; + }, + readFileSync(target, options) { + if (releaseDescriptors.has(target)) { + return "foreign-claim-token\n"; + } + return realFileOperations.readFileSync(target, options); + } + } + }), + (error) => error?.code === "MIGRATION_COMMITTED_LOCK_DEGRADED" + && error.details.committed === true + ); + assert.equal(existsSync(lockPath), true); + await assert.rejects( + () => migrateLegacyConfiguration({ paths: harness.paths, credentialStore: credentials }), + (error) => error?.code === "MIGRATION_BUSY" + ); +}); + +test("keeps committed schema 2 and credential when registry lock cleanup degrades", async (t) => { + const secret = makeSecret(); + const harness = makeHarness(t, { + upstreamBaseUrl: "https://legacy.example/v1", + apiKey: secret + }); + const credentials = new MemoryCredentialStore(); + const registryLockPath = `${harness.registryPath}.crp.lock`; + + await assert.rejects( + () => migrateLegacyConfiguration({ + paths: harness.paths, + credentialStore: credentials, + now: () => NOW, + createProviderId: () => "provider-default", + createCredentialRef: () => "credential-opaque", + fileOperations: { + ...realFileOperations, + rmSync(path, options) { + if (path === registryLockPath) { + const error = new Error("private registry lock cleanup failure"); + error.code = "EACCES"; + throw error; + } + return realFileOperations.rmSync(path, options); + } + } + }), + (error) => error?.code === "MIGRATION_COMMITTED_DEGRADED" + && error.details.committed === true + ); + + const registry = JSON.parse(readFileSync(harness.registryPath, "utf8")); + assert.equal(registry.providers[0].id, "provider-default"); + assert.equal(credentials.values.get("credential-opaque"), secret); + assert.equal("apiKey" in JSON.parse(readFileSync(harness.legacyConfigPath, "utf8")), false); + assert.equal(existsSync(registryLockPath), true); +}); + +test("exclusive registry creation preserves a foreign schema 2 created during credential set", async (t) => { + const secret = makeSecret(); + const harness = makeHarness(t, { + upstreamBaseUrl: "https://legacy.example/v1", + apiKey: secret + }); + const foreignBytes = Buffer.from(`${JSON.stringify({ + schemaVersion: 2, + activeProviderId: null, + providers: [], + settings: { + proxyHost: "127.0.0.1", + proxyPort: 15100, + adminHost: "127.0.0.1", + adminPort: 15101, + captureEnabled: false + } + }, null, 2)}\n`, "utf8"); + const credentials = new MemoryCredentialStore(); + const originalSet = credentials.set.bind(credentials); + let foreignIdentity; + credentials.set = async (ref, value) => { + await originalSet(ref, value); + writeFileSync(harness.registryPath, foreignBytes, { flag: "wx", mode: 0o600 }); + const stats = lstatSync(harness.registryPath); + foreignIdentity = { dev: stats.dev, ino: stats.ino }; + }; + + await assert.rejects( + () => migrateLegacyConfiguration({ + paths: harness.paths, + credentialStore: credentials, + now: () => NOW, + createProviderId: () => "provider-default", + createCredentialRef: () => "credential-opaque" + }), + (error) => error?.code === "MIGRATION_REGISTRY_CONFLICT" + && error.status === 409 + ); + + const after = lstatSync(harness.registryPath); + assert.deepEqual({ dev: after.dev, ino: after.ino }, foreignIdentity); + assert.deepEqual(readFileSync(harness.registryPath), foreignBytes); + assert.deepEqual(readFileSync(harness.legacyConfigPath), harness.legacyBytes); + assert.equal(credentials.values.size, 0); + assert.deepEqual(credentials.deleted, ["credential-opaque"]); +}); diff --git a/node/test/native-keyring-smoke.test.mjs b/node/test/native-keyring-smoke.test.mjs new file mode 100644 index 0000000..966e7cc --- /dev/null +++ b/node/test/native-keyring-smoke.test.mjs @@ -0,0 +1,334 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { performance } from "node:perf_hooks"; +import { fileURLToPath } from "node:url"; + +import { + isNativeKeyringSmokeAuthorized, + runNativeKeyringSmoke, + runNativeKeyringSmokeMain +} from "../scripts/native-keyring-smoke.mjs"; + +const scriptPath = fileURLToPath(new URL("../scripts/native-keyring-smoke.mjs", import.meta.url)); + +function makeIdentity(label = "test") { + return { + ref: `crp-ci-${label}-${randomUUID()}`, + secret: ["crp", "ci", label, randomUUID(), randomUUID()].join("-") + }; +} + +function assertRedacted(value, { ref, secret }) { + const serialized = String(value); + assert.equal(serialized.includes(ref), false); + assert.equal(serialized.includes(secret), false); +} + +function createRecordingStore({ + setWritesThenThrows = false, + wrongGet = false, + firstHasFalse = false, + deletePlan = [] +} = {}) { + const values = new Map(); + const calls = []; + let hasCalls = 0; + let deleteCalls = 0; + return { + calls, + values, + async set(ref, secret) { + calls.push({ operation: "set", args: [ref, secret] }); + values.set(ref, secret); + if (setWritesThenThrows) { + throw new Error(`set failed after write: ${ref}:${secret}`); + } + }, + async get(ref) { + calls.push({ operation: "get", args: [ref] }); + if (wrongGet) { + return "wrong-credential-value"; + } + return values.get(ref); + }, + async has(ref) { + calls.push({ operation: "has", args: [ref] }); + hasCalls += 1; + if (firstHasFalse && hasCalls === 1) { + return false; + } + return values.has(ref); + }, + async delete(ref) { + calls.push({ operation: "delete", args: [ref] }); + const action = deletePlan[deleteCalls]; + deleteCalls += 1; + if (action === "throw") { + const secret = values.get(ref) ?? "missing"; + throw new Error(`delete failed: ${ref}:${secret}`); + } + if (action === "keep-true") { + return true; + } + if (action === "false") { + return false; + } + return values.delete(ref); + } + }; +} + +function operationNames(store) { + return store.calls.map(({ operation }) => operation); +} + +function assertExactIdentity(store, { ref, secret }) { + assert.equal(store.calls.length > 0, true); + assert.equal(store.calls.every(({ args }) => args[0] === ref), true); + const setCalls = store.calls.filter(({ operation }) => operation === "set"); + assert.equal(setCalls.length, 1); + assert.equal(setCalls[0].args[1] === secret, true); +} + +async function captureFailure(options) { + try { + await runNativeKeyringSmoke(options); + } catch (error) { + return error; + } + assert.fail("Expected native keyring smoke to fail"); +} + +test("native keyring smoke uses one exact identity for the complete successful contract", async () => { + const identity = makeIdentity("success"); + const store = createRecordingStore(); + const output = []; + + const result = await runNativeKeyringSmoke({ + storeFactory: () => store, + createRef: () => identity.ref, + createSecret: () => identity.secret, + writeLine: (line) => output.push(line) + }); + + assertRedacted(JSON.stringify(result), identity); + assertRedacted(output.join("\n"), identity); + assert.deepEqual(result, { ok: true }); + assert.deepEqual(operationNames(store), ["set", "get", "has", "delete", "has"]); + assertExactIdentity(store, identity); + assert.equal(store.values.size, 0); + assert.deepEqual(output, ["Native keyring smoke passed."]); +}); + +test("default identity generation is unique across smoke runs", async () => { + const stores = [createRecordingStore(), createRecordingStore()]; + for (const store of stores) { + await runNativeKeyringSmoke({ storeFactory: () => store, writeLine: () => {} }); + } + + const identities = stores.map((store) => ({ + ref: store.calls[0].args[0], + secret: store.calls[0].args[1] + })); + assert.equal(identities[0].ref === identities[1].ref, false); + assert.equal(identities[0].secret === identities[1].secret, false); + for (let index = 0; index < stores.length; index += 1) { + assertExactIdentity(stores[index], identities[index]); + } +}); + +test("primary smoke failures clean up and remain redacted", async (t) => { + const cases = [ + { + name: "set writes before throwing", + options: { setWritesThenThrows: true }, + operations: ["set", "delete", "has"] + }, + { + name: "get returns the wrong credential", + options: { wrongGet: true }, + operations: ["set", "get", "delete", "has"] + }, + { + name: "has reports false after an exact get", + options: { firstHasFalse: true }, + operations: ["set", "get", "has", "delete", "has"] + }, + { + name: "delete reports success but the credential remains", + options: { deletePlan: ["keep-true"] }, + operations: ["set", "get", "has", "delete", "has", "delete", "has"] + } + ]; + + for (const entry of cases) { + await t.test(entry.name, async () => { + const identity = makeIdentity("primary"); + const store = createRecordingStore(entry.options); + const output = []; + const error = await captureFailure({ + storeFactory: () => store, + createRef: () => identity.ref, + createSecret: () => identity.secret, + writeLine: (line) => output.push(line) + }); + + assert.equal(error?.code, "NATIVE_KEYRING_SMOKE_FAILED"); + assertRedacted(error?.message, identity); + assertRedacted(error?.stack, identity); + assertRedacted(output.join("\n"), identity); + assert.deepEqual(operationNames(store), entry.operations); + assertExactIdentity(store, identity); + assert.equal(store.values.size, 0); + assert.deepEqual(output, []); + }); + } +}); + +test("delete failures remain cleanup failures even when the finally retry succeeds", async () => { + const identity = makeIdentity("delete"); + const store = createRecordingStore({ deletePlan: ["throw"] }); + const error = await captureFailure({ + storeFactory: () => store, + createRef: () => identity.ref, + createSecret: () => identity.secret, + writeLine: () => {} + }); + + assert.equal(error?.code, "NATIVE_KEYRING_SMOKE_CLEANUP_FAILED"); + assertRedacted(error?.message, identity); + assertRedacted(error?.stack, identity); + assert.deepEqual( + operationNames(store), + ["set", "get", "has", "delete", "delete", "has"] + ); + assertExactIdentity(store, identity); + assert.equal(store.values.size, 0); +}); + +test("cleanup uncertainty overrides a primary error and never exposes identifiers", async () => { + const identity = makeIdentity("cleanup"); + const store = createRecordingStore({ + setWritesThenThrows: true, + deletePlan: ["throw"] + }); + const error = await captureFailure({ + storeFactory: () => store, + createRef: () => identity.ref, + createSecret: () => identity.secret, + writeLine: () => {} + }); + + assert.equal(error?.code, "NATIVE_KEYRING_SMOKE_CLEANUP_FAILED"); + assertRedacted(error?.message, identity); + assertRedacted(error?.stack, identity); + assert.deepEqual(operationNames(store), ["set", "delete"]); + assertExactIdentity(store, identity); + assert.equal(store.values.get(identity.ref) === identity.secret, true); +}); + +test("native keyring smoke main authorizes before constructing an adapter", async () => { + let constructions = 0; + const stdout = []; + const stderr = []; + const status = await runNativeKeyringSmokeMain({ + environment: {}, + smokeOptions: { + storeFactory: () => { + constructions += 1; + return createRecordingStore(); + } + }, + writeStdout: (chunk) => stdout.push(chunk), + writeStderr: (chunk) => stderr.push(chunk) + }); + + assert.equal(status, 2); + assert.equal(constructions, 0); + assert.deepEqual(stdout, []); + assert.deepEqual(stderr, [ + "Native keyring smoke requires an explicitly authorized platform runner.\n" + ]); +}); + +test("native keyring smoke main returns success and failure exit codes with redacted streams", async () => { + const environment = { GITHUB_ACTIONS: "true", CRP_NATIVE_KEYRING_SMOKE: "1" }; + + const successIdentity = makeIdentity("main-success"); + const successStore = createRecordingStore(); + const successStdout = []; + const successStderr = []; + const successStatus = await runNativeKeyringSmokeMain({ + environment, + smokeOptions: { + storeFactory: () => successStore, + createRef: () => successIdentity.ref, + createSecret: () => successIdentity.secret + }, + writeStdout: (chunk) => successStdout.push(chunk), + writeStderr: (chunk) => successStderr.push(chunk) + }); + assertRedacted(successStdout.join(""), successIdentity); + assertRedacted(successStderr.join(""), successIdentity); + assert.equal(successStatus, 0); + assert.deepEqual(successStdout, ["Native keyring smoke passed.\n"]); + assert.deepEqual(successStderr, []); + assertExactIdentity(successStore, successIdentity); + + const failureIdentity = makeIdentity("main-failure"); + const failureStore = createRecordingStore({ wrongGet: true }); + const failureStdout = []; + const failureStderr = []; + const failureStatus = await runNativeKeyringSmokeMain({ + environment, + smokeOptions: { + storeFactory: () => failureStore, + createRef: () => failureIdentity.ref, + createSecret: () => failureIdentity.secret + }, + writeStdout: (chunk) => failureStdout.push(chunk), + writeStderr: (chunk) => failureStderr.push(chunk) + }); + assertRedacted(failureStdout.join(""), failureIdentity); + assertRedacted(failureStderr.join(""), failureIdentity); + assert.equal(failureStatus, 1); + assert.deepEqual(failureStdout, []); + assert.deepEqual(failureStderr, [ + "Native keyring smoke failed (NATIVE_KEYRING_SMOKE_FAILED).\n" + ]); + assertExactIdentity(failureStore, failureIdentity); +}); + +test("direct CLI refuses unauthorized execution quickly without a test backdoor", () => { + const environment = { ...process.env }; + delete environment.GITHUB_ACTIONS; + delete environment.CRP_NATIVE_KEYRING_SMOKE; + const startedAt = performance.now(); + const result = spawnSync(process.execPath, [scriptPath], { + encoding: "utf8", + env: environment, + timeout: 2_000 + }); + const elapsed = performance.now() - startedAt; + + assert.ifError(result.error); + assert.equal(elapsed < 2_000, true); + assert.equal(result.status, 2); + assert.equal(result.stdout, ""); + assert.equal( + result.stderr, + "Native keyring smoke requires an explicitly authorized platform runner.\n" + ); +}); + +test("native keyring smoke authorization requires both explicit runner signals", () => { + assert.equal(isNativeKeyringSmokeAuthorized({}), false); + assert.equal(isNativeKeyringSmokeAuthorized({ GITHUB_ACTIONS: "true" }), false); + assert.equal(isNativeKeyringSmokeAuthorized({ CRP_NATIVE_KEYRING_SMOKE: "1" }), false); + assert.equal(isNativeKeyringSmokeAuthorized({ + GITHUB_ACTIONS: "true", + CRP_NATIVE_KEYRING_SMOKE: "1" + }), true); +}); diff --git a/node/test/package-content.test.mjs b/node/test/package-content.test.mjs new file mode 100644 index 0000000..936d2a0 --- /dev/null +++ b/node/test/package-content.test.mjs @@ -0,0 +1,201 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const REVIEWED_PACKAGE_PATHS = new Set([ + "LICENSE", + "README.md", + "bin/crp.mjs", + "package.json", + "proxy-config.example.json", + "src/capture-config.mjs", + "src/capture-store.mjs", + "src/codex/codex-config.mjs", + "src/codex/codex-history-repair.mjs", + "src/credentials/credential-store.mjs", + "src/credentials/file-credential-store.mjs", + "src/credentials/native-keyring.mjs", + "src/providers/provider-model-cache.mjs", + "src/providers/provider-registry.mjs", + "src/providers/provider-schema.mjs", + "src/server.mjs", + "src/shared/errors.mjs", + "src/shared/paths.mjs", + "src/supervisor/activity-store.mjs", + "src/supervisor/admin-server.mjs", + "src/supervisor/migration.mjs", + "src/supervisor/metrics-store.mjs", + "src/supervisor/provider-service.mjs", + "src/supervisor/session-auth.mjs", + "src/supervisor/supervisor-client.mjs", + "src/supervisor/supervisor-entry.mjs", + "src/supervisor/supervisor.mjs", + "src/supervisor/worker-manager.mjs", + "src/worker/protocol.mjs", + "src/worker/runtime-settings.mjs", + "src/worker/worker-entry.mjs", + "ui/app.js", + "ui/index.html", + "ui/styles.css" +]); +const FORBIDDEN_TOP_LEVEL_DIRECTORIES = new Set([ + ".changeset", + ".codex-remote-proxy", + ".superpowers", + "credentials", + "logs", + "output", + "runtime", + "secrets", + "state", + "test", + "tests" +]); +const FORBIDDEN_RUNTIME_NAMES = new Set([ + ".env", + "activity.jsonl", + "auth.json", + "control-token", + "credentials.json", + "metrics.json", + "provider-model-cache.json", + "providers.json", + "secrets.json", + "state.json", + "supervisor.log", + "traffic.sqlite3" +]); +const PACK_ARGUMENTS = ["pack", "--dry-run", "--json", "--ignore-scripts"]; + +function buildPackInvocation(platform = process.platform, environment = process.env) { + if (platform === "win32") { + return { + command: environment.ComSpec || environment.COMSPEC || "cmd.exe", + args: [ + "/d", + "/s", + "/c", + "npm.cmd pack --dry-run --json --ignore-scripts" + ] + }; + } + return { command: "npm", args: [...PACK_ARGUMENTS] }; +} + +function normalizePackPath(filePath) { + assert.equal(typeof filePath, "string"); + assert.notEqual(filePath.length, 0); + assert.equal(filePath.includes("\0"), false); + const normalized = filePath.replaceAll("\\", "/"); + assert.equal(normalized.startsWith("/"), false); + assert.equal(normalized.split("/").includes(".."), false); + return normalized; +} + +function isRuntimeOrDevelopmentPath(filePath) { + const segments = filePath.split("/"); + const basename = segments.at(-1).toLowerCase(); + const topLevel = segments[0].toLowerCase(); + return FORBIDDEN_TOP_LEVEL_DIRECTORIES.has(topLevel) + || FORBIDDEN_RUNTIME_NAMES.has(basename) + || basename.startsWith(".env.") + || /\.(?:db|log|pem|key|p12|sqlite|sqlite3)(?:-(?:shm|wal)|-journal)?$/i.test(basename); +} + +function parsePackFilePaths(stdout) { + let parsed; + assert.doesNotThrow(() => { + parsed = JSON.parse(stdout); + }, "npm pack must emit one structured JSON document"); + assert.equal(Array.isArray(parsed), true); + assert.equal(parsed.length, 1); + assert.equal(Array.isArray(parsed[0]?.files), true); + + const paths = parsed[0].files.map((entry) => { + assert.equal(entry !== null && typeof entry === "object", true); + return normalizePackPath(entry.path); + }); + assert.equal(new Set(paths).size, paths.length, "packed paths must be unique"); + return new Set(paths); +} + +function comparePackagePaths(actualPaths, expectedPaths = REVIEWED_PACKAGE_PATHS) { + const actual = new Set(actualPaths); + const expected = new Set(expectedPaths); + return { + missing: [...expected].filter((path) => !actual.has(path)).sort(), + unexpected: [...actual].filter((path) => !expected.has(path)).sort() + }; +} + +test("npm pack invocation uses an explicit command interpreter only on Windows", () => { + assert.deepEqual( + buildPackInvocation("win32", { ComSpec: "C:\\Windows\\System32\\cmd.exe" }), + { + command: "C:\\Windows\\System32\\cmd.exe", + args: [ + "/d", + "/s", + "/c", + "npm.cmd pack --dry-run --json --ignore-scripts" + ] + } + ); + assert.deepEqual(buildPackInvocation("linux", {}), { + command: "npm", + args: ["pack", "--dry-run", "--json", "--ignore-scripts"] + }); +}); + +test("exact package path comparison detects every extra and missing path", () => { + const reviewed = [...REVIEWED_PACKAGE_PATHS]; + assert.equal(REVIEWED_PACKAGE_PATHS.size, 34); + assert.deepEqual(comparePackagePaths(reviewed), { missing: [], unexpected: [] }); + + const runtimeExtras = [ + "secrets.json", + "state.json", + "metrics.json", + "traffic.sqlite3", + "supervisor.log", + "cluic-codex-remote-proxy-0.2.2.tgz" + ]; + assert.deepEqual( + comparePackagePaths([...reviewed, ...runtimeExtras]), + { missing: [], unexpected: [...runtimeExtras].sort() } + ); + + assert.deepEqual( + comparePackagePaths(reviewed.filter((path) => path !== "ui/app.js")), + { missing: ["ui/app.js"], unexpected: [] } + ); +}); + +test("npm package contains executable, source, and UI assets without development or runtime state", () => { + const invocation = buildPackInvocation(); + const result = spawnSync( + invocation.command, + invocation.args, + { + cwd: packageRoot, + encoding: "utf8", + maxBuffer: 10 * 1024 * 1024, + timeout: 60_000 + } + ); + + assert.ifError(result.error); + assert.equal(result.status, 0, result.stderr); + const paths = parsePackFilePaths(result.stdout); + + assert.deepEqual(comparePackagePaths(paths), { missing: [], unexpected: [] }); + assert.equal(paths.has("src/credentials/native-keyring.mjs"), true); + assert.equal(isRuntimeOrDevelopmentPath("src/credentials/native-keyring.mjs"), false); + assert.equal(isRuntimeOrDevelopmentPath("credentials/provider.json"), true); + + const prohibited = [...paths].filter(isRuntimeOrDevelopmentPath); + assert.deepEqual(prohibited, []); +}); diff --git a/node/test/provider-model-cache.test.mjs b/node/test/provider-model-cache.test.mjs new file mode 100644 index 0000000..845debd --- /dev/null +++ b/node/test/provider-model-cache.test.mjs @@ -0,0 +1,645 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import * as realFileOperations from "node:fs"; +import { + existsSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync +} from "node:fs"; +import os from "node:os"; +import { basename, dirname, join } from "node:path"; + +import { + MAX_MODEL_ID_LENGTH, + MAX_PROVIDER_MODELS, + ProviderModelCache, + createProviderSourceFingerprint +} from "../src/providers/provider-model-cache.mjs"; +import { normalizeProvider } from "../src/providers/provider-schema.mjs"; +import { CrpError } from "../src/shared/errors.mjs"; + +const FETCHED_AT = "2026-07-16T00:00:00.000Z"; +const DAY_MS = 24 * 60 * 60 * 1000; +const FINGERPRINT = `sha256:${"a".repeat(64)}`; +const MAX_CACHE_ENTRIES = 512; +const MAX_CACHE_FILE_BYTES = 16 * 1024 * 1024; + +function makeTempCache(t, prefix = "crp-provider-model-cache-") { + const tempDir = mkdtempSync(join(os.tmpdir(), prefix)); + const cachePath = join(tempDir, "provider-model-cache.json"); + t.after(() => rmSync(tempDir, { recursive: true, force: true })); + return { tempDir, cachePath }; +} + +function validEntry(overrides = {}) { + return { + providerId: "provider-1", + sourceFingerprint: FINGERPRINT, + fetchedAt: FETCHED_AT, + models: ["gpt-alpha", "gpt-beta"], + ...overrides + }; +} + +function validEntries(count, entry = {}) { + return Array.from({ length: count }, (_, index) => validEntry({ + providerId: `provider-${index + 1}`, + models: [`model-${index + 1}`], + ...entry + })); +} + +function missing(providerId = "provider-1") { + return { + providerId, + state: "missing", + fetchedAt: null, + expiresAt: null, + models: [] + }; +} + +function assertCrpError(expectedCode, expectedStatus) { + return (error) => { + assert.ok(error instanceof CrpError); + assert.equal(error.code, expectedCode); + assert.equal(error.status, expectedStatus); + assert.equal(typeof error.action, "string"); + assert.notEqual(error.action.length, 0); + return true; + }; +} + +function listTempFiles(tempDir, cachePath) { + const cacheName = basename(cachePath); + return readdirSync(tempDir).filter((name) => ( + name.startsWith(`.${cacheName}.`) && name.endsWith(".tmp") + )); +} + +function makeFileError(message, code) { + const error = new Error(message); + error.code = code; + return error; +} + +test("keeps an absent cache lazy and returns the exact missing projection", (t) => { + const { cachePath } = makeTempCache(t); + const cache = new ProviderModelCache({ + path: cachePath, + now: () => FETCHED_AT + }); + + assert.deepEqual(cache.get("provider-1", FINGERPRINT), missing()); + assert.deepEqual(Object.keys(cache.get("provider-1")), [ + "providerId", + "state", + "fetchedAt", + "expiresAt", + "models" + ]); + assert.equal(existsSync(cachePath), false); +}); + +test("persists schema 1 entries and returns fresh defensive public projections", (t) => { + const { cachePath } = makeTempCache(t); + const cache = new ProviderModelCache({ + path: cachePath, + now: () => FETCHED_AT + }); + + const stored = cache.put(validEntry()); + assert.deepEqual(stored, { + providerId: "provider-1", + state: "fresh", + fetchedAt: FETCHED_AT, + expiresAt: "2026-07-17T00:00:00.000Z", + models: ["gpt-alpha", "gpt-beta"] + }); + const document = JSON.parse(readFileSync(cachePath, "utf8")); + assert.deepEqual(document, { + schemaVersion: 1, + entries: [validEntry()] + }); + if (process.platform !== "win32") { + assert.equal(statSync(cachePath).mode & 0o777, 0o600); + } + + const first = cache.get("provider-1", FINGERPRINT); + first.models.push("mutated"); + assert.deepEqual(cache.get("provider-1", FINGERPRINT).models, [ + "gpt-alpha", + "gpt-beta" + ]); + assert.equal(Object.hasOwn(first, "sourceFingerprint"), false); +}); + +test("fingerprints only canonical non-credential request settings", () => { + const firstCredential = "first-credential-placeholder"; + const secondCredential = "second-credential-placeholder"; + const first = createProviderSourceFingerprint({ + baseUrl: "https://example.test/v1", + authHeader: "authorization", + authScheme: "Bearer", + extraHeaders: { "x-region": "east", "x-tenant": "one" }, + credentialRef: firstCredential, + apiKey: firstCredential, + modelMode: "passthrough" + }); + const reordered = createProviderSourceFingerprint({ + baseUrl: "https://example.test/v1", + authHeader: "authorization", + authScheme: "Bearer", + extraHeaders: { "x-tenant": "one", "x-region": "east" }, + credentialRef: secondCredential, + apiKey: secondCredential, + modelMode: "override", + modelOverride: "ignored-model" + }); + + assert.doesNotMatch(first, new RegExp(firstCredential)); + assert.doesNotMatch(reordered, new RegExp(secondCredential)); + assert.match(first, /^sha256:[a-f0-9]{64}$/); + assert.equal(first, reordered); + assert.notEqual(first, createProviderSourceFingerprint({ + baseUrl: "https://other.example.test/v1", + authHeader: "authorization", + authScheme: "Bearer", + extraHeaders: { "x-region": "east", "x-tenant": "one" } + })); +}); + +test("fingerprints provider-valid empty auth schemes and header values", () => { + assert.match(createProviderSourceFingerprint({ + baseUrl: "https://example.test/v1", + authHeader: "x-api-key", + authScheme: "", + extraHeaders: { "x-optional-context": "" } + }), /^sha256:[a-f0-9]{64}$/); +}); + +test("fingerprints every provider-schema-valid request setting without private length caps", () => { + const longHeaderName = `x-${"h".repeat(300)}`; + const profile = normalizeProvider({ + name: "Long but valid request settings", + baseUrl: `https://example.test/${"p".repeat(3_000)}`, + credentialRef: "credential-1", + authHeader: `x-${"a".repeat(300)}`, + authScheme: "S".repeat(300), + extraHeaders: { + [longHeaderName]: "v".repeat(3_000) + } + }, { + id: "provider-1", + now: FETCHED_AT + }); + + assert.match(createProviderSourceFingerprint(profile), /^sha256:[a-f0-9]{64}$/); +}); + +test("fingerprints provider-schema-valid Latin-1 obs-text header values", () => { + const obsText = "\u0080\u0085\u009f\u00ff"; + const profile = normalizeProvider({ + name: "Latin-1 header", + baseUrl: "https://example.test/v1", + credentialRef: "credential-1", + extraHeaders: { "x-legacy-context": obsText } + }, { + id: "provider-1", + now: FETCHED_AT + }); + + assert.equal(profile.extraHeaders["x-legacy-context"], obsText); + assert.match(createProviderSourceFingerprint(profile), /^sha256:[a-f0-9]{64}$/); +}); + +test("classifies 24-hour fresh and 30-day stale retention boundaries", (t) => { + const { cachePath } = makeTempCache(t); + let nowMs = Date.parse(FETCHED_AT); + const cache = new ProviderModelCache({ + path: cachePath, + now: () => new Date(nowMs).toISOString() + }); + cache.put(validEntry()); + + nowMs = Date.parse(FETCHED_AT) + DAY_MS - 1; + assert.equal(cache.get("provider-1", FINGERPRINT).state, "fresh"); + nowMs = Date.parse(FETCHED_AT) + DAY_MS; + assert.equal(cache.get("provider-1", FINGERPRINT).state, "stale"); + nowMs = Date.parse(FETCHED_AT) + (30 * DAY_MS) - 1; + assert.equal(cache.get("provider-1", FINGERPRINT).state, "stale"); + nowMs = Date.parse(FETCHED_AT) + (30 * DAY_MS); + assert.deepEqual(cache.get("provider-1", FINGERPRINT), missing()); +}); + +test("treats a source mismatch or future entry as missing", (t) => { + const { cachePath } = makeTempCache(t); + const cache = new ProviderModelCache({ + path: cachePath, + now: () => FETCHED_AT + }); + cache.put(validEntry()); + + assert.deepEqual( + cache.get("provider-1", `sha256:${"b".repeat(64)}`), + missing() + ); + cache.put(validEntry({ + providerId: "provider-future", + fetchedAt: "2026-07-16T00:00:00.001Z" + })); + assert.deepEqual( + cache.get("provider-future", FINGERPRINT), + missing("provider-future") + ); +}); + +test("updates entries, deletes them durably, and avoids creating on no-op delete", (t) => { + const { cachePath } = makeTempCache(t); + const cache = new ProviderModelCache({ + path: cachePath, + now: () => FETCHED_AT + }); + + assert.equal(cache.delete("provider-1"), false); + assert.equal(existsSync(cachePath), false); + cache.put(validEntry()); + cache.put(validEntry({ models: ["replacement"] })); + assert.deepEqual(cache.get("provider-1", FINGERPRINT).models, ["replacement"]); + assert.equal(cache.delete("provider-1"), true); + assert.deepEqual(cache.get("provider-1", FINGERPRINT), missing()); + assert.deepEqual(JSON.parse(readFileSync(cachePath, "utf8")), { + schemaVersion: 1, + entries: [] + }); +}); + +test("enforces exact documents, exact entries, unique providers, and model bounds", (t) => { + const { tempDir } = makeTempCache(t, "crp-provider-model-cache-invalid-"); + const tooManyModels = Array.from( + { length: MAX_PROVIDER_MODELS + 1 }, + (_, index) => `model-${index}` + ); + const invalidDocuments = [ + { schemaVersion: 2, entries: [] }, + { schemaVersion: 1, entries: [], extra: true }, + { schemaVersion: 1, entries: {} }, + { schemaVersion: 1, entries: [{ ...validEntry(), extra: true }] }, + { schemaVersion: 1, entries: [validEntry(), validEntry()] }, + { schemaVersion: 1, entries: [validEntry({ providerId: "" })] }, + { schemaVersion: 1, entries: [validEntry({ sourceFingerprint: "not-a-hash" })] }, + { schemaVersion: 1, entries: [validEntry({ fetchedAt: "not-a-date" })] }, + { schemaVersion: 1, entries: [validEntry({ models: "gpt-alpha" })] }, + { schemaVersion: 1, entries: [validEntry({ models: tooManyModels })] }, + { schemaVersion: 1, entries: [validEntry({ models: [""] })] }, + { schemaVersion: 1, entries: [validEntry({ models: [" padded"] })] }, + { schemaVersion: 1, entries: [validEntry({ models: ["bad\nmodel"] })] }, + { + schemaVersion: 1, + entries: [validEntry({ models: ["m".repeat(MAX_MODEL_ID_LENGTH + 1)] })] + }, + { schemaVersion: 1, entries: [validEntry({ models: ["duplicate", "duplicate"] })] }, + { schemaVersion: 1, entries: validEntries(MAX_CACHE_ENTRIES + 1) } + ]; + + for (const [index, document] of invalidDocuments.entries()) { + const cachePath = join(tempDir, `invalid-${index}.json`); + const bytes = `${JSON.stringify(document)}\n`; + writeFileSync(cachePath, bytes, "utf8"); + const cache = new ProviderModelCache({ + path: cachePath, + now: () => FETCHED_AT + }); + assert.deepEqual(cache.get("provider-1", FINGERPRINT), missing()); + assert.equal(readFileSync(cachePath, "utf8"), bytes); + } +}); + +test("refuses a 513th cache entry without changing durable bytes", (t) => { + const { cachePath } = makeTempCache(t, "crp-model-cache-entry-bound-"); + const originalBytes = `${JSON.stringify({ + schemaVersion: 1, + entries: validEntries(MAX_CACHE_ENTRIES) + }, null, 2)}\n`; + writeFileSync(cachePath, originalBytes, "utf8"); + const cache = new ProviderModelCache({ + path: cachePath, + now: () => FETCHED_AT + }); + + assert.throws( + () => cache.put(validEntry({ + providerId: `provider-${MAX_CACHE_ENTRIES + 1}`, + models: ["model-over-limit"] + })), + assertCrpError("PROVIDER_MODEL_CACHE_INPUT_INVALID", 400) + ); + assert.equal(readFileSync(cachePath, "utf8"), originalBytes); +}); + +test("uses file metadata to reject oversized cache state before reading or parsing it", (t) => { + const { cachePath } = makeTempCache(t, "crp-model-cache-file-bound-read-"); + const originalBytes = `${JSON.stringify({ + schemaVersion: 1, + entries: [validEntry()] + })}\n`; + writeFileSync(cachePath, originalBytes, "utf8"); + let cacheStatCalls = 0; + let cacheReadCalls = 0; + let cacheRenames = 0; + const cache = new ProviderModelCache({ + path: cachePath, + now: () => FETCHED_AT, + fileOperations: { + ...realFileOperations, + statSync(path, options) { + if (path === cachePath) { + cacheStatCalls += 1; + const stats = realFileOperations.statSync(path, options); + stats.size = MAX_CACHE_FILE_BYTES + 1; + return stats; + } + return realFileOperations.statSync(path, options); + }, + readFileSync(path, ...args) { + if (path === cachePath) cacheReadCalls += 1; + return realFileOperations.readFileSync(path, ...args); + }, + renameSync(source, destination) { + if (destination === cachePath) cacheRenames += 1; + return realFileOperations.renameSync(source, destination); + } + } + }); + + assert.deepEqual(cache.get("provider-1", FINGERPRINT), missing()); + assert.equal(cacheStatCalls, 1); + assert.equal(cacheReadCalls, 0); + assert.throws( + () => cache.put(validEntry({ models: ["must-not-overwrite"] })), + assertCrpError("PROVIDER_MODEL_CACHE_INVALID", 500) + ); + assert.equal(cacheReadCalls, 0); + assert.equal(cacheRenames, 0); + assert.equal(readFileSync(cachePath, "utf8"), originalBytes); +}); + +test("rejects an over-16-MiB put candidate before writing and preserves prior bytes", (t) => { + const { cachePath } = makeTempCache(t, "crp-model-cache-file-bound-put-"); + const largeEntries = []; + let rejectedEntry = null; + for (let entryIndex = 0; entryIndex < MAX_CACHE_ENTRIES; entryIndex += 1) { + const models = Array.from({ length: MAX_PROVIDER_MODELS }, (_, modelIndex) => { + const prefix = `${entryIndex}-${modelIndex}-`; + return `${prefix}${"\u{1f4be}".repeat(MAX_MODEL_ID_LENGTH - [...prefix].length)}`; + }); + const candidate = validEntry({ + providerId: `large-provider-${entryIndex}`, + models + }); + const candidateBytes = `${JSON.stringify({ + schemaVersion: 1, + entries: [...largeEntries, candidate] + }, null, 2)}\n`; + if (Buffer.byteLength(candidateBytes, "utf8") > MAX_CACHE_FILE_BYTES) { + rejectedEntry = candidate; + break; + } + largeEntries.push(candidate); + } + assert.notEqual(rejectedEntry, null); + const originalBytes = `${JSON.stringify({ + schemaVersion: 1, + entries: largeEntries + }, null, 2)}\n`; + assert.ok(Buffer.byteLength(originalBytes, "utf8") <= MAX_CACHE_FILE_BYTES); + writeFileSync(cachePath, originalBytes, "utf8"); + let cacheTempOpens = 0; + const cache = new ProviderModelCache({ + path: cachePath, + now: () => FETCHED_AT, + fileOperations: { + ...realFileOperations, + openSync(path, ...args) { + if (path !== `${cachePath}.crp.lock`) cacheTempOpens += 1; + return realFileOperations.openSync(path, ...args); + } + } + }); + + assert.throws( + () => cache.put(rejectedEntry), + assertCrpError("PROVIDER_MODEL_CACHE_INPUT_INVALID", 400) + ); + assert.equal(cacheTempOpens, 0); + assert.equal(readFileSync(cachePath, "utf8"), originalBytes); +}); + +test("rejects invalid put inputs before creating persistent state", (t) => { + const { cachePath } = makeTempCache(t); + const cache = new ProviderModelCache({ + path: cachePath, + now: () => FETCHED_AT + }); + + assert.throws( + () => cache.put({ ...validEntry(), extra: true }), + assertCrpError("PROVIDER_MODEL_CACHE_INPUT_INVALID", 400) + ); + assert.throws( + () => cache.put(validEntry({ models: ["duplicate", "duplicate"] })), + assertCrpError("PROVIDER_MODEL_CACHE_INPUT_INVALID", 400) + ); + assert.equal(existsSync(cachePath), false); +}); + +test("isolates corrupt state on reads but refuses to overwrite or delete it", (t) => { + const { cachePath } = makeTempCache(t); + const corruptBytes = "{ definitely-not-json\n"; + writeFileSync(cachePath, corruptBytes, "utf8"); + const cache = new ProviderModelCache({ + path: cachePath, + now: () => FETCHED_AT + }); + + assert.deepEqual(cache.get("provider-1", FINGERPRINT), missing()); + assert.equal(readFileSync(cachePath, "utf8"), corruptBytes); + assert.throws( + () => cache.put(validEntry()), + assertCrpError("PROVIDER_MODEL_CACHE_INVALID", 500) + ); + assert.throws( + () => cache.delete("provider-1"), + assertCrpError("PROVIDER_MODEL_CACHE_INVALID", 500) + ); + assert.equal(readFileSync(cachePath, "utf8"), corruptBytes); +}); + +test("persists with an exclusive owned lock and same-directory fsynced rename", (t) => { + const { tempDir, cachePath } = makeTempCache(t, "crp-model-cache-atomic-"); + const lockPath = `${cachePath}.crp.lock`; + const operations = []; + const cache = new ProviderModelCache({ + path: cachePath, + now: () => FETCHED_AT, + fileOperations: { + ...realFileOperations, + openSync(path, flags, mode) { + operations.push(["open", path, flags, mode]); + return realFileOperations.openSync(path, flags, mode); + }, + fsyncSync(fileDescriptor) { + operations.push(["fsync", fileDescriptor]); + return realFileOperations.fsyncSync(fileDescriptor); + }, + renameSync(source, destination) { + operations.push(["rename", source, destination]); + return realFileOperations.renameSync(source, destination); + }, + rmSync(path, options) { + operations.push(["rm", path]); + return realFileOperations.rmSync(path, options); + } + } + }); + + cache.put(validEntry()); + + const lockOpenIndex = operations.findIndex(([name, path]) => ( + name === "open" && path === lockPath + )); + const tempOpenIndex = operations.findIndex(([name, path]) => ( + name === "open" && path !== lockPath + )); + const fsyncIndex = operations.findIndex(([name]) => name === "fsync"); + const renameIndex = operations.findIndex(([name]) => name === "rename"); + const lockRemovalIndex = operations.findIndex(([name, path]) => ( + name === "rm" && path === lockPath + )); + const lockOpen = operations[lockOpenIndex]; + const tempOpen = operations[tempOpenIndex]; + const rename = operations[renameIndex]; + + assert.equal(lockOpen[2], "wx"); + assert.equal(lockOpen[3], 0o600); + assert.equal(dirname(tempOpen[1]), tempDir); + assert.equal(tempOpen[2], "wx"); + assert.equal(tempOpen[3], 0o600); + assert.equal(dirname(rename[1]), tempDir); + assert.equal(rename[2], cachePath); + assert.ok(lockOpenIndex < tempOpenIndex); + assert.ok(tempOpenIndex < fsyncIndex && fsyncIndex < renameIndex); + assert.ok(renameIndex < lockRemovalIndex); + assert.deepEqual(listTempFiles(tempDir, cachePath), []); + assert.equal(existsSync(lockPath), false); +}); + +test("does not disturb a foreign lock or create cache state", (t) => { + const { cachePath } = makeTempCache(t, "crp-model-cache-foreign-lock-"); + const lockPath = `${cachePath}.crp.lock`; + const foreignBytes = "foreign-owner\n"; + writeFileSync(lockPath, foreignBytes, { mode: 0o600 }); + const cache = new ProviderModelCache({ + path: cachePath, + now: () => FETCHED_AT + }); + + assert.throws( + () => cache.put(validEntry()), + assertCrpError("PROVIDER_MODEL_CACHE_BUSY", 409) + ); + assert.equal(readFileSync(lockPath, "utf8"), foreignBytes); + assert.equal(existsSync(cachePath), false); +}); + +test("preserves durable bytes and cleans temporary state on rename failure", (t) => { + const { tempDir, cachePath } = makeTempCache(t, "crp-model-cache-rollback-"); + const initial = new ProviderModelCache({ + path: cachePath, + now: () => FETCHED_AT + }); + initial.put(validEntry()); + const originalBytes = readFileSync(cachePath); + const renameError = makeFileError("forced cache rename failure", "EIO"); + const cache = new ProviderModelCache({ + path: cachePath, + now: () => FETCHED_AT, + fileOperations: { + ...realFileOperations, + renameSync() { + throw renameError; + } + } + }); + + assert.throws( + () => cache.put(validEntry({ models: ["replacement"] })), + (error) => error === renameError + ); + assert.deepEqual(readFileSync(cachePath), originalBytes); + assert.deepEqual(listTempFiles(tempDir, cachePath), []); + assert.equal(existsSync(`${cachePath}.crp.lock`), false); +}); + +test("retries transient owned-lock cleanup without repeating a mutation", (t) => { + const { cachePath } = makeTempCache(t, "crp-model-cache-lock-retry-"); + const lockPath = `${cachePath}.crp.lock`; + let removalAttempts = 0; + let renameCount = 0; + const cache = new ProviderModelCache({ + path: cachePath, + now: () => FETCHED_AT, + fileOperations: { + ...realFileOperations, + rmSync(path, options) { + if (path === lockPath) { + removalAttempts += 1; + if (removalAttempts === 1) { + throw makeFileError("forced transient removal failure", "EBUSY"); + } + } + return realFileOperations.rmSync(path, options); + }, + renameSync(source, destination) { + if (destination === cachePath) renameCount += 1; + return realFileOperations.renameSync(source, destination); + } + } + }); + + assert.equal(cache.put(validEntry()).state, "fresh"); + assert.equal(removalAttempts, 2); + assert.equal(renameCount, 1); + assert.equal(existsSync(lockPath), false); +}); + +test("isolates read failures while mutations surface them without creating state", (t) => { + const { cachePath } = makeTempCache(t, "crp-model-cache-read-failure-"); + writeFileSync(cachePath, `${JSON.stringify({ + schemaVersion: 1, + entries: [validEntry()] + })}\n`, "utf8"); + const cache = new ProviderModelCache({ + path: cachePath, + now: () => FETCHED_AT, + fileOperations: { + ...realFileOperations, + readFileSync(path, ...args) { + if (path === cachePath) { + throw makeFileError("forced cache read failure", "EACCES"); + } + return realFileOperations.readFileSync(path, ...args); + } + } + }); + + assert.deepEqual(cache.get("provider-1", FINGERPRINT), missing()); + assert.throws( + () => cache.put(validEntry()), + assertCrpError("PROVIDER_MODEL_CACHE_READ_FAILED", 500) + ); +}); diff --git a/node/test/provider-registry.test.mjs b/node/test/provider-registry.test.mjs new file mode 100644 index 0000000..a8efddc --- /dev/null +++ b/node/test/provider-registry.test.mjs @@ -0,0 +1,1108 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import * as realFileOperations from "node:fs"; +import { + existsSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync +} from "node:fs"; +import os from "node:os"; +import { basename, dirname, join } from "node:path"; + +import { ProviderRegistry } from "../src/providers/provider-registry.mjs"; +import { + TEST_STATUSES, + normalizeProvider, + toPublicProvider, + validateProviderInput +} from "../src/providers/provider-schema.mjs"; +import { CrpError, toPublicError } from "../src/shared/errors.mjs"; + +const FIXED_NOW = "2026-07-10T00:00:00.000Z"; +const LATER_NOW = "2026-07-10T01:00:00.000Z"; +const DEFAULT_SETTINGS = { + proxyHost: "127.0.0.1", + proxyPort: 15100, + adminHost: "127.0.0.1", + adminPort: 15101, + captureEnabled: false +}; + +function makeTempRegistry(t, prefix = "crp-provider-registry-") { + const tempDir = mkdtempSync(join(os.tmpdir(), prefix)); + const registryPath = join(tempDir, "providers.json"); + t.after(() => rmSync(tempDir, { recursive: true, force: true })); + return { tempDir, registryPath }; +} + +function makeIds(...ids) { + let index = 0; + return () => ids[index++] ?? `provider-${index}`; +} + +function makeClock(...timestamps) { + let index = 0; + return () => timestamps[index++] ?? timestamps.at(-1) ?? FIXED_NOW; +} + +function validInput(overrides = {}) { + return { + name: "OpenRouter", + baseUrl: "https://openrouter.ai/api/v1", + credentialRef: "provider-1", + ...overrides + }; +} + +function assertCrpError(expectedCode, expectedStatus) { + return (error) => { + assert.ok(error instanceof CrpError); + assert.equal(error.code, expectedCode); + assert.equal(error.status, expectedStatus); + assert.equal(typeof error.action, "string"); + assert.notEqual(error.action.length, 0); + return true; + }; +} + +function listTempFiles(tempDir, registryPath) { + const registryName = basename(registryPath); + return readdirSync(tempDir).filter((name) => ( + name.startsWith(`.${registryName}.`) && name.endsWith(".tmp") + )); +} + +function makeFileError(message, code) { + const error = new Error(message); + error.code = code; + return error; +} + +test("creates, lists, gets, and updates normalized providers", (t) => { + const { registryPath } = makeTempRegistry(t); + const registry = new ProviderRegistry({ + path: registryPath, + createId: () => "provider-1", + now: makeClock(FIXED_NOW, LATER_NOW) + }); + + assert.deepEqual(registry.getDocument(), { + schemaVersion: 2, + activeProviderId: null, + providers: [], + settings: DEFAULT_SETTINGS + }); + + const created = registry.create(validInput({ name: " OpenRouter " })); + assert.deepEqual(created, { + id: "provider-1", + name: "OpenRouter", + baseUrl: "https://openrouter.ai/api/v1", + credentialRef: "provider-1", + authHeader: "authorization", + authScheme: "Bearer", + extraHeaders: {}, + modelMode: "passthrough", + modelOverride: null, + lastTestAt: null, + lastTestStatus: "untested", + lastTestCode: null, + createdAt: FIXED_NOW, + updatedAt: FIXED_NOW + }); + assert.deepEqual(registry.list(), [created]); + assert.deepEqual(registry.get("provider-1"), created); + + const updated = registry.update("provider-1", { + name: "Router Primary", + authHeader: "x-provider-auth", + authScheme: "Token", + extraHeaders: { "x-region": "us-east" }, + modelMode: "override", + modelOverride: "gpt-compatible" + }); + assert.equal(updated.id, "provider-1"); + assert.equal(updated.credentialRef, "provider-1"); + assert.equal(updated.createdAt, FIXED_NOW); + assert.equal(updated.updatedAt, LATER_NOW); + assert.equal(updated.name, "Router Primary"); + assert.equal(updated.modelMode, "override"); + assert.equal(updated.modelOverride, "gpt-compatible"); + assert.deepEqual(updated.extraHeaders, { "x-region": "us-east" }); +}); + +test("requires and trims names and rejects case-insensitive duplicates", (t) => { + const { registryPath } = makeTempRegistry(t); + const registry = new ProviderRegistry({ + path: registryPath, + createId: makeIds("provider-1", "provider-2", "provider-3"), + now: () => FIXED_NOW + }); + + assert.throws( + () => registry.create(validInput({ name: " " })), + assertCrpError("PROVIDER_INPUT_INVALID", 400) + ); + registry.create(validInput()); + assert.throws( + () => registry.create(validInput({ + name: " openrouter ", + credentialRef: "provider-2" + })), + assertCrpError("PROVIDER_NAME_CONFLICT", 409) + ); + + registry.create(validInput({ + name: "Backup", + baseUrl: "https://backup.example/v1", + credentialRef: "provider-3" + })); + assert.throws( + () => registry.update("provider-3", { name: "OPENROUTER" }), + assertCrpError("PROVIDER_NAME_CONFLICT", 409) + ); +}); + +test("serializes multi-instance mutations and refreshes existing readers", (t) => { + const { registryPath } = makeTempRegistry(t, "crp-provider-multi-instance-"); + const first = new ProviderRegistry({ + path: registryPath, + createId: () => "provider-1", + now: () => FIXED_NOW + }); + const second = new ProviderRegistry({ + path: registryPath, + createId: () => "provider-2", + now: () => FIXED_NOW + }); + const staleDuplicate = new ProviderRegistry({ + path: registryPath, + createId: () => "provider-3", + now: () => FIXED_NOW + }); + + first.create(validInput({ name: "Primary" })); + second.create(validInput({ + name: "Backup", + baseUrl: "https://backup.example/v1", + credentialRef: "provider-2" + })); + + assert.deepEqual( + first.list().map(({ id, name }) => ({ id, name })), + [ + { id: "provider-1", name: "Primary" }, + { id: "provider-2", name: "Backup" } + ] + ); + assert.equal(first.get("provider-2").name, "Backup"); + assert.throws( + () => staleDuplicate.create(validInput({ + name: " PRIMARY ", + credentialRef: "provider-3" + })), + assertCrpError("PROVIDER_NAME_CONFLICT", 409) + ); + assert.equal(first.getDocument().providers.length, 2); +}); + +test("rejects a foreign registry lock without removing it", (t) => { + const { tempDir, registryPath } = makeTempRegistry(t, "crp-provider-foreign-lock-"); + const lockPath = `${registryPath}.crp.lock`; + const foreignLockBytes = Buffer.from("foreign-owner\n", "utf8"); + const registry = new ProviderRegistry({ + path: registryPath, + createId: () => "provider-1", + now: () => FIXED_NOW + }); + writeFileSync(lockPath, foreignLockBytes, { mode: 0o600 }); + + assert.throws( + () => registry.create(validInput()), + assertCrpError("PROVIDER_REGISTRY_BUSY", 409) + ); + assert.deepEqual(readFileSync(lockPath), foreignLockBytes); + assert.equal(existsSync(registryPath), false); + assert.deepEqual(listTempFiles(tempDir, registryPath), []); +}); + +test("rejects missing providers and immutable profile fields", (t) => { + const { registryPath } = makeTempRegistry(t); + const registry = new ProviderRegistry({ + path: registryPath, + createId: () => "provider-1", + now: () => FIXED_NOW + }); + + assert.throws( + () => registry.get("missing"), + assertCrpError("PROVIDER_NOT_FOUND", 404) + ); + assert.throws( + () => registry.update("missing", { name: "Missing" }), + assertCrpError("PROVIDER_NOT_FOUND", 404) + ); + assert.throws( + () => registry.delete("missing"), + assertCrpError("PROVIDER_NOT_FOUND", 404) + ); + assert.throws( + () => registry.setActive("missing"), + assertCrpError("PROVIDER_NOT_FOUND", 404) + ); + + registry.create(validInput()); + for (const patch of [ + { id: "provider-2" }, + { createdAt: LATER_NOW }, + { credentialRef: "provider-2" } + ]) { + assert.throws( + () => registry.update("provider-1", patch), + assertCrpError("PROVIDER_IMMUTABLE_FIELD", 400) + ); + } +}); + +test("accepts, rejects, and canonically persists provider URLs", (t) => { + for (const baseUrl of [ + "https://provider.example/v1", + "https://provider.example/v1/@scope", + "https://provider.example/v1?contact=user@example.com", + "http://localhost:8080/v1", + "http://127.0.0.1:8080/v1", + "http://127.42.0.9/v1", + "http://[::1]:8080/v1" + ]) { + assert.doesNotThrow(() => validateProviderInput(validInput({ baseUrl }))); + } + + for (const baseUrl of [ + "ftp://provider.example/v1", + "http://provider.example/v1", + "http://localhost.example/v1", + "http://128.0.0.1/v1", + "https://user:password@provider.example/v1", + "https://@provider.example/v1", + "https://:@provider.example/v1", + "https://provider.example/v1\rignored", + "https://provider.example/v1\nignored", + "https://provider.example/v1\0ignored", + "https://provider.example/v1\x7fignored", + "not a url" + ]) { + assert.throws( + () => validateProviderInput(validInput({ baseUrl })), + assertCrpError("PROVIDER_INPUT_INVALID", 400) + ); + } + + const { registryPath } = makeTempRegistry(t, "crp-provider-canonical-url-"); + const registry = new ProviderRegistry({ + path: registryPath, + createId: () => "provider-1", + now: () => FIXED_NOW + }); + const created = registry.create(validInput({ + baseUrl: "HTTPS://Provider.Example:443/a/../v1" + })); + assert.equal(created.baseUrl, "https://provider.example/v1"); + assert.equal( + JSON.parse(readFileSync(registryPath, "utf8")).providers[0].baseUrl, + "https://provider.example/v1" + ); +}); + +test("requires extraHeaders to be a string map with non-sensitive names", () => { + for (const extraHeaders of [ + [], + { "x-region": 1 }, + { authorization: "hidden" }, + { "Proxy-Authorization": "hidden" }, + { COOKIE: "hidden" }, + { "Set-Cookie": "hidden" }, + { "x-auth-token": "hidden" }, + { "client-secret-mode": "hidden" }, + { "X-API-KEY": "hidden" }, + { "x-api_key": "ordinary-value" }, + { "x-apikey": "ordinary-value" }, + { "x-authorization": "ordinary-value" }, + { "X_AuThOrIzAtIoN": "ordinary-value" }, + { "x-region": "line\rbreak" }, + { "x-region": "line\nbreak" }, + { "x-region": "value\0break" }, + { "x-region": "value\x7fbreak" } + ]) { + assert.throws( + () => validateProviderInput(validInput({ extraHeaders })), + assertCrpError("PROVIDER_INPUT_INVALID", 400) + ); + } + + const sensitivePlaceholder = "sensitive-placeholder"; + let error; + try { + validateProviderInput(validInput({ + extraHeaders: { "x-client-secret": sensitivePlaceholder } + })); + } catch (caught) { + error = caught; + } + assert.ok(error instanceof CrpError); + assert.doesNotMatch( + JSON.stringify(toPublicError(error, "request-1")), + new RegExp(sensitivePlaceholder) + ); +}); + +test("allows an empty auth scheme and requires non-empty schemes to be HTTP tokens", () => { + assert.equal( + normalizeProvider(validInput({ authScheme: "" }), { + id: "provider-1", + now: FIXED_NOW + }).authScheme, + "" + ); + for (const authScheme of ["Bearer value", "Bearer/Token", "Bearer,Token", "Bearer\n"]) { + assert.throws( + () => validateProviderInput(validInput({ authScheme })), + assertCrpError("PROVIDER_INPUT_INVALID", 400) + ); + } +}); + +test("validates passthrough and override model modes", () => { + assert.doesNotThrow(() => validateProviderInput(validInput({ + modelMode: "passthrough", + modelOverride: null + }))); + assert.doesNotThrow(() => validateProviderInput(validInput({ + modelMode: "override", + modelOverride: " compatible-model " + }))); + assert.throws( + () => validateProviderInput(validInput({ modelMode: "unknown" })), + assertCrpError("PROVIDER_INPUT_INVALID", 400) + ); + assert.throws( + () => validateProviderInput(validInput({ + modelMode: "override", + modelOverride: " " + })), + assertCrpError("PROVIDER_INPUT_INVALID", 400) + ); + + assert.deepEqual(TEST_STATUSES, new Set(["untested", "passed", "failed"])); + assert.deepEqual( + normalizeProvider(validInput({ + authHeader: undefined, + authScheme: undefined, + extraHeaders: undefined, + modelMode: undefined, + modelOverride: undefined + }), { id: "provider-1", now: FIXED_NOW }), + { + id: "provider-1", + name: "OpenRouter", + baseUrl: "https://openrouter.ai/api/v1", + credentialRef: "provider-1", + authHeader: "authorization", + authScheme: "Bearer", + extraHeaders: {}, + modelMode: "passthrough", + modelOverride: null, + lastTestAt: null, + lastTestStatus: "untested", + lastTestCode: null, + createdAt: FIXED_NOW, + updatedAt: FIXED_NOW + } + ); +}); + +test("marks passed and failed tests and manages active deletion", (t) => { + const { registryPath } = makeTempRegistry(t); + const registry = new ProviderRegistry({ + path: registryPath, + createId: makeIds("provider-1", "provider-2"), + now: makeClock(FIXED_NOW, FIXED_NOW, LATER_NOW, LATER_NOW, LATER_NOW, LATER_NOW) + }); + + registry.create(validInput()); + registry.create(validInput({ + name: "Backup", + baseUrl: "https://backup.example/v1", + credentialRef: "provider-2" + })); + + const passed = registry.markTest("provider-1", { status: "passed", code: null }); + assert.equal(passed.lastTestAt, LATER_NOW); + assert.equal(passed.lastTestStatus, "passed"); + assert.equal(passed.lastTestCode, null); + + const failed = registry.markTest("provider-2", { + status: "failed", + code: "UPSTREAM_AUTH_FAILED" + }); + assert.equal(failed.lastTestStatus, "failed"); + assert.equal(failed.lastTestCode, "UPSTREAM_AUTH_FAILED"); + const reset = registry.markTest("provider-2", { status: "untested", code: null }); + assert.equal(reset.lastTestAt, null); + assert.equal(reset.lastTestStatus, "untested"); + assert.equal(reset.lastTestCode, null); + + const activated = registry.setActive("provider-1"); + assert.equal(activated.id, "provider-1"); + assert.equal(registry.getActive().id, "provider-1"); + assert.equal(registry.getDocument().activeProviderId, "provider-1"); + assert.throws( + () => registry.delete("provider-1"), + assertCrpError("PROVIDER_ACTIVE", 409) + ); + + const deleted = registry.delete("provider-2"); + assert.equal(deleted.id, "provider-2"); + assert.equal(registry.list().length, 1); +}); + +test("compare-and-set active operations change state only when their predicates match", (t) => { + const { registryPath } = makeTempRegistry(t, "crp-provider-active-cas-"); + const lockPath = `${registryPath}.crp.lock`; + let persistenceOpenCount = 0; + let registryRenameCount = 0; + const registry = new ProviderRegistry({ + path: registryPath, + createId: makeIds("provider-1", "provider-2"), + now: () => FIXED_NOW, + fileOperations: { + ...realFileOperations, + openSync(path, flags, mode) { + if (path !== lockPath) persistenceOpenCount += 1; + return realFileOperations.openSync(path, flags, mode); + }, + renameSync(source, destination) { + if (destination === registryPath) registryRenameCount += 1; + return realFileOperations.renameSync(source, destination); + } + } + }); + registry.create(validInput({ name: "Primary" })); + registry.create(validInput({ + name: "Backup", + baseUrl: "https://backup.example/v1", + credentialRef: "provider-2" + })); + + assert.equal(registry.setActiveIfNull("provider-1"), true); + let diskBytes = readFileSync(registryPath); + let persistenceOpens = persistenceOpenCount; + let registryRenames = registryRenameCount; + assert.equal(registry.setActiveIfNull("provider-1"), false); + assert.equal(registry.setActiveIfNull("provider-2"), false); + assert.equal(persistenceOpenCount, persistenceOpens); + assert.equal(registryRenameCount, registryRenames); + assert.deepEqual(readFileSync(registryPath), diskBytes); + assert.equal(registry.getDocument().activeProviderId, "provider-1"); + + assert.equal(registry.clearActiveIf("provider-2"), false); + assert.equal(persistenceOpenCount, persistenceOpens); + assert.equal(registryRenameCount, registryRenames); + assert.deepEqual(readFileSync(registryPath), diskBytes); + assert.equal(registry.getDocument().activeProviderId, "provider-1"); + assert.equal(registry.clearActiveIf("provider-1"), true); + diskBytes = readFileSync(registryPath); + persistenceOpens = persistenceOpenCount; + registryRenames = registryRenameCount; + assert.equal(registry.clearActiveIf("provider-1"), false); + assert.equal(persistenceOpenCount, persistenceOpens); + assert.equal(registryRenameCount, registryRenames); + assert.deepEqual(readFileSync(registryPath), diskBytes); + assert.equal(registry.getDocument().activeProviderId, null); + + assert.throws( + () => registry.setActiveIfNull("missing"), + assertCrpError("PROVIDER_NOT_FOUND", 404) + ); + assert.throws( + () => registry.clearActiveIf("missing"), + assertCrpError("PROVIDER_NOT_FOUND", 404) + ); +}); + +test("multi-instance initial activation compare-and-set is first-wins", (t) => { + const { registryPath } = makeTempRegistry(t, "crp-provider-active-first-wins-"); + let staleInstanceRegistryRenames = 0; + const first = new ProviderRegistry({ + path: registryPath, + createId: makeIds("provider-1", "provider-2"), + now: () => FIXED_NOW + }); + const staleSecond = new ProviderRegistry({ + path: registryPath, + now: () => FIXED_NOW, + fileOperations: { + ...realFileOperations, + renameSync(source, destination) { + if (destination === registryPath) staleInstanceRegistryRenames += 1; + return realFileOperations.renameSync(source, destination); + } + } + }); + first.create(validInput({ name: "Primary" })); + first.create(validInput({ + name: "Backup", + baseUrl: "https://backup.example/v1", + credentialRef: "provider-2" + })); + + assert.equal(first.setActiveIfNull("provider-1"), true); + const firstWinnerBytes = readFileSync(registryPath); + assert.equal(staleSecond.setActiveIfNull("provider-2"), false); + assert.equal(staleInstanceRegistryRenames, 0); + assert.deepEqual(readFileSync(registryPath), firstWinnerBytes); + assert.equal(staleSecond.getDocument().activeProviderId, "provider-1"); + + assert.equal(staleSecond.clearActiveIf("provider-2"), false); + assert.equal(staleInstanceRegistryRenames, 0); + assert.deepEqual(readFileSync(registryPath), firstWinnerBytes); + assert.equal(first.clearActiveIf("provider-1"), true); + assert.equal(staleSecond.setActiveIfNull("provider-2"), true); + assert.equal(first.getDocument().activeProviderId, "provider-2"); +}); + +test("preserves tests for name edits and invalidates them for operational changes", (t) => { + const { registryPath } = makeTempRegistry(t, "crp-provider-test-invalidation-"); + const registry = new ProviderRegistry({ + path: registryPath, + createId: () => "provider-1", + now: () => FIXED_NOW + }); + registry.create(validInput()); + registry.markTest("provider-1", { status: "passed", code: null }); + + const renamed = registry.update("provider-1", { name: "Primary" }); + assert.equal(renamed.lastTestAt, FIXED_NOW); + assert.equal(renamed.lastTestStatus, "passed"); + assert.equal(renamed.lastTestCode, null); + + const operationalPatches = [ + { baseUrl: "https://alternate.example/v1" }, + { authHeader: "x-provider-auth" }, + { authScheme: "Token" }, + { extraHeaders: { "x-region": "eu-west" } }, + { modelMode: "override", modelOverride: "compatible-model-a" }, + { modelOverride: "compatible-model-b" } + ]; + for (const patch of operationalPatches) { + registry.markTest("provider-1", { status: "passed", code: null }); + const updated = registry.update("provider-1", patch); + assert.equal(updated.lastTestAt, null); + assert.equal(updated.lastTestStatus, "untested"); + assert.equal(updated.lastTestCode, null); + } +}); + +test("reloads the complete persisted document", (t) => { + const { registryPath } = makeTempRegistry(t); + const registry = new ProviderRegistry({ + path: registryPath, + createId: () => "provider-1", + now: makeClock(FIXED_NOW, LATER_NOW, LATER_NOW, LATER_NOW) + }); + + registry.create(validInput({ + extraHeaders: { "x-region": "eu-west" }, + modelMode: "override", + modelOverride: "provider-model" + })); + registry.markTest("provider-1", { status: "passed", code: null }); + registry.setActive("provider-1"); + const beforeReload = registry.getDocument(); + + const reloaded = new ProviderRegistry({ path: registryPath }); + assert.deepEqual(reloaded.getDocument(), beforeReload); + assert.deepEqual(reloaded.getActive(), beforeReload.providers[0]); +}); + +test("rejects malformed JSON and invalid schema-version-2 documents", (t) => { + const { tempDir } = makeTempRegistry(t, "crp-provider-invalid-"); + const documents = [ + "{ malformed", + `${JSON.stringify({ schemaVersion: 1, activeProviderId: null, providers: [], settings: DEFAULT_SETTINGS })}\n`, + `${JSON.stringify({ schemaVersion: 2, activeProviderId: null, providers: {}, settings: DEFAULT_SETTINGS })}\n`, + `${JSON.stringify({ schemaVersion: 2, activeProviderId: "missing", providers: [], settings: DEFAULT_SETTINGS })}\n`, + `${JSON.stringify({ + schemaVersion: 2, + activeProviderId: null, + providers: [], + settings: { ...DEFAULT_SETTINGS, proxyPort: 15102 } + })}\n`, + `${JSON.stringify({ + schemaVersion: 2, + activeProviderId: null, + providers: [{ + ...normalizeProvider(validInput(), { id: "provider-1", now: FIXED_NOW }), + lastTestStatus: "unknown" + }], + settings: DEFAULT_SETTINGS + })}\n`, + `${JSON.stringify({ + schemaVersion: 2, + activeProviderId: null, + providers: [{ + ...normalizeProvider(validInput(), { id: "provider-1", now: FIXED_NOW }), + lastTestAt: "2026-07-09T23:59:59.000Z", + lastTestStatus: "passed" + }], + settings: DEFAULT_SETTINGS + })}\n`, + `${JSON.stringify({ + schemaVersion: 2, + activeProviderId: null, + providers: [{ + ...normalizeProvider(validInput(), { id: "provider-1", now: FIXED_NOW }), + lastTestAt: LATER_NOW, + lastTestStatus: "passed" + }], + settings: DEFAULT_SETTINGS + })}\n` + ]; + + for (const [index, bytes] of documents.entries()) { + const registryPath = join(tempDir, `providers-${index}.json`); + writeFileSync(registryPath, bytes, "utf8"); + assert.throws( + () => new ProviderRegistry({ path: registryPath }), + assertCrpError("PROVIDER_REGISTRY_INVALID", 500) + ); + assert.equal(readFileSync(registryPath, "utf8"), bytes); + } +}); + +test("persists through a same-directory fsynced rename with mode 0600", (t) => { + const { tempDir, registryPath } = makeTempRegistry(t, "crp-provider-atomic-"); + const lockPath = `${registryPath}.crp.lock`; + const operations = []; + const fileOperations = { + ...realFileOperations, + openSync(path, flags, mode) { + operations.push(["open", path, flags, mode]); + return realFileOperations.openSync(path, flags, mode); + }, + fsyncSync(fd) { + operations.push(["fsync", fd]); + return realFileOperations.fsyncSync(fd); + }, + chmodSync(path, mode) { + operations.push(["chmod", path, mode]); + return realFileOperations.chmodSync(path, mode); + }, + renameSync(source, destination) { + operations.push(["rename", source, destination]); + return realFileOperations.renameSync(source, destination); + }, + rmSync(path, options) { + operations.push(["rm", path]); + return realFileOperations.rmSync(path, options); + } + }; + const registry = new ProviderRegistry({ + path: registryPath, + createId: () => "provider-1", + now: () => FIXED_NOW, + fileOperations + }); + + registry.create(validInput()); + + const lockOpenIndex = operations.findIndex(([name, path]) => ( + name === "open" && path === lockPath + )); + const tempOpenIndex = operations.findIndex(([name, path]) => ( + name === "open" && path !== lockPath + )); + const fsyncIndex = operations.findIndex(([name]) => name === "fsync"); + const renameIndex = operations.findIndex(([name]) => name === "rename"); + const lockRemovalIndex = operations.findIndex(([name, path]) => ( + name === "rm" && path === lockPath + )); + const lockOpen = operations[lockOpenIndex]; + const open = operations[tempOpenIndex]; + const rename = operations[renameIndex]; + assert.ok(lockOpenIndex > -1); + assert.equal(lockOpen[2], "wx"); + assert.equal(lockOpen[3], 0o600); + assert.equal(dirname(open[1]), tempDir); + assert.equal(open[2], "wx"); + assert.equal(open[3], 0o600); + assert.equal(dirname(rename[1]), tempDir); + assert.equal(rename[2], registryPath); + assert.ok(lockOpenIndex < tempOpenIndex); + assert.ok(tempOpenIndex < fsyncIndex && fsyncIndex < renameIndex); + assert.ok(renameIndex < lockRemovalIndex); + if (process.platform !== "win32") { + assert.equal(statSync(registryPath).mode & 0o777, 0o600); + } else { + assert.doesNotThrow(() => readFileSync(registryPath)); + } + assert.deepEqual(listTempFiles(tempDir, registryPath), []); + assert.equal(existsSync(lockPath), false); +}); + +test("returns a durable result after a one-shot registry lock close failure", (t) => { + const { registryPath } = makeTempRegistry(t, "crp-provider-lock-close-retry-"); + const lockPath = `${registryPath}.crp.lock`; + let lockFileDescriptor; + let lockCloseAttempts = 0; + let registryRenameCount = 0; + const registry = new ProviderRegistry({ + path: registryPath, + createId: () => "provider-1", + now: () => FIXED_NOW, + fileOperations: { + ...realFileOperations, + openSync(path, flags, mode) { + const fileDescriptor = realFileOperations.openSync(path, flags, mode); + if (path === lockPath) { + lockFileDescriptor = fileDescriptor; + } + return fileDescriptor; + }, + closeSync(fileDescriptor) { + if (fileDescriptor === lockFileDescriptor) { + lockCloseAttempts += 1; + if (lockCloseAttempts === 1) { + realFileOperations.closeSync(fileDescriptor); + throw makeFileError("forced one-shot lock close failure", "EIO"); + } + } + return realFileOperations.closeSync(fileDescriptor); + }, + renameSync(source, destination) { + if (destination === registryPath) { + registryRenameCount += 1; + } + return realFileOperations.renameSync(source, destination); + } + } + }); + + const created = registry.create(validInput()); + + assert.equal(created.id, "provider-1"); + assert.equal(lockCloseAttempts, 2); + assert.equal(registryRenameCount, 1); + assert.equal(registry.document.providers.length, 1); + assert.equal(JSON.parse(readFileSync(registryPath, "utf8")).providers.length, 1); + assert.equal(existsSync(lockPath), false); +}); + +test("returns a durable result after a one-shot registry lock removal failure", (t) => { + const { registryPath } = makeTempRegistry(t, "crp-provider-lock-rm-retry-"); + const lockPath = `${registryPath}.crp.lock`; + let lockRemovalAttempts = 0; + let registryRenameCount = 0; + const registry = new ProviderRegistry({ + path: registryPath, + createId: () => "provider-1", + now: () => FIXED_NOW, + fileOperations: { + ...realFileOperations, + rmSync(path, options) { + if (path === lockPath) { + lockRemovalAttempts += 1; + if (lockRemovalAttempts === 1) { + throw makeFileError("forced one-shot lock removal failure", "EBUSY"); + } + } + return realFileOperations.rmSync(path, options); + }, + renameSync(source, destination) { + if (destination === registryPath) { + registryRenameCount += 1; + } + return realFileOperations.renameSync(source, destination); + } + } + }); + + const created = registry.create(validInput()); + + assert.equal(created.id, "provider-1"); + assert.equal(lockRemovalAttempts, 2); + assert.equal(registryRenameCount, 1); + assert.equal(registry.document.providers.length, 1); + assert.equal(JSON.parse(readFileSync(registryPath, "utf8")).providers.length, 1); + assert.equal(existsSync(lockPath), false); +}); + +test("preserves a primary persistence error when lock cleanup transiently fails", (t) => { + const { tempDir, registryPath } = makeTempRegistry(t, "crp-provider-primary-error-"); + const lockPath = `${registryPath}.crp.lock`; + const initial = new ProviderRegistry({ + path: registryPath, + createId: () => "provider-1", + now: () => FIXED_NOW + }); + initial.create(validInput()); + const originalBytes = readFileSync(registryPath); + const renameError = makeFileError("forced primary rename failure", "EIO"); + let lockRemovalAttempts = 0; + const registry = new ProviderRegistry({ + path: registryPath, + now: () => LATER_NOW, + fileOperations: { + ...realFileOperations, + renameSync() { + throw renameError; + }, + rmSync(path, options) { + if (path === lockPath) { + lockRemovalAttempts += 1; + if (lockRemovalAttempts === 1) { + throw makeFileError("forced transient cleanup failure", "EBUSY"); + } + } + return realFileOperations.rmSync(path, options); + } + } + }); + const originalDocument = structuredClone(registry.document); + + assert.throws( + () => registry.update("provider-1", { name: "Updated" }), + (error) => error === renameError + ); + assert.equal(lockRemovalAttempts, 2); + assert.deepEqual(readFileSync(registryPath), originalBytes); + assert.deepEqual(registry.document, originalDocument); + assert.equal(existsSync(lockPath), false); + assert.deepEqual(listTempFiles(tempDir, registryPath), []); +}); + +test("reports a durable mutation with a permanently degraded owned lock", (t) => { + const { registryPath } = makeTempRegistry(t, "crp-provider-lock-degraded-"); + const lockPath = `${registryPath}.crp.lock`; + let lockRemovalAttempts = 0; + let lockReadAttempts = 0; + let rejectLockRemoval = true; + let lockOpenAttempts = 0; + const registry = new ProviderRegistry({ + path: registryPath, + createId: () => "provider-1", + now: () => FIXED_NOW, + fileOperations: { + ...realFileOperations, + openSync(path, flags, mode) { + if (path === lockPath) { + lockOpenAttempts += 1; + } + return realFileOperations.openSync(path, flags, mode); + }, + readFileSync(path, ...args) { + if (path === lockPath) { + lockReadAttempts += 1; + } + return realFileOperations.readFileSync(path, ...args); + }, + rmSync(path, options) { + if (path === lockPath) { + lockRemovalAttempts += 1; + if (rejectLockRemoval) { + throw makeFileError("forced permanent lock removal failure", "EACCES"); + } + } + return realFileOperations.rmSync(path, options); + } + } + }); + + assert.throws( + () => registry.create(validInput()), + (error) => { + assert.ok(error instanceof CrpError); + assert.equal(error.code, "PROVIDER_REGISTRY_COMMITTED_LOCK_DEGRADED"); + assert.equal(error.status, 500); + assert.deepEqual(error.details, { committed: true }); + assert.doesNotMatch(error.action, /retry|try again/i); + return true; + } + ); + assert.equal(registry.document.providers.length, 1); + assert.equal(JSON.parse(readFileSync(registryPath, "utf8")).providers.length, 1); + assert.equal(existsSync(lockPath), true); + + const foreignLockBytes = Buffer.from("foreign-owner\n", "utf8"); + writeFileSync(lockPath, foreignLockBytes); + rejectLockRemoval = false; + const lockReadAttemptsBeforeRetry = lockReadAttempts; + const lockRemovalAttemptsBeforeRetry = lockRemovalAttempts; + const lockOpenAttemptsBeforeRetry = lockOpenAttempts; + assert.throws( + () => registry.update("provider-1", { name: "Updated" }), + assertCrpError("PROVIDER_REGISTRY_LOCK_DEGRADED", 500) + ); + assert.equal(lockReadAttempts, lockReadAttemptsBeforeRetry); + assert.equal(lockRemovalAttempts, lockRemovalAttemptsBeforeRetry); + assert.equal(lockOpenAttempts, lockOpenAttemptsBeforeRetry); + assert.deepEqual(readFileSync(lockPath), foreignLockBytes); + assert.equal(lockOpenAttempts, 1); + assert.equal(registry.document.providers[0].name, "OpenRouter"); +}); + +test("validation failures preserve disk bytes and in-memory state", (t) => { + const { tempDir, registryPath } = makeTempRegistry(t, "crp-provider-validation-rollback-"); + const registry = new ProviderRegistry({ + path: registryPath, + createId: makeIds("provider-1", "provider-2"), + now: () => FIXED_NOW + }); + registry.create(validInput()); + const originalBytes = readFileSync(registryPath); + const originalDocument = registry.getDocument(); + const originalDocumentBytes = JSON.stringify(originalDocument); + + assert.throws( + () => registry.create(validInput({ + name: "OPENROUTER", + credentialRef: "provider-2" + })), + assertCrpError("PROVIDER_NAME_CONFLICT", 409) + ); + assert.deepEqual(readFileSync(registryPath), originalBytes); + assert.deepEqual(registry.getDocument(), originalDocument); + assert.equal(JSON.stringify(registry.getDocument()), originalDocumentBytes); + assert.deepEqual(listTempFiles(tempDir, registryPath), []); +}); + +test("rename failures preserve disk bytes and in-memory state and clean the temp file", (t) => { + const { tempDir, registryPath } = makeTempRegistry(t, "crp-provider-rename-rollback-"); + const initial = new ProviderRegistry({ + path: registryPath, + createId: () => "provider-1", + now: () => FIXED_NOW + }); + initial.create(validInput()); + const originalBytes = readFileSync(registryPath); + const renameError = new Error("forced provider registry rename failure"); + const registry = new ProviderRegistry({ + path: registryPath, + now: () => LATER_NOW, + fileOperations: { + ...realFileOperations, + renameSync() { + throw renameError; + } + } + }); + const originalDocument = registry.getDocument(); + + assert.throws( + () => registry.update("provider-1", { name: "Updated" }), + (error) => error === renameError + ); + assert.deepEqual(readFileSync(registryPath), originalBytes); + assert.deepEqual(registry.getDocument(), originalDocument); + assert.deepEqual(listTempFiles(tempDir, registryPath), []); +}); + +test("list, get, getActive, and getDocument return defensive copies", (t) => { + const { registryPath } = makeTempRegistry(t); + const registry = new ProviderRegistry({ + path: registryPath, + createId: () => "provider-1", + now: () => FIXED_NOW + }); + registry.create(validInput({ extraHeaders: { "x-region": "original" } })); + registry.setActive("provider-1"); + + const listed = registry.list(); + listed[0].name = "Mutated"; + listed[0].extraHeaders["x-region"] = "mutated"; + listed.push({ id: "injected" }); + + const fetched = registry.get("provider-1"); + fetched.name = "Mutated again"; + fetched.extraHeaders.injected = "value"; + + const active = registry.getActive(); + active.name = "Mutated active"; + + const document = registry.getDocument(); + document.activeProviderId = null; + document.providers.length = 0; + document.settings.proxyPort = 9999; + + assert.equal(registry.list().length, 1); + assert.equal(registry.get("provider-1").name, "OpenRouter"); + assert.deepEqual(registry.get("provider-1").extraHeaders, { "x-region": "original" }); + assert.equal(registry.getActive().id, "provider-1"); + assert.equal(registry.getDocument().activeProviderId, "provider-1"); + assert.equal(registry.getDocument().settings.proxyPort, 15100); +}); + +test("toPublicProvider returns an exact allowlisted shape with a boolean credential flag", () => { + const credentialReference = "credential-reference"; + const futureSensitiveValue = "future-sensitive-placeholder"; + const profile = { + ...normalizeProvider(validInput({ credentialRef: credentialReference }), { + id: "provider-1", + now: FIXED_NOW + }), + futureSecretField: futureSensitiveValue, + futureInternalState: { value: futureSensitiveValue } + }; + + const publicProvider = toPublicProvider(profile, true); + const serialized = JSON.stringify(publicProvider); + assert.deepEqual(Object.keys(publicProvider), [ + "id", + "name", + "baseUrl", + "authHeader", + "authScheme", + "extraHeaders", + "modelMode", + "modelOverride", + "lastTestAt", + "lastTestStatus", + "lastTestCode", + "createdAt", + "updatedAt", + "credentialConfigured" + ]); + assert.equal(Object.hasOwn(publicProvider, "credentialRef"), false); + assert.equal(publicProvider.credentialConfigured, true); + assert.doesNotMatch(serialized, new RegExp(credentialReference)); + assert.doesNotMatch(serialized, new RegExp(futureSensitiveValue)); + assert.throws( + () => toPublicProvider(profile, "true"), + assertCrpError("PROVIDER_INPUT_INVALID", 400) + ); +}); + +test("an absent registry file stays absent until the first successful mutation", (t) => { + const { registryPath } = makeTempRegistry(t, "crp-provider-lazy-create-"); + const registry = new ProviderRegistry({ + path: registryPath, + createId: () => "provider-1", + now: () => FIXED_NOW + }); + + assert.equal(realFileOperations.existsSync(registryPath), false); + assert.deepEqual(registry.list(), []); + assert.equal(realFileOperations.existsSync(registryPath), false); + + assert.throws( + () => registry.create(validInput({ baseUrl: "http://remote.example/v1" })), + assertCrpError("PROVIDER_INPUT_INVALID", 400) + ); + assert.equal(realFileOperations.existsSync(registryPath), false); + + registry.create(validInput()); + assert.equal(realFileOperations.existsSync(registryPath), true); +}); diff --git a/node/test/provider-service.test.mjs b/node/test/provider-service.test.mjs new file mode 100644 index 0000000..fd9cd79 --- /dev/null +++ b/node/test/provider-service.test.mjs @@ -0,0 +1,1522 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { createServer } from "node:http"; +import os from "node:os"; +import { join } from "node:path"; + +import { ProviderRegistry } from "../src/providers/provider-registry.mjs"; +import { CrpError, toPublicError } from "../src/shared/errors.mjs"; +import { ProviderService } from "../src/supervisor/provider-service.mjs"; + +const NOW = "2026-07-13T02:00:00.000Z"; + +function makeSecret(label = "provider") { + return `${label}-${crypto.randomUUID()}`; +} + +function providerInput(name = "Primary", baseUrl = "https://provider.example/v1") { + return { name, baseUrl }; +} + +class MemoryCredentials { + constructor() { + this.values = new Map(); + this.operations = []; + this.setCalls = []; + } + + async set(ref, secret, ...extraArguments) { + this.operations.push(["set", ref]); + this.setCalls.push([ref, secret, ...extraArguments]); + this.values.set(ref, secret); + } + + async get(ref) { + this.operations.push(["get", ref]); + if (!this.values.has(ref)) { + const error = new Error("missing"); + error.code = "CREDENTIAL_NOT_FOUND"; + throw error; + } + return this.values.get(ref); + } + + async has(ref) { + this.operations.push(["has", ref]); + return this.values.has(ref); + } + + async delete(ref) { + this.operations.push(["delete", ref]); + return this.values.delete(ref); + } +} + +class MemoryActivity { + constructor() { + this.events = []; + } + + append(event) { + this.events.push(structuredClone(event)); + } +} + +class MemoryModelCache { + constructor() { + this.entries = new Map(); + this.getCalls = []; + this.putCalls = []; + this.deleteCalls = []; + } + + get(providerId, sourceFingerprint) { + this.getCalls.push([providerId, sourceFingerprint]); + const entry = this.entries.get(providerId); + if (!entry || entry.sourceFingerprint !== sourceFingerprint) { + return { + providerId, + state: "missing", + fetchedAt: null, + expiresAt: null, + models: [] + }; + } + const { sourceFingerprint: _privateFingerprint, ...catalog } = entry; + return structuredClone(catalog); + } + + put(entry) { + this.putCalls.push(structuredClone(entry)); + const catalog = { + providerId: entry.providerId, + state: "fresh", + fetchedAt: entry.fetchedAt, + expiresAt: new Date(Date.parse(entry.fetchedAt) + 24 * 60 * 60 * 1_000).toISOString(), + models: [...entry.models] + }; + this.entries.set(entry.providerId, { + ...structuredClone(catalog), + sourceFingerprint: entry.sourceFingerprint + }); + return structuredClone(catalog); + } + + delete(providerId) { + this.deleteCalls.push(providerId); + return this.entries.delete(providerId); + } + + seed(entry) { + this.entries.set(entry.providerId, structuredClone(entry)); + } +} + +class FakeWorkerManager { + constructor() { + this.phase = "stopped"; + this.generation = 0; + this.calls = []; + this.failure = null; + } + + getPublicState() { + return { + phase: this.phase, + pid: this.phase === "running" ? 9001 : null, + generation: this.generation, + state: this.phase === "running" ? { + phase: "running", + configured: true, + generation: this.generation, + listening: true, + listenHost: "127.0.0.1", + listenPort: 15100, + inFlight: 0 + } : null, + restartCount: 0, + startedAt: this.phase === "running" ? NOW : null, + error: null + }; + } + + async start(snapshot) { + this.calls.push(["start", structuredClone(snapshot)]); + if (this.failure) throw this.failure; + this.phase = "running"; + this.generation = snapshot.generation; + return this.getPublicState(); + } + + async applySnapshot(snapshot) { + this.calls.push(["applySnapshot", structuredClone(snapshot)]); + if (this.failure) throw this.failure; + this.generation = snapshot.generation; + return this.getPublicState(); + } + + async restart(snapshot) { + this.calls.push(["restart", structuredClone(snapshot)]); + if (this.failure) throw this.failure; + this.phase = "running"; + this.generation = snapshot.generation; + return this.getPublicState(); + } + + async stop() { + this.calls.push(["stop"]); + this.phase = "stopped"; + return this.getPublicState(); + } +} + +function makeHarness(t, overrides = {}) { + const root = mkdtempSync(join(os.tmpdir(), "crp-provider-service-")); + const registry = new ProviderRegistry({ + path: join(root, "providers.json"), + createId: (() => { + let index = 0; + return () => `provider-${++index}`; + })(), + now: () => NOW + }); + const credentials = new MemoryCredentials(); + const activity = new MemoryActivity(); + const workerManager = new FakeWorkerManager(); + const modelCache = overrides.modelCache ?? new MemoryModelCache(); + let credentialIndex = 0; + const service = new ProviderService({ + registry, + credentialStore: credentials, + activityStore: activity, + workerManager, + modelCache, + now: () => NOW, + createCredentialRef: () => `credential-${++credentialIndex}`, + createTimeoutSignal: () => ({ aborted: false }), + paths: { + runtimeConfigPath: join(root, "node", "proxy-config.json"), + capturePath: join(root, "traffic.sqlite3") + }, + ...overrides + }); + t.after(() => rmSync(root, { recursive: true, force: true })); + return { root, registry, credentials, activity, workerManager, modelCache, service }; +} + +function compatibleResponse() { + return { + ok: true, + status: 200, + async json() { + return { id: "resp-test", object: "response", output: [] }; + } + }; +} + +function modelListResponse(models, { status = 200, payload } = {}) { + return new Response(JSON.stringify(payload ?? { + object: "list", + data: models.map((id) => ({ id, object: "model", owned_by: "test" })) + }), { + status, + headers: { "content-type": "application/json" } + }); +} + +function committedError(code, action = "Repair the residual state.") { + return new CrpError( + code, + "Committed operation degraded.", + action, + { details: { committed: true } } + ); +} + +function createGate() { + let resolve; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +async function listen(server) { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + return server.address().port; +} + +async function closeServer(server) { + await new Promise((resolve) => server.close(resolve)); +} + +test("CRUD returns only public provider fields and removes inactive credentials", async (t) => { + const secret = makeSecret(); + const { service, credentials, activity } = makeHarness(t); + + const created = await service.createProvider(providerInput(), secret); + assert.equal(created.id, "provider-1"); + assert.equal(created.credentialConfigured, true); + assert.equal("credentialRef" in created, false); + assert.equal(JSON.stringify(created).includes(secret), false); + assert.deepEqual(await service.listProviders(), [created]); + + const updated = await service.updateProvider(created.id, { name: "Primary Updated" }); + assert.equal(updated.name, "Primary Updated"); + assert.equal("credentialRef" in updated, false); + const deleted = await service.deleteProvider(created.id); + assert.equal(deleted.id, created.id); + assert.equal("credentialRef" in deleted, false); + assert.equal(credentials.values.size, 0); + assert.deepEqual(activity.events.map((event) => event.action), ["create", "update", "delete"]); + assert.equal(JSON.stringify(activity.events).includes(secret), false); +}); + +test("create does not forward a public fallback-consent option to credential storage", async (t) => { + const secret = makeSecret("no-public-fallback"); + const { service, credentials } = makeHarness(t); + + await service.createProvider(providerInput(), secret, { fallbackConsent: true }); + + assert.equal(credentials.setCalls.length, 1); + assert.equal(credentials.setCalls[0].length, 2); + assert.equal(credentials.setCalls[0][0], "credential-1"); + assert.equal(credentials.setCalls[0][1], secret); +}); + +test("CRUD compensates credential changes and rejects active delete before credential access", async (t) => { + const oldSecret = makeSecret("old"); + const otherSecret = makeSecret("other"); + const replacementSecret = makeSecret("replacement"); + const { service, registry, credentials, activity } = makeHarness(t); + const primary = await service.createProvider(providerInput("Primary"), oldSecret); + const other = await service.createProvider( + providerInput("Other", "https://other.example/v1"), + otherSecret + ); + + await assert.rejects( + () => service.createProvider( + providerInput("primary", "https://duplicate.example/v1"), + makeSecret("duplicate") + ), + (error) => error?.code === "PROVIDER_NAME_CONFLICT" + ); + assert.equal(credentials.values.has("credential-3"), false); + + await assert.rejects( + () => service.updateProvider(primary.id, { name: "OTHER" }, replacementSecret), + (error) => error?.code === "PROVIDER_NAME_CONFLICT" + ); + assert.equal(credentials.values.get("credential-1"), oldSecret); + assert.equal(registry.get(primary.id).name, "Primary"); + + const originalDelete = registry.delete.bind(registry); + registry.delete = () => { + throw new CrpError( + "PROVIDER_REGISTRY_WRITE_FAILED", + "Registry write failed.", + "Retry the operation." + ); + }; + await assert.rejects( + () => service.deleteProvider(other.id), + (error) => error?.code === "PROVIDER_REGISTRY_WRITE_FAILED" + ); + assert.equal(credentials.values.get("credential-2"), otherSecret); + registry.delete = originalDelete; + + registry.setActive(primary.id); + credentials.operations.length = 0; + await assert.rejects( + () => service.deleteProvider(primary.id), + (error) => error?.code === "PROVIDER_ACTIVE" + ); + assert.deepEqual(credentials.operations, []); + assert.deepEqual( + activity.events.filter((entry) => entry.result === "failed") + .map((entry) => [entry.action, entry.errorCode]), + [ + ["create", "PROVIDER_NAME_CONFLICT"], + ["update", "PROVIDER_NAME_CONFLICT"], + ["delete", "PROVIDER_REGISTRY_WRITE_FAILED"], + ["delete", "PROVIDER_ACTIVE"] + ] + ); + assert.equal(JSON.stringify(activity.events).includes(oldSecret), false); + assert.equal(JSON.stringify(activity.events).includes(replacementSecret), false); +}); + +test("rejects active provider updates before credential or registry mutation", async (t) => { + const { service, registry, credentials, activity } = makeHarness(t); + const provider = await service.createProvider(providerInput(), makeSecret()); + registry.setActive(provider.id); + credentials.operations.length = 0; + let updateCalls = 0; + let markTestCalls = 0; + const originalUpdate = registry.update.bind(registry); + const originalMarkTest = registry.markTest.bind(registry); + registry.update = (...args) => { + updateCalls += 1; + return originalUpdate(...args); + }; + registry.markTest = (...args) => { + markTestCalls += 1; + return originalMarkTest(...args); + }; + + await assert.rejects( + () => service.updateProvider(provider.id, { name: "Must Not Change" }, makeSecret()), + (error) => error?.code === "PROVIDER_ACTIVE" && error.status === 409 + ); + assert.deepEqual(credentials.operations, []); + assert.equal(updateCalls, 0); + assert.equal(markTestCalls, 0); + assert.equal(registry.get(provider.id).name, "Primary"); + assert.equal(activity.events.at(-1).action, "update"); + assert.equal(activity.events.at(-1).errorCode, "PROVIDER_ACTIVE"); +}); + +test("keeps a created provider and credential when registry create reports committed", async (t) => { + const { service, registry, credentials, activity } = makeHarness(t); + const originalCreate = registry.create.bind(registry); + registry.create = (input) => { + originalCreate(input); + throw committedError("PROVIDER_REGISTRY_COMMITTED_LOCK_DEGRADED"); + }; + + await assert.rejects( + () => service.createProvider(providerInput(), makeSecret()), + (error) => error?.code === "PROVIDER_CREATE_COMMITTED_DEGRADED" + && error.details.committed === true + ); + assert.equal(registry.list().length, 1); + assert.equal(credentials.values.has("credential-1"), true); + assert.equal(activity.events.at(-1).result, "degraded"); + assert.equal(activity.events.at(-1).errorCode, "PROVIDER_CREATE_COMMITTED_DEGRADED"); +}); + +test("keeps replacement secret and metadata when registry update reports committed", async (t) => { + const { service, registry, credentials, activity } = makeHarness(t); + const provider = await service.createProvider(providerInput(), makeSecret("old")); + registry.markTest(provider.id, { status: "passed" }); + const replacement = makeSecret("replacement"); + const originalUpdate = registry.update.bind(registry); + registry.update = (id, patch) => { + originalUpdate(id, patch); + throw committedError("PROVIDER_REGISTRY_COMMITTED_LOCK_DEGRADED"); + }; + + await assert.rejects( + () => service.updateProvider(provider.id, { name: "Committed Name" }, replacement), + (error) => error?.code === "PROVIDER_UPDATE_COMMITTED_DEGRADED" + && error.details.committed === true + ); + assert.equal(registry.get(provider.id).name, "Committed Name"); + assert.equal(registry.get(provider.id).lastTestStatus, "untested"); + assert.equal(credentials.values.get("credential-1"), replacement); + assert.equal(activity.events.at(-1).result, "degraded"); +}); + +test("does not restore a credential when registry delete reports committed", async (t) => { + const { service, registry, credentials, activity } = makeHarness(t); + const provider = await service.createProvider(providerInput(), makeSecret()); + const originalDelete = registry.delete.bind(registry); + registry.delete = (id) => { + originalDelete(id); + throw committedError("PROVIDER_REGISTRY_COMMITTED_LOCK_DEGRADED"); + }; + + await assert.rejects( + () => service.deleteProvider(provider.id), + (error) => error?.code === "PROVIDER_DELETE_COMMITTED_DEGRADED" + && error.details.committed === true + ); + assert.deepEqual(registry.list(), []); + assert.equal(credentials.values.has("credential-1"), false); + assert.equal(activity.events.at(-1).result, "degraded"); +}); + +test("reconciles committed credential create, replacement, and delete mutations", async (t) => { + await t.test("credential create", async (t) => { + const { service, registry, credentials } = makeHarness(t); + const originalSet = credentials.set.bind(credentials); + let injected = false; + credentials.set = async (ref, secret, options) => { + await originalSet(ref, secret, options); + if (!injected) { + injected = true; + throw committedError("CREDENTIAL_STORE_COMMITTED_LOCK_DEGRADED"); + } + }; + await assert.rejects( + () => service.createProvider(providerInput(), makeSecret()), + (error) => error?.code === "PROVIDER_CREATE_COMMITTED_DEGRADED" + ); + assert.equal(registry.list().length, 1); + assert.equal(credentials.values.has("credential-1"), true); + }); + + await t.test("credential replacement", async (t) => { + const { service, registry, credentials } = makeHarness(t); + const provider = await service.createProvider(providerInput(), makeSecret("old")); + registry.markTest(provider.id, { status: "passed" }); + const replacement = makeSecret("new"); + const originalSet = credentials.set.bind(credentials); + credentials.set = async (ref, secret, options) => { + await originalSet(ref, secret, options); + if (secret === replacement) { + throw committedError("CREDENTIAL_STORE_COMMITTED_LOCK_DEGRADED"); + } + }; + await assert.rejects( + () => service.updateProvider(provider.id, { name: "New Name" }, replacement), + (error) => error?.code === "PROVIDER_UPDATE_COMMITTED_DEGRADED" + ); + assert.equal(registry.get(provider.id).name, "New Name"); + assert.equal(registry.get(provider.id).lastTestStatus, "untested"); + assert.equal(credentials.values.get("credential-1"), replacement); + }); + + await t.test("credential delete", async (t) => { + const { service, registry, credentials } = makeHarness(t); + const provider = await service.createProvider(providerInput(), makeSecret()); + const originalDelete = credentials.delete.bind(credentials); + credentials.delete = async (ref) => { + await originalDelete(ref); + throw committedError("CREDENTIAL_STORE_COMMITTED_LOCK_DEGRADED"); + }; + await assert.rejects( + () => service.deleteProvider(provider.id), + (error) => error?.code === "PROVIDER_DELETE_COMMITTED_DEGRADED" + ); + assert.deepEqual(registry.list(), []); + assert.equal(credentials.values.has("credential-1"), false); + }); +}); + +test("keeps provider not ready when replacement-secret rollback cannot restore the old secret", async (t) => { + const oldSecret = makeSecret("old"); + const replacement = makeSecret("replacement"); + const { service, registry, credentials, workerManager } = makeHarness(t); + const primary = await service.createProvider(providerInput("Primary"), oldSecret); + await service.createProvider(providerInput("Other", "https://other.example/v1"), makeSecret()); + registry.markTest(primary.id, { status: "passed" }); + const originalSet = credentials.set.bind(credentials); + credentials.set = async (ref, secret, options) => { + if (ref === "credential-1" && secret === oldSecret) { + throw new Error("private old-secret restore failure"); + } + return await originalSet(ref, secret, options); + }; + + await assert.rejects( + () => service.updateProvider(primary.id, { name: "OTHER" }, replacement), + (error) => error?.code === "PROVIDER_UPDATE_ROLLBACK_DEGRADED" + && error.details.degraded === true + ); + assert.equal(credentials.values.get("credential-1"), replacement); + assert.notEqual(registry.get(primary.id).lastTestStatus, "passed"); + credentials.operations.length = 0; + const workerCallCount = workerManager.calls.length; + await assert.rejects( + () => service.activate(primary.id), + (error) => error?.code === "PROVIDER_NOT_READY" + ); + assert.deepEqual( + credentials.operations.filter(([operation]) => operation === "get"), + [] + ); + assert.equal(workerManager.calls.length, workerCallCount); +}); + +test("testProvider sends the fixed Responses request and classifies stable failures", async (t) => { + const secret = makeSecret(); + const requests = []; + let nextFetch = async () => compatibleResponse(); + const { service, registry, credentials, workerManager } = makeHarness(t, { + fetchImpl: async (...args) => { + requests.push(args); + return nextFetch(...args); + } + }); + const provider = await service.createProvider({ + ...providerInput(), + authHeader: "x-provider-auth", + authScheme: "Token", + extraHeaders: { "x-region": "test" } + }, secret); + + assert.deepEqual(await service.testProvider(provider.id, "model-test"), { + ok: true, + code: null, + initialActivation: null + }); + const [url, options] = requests.at(-1); + assert.equal(url, "https://provider.example/v1/responses"); + assert.equal(options.method, "POST"); + assert.equal(options.redirect, "manual"); + assert.deepEqual(JSON.parse(options.body), { + model: "model-test", + stream: false, + input: "Reply with OK." + }); + assert.equal(options.headers["x-provider-auth"], `Token ${secret}`); + assert.equal(options.headers["x-region"], "test"); + assert.ok(options.signal); + assert.equal(registry.getDocument().activeProviderId, null); + assert.deepEqual(workerManager.calls, []); + + const scenarios = [ + ["PROVIDER_TEST_DNS", async () => { const error = new Error("private dns"); error.code = "ENOTFOUND"; throw error; }], + ["PROVIDER_TEST_TLS", async () => { const error = new Error("private tls"); error.code = "CERT_HAS_EXPIRED"; throw error; }], + ["PROVIDER_TEST_TIMEOUT", async () => { const error = new Error("private timeout"); error.name = "TimeoutError"; throw error; }], + ["PROVIDER_TEST_AUTH", async () => ({ ok: false, status: 401, json: async () => ({ private: secret }) })], + ["PROVIDER_TEST_NOT_FOUND", async () => ({ ok: false, status: 404, json: async () => ({ private: secret }) })], + ["PROVIDER_TEST_HTTP", async () => ({ ok: false, status: 503, json: async () => ({ private: secret }) })], + ["PROVIDER_TEST_INVALID_JSON", async () => ({ ok: true, status: 200, json: async () => { throw new Error(secret); } })], + ["PROVIDER_TEST_INVALID_RESPONSES", async () => ({ ok: true, status: 200, json: async () => ({ id: "wrong" }) })], + ["PROVIDER_TEST_NETWORK", async () => { throw new Error(`private other ${secret}`); }] + ]; + for (const [code, fetchScenario] of scenarios) { + nextFetch = fetchScenario; + assert.deepEqual(await service.testProvider(provider.id, "model-test"), { + ok: false, + code, + initialActivation: null + }); + } + + assert.deepEqual( + credentials.operations.filter(([operation]) => operation === "get") + .map(([, ref]) => ref), + Array(scenarios.length + 1).fill("credential-1") + ); + assert.equal(JSON.stringify(await service.listProviders()).includes(secret), false); +}); + +test("never follows a redirect or forwards custom authentication to a second origin", async (t) => { + const secret = makeSecret("redirect"); + const secondRequests = []; + const second = createServer((request, response) => { + secondRequests.push({ url: request.url, headers: { ...request.headers } }); + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ id: "resp", object: "response", output: [] })); + }); + const secondPort = await listen(second); + t.after(() => closeServer(second)); + const firstRequests = []; + const first = createServer((request, response) => { + firstRequests.push({ url: request.url, headers: { ...request.headers } }); + response.writeHead(302, { + location: `http://127.0.0.1:${secondPort}/stolen` + }); + response.end(); + }); + const firstPort = await listen(first); + t.after(() => closeServer(first)); + const { service, activity } = makeHarness(t, { + fetchImpl: globalThis.fetch, + createTimeoutSignal: () => AbortSignal.timeout(1_000) + }); + const provider = await service.createProvider({ + ...providerInput("Redirect", `http://127.0.0.1:${firstPort}/v1`), + authHeader: "x-private-auth", + authScheme: "Token" + }, secret); + + assert.deepEqual(await service.testProvider(provider.id, "model-test"), { + ok: false, + code: "PROVIDER_TEST_REDIRECT", + initialActivation: null + }); + assert.equal(firstRequests.length, 1); + assert.equal(firstRequests[0].headers["x-private-auth"], `Token ${secret}`); + assert.equal(secondRequests.length, 0); + assert.equal(JSON.stringify(activity.events).includes(secret), false); +}); + +test("model discovery reads cache and refreshes a bounded authenticated catalog without lifecycle changes", async (t) => { + const secret = makeSecret("models"); + const requests = []; + const harness = makeHarness(t, { + fetchImpl: async (...args) => { + requests.push(args); + return modelListResponse(["model-b", "model-a", "model-b"], { status: 206 }); + } + }); + const { service, registry, workerManager, modelCache } = harness; + const provider = await service.createProvider({ + ...providerInput("Models", "https://provider.example/root/v1"), + authHeader: "x-provider-auth", + authScheme: "Token", + extraHeaders: { "x-region": "test" } + }, secret); + const before = registry.getDocument(); + + assert.deepEqual(await service.getProviderModels(provider.id), { + providerId: provider.id, + state: "missing", + fetchedAt: null, + expiresAt: null, + models: [] + }); + const refreshed = await service.refreshProviderModels(provider.id); + assert.deepEqual(refreshed, { + providerId: provider.id, + state: "fresh", + fetchedAt: NOW, + expiresAt: "2026-07-14T02:00:00.000Z", + models: ["model-b", "model-a"] + }); + assert.deepEqual(await service.getProviderModels(provider.id), refreshed); + + const [url, options] = requests[0]; + assert.equal(url, "https://provider.example/root/v1/models"); + assert.equal(options.method, "GET"); + assert.equal(options.redirect, "manual"); + assert.equal(options.body, undefined); + assert.equal(options.headers["x-provider-auth"], `Token ${secret}`); + assert.equal(options.headers["x-region"], "test"); + assert.ok(options.signal); + assert.equal(modelCache.putCalls.length, 1); + assert.equal(typeof modelCache.putCalls[0].sourceFingerprint, "string"); + assert.notEqual(modelCache.putCalls[0].sourceFingerprint.length, 0); + assert.equal(modelCache.putCalls[0].sourceFingerprint.includes(secret), false); + assert.deepEqual(modelCache.putCalls[0].models, ["model-b", "model-a"]); + assert.deepEqual(registry.getDocument(), before); + assert.deepEqual(workerManager.calls, []); +}); + +test("model refresh classifies failures, enforces bounds, and preserves the last good cache", async (t) => { + const secret = makeSecret("models-failure"); + let nextFetch; + const harness = makeHarness(t, { + fetchImpl: async () => await nextFetch() + }); + const { service, registry, workerManager, modelCache } = harness; + const provider = await service.createProvider(providerInput(), secret); + await service.getProviderModels(provider.id); + const sourceFingerprint = modelCache.getCalls.at(-1)[1]; + const stale = { + providerId: provider.id, + sourceFingerprint, + state: "stale", + fetchedAt: "2026-07-11T00:00:00.000Z", + expiresAt: "2026-07-12T00:00:00.000Z", + models: ["last-good-model"] + }; + modelCache.seed(stale); + const before = registry.getDocument(); + const tooMany = Array.from({ length: 2_001 }, (_, index) => `model-${index}`); + const tooLong = "m".repeat(257); + const oversizedBody = JSON.stringify({ + object: "list", + data: [{ id: "m".repeat(1_048_576), object: "model" }] + }); + const scenarios = [ + ["PROVIDER_MODELS_REDIRECT", async () => new Response(null, { status: 302 })], + ["PROVIDER_MODELS_AUTH", async () => new Response(null, { status: 401 })], + ["PROVIDER_MODELS_NOT_FOUND", async () => new Response(null, { status: 404 })], + ["PROVIDER_MODELS_HTTP", async () => new Response(null, { status: 503 })], + ["PROVIDER_MODELS_INVALID_JSON", async () => new Response("{", { status: 200 })], + ["PROVIDER_MODELS_INVALID_RESPONSE", async () => modelListResponse([], { + payload: { object: "list", data: [{ object: "model" }] } + })], + ["PROVIDER_MODELS_INVALID_RESPONSE", async () => modelListResponse([tooLong])], + ["PROVIDER_MODELS_INVALID_RESPONSE", async () => modelListResponse(tooMany)], + ["PROVIDER_MODELS_RESPONSE_TOO_LARGE", async () => new Response(oversizedBody, { status: 200 })], + ["PROVIDER_MODELS_DNS", async () => { const error = new Error(secret); error.code = "ENOTFOUND"; throw error; }], + ["PROVIDER_MODELS_TLS", async () => { const error = new Error(secret); error.code = "CERT_HAS_EXPIRED"; throw error; }], + ["PROVIDER_MODELS_TIMEOUT", async () => { const error = new Error(secret); error.name = "TimeoutError"; throw error; }], + ["PROVIDER_MODELS_NETWORK", async () => { throw new Error(secret); }] + ]; + + for (const [code, fetchScenario] of scenarios) { + nextFetch = fetchScenario; + await assert.rejects( + () => service.refreshProviderModels(provider.id), + (error) => error?.code === code + ); + const cached = await service.getProviderModels(provider.id); + assert.deepEqual(cached, { + providerId: provider.id, + state: "stale", + fetchedAt: stale.fetchedAt, + expiresAt: stale.expiresAt, + models: stale.models + }); + } + assert.equal(modelCache.putCalls.length, 0); + assert.deepEqual(registry.getDocument(), before); + assert.deepEqual(workerManager.calls, []); +}); + +test("model refresh reports committed degradation when Activity fails after the cache commit", async (t) => { + let fetchCalls = 0; + const { service, activity, modelCache } = makeHarness(t, { + fetchImpl: async () => { + fetchCalls += 1; + return modelListResponse(["model-a", "model-b"]); + } + }); + const provider = await service.createProvider( + providerInput(), + makeSecret("models-committed") + ); + const originalAppend = activity.append.bind(activity); + activity.append = () => { + throw new Error("forced Activity append failure after model cache commit"); + }; + + const failure = await service.refreshProviderModels(provider.id).then( + () => null, + (error) => error + ); + + assert.notEqual(failure?.code, "PROVIDER_MODELS_NETWORK"); + assert.equal(failure?.code, "PROVIDER_MODELS_COMMITTED_DEGRADED"); + assert.deepEqual(failure?.details, { committed: true, degraded: true }); + assert.equal(modelCache.putCalls.length, 1); + assert.deepEqual(await service.getProviderModels(provider.id), { + providerId: provider.id, + state: "fresh", + fetchedAt: NOW, + expiresAt: "2026-07-14T02:00:00.000Z", + models: ["model-a", "model-b"] + }); + assert.equal(fetchCalls, 1); + await service.getProviderModels(provider.id); + assert.equal(fetchCalls, 1); + activity.append = originalAppend; +}); + +test("model refresh preserves cache-lock repair guidance after a committed cache put", async (t) => { + const cacheRepairAction = "Stop CRP, repair the residual model-cache lock, then restart CRP."; + const { service, modelCache } = makeHarness(t, { + fetchImpl: async () => modelListResponse(["model-a"]) + }); + const provider = await service.createProvider( + providerInput(), + makeSecret("models-cache-lock") + ); + const originalPut = modelCache.put.bind(modelCache); + modelCache.put = (entry) => { + originalPut(entry); + throw committedError( + "PROVIDER_MODEL_CACHE_COMMITTED_LOCK_DEGRADED", + cacheRepairAction + ); + }; + + const failure = await service.refreshProviderModels(provider.id).then( + () => null, + (error) => error + ); + + assert.equal(failure?.code, "PROVIDER_MODELS_COMMITTED_DEGRADED"); + assert.deepEqual(failure?.details, { committed: true, degraded: true }); + assert.equal(failure?.action, cacheRepairAction); + assert.doesNotMatch(failure?.action ?? "", /Activity/i); + assert.deepEqual(await service.getProviderModels(provider.id), { + providerId: provider.id, + state: "fresh", + fetchedAt: NOW, + expiresAt: "2026-07-14T02:00:00.000Z", + models: ["model-a"] + }); +}); + +test("model discovery rejects credential-bearing model ids before any public or cached projection", async (t) => { + const secret = makeSecret("models-id-credential"); + const { service, registry, activity, modelCache } = makeHarness(t, { + fetchImpl: async () => modelListResponse([`prefix-${secret}-suffix`]) + }); + const provider = await service.createProvider(providerInput(), secret); + await service.getProviderModels(provider.id); + const sourceFingerprint = modelCache.getCalls.at(-1)[1]; + const previous = { + providerId: provider.id, + sourceFingerprint, + state: "stale", + fetchedAt: "2026-07-11T00:00:00.000Z", + expiresAt: "2026-07-12T00:00:00.000Z", + models: ["last-good-model"] + }; + modelCache.seed(previous); + + const failure = await service.refreshProviderModels(provider.id).then( + () => null, + (error) => error + ); + const cached = await service.getProviderModels(provider.id); + const publicError = failure === null ? null : toPublicError(failure, "request-models-safe"); + const publicProviders = await service.listProviders(); + const publicStatus = await service.getStatus(); + const reachable = JSON.stringify({ + publicError, + cached, + publicProviders, + publicStatus, + activity: activity.events + }); + + assert.equal(reachable.includes(secret), false); + assert.equal(failure?.message?.includes(secret) ?? false, false); + assert.equal(failure?.action?.includes(secret) ?? false, false); + assert.equal(failure?.cause?.message?.includes(secret) ?? false, false); + assert.equal(failure?.code, "PROVIDER_MODELS_INVALID_RESPONSE"); + assert.deepEqual(cached, { + providerId: provider.id, + state: "stale", + fetchedAt: previous.fetchedAt, + expiresAt: previous.expiresAt, + models: previous.models + }); + assert.equal(modelCache.putCalls.length, 0); + assert.equal(registry.getDocument().activeProviderId, null); +}); + +test("successful opt-in tests select the first provider without starting the Worker", async (t) => { + const secret = makeSecret("initial-activation"); + const { service, registry, workerManager } = makeHarness(t, { + fetchImpl: async () => compatibleResponse() + }); + const provider = await service.createProvider(providerInput(), secret); + + const before = workerManager.getPublicState(); + const result = await service.testProvider(provider.id, "model-test", { + activateIfNone: true + }); + + assert.deepEqual(result, { + ok: true, + code: null, + initialActivation: { + automatic: true, + activeProviderId: provider.id, + workerStarted: false + } + }); + assert.equal(registry.get(provider.id).lastTestStatus, "passed"); + assert.equal(registry.getDocument().activeProviderId, provider.id); + assert.deepEqual(workerManager.calls, []); + assert.equal(workerManager.getPublicState().phase, "stopped"); + assert.equal(workerManager.getPublicState().generation, before.generation); +}); + +test("provider test reports committed degradation when Activity fails after markTest", async (t) => { + let fetchCalls = 0; + const { service, registry, activity, workerManager } = makeHarness(t, { + fetchImpl: async () => { + fetchCalls += 1; + return compatibleResponse(); + } + }); + const provider = await service.createProvider( + providerInput(), + makeSecret("test-committed") + ); + const originalAppend = activity.append.bind(activity); + activity.append = () => { + throw new Error("forced Activity append failure after provider test commit"); + }; + + const failure = await service.testProvider(provider.id, "model-test", { + activateIfNone: true + }).then( + () => null, + (error) => error + ); + + assert.equal(failure?.code, "PROVIDER_TEST_COMMITTED_DEGRADED"); + assert.deepEqual(failure?.details, { committed: true, degraded: true }); + assert.equal(registry.get(provider.id).lastTestStatus, "passed"); + assert.equal(registry.get(provider.id).lastTestCode, null); + assert.equal(registry.getDocument().activeProviderId, null); + assert.deepEqual(workerManager.calls, []); + + activity.append = originalAppend; + const retried = await service.testProvider(provider.id, "model-test", { + activateIfNone: true + }); + assert.deepEqual(retried.initialActivation, { + automatic: true, + activeProviderId: provider.id, + workerStarted: false + }); + assert.equal(registry.getDocument().activeProviderId, provider.id); + assert.deepEqual(workerManager.calls, []); + assert.equal(fetchCalls, 2); +}); + +test("provider test preserves registry-lock repair guidance after a committed markTest", async (t) => { + const registryRepairAction = "Stop CRP, repair the residual provider-registry lock, then restart CRP."; + const { service, registry, workerManager } = makeHarness(t, { + fetchImpl: async () => compatibleResponse() + }); + const provider = await service.createProvider( + providerInput(), + makeSecret("test-registry-lock") + ); + const originalMarkTest = registry.markTest.bind(registry); + registry.markTest = (id, result) => { + originalMarkTest(id, result); + throw committedError( + "PROVIDER_REGISTRY_COMMITTED_LOCK_DEGRADED", + registryRepairAction + ); + }; + + const failure = await service.testProvider(provider.id, "model-test", { + activateIfNone: true + }).then( + () => null, + (error) => error + ); + + assert.equal(failure?.code, "PROVIDER_TEST_COMMITTED_DEGRADED"); + assert.deepEqual(failure?.details, { committed: true, degraded: true }); + assert.equal(failure?.action, registryRepairAction); + assert.doesNotMatch(failure?.action ?? "", /Activity/i); + assert.equal(registry.get(provider.id).lastTestStatus, "passed"); + assert.equal(registry.get(provider.id).lastTestCode, null); + assert.equal(registry.getDocument().activeProviderId, null); + assert.deepEqual(workerManager.calls, []); +}); + +test("committed initial selection survives a simultaneous Activity append failure", async (t) => { + const registryRepairAction = "Stop CRP, repair the residual provider-registry lock, then restart CRP."; + const { service, registry, activity, workerManager } = makeHarness(t, { + fetchImpl: async () => compatibleResponse() + }); + const provider = await service.createProvider( + providerInput(), + makeSecret("initial-selection-double-failure") + ); + const originalSetActiveIfNull = registry.setActiveIfNull.bind(registry); + registry.setActiveIfNull = (id) => { + originalSetActiveIfNull(id); + throw committedError( + "PROVIDER_REGISTRY_COMMITTED_LOCK_DEGRADED", + registryRepairAction + ); + }; + const originalAppend = activity.append.bind(activity); + activity.append = (event) => { + if (event.action === "activate") { + throw new Error("forced Activity append failure after committed initial selection"); + } + return originalAppend(event); + }; + + const failure = await service.testProvider(provider.id, "model-test", { + activateIfNone: true + }).then( + () => null, + (error) => error + ); + + assert.equal(failure?.code, "PROVIDER_ACTIVATION_COMMITTED_DEGRADED"); + assert.deepEqual(failure?.details, { committed: true, degraded: true }); + assert.equal(failure?.action, registryRepairAction); + assert.equal(registry.getDocument().activeProviderId, provider.id); + assert.equal(registry.get(provider.id).lastTestStatus, "passed"); + assert.deepEqual(workerManager.calls, []); + assert.equal(activity.events.some((event) => event.action === "activate"), false); + assert.equal(activity.events.at(-1).action, "test"); +}); + +test("opt-in tests never replace an existing active provider", async (t) => { + const { service, registry, workerManager } = makeHarness(t, { + fetchImpl: async () => compatibleResponse() + }); + const active = await service.createProvider(providerInput("Active"), makeSecret("active")); + const candidate = await service.createProvider( + providerInput("Candidate", "https://candidate.example/v1"), + makeSecret("candidate") + ); + registry.setActive(active.id); + + assert.deepEqual( + await service.testProvider(candidate.id, "model-test", { activateIfNone: true }), + { ok: true, code: null, initialActivation: null } + ); + assert.equal(registry.getDocument().activeProviderId, active.id); + assert.equal(registry.get(candidate.id).lastTestStatus, "passed"); + assert.deepEqual(workerManager.calls, []); +}); + +test("failed opt-in tests do not select a provider", async (t) => { + const { service, registry, workerManager } = makeHarness(t, { + fetchImpl: async () => ({ ok: false, status: 401 }) + }); + const provider = await service.createProvider(providerInput(), makeSecret("failed")); + + assert.deepEqual( + await service.testProvider(provider.id, "model-test", { activateIfNone: true }), + { ok: false, code: "PROVIDER_TEST_AUTH", initialActivation: null } + ); + assert.equal(registry.get(provider.id).lastTestStatus, "failed"); + assert.equal(registry.getDocument().activeProviderId, null); + assert.deepEqual(workerManager.calls, []); +}); + +test("opt-in tests reject an active-null state with a non-stopped Worker before I/O", async (t) => { + let fetchCalls = 0; + const { service, registry, credentials, workerManager } = makeHarness(t, { + fetchImpl: async () => { + fetchCalls += 1; + return compatibleResponse(); + } + }); + const provider = await service.createProvider(providerInput(), makeSecret("unsafe")); + credentials.operations.length = 0; + workerManager.phase = "running"; + workerManager.generation = 7; + + await assert.rejects( + () => service.testProvider(provider.id, "model-test", { activateIfNone: true }), + (error) => error?.code === "PROVIDER_INITIAL_ACTIVATION_UNSAFE" + && error.status === 409 + ); + assert.equal(fetchCalls, 0); + assert.deepEqual(credentials.operations, []); + assert.equal(registry.get(provider.id).lastTestStatus, "untested"); + assert.equal(registry.getDocument().activeProviderId, null); + assert.deepEqual(workerManager.calls, []); + assert.equal(workerManager.getPublicState().generation, 7); +}); + +test("activate persists then confirms increasing generations and rolls back failures", async (t) => { + const secretA = makeSecret("a"); + const secretB = makeSecret("b"); + const healthCalls = []; + const harness = makeHarness(t, { + verifyWorkerHealth: async (generation, state) => { + healthCalls.push([generation, state.generation]); + return true; + } + }); + const { service, registry, credentials, workerManager } = harness; + const providerA = await service.createProvider(providerInput("A"), secretA); + const providerB = await service.createProvider( + providerInput("B", "https://b.example/v1"), + secretB + ); + + await assert.rejects( + () => service.activate(providerA.id), + (error) => error?.code === "PROVIDER_NOT_READY" && error.status === 409 + ); + registry.markTest(providerA.id, { status: "passed" }); + const first = await service.activate(providerA.id); + assert.equal(first.activeProviderId, providerA.id); + assert.equal(first.generation, 1); + assert.equal(registry.getDocument().activeProviderId, providerA.id); + assert.equal(workerManager.calls[0][0], "start"); + assert.equal(workerManager.calls[0][1].providerId, providerA.id); + assert.equal(workerManager.calls[0][1].generation, 1); + assert.equal(workerManager.calls[0][1].settings.upstream.apiKey, secretA); + assert.deepEqual(healthCalls, [[1, 1]]); + + registry.markTest(providerB.id, { status: "passed" }); + const originalApply = workerManager.applySnapshot.bind(workerManager); + let failCandidate = true; + workerManager.applySnapshot = async (snapshot) => { + if (failCandidate) { + failCandidate = false; + workerManager.failure = Object.assign(new Error(`private worker ${secretB}`), { + code: "WORKER_HEALTH_FAILED" + }); + try { + return await originalApply(snapshot); + } finally { + workerManager.failure = null; + } + } + return await originalApply(snapshot); + }; + await assert.rejects( + () => service.activate(providerB.id), + (error) => error?.code === "PROVIDER_ACTIVATION_FAILED" + && !error.message.includes(secretB) + ); + assert.equal(registry.getDocument().activeProviderId, providerA.id); + assert.equal((await service.getStatus()).generation, 3); + + const second = await service.activate(providerB.id); + assert.equal(second.generation, 4); + assert.equal(second.activeProviderId, providerB.id); + assert.equal(workerManager.calls.at(-1)[0], "applySnapshot"); + assert.equal(workerManager.calls.at(-1)[1].providerId, providerB.id); + assert.equal(workerManager.calls.at(-1)[1].settings.upstream.apiKey, secretB); + const credentialGets = credentials.operations + .filter(([operation]) => operation === "get") + .map(([, ref]) => ref); + assert.deepEqual(credentialGets, ["credential-1", "credential-2", "credential-2"]); + const status = await service.getStatus(); + assert.equal(JSON.stringify(status).includes(secretA), false); + assert.equal(JSON.stringify(status).includes(secretB), false); + assert.equal(JSON.stringify(status).includes("credential-"), false); +}); + +test("serializes concurrent activations before credential reads and worker changes", async (t) => { + let releaseFirstStart; + let firstStartEntered; + const startEntered = new Promise((resolve) => { firstStartEntered = resolve; }); + const harness = makeHarness(t); + const { service, registry, credentials, workerManager } = harness; + const originalStart = workerManager.start.bind(workerManager); + workerManager.start = async (snapshot) => { + firstStartEntered(); + await new Promise((resolve) => { releaseFirstStart = resolve; }); + return await originalStart(snapshot); + }; + const providerA = await service.createProvider(providerInput("A"), makeSecret("a")); + const providerB = await service.createProvider( + providerInput("B", "https://b.example/v1"), + makeSecret("b") + ); + registry.markTest(providerA.id, { status: "passed" }); + registry.markTest(providerB.id, { status: "passed" }); + credentials.operations.length = 0; + + const first = service.activate(providerA.id); + const second = service.activate(providerB.id); + await startEntered; + await Promise.resolve(); + assert.deepEqual( + credentials.operations.filter(([operation]) => operation === "get"), + [["get", "credential-1"]] + ); + assert.equal(workerManager.calls.length, 0); + releaseFirstStart(); + await first; + const secondResult = await second; + + assert.equal(secondResult.activeProviderId, providerB.id); + assert.equal(secondResult.generation, 2); + assert.deepEqual(workerManager.calls.map(([operation, snapshot]) => ( + [operation, snapshot?.generation ?? null] + )), [["start", 1], ["applySnapshot", 2]]); + assert.deepEqual( + credentials.operations.filter(([operation]) => operation === "get"), + [["get", "credential-1"], ["get", "credential-2"]] + ); +}); + +test("restores the confirmed worker snapshot when post-ack health fails", async (t) => { + let failHealthGeneration = null; + const harness = makeHarness(t, { + verifyWorkerHealth: async (generation) => generation !== failHealthGeneration + }); + const { service, registry, credentials, workerManager } = harness; + const secretA = makeSecret("a"); + const secretB = makeSecret("b"); + const providerA = await service.createProvider(providerInput("A"), secretA); + const providerB = await service.createProvider( + providerInput("B", "https://b.example/v1"), + secretB + ); + registry.markTest(providerA.id, { status: "passed" }); + registry.markTest(providerB.id, { status: "passed" }); + await service.activate(providerA.id); + credentials.operations.length = 0; + failHealthGeneration = 2; + + await assert.rejects( + () => service.activate(providerB.id), + (error) => error?.code === "PROVIDER_ACTIVATION_FAILED" + ); + assert.equal(registry.getDocument().activeProviderId, providerA.id); + assert.equal((await service.getStatus()).generation, 3); + assert.equal(workerManager.generation, 3); + assert.deepEqual(workerManager.calls.slice(-2).map(([operation, snapshot]) => [ + operation, + snapshot.providerId, + snapshot.generation, + snapshot.settings.upstream.apiKey + ]), [ + ["applySnapshot", providerB.id, 2, secretB], + ["applySnapshot", providerA.id, 3, secretA] + ]); + assert.deepEqual( + credentials.operations.filter(([operation]) => operation === "get"), + [["get", "credential-2"]] + ); +}); + +test("reports degraded activation when a post-ack worker rollback cannot be confirmed", async (t) => { + let failHealthGeneration = null; + const harness = makeHarness(t, { + verifyWorkerHealth: async (generation) => generation !== failHealthGeneration + }); + const { service, registry, workerManager, activity } = harness; + const providerA = await service.createProvider(providerInput("A"), makeSecret("a")); + const providerB = await service.createProvider( + providerInput("B", "https://b.example/v1"), + makeSecret("b") + ); + registry.markTest(providerA.id, { status: "passed" }); + registry.markTest(providerB.id, { status: "passed" }); + await service.activate(providerA.id); + failHealthGeneration = 2; + const originalApply = workerManager.applySnapshot.bind(workerManager); + let applyCount = 0; + workerManager.applySnapshot = async (snapshot) => { + applyCount += 1; + if (applyCount === 2) throw new Error("private rollback failure"); + return await originalApply(snapshot); + }; + + await assert.rejects( + () => service.activate(providerB.id), + (error) => error?.code === "PROVIDER_ACTIVATION_ROLLBACK_DEGRADED" + && error.details.committed === false + && error.details.degraded === true + ); + assert.equal(registry.getDocument().activeProviderId, providerA.id); + assert.equal((await service.getStatus()).generation, 1); + assert.equal(workerManager.generation, 2); + assert.deepEqual(activity.events.at(-1), { + category: "provider", + action: "activate", + providerId: providerB.id, + result: "failed", + errorCode: "PROVIDER_ACTIVATION_ROLLBACK_DEGRADED", + details: { generation: 2 } + }); +}); + +test("reconciles committed active persistence and continues the selected worker snapshot", async (t) => { + const harness = makeHarness(t); + const { service, registry, workerManager, activity } = harness; + const providerA = await service.createProvider(providerInput("A"), makeSecret("a")); + const secretB = makeSecret("b"); + const providerB = await service.createProvider( + providerInput("B", "https://b.example/v1"), + secretB + ); + registry.markTest(providerA.id, { status: "passed" }); + registry.markTest(providerB.id, { status: "passed" }); + await service.activate(providerA.id); + const originalSetActive = registry.setActive.bind(registry); + let injected = false; + registry.setActive = (id) => { + const result = originalSetActive(id); + if (id === providerB.id && !injected) { + injected = true; + throw committedError("PROVIDER_REGISTRY_COMMITTED_LOCK_DEGRADED"); + } + return result; + }; + + await assert.rejects( + () => service.activate(providerB.id), + (error) => error?.code === "PROVIDER_ACTIVATION_COMMITTED_DEGRADED" + && error.details.committed === true + ); + assert.equal(registry.getDocument().activeProviderId, providerB.id); + assert.equal(workerManager.generation, 2); + assert.equal(workerManager.calls.at(-1)[0], "applySnapshot"); + assert.equal(workerManager.calls.at(-1)[1].settings.upstream.apiKey, secretB); + assert.equal((await service.getStatus()).generation, 2); + assert.equal(activity.events.at(-1).result, "degraded"); + assert.equal(activity.events.at(-1).errorCode, "PROVIDER_ACTIVATION_COMMITTED_DEGRADED"); +}); + +test("deterministically restores generation 3 after candidate apply commits but ACK is lost", async (t) => { + const harness = makeHarness(t); + const { service, registry, workerManager } = harness; + const secretA = makeSecret("a"); + const secretB = makeSecret("b"); + const providerA = await service.createProvider(providerInput("A"), secretA); + const providerB = await service.createProvider( + providerInput("B", "https://b.example/v1"), + secretB + ); + registry.markTest(providerA.id, { status: "passed" }); + registry.markTest(providerB.id, { status: "passed" }); + await service.activate(providerA.id); + const originalApply = workerManager.applySnapshot.bind(workerManager); + workerManager.applySnapshot = async (snapshot) => { + const state = await originalApply(snapshot); + if (snapshot.generation === 2) { + const error = new Error("private lost acknowledgement"); + error.code = "WORKER_ACK_TIMEOUT"; + throw error; + } + return state; + }; + + await assert.rejects( + () => service.activate(providerB.id), + (error) => error?.code === "PROVIDER_ACTIVATION_FAILED" + ); + assert.equal(registry.getDocument().activeProviderId, providerA.id); + assert.equal(workerManager.generation, 3); + assert.equal((await service.getStatus()).generation, 3); + assert.deepEqual(workerManager.calls.slice(-2).map(([operation, snapshot]) => [ + operation, + snapshot.generation, + snapshot.settings.upstream.apiKey + ]), [ + ["applySnapshot", 2, secretB], + ["applySnapshot", 3, secretA] + ]); +}); + +test("reports rollback degraded when first activation may commit and bounded stop fails", async (t) => { + const harness = makeHarness(t); + const { service, registry, workerManager, activity } = harness; + const provider = await service.createProvider(providerInput(), makeSecret()); + registry.markTest(provider.id, { status: "passed" }); + const originalStart = workerManager.start.bind(workerManager); + workerManager.start = async (snapshot) => { + await originalStart(snapshot); + const error = new Error("private lost first acknowledgement"); + error.code = "WORKER_ACK_TIMEOUT"; + throw error; + }; + workerManager.stop = async () => { + throw new Error("private bounded stop failure"); + }; + + await assert.rejects( + () => service.activate(provider.id), + (error) => error?.code === "PROVIDER_ACTIVATION_ROLLBACK_DEGRADED" + && error.details.degraded === true + ); + assert.equal(registry.getDocument().activeProviderId, null); + assert.equal((await service.getStatus()).generation, 0); + assert.equal(workerManager.generation, 1); + assert.equal(activity.events.at(-1).errorCode, "PROVIDER_ACTIVATION_ROLLBACK_DEGRADED"); +}); + +test("proxy lifecycle facade resolves only the active credential and advances confirmed generations", async (t) => { + const harness = makeHarness(t); + const { service, registry, credentials, workerManager, activity } = harness; + const secretA = makeSecret("active"); + const secretB = makeSecret("inactive"); + const providerA = await service.createProvider(providerInput("A"), secretA); + await service.createProvider(providerInput("B", "https://b.example/v1"), secretB); + registry.markTest(providerA.id, { status: "passed" }); + await service.activate(providerA.id); + + credentials.operations.length = 0; + workerManager.calls.length = 0; + const stopped = await service.stopProxy(); + assert.equal(stopped.phase, "stopped"); + assert.deepEqual(credentials.operations, []); + + const started = await service.startProxy(); + assert.equal(started.phase, "running"); + assert.equal(started.generation, 2); + assert.equal(workerManager.calls.at(-1)[0], "start"); + assert.equal(workerManager.calls.at(-1)[1].providerId, providerA.id); + assert.equal(workerManager.calls.at(-1)[1].settings.upstream.apiKey, secretA); + + const restarted = await service.restartProxy(); + assert.equal(restarted.phase, "running"); + assert.equal(restarted.generation, 3); + assert.equal(workerManager.calls.at(-1)[0], "restart"); + assert.equal(workerManager.calls.at(-1)[1].providerId, providerA.id); + assert.equal(workerManager.calls.at(-1)[1].settings.upstream.apiKey, secretA); + assert.deepEqual( + credentials.operations.filter(([operation]) => operation === "get").map(([, ref]) => ref), + ["credential-1", "credential-1"] + ); + + const serialized = JSON.stringify({ stopped, started, restarted, events: activity.events }); + for (const forbidden of [secretA, secretB, "credential-1", "credential-2", "apiKey"]) { + assert.equal(serialized.includes(forbidden), false, `lifecycle output leaked ${forbidden}`); + } + assert.deepEqual( + activity.events.slice(-3).map(({ category, action, result }) => ({ category, action, result })), + [ + { category: "proxy", action: "stop", result: "success" }, + { category: "proxy", action: "start", result: "success" }, + { category: "proxy", action: "restart", result: "success" } + ] + ); +}); + +test("shares each pending proxy lifecycle operation across concurrent commands", async (t) => { + for (const leader of ["start", "stop", "restart"]) { + await t.test(leader, async (t) => { + const { service, registry, credentials, workerManager, activity } = makeHarness(t); + const provider = await service.createProvider(providerInput(), makeSecret()); + registry.markTest(provider.id, { status: "passed" }); + registry.setActive(provider.id); + if (leader === "stop") { + workerManager.phase = "running"; + } + credentials.operations.length = 0; + workerManager.calls.length = 0; + activity.events.length = 0; + + const gate = createGate(); + const method = `${leader}Proxy`; + const original = workerManager[leader].bind(workerManager); + let leaderCalls = 0; + workerManager[leader] = (...args) => { + leaderCalls += 1; + return gate.promise.then(() => original(...args)); + }; + + const current = service[method](); + const concurrent = [ + service.startProxy(), + service.stopProxy(), + service.restartProxy() + ]; + await new Promise((resolvePromise) => setImmediate(resolvePromise)); + const samePromises = concurrent.map((operation) => operation === current); + gate.resolve(); + const [result, ...concurrentResults] = await Promise.all([current, ...concurrent]); + + assert.deepEqual(samePromises, [true, true, true]); + assert.ok(concurrentResults.every((candidate) => candidate === result)); + assert.equal(leaderCalls, 1); + assert.deepEqual(workerManager.calls.map(([operation]) => operation), [leader]); + assert.equal( + credentials.operations.filter(([operation]) => operation === "get").length, + leader === "stop" ? 0 : 1 + ); + assert.deepEqual( + activity.events.map(({ category, action, result }) => ({ category, action, result })), + [{ category: "proxy", action: leader, result: "success" }] + ); + assert.equal(result.generation, leader === "stop" ? 0 : 1); + + const later = service.restartProxy(); + assert.notEqual(later, current); + const laterResult = await later; + assert.equal(laterResult.generation, leader === "stop" ? 1 : 2); + assert.equal(activity.events.length, 2); + }); + } +}); + +test("proxy start and restart reject without an active provider while stop remains idempotent", async (t) => { + const { service, credentials } = makeHarness(t); + await assert.rejects( + () => service.startProxy(), + (error) => error instanceof CrpError + && error.code === "PROXY_NOT_CONFIGURED" + && error.status === 409 + ); + await assert.rejects( + () => service.restartProxy(), + (error) => error instanceof CrpError + && error.code === "PROXY_NOT_CONFIGURED" + && error.status === 409 + ); + assert.equal((await service.stopProxy()).phase, "stopped"); + assert.deepEqual(credentials.operations, []); +}); diff --git a/node/test/release-workflows.test.mjs b/node/test/release-workflows.test.mjs new file mode 100644 index 0000000..ae43bc8 --- /dev/null +++ b/node/test/release-workflows.test.mjs @@ -0,0 +1,259 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const packageName = "@cluic/codex-remote-proxy"; +const repository = "cluic/codex-remote-proxy"; + +function readWorkflowText(name) { + return readFileSync(resolve(repositoryRoot, ".github/workflows", name), "utf8") + .replace(/\r\n/g, "\n"); +} + +function extractStepBlock(workflowText, stepName) { + const lines = workflowText.split("\n"); + const marker = ` - name: ${stepName}`; + const starts = lines + .map((line, index) => line === marker ? index : -1) + .filter((index) => index !== -1); + assert.equal(starts.length, 1, `expected one active step named ${stepName}`); + const start = starts[0]; + const next = lines.findIndex((line, index) => ( + index > start && line.startsWith(" - name: ") + )); + return lines.slice(start, next === -1 ? lines.length : next).join("\n"); +} + +function extractTopLevelChildKeys(workflowText, sectionName) { + const lines = workflowText.split("\n"); + const marker = `${sectionName}:`; + const starts = lines + .map((line, index) => line === marker ? index : -1) + .filter((index) => index !== -1); + assert.equal(starts.length, 1, `expected one top-level ${sectionName} section`); + const keys = []; + for (const line of lines.slice(starts[0] + 1)) { + if (/^[A-Za-z0-9_-]+:/.test(line)) break; + const match = /^ ([A-Za-z0-9_-]+):(?:\s|$)/.exec(line); + if (match) keys.push(match[1]); + } + return keys; +} + +function extractFoldedIf(stepBlock) { + const lines = stepBlock.split("\n"); + const start = lines.indexOf(" if: >-"); + assert.notEqual(start, -1, "expected one folded if field in the active step"); + const values = []; + for (const line of lines.slice(start + 1)) { + if (!line.startsWith(" ")) break; + const value = line.slice(10); + assert.equal(value.startsWith("#"), false); + values.push(value); + } + assert.equal(values.length > 0, true); + return values.join(" "); +} + +function extractSingleLineIf(stepBlock) { + const matches = [...stepBlock.matchAll(/^ if: ([^#].*)$/gm)]; + assert.equal(matches.length, 1, "expected one active single-line if field"); + return matches[0][1]; +} + +function normalizeExpression(expression) { + return String(expression).replace(/\s+/g, " ").trim(); +} + +function makePullRequestEvent({ + headRepository = repository, + headRef = "feature/provider-ui", + author = "developer" +} = {}) { + return { + name: "pull_request", + pull_request: { + head: { repo: { full_name: headRepository }, ref: headRef }, + user: { login: author } + } + }; +} + +function isStrictChangesetsReleasePullRequest(event, currentRepository) { + return event.name === "pull_request" + && event.pull_request?.head?.repo?.full_name === currentRepository + && event.pull_request?.head?.ref?.startsWith("changeset-release/") + && event.pull_request?.user?.login === "github-actions[bot]"; +} + +function evaluateChangesetGate({ event, changedPaths, releases }) { + const exempt = isStrictChangesetsReleasePullRequest(event, repository); + const releaseImpact = changedPaths.some((path) => ( + /^node\/(?:bin|src|ui)\//.test(path) + || /^node\/package(?:-lock)?\.json$/.test(path) + )); + const changesetPathPresent = changedPaths.some((path) => ( + /^node\/\.changeset\/.*\.md$/.test(path) + && path !== "node/.changeset/README.md" + )); + const minorRelease = releases.some((release) => ( + release.name === packageName && release.type === "minor" + )); + + if (exempt) return { passes: true, exempt: true }; + if (releaseImpact && !changesetPathPresent) return { passes: false, exempt: false }; + if (changesetPathPresent && !minorRelease) return { passes: false, exempt: false }; + return { passes: true, exempt: false }; +} + +test("release workflow allows only the exact Changesets release pull request exemption", () => { + const releaseEvent = makePullRequestEvent({ + headRef: "changeset-release/main", + author: "github-actions[bot]" + }); + const consumedReleaseChanges = [ + "node/.changeset/multi-provider-local-ui.md", + "node/package.json", + "node/package-lock.json", + "node/CHANGELOG.md" + ]; + assert.deepEqual( + evaluateChangesetGate({ + event: releaseEvent, + changedPaths: consumedReleaseChanges, + releases: [] + }), + { passes: true, exempt: true } + ); + + const mismatches = [ + { ...releaseEvent, name: "push" }, + makePullRequestEvent({ + headRepository: "fork/codex-remote-proxy", + headRef: "changeset-release/main", + author: "github-actions[bot]" + }), + makePullRequestEvent({ headRef: "feature/not-release", author: "github-actions[bot]" }), + makePullRequestEvent({ headRef: "changeset-release/main", author: "developer" }) + ]; + for (const event of mismatches) { + assert.deepEqual( + evaluateChangesetGate({ event, changedPaths: consumedReleaseChanges, releases: [] }), + { passes: false, exempt: false } + ); + } +}); + +test("ordinary feature pull requests require this package's minor Changeset", () => { + const event = makePullRequestEvent(); + const sourceChange = "node/src/providers/provider-service.mjs"; + const changeset = "node/.changeset/multi-provider-local-ui.md"; + + assert.equal(evaluateChangesetGate({ + event, + changedPaths: [sourceChange, changeset], + releases: [{ name: packageName, type: "minor" }] + }).passes, true); + assert.equal(evaluateChangesetGate({ + event, + changedPaths: [sourceChange], + releases: [] + }).passes, false); + assert.equal(evaluateChangesetGate({ + event, + changedPaths: [sourceChange, changeset], + releases: [{ name: packageName, type: "patch" }] + }).passes, false); + assert.equal(evaluateChangesetGate({ + event, + changedPaths: [sourceChange, changeset], + releases: [{ name: "another-package", type: "minor" }] + }).passes, false); + assert.equal(evaluateChangesetGate({ + event, + changedPaths: [sourceChange, "node/.changeset/feature-README.md"], + releases: [{ name: packageName, type: "minor" }] + }).passes, true); + assert.equal(evaluateChangesetGate({ + event, + changedPaths: [sourceChange, "node/.changeset/README.md"], + releases: [{ name: packageName, type: "minor" }] + }).passes, false); +}); + +test("release preflight encodes the strict event-field exemption and minor gate", () => { + const workflow = readWorkflowText("release-preflight.yml"); + assert.deepEqual(extractTopLevelChildKeys(workflow, "on"), ["pull_request"]); + + const classifier = extractStepBlock(workflow, "Classify Changesets release pull request"); + const expectedExpression = normalizeExpression(` + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository && + startsWith(github.event.pull_request.head.ref, 'changeset-release/') && + github.event.pull_request.user.login == 'github-actions[bot]' + `); + assert.equal(normalizeExpression(extractFoldedIf(classifier)), expectedExpression); + assert.equal(classifier.includes("github.actor"), false); + assert.match(classifier, /^ id: release_pr$/m); + + const requireStep = extractStepBlock( + workflow, + "Require a minor Changeset for package behavior changes" + ); + assert.equal( + normalizeExpression(extractSingleLineIf(requireStep)), + "steps.release_pr.outputs.exempt != 'true' && steps.changesets.outputs.release_impact == 'true' && steps.changesets.outputs.present != 'true'" + ); + + const validateStep = extractStepBlock(workflow, "Validate minor Changeset state"); + assert.equal( + normalizeExpression(extractSingleLineIf(validateStep)), + "steps.release_pr.outputs.exempt != 'true' && steps.changesets.outputs.present == 'true'" + ); + assert.match(validateStep, /changeset -- status --since=origin\/main --output/); + assert.match(validateStep, /@cluic\/codex-remote-proxy/); + assert.match(validateStep, /type !== "minor"/); + + const detectStep = extractStepBlock( + workflow, + "Detect release-impacting and changeset files" + ); + assert.match( + detectStep, + /grep -v -E '\^node\/\\\.changeset\/README\\\.md\$'/ + ); + assert.equal(detectStep.includes("grep -v 'README.md'"), false); +}); + +test("every workflow checkout disables persisted credentials", () => { + for (const name of ["release-preflight.yml", "platform-tests.yml"]) { + const checkout = extractStepBlock(readWorkflowText(name), "Checkout"); + assert.match(checkout, /^ uses: actions\/checkout@v4$/m); + assert.match(checkout, /^ persist-credentials: false$/m); + } +}); + +test("Linux native smoke proves Secret Service and the default collection before Node", () => { + const workflow = readWorkflowText("platform-tests.yml"); + const installScript = extractStepBlock(workflow, "Install Linux Secret Service"); + const smokeScript = extractStepBlock( + workflow, + "Smoke test Linux native keyring through Secret Service" + ); + + assert.match(installScript, /libglib2\.0-bin/); + assert.match(smokeScript, /HOME="\$smoke_home" dbus-run-session/); + assert.match(smokeScript, /gdbus wait --session --timeout [0-9]+ org\.freedesktop\.secrets/); + assert.match(smokeScript, /gdbus call --session --dest org\.freedesktop\.secrets/); + assert.match(smokeScript, /\/org\/freedesktop\/secrets\/aliases\/default/); + assert.match(smokeScript, /org\.freedesktop\.Secret\.Collection Label/); + assert.equal(smokeScript.includes("gdbus") && smokeScript.includes("|| true"), false); + assert.equal( + smokeScript.lastIndexOf("org.freedesktop.Secret.Collection Label") + < smokeScript.indexOf("node scripts/native-keyring-smoke.mjs"), + true + ); +}); diff --git a/node/test/runtime-settings.test.mjs b/node/test/runtime-settings.test.mjs new file mode 100644 index 0000000..ea6c9e1 --- /dev/null +++ b/node/test/runtime-settings.test.mjs @@ -0,0 +1,154 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { RuntimeSettingsSource } from "../src/worker/runtime-settings.mjs"; + +function makeSettings(label = "a") { + return { + configPath: `/private/${label}/proxy-config.json`, + server: { + host: "127.0.0.1", + port: 15100, + logLevel: "info" + }, + upstream: { + baseUrl: `https://${label}.example.test/v1`, + apiKey: `${label}-super-secret-api-key`, + timeoutMs: 5000, + verifySsl: true, + authHeader: "x-provider-api-key", + authScheme: "Bearer", + extraHeaders: { + "x-provider-region": `${label}-region` + } + }, + proxy: { + overrideAuthorization: true, + requestIdHeader: "x-client-request-id" + }, + capture: { + enabled: false, + dbPath: `/private/${label}/traffic.sqlite3`, + ignoredPaths: ["/_proxy/health"] + } + }; +} + +test("RuntimeSettingsSource reports an allowlisted unconfigured public state", () => { + const source = new RuntimeSettingsSource(); + + assert.deepEqual(source.publicState(), { + configured: false, + generation: 0 + }); + assert.throws( + () => source.current(), + (error) => error?.code === "RUNTIME_SETTINGS_UNAVAILABLE" + ); +}); + +test("RuntimeSettingsSource accepts only strictly increasing generations", () => { + const source = new RuntimeSettingsSource(); + const settingsA = makeSettings("a"); + const settingsB = makeSettings("b"); + + source.apply({ generation: 2, settings: settingsA }); + const first = source.current(); + assert.equal(first.generation, 2); + + for (const generation of [2, 1]) { + assert.throws( + () => source.apply({ generation, settings: settingsB }), + (error) => error?.code === "STALE_SNAPSHOT" + ); + assert.strictEqual(source.current(), first); + } + + source.apply({ generation: 3, settings: settingsB }); + assert.equal(source.current().generation, 3); + assert.equal(source.current().settings.upstream.baseUrl, settingsB.upstream.baseUrl); +}); + +test("RuntimeSettingsSource rejects invalid input without replacing the current snapshot", () => { + const source = new RuntimeSettingsSource(); + source.apply({ generation: 3, settings: makeSettings("stable") }); + const stable = source.current(); + + const invalidSnapshots = [ + null, + {}, + { generation: -1, settings: makeSettings("negative") }, + { generation: 0, settings: makeSettings("zero") }, + { generation: 1.5, settings: makeSettings("fraction") }, + { generation: Number.NaN, settings: makeSettings("nan") }, + { generation: Number.POSITIVE_INFINITY, settings: makeSettings("infinity") }, + { generation: Number.MAX_SAFE_INTEGER + 1, settings: makeSettings("unsafe") }, + { generation: 4 }, + { generation: 4, settings: null }, + { generation: 4, settings: [] }, + { generation: 4, settings: { nested: () => "not cloneable" } }, + { generation: 4, settings: { nested: Number.POSITIVE_INFINITY } } + ]; + + for (const snapshot of invalidSnapshots) { + assert.throws( + () => source.apply(snapshot), + (error) => error?.code === "RUNTIME_SETTINGS_INVALID" + ); + assert.strictEqual(source.current(), stable); + } +}); + +test("RuntimeSettingsSource clones input and deeply freezes the active snapshot", () => { + const source = new RuntimeSettingsSource(); + const input = { + generation: 1, + settings: makeSettings("clone") + }; + + source.apply(input); + const active = source.current(); + + input.generation = 99; + input.settings.upstream.baseUrl = "https://mutated.example.test"; + input.settings.upstream.extraHeaders["x-provider-region"] = "mutated"; + input.settings.capture.ignoredPaths.push("/mutated"); + + assert.equal(active.generation, 1); + assert.equal(active.settings.upstream.baseUrl, "https://clone.example.test/v1"); + assert.equal(active.settings.upstream.extraHeaders["x-provider-region"], "clone-region"); + assert.deepEqual(active.settings.capture.ignoredPaths, ["/_proxy/health"]); + assert.equal(Object.isFrozen(active), true); + assert.equal(Object.isFrozen(active.settings), true); + assert.equal(Object.isFrozen(active.settings.upstream), true); + assert.equal(Object.isFrozen(active.settings.upstream.extraHeaders), true); + assert.equal(Object.isFrozen(active.settings.capture.ignoredPaths), true); + assert.throws(() => { + active.settings.upstream.extraHeaders["x-provider-region"] = "changed"; + }, TypeError); +}); + +test("RuntimeSettingsSource public state never projects settings or secret values", () => { + const source = new RuntimeSettingsSource(); + const settings = makeSettings("public-state-sentinel"); + source.apply({ generation: 7, settings }); + + const publicState = source.publicState(); + assert.deepEqual(publicState, { + configured: true, + generation: 7 + }); + assert.equal(Object.isFrozen(publicState), true); + + const serialized = JSON.stringify(publicState); + for (const forbidden of [ + "settings", + "apiKey", + "authHeader", + settings.upstream.apiKey, + settings.upstream.authHeader, + settings.upstream.extraHeaders["x-provider-region"] + ]) { + assert.equal(serialized.includes(forbidden), false, `public state leaked ${forbidden}`); + } +}); diff --git a/node/test/server.test.mjs b/node/test/server.test.mjs index 82147cf..cefe38d 100644 --- a/node/test/server.test.mjs +++ b/node/test/server.test.mjs @@ -1,13 +1,16 @@ import test from "node:test"; import assert from "node:assert/strict"; import http from "node:http"; +import https from "node:https"; +import { Readable } from "node:stream"; import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import { join } from "node:path"; -import { once } from "node:events"; +import { EventEmitter, once } from "node:events"; import { DatabaseSync } from "node:sqlite"; -import { createApp, isDirectExecution } from "../src/server.mjs"; +import { buildTargetUrl, createApp, createServer, isDirectExecution } from "../src/server.mjs"; +import { RuntimeSettingsSource } from "../src/worker/runtime-settings.mjs"; function makeTempDir(prefix) { return join(os.tmpdir(), `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`); @@ -23,6 +26,104 @@ function listen(server, host = "127.0.0.1") { }); } +async function closeServer(server) { + if (!server.listening) { + return; + } + const closed = once(server, "close"); + server.close(); + await closed; +} + +function createGate() { + let release; + const promise = new Promise((resolvePromise) => { + release = resolvePromise; + }); + return { promise, release }; +} + +function createSignal() { + let resolve; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +function makeSettings({ + baseUrl, + apiKey = "test-api-key", + authHeader = "authorization", + authScheme = "Bearer", + extraHeaders = {}, + timeoutMs = 300000, + verifySsl = true, + requestIdHeader = "x-client-request-id", + logLevel = "info", + captureEnabled = true +}) { + return { + configPath: "/tmp/crp-task5-proxy-config.json", + server: { + host: "127.0.0.1", + port: 0, + logLevel + }, + upstream: { + baseUrl, + apiKey, + timeoutMs, + verifySsl, + authHeader, + authScheme, + extraHeaders + }, + proxy: { + overrideAuthorization: true, + requestIdHeader + }, + capture: { + enabled: captureEnabled, + dbPath: "/tmp/crp-task5-traffic.sqlite3" + } + }; +} + +function createMemoryCaptureManager(publicState = {}) { + const records = []; + return { + records, + beginRecord() { + let saved = false; + return { + save(record) { + if (!saved) { + saved = true; + records.push(record); + } + } + }; + }, + getPublicState() { + return { + captureConfigured: true, + captureActive: true, + ...publicState + }; + }, + close() {} + }; +} + +async function fetchJson(url, options) { + const response = await fetch(url, options); + return { + status: response.status, + body: await response.json() + }; +} + function requestJson(url, body) { return fetch(url, { method: "POST", @@ -36,6 +137,50 @@ function requestJson(url, body) { }); } +test("buildTargetUrl joins base and request paths with one separator", () => { + const cases = [ + { + baseUrl: "https://api.example.test/", + requestUrl: "/responses?model=gpt%2F5", + expected: "https://api.example.test/responses?model=gpt%2F5" + }, + { + baseUrl: "https://api.example.test/v1", + requestUrl: "/responses", + expected: "https://api.example.test/v1/responses" + }, + { + baseUrl: "https://api.example.test/v1/", + requestUrl: "/responses", + expected: "https://api.example.test/v1/responses" + }, + { + baseUrl: "https://api.example.test/v1/", + requestUrl: "/", + expected: "https://api.example.test/v1/" + }, + { + baseUrl: "https://api.example.test/v1/", + requestUrl: "/responses/%2Fencoded?cursor=a%2Fb&space=a%20b", + expected: "https://api.example.test/v1/responses/%2Fencoded?cursor=a%2Fb&space=a%20b" + } + ]; + + for (const { baseUrl, requestUrl, expected } of cases) { + assert.equal(buildTargetUrl(baseUrl, requestUrl).href, expected); + } +}); + +test("buildTargetUrl preserves base query parameters without forwarding fragments", () => { + assert.equal( + buildTargetUrl( + "https://api.example.test/v1?tenant=one%20two#section", + "/responses?model=gpt%2F5" + ).href, + "https://api.example.test/v1/responses?tenant=one%20two&model=gpt%2F5" + ); +}); + test("server writes proxied request and response to sqlite", async () => { const dir = makeTempDir("crp-server"); mkdirSync(dir, { recursive: true }); @@ -48,6 +193,7 @@ test("server writes proxied request and response to sqlite", async () => { res.statusCode = 200; res.setHeader("content-type", "application/json"); res.setHeader("x-request-id", "upstream-test-1"); + res.setHeader("x-provider-auth", "response-upstream-secret"); res.end(JSON.stringify({ ok: true, echoed: JSON.parse(payload) })); }); }); @@ -66,7 +212,7 @@ test("server writes proxied request and response to sqlite", async () => { apiKey: "upstream-secret", timeoutMs: 300000, verifySsl: true, - authHeader: "authorization", + authHeader: "x-provider-auth", authScheme: "Bearer", extraHeaders: {} }, @@ -92,7 +238,7 @@ test("server writes proxied request and response to sqlite", async () => { apiKey: "upstream-secret", timeoutMs: 300000, verifySsl: true, - authHeader: "authorization", + authHeader: "x-provider-auth", authScheme: "Bearer", extraHeaders: {} }, @@ -129,6 +275,9 @@ test("server writes proxied request and response to sqlite", async () => { assert.equal(rows[0].thread_id, "thread-it-1"); assert.equal(rows[0].upstream_request_id, "upstream-test-1"); assert.match(rows[0].request_headers_json, /REDACTED/); + assert.doesNotMatch(rows[0].request_headers_json, /upstream-secret/); + assert.match(rows[0].response_headers_json, /REDACTED/); + assert.doesNotMatch(rows[0].response_headers_json, /response-upstream-secret/); assert.match(rows[0].response_body, /"ok":true/); rmSync(dir, { recursive: true, force: true }); @@ -152,3 +301,640 @@ test("isDirectExecution handles both POSIX and Windows paths", () => { false ); }); + +test("dynamic requests capture current settings exactly once before body listeners", () => { + const settings = makeSettings({ baseUrl: "http://127.0.0.1:9" }); + const runtime = new RuntimeSettingsSource(); + runtime.apply({ generation: 1, settings }); + const events = []; + const settingsSource = { + current() { + events.push("current"); + return runtime.current(); + }, + publicState() { + return runtime.publicState(); + } + }; + const server = createServer(settings, { + settingsSource, + captureManager: createMemoryCaptureManager(), + logFn() {} + }); + const req = new EventEmitter(); + const originalOn = req.on.bind(req); + req.on = (eventName, listener) => { + if (eventName === "data" || eventName === "end") { + events.push(`on:${eventName}`); + } + return originalOn(eventName, listener); + }; + Object.assign(req, { + url: "/responses", + method: "POST", + headers: {}, + rawHeaders: [] + }); + const res = { + statusCode: 200, + setHeader() {}, + end() {}, + on() {}, + appendHeader() {} + }; + + server.emit("request", req, res); + + assert.deepEqual(events, ["current", "on:data", "on:end"]); +}); + +test("TLS and timeout options stay pinned when settings change before the request body", async (t) => { + const settingsA = makeSettings({ + baseUrl: "https://a.example.test:4443", + timeoutMs: 1111, + verifySsl: false + }); + const settingsB = makeSettings({ + baseUrl: "https://b.example.test:5443", + timeoutMs: 2222, + verifySsl: true + }); + const runtime = new RuntimeSettingsSource(); + runtime.apply({ generation: 1, settings: settingsA }); + const captured = createSignal(); + const settingsSource = { + current() { + const active = runtime.current(); + captured.resolve(); + return active; + }, + publicState() { + return runtime.publicState(); + } + }; + const observed = {}; + const originalHttpsRequest = https.request; + t.after(() => { + https.request = originalHttpsRequest; + }); + https.request = (options, onResponse) => { + Object.assign(observed, options); + const request = new EventEmitter(); + request.setTimeout = (timeoutMs) => { + observed.timeoutMs = timeoutMs; + }; + request.destroy = (error) => request.emit("error", error); + request.end = () => { + const response = Readable.from([Buffer.from(JSON.stringify({ ok: true }))]); + response.statusCode = 200; + response.headers = { "content-type": "application/json" }; + response.rawHeaders = ["content-type", "application/json"]; + queueMicrotask(() => onResponse(response)); + }; + return request; + }; + + const proxy = createServer(settingsB, { + settingsSource, + captureManager: createMemoryCaptureManager(), + logFn() {} + }); + const proxyPort = await listen(proxy); + t.after(() => closeServer(proxy)); + + const responsePromise = new Promise((resolvePromise, rejectPromise) => { + const clientRequest = http.request({ + host: "127.0.0.1", + port: proxyPort, + path: "/responses", + method: "POST" + }, (response) => { + const chunks = []; + response.on("data", (chunk) => chunks.push(chunk)); + response.on("end", () => resolvePromise({ + status: response.statusCode, + body: JSON.parse(Buffer.concat(chunks).toString("utf8")) + })); + }); + clientRequest.on("error", rejectPromise); + clientRequest.flushHeaders(); + captured.promise.then(() => { + runtime.apply({ generation: 2, settings: settingsB }); + clientRequest.end("{}"); + }, rejectPromise); + }); + + const response = await responsePromise; + assert.deepEqual(response, { status: 200, body: { ok: true } }); + assert.equal(observed.hostname, "a.example.test"); + assert.equal(observed.port, "4443"); + assert.equal(observed.rejectUnauthorized, false); + assert.equal(observed.timeoutMs, 1111); +}); + +test("in-flight request keeps A target, credential, headers, capture, and logs while new request uses B", async (t) => { + const releaseA = createGate(); + t.after(() => releaseA.release()); + const receivedA = createSignal(); + const observedA = []; + const observedB = []; + + const upstreamA = http.createServer((req, res) => { + const chunks = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => { + observedA.push({ headers: req.headers, body: Buffer.concat(chunks).toString("utf8") }); + if (observedA.length === 1) { + receivedA.resolve(); + releaseA.promise.then(() => { + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify({ upstream: "A" })); + }); + } else { + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify({ upstream: "A-unexpected-repeat" })); + } + }); + }); + const portA = await listen(upstreamA); + t.after(async () => { + releaseA.release(); + await closeServer(upstreamA); + }); + + const upstreamB = http.createServer((req, res) => { + const chunks = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => { + observedB.push({ headers: req.headers, body: Buffer.concat(chunks).toString("utf8") }); + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify({ upstream: "B" })); + }); + }); + const portB = await listen(upstreamB); + t.after(() => closeServer(upstreamB)); + + const settingsA = makeSettings({ + baseUrl: `http://127.0.0.1:${portA}`, + apiKey: "a-api-key-sentinel", + authHeader: "x-provider-a-auth", + authScheme: "Token", + extraHeaders: { "x-snapshot-route": "A" }, + timeoutMs: 5000, + verifySsl: false, + requestIdHeader: "x-a-request-id" + }); + const settingsB = makeSettings({ + baseUrl: `http://127.0.0.1:${portB}`, + apiKey: "b-api-key-sentinel", + authHeader: "x-provider-b-auth", + authScheme: "", + extraHeaders: { "x-snapshot-route": "B" }, + timeoutMs: 1000, + verifySsl: true, + requestIdHeader: "x-b-request-id" + }); + const source = new RuntimeSettingsSource(); + source.apply({ generation: 1, settings: settingsA }); + const captureManager = createMemoryCaptureManager(); + const logs = []; + const metrics = []; + const proxy = createServer(settingsA, { + settingsSource: source, + captureManager, + recordMetric(observation) { + metrics.push(structuredClone(observation)); + }, + logFn(level, message, fields) { + logs.push({ level, message, fields }); + } + }); + const proxyPort = await listen(proxy); + t.after(async () => { + releaseA.release(); + await closeServer(proxy); + }); + + const responseAPromise = fetchJson(`http://127.0.0.1:${proxyPort}/responses?request=A`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-a-request-id": "request-a", + "x-provider-a-auth": "client-a-value" + }, + body: JSON.stringify({ request: "A", model: "model-a" }) + }); + await Promise.race([ + receivedA.promise, + responseAPromise.then((response) => { + throw new Error(`request A completed before reaching upstream A (${response.status})`); + }) + ]); + + source.apply({ generation: 2, settings: settingsB }); + const responseB = await fetchJson(`http://127.0.0.1:${proxyPort}/responses?request=B`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-b-request-id": "request-b", + "x-provider-b-auth": "client-b-value" + }, + body: JSON.stringify({ request: "B", model: "model-b" }) + }); + releaseA.release(); + const responseA = await responseAPromise; + + assert.deepEqual(responseA, { status: 200, body: { upstream: "A" } }); + assert.deepEqual(responseB, { status: 200, body: { upstream: "B" } }); + assert.equal(observedA.length, 1); + assert.equal(observedA[0].headers["x-provider-a-auth"], "Token a-api-key-sentinel"); + assert.equal(observedA[0].headers["x-snapshot-route"], "A"); + assert.equal(observedB.length, 1); + assert.equal(observedB[0].headers["x-provider-b-auth"], "b-api-key-sentinel"); + assert.equal(observedB[0].headers["x-snapshot-route"], "B"); + assert.deepEqual(captureManager.records.map((record) => new URL(record.targetUrl).host), [ + `127.0.0.1:${portB}`, + `127.0.0.1:${portA}` + ]); + assert.deepEqual( + logs.filter((entry) => entry.message === "Proxied request").map((entry) => entry.fields.request_id).sort(), + ["request-a", "request-b"] + ); + assert.deepEqual(metrics.map(({ generation, result, model, inputTokens, outputTokens }) => ({ + generation, + result, + model, + inputTokens, + outputTokens + })), [ + { + generation: 2, + result: "success", + model: "model-b", + inputTokens: null, + outputTokens: null + }, + { + generation: 1, + result: "success", + model: "model-a", + inputTokens: null, + outputTokens: null + } + ]); +}); + +test("metrics extract bounded JSON and SSE usage while screening credential-bearing model ids", async (t) => { + const secret = "metrics-active-credential-sentinel"; + const upstream = http.createServer((req, res) => { + const chunks = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => { + const payload = JSON.parse(Buffer.concat(chunks).toString("utf8")); + if (payload.stream === true) { + res.setHeader("content-type", "text/event-stream"); + res.end(`data: ${JSON.stringify({ + type: "response.completed", + response: { usage: { input_tokens: 21, output_tokens: 8 } } + })}\n\ndata: [DONE]\n\n`); + return; + } + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify({ + id: "response-private-id", + usage: { input_tokens: 13, output_tokens: 5 } + })); + }); + }); + const upstreamPort = await listen(upstream); + t.after(() => closeServer(upstream)); + + const settings = makeSettings({ + baseUrl: `http://127.0.0.1:${upstreamPort}`, + apiKey: secret, + captureEnabled: false + }); + const source = new RuntimeSettingsSource(); + source.apply({ generation: 9, settings }); + const metrics = []; + const proxy = createServer(settings, { + settingsSource: source, + captureManager: createMemoryCaptureManager(), + recordMetric(observation) { + metrics.push(structuredClone(observation)); + }, + logFn() {} + }); + const proxyPort = await listen(proxy); + t.after(() => closeServer(proxy)); + + const jsonResponse = await fetch(`http://127.0.0.1:${proxyPort}/responses`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "model-json", stream: false }) + }); + assert.equal(jsonResponse.status, 200); + await jsonResponse.text(); + const streamResponse = await fetch(`http://127.0.0.1:${proxyPort}/responses`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: `prefix-${secret}-suffix`, stream: true }) + }); + assert.equal(streamResponse.status, 200); + await streamResponse.text(); + + assert.equal(metrics.length, 2); + assert.deepEqual(metrics.map(({ generation, result, model, inputTokens, outputTokens }) => ({ + generation, + result, + model, + inputTokens, + outputTokens + })), [ + { + generation: 9, + result: "success", + model: "model-json", + inputTokens: 13, + outputTokens: 5 + }, + { + generation: 9, + result: "success", + model: null, + inputTokens: 21, + outputTokens: 8 + } + ]); + const serialized = JSON.stringify(metrics); + assert.equal(serialized.includes(secret), false); + assert.equal(serialized.includes("response-private-id"), false); + assert.equal(serialized.includes("url"), false); + assert.equal(serialized.includes("headers"), false); + assert.equal(serialized.includes("body"), false); +}); + +test("metrics response-start latency begins at the first response body byte", async (t) => { + const releaseBody = createGate(); + const headersSent = createSignal(); + t.after(() => releaseBody.release()); + const upstream = http.createServer((req, res) => { + req.resume(); + req.on("end", () => { + if (req.url === "/bodyless") { + res.writeHead(204); + res.end(); + return; + } + res.setHeader("content-type", "application/json"); + res.flushHeaders(); + headersSent.resolve(); + releaseBody.promise.then(() => res.end(JSON.stringify({ ok: true }))); + }); + }); + const upstreamPort = await listen(upstream); + t.after(() => closeServer(upstream)); + + const settings = makeSettings({ + baseUrl: `http://127.0.0.1:${upstreamPort}`, + captureEnabled: false + }); + const source = new RuntimeSettingsSource(); + source.apply({ generation: 3, settings }); + const metricSignals = [createSignal(), createSignal()]; + const metrics = []; + let metricNowMs = 1_000; + const proxy = createServer(settings, { + settingsSource: source, + captureManager: createMemoryCaptureManager(), + metricNow: () => metricNowMs, + recordMetric(observation) { + const index = metrics.length; + const clone = structuredClone(observation); + metrics.push(clone); + metricSignals[index]?.resolve(clone); + }, + logFn() {} + }); + const proxyPort = await listen(proxy); + t.after(() => closeServer(proxy)); + + const bodyResponsePromise = fetch(`http://127.0.0.1:${proxyPort}/delayed-body`, { + method: "POST", + body: "{}" + }); + await headersSent.promise; + await new Promise((resolvePromise) => setTimeout(resolvePromise, 25)); + assert.equal(metrics.length, 0); + metricNowMs = 1_301; + releaseBody.release(); + const bodyResponse = await bodyResponsePromise; + assert.equal(bodyResponse.status, 200); + assert.deepEqual(await bodyResponse.json(), { ok: true }); + const bodyMetric = await metricSignals[0].promise; + assert.equal(bodyMetric.responseStartBin, 3); + + metricNowMs = 2_000; + const bodylessResponse = await fetch(`http://127.0.0.1:${proxyPort}/bodyless`, { + method: "POST", + body: "{}" + }); + assert.equal(bodylessResponse.status, 204); + await bodylessResponse.arrayBuffer(); + const bodylessMetric = await metricSignals[1].promise; + assert.equal(bodylessMetric.responseStartBin, null); +}); + +test("an in-flight A request retains its longer timeout after B is applied", async (t) => { + const releaseA = createGate(); + t.after(() => releaseA.release()); + const receivedA = createSignal(); + + const upstreamA = http.createServer((req, res) => { + req.resume(); + req.on("end", () => { + receivedA.resolve(); + releaseA.promise.then(() => res.end(JSON.stringify({ upstream: "A" }))); + }); + }); + const portA = await listen(upstreamA); + t.after(async () => { + releaseA.release(); + await closeServer(upstreamA); + }); + + const upstreamB = http.createServer((req) => { + req.resume(); + }); + const portB = await listen(upstreamB); + t.after(() => closeServer(upstreamB)); + + const settingsA = makeSettings({ baseUrl: `http://127.0.0.1:${portA}`, timeoutMs: 5000 }); + const settingsB = makeSettings({ baseUrl: `http://127.0.0.1:${portB}`, timeoutMs: 75 }); + const source = new RuntimeSettingsSource(); + source.apply({ generation: 1, settings: settingsA }); + const metrics = []; + const proxy = createServer(settingsB, { + settingsSource: source, + captureManager: createMemoryCaptureManager(), + recordMetric(observation) { + metrics.push(structuredClone(observation)); + }, + logFn() {} + }); + const proxyPort = await listen(proxy); + t.after(async () => { + releaseA.release(); + await closeServer(proxy); + }); + + const responseAPromise = fetchJson(`http://127.0.0.1:${proxyPort}/responses`, { + method: "POST", + body: "A" + }); + await Promise.race([ + receivedA.promise, + responseAPromise.then((response) => { + throw new Error(`request A completed before reaching upstream A (${response.status})`); + }) + ]); + source.apply({ generation: 2, settings: settingsB }); + + const responseB = await fetchJson(`http://127.0.0.1:${proxyPort}/responses`, { + method: "POST", + body: "B" + }); + assert.equal(responseB.status, 504); + assert.equal(responseB.body.error.type, "proxy_timeout"); + + releaseA.release(); + const responseA = await responseAPromise; + assert.deepEqual(responseA, { status: 200, body: { upstream: "A" } }); + assert.deepEqual(metrics.map(({ generation, result }) => ({ generation, result })), [ + { generation: 2, result: "timeout" }, + { generation: 1, result: "success" } + ]); +}); + +test("dynamic health is allowlisted and an unconfigured source never falls back to static settings", async (t) => { + let staticUpstreamHits = 0; + const staticUpstream = http.createServer((req, res) => { + staticUpstreamHits += 1; + req.resume(); + req.on("end", () => res.end(JSON.stringify({ unexpected: true }))); + }); + const upstreamPort = await listen(staticUpstream); + t.after(() => closeServer(staticUpstream)); + + const staticSettings = makeSettings({ + baseUrl: `http://127.0.0.1:${upstreamPort}`, + apiKey: "static-health-secret", + authHeader: "x-static-health-auth", + extraHeaders: { "x-static-health-extra": "static-extra-secret" } + }); + const source = new RuntimeSettingsSource(); + const captureManager = createMemoryCaptureManager({ failedWriteCount: 0 }); + const proxy = createServer(staticSettings, { settingsSource: source, captureManager, logFn() {} }); + const proxyPort = await listen(proxy); + t.after(() => closeServer(proxy)); + + const health = await fetchJson(`http://127.0.0.1:${proxyPort}/_proxy/health`); + assert.equal(health.status, 200); + assert.deepEqual(health.body, { + ok: true, + configured: false, + generation: 0, + captureConfigured: true, + captureActive: true, + failedWriteCount: 0 + }); + + const serializedHealth = JSON.stringify(health.body); + for (const forbidden of [ + "settings", + staticSettings.upstream.apiKey, + staticSettings.upstream.authHeader, + staticSettings.upstream.extraHeaders["x-static-health-extra"] + ]) { + assert.equal(serializedHealth.includes(forbidden), false, `health leaked ${forbidden}`); + } + + const unavailable = await fetchJson(`http://127.0.0.1:${proxyPort}/responses`, { + method: "POST", + body: "{}" + }); + assert.equal(unavailable.status, 503); + assert.equal(unavailable.body.error.code, "RUNTIME_SETTINGS_UNAVAILABLE"); + assert.equal(staticUpstreamHits, 0); +}); + +test("debug and startup logs mask short keys and the active custom auth header", async (t) => { + let observedAuthHeader = null; + const upstream = http.createServer((req, res) => { + observedAuthHeader = req.headers["x-provider-auth"] ?? null; + req.resume(); + req.on("end", () => { + res.setHeader("x-provider-auth", "response-custom-auth-secret"); + res.setHeader("set-cookie", "session-cookie-secret"); + res.setHeader("x-api-key", "tiny"); + res.setHeader("x-diagnostic", "trace-visible"); + res.end(JSON.stringify({ ok: true })); + }); + }); + const upstreamPort = await listen(upstream); + t.after(() => closeServer(upstream)); + + const staticSettings = makeSettings({ + baseUrl: `http://127.0.0.1:${upstreamPort}`, + apiKey: "static-api-key-sentinel", + logLevel: "debug" + }); + const activeSettings = makeSettings({ + baseUrl: `http://127.0.0.1:${upstreamPort}`, + apiKey: "k3y", + authHeader: "x-provider-auth", + authScheme: "", + logLevel: "debug" + }); + const source = new RuntimeSettingsSource(); + source.apply({ generation: 1, settings: activeSettings }); + const lines = []; + const originalConsoleLog = console.log; + console.log = (...args) => lines.push(args.join(" ")); + t.after(() => { + console.log = originalConsoleLog; + }); + + let app; + try { + app = createApp(staticSettings, { settingsSource: source }); + const proxyPort = await listen(app.server); + const response = await fetch(`http://127.0.0.1:${proxyPort}/responses`, { + method: "POST", + headers: { + "x-provider-auth": "client-custom-auth-sentinel" + }, + body: "{}" + }); + assert.equal(response.status, 200); + await response.text(); + } finally { + if (app) { + await closeServer(app.server); + app.captureManager.close(); + } + console.log = originalConsoleLog; + } + + const output = lines.join("\n"); + assert.match(output, /DEBUG \[REQUEST\]/); + assert.equal(output.includes("k3y"), false); + assert.equal(output.includes("client-custom-auth-sentinel"), false); + assert.equal(output.includes("response-custom-auth-secret"), false); + assert.equal(output.includes("session-cookie-secret"), false); + assert.equal(output.includes('"x-api-key": "tiny"'), false); + assert.match(output, /"x-api-key": "\[REDACTED\]"/); + assert.match(output, /"x-diagnostic": "trace-visible"/); + assert.equal(output.includes(JSON.stringify(source.current())), false); + assert.equal(observedAuthHeader, "k3y"); +}); diff --git a/node/test/session-auth.test.mjs b/node/test/session-auth.test.mjs new file mode 100644 index 0000000..0a1a8f8 --- /dev/null +++ b/node/test/session-auth.test.mjs @@ -0,0 +1,295 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + writeFileSync +} from "node:fs"; +import os from "node:os"; +import { join } from "node:path"; + +import { SessionAuth } from "../src/supervisor/session-auth.mjs"; +import { CrpError, toPublicError } from "../src/shared/errors.mjs"; + +const CONTROL_BYTES = Buffer.alloc(32, 0x11); +const SESSION_BYTES = Buffer.alloc(32, 0x22); +const CSRF_BYTES = Buffer.alloc(32, 0x33); +const RESUMED_SESSION_BYTES = Buffer.alloc(32, 0x44); +const RESUMED_CSRF_BYTES = Buffer.alloc(32, 0x55); +const CONTROL_TOKEN = CONTROL_BYTES.toString("base64url"); +const SESSION_TOKEN = SESSION_BYTES.toString("base64url"); +const CSRF_TOKEN = CSRF_BYTES.toString("base64url"); +const RESUMED_SESSION_TOKEN = RESUMED_SESSION_BYTES.toString("base64url"); +const RESUMED_CSRF_TOKEN = RESUMED_CSRF_BYTES.toString("base64url"); +const START_MS = Date.parse("2026-07-13T00:00:00.000Z"); + +function makeTempAuth(t, prefix = "crp-session-auth-") { + const dir = mkdtempSync(join(os.tmpdir(), prefix)); + const tokenPath = join(dir, "control-token"); + t.after(() => rmSync(dir, { recursive: true, force: true })); + return { dir, tokenPath }; +} + +function sequenceRandomBytes(...values) { + let index = 0; + return (size) => { + assert.equal(size, 32); + const value = values[index++]; + assert.ok(value, `unexpected randomBytes call ${index}`); + return Buffer.from(value); + }; +} + +function assertCrpError(code, status) { + return (error) => { + assert.ok(error instanceof CrpError); + assert.equal(error.code, code); + assert.equal(error.status, status); + assert.equal(typeof error.action, "string"); + assert.notEqual(error.action.length, 0); + return true; + }; +} + +test("creates one private 32-byte control token and reuses it after restart", (t) => { + const { tokenPath } = makeTempAuth(t); + const first = new SessionAuth({ + controlTokenPath: tokenPath, + randomBytes: sequenceRandomBytes(CONTROL_BYTES), + now: () => START_MS + }); + + assert.equal(readFileSync(tokenPath, "utf8"), `${CONTROL_TOKEN}\n`); + if (process.platform !== "win32") { + assert.equal(statSync(tokenPath).mode & 0o777, 0o600); + assert.equal(statSync(join(tokenPath, "..")).mode & 0o777, 0o700); + } + assert.deepEqual( + first.authorize({ authorization: `Bearer ${CONTROL_TOKEN}`, mutation: true }), + { kind: "cli" } + ); + + const restarted = new SessionAuth({ + controlTokenPath: tokenPath, + randomBytes: () => assert.fail("an existing control token must be reused"), + now: () => START_MS + }); + assert.deepEqual( + restarted.authorize({ authorization: `Bearer ${CONTROL_TOKEN}`, mutation: true }), + { kind: "cli" } + ); +}); + +test("exchanges bearer auth for an HttpOnly strict browser session and enforces CSRF", (t) => { + const { tokenPath } = makeTempAuth(t); + const auth = new SessionAuth({ + controlTokenPath: tokenPath, + randomBytes: sequenceRandomBytes(CONTROL_BYTES, SESSION_BYTES, CSRF_BYTES), + now: () => START_MS, + sessionTtlMs: 60_000 + }); + + const session = auth.createBrowserSession(`Bearer ${CONTROL_TOKEN}`); + assert.equal(session.csrfToken, CSRF_TOKEN); + assert.equal(session.expiresAt, "2026-07-13T00:01:00.000Z"); + assert.match(session.setCookie, new RegExp(`^crp_session=${SESSION_TOKEN};`)); + assert.match(session.setCookie, /Path=\//); + assert.match(session.setCookie, /HttpOnly/); + assert.match(session.setCookie, /SameSite=Strict/); + assert.match(session.setCookie, /Max-Age=60/); + assert.doesNotMatch(session.setCookie, /Secure/); + + const cookie = `unrelated=value; crp_session=${SESSION_TOKEN}`; + assert.deepEqual(auth.authorize({ cookie, mutation: false }), { kind: "browser" }); + assert.deepEqual( + auth.authorize({ cookie, csrfToken: CSRF_TOKEN, mutation: true }), + { kind: "browser" } + ); + assert.throws( + () => auth.authorize({ cookie, mutation: true }), + assertCrpError("AUTH_CSRF_INVALID", 403) + ); + assert.throws( + () => auth.authorize({ cookie, csrfToken: `${CSRF_TOKEN}x`, mutation: true }), + assertCrpError("AUTH_CSRF_INVALID", 403) + ); +}); + +test("expired browser sessions are rejected and request cookie clearing", (t) => { + const { tokenPath } = makeTempAuth(t); + let now = START_MS; + const auth = new SessionAuth({ + controlTokenPath: tokenPath, + randomBytes: sequenceRandomBytes(CONTROL_BYTES, SESSION_BYTES, CSRF_BYTES), + now: () => now, + sessionTtlMs: 1_000 + }); + const session = auth.createBrowserSession(`Bearer ${CONTROL_TOKEN}`); + + now += 1_001; + let caught; + try { + auth.authorize({ cookie: `crp_session=${SESSION_TOKEN}`, mutation: false }); + } catch (error) { + caught = error; + } + assertCrpError("AUTH_SESSION_EXPIRED", 401)(caught); + assert.equal(caught.clearCookie, true); + assert.equal(auth.clearCookie(), "crp_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0"); + assert.equal(JSON.stringify(toPublicError(caught, "request-1")).includes(SESSION_TOKEN), false); + assert.equal(JSON.stringify(toPublicError(caught, "request-1")).includes(CSRF_TOKEN), false); + assert.equal(session.csrfToken, CSRF_TOKEN); +}); + +test("explicit browser resume rotates session and CSRF without extending absolute expiry", (t) => { + const { tokenPath } = makeTempAuth(t); + let now = START_MS; + const auth = new SessionAuth({ + controlTokenPath: tokenPath, + randomBytes: sequenceRandomBytes( + CONTROL_BYTES, + SESSION_BYTES, + CSRF_BYTES, + RESUMED_SESSION_BYTES, + RESUMED_CSRF_BYTES + ), + now: () => now, + sessionTtlMs: 60_000 + }); + const original = auth.createBrowserSession(`Bearer ${CONTROL_TOKEN}`); + now += 20_500; + + const resumed = auth.resumeBrowserSession({ cookie: `crp_session=${SESSION_TOKEN}` }); + assert.equal(resumed.csrfToken, RESUMED_CSRF_TOKEN); + assert.equal(resumed.expiresAt, original.expiresAt); + assert.match(resumed.setCookie, new RegExp(`^crp_session=${RESUMED_SESSION_TOKEN};`)); + assert.match(resumed.setCookie, /Max-Age=40$/); + assert.throws( + () => auth.authorize({ cookie: `crp_session=${SESSION_TOKEN}`, mutation: false }), + assertCrpError("AUTH_REQUIRED", 401) + ); + assert.throws( + () => auth.authorize({ + cookie: `crp_session=${RESUMED_SESSION_TOKEN}`, + csrfToken: CSRF_TOKEN, + mutation: true + }), + assertCrpError("AUTH_CSRF_INVALID", 403) + ); + assert.deepEqual(auth.authorize({ + cookie: `crp_session=${RESUMED_SESSION_TOKEN}`, + csrfToken: RESUMED_CSRF_TOKEN, + mutation: true + }), { kind: "browser" }); + assert.throws( + () => auth.resumeBrowserSession({ + cookie: `crp_session=${RESUMED_SESSION_TOKEN}`, + authorization: `Bearer ${CONTROL_TOKEN}` + }), + assertCrpError("AUTH_REQUIRED", 401) + ); +}); + +test("browser resume rejects missing, duplicate, and expired cookies", (t) => { + const { tokenPath } = makeTempAuth(t); + let now = START_MS; + const auth = new SessionAuth({ + controlTokenPath: tokenPath, + randomBytes: sequenceRandomBytes(CONTROL_BYTES, SESSION_BYTES, CSRF_BYTES), + now: () => now, + sessionTtlMs: 1_000 + }); + auth.createBrowserSession(`Bearer ${CONTROL_TOKEN}`); + + for (const cookie of [undefined, "unrelated=value", `crp_session=${SESSION_TOKEN}; crp_session=${SESSION_TOKEN}`]) { + assert.throws( + () => auth.resumeBrowserSession({ cookie }), + assertCrpError("AUTH_REQUIRED", 401) + ); + } + now += 1_001; + let expired; + try { + auth.resumeBrowserSession({ cookie: `crp_session=${SESSION_TOKEN}` }); + } catch (error) { + expired = error; + } + assertCrpError("AUTH_SESSION_EXPIRED", 401)(expired); + assert.equal(expired.clearCookie, true); +}); + +test("rejects invalid bearer, missing or duplicate cookies, and destroys sessions on close", (t) => { + const { tokenPath } = makeTempAuth(t); + const auth = new SessionAuth({ + controlTokenPath: tokenPath, + randomBytes: sequenceRandomBytes(CONTROL_BYTES, SESSION_BYTES, CSRF_BYTES), + now: () => START_MS + }); + auth.createBrowserSession(`Bearer ${CONTROL_TOKEN}`); + + for (const authorization of [ + CONTROL_TOKEN, + "Basic invalid", + "Bearer", + `Bearer ${CONTROL_TOKEN}x` + ]) { + assert.throws( + () => auth.authorize({ authorization, mutation: false }), + assertCrpError("AUTH_REQUIRED", 401) + ); + } + for (const cookie of [ + undefined, + "unrelated=value", + `crp_session=${SESSION_TOKEN}; crp_session=${SESSION_TOKEN}`, + `crp_session=${SESSION_TOKEN}x` + ]) { + assert.throws( + () => auth.authorize({ cookie, mutation: false }), + assertCrpError("AUTH_REQUIRED", 401) + ); + } + + auth.close(); + assert.throws( + () => auth.authorize({ cookie: `crp_session=${SESSION_TOKEN}`, mutation: false }), + assertCrpError("AUTH_REQUIRED", 401) + ); +}); + +test("rejects malformed, permissive, and symbolic-link control token files safely", (t) => { + const secrets = ["short-control-secret", CONTROL_TOKEN]; + for (const [label, prepare] of [ + ["malformed", ({ tokenPath }) => writeFileSync(tokenPath, `${secrets[0]}\n`, { mode: 0o600 })], + ["permissive", ({ tokenPath }) => { + writeFileSync(tokenPath, `${CONTROL_TOKEN}\n`, { mode: 0o600 }); + chmodSync(tokenPath, 0o644); + }], + ["symlink", ({ dir, tokenPath }) => { + const target = join(dir, "target-token"); + writeFileSync(target, `${CONTROL_TOKEN}\n`, { mode: 0o600 }); + symlinkSync(target, tokenPath); + }] + ]) { + if (label === "permissive" && process.platform === "win32") continue; + if (label === "symlink" && process.platform === "win32") continue; + const paths = makeTempAuth(t, `crp-session-${label}-`); + mkdirSync(paths.dir, { recursive: true, mode: 0o700 }); + prepare(paths); + let caught; + try { + new SessionAuth({ controlTokenPath: paths.tokenPath }); + } catch (error) { + caught = error; + } + assertCrpError("AUTH_CONTROL_TOKEN_INVALID", 500)(caught); + const serialized = JSON.stringify(toPublicError(caught, "request-1")); + for (const secret of secrets) assert.equal(serialized.includes(secret), false); + assert.equal(serialized.includes(paths.tokenPath), false); + } +}); diff --git a/node/test/worker-manager.test.mjs b/node/test/worker-manager.test.mjs new file mode 100644 index 0000000..febce74 --- /dev/null +++ b/node/test/worker-manager.test.mjs @@ -0,0 +1,1030 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; + +import { WorkerManager } from "../src/supervisor/worker-manager.mjs"; + +const SECRET = "manager-unit-secret"; + +function makeSnapshot(generation = 1, port = 15100, providerId = null) { + return { + ...(providerId === null ? {} : { providerId }), + generation, + settings: { + configPath: "/tmp/crp-worker-manager/proxy-config.json", + server: { host: "127.0.0.1", port, logLevel: "info" }, + upstream: { + baseUrl: "http://127.0.0.1:41001", + apiKey: SECRET, + timeoutMs: 5000, + verifySsl: true, + authHeader: "x-provider-auth", + authScheme: "Bearer", + extraHeaders: {} + }, + proxy: { overrideAuthorization: true, requestIdHeader: "x-client-request-id" }, + capture: { enabled: false, dbPath: "/tmp/crp-worker-manager/traffic.sqlite3" } + } + }; +} + +function state(overrides = {}) { + return { + phase: "running", + configured: true, + generation: 1, + listening: true, + listenHost: "127.0.0.1", + listenPort: 15100, + inFlight: 0, + ...overrides + }; +} + +function childMessage(type, requestId, overrides = {}) { + return { version: 1, type, requestId, state: state(overrides) }; +} + +function createClock() { + let current = 1_000; + let sequence = 0; + const timers = new Map(); + return { + now: () => current, + setTimeout(callback, delay) { + const id = ++sequence; + timers.set(id, { id, at: current + delay, callback }); + return id; + }, + clearTimeout(id) { + timers.delete(id); + }, + nextDelay() { + if (timers.size === 0) return null; + return Math.min(...[...timers.values()].map((timer) => timer.at - current)); + }, + async advance(delay) { + current += delay; + const due = [...timers.values()] + .filter((timer) => timer.at <= current) + .sort((left, right) => left.at - right.at || left.id - right.id); + for (const timer of due) { + timers.delete(timer.id); + timer.callback(); + await Promise.resolve(); + } + }, + pending: () => timers.size + }; +} + +let nextPid = 9_000; + +class FakeChild extends EventEmitter { + constructor(script = {}) { + super(); + this.pid = ++nextPid; + this.connected = true; + this.exitCode = null; + this.signalCode = null; + this.sent = []; + this.killed = []; + this.script = script; + this.sendCounts = new Map(); + queueMicrotask(() => { + if (script.ready !== false) { + this.emit("message", childMessage("ready", "worker-ready", { + phase: "ready", + configured: false, + generation: 0, + listening: false, + listenHost: null, + listenPort: null + })); + } + }); + } + + send(message, callback) { + this.sent.push(structuredClone(message)); + const sendCount = (this.sendCounts.get(message.type) ?? 0) + 1; + this.sendCounts.set(message.type, sendCount); + queueMicrotask(() => { + if (this.script.failSend?.[message.type]?.includes(sendCount)) { + callback?.(new Error("sensitive send failure must not pass")); + return; + } + if (message.type === "configure" + && this.script.ackBeforeCallback + && this.script.configure !== false) { + const requestId = this.script.configureRequestId ?? message.requestId; + this.emit("message", childMessage("configured", requestId, { + generation: message.generation, + listenPort: message.settings.server.port + })); + callback?.(null); + return; + } + callback?.(null); + if (message.type === "configure" && this.script.configure !== false) { + const requestId = this.script.configureRequestId ?? message.requestId; + this.emit("message", childMessage("configured", requestId, { + generation: message.generation, + listenPort: message.settings.server.port + })); + } + if (message.type === "drain" && this.script.drain !== false) { + this.emit("message", childMessage("drained", message.requestId, { + phase: "drained", + generation: this.script.generation ?? 1, + listening: false, + listenHost: null, + listenPort: null + })); + } + if (message.type === "shutdown" && this.script.shutdown !== false) { + this.exit(0, null); + } + }); + } + + kill(signal = "SIGTERM") { + this.killed.push(signal); + if (signal === "SIGTERM" && this.script.ignoreTerm) return true; + if (signal === "SIGKILL" && this.script.ignoreKill) return true; + queueMicrotask(() => this.exit(null, signal)); + return true; + } + + disconnect() { + this.connected = false; + } + + exit(code = 1, signal = null) { + if (this.exitCode !== null || this.signalCode !== null) return; + this.connected = false; + this.exitCode = code; + this.signalCode = signal; + this.emit("exit", code, signal); + } +} + +function createHarness(scripts = [], { + healthOk = true, + forkError = false, + portError = false, + useDefaultFork = false, + runRecoveryWhenReady = (operation) => operation(), + recordMetric, + noteDroppedMetric +} = {}) { + const clock = createClock(); + const children = []; + const healthCalls = []; + const portChecks = []; + const forkCalls = []; + const metrics = []; + let droppedMetrics = 0; + const forkWorker = () => { + if (forkError) throw new Error("sensitive fork cause must not pass"); + const child = new FakeChild(scripts[children.length] ?? {}); + children.push(child); + return child; + }; + const forkDependency = useDefaultFork ? { + forkImpl(entryPath, args, options) { + forkCalls.push({ + entryPath, + args: structuredClone(args), + options: structuredClone(options) + }); + return forkWorker(); + } + } : { forkWorker }; + const manager = new WorkerManager({ + host: "127.0.0.1", + port: 15100, + clock, + readyTimeoutMs: 100, + ackTimeoutMs: 100, + healthTimeoutMs: 100, + terminateTimeoutMs: 100, + killTimeoutMs: 100, + ...forkDependency, + async fetchImpl(url) { + healthCalls.push(url); + const generation = children.at(-1)?.sent.findLast((message) => message.type === "configure")?.generation; + return { ok: healthOk, json: async () => ({ configured: healthOk, generation }) }; + }, + async waitForPortFree(host, port) { + portChecks.push({ host, port }); + if (portError) { + const error = new Error("sensitive port probe cause must not pass"); + error.code = "WORKER_PORT_BUSY"; + throw error; + } + }, + runRecoveryWhenReady, + recordMetric: recordMetric ?? ((observation) => { + metrics.push(structuredClone(observation)); + return true; + }), + noteDroppedMetric: noteDroppedMetric ?? (() => { + droppedMetrics += 1; + }) + }); + return { + manager, + clock, + children, + forkCalls, + healthCalls, + portChecks, + metrics, + get droppedMetrics() { + return droppedMetrics; + } + }; +} + +async function settle(promise, clock, { maxSteps = 30 } = {}) { + let settled = false; + let result; + let failure; + promise.then((value) => { + settled = true; + result = value; + }, (error) => { + settled = true; + failure = error; + }); + for (let step = 0; step < maxSteps && !settled; step += 1) { + for (let turn = 0; turn < 12 && !settled; turn += 1) { + await Promise.resolve(); + } + if (!settled && clock.nextDelay() !== null) { + await clock.advance(clock.nextDelay()); + } + } + if (!settled) throw new Error("Test promise did not settle."); + if (failure) throw failure; + return result; +} + +async function flushUntil(predicate, description, maxTurns = 40) { + for (let turn = 0; turn < maxTurns; turn += 1) { + if (predicate()) return; + await Promise.resolve(); + } + throw new Error(`Timed out waiting for ${description}`); +} + +function collectUnhandledRejections(t) { + const reasons = []; + const listener = (reason) => reasons.push(reason); + process.on("unhandledRejection", listener); + t.after(() => process.off("unhandledRejection", listener)); + return reasons; +} + +test("start waits for ready, correlated configure, and matching health before running", async (t) => { + const harness = createHarness(); + t.after(() => harness.manager.close()); + + assert.equal(harness.manager.getPublicState().phase, "stopped"); + const started = await settle(harness.manager.start(makeSnapshot()), harness.clock); + + assert.equal(started.phase, "running"); + assert.equal(started.pid, harness.children[0].pid); + assert.equal(started.generation, 1); + assert.equal(started.state.phase, "running"); + assert.equal(started.restartCount, 0); + assert.equal(typeof started.startedAt, "string"); + assert.deepEqual(Object.keys(started).sort(), [ + "error", "generation", "phase", "pid", "restartCount", "startedAt", "state" + ]); + assert.equal(JSON.stringify(started).includes(SECRET), false); + assert.equal(harness.healthCalls.length, 1); + assert.equal(harness.children[0].sent[0].type, "configure"); + assert.equal(harness.children[0].sent[0].settings.upstream.apiKey, SECRET); +}); + +test("default worker forks stay hidden across start, restart, and crash recovery", async (t) => { + const harness = createHarness([], { useDefaultFork: true }); + t.after(() => harness.manager.close()); + + await settle(harness.manager.start(makeSnapshot()), harness.clock); + await settle(harness.manager.restart(makeSnapshot(2)), harness.clock); + harness.children[1].exit(1, null); + await flushUntil( + () => harness.manager.getPublicState().phase === "backoff", + "hidden worker recovery backoff" + ); + await harness.clock.advance(250); + await flushUntil( + () => harness.manager.getPublicState().phase === "running", + "hidden worker recovery" + ); + + assert.equal(harness.forkCalls.length, 3); + for (const call of harness.forkCalls) { + assert.match(call.entryPath, /worker-entry\.mjs$/); + assert.deepEqual(call.args, []); + assert.deepEqual(call.options, { + execPath: process.execPath, + stdio: ["ignore", "ignore", "ignore", "ipc"], + windowsHide: true + }); + } +}); + +test("applySnapshot updates the confirmed generation only after a matching acknowledgement", async (t) => { + const harness = createHarness(); + t.after(() => harness.manager.close()); + await settle(harness.manager.start(makeSnapshot()), harness.clock); + + const applied = await settle(harness.manager.applySnapshot(makeSnapshot(2)), harness.clock); + assert.equal(applied.generation, 2); + assert.equal(applied.state.generation, 2); + + harness.children[0].script.configureRequestId = "wrong-request"; + await assert.rejects( + settle(harness.manager.applySnapshot(makeSnapshot(3)), harness.clock), + (error) => error?.code === "WORKER_ACK_TIMEOUT" && !error.message.includes(SECRET) + ); + assert.equal(harness.manager.getPublicState().generation, 2); + assert.equal(harness.manager.getPublicState().state.generation, 2); +}); + +test("metric observations retain request-generation provider attribution across hot switching", async (t) => { + const harness = createHarness(); + t.after(() => harness.manager.close()); + await settle(harness.manager.start(makeSnapshot(1, 15100, "provider-a")), harness.clock); + await settle(harness.manager.applySnapshot(makeSnapshot(2, 15100, "provider-b")), harness.clock); + const child = harness.children[0]; + const metric = (generation, result, model) => ({ + version: 1, + type: "metric", + requestId: "metric-observation", + observation: { + generation, + result, + model, + inputTokens: result === "success" ? 12 : null, + outputTokens: result === "success" ? 3 : null, + durationBin: 4, + responseStartBin: result === "success" ? 2 : null + } + }); + + child.emit("message", metric(2, "success", "model-b")); + child.emit("message", metric(1, "upstreamError", "model-a")); + child.emit("message", metric(999, "networkError", null)); + await Promise.resolve(); + + assert.deepEqual(harness.metrics, [ + { + providerId: "provider-b", + result: "success", + model: "model-b", + inputTokens: 12, + outputTokens: 3, + durationBin: 4, + responseStartBin: 2 + }, + { + providerId: "provider-a", + result: "upstreamError", + model: "model-a", + inputTokens: null, + outputTokens: null, + durationBin: 4, + responseStartBin: null + } + ]); + assert.equal(harness.droppedMetrics, 1); + assert.equal(harness.manager.getPublicState().phase, "running"); + assert.equal(JSON.stringify(harness.manager.getPublicState()).includes("provider-a"), false); +}); + +test("metric callback failures are dropped without changing worker lifecycle state", async (t) => { + let dropped = 0; + const harness = createHarness([], { + recordMetric() { + throw new Error("private metrics persistence failure"); + }, + noteDroppedMetric() { + dropped += 1; + } + }); + t.after(() => harness.manager.close()); + await settle(harness.manager.start(makeSnapshot(1, 15100, "provider-a")), harness.clock); + + harness.children[0].emit("message", { + version: 1, + type: "metric", + requestId: "metric-observation", + observation: { + generation: 1, + result: "success", + model: null, + inputTokens: null, + outputTokens: null, + durationBin: 0, + responseStartBin: 0 + } + }); + await Promise.resolve(); + + assert.equal(dropped, 1); + assert.equal(harness.manager.getPublicState().phase, "running"); + assert.equal(harness.manager.getPublicState().error, null); +}); + +test("stop drains, shuts down, observes exit, and confirms fixed-port release", async (t) => { + const harness = createHarness(); + t.after(() => harness.manager.close()); + await settle(harness.manager.start(makeSnapshot()), harness.clock); + + const stopped = await settle(harness.manager.stop({ drainTimeoutMs: 100 }), harness.clock); + + assert.equal(stopped.phase, "stopped"); + assert.equal(stopped.pid, null); + assert.deepEqual(harness.children[0].sent.map((message) => message.type), [ + "configure", "drain", "shutdown" + ]); + assert.deepEqual(harness.portChecks, [{ host: "127.0.0.1", port: 15100 }]); +}); + +test("stop escalates a drain timeout through TERM and bounded KILL", async (t) => { + const harness = createHarness([{ drain: false, ignoreTerm: true }]); + t.after(() => harness.manager.close()); + await settle(harness.manager.start(makeSnapshot()), harness.clock); + + await settle(harness.manager.stop({ drainTimeoutMs: 100 }), harness.clock); + + assert.deepEqual(harness.children[0].killed, ["SIGTERM", "SIGKILL"]); + assert.equal(harness.manager.getPublicState().phase, "stopped"); + assert.equal(harness.portChecks.length, 1); +}); + +test("termination timeout retains child control so a later close can retry cleanup", async () => { + const harness = createHarness([{ + drain: false, + ignoreTerm: true, + ignoreKill: true + }]); + await settle(harness.manager.start(makeSnapshot()), harness.clock); + const child = harness.children[0]; + + await assert.rejects( + settle(harness.manager.stop({ drainTimeoutMs: 100 }), harness.clock), + (error) => error?.code === "WORKER_STOP_FAILED" + ); + const failed = harness.manager.getPublicState(); + assert.equal(failed.phase, "failed"); + assert.equal(failed.pid, child.pid); + assert.equal(child.listenerCount("exit"), 1); + assert.deepEqual(child.killed, ["SIGTERM", "SIGKILL"]); + assert.equal(harness.children.length, 1); + await Promise.resolve(); + await assert.rejects( + harness.manager.start(makeSnapshot()), + (error) => error?.code === "WORKER_STOP_FAILED" + ); + assert.equal(harness.children.length, 1); + assert.equal(harness.manager.getPublicState().pid, child.pid); + + child.script.ignoreTerm = false; + child.script.ignoreKill = false; + const closed = await settle(harness.manager.close(), harness.clock); + assert.equal(closed.phase, "stopped"); + assert.equal(closed.pid, null); + assert.deepEqual(child.killed, ["SIGTERM", "SIGKILL", "SIGTERM"]); + assert.equal(harness.portChecks.length, 1); + assert.equal(child.listenerCount("message"), 0); + assert.equal(child.listenerCount("error"), 0); + assert.equal(child.listenerCount("exit"), 0); + assert.equal(harness.children.length, 1); +}); + +test("partial-start termination timeout preserves the startup error and retryable child control", async () => { + const harness = createHarness([{ + ignoreTerm: true, + ignoreKill: true + }], { healthOk: false }); + const childPromise = harness.manager.start(makeSnapshot()); + + await assert.rejects( + settle(childPromise, harness.clock), + (error) => error?.code === "WORKER_HEALTH_FAILED" + ); + const failed = harness.manager.getPublicState(); + const child = harness.children[0]; + assert.equal(failed.phase, "failed"); + assert.equal(failed.pid, child.pid); + assert.deepEqual(failed.error, { + code: "WORKER_HEALTH_FAILED", + message: "Worker health verification failed." + }); + assert.equal(child.listenerCount("exit"), 1); + + child.script.ignoreTerm = false; + child.script.ignoreKill = false; + const closed = await settle(harness.manager.close(), harness.clock); + assert.equal(closed.phase, "stopped"); + assert.equal(closed.pid, null); + assert.equal(harness.portChecks.length, 1); + assert.equal(harness.children.length, 1); +}); + +test("restart releases the fixed port, changes PID, and requires matching health", async (t) => { + const harness = createHarness(); + t.after(() => harness.manager.close()); + await settle(harness.manager.start(makeSnapshot()), harness.clock); + const oldPid = harness.manager.getPublicState().pid; + + const restarted = await settle( + harness.manager.restart(makeSnapshot(2), { drainTimeoutMs: 100 }), + harness.clock + ); + + assert.equal(restarted.phase, "running"); + assert.notEqual(restarted.pid, oldPid); + assert.equal(restarted.generation, 2); + assert.equal(restarted.restartCount, 1); + assert.equal(harness.children.length, 2); + assert.equal(harness.portChecks.length, 1); + assert.equal(harness.healthCalls.length, 2); +}); + +test("restart shares an active lifecycle operation before inspecting its snapshot", async (t) => { + const harness = createHarness(); + t.after(() => harness.manager.close()); + const starting = harness.manager.start(makeSnapshot()); + let validationReads = 0; + const invalid = { + get generation() { + validationReads += 1; + return 0; + } + }; + + const concurrent = harness.manager.restart(invalid, { drainTimeoutMs: 100 }); + void concurrent.catch(() => {}); + + assert.equal(validationReads, 0); + assert.equal(concurrent, starting); + assert.deepEqual( + await settle(concurrent, harness.clock), + await starting + ); +}); + +test("restart rejects an invalid snapshot before draining or changing the running worker", async (t) => { + const harness = createHarness(); + t.after(() => harness.manager.close()); + await settle(harness.manager.start(makeSnapshot()), harness.clock); + const before = harness.manager.getPublicState(); + const invalid = makeSnapshot(2); + invalid.settings.upstream.baseUrl = "http://remote.example.test"; + + await assert.rejects( + harness.manager.restart(invalid, { drainTimeoutMs: 100 }), + (error) => error?.code === "WORKER_SNAPSHOT_INVALID" + ); + + const after = harness.manager.getPublicState(); + assert.equal(after.phase, "running"); + assert.equal(after.pid, before.pid); + assert.equal(after.generation, before.generation); + assert.deepEqual(after.state, before.state); + assert.deepEqual(harness.children[0].sent.map((message) => message.type), ["configure"]); +}); + +test("an unexpected exit backs off 250 ms and ignores the old child epoch after recovery", async (t) => { + const harness = createHarness(); + t.after(() => harness.manager.close()); + await settle(harness.manager.start(makeSnapshot()), harness.clock); + const oldChild = harness.children[0]; + + oldChild.exit(1, null); + await flushUntil( + () => harness.manager.getPublicState().phase === "backoff", + "worker backoff" + ); + assert.equal(harness.clock.nextDelay(), 250); + await harness.clock.advance(250); + await flushUntil( + () => harness.manager.getPublicState().phase === "running" + && harness.manager.getPublicState().pid !== oldChild.pid, + "worker recovery" + ); + const recovered = harness.manager.getPublicState(); + + oldChild.emit("message", childMessage("configured", "late-old", { generation: 99 })); + oldChild.emit("exit", 1, null); + await Promise.resolve(); + + assert.equal(harness.manager.getPublicState().pid, recovered.pid); + assert.equal(harness.manager.getPublicState().generation, 1); + assert.equal(harness.manager.getPublicState().phase, "running"); + assert.equal(harness.manager.getPublicState().restartCount, 1); +}); + +test("unexpected-exit recovery rechecks readiness immediately before spawning", async (t) => { + let ready = false; + let readinessChecks = 0; + const harness = createHarness([], { + async runRecoveryWhenReady(operation) { + readinessChecks += 1; + if (!ready) { + const error = new Error("private Codex readiness failure"); + error.code = "CODEX_NOT_READY"; + throw error; + } + return operation(); + } + }); + t.after(() => harness.manager.close()); + await settle(harness.manager.start(makeSnapshot()), harness.clock); + harness.children[0].exit(1, null); + await flushUntil( + () => harness.manager.getPublicState().phase === "backoff", + "worker recovery backoff" + ); + + await harness.clock.advance(250); + await flushUntil( + () => readinessChecks === 1 + && harness.manager.getPublicState().phase === "backoff" + && harness.clock.nextDelay() === 500, + "blocked recovery readiness check" + ); + assert.equal(harness.children.length, 1); + assert.equal(harness.portChecks.length, 1); + assert.equal(harness.clock.nextDelay(), 500); + + ready = true; + await harness.clock.advance(500); + await flushUntil( + () => harness.manager.getPublicState().phase === "running", + "readiness-approved recovery" + ); + assert.equal(readinessChecks, 2); + assert.equal(harness.portChecks.length, 2); + assert.equal(harness.children.length, 2); +}); + +test("stop cancels an unexpected-exit recovery waiting inside the readiness gate", async (t) => { + let gateEntered = false; + let releaseGate; + const gate = new Promise((resolvePromise) => { + releaseGate = resolvePromise; + }); + const harness = createHarness([], { + async runRecoveryWhenReady(operation) { + gateEntered = true; + await gate; + return operation(); + } + }); + t.after(() => harness.manager.close()); + await settle(harness.manager.start(makeSnapshot()), harness.clock); + harness.children[0].exit(1, null); + await flushUntil( + () => harness.manager.getPublicState().phase === "backoff", + "worker recovery backoff" + ); + await harness.clock.advance(250); + await flushUntil(() => gateEntered, "recovery readiness gate"); + + const stopped = await harness.manager.stop(); + releaseGate(); + await flushUntil( + () => harness.manager.getPublicState().phase === "stopped", + "cancelled recovery" + ); + assert.equal(stopped.phase, "stopped"); + assert.equal(harness.children.length, 1); + assert.equal(harness.clock.pending(), 0); +}); + +test("close cancels an unexpected-exit recovery waiting inside the readiness gate", async () => { + let gateEntered = false; + let releaseGate; + const gate = new Promise((resolvePromise) => { + releaseGate = resolvePromise; + }); + const harness = createHarness([], { + async runRecoveryWhenReady(operation) { + gateEntered = true; + await gate; + return operation(); + } + }); + await settle(harness.manager.start(makeSnapshot()), harness.clock); + harness.children[0].exit(1, null); + await flushUntil( + () => harness.manager.getPublicState().phase === "backoff", + "worker recovery backoff" + ); + await harness.clock.advance(250); + await flushUntil(() => gateEntered, "recovery readiness gate"); + + await harness.manager.close(); + releaseGate(); + await Promise.resolve(); + await Promise.resolve(); + assert.equal(harness.manager.getPublicState().phase, "stopped"); + assert.equal(harness.children.length, 1); + assert.equal(harness.clock.pending(), 0); +}); + +test("readiness rejection uses bounded backoff without exposing its private failure", async (t) => { + const sentinel = "recovery-readiness-secret-sentinel"; + const unhandled = collectUnhandledRejections(t); + let readinessChecks = 0; + const harness = createHarness([], { + async runRecoveryWhenReady() { + readinessChecks += 1; + throw new Error(sentinel); + } + }); + t.after(() => harness.manager.close()); + await settle(harness.manager.start(makeSnapshot()), harness.clock); + harness.children[0].exit(1, null); + const delays = []; + + for (const delay of [250, 500, 1_000, 2_000]) { + await flushUntil( + () => harness.manager.getPublicState().phase === "backoff" + && harness.clock.nextDelay() === delay, + `recovery delay ${delay}` + ); + delays.push(harness.clock.nextDelay()); + await harness.clock.advance(delay); + } + await flushUntil( + () => harness.manager.getPublicState().phase === "failed", + "terminal readiness failure" + ); + + assert.deepEqual(delays, [250, 500, 1_000, 2_000]); + assert.equal(readinessChecks, 4); + assert.equal(harness.children.length, 1); + assert.equal(harness.clock.pending(), 0); + assert.equal(unhandled.length, 0); + assert.equal(JSON.stringify(harness.manager.getPublicState()).includes(sentinel), false); +}); + +test("the fifth crash in 60 seconds enters failed without spawning again", async (t) => { + const harness = createHarness(); + t.after(() => harness.manager.close()); + await settle(harness.manager.start(makeSnapshot()), harness.clock); + const observedDelays = []; + + for (let crash = 1; crash <= 5; crash += 1) { + harness.children.at(-1).exit(1, null); + if (crash === 5) { + await flushUntil( + () => harness.manager.getPublicState().phase === "failed", + "terminal failed state" + ); + break; + } + await flushUntil( + () => harness.manager.getPublicState().phase === "backoff", + `backoff ${crash}` + ); + observedDelays.push(harness.clock.nextDelay()); + await harness.clock.advance(harness.clock.nextDelay()); + await flushUntil( + () => harness.manager.getPublicState().phase === "running", + `recovery ${crash}` + ); + } + + assert.deepEqual(observedDelays, [250, 500, 1000, 2000]); + assert.equal(harness.manager.getPublicState().phase, "failed"); + assert.equal(harness.manager.getPublicState().restartCount, 4); + assert.equal(harness.children.length, 5); + assert.equal(harness.clock.pending(), 0); +}); + +test("close is idempotent, cancels backoff, and permanently disables recovery and start", async () => { + const harness = createHarness(); + const first = harness.manager.start(makeSnapshot()); + const concurrent = harness.manager.start(makeSnapshot()); + assert.equal(first, concurrent); + await settle(first, harness.clock); + + harness.children[0].exit(1, null); + await flushUntil( + () => harness.manager.getPublicState().phase === "backoff", + "worker backoff before close" + ); + assert.equal(harness.clock.pending(), 1); + + const closing = harness.manager.close(); + assert.equal(harness.manager.close(), closing); + await settle(closing, harness.clock); + + assert.equal(harness.clock.pending(), 0); + assert.equal(harness.manager.getPublicState().phase, "stopped"); + assert.equal(harness.children.length, 1); + await assert.rejects( + harness.manager.start(makeSnapshot()), + (error) => error?.code === "WORKER_MANAGER_CLOSED" + ); +}); + +test("stop cancels backoff and prevents the scheduled recovery from spawning", async (t) => { + const harness = createHarness(); + t.after(() => harness.manager.close()); + await settle(harness.manager.start(makeSnapshot()), harness.clock); + harness.children[0].exit(1, null); + await flushUntil( + () => harness.manager.getPublicState().phase === "backoff", + "backoff before stop" + ); + assert.equal(harness.clock.nextDelay(), 250); + + const stopped = await harness.manager.stop(); + assert.equal(stopped.phase, "stopped"); + assert.equal(stopped.pid, null); + assert.equal(harness.clock.pending(), 0); + assert.equal(harness.children.length, 1); +}); + +test("a correlated fatal rejects startup with its stable code instead of waiting for timeout", async (t) => { + const harness = createHarness([{ configure: false }]); + t.after(() => harness.manager.close()); + const starting = harness.manager.start(makeSnapshot()); + await flushUntil( + () => harness.children[0]?.sent.some((message) => message.type === "configure"), + "configure send before fatal" + ); + const requestId = harness.children[0].sent.find((message) => message.type === "configure").requestId; + + harness.children[0].emit("message", { + version: 1, + type: "fatal", + requestId, + error: { + code: "WORKER_CONFIGURE_FAILED", + message: "Worker configuration failed." + } + }); + + await assert.rejects( + settle(starting, harness.clock), + (error) => error?.code === "WORKER_CONFIGURE_FAILED" + && error.message === "Worker configuration failed." + && !error.message.includes(SECRET) + ); +}); + +test("send failures cancel and consume start, apply, and drain waiters without unhandled rejection", async (t) => { + const unhandled = collectUnhandledRejections(t); + + const starting = createHarness([{ failSend: { configure: [1] } }]); + t.after(() => starting.manager.close()); + await assert.rejects( + settle(starting.manager.start(makeSnapshot()), starting.clock), + (error) => error?.code === "WORKER_IPC_SEND_FAILED" + && !error.message.includes("sensitive send failure") + ); + assert.equal(starting.clock.pending(), 0); + + const applying = createHarness([{ failSend: { configure: [2] } }]); + t.after(() => applying.manager.close()); + await settle(applying.manager.start(makeSnapshot()), applying.clock); + await assert.rejects( + settle(applying.manager.applySnapshot(makeSnapshot(2)), applying.clock), + (error) => error?.code === "WORKER_IPC_SEND_FAILED" + ); + assert.equal(applying.clock.pending(), 0); + assert.equal(applying.manager.getPublicState().generation, 1); + + const stopping = createHarness([{ failSend: { drain: [1] } }]); + t.after(() => stopping.manager.close()); + await settle(stopping.manager.start(makeSnapshot()), stopping.clock); + await assert.rejects( + settle(stopping.manager.stop({ drainTimeoutMs: 100 }), stopping.clock), + (error) => error?.code === "WORKER_IPC_SEND_FAILED" + ); + assert.equal(stopping.clock.pending(), 0); + assert.equal(stopping.manager.getPublicState().phase, "stopped"); + + for (let turn = 0; turn < 8; turn += 1) await Promise.resolve(); + assert.deepEqual(unhandled, []); +}); + +test("configure acknowledgements arriving before the send callback are retained", async (t) => { + const harness = createHarness([{ ackBeforeCallback: true }]); + t.after(() => harness.manager.close()); + + const started = await settle(harness.manager.start(makeSnapshot()), harness.clock); + assert.equal(started.generation, 1); + const applied = await settle(harness.manager.applySnapshot(makeSnapshot(2)), harness.clock); + assert.equal(applied.generation, 2); + assert.equal(harness.clock.pending(), 0); +}); + +test("a failed startup terminates the partial child and confirms fixed-port release", async (t) => { + const harness = createHarness([], { healthOk: false }); + t.after(() => harness.manager.close()); + + await assert.rejects( + settle(harness.manager.start(makeSnapshot()), harness.clock), + (error) => error?.code === "WORKER_HEALTH_FAILED" + ); + + assert.deepEqual(harness.children[0].killed, ["SIGTERM"]); + assert.deepEqual(harness.portChecks, [{ host: "127.0.0.1", port: 15100 }]); + assert.equal(harness.manager.getPublicState().phase, "stopped"); + assert.equal(harness.manager.getPublicState().pid, null); +}); + +test("close terminates a live child, confirms port release, and removes child listeners", async () => { + const harness = createHarness(); + await settle(harness.manager.start(makeSnapshot()), harness.clock); + const child = harness.children[0]; + + await settle(harness.manager.close(), harness.clock); + + assert.deepEqual(child.killed, ["SIGTERM"]); + assert.deepEqual(harness.portChecks, [{ host: "127.0.0.1", port: 15100 }]); + assert.equal(child.listenerCount("message"), 0); + assert.equal(child.listenerCount("error"), 0); + assert.equal(child.listenerCount("exit"), 0); + assert.equal(harness.manager.getPublicState().phase, "stopped"); +}); + +test("a synchronous fork failure returns to stopped with a stable sanitized error", async (t) => { + const harness = createHarness([], { forkError: true }); + t.after(() => harness.manager.close()); + + await assert.rejects( + harness.manager.start(makeSnapshot()), + (error) => error?.code === "WORKER_START_FAILED" + && !error.message.includes("sensitive fork cause") + ); + + const current = harness.manager.getPublicState(); + assert.equal(current.phase, "stopped"); + assert.equal(current.pid, null); + assert.deepEqual(current.error, { + code: "WORKER_START_FAILED", + message: "Worker failed to start." + }); +}); + +test("a port-release failure cleans lifecycle resources and enters a stable failed state", async (t) => { + const harness = createHarness([], { portError: true }); + t.after(() => harness.manager.close().catch(() => {})); + await settle(harness.manager.start(makeSnapshot()), harness.clock); + const child = harness.children[0]; + + await assert.rejects( + settle(harness.manager.stop({ drainTimeoutMs: 100 }), harness.clock), + (error) => error?.code === "WORKER_PORT_BUSY" + && !error.message.includes("sensitive port probe cause") + ); + + const current = harness.manager.getPublicState(); + assert.equal(current.phase, "failed"); + assert.equal(current.pid, null); + assert.deepEqual(current.error, { + code: "WORKER_PORT_BUSY", + message: "The fixed proxy port is still in use." + }); + assert.equal(child.listenerCount("message"), 0); + assert.equal(child.listenerCount("error"), 0); + assert.equal(child.listenerCount("exit"), 0); +}); + +test("a malformed secret-bearing child message is sanitized and recovered as a protocol failure", async (t) => { + const harness = createHarness(); + t.after(() => harness.manager.close()); + await settle(harness.manager.start(makeSnapshot()), harness.clock); + const child = harness.children[0]; + + child.emit("message", { + version: 1, + type: "status", + requestId: "malformed-secret", + state: { ...state(), apiKey: SECRET } + }); + await flushUntil( + () => harness.manager.getPublicState().phase === "backoff", + "protocol-failure backoff" + ); + + const current = harness.manager.getPublicState(); + assert.deepEqual(child.killed, ["SIGTERM"]); + assert.equal(current.error.code, "WORKER_PROTOCOL_INVALID"); + assert.equal(current.error.message, "Worker protocol message is invalid."); + assert.equal(JSON.stringify(current).includes(SECRET), false); +}); diff --git a/node/test/worker-protocol.test.mjs b/node/test/worker-protocol.test.mjs new file mode 100644 index 0000000..61b25da --- /dev/null +++ b/node/test/worker-protocol.test.mjs @@ -0,0 +1,461 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + PROTOCOL_VERSION, + createFatalMessage, + sanitizeProtocolMessage, + validateChildMessage, + validateParentMessage +} from "../src/worker/protocol.mjs"; + +function makeSettings() { + return { + configPath: "/tmp/crp-worker/proxy-config.json", + server: { + host: "127.0.0.1", + port: 0, + logLevel: "info" + }, + upstream: { + baseUrl: "http://127.0.0.1:41001", + apiKey: "protocol-test-secret", + timeoutMs: 5000, + verifySsl: true, + authHeader: "x-provider-auth", + authScheme: "Bearer", + extraHeaders: { + "x-region": "test" + } + }, + proxy: { + overrideAuthorization: true, + requestIdHeader: "x-client-request-id" + }, + capture: { + enabled: false, + dbPath: "/tmp/crp-worker/traffic.sqlite3" + } + }; +} + +function makeState(overrides = {}) { + return { + phase: "running", + configured: true, + generation: 1, + listening: true, + listenHost: "127.0.0.1", + listenPort: 15100, + inFlight: 0, + ...overrides + }; +} + +test("parent protocol accepts exact configure, drain, shutdown, and status messages", () => { + assert.equal(PROTOCOL_VERSION, 1); + const messages = [ + { + version: 1, + type: "configure", + requestId: "configure-1", + generation: 1, + settings: makeSettings() + }, + { version: 1, type: "drain", requestId: "drain-1" }, + { version: 1, type: "shutdown", requestId: "shutdown-1" }, + { version: 1, type: "status", requestId: "status-1" } + ]; + + for (const message of messages) { + assert.deepEqual(validateParentMessage(message), message); + } +}); + +test("parent protocol rejects unknown, malformed, and secret-bearing non-configure messages", () => { + const invalidMessages = [ + null, + [], + { version: 2, type: "status", requestId: "status-1" }, + { version: 1, type: "unknown", requestId: "unknown-1" }, + { version: 1, type: "status", requestId: "" }, + { version: 1, type: "status", requestId: "contains spaces" }, + { version: 1, type: "status", requestId: "status-1", extra: true }, + { version: 1, type: "drain", requestId: "drain-1", apiKey: "must-not-pass" }, + { version: 1, type: "configure", requestId: "configure-1", generation: 0, settings: makeSettings() }, + { version: 1, type: "configure", requestId: "configure-1", generation: 1 }, + { version: 1, type: "configure", requestId: "configure-1", generation: 1, settings: [] } + ]; + + for (const message of invalidMessages) { + assert.throws( + () => validateParentMessage(message), + (error) => error?.code === "WORKER_PROTOCOL_INVALID" + && !String(error.message).includes("must-not-pass") + ); + } +}); + +test("configure rejects incomplete, extra, and invalid runtime settings before worker startup", () => { + const missingServer = makeSettings(); + delete missingServer.server; + const extraRootField = { ...makeSettings(), credentialRef: "must-not-pass" }; + const extraNestedField = makeSettings(); + extraNestedField.upstream.secret = "must-not-pass"; + const invalidPort = makeSettings(); + invalidPort.server.port = -1; + const invalidTimeout = makeSettings(); + invalidTimeout.upstream.timeoutMs = Number.POSITIVE_INFINITY; + const emptyApiKey = makeSettings(); + emptyApiKey.upstream.apiKey = ""; + const invalidExtraHeaders = makeSettings(); + invalidExtraHeaders.upstream.extraHeaders = { "x-region": 123 }; + + for (const settings of [ + missingServer, + extraRootField, + extraNestedField, + invalidPort, + invalidTimeout, + emptyApiKey, + invalidExtraHeaders + ]) { + assert.throws( + () => validateParentMessage({ + version: 1, + type: "configure", + requestId: "configure-invalid", + generation: 1, + settings + }), + (error) => error?.code === "WORKER_PROTOCOL_INVALID" + && !String(error.message).includes("must-not-pass") + ); + } +}); + +test("configure enforces provider URL and header security contracts", () => { + for (const baseUrl of [ + "https://api.example.com/v1", + "http://localhost:41001/v1", + "http://127.42.0.9:41001/v1", + "http://[::1]:41001/v1" + ]) { + const settings = makeSettings(); + settings.upstream.baseUrl = baseUrl; + assert.doesNotThrow(() => validateParentMessage({ + version: 1, + type: "configure", + requestId: "configure-valid-security", + generation: 1, + settings + })); + } + + const remotePlaintext = makeSettings(); + remotePlaintext.upstream.baseUrl = "http://api.example.com/v1"; + const spacedAuthScheme = makeSettings(); + spacedAuthScheme.upstream.authScheme = "Bearer token"; + const controlAuthScheme = makeSettings(); + controlAuthScheme.upstream.authScheme = "Bearer\n"; + const invalidAuthHeader = makeSettings(); + invalidAuthHeader.upstream.authHeader = "x provider auth"; + const unsafeHeaderValue = makeSettings(); + unsafeHeaderValue.upstream.extraHeaders = { "x-region": "safe\r\nmust-not-pass" }; + + const invalidSettings = [ + remotePlaintext, + spacedAuthScheme, + controlAuthScheme, + invalidAuthHeader, + unsafeHeaderValue + ]; + for (const name of [ + "Authorization", + "Proxy-Authorization", + "Cookie", + "Set-Cookie", + "X-API-Key", + "X-Service-Secret", + "X-PROVIDER-AUTH" + ]) { + const settings = makeSettings(); + settings.upstream.extraHeaders = { [name]: "must-not-pass" }; + invalidSettings.push(settings); + } + + for (const settings of invalidSettings) { + assert.throws( + () => validateParentMessage({ + version: 1, + type: "configure", + requestId: "configure-invalid-security", + generation: 1, + settings + }), + (error) => error?.code === "WORKER_PROTOCOL_INVALID" + && error.message === "Worker protocol message is invalid." + && !String(error.message).includes("must-not-pass") + ); + } +}); + +test("configure rejects API keys that cannot form an HTTP authentication header", () => { + const validSettings = makeSettings(); + validSettings.upstream.apiKey = "sk-test_123.abc-XYZ"; + assert.doesNotThrow(() => validateParentMessage({ + version: 1, + type: "configure", + requestId: "configure-valid-api-key", + generation: 1, + settings: validSettings + })); + + for (const apiKey of [ + "line-one\r\nx-injected: must-not-pass", + "control-\u0000must-not-pass", + "unicode-\u{1f512}-must-not-pass" + ]) { + const settings = makeSettings(); + settings.upstream.apiKey = apiKey; + assert.throws( + () => validateParentMessage({ + version: 1, + type: "configure", + requestId: "configure-invalid-api-key", + generation: 1, + settings + }), + (error) => error?.code === "WORKER_PROTOCOL_INVALID" + && error.message === "Worker protocol message is invalid." + && !String(error.message).includes(apiKey) + ); + } +}); + +test("child protocol accepts exact lifecycle acknowledgements, status, and fatal messages", () => { + const messages = [ + { version: 1, type: "ready", requestId: "worker-ready", state: makeState({ + phase: "ready", + configured: false, + generation: 0, + listening: false, + listenHost: null, + listenPort: null + }) }, + { version: 1, type: "configured", requestId: "configure-1", state: makeState() }, + { version: 1, type: "drained", requestId: "drain-1", state: makeState({ + phase: "drained", + listening: false, + listenHost: null, + listenPort: null + }) }, + { version: 1, type: "status", requestId: "status-1", state: makeState() }, + { + version: 1, + type: "fatal", + requestId: "configure-1", + error: { + code: "WORKER_CONFIGURE_FAILED", + message: "Worker configuration failed." + } + } + ]; + + for (const message of messages) { + assert.deepEqual(validateChildMessage(message), message); + } +}); + +test("child protocol accepts only bounded anonymous metric observations", () => { + const message = { + version: 1, + type: "metric", + requestId: "metric-observation", + observation: { + generation: 7, + result: "success", + model: "gpt-5-codex", + inputTokens: 123, + outputTokens: 45, + durationBin: 4, + responseStartBin: 2 + } + }; + assert.deepEqual(validateChildMessage(message), message); + assert.deepEqual(validateChildMessage({ + ...message, + observation: { + ...message.observation, + result: "networkError", + model: null, + inputTokens: null, + outputTokens: null, + responseStartBin: null + } + }).observation, { + generation: 7, + result: "networkError", + model: null, + inputTokens: null, + outputTokens: null, + durationBin: 4, + responseStartBin: null + }); + + const sentinel = "metric-secret-sentinel"; + const invalid = [ + { ...message, requestId: "actual request id" }, + { ...message, requestId: "actual-request-id" }, + { ...message, observation: { ...message.observation, generation: 0 } }, + { ...message, observation: { ...message.observation, result: "raw-error" } }, + { ...message, observation: { ...message.observation, model: `bad\u0000${sentinel}` } }, + { ...message, observation: { ...message.observation, inputTokens: 100_000_001 } }, + { ...message, observation: { ...message.observation, outputTokens: null } }, + { ...message, observation: { ...message.observation, durationBin: 13 } }, + { ...message, observation: { ...message.observation, responseStartBin: -1 } }, + { ...message, observation: { ...message.observation, requestId: sentinel } }, + { ...message, observation: { ...message.observation, url: `https://${sentinel}.invalid` } }, + { ...message, observation: { ...message.observation, error: sentinel } } + ]; + for (const candidate of invalid) { + assert.throws( + () => validateChildMessage(candidate), + (error) => error?.code === "WORKER_PROTOCOL_INVALID" + && !String(error.message).includes(sentinel) + ); + } + + const sanitized = sanitizeProtocolMessage({ + ...message, + observation: { ...message.observation, model: sentinel } + }); + assert.equal(JSON.stringify(sanitized).includes(sentinel), false); + assert.deepEqual(sanitized, { + version: 1, + type: "metric", + requestId: "metric-observation" + }); +}); + +test("child protocol rejects invalid state, extra fields, and secret-bearing errors", () => { + const invalidMessages = [ + { version: 1, type: "ready", requestId: "worker-ready" }, + { version: 1, type: "configured", requestId: "configure-1", state: makeState({ generation: -1 }) }, + { version: 1, type: "status", requestId: "status-1", state: makeState({ inFlight: 1.5 }) }, + { version: 1, type: "drained", requestId: "drain-1", state: { ...makeState(), apiKey: "must-not-pass" } }, + { version: 1, type: "fatal", requestId: "fatal-1", error: { code: "bad-code", message: "bad" } }, + { + version: 1, + type: "fatal", + requestId: "fatal-1", + error: { + code: "WORKER_START_FAILED", + message: "Worker failed.", + secret: "must-not-pass" + } + } + ]; + + for (const message of invalidMessages) { + assert.throws( + () => validateChildMessage(message), + (error) => error?.code === "WORKER_PROTOCOL_INVALID" + && !String(error.message).includes("must-not-pass") + ); + } +}); + +test("sanitizeProtocolMessage removes configure settings and projects only safe fields", () => { + const message = { + version: 1, + type: "configure", + requestId: "configure-1", + generation: 7, + settings: makeSettings(), + authorization: "complete-authorization-secret", + nested: { + cookie: "complete-cookie-secret" + } + }; + + const sanitized = sanitizeProtocolMessage(message); + assert.deepEqual(sanitized, { + version: 1, + type: "configure", + requestId: "configure-1", + generation: 7 + }); + const serialized = JSON.stringify(sanitized); + for (const forbidden of [ + "settings", + "apiKey", + "authorization", + "cookie", + "protocol-test-secret", + "complete-authorization-secret", + "complete-cookie-secret" + ]) { + assert.equal(serialized.includes(forbidden), false, `sanitized message leaked ${forbidden}`); + } +}); + +test("sanitizeProtocolMessage allowlists child state and replaces arbitrary fatal details", () => { + const stateMessage = { + version: 1, + type: "status", + requestId: "status-1", + state: { + ...makeState(), + apiKey: "state-secret", + settings: makeSettings() + } + }; + assert.deepEqual(sanitizeProtocolMessage(stateMessage), { + version: 1, + type: "status", + requestId: "status-1", + state: makeState() + }); + + const fatal = sanitizeProtocolMessage({ + version: 1, + type: "fatal", + requestId: "fatal-1", + error: { + code: "WORKER_START_FAILED", + message: "backend included complete-secret-value", + stack: "complete-secret-value" + } + }); + assert.deepEqual(fatal, { + version: 1, + type: "fatal", + requestId: "fatal-1", + error: { + code: "WORKER_START_FAILED", + message: "Worker failed to start." + } + }); + assert.equal(JSON.stringify(fatal).includes("complete-secret-value"), false); +}); + +test("createFatalMessage uses a safe fallback request ID and never echoes causes", () => { + const message = createFatalMessage({ + requestId: "unsafe request id complete-secret-value", + code: "WORKER_PROTOCOL_INVALID", + message: "Protocol rejected complete-secret-value", + cause: new Error("complete-secret-value") + }); + + assert.deepEqual(message, { + version: 1, + type: "fatal", + requestId: "worker-fatal", + error: { + code: "WORKER_PROTOCOL_INVALID", + message: "Worker protocol message is invalid." + } + }); + assert.equal(JSON.stringify(message).includes("complete-secret-value"), false); +}); diff --git a/node/tsconfig.ui.json b/node/tsconfig.ui.json new file mode 100644 index 0000000..0d3288a --- /dev/null +++ b/node/tsconfig.ui.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "types": ["vite/client"], + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "noUncheckedIndexedAccess": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["ui-src/src/**/*.ts", "ui-src/src/**/*.tsx"] +} diff --git a/node/ui-src/index.html b/node/ui-src/index.html new file mode 100644 index 0000000..29525db --- /dev/null +++ b/node/ui-src/index.html @@ -0,0 +1,15 @@ + + + + + + + + CRP Local Console + + +
+ + + + diff --git a/node/ui-src/src/api.ts b/node/ui-src/src/api.ts new file mode 100644 index 0000000..aad5954 --- /dev/null +++ b/node/ui-src/src/api.ts @@ -0,0 +1,385 @@ +import type { + ActivityPageData, + BootstrapResult, + DiagnosticResult, + MetricsOverview, + MetricsWindow, + ModelCatalog, + Provider, + ProviderInput, + ProviderTestResult, + SafeErrorDetails, + Settings, + StatusResponse, + SupervisorIdentity, + SupervisorShutdownAcceptance, + WorkerStatus +} from "./types"; + +const TERMINAL_ERROR_CODES = new Set([ + "AUTH_REQUIRED", + "AUTH_INVALID", + "AUTH_SESSION_INVALID", + "AUTH_SESSION_EXPIRED", + "AUTH_CSRF_MISSING", + "AUTH_CSRF_INVALID" +]); + +const SAFE_DETAIL_KEYS = new Set([ + "field", + "reason", + "committed", + "degraded", + "pending", + "generation", + "httpStatus" +]); + +type RequestOptions = { + method?: "GET" | "POST" | "PATCH" | "DELETE"; + body?: unknown; + emptyBody?: boolean; + signal?: AbortSignal; + sessionResume?: boolean; + expectedStatus?: number; +}; + +function isPlainRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function isExactShutdownPayload( + value: unknown, + identity: SupervisorIdentity +): value is { shutdown: SupervisorShutdownAcceptance } { + if (!isPlainRecord(value) || Object.keys(value).length !== 1 + || !isPlainRecord(value.shutdown)) return false; + return Object.keys(value.shutdown).length === 3 + && value.shutdown.accepted === true + && value.shutdown.supervisorPid === identity.pid + && value.shutdown.startedAt === identity.startedAt; +} + +export class ApiError extends Error { + readonly code: string; + readonly status: number; + readonly action: string | null; + readonly requestId: string | null; + readonly details: SafeErrorDetails; + + constructor( + code: string, + status = 0, + options: { + message?: string; + action?: string | null; + requestId?: string | null; + details?: Record; + } = {} + ) { + super(options.message ?? code); + this.name = "ApiError"; + this.code = code; + this.status = status; + this.action = options.action ?? null; + this.requestId = typeof options.requestId === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(options.requestId) + ? options.requestId + : null; + this.details = Object.fromEntries( + Object.entries(options.details ?? {}).filter(([key]) => SAFE_DETAIL_KEYS.has(key)) + ); + } + + static fromTestResult(result: ProviderTestResult): ApiError { + return new ApiError(result.code ?? "PROVIDER_TEST_INVALID_RESPONSES", 200); + } +} + +export function asApiError(error: unknown): ApiError { + if (error instanceof ApiError) return error; + if (error instanceof DOMException && error.name === "AbortError") throw error; + return new ApiError("INTERNAL_ERROR"); +} + +export function readAndClearControlToken(): string | null { + const hash = location.hash; + const match = /^#token=([A-Za-z0-9_-]{43})$/.exec(hash); + if (hash.length > 0) history.replaceState(null, "", `${location.pathname}${location.search}`); + return match?.[1] ?? null; +} + +export class CrpApi { + #csrfToken: string | null = null; + #mutationAllowed = false; + #terminal = false; + readonly #onTerminal: (error: ApiError) => void; + + constructor(onTerminal: (error: ApiError) => void) { + this.#onTerminal = onTerminal; + } + + get mutationAllowed(): boolean { + return this.#mutationAllowed && !this.#terminal; + } + + enterReadOnly(): void { + this.#csrfToken = null; + this.#mutationAllowed = false; + } + + terminate(error: ApiError): void { + if (this.#terminal) return; + this.#terminal = true; + this.#csrfToken = null; + this.#mutationAllowed = false; + this.#onTerminal(error); + } + + async exchangeSession(controlToken: string): Promise { + try { + const payload = await this.#request<{ csrfToken?: unknown }>("/api/v1/session", { + method: "POST", + emptyBody: true, + authorization: `Bearer ${controlToken}` + }); + if (typeof payload.csrfToken !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(payload.csrfToken)) { + throw new ApiError("AUTH_SESSION_INVALID", 401); + } + this.#csrfToken = payload.csrfToken; + this.#mutationAllowed = true; + } catch (error) { + const apiError = asApiError(error); + this.terminate(apiError); + throw apiError; + } + } + + async resumeSession(): Promise { + const payload = await this.#request<{ csrfToken?: unknown }>("/api/v1/session/resume", { + method: "POST", + emptyBody: true, + sessionResume: true + }); + if (typeof payload.csrfToken !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(payload.csrfToken)) { + const error = new ApiError("AUTH_SESSION_INVALID", 401); + this.terminate(error); + throw error; + } + this.#csrfToken = payload.csrfToken; + this.#mutationAllowed = true; + } + + async getStatus(signal?: AbortSignal): Promise { + return await this.get("/api/v1/status", signal); + } + + async listProviders(signal?: AbortSignal): Promise { + const payload = await this.get<{ providers: Provider[] }>("/api/v1/providers", signal); + return payload.providers; + } + + async getSettings(signal?: AbortSignal): Promise { + const payload = await this.get<{ settings: Settings }>("/api/v1/settings", signal); + return payload.settings; + } + + async getActivity(offset = 0, signal?: AbortSignal): Promise { + return await this.get(`/api/v1/activity?limit=50&offset=${offset}`, signal); + } + + async getMetrics(window: MetricsWindow, signal?: AbortSignal): Promise { + const payload = await this.get<{ metrics: MetricsOverview }>( + `/api/v1/metrics/overview?window=${window}`, + signal + ); + return payload.metrics; + } + + async createProvider(provider: ProviderInput, credential: string): Promise { + const payload = await this.mutate<{ provider: Provider }>("/api/v1/providers", { + method: "POST", + body: { provider, credential } + }); + return payload.provider; + } + + async updateProvider(id: string, patch: ProviderInput, replacementCredential?: string): Promise { + const body = replacementCredential === undefined ? { patch } : { patch, replacementCredential }; + const payload = await this.mutate<{ provider: Provider }>(this.providerPath(id), { + method: "PATCH", + body + }); + return payload.provider; + } + + async deleteProvider(id: string): Promise { + const payload = await this.mutate<{ provider: Provider }>(this.providerPath(id), { + method: "DELETE", + emptyBody: true + }); + return payload.provider; + } + + async testProvider(id: string, model: string, activateIfNone = false): Promise { + const payload = await this.mutate<{ result: ProviderTestResult }>(`${this.providerPath(id)}/test`, { + method: "POST", + body: activateIfNone ? { model, activateIfNone: true } : { model } + }); + return payload.result; + } + + async getProviderModels(id: string, signal?: AbortSignal): Promise { + const payload = await this.get<{ modelCatalog: ModelCatalog }>(`${this.providerPath(id)}/models`, signal); + return payload.modelCatalog; + } + + async refreshProviderModels(id: string): Promise { + const payload = await this.mutate<{ modelCatalog: ModelCatalog }>(`${this.providerPath(id)}/models`, { + method: "POST", + emptyBody: true + }); + return payload.modelCatalog; + } + + async activateProvider(id: string): Promise { + await this.mutate(`${this.providerPath(id)}/activate`, { method: "POST", emptyBody: true }); + } + + async startProxy(): Promise { + const payload = await this.mutate<{ worker: WorkerStatus }>("/api/v1/proxy/start", { + method: "POST", + emptyBody: true + }); + return payload.worker; + } + + async stopProxy(): Promise { + const payload = await this.mutate<{ worker: WorkerStatus }>("/api/v1/proxy/stop", { + method: "POST", + emptyBody: true + }); + return payload.worker; + } + + async restartProxy(): Promise { + const payload = await this.mutate<{ worker: WorkerStatus }>("/api/v1/proxy/restart", { + method: "POST", + emptyBody: true + }); + return payload.worker; + } + + async shutdownSupervisor(identity: SupervisorIdentity): Promise { + const payload = await this.mutate("/api/v1/supervisor/shutdown", { + method: "POST", + body: { + supervisorPid: identity.pid, + startedAt: identity.startedAt + }, + expectedStatus: 202 + }); + if (!isExactShutdownPayload(payload, identity)) { + throw new ApiError("INTERNAL_ERROR", 202); + } + this.#terminal = true; + this.#csrfToken = null; + this.#mutationAllowed = false; + return payload.shutdown; + } + + async bootstrapCodex(): Promise { + const payload = await this.mutate<{ result: BootstrapResult }>("/api/v1/codex/bootstrap", { + method: "POST", + emptyBody: true + }); + return payload.result; + } + + async generateDiagnostics(): Promise { + const payload = await this.mutate<{ diagnostics: DiagnosticResult }>("/api/v1/diagnostics/export", { + method: "POST", + emptyBody: true + }); + return payload.diagnostics; + } + + private providerPath(id: string): string { + return `/api/v1/providers/${encodeURIComponent(id)}`; + } + + private async get(path: string, signal?: AbortSignal): Promise { + return await this.#request(path, { method: "GET", signal }); + } + + private async mutate(path: string, options: RequestOptions): Promise { + if (!this.mutationAllowed || this.#csrfToken === null) throw new ApiError("AUTH_REQUIRED", 401); + return await this.#request(path, options); + } + + async #request( + path: string, + options: RequestOptions & { authorization?: string } = {} + ): Promise { + if (this.#terminal) throw new ApiError("AUTH_REQUIRED", 401); + const method = options.method ?? "GET"; + const mutation = method !== "GET"; + const sessionBootstrap = path === "/api/v1/session" || path === "/api/v1/session/resume"; + const headers: Record = {}; + if (options.authorization !== undefined) headers.authorization = options.authorization; + if (options.sessionResume) headers["x-crp-session-resume"] = "1"; + if (mutation && !sessionBootstrap) { + if (!this.mutationAllowed || this.#csrfToken === null) throw new ApiError("AUTH_REQUIRED", 401); + headers["x-crp-csrf"] = this.#csrfToken; + } + const request: RequestInit = { + method, + headers, + credentials: "same-origin", + signal: options.signal + }; + if (options.body !== undefined) { + headers["content-type"] = "application/json"; + request.body = JSON.stringify(options.body); + } + try { + const response = await fetch(path, request); + let payload: unknown; + try { + payload = await response.json(); + } catch { + throw new ApiError("INTERNAL_ERROR", response.status); + } + if (!response.ok) { + const envelope = payload as { + error?: { + code?: unknown; + message?: unknown; + action?: unknown; + requestId?: unknown; + details?: unknown; + }; + }; + const code = typeof envelope.error?.code === "string" ? envelope.error.code : "INTERNAL_ERROR"; + const error = new ApiError(code, response.status, { + message: typeof envelope.error?.message === "string" ? envelope.error.message : undefined, + action: typeof envelope.error?.action === "string" ? envelope.error.action : null, + requestId: typeof envelope.error?.requestId === "string" ? envelope.error.requestId : null, + details: envelope.error?.details && typeof envelope.error.details === "object" + ? envelope.error.details as Record + : {} + }); + if (response.status === 401 || response.status === 403 && TERMINAL_ERROR_CODES.has(error.code)) { + this.terminate(error); + } + throw error; + } + if (options.expectedStatus !== undefined && response.status !== options.expectedStatus) { + throw new ApiError("INTERNAL_ERROR", response.status); + } + return payload as T; + } catch (error) { + if (error instanceof ApiError || error instanceof DOMException && error.name === "AbortError") throw error; + throw new ApiError("INTERNAL_ERROR"); + } + } +} diff --git a/node/ui-src/src/app.tsx b/node/ui-src/src/app.tsx new file mode 100644 index 0000000..898a0fb --- /dev/null +++ b/node/ui-src/src/app.tsx @@ -0,0 +1,631 @@ +import { Power, TerminalSquare } from "lucide-react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState +} from "react"; + +import { ApiError, CrpApi, asApiError, readAndClearControlToken } from "./api"; +import { Shell } from "./components/Shell"; +import { Button, ErrorNotice, Notice, StatusBadge } from "./components/Primitives"; +import { + createTranslator, + persistLocale, + readInitialLocale, + type TranslationKey +} from "./i18n"; +import { ActivityPage } from "./pages/Activity"; +import { OverviewPage } from "./pages/Overview"; +import { ProvidersPage } from "./pages/Providers"; +import { SetupPage } from "./pages/Setup"; +import { SystemPage } from "./pages/System"; +import type { + AccessMode, + ActivityPageData, + BootstrapResult, + DiagnosticResult, + Locale, + MetricsOverview, + MetricsWindow, + ModelCatalog, + Provider, + ProviderInput, + Route, + SupervisorIdentity, + WorkspaceData +} from "./types"; + +const emptyActivity: ActivityPageData = { + events: [], + page: { limit: 50, offset: 0, nextOffset: null } +}; + +function routeFromPath(pathname: string): Route { + if (pathname === "/providers") return "providers"; + if (pathname === "/activity") return "activity"; + if (pathname === "/system") return "system"; + if (pathname === "/setup") return "setup"; + return "overview"; +} + +function routePath(route: Route): string { + return route === "overview" ? "/overview" : `/${route}`; +} + +type NoticeState = { key: TranslationKey; variables?: Record }; +const TOKEN_UNREAD = Symbol("token-unread"); + +export function App() { + const [locale, setLocale] = useState(() => readInitialLocale()); + const [accessMode, setAccessMode] = useState("initializing"); + const [route, setRoute] = useState(() => routeFromPath(location.pathname)); + const [workspace, setWorkspace] = useState(null); + const [activity, setActivity] = useState(emptyActivity); + const [metrics, setMetrics] = useState(null); + const [metricsError, setMetricsError] = useState(null); + const [metricsWindow, setMetricsWindow] = useState("24h"); + const [loadError, setLoadError] = useState(null); + const [actionError, setActionError] = useState(null); + const [terminalError, setTerminalError] = useState(null); + const [notice, setNotice] = useState(null); + const [pending, setPending] = useState(null); + const [activityLoading, setActivityLoading] = useState(false); + const [ready, setReady] = useState(false); + const initializedRef = useRef(false); + const launchTokenRef = useRef(TOKEN_UNREAD); + const loadControllerRef = useRef(null); + const loadSequenceRef = useRef(0); + const metricsSequenceRef = useRef(0); + const activitySequenceRef = useRef(0); + const pendingRef = useRef(false); + const activityLoadingRef = useRef(false); + const metricsWindowRef = useRef(metricsWindow); + const routeFocusSourceRef = useRef(null); + const routeFocusFrameRef = useRef(null); + + const api = useMemo(() => new CrpApi((error) => { + pendingRef.current = false; + setPending(null); + setTerminalError(error); + setAccessMode("terminal"); + setWorkspace(null); + setActionError(null); + setLoadError(null); + }), []); + const t = useMemo(() => createTranslator(locale), [locale]); + + useEffect(() => { + if (!notice) return undefined; + const timeout = window.setTimeout(() => setNotice(null), 4_000); + return () => window.clearTimeout(timeout); + }, [notice]); + + useEffect(() => { + metricsWindowRef.current = metricsWindow; + }, [metricsWindow]); + + useEffect(() => { + document.documentElement.lang = locale; + const titleKey = accessMode === "stopped" + ? "session.stoppedTitle" + : route === "setup" + ? "setup.title" + : `nav.${route}` as TranslationKey; + document.title = `${t(titleKey)} | CRP`; + }, [accessMode, locale, route, t]); + + const loadWorkspace = useCallback(async (window: MetricsWindow): Promise => { + const sequence = ++loadSequenceRef.current; + const metricsSequence = ++metricsSequenceRef.current; + loadControllerRef.current?.abort(); + const controller = new AbortController(); + loadControllerRef.current = controller; + try { + const [status, providers, settings, nextActivity] = await Promise.all([ + api.getStatus(controller.signal), + api.listProviders(controller.signal), + api.getSettings(controller.signal), + api.getActivity(0, controller.signal) + ]); + if (controller.signal.aborted || sequence !== loadSequenceRef.current) return null; + const nextWorkspace = { status, providers, settings }; + setWorkspace(nextWorkspace); + setActivity(nextActivity); + setLoadError(null); + try { + const nextMetrics = await api.getMetrics(window, controller.signal); + if (!controller.signal.aborted + && sequence === loadSequenceRef.current + && metricsSequence === metricsSequenceRef.current) { + setMetrics(nextMetrics); + setMetricsError(null); + } + } catch (error) { + if (!(error instanceof DOMException && error.name === "AbortError") + && sequence === loadSequenceRef.current + && metricsSequence === metricsSequenceRef.current) { + setMetrics(null); + setMetricsError(asApiError(error)); + } + } + return nextWorkspace; + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") return null; + const failure = asApiError(error); + setLoadError(failure); + return null; + } finally { + if (sequence === loadSequenceRef.current) loadControllerRef.current = null; + } + }, [api]); + + useEffect(() => { + if (initializedRef.current) return undefined; + initializedRef.current = true; + let cancelled = false; + const initialize = async () => { + const launchToken = launchTokenRef.current === TOKEN_UNREAD + ? readAndClearControlToken() + : launchTokenRef.current; + launchTokenRef.current = null; + try { + if (launchToken !== null) { + await api.exchangeSession(launchToken); + if (!cancelled) setAccessMode("writable"); + } else { + api.enterReadOnly(); + if (!cancelled) setAccessMode("read-only"); + } + } catch { + // ApiClient already moved this tab into the terminal state. + if (!cancelled) { + setReady(true); + document.documentElement.setAttribute("aria-busy", "false"); + } + return; + } + if (cancelled) return; + const loaded = await loadWorkspace("24h"); + if (cancelled) return; + if (loaded && loaded.status.activeProviderId === null) { + history.replaceState(null, "", "/setup"); + setRoute("setup"); + } + setReady(true); + document.documentElement.setAttribute("aria-busy", "false"); + }; + void initialize(); + return () => { + cancelled = true; + loadControllerRef.current?.abort(); + }; + }, [api, loadWorkspace]); + + useEffect(() => { + const onPopState = () => { + routeFocusSourceRef.current = document.activeElement; + setRoute(routeFromPath(location.pathname)); + }; + addEventListener("popstate", onPopState); + return () => removeEventListener("popstate", onPopState); + }, []); + + useEffect(() => { + if (!ready || accessMode === "terminal") return undefined; + if (routeFocusFrameRef.current !== null) cancelAnimationFrame(routeFocusFrameRef.current); + const source = routeFocusSourceRef.current; + routeFocusFrameRef.current = requestAnimationFrame(() => { + routeFocusFrameRef.current = null; + const active = document.activeElement; + if (active === source || active === document.body || active === null) { + document.querySelector("#main-content")?.focus({ preventScroll: true }); + } + routeFocusSourceRef.current = null; + }); + return () => { + if (routeFocusFrameRef.current !== null) cancelAnimationFrame(routeFocusFrameRef.current); + }; + }, [accessMode, ready, route]); + + const changeLocale = useCallback((next: Locale) => { + persistLocale(next); + setLocale(next); + }, []); + + const navigate = useCallback((next: Route) => { + routeFocusSourceRef.current = document.activeElement; + history.pushState(null, "", routePath(next)); + setActionError(null); + setNotice(null); + setRoute(next); + }, []); + + const refresh = useCallback(async () => { + setActionError(null); + setNotice(null); + const loaded = await loadWorkspace(metricsWindowRef.current); + if (loaded) setNotice({ key: "notice.refreshed" }); + }, [loadWorkspace]); + + const resumeManagement = useCallback(async () => { + if (pendingRef.current || api.mutationAllowed) return; + pendingRef.current = true; + setPending("session-resume"); + setActionError(null); + setNotice(null); + try { + await api.resumeSession(); + setAccessMode("writable"); + await loadWorkspace(metricsWindowRef.current); + setNotice({ key: "notice.managementResumed" }); + } catch (error) { + setActionError(asApiError(error)); + } finally { + pendingRef.current = false; + setPending(null); + } + }, [api, loadWorkspace]); + + const executeMutation = useCallback(async ( + key: string, + operation: () => Promise, + successKey: TranslationKey + ): Promise => { + if (pendingRef.current || !api.mutationAllowed) return null; + pendingRef.current = true; + setPending(key); + setActionError(null); + setNotice(null); + let value: T | null = null; + let failure: ApiError | null = null; + try { + value = await operation(); + } catch (error) { + failure = asApiError(error); + } + if (api.mutationAllowed) await loadWorkspace(metricsWindowRef.current); + if (failure) setActionError(failure); + else setNotice({ key: successKey }); + pendingRef.current = false; + setPending(null); + return failure ? null : value; + }, [api, loadWorkspace]); + + const createProvider = useCallback(async (input: ProviderInput, credential: string) => ( + await executeMutation("provider-create", () => api.createProvider(input, credential), "notice.providerCreated") + ), [api, executeMutation]); + + const updateProvider = useCallback(async (id: string, input: ProviderInput, replacement?: string) => ( + await executeMutation( + `provider-update-${id}`, + () => api.updateProvider(id, input, replacement), + "notice.providerUpdated" + ) + ), [api, executeMutation]); + + const testProvider = useCallback(async ( + id: string, + model: string, + switchAfter: boolean, + activateIfNone = false + ): Promise => { + const result = await executeMutation( + switchAfter ? `provider-switch-${id}` : `provider-test-${id}`, + async () => { + const test = await api.testProvider(id, model, activateIfNone); + if (!test.ok) throw ApiError.fromTestResult(test); + if (switchAfter) await api.activateProvider(id); + return true; + }, + switchAfter ? "notice.providerSwitched" : "notice.providerTested" + ); + return result === true; + }, [api, executeMutation]); + + const activateProvider = useCallback(async (id: string): Promise => ( + await executeMutation( + `provider-switch-${id}`, + async () => { await api.activateProvider(id); return true; }, + "notice.providerSwitched" + ) === true + ), [api, executeMutation]); + + const deleteProvider = useCallback(async (id: string): Promise => ( + await executeMutation( + `provider-delete-${id}`, + async () => { await api.deleteProvider(id); return true; }, + "notice.providerDeleted" + ) === true + ), [api, executeMutation]); + + const getProviderModels = useCallback(async (id: string, signal?: AbortSignal): Promise => ( + await api.getProviderModels(id, signal) + ), [api]); + + const refreshProviderModels = useCallback(async (id: string): Promise => ( + await executeMutation( + `provider-models-${id}`, + () => api.refreshProviderModels(id), + "notice.modelsRefreshed" + ) + ), [api, executeMutation]); + + const startProxy = useCallback(() => { + void executeMutation("proxy-start", () => api.startProxy(), "notice.workerStarted"); + }, [api, executeMutation]); + + const stopProxy = useCallback(() => { + void executeMutation("proxy-stop", () => api.stopProxy(), "notice.workerStopped"); + }, [api, executeMutation]); + + const restartProxy = useCallback(() => { + void executeMutation("proxy-restart", () => api.restartProxy(), "notice.workerRestarted"); + }, [api, executeMutation]); + + const shutdownSupervisor = useCallback(async (): Promise => { + if (pendingRef.current || !api.mutationAllowed) return false; + const supervisor = workspace?.status.supervisor; + if (!Number.isSafeInteger(supervisor?.pid) || supervisor?.pid === null + || typeof supervisor?.startedAt !== "string") { + setActionError(new ApiError("SUPERVISOR_IDENTITY_CHANGED", 409)); + return false; + } + const identity: SupervisorIdentity = { + pid: supervisor.pid, + startedAt: supervisor.startedAt + }; + pendingRef.current = true; + setPending("supervisor-shutdown"); + setActionError(null); + setLoadError(null); + setNotice(null); + try { + await api.shutdownSupervisor(identity); + loadControllerRef.current?.abort(); + loadControllerRef.current = null; + loadSequenceRef.current += 1; + metricsSequenceRef.current += 1; + activitySequenceRef.current += 1; + activityLoadingRef.current = false; + setActivityLoading(false); + setWorkspace(null); + setActivity(emptyActivity); + setMetrics(null); + setMetricsError(null); + setTerminalError(null); + setAccessMode("stopped"); + return true; + } catch (error) { + setActionError(asApiError(error)); + return false; + } finally { + pendingRef.current = false; + setPending(null); + } + }, [api, workspace]); + + const prepareCodex = useCallback(async (): Promise => ( + await executeMutation("codex-bootstrap", () => api.bootstrapCodex(), "notice.codexPrepared") + ), [api, executeMutation]); + + const generateDiagnostics = useCallback(async (): Promise => ( + await executeMutation("diagnostics", () => api.generateDiagnostics(), "notice.diagnosticsReady") + ), [api, executeMutation]); + + const changeMetricsWindow = useCallback((next: MetricsWindow) => { + metricsWindowRef.current = next; + setMetricsWindow(next); + const sequence = ++metricsSequenceRef.current; + setMetricsError(null); + void api.getMetrics(next).then((nextMetrics) => { + if (sequence === metricsSequenceRef.current) setMetrics(nextMetrics); + }).catch((error) => { + if (sequence === metricsSequenceRef.current) { + setMetrics(null); + setMetricsError(asApiError(error)); + } + }); + }, [api]); + + const loadActivityPage = useCallback((offset: number) => { + if (activityLoadingRef.current) return; + activityLoadingRef.current = true; + const sequence = ++activitySequenceRef.current; + setActivityLoading(true); + setActionError(null); + void api.getActivity(offset).then((next) => { + if (sequence === activitySequenceRef.current) setActivity(next); + }).catch((error) => { + if (sequence === activitySequenceRef.current) setActionError(asApiError(error)); + }).finally(() => { + if (sequence === activitySequenceRef.current) { + activityLoadingRef.current = false; + setActivityLoading(false); + } + }); + }, [api]); + + if (accessMode === "terminal") { + return ( +
+
+ +

{t("session.terminalEyebrow")}

+

{t("session.terminalTitle")}

+

{t("session.terminalHelp")}

+

{t("session.reopen")}

+ crp ui + + {terminalError ? {terminalError.code} : null} +
+
+ ); + } + + if (accessMode === "stopped") { + return ( +
+
+ +

{t("session.stoppedEyebrow")}

+

{t("session.stoppedTitle")}

+

{t("session.stoppedHelp")}

+

{t("session.stoppedClose")}

+

{t("session.stoppedRestart")}

+ crp ui + +
+
+ ); + } + + if (accessMode === "initializing" || !ready) { + return ( +
+
+ + CRP +

{t("session.initializing")}

+

{t("session.initializingHelp")}

+
+
+ ); + } + + if (!workspace) { + return ( +
+
+ {loadError ? : null} + +
+
+ ); + } + + const readOnly = accessMode !== "writable"; + const message = actionError + ? + : loadError + ? + : notice + ? + : undefined; + const dismissMessage = () => { + setActionError(null); + setLoadError(null); + setNotice(null); + }; + const sharedProviderProps = { + t, + providers: workspace.providers, + activeProviderId: workspace.status.activeProviderId, + readOnly, + pending, + onCreate: createProvider, + onTest: testProvider, + onActivate: activateProvider, + onGetModels: getProviderModels, + onRefreshModels: refreshProviderModels + }; + + return ( + void refresh()} + onDismissMessage={dismissMessage} + onResume={() => void resumeManagement()} + onActivate={activateProvider} + onStart={startProxy} + onStop={stopProxy} + onRestart={restartProxy} + onShutdown={shutdownSupervisor} + > + {route === "overview" ? ( + void prepareCodex()} + /> + ) : null} + {route === "providers" ? ( + + ) : null} + {route === "activity" ? ( + + ) : null} + {route === "system" ? ( + + ) : null} + {route === "setup" ? ( + navigate("overview")} + /> + ) : null} + + ); +} diff --git a/node/ui-src/src/components/Charts.tsx b/node/ui-src/src/components/Charts.tsx new file mode 100644 index 0000000..22444d3 --- /dev/null +++ b/node/ui-src/src/components/Charts.tsx @@ -0,0 +1,260 @@ +import { memo } from "react"; + +import { formatCompactNumber, formatDate, formatNumber, type Translator } from "../i18n"; +import type { + Locale, + MetricsResultKey, + MetricsResults, + MetricsSeriesPoint, + TokenTotals +} from "../types"; + +const resultKeys: MetricsResultKey[] = [ + "success", + "upstreamRejected", + "upstreamError", + "timeout", + "networkError", + "clientAbort" +]; + +const emptyResults = (): MetricsResults => ({ + success: 0, + upstreamRejected: 0, + upstreamError: 0, + timeout: 0, + networkError: 0, + clientAbort: 0 +}); + +function groupSeries(series: MetricsSeriesPoint[], maximum = 28): MetricsSeriesPoint[] { + if (series.length <= maximum) return series; + const size = Math.ceil(series.length / maximum); + const grouped: MetricsSeriesPoint[] = []; + for (let index = 0; index < series.length; index += size) { + const slice = series.slice(index, index + size); + const first = slice[0]; + if (!first) continue; + const results = emptyResults(); + const tokens: TokenTotals = { input: 0, output: 0, observedRequests: 0 }; + let requests = 0; + for (const point of slice) { + requests += point.requests; + for (const key of resultKeys) results[key] += point.results[key]; + tokens.input += point.tokens.input; + tokens.output += point.tokens.output; + tokens.observedRequests += point.tokens.observedRequests; + } + grouped.push({ start: first.start, requests, results, tokens }); + } + return grouped; +} + +function resultLabels(t: Translator): Record { + return { + success: t("metrics.success"), + upstreamRejected: t("metrics.rejected"), + upstreamError: t("metrics.upstreamError"), + timeout: t("metrics.timeout"), + networkError: t("metrics.networkError"), + clientAbort: t("metrics.clientAbort") + }; +} + +export const ResultsChart = memo(function ResultsChart({ + series, + locale, + t +}: { + series: MetricsSeriesPoint[]; + locale: Locale; + t: Translator; +}) { + const labels = resultLabels(t); + const points = groupSeries(series); + const width = 720; + const plotHeight = 154; + const baseline = 174; + const plotStart = 38; + const plotWidth = 664; + const maxRequests = Math.max(1, ...points.map((point) => point.requests)); + const slot = plotWidth / Math.max(1, points.length); + const barWidth = Math.max(4, slot - 4); + const labelIndexes = new Set([0, Math.floor((points.length - 1) / 2), points.length - 1]); + + return ( +
+ + + {t("overview.requestTrend")} + {[0, 0.5, 1].map((ratio) => { + const y = baseline - ratio * plotHeight; + return ; + })} + {points.map((point, pointIndex) => { + const x = plotStart + pointIndex * slot + (slot - barWidth) / 2; + let used = 0; + return ( + + {resultKeys.map((key) => { + const value = point.results[key]; + const height = value / maxRequests * plotHeight; + const y = baseline - used - height; + used += height; + return height > 0 ? ( + + ) : null; + })} + {labelIndexes.has(pointIndex) ? ( + + {new Intl.DateTimeFormat(locale, { month: "short", day: "numeric", hour: "2-digit" }).format(new Date(point.start))} + + ) : null} + + ); + })} + +
+ + + {resultKeys.map((key) => )} + {points.map((point) => ( + + + + {resultKeys.map((key) => )} + + ))} +
{t("overview.requestTrend")}
{t("common.time")}{t("overview.requestVolume")}{labels[key]}
{formatDate(locale, point.start)}{point.requests}{point.results[key]}
+
+
+ ); +}); + +function linePoints(values: number[], maximum: number, width: number, height: number, x: number, y: number): string { + const step = values.length > 1 ? width / (values.length - 1) : 0; + return values.map((value, index) => ( + `${x + index * step},${y + height - value / maximum * height}` + )).join(" "); +} + +export const TokenChart = memo(function TokenChart({ + series, + locale, + t +}: { + series: MetricsSeriesPoint[]; + locale: Locale; + t: Translator; +}) { + const points = groupSeries(series); + const input = points.map((point) => point.tokens.input); + const output = points.map((point) => point.tokens.output); + const maximum = Math.max(1, ...input, ...output); + const observed = points.reduce((sum, point) => sum + point.tokens.observedRequests, 0); + if (observed === 0) { + return
{t("overview.noTokenUsage")}
; + } + return ( +
+ + + {t("overview.tokenTrend")} + {[20, 97, 174].map((y) => )} + + + {points[0] ? formatDate(locale, points[0].start) : ""} + + {points.at(-1) ? formatDate(locale, points.at(-1)?.start ?? null) : ""} + + +
+ + + + {points.map((point) => ( + + + + + + + ))} +
{t("overview.tokenTrend")}
{t("common.time")}{t("overview.inputTokens")}{t("overview.outputTokens")}{t("overview.requestVolume")}
{formatDate(locale, point.start)}{point.tokens.input}{point.tokens.output}{point.tokens.observedRequests}
+
+
+ ); +}); + +export const DistributionChart = memo(function DistributionChart({ + items, + locale, + title, + t +}: { + items: Array<{ label: string; value: number }>; + locale: Locale; + title: string; + t: Translator; +}) { + const visible = items; + const maximum = Math.max(1, ...visible.map((item) => item.value)); + const rowHeight = 38; + const height = Math.max(78, visible.length * rowHeight + 20); + return ( +
+ + {title} + {visible.map((item, index) => { + const y = index * rowHeight + 8; + const width = item.value / maximum * 330; + return ( + + {item.label} + + {item.label.length > 30 ? `${item.label.slice(0, 27)}...` : item.label} + + + + + {formatCompactNumber(locale, item.value)} + + + ); + })} + +
+ + + + {visible.map((item, index) => ( + + ))} +
{title}
{t("metrics.group")}{t("overview.requestVolume")}
{item.label}{formatNumber(locale, item.value)}
+
+
+ ); +}); diff --git a/node/ui-src/src/components/ModelPicker.tsx b/node/ui-src/src/components/ModelPicker.tsx new file mode 100644 index 0000000..5fd0308 --- /dev/null +++ b/node/ui-src/src/components/ModelPicker.tsx @@ -0,0 +1,85 @@ +import { useState } from "react"; + +import type { Translator } from "../i18n"; +import { Field, SelectField } from "./Primitives"; + +const MANUAL_VALUE = "__crp_manual_model__"; + +type ModelPickerProps = { + id: string; + model: string; + models: string[]; + disabled?: boolean; + autoFocus?: boolean; + t: Translator; + onChange: (model: string) => void; +}; + +export function ModelPicker({ + id, + model, + models, + disabled = false, + autoFocus = false, + t, + onChange +}: ModelPickerProps) { + const [manualRequested, setManualRequested] = useState(false); + const manual = models.length === 0 + || manualRequested + || model !== "" && !models.includes(model); + + if (models.length === 0) { + return ( + onChange(event.target.value)} + disabled={disabled} + autoFocus={autoFocus} + spellCheck={false} + /> + ); + } + + return ( +
+ { + if (event.target.value === MANUAL_VALUE) { + setManualRequested(true); + onChange(""); + return; + } + setManualRequested(false); + onChange(event.target.value); + }} + disabled={disabled} + autoFocus={autoFocus && !manual} + > + {models.map((item) => )} + + + {manual ? ( + onChange(event.target.value)} + disabled={disabled} + autoFocus={autoFocus} + spellCheck={false} + /> + ) : null} +
+ ); +} diff --git a/node/ui-src/src/components/Primitives.tsx b/node/ui-src/src/components/Primitives.tsx new file mode 100644 index 0000000..fba3d41 --- /dev/null +++ b/node/ui-src/src/components/Primitives.tsx @@ -0,0 +1,418 @@ +import { + AlertCircle, + CheckCircle2, + Info, + LoaderCircle, + TriangleAlert, + X +} from "lucide-react"; +import { + forwardRef, + type ButtonHTMLAttributes, + type InputHTMLAttributes, + type PropsWithChildren, + type ReactNode, + type SelectHTMLAttributes, + type TextareaHTMLAttributes, + useEffect, + useId, + useRef +} from "react"; + +import { ApiError } from "../api"; +import type { Translator, TranslationKey } from "../i18n"; + +export function cx(...values: Array): string { + return values.filter(Boolean).join(" "); +} + +type ButtonProps = ButtonHTMLAttributes & { + variant?: "primary" | "secondary" | "ghost" | "danger"; + busy?: boolean; +}; + +export const Button = forwardRef(function Button( + { variant = "secondary", busy = false, className, children, disabled, type = "button", ...props }, + ref +) { + return ( + + ); +}); + +type IconButtonProps = ButtonHTMLAttributes & { + label: string; + children: ReactNode; +}; + +export const IconButton = forwardRef(function IconButton( + { label, children, className, type = "button", ...props }, + ref +) { + return ( + + ); +}); + +export function Panel({ children, className }: PropsWithChildren<{ className?: string }>) { + return
{children}
; +} + +export function PanelHeader({ + title, + description, + action +}: { + title: string; + description?: string; + action?: ReactNode; +}) { + return ( +
+
+

{title}

+ {description ?

{description}

: null} +
+ {action ?
{action}
: null} +
+ ); +} + +export function PageHeader({ + title, + subtitle, + action +}: { + title: string; + subtitle: string; + action?: ReactNode; +}) { + return ( +
+
+

{title}

+

{subtitle}

+
+ {action ?
{action}
: null} +
+ ); +} + +export function StatusBadge({ + children, + tone = "neutral", + icon = true +}: PropsWithChildren<{ + tone?: "success" | "warning" | "danger" | "neutral" | "info"; + icon?: boolean; +}>) { + return ( + + {icon ? + ); +} + +export function Notice({ + title, + children, + tone = "info", + role +}: PropsWithChildren<{ + title: string; + tone?: "info" | "success" | "warning" | "danger"; + role?: "alert" | "status"; +}>) { + const Icon = tone === "success" + ? CheckCircle2 + : tone === "warning" + ? TriangleAlert + : tone === "danger" + ? AlertCircle + : Info; + return ( +
+
+ ); +} + +function errorCopy(error: ApiError): [TranslationKey, TranslationKey] { + const code = error.code.toUpperCase(); + if (code.includes("AUTH") && code.startsWith("PROVIDER")) { + return ["error.authTitle", "error.authAction"]; + } + if (code.includes("TIMEOUT")) return ["error.timeoutTitle", "error.timeoutAction"]; + if (code === "PROVIDER_NOT_READY") return ["error.notReadyTitle", "error.notReadyAction"]; + if (code === "PROVIDER_ACTIVE") return ["error.activeTitle", "error.activeAction"]; + if (code.startsWith("CODEX_") || code.startsWith("MIGRATION_")) { + return error.details.degraded === true || code.includes("DEGRADED") + ? ["error.degradedTitle", "error.degradedAction"] + : ["error.codexTitle", "error.codexAction"]; + } + if (code.includes("INPUT") || code.includes("BODY_INVALID") || code.includes("VALIDATION")) { + return ["error.inputTitle", "error.inputAction"]; + } + if (code.startsWith("AUTH_")) return ["error.sessionTitle", "error.sessionAction"]; + if (error.details.degraded === true || error.details.committed === true || code.includes("DEGRADED")) { + return ["error.degradedTitle", "error.degradedAction"]; + } + return ["error.genericTitle", "error.genericAction"]; +} + +export function ErrorNotice({ error, t }: { error: ApiError; t: Translator }) { + const [titleKey, actionKey] = errorCopy(error); + const details = Object.entries(error.details); + return ( + +

{t(actionKey)}

+
+ {t("error.technical")} +
+
{t("error.code")}
{error.code}
+ {error.requestId ? ( +
{t("error.requestId")}
{error.requestId}
+ ) : null} + {details.map(([key, value]) => ( +
{key}
{String(value)}
+ ))} +
+
+
+ ); +} + +export function EmptyState({ + icon, + title, + description, + action +}: { + icon: ReactNode; + title: string; + description: string; + action?: ReactNode; +}) { + return ( +
+ +

{title}

+

{description}

+ {action ?
{action}
: null} +
+ ); +} + +type FieldProps = InputHTMLAttributes & { + label: string; + help?: string; + error?: string; +}; + +export const Field = forwardRef(function Field( + { label, help, error, id, className, ...props }, + ref +) { + const fieldId = id ?? props.name; + const helpId = help && fieldId ? `${fieldId}-help` : undefined; + const errorId = error && fieldId ? `${fieldId}-error` : undefined; + return ( + + ); +}); + +type SelectFieldProps = SelectHTMLAttributes & { + label: string; + help?: string; +}; + +export function SelectField({ label, help, id, children, className, ...props }: PropsWithChildren) { + const fieldId = id ?? props.name; + const helpId = help && fieldId ? `${fieldId}-help` : undefined; + return ( + + ); +} + +type TextareaFieldProps = TextareaHTMLAttributes & { + label: string; + help?: string; + error?: string; +}; + +export function TextareaField({ label, help, error, id, className, ...props }: TextareaFieldProps) { + const fieldId = id ?? props.name; + const helpId = help && fieldId ? `${fieldId}-help` : undefined; + const errorId = error && fieldId ? `${fieldId}-error` : undefined; + return ( +