diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39930f894..9661be420 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,21 +48,20 @@ jobs: test: name: ${{ matrix.os }} runs-on: ${{ matrix.os }} - # Windows dominates this matrix: on run 30459554635 the same suite took - # ubuntu 4.6min / macos 5.6min / windows 11.8min. Against the previous - # 12-minute ceiling that left ~12s of headroom, so runner variance decided - # the result rather than the code under review — #711's rerun finished at - # 11.8min and passed while #653's was killed at 12.0min (issue #717). - # A cancelled job renders as `fail` in `gh pr checks`, so that flakiness - # reads as a broken PR. 20 minutes keeps a green Windows run green with - # real margin; it is not a licence for the suite to grow into it. If - # Windows approaches this ceiling too, fix the 2.5x platform gap instead - # of raising the number again. - timeout-minutes: 20 + # Windows dominates this matrix: recent green runs took about 11.8 minutes + # versus 4.6 on Linux and 5.6 on macOS. Keep platform-specific ceilings so + # POSIX regressions remain bounded while Windows has enough runner margin. + timeout-minutes: ${{ matrix.timeout }} strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest, macos-latest] + include: + - os: ubuntu-latest + timeout: 12 + - os: macos-latest + timeout: 12 + - os: windows-latest + timeout: 20 steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 diff --git a/README.md b/README.md index 86b30f082..2b881eec3 100644 --- a/README.md +++ b/README.md @@ -310,7 +310,7 @@ ocx init # interactive setup ocx start [--port 10100] # start the proxy; falls back to a free port if busy ocx stop # stop + restore native Codex ocx restore # restore without stopping (alias: ocx eject) -ocx uninstall # remove service/shim/config and restore native Codex +ocx uninstall # remove service/shim/owned state and restore native Codex ocx ensure # start if needed + refresh Codex config/cache ocx sync # refresh models + re-inject into Codex ocx codex-shim install # run `ocx ensure` whenever `codex` is launched @@ -384,8 +384,11 @@ ocx uninstall npm uninstall -g @bitkyc08/opencodex ``` -`ocx uninstall` stops the proxy, removes any installed service, removes the Codex shim, restores -native Codex config/catalog/history, and deletes `~/.opencodex`. +`ocx uninstall` stops the proxy, removes any installed service, removes the Codex shim, and restores +native Codex config/catalog/history. It removes local state only through the installation ownership +manifest: entries not recorded there are preserved. A legacy non-empty config directory without a +manifest, or a directory that still contains foreign files after owned entries are removed, is left +in place for manual review. Inspect the path reported by the command before deleting any remainder. ## Configuration @@ -466,26 +469,55 @@ WebSocket transport is off by default. Set `"websockets": true` only if you want ### Remote access -By default opencodex binds to `127.0.0.1` (loopback) and requires no extra authentication. -If you set `"hostname": "0.0.0.0"` to expose the proxy on the LAN, opencodex requires a bearer token -to protect both the management API (`/api/*`) and the data-plane (`/v1/responses`, -`/v1/images/generations`, and `/v1/images/edits`): +By default opencodex binds to `127.0.0.1` (loopback). Authentication is split by role: + +- **Data-plane credentials** — `OPENCODEX_API_AUTH_TOKEN`, the protected `service-api-token` file, + and dashboard-generated `config.apiKeys` authorize only `/v1/*` and the corresponding WebSocket + handshakes. A non-loopback bind requires either `OPENCODEX_API_AUTH_TOKEN` (set directly or + delivered through the protected service file) or at least one `config.apiKeys` entry. +- **Management credentials** — `OPENCODEX_ADMIN_AUTH_TOKEN` or the independent protected + `admin-api-token` file authorize only `/api/*`. The server creates the file when no management + environment token is configured. Service installs persist an explicit management token through + the protected `service-admin-token` delivery file; this is still the same management credential + class, not a fourth credential type. +- **Dashboard sessions** — a legal same-origin loopback dashboard entry receives a short-lived, + Origin-bound session that authorizes only `/api/*`. Remote operators and scripts must use the + management token instead. + +**Upgrade note:** earlier releases also admitted `OPENCODEX_API_AUTH_TOKEN` and `config.apiKeys` to +`/api/*`. They are data-plane-only after this split. Update existing management scripts to use +`OPENCODEX_ADMIN_AUTH_TOKEN` or the protected `admin-api-token`; data-plane clients do not need to +change. + +For a LAN bind, configure either form of data-plane credential before starting. The environment-token +form is shown below. Set an explicit management token as well when remote operators or automation +need a stable credential: ```bash -export OPENCODEX_API_AUTH_TOKEN="your-secret-token" +export OPENCODEX_API_AUTH_TOKEN="your-data-plane-token" +export OPENCODEX_ADMIN_AUTH_TOKEN="your-management-token" ocx start ``` -The proxy refuses to start without this variable when binding beyond loopback. If you install a -background service for LAN access, export the same variable before `ocx service install` so the -service manager receives it. -Clients (scripts, remote machines) must include the token in every request: +Export the same variables before `ocx service install`. The installer writes their values to the +protected `service-api-token` and `service-admin-token` delivery files; service definitions store +only those file paths, never the raw token values. +Data-plane clients and management clients use the same header name, but their credential values are +not interchangeable: ``` -x-opencodex-api-key: your-secret-token +x-opencodex-api-key: the-token-for-this-request-plane ``` -The token is compared in constant time to prevent timing attacks. +Use `x-opencodex-api-key` for compatibility across the data plane. `Authorization: Bearer …` is +also accepted by management routes and data routes that permit bearer admission, but +`/v1/responses`, `/v1/responses/compact`, `/v1/chat/completions`, and the Responses WebSocket +handshake require the dedicated `x-opencodex-api-key` header. + +If the management token cannot be created, permission-hardened, or read, `/api/*` returns `503` and +never falls back to loopback trust. `/v1/*` and the unauthenticated `/healthz` endpoint continue to +operate. Long-lived data-plane and management secrets, plus GUI CSRF tokens, use constant-time +comparisons; short-lived high-entropy GUI session ids use an in-memory lookup. opencodex automatically remaps Codex resume history so old OpenAI chats and opencodex-created project threads stay visible in Codex App while the proxy is active. opencodex records the original provider/source metadata in diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 4e20b136b..542ad72a0 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -29,6 +29,10 @@ they expire or the proxy restarts. Only a dashboard bound to a non-loopback host the admin token (`OPENCODEX_ADMIN_AUTH_TOKEN`, or the auto-generated `~/.opencodex/admin-api-token` file). +Dashboard responses set `X-Frame-Options: DENY` and CSP `frame-ancestors 'none'` to prevent +clickjacking. Embedding the dashboard in an `iframe` is intentionally unsupported; open it as a +top-level page through `ocx gui`. + ## What you can do | Area | What it does | diff --git a/docs-site/src/content/docs/ja/guides/web-dashboard.md b/docs-site/src/content/docs/ja/guides/web-dashboard.md index 16a62950a..ccb51d8ed 100644 --- a/docs-site/src/content/docs/ja/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ja/guides/web-dashboard.md @@ -21,6 +21,10 @@ ocx start bun run dev:gui ``` +クリックジャッキングを防ぐため、ダッシュボード応答には `X-Frame-Options: DENY` と CSP +`frame-ancestors 'none'` が設定されます。ダッシュボードの `iframe` 埋め込みは意図的に +サポートされません。`ocx gui` からトップレベルページとして開いてください。 + ## できること | 領域 | 機能 | diff --git a/docs-site/src/content/docs/ja/reference/cli.md b/docs-site/src/content/docs/ja/reference/cli.md index d54e2f63c..4a8e38608 100644 --- a/docs-site/src/content/docs/ja/reference/cli.md +++ b/docs-site/src/content/docs/ja/reference/cli.md @@ -127,7 +127,10 @@ ocx status --json ### `ocx uninstall`  ·  `ocx remove` サービスとプロキシを停止し、サービスと Codex shim を削除したのちネイティブ Codex を復元します。すべての -復元ステップが成功した場合にのみ opencodex のローカル設定まで消します。`remove` は `uninstall` の別名です。 +復元ステップが成功した場合にのみ opencodex 所有のローカル状態を削除します。削除はインストール所有権 +マニフェストに従い、記録されていない項目は保持します。マニフェストがない旧版の空でない設定ディレクトリや、 +所有項目の削除後も外部ファイルが残るディレクトリは手動確認のためそのまま残ります。残りを手動削除する前に +報告されたパスを確認してください。`remove` は `uninstall` の別名です。 ## モデルと Codex diff --git a/docs-site/src/content/docs/ja/reference/configuration.md b/docs-site/src/content/docs/ja/reference/configuration.md index 524131fae..578d00921 100644 --- a/docs-site/src/content/docs/ja/reference/configuration.md +++ b/docs-site/src/content/docs/ja/reference/configuration.md @@ -25,7 +25,7 @@ namespaced selected id を bare id に変えます。 | Field | Type | Default | Meaning | | --- | --- | --- | --- | | `port` | `number` | `10100` | プロキシがリッスンするポート。 | -| `hostname?` | `string` | `"127.0.0.1"` | バインドアドレス。LAN に公開するには `"0.0.0.0"` に設定します(`OPENCODEX_API_AUTH_TOKEN` が必要、下記 [リモートアクセス](#リモートアクセス) 参照)。 | +| `hostname?` | `string` | `"127.0.0.1"` | バインドアドレス。LAN に公開するには `"0.0.0.0"` に設定します(データプレーンには `OPENCODEX_API_AUTH_TOKEN` または `config.apiKeys` が必要で、管理認証は独立しています。下記 [リモートアクセス](#リモートアクセス) 参照)。 | | `proxy?` | `string` | — | 外向きの HTTP(S) プロキシ URL または `${ENV_VAR}` 参照。該当環境変数が空のとき `HTTP_PROXY` / `HTTPS_PROXY` に適用し、loopback は `NO_PROXY` に維持します。 | | `providers` | `Record` | — | プロバイダー名 → 設定 map。 | | `openaiProviderTierVersion?` | `2` | 移行設定 | 単一の省略可能 OpenAI projection 完了マーカー。 | @@ -46,7 +46,7 @@ namespaced selected id を bare id に変えます。 | `connectTimeoutMs?` | `number` | `200000` | DNS/TCP/TLS と最終レスポンスヘッダーだけを待つ試行ごとの deadline。レスポンス body 生成前に終了します。 | | `shutdownTimeoutMs?` | `number` | `5000` | 進行中のターンを中断する前の graceful drain deadline。 | | `websockets?` | `boolean` | `false` | `supports_websockets` を知らせ Codex が Responses WebSocket 経路を使うようにします。省略または `false` なら HTTP/SSE を維持します。 | -| `apiKeys?` | `OcxApiKey[]` | `[]` | 非 loopback バインドで管理 API とデータプレーン認証に追加で許可する生成型 `ocx_…` 認証情報。ダッシュボードが管理し、項目フィールドは下で説明します。 | +| `apiKeys?` | `OcxApiKey[]` | `[]` | `/v1/*` と対応する WebSocket ハンドシェイクだけで使える追加のデータプレーン `ocx_…` 認証情報です。`/api/*` は認証しません。ダッシュボードが管理し、項目フィールドは下で説明します。 | | `codexAutoStart?` | `boolean` | `true` | Codex shim が Codex 実行前に `ocx ensure` を実行するようにします。`false` なら `ocx ensure` は何もしません。 | | `codexShimAutoRestore?` | `boolean` | `true` | 完了した外部 Codex 更新で以前にインストールした shim が置換された場合に復元します。無効にするには `false`、またはプロセスで `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` を設定します。 | | `syncResumeHistory?` | `boolean` | `true` | 戻せる Codex App 履歴互換モード。opencodex は元の Codex thread metadata をバックアップし、旧 OpenAI interactive row を `opencodex` に再マッピングし、opencodex が作成した `exec` row を App に見えるソースとして一時的に昇格します。`ocx stop` / `ocx restore` はバックアップした OpenAI row を復元し、残った opencodex user thread を OpenAI に戻し、ネイティブ Codex が `config.toml` からプロキシを削除した後でも開き続けられるようにします。オフにするには `false` に設定します。 | @@ -63,7 +63,7 @@ namespaced selected id を bare id に変えます。 | `webSearchSidecar?` | `OcxWebSearchSidecarConfig` | on | ウェブ検索サイドカーオプション(下記参照)。 | | `visionSidecar?` | `OcxVisionSidecarConfig` | on | ビジョンサイドカーオプション(下記参照)。 | | `tokenGuardian?` | `OcxTokenGuardianConfig` | off | 選択型の proactive OAuth 更新と Codex アカウント warmup ポリシー。フィールドは下で説明します。 | -| `corsAllowOrigins?` | `string[]` | `[]` | CORS で追加で許可する正確な origin。loopback origin は常に許可します。 | +| `corsAllowOrigins?` | `string[]` | `[]` | データプレーン CORS で追加で許可する正確な origin。loopback origin は常に許可します。管理 CORS や GUI セッション発行の範囲は拡張しません。 | `codexAccountNamespaces` のキーは公開 selector です。長さは 1〜64 文字、先頭と末尾は ASCII 英数字、内部には英数字、`.`、`_`、`-` を使用でき、予約済み JavaScript object 名は拒否されます。 @@ -115,35 +115,92 @@ pool アカウントの追加と quota 更新はダッシュボードの **Codex | `codexWarmupMaxAgeSeconds?` | `number` | `691200` | アカウントを再検証する最大期間(8 日)。 | | `codexWarmupModel?` | `string` | `gpt-5.4-mini` | 選択型 warmup に使うネイティブモデル。 | +## プロバイダー診断の外向き通信安全性 + +ダッシュボードのプロバイダー接続テストとライブモデル検出は、制限付きの `GET` 専用外向き通信を使用します。 +外向きプロキシがなければ、opencodex はプロバイダーのホスト名を一度だけ解決し、その検証済みアドレスにだけ +接続します。`HTTPS` は `Host`、`SNI`、証明書検証に元のホスト名を維持し、プロバイダー設定から証明書検証を +無効にすることはできません。 + +URL のプロトコルに対応する `HTTP_PROXY` または `HTTPS_PROXY` が適用される場合、この二つの操作は +既存のプロキシを暗黙に迂回しないよう Bun ネイティブの `fetch` を維持します。小文字の `http_proxy`、 +`https_proxy`、`no_proxy` は、空でない場合に対応する大文字の変数より優先されます。空の小文字変数で +大文字変数を無効化する方法は移植性がありません。opencodex は空でない大文字値へフォールバックし、 +これは POSIX の Bun と同じですが、Windows で Bun を直接使う場合は直結になることがあります。 +継承したプロキシを無効にするには大小両方の変数を削除してください。同梱の +Bun 1.3.14 は `ALL_PROXY` を使用しません。これが URL のプロトコルに対する唯一の有効なプロキシ宣言なら、 +接続テストとモデル検出は安全側に拒否し、対応するプロトコル変数を設定するよう案内します。 +URL とリテラルアドレスの検査は引き続き行い、 +ローカル DNS が成功すれば結果を分類します。ただしプロキシ専用ネットワークでは名前解決をプロキシへ委ねるのが +一般的なため、ローカル DNS 失敗は許可します。最終ルート、DNS 応答、接続先はプロキシが選ぶので、opencodex は +この経路でプロキシが選んだ相手を固定または検証できないことをログに記録します。これは明示的な安全上の制約であり、 +DNS リバインディングと同等の保護ではありません。 + +外向きプロキシが設定されている場合、プライベートまたはローカルなプロバイダー宛先には +`allowPrivateNetwork: true` と一致する `NO_PROXY` 項目の両方が必要です。ループバック項目は自動で +`NO_PROXY` に追加されます。`192.168.1.50` のような LAN プロバイダーは明示的に追加してください。未追加なら +接続テストとモデル検出はプロキシへ送らず、対処方法付きで拒否します。`allowPrivateNetwork` を有効にしても +メタデータとリンクローカル宛先は拒否されます。安全検査は `NO_PROXY` の完全一致ホスト、ドメインサフィックス、 +任意ポート、角括弧付き IPv6、`*` を扱いますが CIDR は解釈しません。プライベートプロバイダーのホスト名または +アドレスを個別に列挙してください。 + +直結とプロキシの両診断経路はリダイレクトを拒否し、認証情報を除いた転送先を報告します。最終プロバイダー URL を +直接設定してください。通常のプロバイダーリクエスト、ストリーミング応答、再試行経路はこの段階では未移行です。 +それらのリダイレクト処理とホップごとの宛先検査は延期中なので、この段階で主リクエストのリダイレクト問題は +解消されません。 + ## リモートアクセス opencodex はデフォルトで `127.0.0.1`(loopback 専用)にバインドします。`hostname` を `0.0.0.0` のような -非 loopback アドレスに設定すると管理 API(`/api/*`)とデータプレーン(`/v1/responses`)の **両方** に token -認証を強制します。 +非 loopback アドレスに設定すると、データプレーン(`/v1/*` と対応する WebSocket ハンドシェイク)には +少なくとも一つの認証情報が必要です。`OPENCODEX_API_AUTH_TOKEN`(直接設定、または保護された +`service-api-token` ファイル経由)か、ダッシュボードで生成した `config.apiKeys` の項目を使用できます。 -起動前に `OPENCODEX_API_AUTH_TOKEN` 環境変数を設定してください。 +起動前にいずれかのデータプレーン認証情報を設定してください。以下は環境 token 形式です。リモート運用者や +自動化に安定した認証情報が必要なら、独立した管理 token も明示的に設定します。 ```bash -export OPENCODEX_API_AUTH_TOKEN="your-secret-token" +export OPENCODEX_API_AUTH_TOKEN="your-data-plane-token" +export OPENCODEX_ADMIN_AUTH_TOKEN="your-management-token" ocx start ``` -非 loopback バインドではこの変数がないとプロキシは起動しません。LAN アクセス用のバックグラウンド -サービスをインストールするときも同じ変数を先に export したのち `ocx service install` を実行し、launchd、 -systemd、Task Scheduler に渡す必要があります。クライアントはすべてのリクエストの `x-opencodex-api-key` ヘッダーに -token を入れる必要があります。 +非 loopback バインドでは `OPENCODEX_API_AUTH_TOKEN`(直接設定、または保護された +`service-api-token` ファイル経由)と `config.apiKeys` の両方がないとプロキシは起動しません。 +管理面は独立しており、 +`OPENCODEX_ADMIN_AUTH_TOKEN` または別途生成され権限が保護された `admin-api-token` ファイルだけが +`/api/*` を認証します。サービスをインストールすると、明示した管理 token は保護された +`service-admin-token` 配布ファイルに保存されます。これは管理認証情報の配布方法であり、第四の認証情報種別ではありません。 + +:::caution[認証情報の移行] +以前のリリースでは `OPENCODEX_API_AUTH_TOKEN` と `config.apiKeys` も `/api/*` に使用できました。 +分離後はデータプレーン専用です。既存の管理スクリプトは `OPENCODEX_ADMIN_AUTH_TOKEN` または保護された +`admin-api-token` に切り替えてください。データプレーンのクライアントは変更不要です。 +::: + +`ocx service install` の前に必要な明示 token を export してください。インストーラーは値を保護された +`service-api-token` と `service-admin-token` 配布ファイルへ書き込みます。launchd、systemd、 +Task Scheduler、WinSW のサービス定義には対応するファイルパスだけが保存され、token の平文は保存されません。 +クライアントは対象面の認証情報を `x-opencodex-api-key` ヘッダーに入れます。 -``` -x-opencodex-api-key: your-secret-token +```http +x-opencodex-api-key: the-token-for-this-request-plane ``` -`Authorization: Bearer …` ヘッダーも許可します。起動後はダッシュボードで生成した `apiKeys` を環境変数 -token の代わりに使えます。すべての候補は timing side channel を防ぐため定数時間 -(`timingSafeEqual`)で比較します。 +すべてのデータプレーン経路との互換性のため、`x-opencodex-api-key` を使用してください。 +`Authorization: Bearer …` は管理 route と bearer admission を許可するデータ route でも使えますが、 +`/v1/responses`、`/v1/responses/compact`、`/v1/chat/completions`、および Responses の +WebSocket ハンドシェイクには `x-opencodex-api-key` が必要です。データプレーンと管理面の認証情報は +交換できません。正当な同一 origin の loopback ダッシュボード入口には、短期かつ Origin に結び付いた +GUI セッションが発行されますが、これも `/api/*` 専用です。リモート運用者とスクリプトは管理 token を +使う必要があります。管理認証情報の作成、権限強化、読み取りのいずれかに失敗すると、すべての `/api/*` は +`503` を返しますが、`/v1/*` と認証不要の `/healthz` は継続します。長期のデータプレーン秘密、 +管理秘密、および GUI CSRF token は定数時間(`timingSafeEqual`)で比較し、短期で高エントロピーな +GUI セッション id はメモリ内検索で検証します。 :::caution[LAN 公開] `0.0.0.0` にバインドするとプロキシと設定されたすべてのプロバイダー認証情報がローカルネットワークにさらされます。 -信頼できるネットワークでのみ使い、強力な `OPENCODEX_API_AUTH_TOKEN` を必ず設定してください。 +信頼できるネットワークでのみ使い、データプレーンと管理面には強力で異なる token を設定してください。 ::: ## プロバイダー(`OcxProviderConfig`) diff --git a/docs-site/src/content/docs/ko/guides/web-dashboard.md b/docs-site/src/content/docs/ko/guides/web-dashboard.md index b5ea350c9..679af60de 100644 --- a/docs-site/src/content/docs/ko/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ko/guides/web-dashboard.md @@ -21,6 +21,10 @@ ocx start bun run dev:gui ``` +클릭재킹을 막기 위해 대시보드 응답은 `X-Frame-Options: DENY`와 CSP +`frame-ancestors 'none'`를 설정합니다. 대시보드를 `iframe`에 삽입하는 방식은 의도적으로 +지원하지 않습니다. `ocx gui`로 최상위 페이지에서 여세요. + ## 할 수 있는 일 | 영역 | 기능 | diff --git a/docs-site/src/content/docs/ko/reference/cli.md b/docs-site/src/content/docs/ko/reference/cli.md index db267493e..901f9ebeb 100644 --- a/docs-site/src/content/docs/ko/reference/cli.md +++ b/docs-site/src/content/docs/ko/reference/cli.md @@ -131,7 +131,10 @@ ocx status --json ### `ocx uninstall`  ·  `ocx remove` 서비스와 프록시를 중지하고 서비스와 Codex shim을 제거한 뒤 네이티브 Codex를 복원합니다. 모든 -복원 단계가 성공한 경우에만 opencodex 로컬 설정까지 지웁니다. `remove`는 `uninstall`의 별칭입니다. +복원 단계가 성공한 경우에만 opencodex 소유의 로컬 상태를 삭제합니다. 삭제는 설치 소유권 +매니페스트를 따르며 기록되지 않은 항목은 보존합니다. 매니페스트가 없는 기존 비어 있지 않은 설정 +디렉터리와 소유 항목 삭제 후에도 외부 파일이 남은 디렉터리는 수동 검토를 위해 그대로 둡니다. +나머지를 직접 삭제하기 전에 보고된 경로를 확인하세요. `remove`는 `uninstall`의 별칭입니다. ## 모델 및 Codex diff --git a/docs-site/src/content/docs/ko/reference/configuration.md b/docs-site/src/content/docs/ko/reference/configuration.md index 73437d327..02878daff 100644 --- a/docs-site/src/content/docs/ko/reference/configuration.md +++ b/docs-site/src/content/docs/ko/reference/configuration.md @@ -26,7 +26,7 @@ namespaced selected id를 bare id로 바꿉니다. | Field | Type | Default | Meaning | | --- | --- | --- | --- | | `port` | `number` | `10100` | 프록시가 수신할 포트. | -| `hostname?` | `string` | `"127.0.0.1"` | 바인드 주소. LAN에 공개하려면 `"0.0.0.0"`으로 설정합니다(`OPENCODEX_API_AUTH_TOKEN` 필요, 아래 [원격 접근](#원격-접근) 참조). | +| `hostname?` | `string` | `"127.0.0.1"` | 바인드 주소. LAN에 공개하려면 `"0.0.0.0"`으로 설정합니다(데이터 플레인에는 `OPENCODEX_API_AUTH_TOKEN` 또는 `config.apiKeys`가 필요하고 관리 인증은 별도입니다. 아래 [원격 접근](#원격-접근) 참조). | | `proxy?` | `string` | — | 외부로 나가는 HTTP(S) 프록시 URL 또는 `${ENV_VAR}` 참조. 해당 환경 변수가 비어 있을 때 `HTTP_PROXY` / `HTTPS_PROXY`에 적용하고, loopback은 `NO_PROXY`에 유지합니다. | | `providers` | `Record` | — | 프로바이더 이름 → 설정 map. | | `openaiProviderTierVersion?` | `2` | migration 설정 | 단일 옵션형 OpenAI projection 완료 마커. | @@ -47,7 +47,7 @@ namespaced selected id를 bare id로 바꿉니다. | `connectTimeoutMs?` | `number` | `200000` | DNS/TCP/TLS와 최종 응답 헤더만 기다리는 시도별 deadline. 응답 body 생성 전 종료됩니다. | | `shutdownTimeoutMs?` | `number` | `5000` | 진행 중인 turn을 중단하기 전 graceful drain deadline. | | `websockets?` | `boolean` | `false` | `supports_websockets`를 알려 Codex가 Responses WebSocket 경로를 쓰게 합니다. 생략하거나 `false`이면 HTTP/SSE를 유지합니다. | -| `apiKeys?` | `OcxApiKey[]` | `[]` | 비-loopback 바인드에서 관리 API와 data plane 인증에 추가로 허용할 생성형 `ocx_…` 자격 증명. 대시보드가 관리하며 항목 필드는 아래에 설명합니다. | +| `apiKeys?` | `OcxApiKey[]` | `[]` | `/v1/*`와 대응하는 WebSocket 핸드셰이크에서만 허용되는 추가 데이터 플레인 `ocx_…` 자격 증명. `/api/*`를 인증하지 않습니다. 대시보드가 관리하며 항목 필드는 아래에 설명합니다. | | `codexAutoStart?` | `boolean` | `true` | Codex shim이 Codex 실행 전에 `ocx ensure`를 실행하게 합니다. `false`이면 `ocx ensure`가 아무 작업도 하지 않습니다. | | `codexShimAutoRestore?` | `boolean` | `true` | 완료된 외부 Codex 업데이트가 이전에 설치한 shim을 교체하면 자동으로 복구합니다. 끄려면 `false`로 설정하거나 프로세스에 `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`을 설정합니다. | | `syncResumeHistory?` | `boolean` | `true` | 되돌릴 수 있는 Codex App 기록 호환 모드. opencodex가 원래 Codex thread metadata를 백업하고, 예전 OpenAI interactive row를 `opencodex`로 재매핑하며, opencodex가 만든 `exec` row를 App에 보이는 source로 잠시 승격합니다. `ocx stop` / `ocx restore`는 백업한 OpenAI row를 복원하고 남은 opencodex user thread를 OpenAI로 돌려 네이티브 Codex가 `config.toml`에서 프록시를 제거한 뒤에도 이어서 열 수 있게 합니다. 끄려면 `false`로 설정합니다. | @@ -64,7 +64,7 @@ namespaced selected id를 bare id로 바꿉니다. | `webSearchSidecar?` | `OcxWebSearchSidecarConfig` | on | 웹 검색 사이드카 옵션(아래 참조). | | `visionSidecar?` | `OcxVisionSidecarConfig` | on | 비전 사이드카 옵션(아래 참조). | | `tokenGuardian?` | `OcxTokenGuardianConfig` | off | 선택형 proactive OAuth 갱신 및 Codex 계정 warmup 정책. 필드는 아래에 설명합니다. | -| `corsAllowOrigins?` | `string[]` | `[]` | CORS에서 추가로 허용할 정확한 origin. loopback origin은 항상 허용합니다. | +| `corsAllowOrigins?` | `string[]` | `[]` | 데이터 플레인 CORS에서 추가로 허용할 정확한 origin. loopback origin은 항상 허용합니다. 관리 CORS나 GUI 세션 발급 범위는 확장하지 않습니다. | `codexAccountNamespaces` 키는 공개 selector입니다. 길이는 1~64자이고 시작과 끝은 ASCII 영숫자여야 하며, 내부에는 영숫자, `.`, `_`, `-`를 사용할 수 있습니다. 예약된 JavaScript object 이름은 거부됩니다. @@ -122,35 +122,91 @@ cooldown, health에 따라 자동 라우팅됩니다. | `codexWarmupMaxAgeSeconds?` | `number` | `691200` | 계정을 다시 검증할 최대 기간(8일). | | `codexWarmupModel?` | `string` | `gpt-5.4-mini` | 선택형 warmup에 쓸 네이티브 모델. | +## 프로바이더 진단 아웃바운드 안전성 + +대시보드의 프로바이더 연결 테스트와 실시간 모델 검색은 제한된 `GET` 전용 아웃바운드 전송을 사용합니다. +아웃바운드 프록시가 없으면 opencodex는 프로바이더 호스트 이름을 한 번만 확인하고 검증된 주소에만 +연결합니다. `HTTPS`는 `Host`, `SNI`, 인증서 검증에 원래 호스트 이름을 유지하며 프로바이더 설정으로 +인증서 검증을 끌 수 없습니다. + +URL 프로토콜에 맞는 `HTTP_PROXY` 또는 `HTTPS_PROXY`가 적용되면 이 두 작업은 기존 프록시를 조용히 +우회하지 않도록 Bun 네이티브 `fetch`를 유지합니다. 소문자 `http_proxy`, `https_proxy`, `no_proxy`는 +값이 비어 있지 않을 때 대응하는 대문자 변수보다 우선합니다. 빈 소문자 값으로 대문자 값을 끄는 방식은 +이식 가능하지 않습니다. opencodex는 비어 있지 않은 대문자 값으로 폴백하며 이는 POSIX의 Bun과 같지만, +Windows에서 Bun을 직접 사용하면 대신 직결될 수 있습니다. 상속된 프록시를 끄려면 대소문자 변수를 모두 +제거하세요. 번들 Bun 1.3.14는 `ALL_PROXY`를 +사용하지 않습니다. 이 변수가 URL 프로토콜에 대한 유일한 유효 프록시 선언이면 연결 테스트와 모델 검색은 +안전하게 요청을 거부하고 대응하는 프로토콜 변수를 설정하라는 안내를 표시합니다. +URL과 리터럴 주소 검사는 계속 실행하며 로컬 DNS가 +성공하면 결과 주소를 분류합니다. 다만 프록시 전용 네트워크는 이름 확인을 프록시에 맡기는 경우가 많으므로 +로컬 DNS 실패는 허용합니다. 최종 경로, DNS 응답, 피어는 프록시가 선택하므로 opencodex는 이 경로에서 +프록시가 선택한 피어를 고정하거나 검증할 수 없음을 로그에 기록합니다. 이는 명시적인 보안 제한이며 +DNS 리바인딩에 대한 동등한 보호가 아닙니다. + +아웃바운드 프록시가 설정된 경우 사설 또는 로컬 프로바이더 목적지에는 `allowPrivateNetwork: true`와 +일치하는 `NO_PROXY` 항목이 모두 필요합니다. 루프백 항목은 `NO_PROXY`에 자동으로 추가됩니다. +`192.168.1.50` 같은 LAN 프로바이더는 명시적으로 추가해야 하며, 그렇지 않으면 연결 테스트와 모델 +검색이 요청을 프록시로 보내지 않고 해결 방법과 함께 거부합니다. `allowPrivateNetwork`를 켜도 메타데이터 +및 링크 로컬 목적지는 차단됩니다. 안전 검사는 `NO_PROXY`의 정확한 호스트, 도메인 접미사, 선택적 포트, +대괄호 IPv6, `*`를 지원하지만 CIDR은 해석하지 않습니다. 사설 프로바이더 호스트나 주소를 각각 적으세요. + +직접 및 프록시 진단 경로는 모두 리디렉션을 거부하고 자격 증명을 제거한 대상을 보고합니다. 최종 프로바이더 +URL을 직접 설정하세요. 일반 프로바이더 요청, 스트리밍 응답, 재시도 경로는 이번 단계에서 마이그레이션되지 +않았습니다. 해당 경로의 리디렉션 처리와 홉별 목적지 검토는 연기되어 있으므로 이번 단계가 주 요청 +리디렉션 문제를 해결하지는 않습니다. + ## 원격 접근 opencodex는 기본적으로 `127.0.0.1`(loopback 전용)에 바인드합니다. `hostname`을 `0.0.0.0` 같은 -비-loopback 주소로 설정하면 관리 API(`/api/*`)와 data plane(`/v1/responses`) **모두**에 token -인증을 강제합니다. +비-loopback 주소로 설정하면 데이터 플레인(`/v1/*`와 대응하는 WebSocket 핸드셰이크)에 하나 이상의 +자격 증명이 필요합니다. `OPENCODEX_API_AUTH_TOKEN`(직접 설정하거나 보호된 `service-api-token` 파일로 +전달) 또는 대시보드에서 생성한 `config.apiKeys` 항목을 사용할 수 있습니다. -시작 전에 `OPENCODEX_API_AUTH_TOKEN` 환경 변수를 설정하세요. +시작 전에 어느 형태든 데이터 플레인 자격 증명을 설정하세요. 아래는 환경 token 형식입니다. 원격 운영자나 +자동화에 안정적인 자격 증명이 필요하면 별도의 관리 token도 명시적으로 설정합니다. ```bash -export OPENCODEX_API_AUTH_TOKEN="your-secret-token" +export OPENCODEX_API_AUTH_TOKEN="your-data-plane-token" +export OPENCODEX_ADMIN_AUTH_TOKEN="your-management-token" ocx start ``` -비-loopback 바인드에서는 이 변수가 없으면 프록시가 시작되지 않습니다. LAN 접근용 백그라운드 -서비스를 설치할 때도 같은 변수를 먼저 export한 뒤 `ocx service install`을 실행해야 launchd, -systemd, Task Scheduler에 전달됩니다. 클라이언트는 모든 요청의 `x-opencodex-api-key` 헤더에 -token을 넣어야 합니다. +비-loopback 바인드에서는 데이터 플레인 환경 token과 `config.apiKeys`가 모두 없으면 프록시가 시작되지 않습니다. +관리 면은 +독립적으로 인증됩니다. `OPENCODEX_ADMIN_AUTH_TOKEN` 또는 별도로 생성되고 권한이 강화된 +`admin-api-token` 파일만 `/api/*`를 인증합니다. 서비스 설치는 명시적인 관리 token을 보호된 +`service-admin-token` 전달 파일에 저장합니다. 이는 관리 자격 증명의 전달 방식이며 네 번째 자격 증명 +종류가 아닙니다. + +:::caution[자격 증명 마이그레이션] +이전 릴리스에서는 `OPENCODEX_API_AUTH_TOKEN`과 `config.apiKeys`도 `/api/*`에 사용할 수 있었습니다. +분리 후에는 데이터 플레인 전용입니다. 기존 관리 스크립트는 `OPENCODEX_ADMIN_AUTH_TOKEN` 또는 보호된 +`admin-api-token`으로 전환해야 합니다. 데이터 플레인 클라이언트는 변경할 필요가 없습니다. +::: + +`ocx service install` 전에 필요한 명시적 token을 export하세요. 설치 프로그램은 값을 보호된 +`service-api-token`과 `service-admin-token` 전달 파일에 기록합니다. launchd, systemd, +Task Scheduler, WinSW 서비스 정의에는 해당 파일 경로만 저장되고 token 원문은 저장되지 않습니다. +클라이언트는 요청 대상 면의 자격 증명을 `x-opencodex-api-key` 헤더에 넣습니다. -``` -x-opencodex-api-key: your-secret-token +```http +x-opencodex-api-key: the-token-for-this-request-plane ``` -`Authorization: Bearer …` 헤더도 허용합니다. 시작 후에는 대시보드에서 생성한 `apiKeys`를 환경 변수 -token 대신 쓸 수 있습니다. 모든 후보는 timing side channel을 막기 위해 상수 시간 -(`timingSafeEqual`)으로 비교합니다. +모든 데이터 플레인 경로와 호환되도록 `x-opencodex-api-key`를 사용하세요. +`Authorization: Bearer …`는 관리 경로와 bearer 허용 데이터 경로에서도 사용할 수 있지만, +`/v1/responses`, `/v1/responses/compact`, `/v1/chat/completions`, Responses WebSocket +핸드셰이크에는 `x-opencodex-api-key`가 필요합니다. 데이터 플레인과 관리 면 자격 증명은 서로 +바꿔 쓸 수 없습니다. 유효한 동일 origin의 loopback 대시보드 진입에는 짧은 수명의 Origin 결합 +GUI 세션이 발급되며, 이 세션도 `/api/*`만 인증합니다. 원격 운영자와 스크립트는 관리 token을 +사용해야 합니다. 관리 자격 증명 생성, 권한 강화 또는 읽기에 실패하면 모든 `/api/*` 요청은 `503`을 +반환하지만, `/v1/*`와 인증이 필요 없는 `/healthz`는 계속 동작합니다. 장기 데이터 플레인 및 관리 +비밀과 GUI CSRF token은 상수 시간(`timingSafeEqual`)으로 비교하고, 수명이 짧고 엔트로피가 높은 +GUI 세션 id는 메모리 조회로 검증합니다. :::caution[LAN 노출] `0.0.0.0`에 바인드하면 프록시와 설정된 모든 프로바이더 자격 증명이 로컬 네트워크에 노출됩니다. -신뢰할 수 있는 네트워크에서만 사용하고 강력한 `OPENCODEX_API_AUTH_TOKEN`을 반드시 설정하세요. +신뢰할 수 있는 네트워크에서만 사용하고 데이터 플레인과 관리 면에 강력하고 서로 다른 token을 설정하세요. ::: ## 프로바이더 (`OcxProviderConfig`) diff --git a/docs-site/src/content/docs/reference/architecture.md b/docs-site/src/content/docs/reference/architecture.md index c33c6047f..6c7a9be06 100644 --- a/docs-site/src/content/docs/reference/architecture.md +++ b/docs-site/src/content/docs/reference/architecture.md @@ -120,8 +120,12 @@ single non-streaming response object from the same events. provider CRUD and key pools, model selection/context caps/v2 controls, catalog sync, diagnostics and debug logs, usage and quotas, sidecar settings, updates, generated client API keys, OAuth login/status/ logout and account selection, Codex account management, and graceful stop. `server/auth-cors.ts` -requires `OPENCODEX_API_AUTH_TOKEN` for both `/api/*` and `/v1/*` when the proxy binds beyond -loopback; configured `corsAllowOrigins` entries extend the local-origin allowlist. +admits data-plane credentials only to `/v1/*` and its WebSocket handshakes, while +`server/management-auth.ts` independently admits `OPENCODEX_ADMIN_AUTH_TOKEN`, the protected admin +token file, or a short-lived same-origin loopback GUI session only to `/api/*`. Missing or failed +management state returns `503` without stopping `/v1/*` or `/healthz`; configured +`corsAllowOrigins` entries extend only the data-plane origin allowlist and never authorize +cross-origin management requests or GUI-session issuance. OAuth implementations live in `oauth/`; access tokens are loaded or refreshed immediately before a routed call, while `oauth/token-guardian.ts` can proactively refresh only providers whose policy diff --git a/docs-site/src/content/docs/reference/cli.md b/docs-site/src/content/docs/reference/cli.md index 88b308ab5..56982f6f8 100644 --- a/docs-site/src/content/docs/reference/cli.md +++ b/docs-site/src/content/docs/reference/cli.md @@ -138,7 +138,11 @@ command exits 0 only when healthy and 1 otherwise, making it suitable for servic ### `ocx uninstall`  ·  `ocx remove` Stop the service and proxy, remove the service and Codex shim, restore native Codex, then remove -opencodex local config only if all restore steps succeeded. `remove` is an alias of `uninstall`. +opencodex-owned local state only if all restore steps succeeded. Removal follows the installation +ownership manifest; unlisted entries are preserved. A legacy non-empty config directory without a +manifest, or a directory that still contains foreign files after owned entries are removed, remains +in place for manual review. Inspect the reported path before deleting any remainder. `remove` is an +alias of `uninstall`. ## Models & Codex diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index 7c2de76b3..af25e2a28 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -30,7 +30,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | Field | Type | Default | Meaning | | --- | --- | --- | --- | | `port` | `number` | `10100` | Port the proxy listens on. | -| `hostname?` | `string` | `"127.0.0.1"` | Bind address. Set `"0.0.0.0"` to expose on the LAN (requires `OPENCODEX_API_AUTH_TOKEN`; see [Remote access](#remote-access) below). | +| `hostname?` | `string` | `"127.0.0.1"` | Bind address. Set `"0.0.0.0"` to expose on the LAN (requires data-plane credentials through `OPENCODEX_API_AUTH_TOKEN` or `config.apiKeys`; management authentication remains separate; see [Remote access](#remote-access) below). | | `proxy?` | `string` | — | Outbound HTTP(S) proxy URL or `${ENV_VAR}` reference. Applied to `HTTP_PROXY` / `HTTPS_PROXY` when those env vars are unset; loopback stays in `NO_PROXY`. | | `providers` | `Record` | — | Map of provider name → config. | | `openaiProviderTierVersion?` | `2` | set by migration | Marks the single option-aware OpenAI projection as complete. | @@ -52,7 +52,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `shutdownTimeoutMs?` | `number` | `5000` | Graceful drain deadline before active turns are aborted. | | `websockets?` | `boolean` | `false` | Advertise `supports_websockets` so Codex uses the Responses WebSocket path. Omit or set `false` to keep HTTP/SSE. | | `storageCleanupPolicy?` | `StorageCleanupPolicy` | unset / `enabled: false` | **Opt-in** archived auto-cleanup (issue #42 Phase 3). Default **OFF** — never enabled implicitly. When enabled, runs on `schedule` (`startup` / `daily` / `weekly` / `manual`) once archived bytes exceed `trigger.archivedBytesOver`, selecting oldest archives toward `target` (`reduceToBytes` or `removeOldestPercent`) in `mode` (`quarantine` default, or explicit `permanent`). Persists `lastRun` / `nextRun`. Configure via Storage page or `GET`/`PUT /api/storage/cleanup-policy`; `POST /api/storage/cleanup-policy/run` for manual. | -| `apiKeys?` | `OcxApiKey[]` | `[]` | Additional generated `ocx_…` credentials accepted by management and data-plane auth on non-loopback binds. Managed by the dashboard; entry fields are listed below. | +| `apiKeys?` | `OcxApiKey[]` | `[]` | Additional generated data-plane `ocx_…` credentials accepted only by `/v1/*` and the corresponding WebSocket handshakes. They never authorize `/api/*`. Managed by the dashboard; entry fields are listed below. | | `codexAutoStart?` | `boolean` | `true` | Let the Codex shim run `ocx ensure` before launching Codex. `false` makes `ocx ensure` a no-op. | | `codexShimAutoRestore?` | `boolean` | `true` | Restore a previously installed Codex shim when a completed external Codex update replaces it. Set `false`, or set `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` for a process-level opt-out. | | `syncResumeHistory?` | `boolean` | `true` | Reversible Codex App history compatibility mode. opencodex backs up original Codex thread metadata, remaps old OpenAI interactive rows to `opencodex`, and temporarily promotes opencodex-created `exec` rows to an app-visible source. `ocx stop` / `ocx restore` restore backed-up OpenAI rows and eject remaining opencodex user threads to OpenAI so native Codex can resume them after the proxy is removed from `config.toml`. Set `false` to opt out. | @@ -70,7 +70,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `visionSidecar?` | `OcxVisionSidecarConfig` | on | Vision sidecar options (see below). | | `images?` | `OcxImagesConfig` | automatic OpenAI selection | Standalone Images relay options for Codex's built-in `image_gen` tool (see below). | | `tokenGuardian?` | `OcxTokenGuardianConfig` | off | Optional proactive OAuth refresh and Codex-account warmup policy; fields are listed below. | -| `corsAllowOrigins?` | `string[]` | `[]` | Additional exact origins allowed by CORS. Loopback origins are always allowed. | +| `corsAllowOrigins?` | `string[]` | `[]` | Additional exact origins allowed by data-plane CORS. Loopback origins are always allowed. This does not extend management CORS or GUI-session issuance. | `codexAccountNamespaces` keys are public selectors: 1–64 characters, starting and ending with an ASCII letter or number, with letters, numbers, `.`, `_`, or `-` inside; reserved JavaScript object @@ -93,8 +93,14 @@ transport. Without an outbound proxy, opencodex resolves the provider hostname o only to that validated address. HTTPS keeps the original hostname for Host, SNI, and certificate verification; certificate verification cannot be disabled by provider config. -When `HTTP_PROXY`, `HTTPS_PROXY`, or `ALL_PROXY` applies, these two operations keep Bun's native fetch -so existing proxy behavior is not silently bypassed. URL/literal checks still run. Successful local DNS answers +When the protocol-matching `HTTP_PROXY` or `HTTPS_PROXY` applies, these two operations keep Bun's native +fetch so existing proxy behavior is not silently bypassed. Lowercase `http_proxy`, `https_proxy`, and +`no_proxy` take precedence over their uppercase forms when they are non-empty. Empty lowercase values are +not a portable way to suppress uppercase values: opencodex falls back to a non-empty uppercase value, +matching Bun on POSIX, while raw Bun on Windows may instead connect directly. Unset both case variants +when disabling an inherited proxy. The bundled Bun 1.3.14 does not use `ALL_PROXY`; if it is the only effective proxy +declaration for the provider URL protocol, connection tests and model discovery fail closed and tell +you to set the corresponding protocol variable. URL/literal checks still run. Successful local DNS answers are classified, but a local DNS failure is allowed through because proxy-only networks commonly delegate name resolution to the proxy. The proxy chooses the final route, DNS answer, and peer, so opencodex logs that this path cannot pin or verify the proxy-selected peer. This is an explicit @@ -233,32 +239,62 @@ normally dashboard-managed. ## Remote access By default opencodex binds to `127.0.0.1` (loopback only). When `hostname` is set to a non-loopback -address such as `0.0.0.0`, opencodex enforces token authentication on **both** the management API -(`/api/*`) and the data-plane (`/v1/responses`). +address such as `0.0.0.0`, opencodex requires at least one data-plane credential for `/v1/*` and the +corresponding WebSocket handshakes: `OPENCODEX_API_AUTH_TOKEN` (set directly or delivered through +the protected `service-api-token` file), or at least one dashboard-generated `config.apiKeys` entry. -Set the `OPENCODEX_API_AUTH_TOKEN` environment variable before starting: +Configure either data-plane credential form before starting. The environment-token form is shown +below. Set an explicit management token too when remote operators or automation need a stable credential: ```bash -export OPENCODEX_API_AUTH_TOKEN="your-secret-token" +export OPENCODEX_API_AUTH_TOKEN="your-data-plane-token" +export OPENCODEX_ADMIN_AUTH_TOKEN="your-management-token" ocx start ``` -The proxy refuses to start without this variable when binding beyond loopback. If you install a -background service for LAN access, export the same variable before `ocx service install` so launchd, -systemd, or Task Scheduler receives it. Clients must include the token in every request via the -`x-opencodex-api-key` header: +The proxy refuses a non-loopback bind when both the data-plane environment token and +`config.apiKeys` are absent. Management access is independent: `OPENCODEX_ADMIN_AUTH_TOKEN`, or the +separately generated and protected +`admin-api-token` file, authorizes only `/api/*`. A service install persists an explicit management +token through the protected `service-admin-token` delivery file; this remains a management +credential, not a fourth credential type. + +For a service process, management credentials are selected in this order: +`OPENCODEX_ADMIN_AUTH_TOKEN`, the protected primary `admin-api-token` file, then the +`service-admin-token` file named by `OCX_ADMIN_TOKEN_FILE`. If `admin-api-token` already exists, +running `ocx service install` with a new explicit management token updates the service delivery +file but does not override that primary file. Keep using the primary credential, or remove the +primary file deliberately before relying on the new service-delivered credential. + +:::caution[Credential migration] +Earlier releases also admitted `OPENCODEX_API_AUTH_TOKEN` and `config.apiKeys` to `/api/*`. They are +data-plane-only after this split. Update existing management scripts to use +`OPENCODEX_ADMIN_AUTH_TOKEN` or the protected `admin-api-token`; data-plane clients are unchanged. +::: -``` -x-opencodex-api-key: your-secret-token +Export explicit tokens before `ocx service install`. The installer writes their values to protected +`service-api-token` and `service-admin-token` delivery files; launchd, systemd, Task Scheduler, and +WinSW definitions store only the corresponding file paths, never raw token values. Clients include +the credential for the requested plane in `x-opencodex-api-key`: + +```http +x-opencodex-api-key: the-token-for-this-request-plane ``` -An `Authorization: Bearer …` header is also accepted. Dashboard-generated `apiKeys` may be used in -place of the environment token after startup; all candidates are compared in constant time -(`timingSafeEqual`) to prevent timing side-channels. +Use `x-opencodex-api-key` for compatibility across the data plane. `Authorization: Bearer …` is +also accepted by management routes and data routes that permit bearer admission, but +`/v1/responses`, `/v1/responses/compact`, `/v1/chat/completions`, and the Responses WebSocket +handshake require `x-opencodex-api-key`. Data-plane and management credentials are not +interchangeable. A legal same-origin loopback dashboard entry receives a short-lived, Origin-bound +GUI session that authorizes only `/api/*`; remote operators and scripts must use the management +token. If management credential creation, permission hardening, or reading fails, every `/api/*` +request returns `503`, while `/v1/*` and the unauthenticated `/healthz` endpoint continue. +Long-lived data-plane and management secrets, plus GUI CSRF tokens, use constant-time comparisons +(`timingSafeEqual`); short-lived high-entropy GUI session ids use an in-memory lookup. :::caution[LAN exposure] Binding to `0.0.0.0` exposes your proxy — and all configured provider credentials — to the local -network. Only do this on trusted networks, and always set a strong `OPENCODEX_API_AUTH_TOKEN`. +network. Only do this on trusted networks, and use strong, distinct data-plane and management tokens. ::: ### SSH port forwarding diff --git a/docs-site/src/content/docs/ru/guides/web-dashboard.md b/docs-site/src/content/docs/ru/guides/web-dashboard.md index c9fde72ee..dad8f335f 100644 --- a/docs-site/src/content/docs/ru/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ru/guides/web-dashboard.md @@ -21,6 +21,10 @@ ocx start bun run dev:gui ``` +Для защиты от clickjacking ответы дашборда задают `X-Frame-Options: DENY` и CSP +`frame-ancestors 'none'`. Встраивание дашборда в `iframe` намеренно не поддерживается; открывайте +его как страницу верхнего уровня через `ocx gui`. + ## Возможности | Раздел | Что делает | diff --git a/docs-site/src/content/docs/ru/reference/architecture.md b/docs-site/src/content/docs/ru/reference/architecture.md index 569652c6a..192f7f087 100644 --- a/docs-site/src/content/docs/ru/reference/architecture.md +++ b/docs-site/src/content/docs/ru/reference/architecture.md @@ -131,9 +131,13 @@ src/ конфигурацию/настройки, CRUD провайдеров и пулы ключей, выбор моделей/лимиты контекста/управление v2, синхронизацию каталога, диагностику и отладочные логи, использование и квоты, настройки сайдкаров, обновления, сгенерированные клиентские API-ключи, вход/статус/выход OAuth и выбор -аккаунта, управление аккаунтами Codex и корректную остановку. `server/auth-cors.ts` требует -`OPENCODEX_API_AUTH_TOKEN` и для `/api/*`, и для `/v1/*`, когда прокси привязан за пределами -loopback; настроенные записи `corsAllowOrigins` расширяют allowlist локальных origin. +аккаунта, управление аккаунтами Codex и корректную остановку. `server/auth-cors.ts` допускает +учётные данные плоскости данных только к `/v1/*` и его WebSocket-рукопожатиям, а +`server/management-auth.ts` независимо допускает `OPENCODEX_ADMIN_AUTH_TOKEN`, защищённый файл +токена управления или короткоживущую GUI-сессию того же loopback-origin только к `/api/*`. +Отсутствующее или неисправное состояние управления возвращает `503`, не останавливая `/v1/*` или +`/healthz`; настроенные записи `corsAllowOrigins` расширяют только allowlist origin плоскости данных +и никогда не разрешают cross-origin-запросы управления или выдачу GUI-сессий. Реализации OAuth живут в `oauth/`; access-токены загружаются или обновляются непосредственно перед маршрутизируемым вызовом, а `oauth/token-guardian.ts` может проактивно обновлять только тех diff --git a/docs-site/src/content/docs/ru/reference/cli.md b/docs-site/src/content/docs/ru/reference/cli.md index 0c1456354..bd656930b 100644 --- a/docs-site/src/content/docs/ru/reference/cli.md +++ b/docs-site/src/content/docs/ru/reference/cli.md @@ -139,8 +139,11 @@ ocx status --json ### `ocx uninstall`  ·  `ocx remove` Останавливает сервис и прокси, удаляет сервис и shim Codex, восстанавливает нативный Codex, затем -удаляет локальную конфигурацию opencodex только если все шаги восстановления завершились успешно. -`remove` — алиас `uninstall`. +удаляет принадлежащее opencodex локальное состояние только если все шаги восстановления завершились +успешно. Удаление следует манифесту владения установкой; не перечисленные в нём элементы сохраняются. +Старый непустой каталог конфигурации без манифеста, а также каталог с посторонними файлами после +удаления принадлежащих opencodex элементов остаётся для ручной проверки. Прежде чем вручную удалить +остаток, проверьте указанный командой путь. `remove` — алиас `uninstall`. ## Модели и Codex diff --git a/docs-site/src/content/docs/ru/reference/configuration.md b/docs-site/src/content/docs/ru/reference/configuration.md index 3a0cff68a..cd7001a12 100644 --- a/docs-site/src/content/docs/ru/reference/configuration.md +++ b/docs-site/src/content/docs/ru/reference/configuration.md @@ -30,7 +30,7 @@ opencodex настраивается файлом `~/.opencodex/config.json`. Е | Field | Type | Default | Meaning | | --- | --- | --- | --- | | `port` | `number` | `10100` | Порт, который слушает прокси. | -| `hostname?` | `string` | `"127.0.0.1"` | Адрес привязки. Установите `"0.0.0.0"`, чтобы открыть доступ по LAN (требуется `OPENCODEX_API_AUTH_TOKEN`; см. [Удалённый доступ](#удалённый-доступ) ниже). | +| `hostname?` | `string` | `"127.0.0.1"` | Адрес привязки. Установите `"0.0.0.0"`, чтобы открыть доступ по LAN (для плоскости данных требуется `OPENCODEX_API_AUTH_TOKEN` или `config.apiKeys`, а управление аутентифицируется отдельно; см. [Удалённый доступ](#удалённый-доступ) ниже). | | `proxy?` | `string` | — | URL исходящего HTTP(S)-прокси или ссылка `${ENV_VAR}`. Применяется к `HTTP_PROXY` / `HTTPS_PROXY`, когда эти переменные окружения не заданы; loopback остаётся в `NO_PROXY`. | | `providers` | `Record` | — | Отображение имя провайдера → конфигурация. | | `openaiProviderTierVersion?` | `2` | задаётся миграцией | Отмечает, что единая проекция OpenAI с учётом опций завершена. | @@ -51,7 +51,7 @@ opencodex настраивается файлом `~/.opencodex/config.json`. Е | `connectTimeoutMs?` | `number` | `200000` | Дедлайн каждой попытки только для DNS/TCP/TLS и финальных заголовков ответа; он заканчивается до генерации тела ответа. | | `shutdownTimeoutMs?` | `number` | `5000` | Дедлайн корректного завершения (drain) перед прерыванием активных ходов. | | `websockets?` | `boolean` | `false` | Объявляет `supports_websockets`, чтобы Codex использовал путь Responses WebSocket. Опустите или установите `false`, чтобы остаться на HTTP/SSE. | -| `apiKeys?` | `OcxApiKey[]` | `[]` | Дополнительные сгенерированные учётные данные `ocx_…`, принимаемые аутентификацией management API и плоскости данных на не-loopback-привязках. Управляются дашбордом; поля записей перечислены ниже. | +| `apiKeys?` | `OcxApiKey[]` | `[]` | Дополнительные учётные данные плоскости данных `ocx_…`, принимаемые только для `/v1/*` и соответствующих WebSocket-рукопожатий. Они никогда не дают доступ к `/api/*`. Управляются дашбордом; поля записей перечислены ниже. | | `codexAutoStart?` | `boolean` | `true` | Разрешает shim Codex выполнять `ocx ensure` перед запуском Codex. `false` делает `ocx ensure` пустой операцией. | | `codexShimAutoRestore?` | `boolean` | `true` | Восстанавливает ранее установленный shim после того, как завершённое внешнее обновление Codex заменило его. Для отключения задайте `false` или установите процессу `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`. | | `syncResumeHistory?` | `boolean` | `true` | Обратимый режим совместимости истории Codex App. opencodex резервирует исходные метаданные потоков Codex, переназначает старые интерактивные строки OpenAI на `opencodex` и временно повышает созданные opencodex строки `exec` до видимого в приложении источника. `ocx stop` / `ocx restore` восстанавливают зарезервированные строки OpenAI и возвращают оставшиеся пользовательские потоки opencodex обратно к OpenAI, чтобы нативный Codex мог возобновлять их после удаления прокси из `config.toml`. Установите `false`, чтобы отказаться. | @@ -68,7 +68,7 @@ opencodex настраивается файлом `~/.opencodex/config.json`. Е | `webSearchSidecar?` | `OcxWebSearchSidecarConfig` | вкл. | Параметры сайдкара веб-поиска (см. ниже). | | `visionSidecar?` | `OcxVisionSidecarConfig` | вкл. | Параметры vision-сайдкара (см. ниже). | | `tokenGuardian?` | `OcxTokenGuardianConfig` | выкл. | Необязательная политика проактивного обновления OAuth и прогрева аккаунтов Codex; поля перечислены ниже. | -| `corsAllowOrigins?` | `string[]` | `[]` | Дополнительные точные origin, разрешённые CORS. Loopback-origin разрешены всегда. | +| `corsAllowOrigins?` | `string[]` | `[]` | Дополнительные точные origin, разрешённые CORS плоскости данных. Loopback-origin разрешены всегда. Поле не расширяет CORS управления и не разрешает выдачу GUI-сессий. | Ключи `codexAccountNamespaces` — публичные селекторы длиной 1–64 символа. Они должны начинаться и заканчиваться ASCII-буквой или цифрой; внутри разрешены буквы, цифры, `.`, `_` и `-`. Зарезервированные @@ -144,37 +144,104 @@ ISO-строку `createdAt: string`. Записи `codexAccounts[]` содер | `codexWarmupMaxAgeSeconds?` | `number` | `691200` | Повторная проверка аккаунта через 8 дней. | | `codexWarmupModel?` | `string` | `gpt-5.4-mini` | Нативная модель для необязательного прогрева. | +## Безопасность исходящих диагностических запросов провайдера + +Проверка подключения провайдера в дашборде и живое обнаружение моделей используют ограниченный +исходящий транспорт только для `GET`. Без исходящего прокси opencodex однократно разрешает имя +хоста провайдера и подключается только к проверенному адресу. `HTTPS` сохраняет исходное имя хоста +для `Host`, `SNI` и проверки сертификата; конфигурация провайдера не может отключить проверку +сертификата. + +Когда действует соответствующая протоколу URL переменная `HTTP_PROXY` или `HTTPS_PROXY`, эти две +операции сохраняют нативный `fetch` Bun, чтобы не обходить существующий прокси без предупреждения. +Строчные `http_proxy`, `https_proxy` и `no_proxy` имеют приоритет над соответствующими переменными +в верхнем регистре, только когда их значения непусты. Пустое значение в нижнем регистре не является +переносимым способом отключить значение в верхнем регистре: opencodex выбирает непустое значение +в верхнем регистре, как Bun на POSIX, тогда как прямой вызов Bun на Windows может подключиться без +прокси. Чтобы отключить унаследованный прокси, удалите обе формы переменной. Встроенный Bun 1.3.14 не использует +`ALL_PROXY`. Если это единственное действующее объявление прокси для протокола URL, проверка +подключения и обнаружение моделей безопасно отклоняют запрос и предлагают задать соответствующую +протоколу переменную. Проверки URL и буквальных +адресов продолжают работать. Успешный локальный ответ DNS классифицируется, но ошибка локального +DNS допускается, поскольку сети только с прокси обычно передают разрешение имён прокси-серверу. +Прокси выбирает окончательный маршрут, ответ DNS и узел назначения, поэтому opencodex записывает +в лог, что этот путь не может закрепить или проверить выбранный прокси узел. Это явное ограничение +безопасности, а не равноценная защита от перепривязки DNS. + +При настроенном исходящем прокси частные и локальные назначения провайдеров требуют одновременно +`allowPrivateNetwork: true` и совпадающей записи `NO_PROXY`. Loopback-записи добавляются в +`NO_PROXY` автоматически. LAN-провайдер вроде `192.168.1.50` нужно добавить явно, иначе проверка +подключения и обнаружение моделей отклонят запрос с понятной подсказкой, а не отправят его прокси. +Адреса метаданных и link-local остаются заблокированы даже при включённом `allowPrivateNetwork`. +Защитная проверка понимает точные хосты, суффиксы доменов, необязательные порты, IPv6 в квадратных +скобках и `*` в `NO_PROXY`, но не обрабатывает CIDR. Перечисляйте каждый частный хост или адрес +провайдера явно. + +И прямой, и проксированный диагностический путь отклоняет перенаправления и сообщает целевой URL +без учётных данных; указывайте окончательный URL провайдера напрямую. Обычные запросы провайдера, +потоковые ответы и пути повторных попыток на этом этапе не перенесены. Их обработка перенаправлений +и проверка назначения на каждом переходе отложены, поэтому этот этап не закрывает проблему +перенаправлений основного пути запросов. + ## Удалённый доступ По умолчанию opencodex привязывается к `127.0.0.1` (только loopback). Когда `hostname` установлен -в не-loopback-адрес, такой как `0.0.0.0`, opencodex принудительно включает токенную -аутентификацию **и** для management API (`/api/*`), **и** для плоскости данных -(`/v1/responses`). +в не-loopback-адрес, такой как `0.0.0.0`, для плоскости данных (`/v1/*` и соответствующих +WebSocket-рукопожатий) нужны хотя бы одни учётные данные: `OPENCODEX_API_AUTH_TOKEN` (заданный +напрямую или доставленный через защищённый файл `service-api-token`) либо хотя бы одна созданная +дашбордом запись `config.apiKeys`. -Установите переменную окружения `OPENCODEX_API_AUTH_TOKEN` перед запуском: +Перед запуском настройте любую форму учётных данных плоскости данных. Ниже показана переменная +окружения. Если удалённым операторам или автоматизации нужны стабильные учётные данные, отдельно +задайте токен управления: ```bash -export OPENCODEX_API_AUTH_TOKEN="your-secret-token" +export OPENCODEX_API_AUTH_TOKEN="your-data-plane-token" +export OPENCODEX_ADMIN_AUTH_TOKEN="your-management-token" ocx start ``` -Без этой переменной прокси отказывается запускаться при привязке за пределами loopback. Если вы -устанавливаете фоновый сервис для доступа по LAN, экспортируйте ту же переменную перед -`ocx service install`, чтобы её получил launchd, systemd или Task Scheduler. Клиенты должны -включать токен в каждый запрос через заголовок `x-opencodex-api-key`: +Если нет ни переменной плоскости данных, ни `config.apiKeys`, прокси отказывается от +не-loopback-привязки. Управление +аутентифицируется независимо: `OPENCODEX_ADMIN_AUTH_TOKEN` или отдельно созданный и защищённый +файл `admin-api-token` разрешает только `/api/*`. При установке службы явно заданный токен +управления сохраняется в защищённом файле доставки `service-admin-token`; он остаётся учётными +данными управления, а не отдельным четвёртым типом. + +:::caution[Миграция учётных данных] +В предыдущих версиях `OPENCODEX_API_AUTH_TOKEN` и `config.apiKeys` также допускались к `/api/*`. +После разделения они предназначены только для плоскости данных. Существующие скрипты управления +должны перейти на `OPENCODEX_ADMIN_AUTH_TOKEN` или защищённый файл `admin-api-token`; клиентам +плоскости данных изменения не требуются. +::: + +Экспортируйте нужные явные токены перед `ocx service install`. Установщик записывает их значения +в защищённые файлы доставки `service-api-token` и `service-admin-token`; определения launchd, +systemd, Task Scheduler и WinSW хранят только пути к этим файлам и никогда не содержат токены +открытым текстом. Клиенты передают учётные данные для нужной плоскости через заголовок +`x-opencodex-api-key`: -``` -x-opencodex-api-key: your-secret-token +```http +x-opencodex-api-key: the-token-for-this-request-plane ``` -Заголовок `Authorization: Bearer …` также принимается. Сгенерированные в дашборде `apiKeys` -после запуска можно использовать вместо токена из окружения; все кандидаты сравниваются за -константное время (`timingSafeEqual`) для защиты от атак по времени. +Для совместимости со всей плоскостью данных используйте `x-opencodex-api-key`. +`Authorization: Bearer …` также принимается маршрутами управления и маршрутами данных, которые +поддерживают bearer-допуск, но `/v1/responses`, `/v1/responses/compact`, +`/v1/chat/completions` и WebSocket-рукопожатие Responses требуют +`x-opencodex-api-key`. Учётные данные плоскости данных и управления не взаимозаменяемы. Законный +вход в loopback-дашборд с тем же origin получает короткоживущую, привязанную к Origin GUI-сессию, +которая разрешает только `/api/*`; удалённые операторы и скрипты обязаны использовать токен +управления. Если создание, усиление прав или чтение учётных данных управления не удалось, все +запросы `/api/*` получают `503`, а `/v1/*` и не требующий аутентификации `/healthz` продолжают +работать. Долгоживущие секреты плоскостей данных и управления, а также GUI CSRF-токены сравниваются +за константное время (`timingSafeEqual`); короткоживущие высокоэнтропийные id GUI-сессий +проверяются поиском в памяти. :::caution[Доступ по LAN] Привязка к `0.0.0.0` открывает ваш прокси — и все настроенные учётные данные провайдеров — -локальной сети. Делайте это только в доверенных сетях и всегда задавайте надёжный -`OPENCODEX_API_AUTH_TOKEN`. +локальной сети. Делайте это только в доверенных сетях и задавайте надёжные разные токены для +плоскости данных и управления. ::: ## Провайдеры (`OcxProviderConfig`) diff --git a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md index cac81c25c..36110a8fa 100644 --- a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md @@ -20,6 +20,9 @@ ocx start bun run dev:gui ``` +为防止点击劫持,仪表盘响应会设置 `X-Frame-Options: DENY` 和 CSP +`frame-ancestors 'none'`。仪表盘有意不支持嵌入 `iframe`;请通过 `ocx gui` 将其作为顶层页面打开。 + ## 可以完成哪些操作 | 区域 | 作用 | diff --git a/docs-site/src/content/docs/zh-cn/reference/architecture.md b/docs-site/src/content/docs/zh-cn/reference/architecture.md index 806631086..01473aaa5 100644 --- a/docs-site/src/content/docs/zh-cn/reference/architecture.md +++ b/docs-site/src/content/docs/zh-cn/reference/architecture.md @@ -116,9 +116,11 @@ item,因此 MCP 命名空间、`apply_patch` 风格的 freeform 工具和客 `server/management/*.ts`。其 `/api/*` route 涵盖安全的配置/设置、provider CRUD 与 key pool、模型选择/context cap/v2 控制、catalog sync、诊断与 debug log、usage 与 quota、sidecar 设置、更新、生成客户端 API key、OAuth 登录/状态/登出与账号选择、Codex 账号 -管理,以及 graceful stop。proxy 绑定到 loopback 之外时,`server/auth-cors.ts` 会要求 -`/api/*` 和 `/v1/*` 都提供 `OPENCODEX_API_AUTH_TOKEN`;配置的 `corsAllowOrigins` 会扩展本地 -origin allowlist。 +管理,以及 graceful stop。`server/auth-cors.ts` 只允许数据面凭据进入 `/v1/*` 及其 WebSocket +握手;`server/management-auth.ts` 则独立允许 `OPENCODEX_ADMIN_AUTH_TOKEN`、受保护的管理 token +文件或短期同源 loopback GUI 会话进入 `/api/*`。管理状态缺失或失败时返回 `503`,不会停止 +`/v1/*` 或 `/healthz`;配置的 `corsAllowOrigins` 只扩展数据面 origin allowlist,绝不会授权 +跨 origin 管理请求或 GUI 会话签发。 OAuth 实现在 `oauth/` 中;每次路由调用前都会即时加载或刷新 access token,而 `oauth/token-guardian.ts` 只会主动刷新策略允许的 provider。Codex/ChatGPT pool credential 与 diff --git a/docs-site/src/content/docs/zh-cn/reference/cli.md b/docs-site/src/content/docs/zh-cn/reference/cli.md index 4895752d3..d64118f9a 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli.md @@ -127,7 +127,9 @@ API key、OAuth token、authorization header、请求内容、电子邮件和账 ### `ocx uninstall`  ·  `ocx remove` 停止服务和代理,移除服务与 Codex shim,恢复原生 Codex;只有所有恢复步骤成功后,才删除 -opencodex 本地配置。`remove` 是 `uninstall` 的别名。 +opencodex 自有的本地状态。删除严格遵循安装所有权清单,未列出的条目会保留。没有清单的旧版 +非空配置目录,以及删除自有条目后仍含外来文件的目录,都会留在原处供人工复核。手工删除任何 +残留前,请先检查命令报告的路径。`remove` 是 `uninstall` 的别名。 ## 模型与 Codex diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration.md b/docs-site/src/content/docs/zh-cn/reference/configuration.md index 44c1b06f3..bda9bdb05 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration.md @@ -24,7 +24,7 @@ no-replace 方式创建 `config.json.pre-openai-tiers-v2.bak`,并把已知旧 | Field | Type | Default | 含义 | | --- | --- | --- | --- | | `port` | `number` | `10100` | 代理监听端口。 | -| `hostname?` | `string` | `"127.0.0.1"` | 绑定地址。设为 `"0.0.0.0"` 可暴露到 LAN(需要 `OPENCODEX_API_AUTH_TOKEN`;见下文 [远程访问](#远程访问))。 | +| `hostname?` | `string` | `"127.0.0.1"` | 绑定地址。设为 `"0.0.0.0"` 可暴露到 LAN(数据面需要 `OPENCODEX_API_AUTH_TOKEN` 或 `config.apiKeys`,管理面独立认证;见下文 [远程访问](#远程访问))。 | | `proxy?` | `string` | — | 出站 HTTP(S) proxy URL 或 `${ENV_VAR}` 引用。对应 env 未设置时应用到 `HTTP_PROXY` / `HTTPS_PROXY`;loopback 会保留在 `NO_PROXY` 中。 | | `providers` | `Record` | — | provider 名称 → 配置的映射。 | | `openaiProviderTierVersion?` | `2` | migration 设置 | 单一选项式 OpenAI projection 完成标记。 | @@ -45,7 +45,7 @@ no-replace 方式创建 `config.json.pre-openai-tiers-v2.bak`,并把已知旧 | `connectTimeoutMs?` | `number` | `200000` | 每次尝试仅等待 DNS/TCP/TLS 和最终响应 header 的 deadline;在响应 body 生成前结束。 | | `shutdownTimeoutMs?` | `number` | `5000` | 中止活跃 turn 前的 graceful drain deadline。 | | `websockets?` | `boolean` | `false` | 公布 `supports_websockets`,让 Codex 使用 Responses WebSocket 路径。省略或设为 `false` 会保持 HTTP/SSE。 | -| `apiKeys?` | `OcxApiKey[]` | `[]` | 非 loopback 绑定下,management 和 data-plane 认证额外接受的生成式 `ocx_…` credential。由仪表盘管理;条目字段见下文。 | +| `apiKeys?` | `OcxApiKey[]` | `[]` | 仅供 `/v1/*` 及对应 WebSocket 握手使用的附加数据面 `ocx_…` credential,绝不授权 `/api/*`。由仪表盘管理;条目字段见下文。 | | `codexAutoStart?` | `boolean` | `true` | 允许 Codex shim 在启动 Codex 前运行 `ocx ensure`。`false` 会让 `ocx ensure` 不执行任何操作。 | | `codexShimAutoRestore?` | `boolean` | `true` | 已完成的外部 Codex 更新替换此前安装的 shim 时自动恢复。若要关闭,请设为 `false`,或为进程设置 `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`。 | | `syncResumeHistory?` | `boolean` | `true` | 可逆的 Codex App 历史兼容模式。opencodex 会备份原始 Codex thread metadata,把旧 OpenAI interactive row 重映射到 `opencodex`,并暂时把 opencodex 创建的 `exec` row 提升成 App 可见 source。`ocx stop` / `ocx restore` 会恢复已备份的 OpenAI row,并把剩余 opencodex user thread 转回 OpenAI,使原生 Codex 在从 `config.toml` 移除代理后仍能继续这些 thread。设为 `false` 可退出该模式。 | @@ -62,7 +62,7 @@ no-replace 方式创建 `config.json.pre-openai-tiers-v2.bak`,并把已知旧 | `webSearchSidecar?` | `OcxWebSearchSidecarConfig` | 开启 | 网络搜索 sidecar 选项(见下文)。 | | `visionSidecar?` | `OcxVisionSidecarConfig` | 开启 | 视觉 sidecar 选项(见下文)。 | | `tokenGuardian?` | `OcxTokenGuardianConfig` | 关闭 | 可选的 proactive OAuth 刷新和 Codex account warmup 策略;字段见下文。 | -| `corsAllowOrigins?` | `string[]` | `[]` | CORS 额外允许的精确 origin。loopback origin 始终允许。 | +| `corsAllowOrigins?` | `string[]` | `[]` | 数据面 CORS 额外允许的精确 origin。loopback origin 始终允许;该字段不会扩展管理面 CORS 或 GUI 会话签发范围。 | `codexAccountNamespaces` 的 key 是公开 selector:长度为 1–64 个字符,首尾必须是 ASCII 字母或数字, 中间可使用字母、数字、`.`、`_` 或 `-`;保留的 JavaScript object 名称会被拒绝。value 必须是有效的 @@ -116,32 +116,81 @@ threshold;未知 usage 不强制切换)后按稳定排序进入下一个。 | `codexWarmupMaxAgeSeconds?` | `number` | `691200` | 账号在 8 天后重新验证。 | | `codexWarmupModel?` | `string` | `gpt-5.4-mini` | 可选 warmup 使用的原生模型。 | +## Provider 诊断外联安全 + +仪表盘的 provider 连接测试和实时模型发现使用有界、仅 `GET` 的外联传输。未配置出站代理时, +opencodex 只解析一次 provider 主机名,并只连接到该次验证通过的地址。`HTTPS` 会保留原主机名用于 +`Host`、`SNI` 和证书验证;provider 配置无法关闭证书验证。 + +当与 URL 协议对应的 `HTTP_PROXY` 或 `HTTPS_PROXY` 生效时,这两个操作会保留 Bun 原生 `fetch`, +避免静默绕过既有代理。小写的 `http_proxy`、`https_proxy` 和 `no_proxy` 优先于对应的大写变量, +但仅限非空值。空的小写变量不能可靠地禁用大写变量:opencodex 会回退到非空的大写值,这与 POSIX 上的 +Bun 一致,而 Windows 上直接使用 Bun 时可能改为直连。要禁用继承的代理,应同时清除大小写两种变量。 +内置 Bun 1.3.14 不使用 `ALL_PROXY`;如果它是该 URL 协议唯一有效的代理声明, +连接测试和模型发现会安全拒绝请求,并提示设置对应的协议变量。URL 和字面地址检查仍会执行; +本机 DNS 成功解析出的地址会被分类,但本机 DNS +解析失败时允许继续,因为仅代理网络通常把域名解析交给代理。最终路由、DNS 答案和对端均由代理选择, +因此 opencodex 会记录此路径无法固定或验证代理选中的对端。这是明确的安全限制,并不等同于防住 +DNS 重绑定。 + +配置出站代理时,私网或本地 provider 目的地必须同时设置 `allowPrivateNetwork: true` 并命中 +`NO_PROXY`。回环条目会自动加入 `NO_PROXY`;像 `192.168.1.50` 这样的局域网 provider 必须显式 +加入,否则连接测试和模型发现会给出可操作的拒绝信息,而不会把请求发给代理。即使启用了 +`allowPrivateNetwork`,元数据和链路本地目的地仍会被拒绝。安全检查支持 `NO_PROXY` 中的精确主机、 +域名后缀、可选端口、带方括号的 IPv6 和 `*`,但不解析 CIDR;请逐个列出私网 provider 的主机名或地址。 + +直连和代理两条诊断路径都会拒绝重定向,并报告已剥离凭据的目标地址;请直接配置最终 provider URL。 +普通 provider 请求、流式响应和重试路径本阶段尚未迁移,其重定向处理和逐跳目的地复核仍属延期项, +因此本阶段没有关闭主请求路径的重定向问题。 + ## 远程访问 opencodex 默认只绑定到 `127.0.0.1`(loopback)。当 `hostname` 设置为 `0.0.0.0` 等非 loopback -地址时,management API(`/api/*`)和 data plane(`/v1/responses`)都会强制 token 认证。 +地址时,`/v1/*` 及对应 WebSocket 握手必须至少配置一种数据面凭据: +`OPENCODEX_API_AUTH_TOKEN`(直接设置或通过受保护的 `service-api-token` 文件投递),或至少一个 +仪表盘生成的 `config.apiKeys` 条目。 -启动前设置 `OPENCODEX_API_AUTH_TOKEN`: +启动前配置任一种数据面凭据。下面演示环境 token 形式;远程运维人员或自动化需要稳定凭据时, +再显式设置独立的管理 token: ```bash -export OPENCODEX_API_AUTH_TOKEN="your-secret-token" +export OPENCODEX_API_AUTH_TOKEN="your-data-plane-token" +export OPENCODEX_ADMIN_AUTH_TOKEN="your-management-token" ocx start ``` -非 loopback 绑定缺少该变量时,代理会拒绝启动。若要为 LAN 访问安装后台服务,也应先 export -同一变量,再运行 `ocx service install`,让 launchd、systemd 或 Task Scheduler 收到 token。 -客户端必须在每个请求的 `x-opencodex-api-key` header 中提供 token: +非 loopback 绑定同时缺少数据面环境 token 和 `config.apiKeys` 时,代理会拒绝启动。管理面独立认证: +`OPENCODEX_ADMIN_AUTH_TOKEN` 或单独生成并加固权限的 `admin-api-token` 文件只授权 +`/api/*`。服务安装会通过受保护的 `service-admin-token` 投递文件保存显式管理 token;它仍属于 +管理凭据,并不是第四类凭据。 -``` -x-opencodex-api-key: your-secret-token +:::caution[凭据迁移] +旧版本还允许 `OPENCODEX_API_AUTH_TOKEN` 和 `config.apiKeys` 访问 `/api/*`。拆分后它们只属于 +数据面。已有管理脚本必须改用 `OPENCODEX_ADMIN_AUTH_TOKEN` 或受保护的 `admin-api-token`; +数据面客户端无需修改。 +::: + +运行 `ocx service install` 前应 export 所需的显式 token。安装器会把值写入受保护的 +`service-api-token` 和 `service-admin-token` 投递文件;launchd、systemd、Task Scheduler 和 +WinSW 的服务定义只保存对应文件路径,绝不保存 token 明文。客户端在 +`x-opencodex-api-key` header 中提供当前请求平面的凭据: + +```http +x-opencodex-api-key: the-token-for-this-request-plane ``` -也可以使用 `Authorization: Bearer …` header。启动后,仪表盘生成的 `apiKeys` 可代替环境 token。 -所有候选值均用常量时间(`timingSafeEqual`)比较,避免 timing side-channel。 +为兼容所有数据面接口,应使用 `x-opencodex-api-key`。管理接口和允许 bearer 准入的数据接口也 +接受 `Authorization: Bearer …`,但 `/v1/responses`、`/v1/responses/compact`、 +`/v1/chat/completions` 和 Responses WebSocket 握手必须使用专用的 +`x-opencodex-api-key` header。数据面和管理面凭据不能互换。合法同源的 loopback 仪表盘入口 +会获得一个短期、绑定 Origin 的 GUI 会话,该会话也只授权 `/api/*`;远程运维人员和脚本必须使用 +管理 token。管理凭据创建、权限加固或读取失败时,所有 `/api/*` 请求都返回 `503`,但 `/v1/*` +和免鉴权的 `/healthz` 会继续工作。长期数据面和管理秘密以及 GUI CSRF token 使用常量时间 +(`timingSafeEqual`)比较;短期高熵 GUI 会话 id 使用内存查找。 :::caution[LAN 暴露] 绑定到 `0.0.0.0` 会把代理和所有已配置 provider credential 暴露到本地网络。只应在可信网络中 -使用,并始终设置强 `OPENCODEX_API_AUTH_TOKEN`。 +使用,并为数据面和管理面设置强度足够且彼此不同的 token。 ::: ## Providers(`OcxProviderConfig`) diff --git a/gui/src/api-access-models.ts b/gui/src/api-access-models.ts index b0d6a2520..e3da0fd0f 100644 --- a/gui/src/api-access-models.ts +++ b/gui/src/api-access-models.ts @@ -5,6 +5,7 @@ export interface ExternalModelRow { disabled?: boolean; native?: boolean; custom?: boolean; + managementTestable?: boolean; } /** Inbound gateway protocols — not inferred from provider type. */ diff --git a/gui/src/pages/ApiKeys.tsx b/gui/src/pages/ApiKeys.tsx index a9f5f62b1..5afb8138f 100644 --- a/gui/src/pages/ApiKeys.tsx +++ b/gui/src/pages/ApiKeys.tsx @@ -3,7 +3,6 @@ import { Notice } from "../ui"; import { useI18n, LOCALES } from "../i18n/shared"; import { readJsonIfOk, readJsonOrThrow } from "../fetch-json"; import { - classifyExternalModel, externalModelId, type ExternalModelRow, } from "../api-access-models"; @@ -32,6 +31,16 @@ interface CreateKeyResponse { key?: unknown; } +interface ManagementModelRow { + provider?: unknown; + namespaced?: unknown; + displayName?: unknown; + disabled?: unknown; + native?: unknown; + custom?: unknown; + managementTestable?: unknown; +} + type CachedKeysShape = { keys: ApiKeyEntry[]; endpoints: ApiEndpointInfo; @@ -119,7 +128,7 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) { if (!hasModelsCacheRef.current) setModelsLoading(true); setModelsLoadFailed(false); try { - const res = await fetch(`${apiBase}/v1/models`); + const res = await fetch(`${apiBase}/api/models`); if (!res.ok) { if (!hasModelsCacheRef.current) setModels([]); setModelsLoadFailed(true); @@ -137,12 +146,29 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) { return; } const rows = rawRows - .filter((row): row is { id: string; owned_by?: string } => ( - typeof row === "object" - && row !== null - && typeof (row as { id?: unknown }).id === "string" - )) - .map(row => classifyExternalModel(row)) + .filter((row): row is ManagementModelRow & { provider: string; namespaced: string } => { + if (typeof row !== "object" || row === null) return false; + const { provider, namespaced, disabled } = row as ManagementModelRow; + return typeof provider === "string" + && typeof namespaced === "string" + && provider.trim().length > 0 + && namespaced.trim().length > 0 + && disabled !== true; + }) + .map(row => { + const displayName = row.displayName; + return { + id: row.namespaced, + displayName: typeof displayName === "string" && displayName.trim() + ? displayName + : row.namespaced, + provider: row.provider, + disabled: row.disabled === true, + native: row.native === true, + custom: row.custom === true, + managementTestable: row.managementTestable !== false, + }; + }) .sort((a, b) => externalModelId(a).localeCompare(externalModelId(b))); setModels(rows); hasModelsCacheRef.current = true; @@ -258,18 +284,23 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) { const modelId = externalModelId(model); setModelTests(current => ({ ...current, [modelId]: { state: "testing" } })); try { - const res = await fetch(endpoints.chatCompletions, { + const res = await fetch(`${apiBase}/api/models/test`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - model: modelId, - messages: [{ role: "user", content: "ping" }], - max_tokens: 1, - stream: false, - }), + body: JSON.stringify({ model: modelId }), }); if (!res.ok) { - const detail = await res.text(); + const payload = await res.json().catch(() => null) as { error?: unknown } | null; + const detail = typeof payload?.error === "string" ? payload.error : String(res.status); + setModelTests(current => ({ + ...current, + [modelId]: { state: "error", detail: detail.slice(0, 160) || String(res.status) }, + })); + return; + } + const payload = await res.json().catch(() => null) as { ok?: unknown; error?: unknown } | null; + if (payload?.ok !== true) { + const detail = typeof payload?.error === "string" ? payload.error : String(res.status); setModelTests(current => ({ ...current, [modelId]: { state: "error", detail: detail.slice(0, 160) || String(res.status) }, diff --git a/gui/src/pages/Storage.tsx b/gui/src/pages/Storage.tsx index c7e97c3cc..44f05f295 100644 --- a/gui/src/pages/Storage.tsx +++ b/gui/src/pages/Storage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from "react"; +import { useCallback, useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react"; import { useI18n, type TFn, type Locale } from "../i18n/shared"; import { EmptyState } from "../ui"; import { IconRefresh } from "../icons"; @@ -1270,7 +1270,7 @@ function StorageCleanupCard({ window.requestAnimationFrame(() => (next === "policy" ? policyTabRef : quarantineTabRef).current?.focus()); }; - const handleTabKey = (event: KeyboardEvent) => { + const handleTabKey = (event: ReactKeyboardEvent) => { if (event.key === "ArrowLeft" || event.key === "ArrowRight") { event.preventDefault(); selectTab(tab === "policy" ? "quarantine" : "policy"); diff --git a/gui/src/pages/api-keys-panels.tsx b/gui/src/pages/api-keys-panels.tsx index b7004bdcf..cb125b15f 100644 --- a/gui/src/pages/api-keys-panels.tsx +++ b/gui/src/pages/api-keys-panels.tsx @@ -403,14 +403,16 @@ export function ApiKeysModelsPanel({ - + {model.managementTestable !== false && ( + + )} {testState === "ok" &&

{t("api.testSucceeded")}

} {testState === "error" &&

{modelTests[modelId]?.detail ?? t("api.testFailed")}

} diff --git a/gui/tests/apikeys-layout.test.ts b/gui/tests/apikeys-layout.test.ts index 2ff753393..490831bb5 100644 --- a/gui/tests/apikeys-layout.test.ts +++ b/gui/tests/apikeys-layout.test.ts @@ -83,7 +83,9 @@ test("ApiKeys workspace keeps endpoint, generate, models, and usage panels", asy expect(between).not.toContain('t("api.usageChatTitle")'); expect(between).not.toContain('t("api.usageResponsesTitle")'); expect(src).toContain("gatewayInboundProtocols(claudeCodeEnabled)"); - expect(page).toContain("classifyExternalModel(row)"); + expect(page).toContain('fetch(`${apiBase}/api/models`)'); + expect(page).not.toContain('fetch(`${apiBase}/v1/models`)'); + expect(page).toContain("row.namespaced"); expect(page).toContain('from "../api-access-models"'); }); diff --git a/gui/tests/apikeys-refresh-preserve.test.tsx b/gui/tests/apikeys-refresh-preserve.test.tsx index 6de7574ac..2dc960e75 100644 --- a/gui/tests/apikeys-refresh-preserve.test.tsx +++ b/gui/tests/apikeys-refresh-preserve.test.tsx @@ -2,6 +2,7 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { Window } from "happy-dom"; import { act } from "react"; import type { Root } from "react-dom/client"; +import { installApiAuthFetch, resetApiAuthFetchForTests } from "../src/api"; import { LanguageProvider } from "../src/i18n/provider"; import ApiKeys from "../src/pages/ApiKeys"; @@ -16,6 +17,7 @@ beforeEach(() => { document: Object.getOwnPropertyDescriptor(globalThis, "document"), window: Object.getOwnPropertyDescriptor(globalThis, "window"), localStorage: Object.getOwnPropertyDescriptor(globalThis, "localStorage"), + sessionStorage: Object.getOwnPropertyDescriptor(globalThis, "sessionStorage"), actEnv: Object.getOwnPropertyDescriptor(globalThis, "IS_REACT_ACT_ENVIRONMENT"), }; restoreGlobals = () => { @@ -23,6 +25,7 @@ beforeEach(() => { ["document", previous.document], ["window", previous.window], ["localStorage", previous.localStorage], + ["sessionStorage", previous.sessionStorage], ["IS_REACT_ACT_ENVIRONMENT", previous.actEnv], ] as const) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); @@ -37,10 +40,18 @@ beforeEach(() => { }); afterEach(() => { + resetApiAuthFetchForTests(); globalThis.fetch = originalFetch; restoreGlobals?.(); }); +function installManagementAuthFetch(testWindow: Window, handler: typeof fetch): void { + Object.defineProperty(testWindow, "fetch", { configurable: true, value: handler }); + Object.defineProperty(globalThis, "fetch", { configurable: true, value: handler }); + installApiAuthFetch(); + Object.defineProperty(globalThis, "fetch", { configurable: true, value: testWindow.fetch }); +} + const EXISTING_KEY = { id: "key-1", name: "existing-key", @@ -67,6 +78,7 @@ test("successful key create keeps last-good keys visible when follow-up refresh document: { configurable: true, value: testWindow.document }, window: { configurable: true, value: testWindow }, localStorage: { configurable: true, value: testWindow.localStorage }, + sessionStorage: { configurable: true, value: testWindow.sessionStorage }, IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true }, }); @@ -74,8 +86,8 @@ test("successful key create keeps last-good keys visible when follow-up refresh globalThis.fetch = (async (input, init) => { const url = String(input); const method = (init?.method ?? "GET").toUpperCase(); - if (url.endsWith("/v1/models")) { - return Response.json({ data: [] }); + if (url.endsWith("/api/models")) { + return Response.json([]); } if (url.endsWith("/api/keys") && method === "GET") { keysGets += 1; @@ -142,6 +154,7 @@ test("successful key delete keeps last-good keys visible when follow-up refresh document: { configurable: true, value: testWindow.document }, window: { configurable: true, value: testWindow }, localStorage: { configurable: true, value: testWindow.localStorage }, + sessionStorage: { configurable: true, value: testWindow.sessionStorage }, IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true }, }); @@ -149,8 +162,8 @@ test("successful key delete keeps last-good keys visible when follow-up refresh globalThis.fetch = (async (input, init) => { const url = String(input); const method = (init?.method ?? "GET").toUpperCase(); - if (url.endsWith("/v1/models")) { - return Response.json({ data: [] }); + if (url.endsWith("/api/models")) { + return Response.json([]); } if (url.endsWith("/api/keys") && method === "GET") { keysGets += 1; @@ -219,3 +232,182 @@ test("successful key delete keeps last-good keys visible when follow-up refresh testWindow.close(); } }); + +test("loads callable model ids through the management API without touching the data plane", async () => { + const testWindow = new Window({ url: "http://localhost/" }); + const container = testWindow.document.createElement("div"); + testWindow.document.body.appendChild(container); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + localStorage: { configurable: true, value: testWindow.localStorage }, + sessionStorage: { configurable: true, value: testWindow.sessionStorage }, + IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true }, + }); + + const requestedPaths: string[] = []; + const authorizedPaths: string[] = []; + let promptCalls = 0; + testWindow.prompt = () => { + promptCalls += 1; + return "admin-token"; + }; + const rawFetch = (async (input, init) => { + const url = new URL(String(input)); + requestedPaths.push(url.pathname); + const token = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)) + .get("X-OpenCodex-API-Key"); + if (token !== "admin-token") return new Response("unauthorized", { status: 401 }); + authorizedPaths.push(url.pathname); + if (url.pathname === "/api/keys") return Response.json(KEYS_OK); + if (url.pathname === "/api/models") { + return Response.json([ + { + provider: "mock", + id: "model-one", + namespaced: "mock/model-one", + displayName: "Model One", + }, + { + provider: "mock", + id: "disabled-model", + namespaced: "mock/disabled-model", + disabled: true, + }, + { + provider: "openai", + id: "gpt-direct", + namespaced: "gpt-direct", + native: true, + managementTestable: false, + }, + ]); + } + return new Response(null, { status: 404 }); + }) as typeof fetch; + installManagementAuthFetch(testWindow, rawFetch); + + const { createRoot } = await import("react-dom/client"); + let root!: Root; + try { + await act(async () => { + root = createRoot(container); + root.render( + + + , + ); + }); + await act(async () => { + await new Promise((resolve) => testWindow.setTimeout(resolve, 0)); + }); + + expect(requestedPaths).toContain("/api/models"); + expect(requestedPaths).not.toContain("/v1/models"); + expect(authorizedPaths).toContain("/api/models"); + expect(promptCalls).toBe(1); + expect(container.textContent).toContain("mock/model-one"); + expect(container.textContent).toContain("Model One"); + expect(container.textContent).not.toContain("mock/disabled-model"); + const directRow = [...container.querySelectorAll("tbody tr")] + .find(row => row.textContent?.includes("gpt-direct")); + expect(directRow).toBeTruthy(); + expect([...directRow!.querySelectorAll("button")].some(button => button.textContent === "Test")).toBe(false); + } finally { + await act(async () => root.unmount()); + testWindow.close(); + } +}); + +test("tests a model through the management API without calling a data-plane endpoint", async () => { + const testWindow = new Window({ url: "http://localhost/" }); + const container = testWindow.document.createElement("div"); + testWindow.document.body.appendChild(container); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + localStorage: { configurable: true, value: testWindow.localStorage }, + sessionStorage: { configurable: true, value: testWindow.sessionStorage }, + IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true }, + }); + + const requests: Array<{ path: string; method: string; body?: unknown }> = []; + let promptCalls = 0; + let probeManagementToken: string | null = null; + let failProbe = false; + testWindow.prompt = () => { + promptCalls += 1; + return "admin-token"; + }; + const rawFetch = (async (input, init) => { + const url = new URL(String(input)); + const method = (init?.method ?? "GET").toUpperCase(); + const token = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)) + .get("X-OpenCodex-API-Key"); + if (token !== "admin-token") return new Response("unauthorized", { status: 401 }); + let body: unknown; + if (typeof init?.body === "string") body = JSON.parse(init.body) as unknown; + requests.push({ path: url.pathname, method, ...(body === undefined ? {} : { body }) }); + if (url.pathname === "/api/keys") return Response.json(KEYS_OK); + if (url.pathname === "/api/models" && method === "GET") { + return Response.json([{ + provider: "mock", + id: "model-one", + namespaced: "mock/model-one", + }]); + } + if (url.pathname === "/api/models/test" && method === "POST") { + probeManagementToken = token; + return failProbe + ? Response.json({ error: "upstream rejected the model" }, { status: 502 }) + : Response.json({ ok: true }); + } + return new Response(null, { status: 404 }); + }) as typeof fetch; + installManagementAuthFetch(testWindow, rawFetch); + + const { createRoot } = await import("react-dom/client"); + let root!: Root; + try { + await act(async () => { + root = createRoot(container); + root.render( + + + , + ); + }); + await act(async () => { + await new Promise((resolve) => testWindow.setTimeout(resolve, 0)); + }); + + const testButton = [...container.querySelectorAll("button")] + .find(button => button.textContent === "Test"); + expect(testButton).toBeTruthy(); + await act(async () => { + testButton!.click(); + await new Promise((resolve) => testWindow.setTimeout(resolve, 0)); + }); + + expect(requests).toContainEqual({ + path: "/api/models/test", + method: "POST", + body: { model: "mock/model-one" }, + }); + expect(requests.some(request => request.path.startsWith("/v1/") && request.method === "POST")).toBe(false); + expect(probeManagementToken).toBe("admin-token"); + expect(promptCalls).toBe(1); + expect(container.querySelector(".api-test-note--ok")).not.toBeNull(); + + failProbe = true; + await act(async () => { + testButton!.click(); + await new Promise((resolve) => testWindow.setTimeout(resolve, 0)); + }); + expect(container.querySelector(".api-test-note--error")?.textContent) + .toContain("upstream rejected the model"); + } finally { + await act(async () => root.unmount()); + testWindow.close(); + } +}); diff --git a/gui/tests/codex-account-pool-controller.test.ts b/gui/tests/codex-account-pool-controller.test.ts index d7e8651b5..1f1b1f15e 100644 --- a/gui/tests/codex-account-pool-controller.test.ts +++ b/gui/tests/codex-account-pool-controller.test.ts @@ -35,12 +35,15 @@ test("main and added account cards expose the same persisted pause control", asy const pool = await read("../src/components/CodexAccountPool.tsx"); const mainCard = await read("../src/components/codex-account-pool-main-card.tsx"); const addedCards = await read("../src/components/codex-account-pool-cards.tsx"); + const helpers = await read("../src/components/codex-account-pool-helpers.tsx"); expect(pool).toContain("controller.setAccountPaused(account.id, paused)"); expect(mainCard).toContain("onTogglePause(mainSwitchEntry)"); expect(addedCards).toContain("onTogglePause(a)"); expect(mainCard).toContain("); }); await act(async () => { await new Promise(r => setTimeout(r, 50)); }); + const expandAll = Array.from(container.querySelectorAll("button")) + .find(button => (button.textContent ?? "").includes("Expand all")); + if (!expandAll) throw new Error("expand-all button not found"); + await act(async () => { expandAll.click(); }); } function switchFor(id: string): HTMLButtonElement { diff --git a/gui/tests/models-empty-provider.test.tsx b/gui/tests/models-empty-provider.test.tsx index 717e88a1b..67c401163 100644 --- a/gui/tests/models-empty-provider.test.tsx +++ b/gui/tests/models-empty-provider.test.tsx @@ -167,7 +167,8 @@ test("Models page combines final visibility, atomic actions, discovery status, a }); const switchFor = (id: string) => container.querySelector(`button[aria-label="${provider}/${id}"]`)!; - const buttonText = (text: string) => [...container.querySelectorAll("button")].find(button => button.textContent === text)!; + const buttonText = (text: string) => [...container.querySelectorAll("button")].find(button => (button.textContent ?? "").includes(text))!; + await act(async () => { buttonText("Expand all").click(); }); expect(container.textContent).toContain("2/5 visible"); expect(switchFor("gemini-pro").getAttribute("aria-pressed")).toBe("true"); expect(switchFor("claude-sonnet").getAttribute("aria-pressed")).toBe("false"); @@ -552,6 +553,10 @@ test("a poll that resolves after a forced refresh cannot overwrite newer models" ); }); await act(async () => { await new Promise(resolve => testWindow.setTimeout(resolve, 0)); await Promise.resolve(); }); + const expandAll = [...container.querySelectorAll("button")] + .find(button => (button.textContent ?? "").includes("Expand all")); + expect(expandAll).toBeTruthy(); + await act(async () => { expandAll!.click(); }); expect(container.textContent).toContain("stale-a"); // Start the poll and leave its /api/models response pending. diff --git a/gui/tests/provider-auth-login-copy-link.test.tsx b/gui/tests/provider-auth-login-copy-link.test.tsx index 8b6fd231a..49f48eace 100644 --- a/gui/tests/provider-auth-login-copy-link.test.tsx +++ b/gui/tests/provider-auth-login-copy-link.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, expect, test } from "bun:test"; +import { afterEach, beforeEach, expect, jest, test } from "bun:test"; import { Window } from "happy-dom"; import { act } from "react"; import type { Root } from "react-dom/client"; @@ -70,6 +70,7 @@ beforeEach(() => { }); afterEach(async () => { + jest.useRealTimers(); if (root) { const current = root; await act(async () => { current.unmount(); }); @@ -133,24 +134,28 @@ test("clipboard-less context reports unavailability instead of a false success", }); test("repeated copies keep the latest feedback for its full window", async () => { + jest.useFakeTimers(); await mountPanel({ provider: "claude", url: AUTH_URL }); await act(async () => { copyLinkButton().dispatchEvent(new win.MouseEvent("click", { bubbles: true })); - await new Promise((r) => setTimeout(r, 0)); + await Promise.resolve(); }); // Second click lands inside the first click's 2.5s feedback window. await act(async () => { - await new Promise((r) => setTimeout(r, 200)); + jest.advanceTimersByTime(200); copyLinkButton().dispatchEvent(new win.MouseEvent("click", { bubbles: true })); - await new Promise((r) => setTimeout(r, 0)); + await Promise.resolve(); }); expect(clipboardWrites).toEqual([AUTH_URL, AUTH_URL]); - // Past the FIRST click's expiry but before the second's: an unguarded - // timer would have already wiped the second click's feedback here. - await act(async () => { await new Promise((r) => setTimeout(r, 2400)); }); + // Reach the FIRST click's expiry exactly. If its timer was not cancelled, + // it would wipe the second click's feedback here. + await act(async () => { jest.advanceTimersByTime(2300); }); expect(host.textContent).toContain("Copied"); + + await act(async () => { jest.advanceTimersByTime(200); }); + expect(host.textContent).not.toContain("Copied"); }); test("the open-in-browser fallback link survives alongside the copy button", async () => { diff --git a/gui/tests/vite-management-proxy.test.ts b/gui/tests/vite-management-proxy.test.ts new file mode 100644 index 000000000..e91b2bcb1 --- /dev/null +++ b/gui/tests/vite-management-proxy.test.ts @@ -0,0 +1,271 @@ +import { expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { createServer as createHttpServer } from "node:http"; +import { connect, type Socket } from "node:net"; +import { createServer, type InlineConfig } from "vite"; +import { handleManagementAPI } from "../../src/server/management-api"; +import type { OcxConfig } from "../../src/types"; + +test("Vite development proxy preserves the browser host for management writes", async () => { + const config = { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + }, + } as OcxConfig; + const backend = Bun.serve({ + port: 0, + async fetch(request) { + return await handleManagementAPI(request, new URL(request.url), config) ?? new Response(null, { status: 404 }); + }, + }); + const previousTarget = process.env.OPENCODEX_PROXY_TARGET; + process.env.OPENCODEX_PROXY_TARGET = backend.url.toString(); + let developmentServer: Awaited> | null = null; + try { + const loaded = await import(`../vite.config.ts?management-proxy-test=${Date.now()}`) as { default: InlineConfig }; + developmentServer = await createServer({ + ...loaded.default, + configFile: false, + logLevel: "silent", + server: { + ...loaded.default.server, + host: "127.0.0.1", + port: 0, + strictPort: false, + }, + }); + await developmentServer.listen(); + const address = developmentServer.httpServer?.address(); + if (!address || typeof address === "string") throw new Error("Vite test server did not expose a TCP port"); + const frontendOrigin = `http://127.0.0.1:${address.port}`; + + const response = await fetch(`${frontendOrigin}/api/providers/test?name=openai`, { + method: "POST", + headers: { origin: frontendOrigin }, + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual(expect.objectContaining({ ok: true })); + } finally { + if (developmentServer) await developmentServer.close(); + backend.stop(true); + if (previousTarget === undefined) delete process.env.OPENCODEX_PROXY_TARGET; + else process.env.OPENCODEX_PROXY_TARGET = previousTarget; + } +}, 10_000); + +async function expectReturnedDataEndpointToBeReachable(hostname: string | undefined): Promise { + const dataKey = "ocx_data_proxy_test"; + const config = { + port: 0, + ...(hostname === undefined ? {} : { hostname }), + defaultProvider: "openai", + apiKeys: [{ + id: "proxy-test-key", + name: "proxy test", + key: dataKey, + createdAt: "2026-07-29T00:00:00.000Z", + }], + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + }, + } as OcxConfig; + let dataRequests = 0; + const backend = Bun.serve({ + port: 0, + async fetch(request) { + const url = new URL(request.url); + if (url.pathname === "/v1/models") { + dataRequests += 1; + if (request.headers.get("x-opencodex-api-key") !== dataKey) { + return new Response("unauthorized", { status: 401 }); + } + return Response.json({ + object: "list", + data: [{ id: "proxy-model", object: "model", created: 0, owned_by: "proxy-test" }], + }); + } + return await handleManagementAPI(request, url, config) ?? new Response(null, { status: 404 }); + }, + }); + const previousTarget = process.env.OPENCODEX_PROXY_TARGET; + process.env.OPENCODEX_PROXY_TARGET = backend.url.toString(); + let developmentServer: Awaited> | null = null; + try { + const loaded = await import( + `../vite.config.ts?data-proxy-test=${encodeURIComponent(hostname ?? "default")}-${Date.now()}` + ) as { default: InlineConfig }; + developmentServer = await createServer({ + ...loaded.default, + configFile: false, + logLevel: "silent", + server: { + ...loaded.default.server, + host: "127.0.0.1", + port: 0, + strictPort: false, + }, + }); + await developmentServer.listen(); + const address = developmentServer.httpServer?.address(); + if (!address || typeof address === "string") throw new Error("Vite test server did not expose a TCP port"); + const frontendOrigin = `http://127.0.0.1:${address.port}`; + + const keysResponse = await fetch(`${frontendOrigin}/api/keys`, { + headers: { origin: frontendOrigin }, + }); + expect(keysResponse.status).toBe(200); + const endpoints = await keysResponse.json() as { modelsEndpoint?: string }; + expect(endpoints.modelsEndpoint).toBe(`${frontendOrigin}/v1/models`); + + const modelsResponse = await fetch(endpoints.modelsEndpoint!, { + headers: { "x-opencodex-api-key": dataKey }, + }); + expect(modelsResponse.status).toBe(200); + expect(await modelsResponse.json()).toEqual({ + object: "list", + data: [{ id: "proxy-model", object: "model", created: 0, owned_by: "proxy-test" }], + }); + expect(dataRequests).toBe(1); + } finally { + if (developmentServer) await developmentServer.close(); + backend.stop(true); + if (previousTarget === undefined) delete process.env.OPENCODEX_PROXY_TARGET; + else process.env.OPENCODEX_PROXY_TARGET = previousTarget; + } +} + +test("Vite development proxy exposes the default-host data endpoint returned by API keys", async () => { + await expectReturnedDataEndpointToBeReachable(undefined); +}); + +test("Vite development proxy exposes the wildcard-host data endpoint returned by API keys", async () => { + await expectReturnedDataEndpointToBeReachable("0.0.0.0"); +}); + +test("Vite development proxy forwards data WebSocket upgrades to the backend", async () => { + let backendUpgrades = 0; + let resolveBackendUpgrade: (() => void) | undefined; + const backendUpgradeReceived = new Promise((resolve) => { + resolveBackendUpgrade = resolve; + }); + let backendSocket: { destroy(): void } | null = null; + const backend = createHttpServer((_request, response) => { + response.writeHead(404).end(); + }); + backend.on("upgrade", (request, upgradedSocket) => { + if (request.url !== "/v1/realtime") { + upgradedSocket.destroy(); + return; + } + const key = request.headers["sec-websocket-key"]; + if (typeof key !== "string") { + upgradedSocket.destroy(); + return; + } + backendUpgrades += 1; + resolveBackendUpgrade?.(); + backendSocket = upgradedSocket; + const accept = createHash("sha1") + .update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`) + .digest("base64"); + upgradedSocket.write([ + "HTTP/1.1 101 Switching Protocols", + "Connection: Upgrade", + "Upgrade: websocket", + `Sec-WebSocket-Accept: ${accept}`, + "", + "", + ].join("\r\n")); + const payload = Buffer.from("backend-ready"); + upgradedSocket.write(Buffer.concat([Buffer.from([0x81, payload.length]), payload])); + }); + await new Promise((resolve, reject) => { + const onError = (error: Error) => reject(error); + backend.once("error", onError); + backend.listen(0, "127.0.0.1", () => { + backend.off("error", onError); + resolve(); + }); + }); + const backendAddress = backend.address(); + if (!backendAddress || typeof backendAddress === "string") { + throw new Error("WebSocket backend did not expose a TCP port"); + } + const previousTarget = process.env.OPENCODEX_PROXY_TARGET; + process.env.OPENCODEX_PROXY_TARGET = `http://127.0.0.1:${backendAddress.port}`; + let developmentServer: Awaited> | null = null; + let socket: Socket | null = null; + try { + const loaded = await import( + `../vite.config.ts?data-websocket-proxy-test=${Date.now()}` + ) as { default: InlineConfig }; + developmentServer = await createServer({ + ...loaded.default, + configFile: false, + logLevel: "silent", + server: { + ...loaded.default.server, + host: "127.0.0.1", + port: 0, + strictPort: false, + }, + }); + await developmentServer.listen(); + const address = developmentServer.httpServer?.address(); + if (!address || typeof address === "string") throw new Error("Vite test server did not expose a TCP port"); + + await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error( + `Timed out waiting for proxied WebSocket upgrade; backend upgrades: ${backendUpgrades}`, + )), + 2_000, + ); + socket = connect({ host: "127.0.0.1", port: address.port }, () => { + socket?.write([ + "GET /v1/realtime HTTP/1.1", + `Host: 127.0.0.1:${address.port}`, + "Connection: Upgrade", + "Upgrade: websocket", + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==", + "Sec-WebSocket-Version: 13", + "", + "", + ].join("\r\n")); + }); + backendUpgradeReceived.then(() => { + clearTimeout(timeout); + resolve(); + }); + socket.once("error", (error) => { + clearTimeout(timeout); + reject(error); + }); + }); + + expect(backendUpgrades).toBe(1); + } finally { + socket?.destroy(); + backendSocket?.destroy(); + if (developmentServer) await developmentServer.close(); + await new Promise((resolve, reject) => { + backend.close((error) => { + if (error) reject(error); + else resolve(); + }); + }); + if (previousTarget === undefined) delete process.env.OPENCODEX_PROXY_TARGET; + else process.env.OPENCODEX_PROXY_TARGET = previousTarget; + } +}); diff --git a/gui/vite.config.ts b/gui/vite.config.ts index 68a5045f9..fda093ff4 100644 --- a/gui/vite.config.ts +++ b/gui/vite.config.ts @@ -18,7 +18,10 @@ export default defineConfig({ */ server: proxyTarget ? { proxy: { - '/api': { target: proxyTarget, changeOrigin: true }, + // Management origin checks compare the browser Origin with Host. Preserve the browser-facing + // development host/port so proxied writes keep the same-origin identity the browser used. + '/api': { target: proxyTarget, changeOrigin: false }, + '/v1': { target: proxyTarget, changeOrigin: false, ws: true }, '/healthz': { target: proxyTarget, changeOrigin: true }, }, } : undefined, diff --git a/readme/README.ja.md b/readme/README.ja.md index fc8fdc020..112c60f3c 100644 --- a/readme/README.ja.md +++ b/readme/README.ja.md @@ -257,7 +257,7 @@ ocx init # 対話型セットアップ ocx start [--port 10100] # プロキシ起動; ポートが使用中なら空きポートに自動切替 ocx stop # プロキシ停止 + Codex を元の設定に復元 ocx restore # 停止せずに復元(エイリアス: ocx eject) -ocx uninstall # service/shim/config を削除 + Codex をオリジナルに復元 +ocx uninstall # service/shim/所有状態を削除 + Codex をオリジナルに復元 ocx ensure # 必要時に起動 + Codex config/cache を更新 ocx sync # モデルを更新 + Codex に再注入 ocx status # プロキシは起動中か? @@ -303,8 +303,11 @@ ocx uninstall npm uninstall -g @bitkyc08/opencodex ``` -`ocx uninstall` はプロキシの停止、インストールされた service の削除、Codex shim の削除、Codex config/catalog/history の -復元、`~/.opencodex` の削除を行います。 +`ocx uninstall` はプロキシを停止し、インストールされた service と Codex shim を削除して、ネイティブ +Codex の config/catalog/history を復元します。ローカル状態はインストール所有権マニフェストに記録された +項目だけを削除し、それ以外は保持します。マニフェストがない旧版の空でない設定ディレクトリや、所有項目の +削除後も外部ファイルが残るディレクトリは手動確認のため保持されます。残りを手動削除する前に、コマンドが +報告したパスと内容を確認してください。 ## 設定 @@ -378,21 +381,38 @@ WebSocket トランスポートはデフォルトでオフです。Codex が HTT ### リモートアクセス -デフォルトで opencodex は `127.0.0.1`(ループバック)にバインドされ、追加の認証は不要です。 -`"hostname": "0.0.0.0"` で LAN に公開する場合、opencodex は管理 API(`/api/*`)とデータプレーン -(`/v1/responses`、`/v1/images/generations`、`/v1/images/edits`)の両方に bearer トークンを要求します: +デフォルトで opencodex は `127.0.0.1`(ループバック)にバインドします。認証情報は用途別に分離されています。 + +- **データプレーン認証情報** — `OPENCODEX_API_AUTH_TOKEN`、保護された `service-api-token` ファイル、 + ダッシュボードで生成した `config.apiKeys` は `/v1/*` と対応する WebSocket ハンドシェイクだけを認証します。 + 非ループバックバインドには `OPENCODEX_API_AUTH_TOKEN`(直接設定、または保護されたサービスファイル経由) + か、少なくとも一つの `config.apiKeys` 項目が必要です。 +- **管理認証情報** — `OPENCODEX_ADMIN_AUTH_TOKEN` または独立して保護された `admin-api-token` ファイルは + `/api/*` だけを認証します。管理環境トークンがなければサーバーがファイルを作成します。サービスインストールは + 明示した管理トークンを保護された `service-admin-token` 配布ファイルに保存しますが、これは第四の種類ではありません。 +- **ダッシュボードセッション** — 正規の同一オリジンのループバック入口は、短時間かつ Origin に結び付いた + `/api/*` 専用セッションを受け取ります。リモート運用者とスクリプトは管理トークンを使用します。 + +以前のリリースでは `OPENCODEX_API_AUTH_TOKEN` と `config.apiKeys` も `/api/*` に使用できましたが、 +分離後はデータプレーン専用です。既存の管理スクリプトは `OPENCODEX_ADMIN_AUTH_TOKEN` または保護された +`admin-api-token` に切り替えてください。 + +LAN バインドではどちらの形式のデータプレーン認証情報も使用できます。以下は環境トークン形式で、リモート運用や +自動化には管理トークンも明示します。 ```bash -export OPENCODEX_API_AUTH_TOKEN="your-secret-token" +export OPENCODEX_API_AUTH_TOKEN="your-data-plane-token" +export OPENCODEX_ADMIN_AUTH_TOKEN="your-management-token" ocx start ``` -非ループバックバインド時にこの環境変数がないとプロキシの起動は拒否されます。LAN アクセス用のバックグラウンド -サービスをインストールする場合も、同じシェルでこの変数を先に設定してから `ocx service install` を実行してください。 -クライアント(スクリプト、リモートマシン)はすべてのリクエストにトークンを含める必要があります: +非ループバックバインドで `OPENCODEX_API_AUTH_TOKEN`(直接設定、または保護された +`service-api-token` ファイル経由)と `config.apiKeys` の両方がなければ起動を拒否します。バックグラウンド +サービスをインストールする前に、必要な明示トークンを同じシェルで export してください。インストーラーは保護された +配布ファイルを介して保存します。クライアント(スクリプト、リモートマシン)はリクエスト対象面の認証情報を送ります。 -``` -x-opencodex-api-key: your-secret-token +```http +x-opencodex-api-key: the-token-for-this-request-plane ``` トークンはタイミング攻撃を防ぐため定数時間で比較されます。 diff --git a/readme/README.ko.md b/readme/README.ko.md index ddb900a20..850bb1f8a 100644 --- a/readme/README.ko.md +++ b/readme/README.ko.md @@ -248,7 +248,7 @@ ocx init # 대화형 설정 ocx start [--port 10100] # 프록시 시작; 포트가 사용 중이면 빈 포트로 자동 전환 ocx stop # 프록시 중지 + Codex 원래 설정 복원 ocx restore # 중지 없이 복원 (별칭: ocx eject) -ocx uninstall # service/shim/config 제거 + Codex 원본 복원 +ocx uninstall # service/shim/소유 상태 제거 + Codex 원본 복원 ocx ensure # 필요 시 시작 + Codex config/cache 갱신 ocx sync # 모델 갱신 + Codex에 재주입 ocx status # 프록시 실행 중인지 확인 @@ -317,8 +317,11 @@ ocx uninstall npm uninstall -g @bitkyc08/opencodex ``` -`ocx uninstall`은 프록시 중지, 설치된 service 제거, Codex shim 제거, Codex config/catalog/history -원복, `~/.opencodex` 삭제를 처리합니다. +`ocx uninstall`은 프록시를 중지하고 설치된 service와 Codex shim을 제거한 뒤 네이티브 Codex +config/catalog/history를 복원합니다. 로컬 상태는 설치 소유권 매니페스트에 기록된 항목만 삭제하며, +기록되지 않은 항목은 보존합니다. 매니페스트가 없는 기존 비어 있지 않은 설정 디렉터리와 소유 항목을 +삭제한 뒤에도 외부 파일이 남은 디렉터리는 수동 검토를 위해 그대로 둡니다. 나머지를 직접 삭제하기 +전에 명령이 보고한 경로와 내용을 확인하세요. ## 설정 @@ -392,21 +395,38 @@ WebSocket 전송은 기본적으로 꺼져 있습니다. Codex가 HTTP/SSE 대 ### 원격 접근 -기본적으로 opencodex는 `127.0.0.1`(루프백)에 바인딩되며 별도 인증이 필요 없습니다. -`"hostname": "0.0.0.0"`으로 LAN에 노출할 경우, opencodex는 관리 API(`/api/*`)와 데이터 플레인 -(`/v1/responses`, `/v1/images/generations`, `/v1/images/edits`) 모두에 bearer 토큰을 요구합니다: +기본적으로 opencodex는 `127.0.0.1`(루프백)에 바인딩됩니다. 자격 증명은 용도별로 분리됩니다. + +- **데이터 플레인 자격 증명** — `OPENCODEX_API_AUTH_TOKEN`, 보호된 `service-api-token` 파일, + 대시보드에서 생성한 `config.apiKeys`는 `/v1/*`와 대응하는 WebSocket 핸드셰이크만 인증합니다. + 비루프백 바인드에는 `OPENCODEX_API_AUTH_TOKEN`(직접 설정하거나 보호된 서비스 파일로 전달) 또는 + 하나 이상의 `config.apiKeys` 항목이 필요합니다. +- **관리 자격 증명** — `OPENCODEX_ADMIN_AUTH_TOKEN` 또는 독립적으로 보호된 `admin-api-token` + 파일은 `/api/*`만 인증합니다. 관리 환경 토큰이 없으면 서버가 파일을 생성합니다. 서비스 설치는 + 명시한 관리 토큰을 보호된 `service-admin-token` 전달 파일에 저장하지만, 이는 네 번째 종류가 아닙니다. +- **대시보드 세션** — 정상적인 동일 출처 루프백 대시보드 진입점은 짧은 수명의 Origin 바인딩 세션을 + 받아 `/api/*`만 인증합니다. 원격 운영자와 스크립트는 관리 토큰을 사용해야 합니다. + +이전 릴리스에서는 `OPENCODEX_API_AUTH_TOKEN`과 `config.apiKeys`도 `/api/*`에 사용할 수 있었지만, +분리 후에는 데이터 플레인 전용입니다. 기존 관리 스크립트는 `OPENCODEX_ADMIN_AUTH_TOKEN` 또는 +보호된 `admin-api-token`으로 전환하세요. + +LAN 바인드에는 어느 형태의 데이터 플레인 자격 증명이든 사용할 수 있습니다. 아래는 환경 토큰 형식이며, +원격 운영이나 자동화에는 관리 토큰도 명시합니다. ```bash -export OPENCODEX_API_AUTH_TOKEN="your-secret-token" +export OPENCODEX_API_AUTH_TOKEN="your-data-plane-token" +export OPENCODEX_ADMIN_AUTH_TOKEN="your-management-token" ocx start ``` -비루프백 바인딩 시 이 환경 변수가 없으면 프록시 시작이 거부됩니다. LAN 접근용 백그라운드 -서비스를 설치할 때도 같은 셸에서 이 변수를 먼저 설정한 뒤 `ocx service install`을 실행해야 합니다. -클라이언트(스크립트, 원격 머신)는 모든 요청에 토큰을 포함해야 합니다: +비루프백 바인드에서 `OPENCODEX_API_AUTH_TOKEN`(직접 설정하거나 보호된 `service-api-token` 파일로 +전달)과 `config.apiKeys`가 모두 없으면 프록시 시작이 거부됩니다. +백그라운드 서비스를 설치하기 전에 필요한 명시적 토큰을 같은 셸에서 export하세요. 설치 프로그램은 +보호된 전달 파일로 저장합니다. 클라이언트(스크립트, 원격 머신)는 요청 대상 면의 자격 증명을 보냅니다. -``` -x-opencodex-api-key: your-secret-token +```http +x-opencodex-api-key: the-token-for-this-request-plane ``` 토큰은 타이밍 공격 방지를 위해 상수 시간으로 비교됩니다. diff --git a/readme/README.ru.md b/readme/README.ru.md index 2f04d0634..afeb3e548 100644 --- a/readme/README.ru.md +++ b/readme/README.ru.md @@ -289,7 +289,7 @@ ocx init # интерактивная настройка ocx start [--port 10100] # запустить прокси; если порт занят, выбирается свободный ocx stop # остановить + восстановить нативный Codex ocx restore # восстановить без остановки (алиас: ocx eject) -ocx uninstall # удалить службу/shim/конфигурацию и восстановить нативный Codex +ocx uninstall # удалить службу/shim/принадлежащее состояние и восстановить нативный Codex ocx ensure # запустить при необходимости + обновить конфигурацию/кэш Codex ocx sync # обновить модели + заново встроиться в Codex ocx codex-shim install # выполнять `ocx ensure` при каждом запуске `codex` @@ -335,8 +335,12 @@ ocx uninstall npm uninstall -g @bitkyc08/opencodex ``` -`ocx uninstall` останавливает прокси, удаляет установленную службу, удаляет shim для Codex, -восстанавливает нативные конфигурацию/каталог/историю Codex и удаляет `~/.opencodex`. +`ocx uninstall` останавливает прокси, удаляет установленную службу и shim для Codex, затем +восстанавливает нативные конфигурацию/каталог/историю Codex. Локальное состояние удаляется только +по манифесту владения установкой; не записанные в нём элементы сохраняются. Старый непустой каталог +конфигурации без манифеста, а также каталог с посторонними файлами после удаления принадлежащих +opencodex элементов остаётся для ручной проверки. Прежде чем вручную удалить остаток, проверьте +путь и содержимое, указанные командой. ## Конфигурация @@ -415,23 +419,42 @@ JSON обрезан или испорчен вручную), opencodex сохр ### Удалённый доступ -По умолчанию opencodex привязывается к `127.0.0.1` (loopback) и не требует дополнительной аутентификации. -Если вы задаёте `"hostname": "0.0.0.0"`, открывая прокси в локальной сети, opencodex требует bearer-токен -для защиты как управляющего API (`/api/*`), так и плоскости данных (`/v1/responses`, -`/v1/images/generations` и `/v1/images/edits`): +По умолчанию opencodex привязывается к `127.0.0.1` (loopback). Учётные данные разделены по назначению: + +- **Учётные данные плоскости данных** — `OPENCODEX_API_AUTH_TOKEN`, защищённый файл + `service-api-token` и созданные дашбордом `config.apiKeys` разрешают только `/v1/*` и + соответствующие WebSocket-рукопожатия. Для не-loopback-привязки нужен либо + `OPENCODEX_API_AUTH_TOKEN` (заданный напрямую или доставленный через защищённый файл службы), + либо хотя бы одна запись `config.apiKeys`. +- **Учётные данные управления** — `OPENCODEX_ADMIN_AUTH_TOKEN` или отдельный защищённый файл + `admin-api-token` разрешает только `/api/*`. Если переменная окружения управления не задана, + сервер создаёт этот файл. Установка службы сохраняет явно заданный токен управления через + защищённый файл доставки `service-admin-token`; это не четвёртый тип учётных данных. +- **Сеансы дашборда** — корректная loopback-страница того же origin получает короткоживущий, + привязанный к Origin сеанс только для `/api/*`. Удалённые операторы и скрипты используют токен + управления. + +В прежних версиях `OPENCODEX_API_AUTH_TOKEN` и `config.apiKeys` также разрешали `/api/*`; после +разделения они относятся только к плоскости данных. Переведите существующие скрипты управления на +`OPENCODEX_ADMIN_AUTH_TOKEN` или защищённый `admin-api-token`. + +Для LAN-привязки можно использовать любую форму учётных данных плоскости данных. Ниже показана +переменная окружения; для удалённого управления и автоматизации также задаётся токен управления: ```bash -export OPENCODEX_API_AUTH_TOKEN="your-secret-token" +export OPENCODEX_API_AUTH_TOKEN="your-data-plane-token" +export OPENCODEX_ADMIN_AUTH_TOKEN="your-management-token" ocx start ``` -Без этой переменной прокси откажется запускаться при привязке за пределами loopback. Если вы -устанавливаете фоновую службу для доступа из локальной сети, экспортируйте ту же переменную перед -`ocx service install`, чтобы менеджер служб её получил. -Клиенты (скрипты, удалённые машины) должны передавать токен в каждом запросе: +Если при не-loopback-привязке нет ни `OPENCODEX_API_AUTH_TOKEN` (заданного напрямую или через +защищённый файл `service-api-token`), ни `config.apiKeys`, прокси не запустится. +Перед установкой фоновой службы экспортируйте нужные явные токены в том же shell; установщик +сохранит их через защищённые файлы доставки. Клиенты (скрипты, удалённые машины) передают учётные +данные для соответствующей плоскости: -``` -x-opencodex-api-key: your-secret-token +```http +x-opencodex-api-key: the-token-for-this-request-plane ``` Токен сравнивается за постоянное время для защиты от атак по времени. diff --git a/readme/README.zh-CN.md b/readme/README.zh-CN.md index b4ef7a8de..54ce50534 100644 --- a/readme/README.zh-CN.md +++ b/readme/README.zh-CN.md @@ -241,7 +241,7 @@ ocx init # 交互式初始化 ocx start [--port 10100] # 启动代理 ocx stop # 停止并恢复原生 Codex 配置 ocx restore # 仅恢复,不停止(别名:ocx eject) -ocx uninstall # 移除 service/shim/config 并恢复原生 Codex +ocx uninstall # 移除 service/shim/自有状态并恢复原生 Codex ocx ensure # 按需启动 + 刷新 Codex config/cache ocx sync # 刷新模型列表 + 重新注入 Codex ocx status # 查看代理是否在运行 @@ -308,7 +308,10 @@ ocx uninstall npm uninstall -g @bitkyc08/opencodex ``` -`ocx uninstall` 会停止代理、移除已安装的 service、移除 Codex shim、恢复原生 Codex config/catalog/history,并删除 `~/.opencodex`。 +`ocx uninstall` 会停止代理、移除已安装的 service、移除 Codex shim,并恢复原生 Codex +config/catalog/history。本地状态只会按照安装所有权清单删除;未记录在清单中的条目会保留。没有清单的 +旧版非空配置目录,以及删除自有条目后仍含外来文件的目录,都会保留以供人工复核。手工删除任何残留前, +请先检查命令报告的路径及其内容。 ## 配置 @@ -372,20 +375,34 @@ WebSocket 传输默认关闭。只有当你希望 Codex 使用 Responses WebSock ### 远程访问 -默认情况下 opencodex 绑定到 `127.0.0.1`(回环)且无需额外认证。 -如果你设置 `"hostname": "0.0.0.0"` 把代理暴露到局域网,opencodex 会要求一个 bearer token 来同时保护管理 -API(`/api/*`)和数据平面(`/v1/responses`、`/v1/images/generations`、`/v1/images/edits`): +默认情况下 opencodex 绑定到 `127.0.0.1`(回环)。认证按用途拆分: + +- **数据面凭据** — `OPENCODEX_API_AUTH_TOKEN`、受保护的 `service-api-token` 文件和仪表盘生成的 + `config.apiKeys` 只授权 `/v1/*` 及对应的 WebSocket 握手。非回环绑定必须配置 + `OPENCODEX_API_AUTH_TOKEN`(直接设置或通过受保护的服务文件投递)或至少一个 `config.apiKeys` 条目。 +- **管理凭据** — `OPENCODEX_ADMIN_AUTH_TOKEN` 或独立受保护的 `admin-api-token` 文件只授权 + `/api/*`。若未配置管理环境令牌,服务器会创建该文件。服务安装通过受保护的 + `service-admin-token` 投递文件保存显式管理令牌;这仍是管理凭据,不是第四类凭据。 +- **仪表盘会话** — 合法的同源回环仪表盘入口会获得短期、绑定 Origin 的会话,只授权 `/api/*`。 + 远程运维人员和脚本必须使用管理令牌。 + +旧版本还允许 `OPENCODEX_API_AUTH_TOKEN` 和 `config.apiKeys` 访问 `/api/*`;拆分后它们只属于 +数据面。已有管理脚本必须改用 `OPENCODEX_ADMIN_AUTH_TOKEN` 或受保护的 `admin-api-token`。 + +局域网绑定可使用任一数据面凭据。下面演示环境令牌形式;远程运维或自动化还应显式设置管理令牌: ```bash -export OPENCODEX_API_AUTH_TOKEN="your-secret-token" +export OPENCODEX_API_AUTH_TOKEN="your-data-plane-token" +export OPENCODEX_ADMIN_AUTH_TOKEN="your-management-token" ocx start ``` -绑定到非回环地址时若缺少该环境变量,代理会拒绝启动。若为局域网访问安装后台服务,请在 `ocx service install` -之前于同一 shell 中导出相同变量,以便服务管理器接收到它。客户端(脚本、远程机器)必须在每个请求中带上 token: +绑定到非回环地址时若环境令牌与 `config.apiKeys` 均未配置,代理会拒绝启动。安装后台服务前请在同一 +shell 中导出需要的显式令牌;安装器会通过受保护的投递文件保存它们。客户端(脚本、远程机器)必须在 +每个请求中带上对应请求平面的凭据: ``` -x-opencodex-api-key: your-secret-token +x-opencodex-api-key: the-token-for-this-request-plane ``` token 以常量时间比较,以防止时序攻击。 diff --git a/scripts/test.ts b/scripts/test.ts index 96d846e88..4e9a3081d 100644 --- a/scripts/test.ts +++ b/scripts/test.ts @@ -1,6 +1,8 @@ import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { delimiter, dirname, join } from "node:path"; + +const WINDOWS_FULL_SUITE_SHARDS = 8; export interface IsolatedTestEnvironment { root: string; @@ -14,13 +16,19 @@ export function createIsolatedTestEnvironment( const root = mkdtempSync(join(tmpdir(), "opencodex-test-")); const opencodexHome = join(root, ".opencodex"); const codexHome = join(root, ".codex"); + const inheritedPath = Object.entries(baseEnv) + .find(([key]) => key.toLowerCase() === "path")?.[1]; + const normalizedBaseEnv = Object.fromEntries( + Object.entries(baseEnv).filter(([key]) => key.toLowerCase() !== "path"), + ); mkdirSync(opencodexHome, { recursive: true }); mkdirSync(codexHome, { recursive: true }); return { root, env: { - ...baseEnv, + ...normalizedBaseEnv, + PATH: [dirname(process.execPath), inheritedPath].filter(Boolean).join(delimiter), HOME: root, USERPROFILE: root, OPENCODEX_HOME: opencodexHome, @@ -32,6 +40,25 @@ export function createIsolatedTestEnvironment( }; } +export function buildTestInvocations( + requestedTests: string[], + platform = process.platform, + executable = process.execPath, +): string[][] { + const base = [executable, "test", "--isolate"]; + if (requestedTests.length > 0) return [[...base, ...requestedTests]]; + if (platform !== "win32") return [[...base, "./tests/"]]; + + // Bun 1.3.14 can crash internally on Windows when this service-heavy suite is + // handed to one worker pool. Official shards keep every file covered exactly once + // while bounding each pool's servers, workers, and native handles. + return Array.from({ length: WINDOWS_FULL_SUITE_SHARDS }, (_, index) => [ + ...base, + `--shard=${index + 1}/${WINDOWS_FULL_SUITE_SHARDS}`, + "./tests/", + ]); +} + /** * Other `bun test` runners already on this machine. * @@ -72,23 +99,28 @@ if (import.meta.main) { ); } const startedAt = Date.now(); - const child = Bun.spawnSync( - [process.execPath, "test", "--isolate", ...(requestedTests.length > 0 ? requestedTests : ["./tests/"])], - { + let exitCode = 0; + for (const invocation of buildTestInvocations(requestedTests)) { + const child = Bun.spawnSync(invocation, { env: isolated.env, stdin: "inherit", stdout: "inherit", stderr: "inherit", - }, - ); + }); + exitCode = child.exitCode ?? 1; + if (exitCode !== 0) { + break; + } + } const elapsedSeconds = Math.round((Date.now() - startedAt) / 1000); - if (requestedTests.length === 0 && elapsedSeconds > 600) { + const slowSuiteThresholdSeconds = process.platform === "win32" ? 1_200 : 600; + if (requestedTests.length === 0 && elapsedSeconds > slowSuiteThresholdSeconds) { console.warn( - `[test] the suite took ${elapsedSeconds}s; it normally runs in about 210s on an idle machine. ` + `[test] the suite took ${elapsedSeconds}s, beyond the expected idle-machine budget. ` + "Check for another test runner, a busy CPU, or a test that started polling something real.", ); } - process.exitCode = child.exitCode ?? 1; + process.exitCode = exitCode; } finally { isolated.cleanup(); } diff --git a/src/cli/help.ts b/src/cli/help.ts index 71850d375..56a6aa7b1 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -32,7 +32,7 @@ const helpEntries: Record = { summary: "Remove service/shim/config and restore native Codex.", details: [ "Alias: ocx remove", - "Config cleanup requires ownership metadata created by a fresh install; legacy or shared directories are left in place.", + "Config cleanup requires valid ownership metadata. Only manifest-listed paths are removed; unowned files remain, and legacy nonempty directories are never automatically claimed.", ], }, remove: { @@ -40,7 +40,7 @@ const helpEntries: Record = { summary: "Remove service/shim/config and restore native Codex.", details: [ "Alias of: ocx uninstall", - "Config cleanup requires ownership metadata created by a fresh install; legacy or shared directories are left in place.", + "Config cleanup requires valid ownership metadata. Only manifest-listed paths are removed; unowned files remain, and legacy nonempty directories are never automatically claimed.", ], }, service: { diff --git a/src/cli/index.ts b/src/cli/index.ts index 0cc8a45d2..37e30b14b 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -27,7 +27,7 @@ import { hasHelpFlag, printSubcommandUsage, printUsage, printVersion } from "./h import { findAvailablePort, isAddrInUse, PortUnavailableError, shouldPersistSelectedPort, waitForPortAvailable } from "../server/ports"; import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness"; import { stopProxy } from "../lib/process-control"; -import { loadServiceTokenFromFile } from "../lib/service-secrets"; +import { loadServiceTokensIntoEnv } from "../lib/service-secrets"; import { diagnoseService, isServiceOwnershipError, serviceCommand, serviceEnvironmentOwnedHere, serviceStartableFromTray, serviceStatusSummary, stopServiceIfInstalled, uninstallServiceIfInstalled } from "../service"; import { startupHealthSummary } from "../codex/autostart-health"; import { drainAndShutdown, isRecyclingForExit, startServer } from "../server"; @@ -161,11 +161,9 @@ async function chooseListenPort(requestedPort?: number): Promise { } async function handleStart(options: { block?: boolean } = {}) { - // Native (WinSW) service mode has no batch wrapper to read the service token file - // into the environment, so the app loads it here before the server binds. The server - // auth path reads OPENCODEX_API_AUTH_TOKEN from the environment. - const serviceToken = loadServiceTokenFromFile(process.env); - if (serviceToken) process.env.OPENCODEX_API_AUTH_TOKEN = serviceToken; + // Load only the service data token into the environment. Management auth reads its + // protected file directly so child processes cannot inherit that credential. + loadServiceTokensIntoEnv(process.env); const requestedPort = parsePortOption(); if (!currentExternalCodexModelProvider()) reconcileJournal(); const existingPid = readPid(); diff --git a/src/cli/opencode.ts b/src/cli/opencode.ts index 966985087..0adc0ecca 100644 --- a/src/cli/opencode.ts +++ b/src/cli/opencode.ts @@ -23,6 +23,7 @@ import { loadConfig } from "../config"; import { visibleNativeSlugs } from "../codex/catalog"; import { shouldInjectApiAuthHeader } from "../codex/inject"; import { commandInvocation } from "../lib/win-exec"; +import { configuredAdminToken } from "../lib/admin-secrets"; import { loadServiceTokenFromFile, serviceApiTokenFilePath } from "../lib/service-secrets"; import { providerCodexAccountMode } from "../providers/registry"; import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness"; @@ -603,6 +604,10 @@ export function opencodeApiKey(config: OcxConfig, env: OpencodeLaunchEnv = proce return config.apiKeys?.[0]?.key || "ocx"; } +export function opencodeManagementApiKey(): string | null { + return configuredAdminToken(); +} + async function ensureProxyForOpencode(config: OcxConfig): Promise { const live = await findLiveProxy(); if (live) return live; @@ -651,9 +656,14 @@ export async function cmdOpencode(args: string[]): Promise { } const apiKey = opencodeApiKey(config); + const managementApiKey = opencodeManagementApiKey(); + if (!managementApiKey) { + console.error("❌ Could not fetch the model catalog from the proxy: management credential unavailable."); + return 1; + } let proxyModels: OpencodeProxyModelRow[]; try { - proxyModels = await fetchOpencodeProxyModels(live, apiKey); + proxyModels = await fetchOpencodeProxyModels(live, managementApiKey); } catch (error) { const reason = error instanceof Error ? error.message : String(error); console.error(`❌ Could not fetch the model catalog from the proxy: ${reason}`); diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index f2cea1bde..2758fc8cc 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -1009,8 +1009,9 @@ export async function handleCodexAuthAPI( let completed = false; for (let i = 0; i < 150; i++) { await new Promise(r => setTimeout(r, 2000)); + if (codexAuthLoginState.get(flowId)?.status !== "pending") return; const st = getLoginStatus("chatgpt"); - if (st.done && st.loggedIn) { + if (st?.done && st.loggedIn) { const { getCredential } = await import("../oauth/store"); const cred = getCredential("chatgpt"); if (cred) { @@ -1154,7 +1155,7 @@ export async function handleCodexAuthAPI( } break; } - if (st.done && st.error) { + if (st?.done && st.error) { codexAuthLoginState.set(flowId, { status: "error", error: st.error, doneAt: Date.now() }); completed = true; break; diff --git a/src/config.ts b/src/config.ts index 9127897c1..c9058ae8e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -14,6 +14,11 @@ import { COMBO_NAMESPACE, comboConfigIssues } from "./combos/types"; import { hardenSecretDir, hardenSecretPath, hardenSecretPathAsync } from "./lib/windows-secret-acl"; import { recordOwnedConfigPath } from "./lib/config-ownership"; import { providerDestinationConfigError } from "./lib/destination-policy"; +import { + outboundProxyConfigured, + selectedProxyEnv, + type ProxyEnvMap, +} from "./lib/proxy-env"; import { openRouterRoutingConfigError } from "./providers/openrouter-routing"; import { isWirePinnedModel, @@ -408,8 +413,8 @@ export function expandUserPath(raw: string): string { let resolvedConfigDirCache: { raw: string | undefined; path: string } | null = null; -function resolveConfigDir(): string { - const raw = process.env["OPENCODEX_HOME"]?.trim() || undefined; +export function resolveConfigDir(env: Record = process.env): string { + const raw = env.OPENCODEX_HOME?.trim() || undefined; if (resolvedConfigDirCache && resolvedConfigDirCache.raw === raw) return resolvedConfigDirCache.path; const path = raw ? resolve(expandUserPath(raw)) : join(homedir(), ".opencodex"); resolvedConfigDirCache = { raw, path }; @@ -1612,12 +1617,20 @@ export function resolveEnvValue(value: string | undefined): string | undefined { * CLI's own health checks and running-proxy API calls stay direct. Call once per process entry * that makes outbound provider requests (server start, catalog sync). */ -export function applyProxyEnv(config: OcxConfig): void { +export function applyProxyEnv( + config: OcxConfig, + env: ProxyEnvMap = process.env, +): void { const proxy = resolveEnvValue(config.proxy); - if (!proxy) return; - if (!process.env.HTTP_PROXY?.trim() && !process.env.http_proxy?.trim()) process.env.HTTP_PROXY = proxy; - if (!process.env.HTTPS_PROXY?.trim() && !process.env.https_proxy?.trim()) process.env.HTTPS_PROXY = proxy; - const existing = process.env.NO_PROXY ?? process.env.no_proxy ?? ""; + if (!proxy && !outboundProxyConfigured(env)) return; + if (proxy) { + for (const key of ["HTTP_PROXY", "HTTPS_PROXY"] as const) { + const selected = selectedProxyEnv(key, env); + if (!selected?.value.trim()) env[selected?.key ?? key] = proxy; + } + } + const selectedNoProxy = selectedProxyEnv("NO_PROXY", env); + const existing = selectedNoProxy?.value ?? ""; const entries = existing.split(",").map(s => s.trim()).filter(Boolean); const seen = new Set(entries.map(e => e.toLowerCase())); for (const host of ["localhost", "127.0.0.1", "::1", "[::1]"]) { @@ -1626,7 +1639,7 @@ export function applyProxyEnv(config: OcxConfig): void { seen.add(host); } } - process.env.NO_PROXY = entries.join(","); + env[selectedNoProxy?.key ?? "NO_PROXY"] = entries.join(","); } export function writePid(pid: number): void { diff --git a/src/lib/admin-secrets.ts b/src/lib/admin-secrets.ts index e0adbb92d..8d415de8d 100644 --- a/src/lib/admin-secrets.ts +++ b/src/lib/admin-secrets.ts @@ -1,13 +1,45 @@ -import { lstatSync, readFileSync } from "node:fs"; +import { existsSync, lstatSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { getConfigDir } from "../config"; +import type { OcxConfig } from "../types"; export const ADMIN_TOKEN_FILE = "admin-api-token"; +export const SERVICE_ADMIN_TOKEN_FILE = "service-admin-token"; +const activeAdminTokens = new WeakMap(); + +export function activeAdminToken(config: OcxConfig): string | undefined { + return activeAdminTokens.get(config); +} + +export function registerActiveAdminToken(config: OcxConfig, token: string): void { + activeAdminTokens.set(config, token); +} + +export function clearActiveAdminToken(config: OcxConfig): void { + activeAdminTokens.delete(config); +} export function adminApiTokenFilePath(configDir = getConfigDir()): string { return join(configDir, ADMIN_TOKEN_FILE); } +export function serviceAdminTokenFilePath(configDir = getConfigDir()): string { + return join(configDir, SERVICE_ADMIN_TOKEN_FILE); +} + +export interface ServiceTokenDefinitionState { + adminTokenFile: string | null; +} + +export function serviceAdminTokenFileForDefinition( + state?: ServiceTokenDefinitionState, + configDir = getConfigDir(), +): string | null { + if (state) return state.adminTokenFile; + const path = serviceAdminTokenFilePath(configDir); + return existsSync(path) ? path : null; +} + export function loadAdminTokenFromFile(configDir = getConfigDir()): string | null { const path = adminApiTokenFilePath(configDir); try { @@ -20,6 +52,24 @@ export function loadAdminTokenFromFile(configDir = getConfigDir()): string | nul } } +export function loadServiceAdminTokenFromFile( + env: Record = process.env, + configDir = getConfigDir(), +): string | null { + if (env.OPENCODEX_ADMIN_AUTH_TOKEN?.trim()) return null; + const path = env.OCX_ADMIN_TOKEN_FILE?.trim() || serviceAdminTokenFilePath(configDir); + try { + const stat = lstatSync(path); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 512) return null; + const token = readFileSync(path, "utf8").trim(); + return token && !/[\r\n\0]/.test(token) ? token : null; + } catch { + return null; + } +} + export function configuredAdminToken(configDir = getConfigDir(), env: NodeJS.ProcessEnv = process.env): string | null { - return env.OPENCODEX_ADMIN_AUTH_TOKEN?.trim() || loadAdminTokenFromFile(configDir); + return env.OPENCODEX_ADMIN_AUTH_TOKEN?.trim() + || loadAdminTokenFromFile(configDir) + || loadServiceAdminTokenFromFile(env, configDir); } diff --git a/src/lib/config-ownership.ts b/src/lib/config-ownership.ts index 6c8b855f5..b3d7721f6 100644 --- a/src/lib/config-ownership.ts +++ b/src/lib/config-ownership.ts @@ -1,8 +1,15 @@ import { + closeSync, existsSync, + fstatSync, + fsyncSync, + futimesSync, + linkSync, lstatSync, mkdirSync, + openSync, readFileSync, + readSync, readdirSync, realpathSync, renameSync, @@ -11,7 +18,7 @@ import { writeFileSync, } from "node:fs"; import { randomUUID } from "node:crypto"; -import { isAbsolute, join, relative, resolve, sep } from "node:path"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; export const CONFIG_OWNER_FILE = ".opencodex-owner.json"; export const CONFIG_UNINSTALL_MANIFEST = ".opencodex-uninstall.json"; @@ -22,6 +29,11 @@ export type ConfigRemovalResult = { residualPaths: string[]; }; +export interface ConfigOwnershipLockOptions { + lockTimeoutMs?: number; + leaseRefreshMs?: number; +} + type ConfigOwner = { version: 1; ownerId: string; @@ -34,8 +46,17 @@ type ConfigUninstallManifest = ConfigOwner & { const METADATA_MAX_BYTES = 64 * 1024; const MANIFEST_MAX_PATHS = 1024; +const OWNERSHIP_LOCK_FILE = ".opencodex-owner.lock"; +const OWNERSHIP_RECOVERY_LOCK_FILE = ".opencodex-owner-recovery.lock"; +const OWNERSHIP_RECOVERY_CLAIM_PREFIX = `${OWNERSHIP_RECOVERY_LOCK_FILE}.claim-`; +const OWNERSHIP_LOCK_PUBLISH_TEMP_PREFIX = ".opencodex-owner-lock-publish-"; +const OWNERSHIP_LOCK_TIMEOUT_MS = 5_000; +const OWNERSHIP_LOCK_STALE_MS = 30_000; +const OWNERSHIP_LOCK_RETRY_MS = 10; +const OWNERSHIP_LOCK_MAX_BYTES = 256; const INITIAL_OWNED_PATHS = [ ".star-prompted", + "admin-api-token", "artifacts", "auth.json", "auth.store.lock", @@ -60,6 +81,7 @@ const INITIAL_OWNED_PATHS = [ "opencodex-tray.ps1", "responses-state.json", "runtime-port.json", + "service-admin-token", "service-api-token", "service-state.json", "service.log", @@ -72,15 +94,7 @@ const INITIAL_OWNED_PATHS = [ "version.json", "winsw", ] as const; -const ownershipCache = new Map(); - -function ownershipCacheKey(configDir: string): string { - const key = resolve(configDir); - return process.platform === "win32" ? key.toLowerCase() : key; -} +const ownershipLockWait = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT)); function samePath(left: string, right: string): boolean { return process.platform === "win32" @@ -141,7 +155,11 @@ function manifestRelativePath(configDir: string, candidatePath: string): string if (normalized.split("/").some(part => !part || part === "." || part === ".." || part.includes("\\"))) { return null; } - if (normalized === CONFIG_OWNER_FILE || normalized === CONFIG_UNINSTALL_MANIFEST) return null; + if ( + normalized === CONFIG_OWNER_FILE + || normalized === CONFIG_UNINSTALL_MANIFEST + || isOwnershipInfrastructureName(normalized) + ) return null; return normalized; } @@ -168,7 +186,10 @@ function loadOwnership(configDir: string): { owner: ConfigOwner; manifest: Confi function createOwnership(configDir: string): { owner: ConfigOwner; manifest: ConfigUninstallManifest } | null { const rootStat = lstatSync(configDir); - if (!rootStat.isDirectory() || rootStat.isSymbolicLink() || readdirSync(configDir).length !== 0) return null; + const existingEntries = readdirSync(configDir).filter( + name => !isOwnershipInfrastructureName(name), + ); + if (!rootStat.isDirectory() || rootStat.isSymbolicLink() || existingEntries.length !== 0) return null; const owner: ConfigOwner = { version: 1, ownerId: randomUUID(), @@ -193,6 +214,387 @@ function createOwnership(configDir: string): { owner: ConfigOwner; manifest: Con return { owner, manifest }; } +function errorCode(error: unknown): string | undefined { + return error && typeof error === "object" && "code" in error + ? String((error as { code?: unknown }).code) + : undefined; +} + +type OwnershipLockSnapshot = { + token: string; + ownerPid: number; + mtimeMs: number; +}; + +function ownershipLockSnapshot(lockPath: string): OwnershipLockSnapshot | null { + try { + const metadata = lstatSync(lockPath); + if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > OWNERSHIP_LOCK_MAX_BYTES) { + return null; + } + const token = readFileSync(lockPath, "utf8"); + const match = /^([1-9]\d*):[0-9a-f-]+\n$/i.exec(token); + if (!match) return null; + const ownerPid = Number(match[1]); + return Number.isSafeInteger(ownerPid) + ? { token, ownerPid, mtimeMs: metadata.mtimeMs } + : null; + } catch { + return null; + } +} + +function isOwnershipLockPublishTempName(name: string): boolean { + if (!name.startsWith(OWNERSHIP_LOCK_PUBLISH_TEMP_PREFIX)) return false; + return /^[1-9]\d*-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.tmp$/i.test( + name.slice(OWNERSHIP_LOCK_PUBLISH_TEMP_PREFIX.length), + ); +} + +export function isOwnershipInfrastructureName(name: string): boolean { + return name === OWNERSHIP_LOCK_FILE + || name === OWNERSHIP_RECOVERY_LOCK_FILE + || name.startsWith(OWNERSHIP_RECOVERY_CLAIM_PREFIX) + || isOwnershipLockPublishTempName(name); +} + +type OwnershipLockFileIdentity = { + device: number; + inode: number; + size: number; +}; + +function lockFileIdentity(metadata: { dev: number; ino: number; size: number }): OwnershipLockFileIdentity { + return { + device: metadata.dev, + inode: metadata.ino, + size: metadata.size, + }; +} + +function isSameLockFile( + metadata: { dev: number; ino: number }, + identity: OwnershipLockFileIdentity, +): boolean { + return metadata.dev === identity.device && metadata.ino === identity.inode; +} + +function readExpectedLockToken(descriptor: number, identity: OwnershipLockFileIdentity, token: string): boolean { + const expectedBytes = Buffer.from(token, "utf8"); + if (identity.size !== expectedBytes.byteLength) return false; + const actualBytes = Buffer.alloc(identity.size); + let offset = 0; + while (offset < actualBytes.byteLength) { + const bytesRead = readSync( + descriptor, + actualBytes, + offset, + actualBytes.byteLength - offset, + offset, + ); + if (bytesRead === 0) return false; + offset += bytesRead; + } + return actualBytes.equals(expectedBytes); +} + +function unlinkPublishTempIfOwned(path: string, identity: OwnershipLockFileIdentity): void { + try { + const current = lstatSync(path); + if (current.isFile() && !current.isSymbolicLink() && isSameLockFile(current, identity)) { + unlinkSync(path); + } + } catch { + // Missing or replaced paths are never removed on behalf of this publisher. + } +} + +function removeVerifiedOwnershipLock(path: string, token: string): boolean { + try { + if (ownershipLockSnapshot(path)?.token !== token) return false; + const identity = lockFileIdentity(lstatSync(path)); + if (ownershipLockSnapshot(path)?.token !== token) return false; + unlinkPublishTempIfOwned(path, identity); + return !existsSync(path); + } catch (error) { + return errorCode(error) === "ENOENT"; + } +} + +function publishOwnershipLock(configDir: string, lockPath: string, token: string): boolean { + const tempPath = join( + configDir, + `${OWNERSHIP_LOCK_PUBLISH_TEMP_PREFIX}${process.pid}-${randomUUID()}.tmp`, + ); + let descriptor: number | undefined; + let identity: OwnershipLockFileIdentity | undefined; + try { + descriptor = openSync(tempPath, "wx+", 0o600); + writeFileSync(descriptor, token, { encoding: "utf8" }); + fsyncSync(descriptor); + const prepared = fstatSync(descriptor); + identity = lockFileIdentity(prepared); + if ( + !prepared.isFile() + || prepared.isSymbolicLink() + || !readExpectedLockToken(descriptor, identity, token) + ) { + throw new Error("ownership lock temporary file was not written completely"); + } + try { + linkSync(tempPath, lockPath); + } catch (error) { + if (errorCode(error) === "EEXIST") return false; + throw error; + } + + const publishedPath = lstatSync(lockPath); + if ( + !publishedPath.isFile() + || publishedPath.isSymbolicLink() + || !isSameLockFile(publishedPath, identity) + || publishedPath.size !== identity.size + ) { + throw new Error("ownership lock publication identity mismatch"); + } + const publishedDescriptor = openSync(lockPath, "r"); + try { + const published = fstatSync(publishedDescriptor); + if ( + !published.isFile() + || published.isSymbolicLink() + || !isSameLockFile(published, identity) + || published.size !== identity.size + || !readExpectedLockToken(publishedDescriptor, identity, token) + ) { + throw new Error("ownership lock publication token mismatch"); + } + const verifiedPath = lstatSync(lockPath); + if ( + !verifiedPath.isFile() + || verifiedPath.isSymbolicLink() + || !isSameLockFile(verifiedPath, identity) + || verifiedPath.size !== identity.size + ) { + throw new Error("ownership lock publication changed during verification"); + } + const linearized = fstatSync(publishedDescriptor); + if ( + !linearized.isFile() + || linearized.isSymbolicLink() + || !isSameLockFile(linearized, identity) + || linearized.size !== identity.size + || !readExpectedLockToken(publishedDescriptor, identity, token) + ) { + throw new Error("ownership lock publication changed at final verification"); + } + return true; + } finally { + closeSync(publishedDescriptor); + } + } finally { + if (descriptor !== undefined) { + try { closeSync(descriptor); } catch { /* failure remains fail-closed before publication */ } + } + if (identity) unlinkPublishTempIfOwned(tempPath, identity); + } +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return errorCode(error) === "EPERM"; + } +} + +function releaseOwnedLock(lockPath: string, token: string): void { + removeVerifiedOwnershipLock(lockPath, token); +} + +function touchOwnershipLock(lockPath: string, token: string): void { + let descriptor: number | undefined; + try { + if (ownershipLockSnapshot(lockPath)?.token !== token) return; + descriptor = openSync(lockPath, "r+"); + const current = fstatSync(descriptor); + const identity = lockFileIdentity(current); + if ( + !current.isFile() + || current.isSymbolicLink() + || !readExpectedLockToken(descriptor, identity, token) + ) return; + const now = new Date(); + futimesSync(descriptor, now, now); + } catch { + // Lease refresh is best-effort; token-checked release remains authoritative. + } finally { + if (descriptor !== undefined) { + try { closeSync(descriptor); } catch { /* best effort */ } + } + } +} + +function staleDeadLock(snapshot: OwnershipLockSnapshot): boolean { + return Date.now() - snapshot.mtimeMs > OWNERSHIP_LOCK_STALE_MS + && !isProcessAlive(snapshot.ownerPid); +} + +function recoveryClaimPaths(configDir: string): string[] { + return readdirSync(configDir) + .filter(name => name.startsWith(OWNERSHIP_RECOVERY_CLAIM_PREFIX)) + .map(name => join(configDir, name)); +} + +function recoveryClaimsAreClear(configDir: string): boolean { + for (const claimPath of recoveryClaimPaths(configDir)) { + const observed = ownershipLockSnapshot(claimPath); + if (!observed || !staleDeadLock(observed)) return false; + const current = ownershipLockSnapshot(claimPath); + if (!current || current.token !== observed.token || !staleDeadLock(current)) return false; + if (!removeVerifiedOwnershipLock(claimPath, current.token)) return false; + } + return true; +} + +function moveStaleRecoveryLockToClaim(configDir: string, recoveryPath: string): boolean { + const observed = ownershipLockSnapshot(recoveryPath); + if (!observed || !staleDeadLock(observed)) return false; + const claimPath = join( + configDir, + `${OWNERSHIP_RECOVERY_CLAIM_PREFIX}${process.pid}-${randomUUID()}`, + ); + try { + renameSync(recoveryPath, claimPath); + } catch (error) { + if (errorCode(error) === "ENOENT") return true; + return false; + } + + const claimed = ownershipLockSnapshot(claimPath); + if (!claimed || claimed.token !== observed.token || !staleDeadLock(claimed)) { + // A successor moved during the race remains an active claim until its owner releases it. + return false; + } + const current = ownershipLockSnapshot(claimPath); + if (!current || current.token !== claimed.token || !staleDeadLock(current)) return false; + return removeVerifiedOwnershipLock(claimPath, current.token); +} + +function releaseRecoveryLock(configDir: string, token: string): void { + const paths = [join(configDir, OWNERSHIP_RECOVERY_LOCK_FILE)]; + try { paths.push(...recoveryClaimPaths(configDir)); } catch { /* fixed-path cleanup still applies */ } + for (const path of paths) { + removeVerifiedOwnershipLock(path, token); + } +} + +function acquireRecoveryLock(configDir: string): { token: string } | null { + const recoveryPath = join(configDir, OWNERSHIP_RECOVERY_LOCK_FILE); + + for (let attempt = 0; attempt < 3; attempt += 1) { + if (!recoveryClaimsAreClear(configDir)) return null; + const token = `${process.pid}:${randomUUID()}\n`; + if (!publishOwnershipLock(configDir, recoveryPath, token)) { + if (!moveStaleRecoveryLockToClaim(configDir, recoveryPath)) return null; + continue; + } + + try { + if (!recoveryClaimsAreClear(configDir)) { + releaseOwnedLock(recoveryPath, token); + return null; + } + } catch (error) { + releaseOwnedLock(recoveryPath, token); + throw error; + } + return { token }; + } + return null; +} + +function reclaimStaleOwnershipLock( + configDir: string, + lockPath: string, + observed: OwnershipLockSnapshot, +): boolean { + const recoveryLock = acquireRecoveryLock(configDir); + if (!recoveryLock) return false; + + try { + const current = ownershipLockSnapshot(lockPath); + if ( + !current + || current.token !== observed.token + || Date.now() - current.mtimeMs <= OWNERSHIP_LOCK_STALE_MS + || isProcessAlive(current.ownerPid) + ) { + return false; + } + return removeVerifiedOwnershipLock(lockPath, current.token); + } finally { + releaseRecoveryLock(configDir, recoveryLock.token); + } +} + +function withOwnershipLock( + configDir: string, + action: (refreshLease: () => void) => T, + options: ConfigOwnershipLockOptions = {}, +): T { + const lockPath = join(configDir, OWNERSHIP_LOCK_FILE); + const token = `${process.pid}:${randomUUID()}\n`; + const lockTimeoutMs = typeof options.lockTimeoutMs === "number" + && Number.isFinite(options.lockTimeoutMs) + && options.lockTimeoutMs >= 0 + ? options.lockTimeoutMs + : OWNERSHIP_LOCK_TIMEOUT_MS; + const leaseRefreshMs = typeof options.leaseRefreshMs === "number" + && Number.isFinite(options.leaseRefreshMs) + && options.leaseRefreshMs >= 0 + ? options.leaseRefreshMs + : Math.floor(OWNERSHIP_LOCK_STALE_MS / 3); + const deadline = Date.now() + lockTimeoutMs; + let acquired = false; + + while (!acquired) { + if (!publishOwnershipLock(configDir, lockPath, token)) { + try { + const observed = ownershipLockSnapshot(lockPath); + if ( + observed + && Date.now() - observed.mtimeMs > OWNERSHIP_LOCK_STALE_MS + && !isProcessAlive(observed.ownerPid) + && reclaimStaleOwnershipLock(configDir, lockPath, observed) + ) { + continue; + } + } catch (staleError) { + if (errorCode(staleError) === "ENOENT") continue; + } + if (Date.now() >= deadline) throw new Error("timed out waiting for config ownership lock"); + Atomics.wait(ownershipLockWait, 0, 0, OWNERSHIP_LOCK_RETRY_MS); + continue; + } + acquired = true; + } + + let lastLeaseRefresh = Date.now(); + const refreshLease = (): void => { + const now = Date.now(); + if (now - lastLeaseRefresh < leaseRefreshMs) return; + touchOwnershipLock(lockPath, token); + lastLeaseRefresh = now; + }; + try { + return action(refreshLease); + } finally { + releaseOwnedLock(lockPath, token); + } +} + function writeManifest(configDir: string, manifest: ConfigUninstallManifest): void { const path = join(configDir, CONFIG_UNINSTALL_MANIFEST); const temp = `${path}.${process.pid}.${randomUUID()}.tmp`; @@ -205,7 +607,12 @@ function writeManifest(configDir: string, manifest: ConfigUninstallManifest): vo } } -function removeOwnedEntry(root: string, path: string): void { +function removeOwnedEntry(root: string, path: string, refreshLease: () => void): void { + refreshLease(); + const realParent = realpathSync.native(dirname(path)); + if (!isWithinRoot(root, realParent)) { + throw new Error(`owned path parent resolves outside the config root: ${path}`); + } const entry = lstatSync(path); if (entry.isSymbolicLink()) { unlinkSync(path); @@ -221,56 +628,69 @@ function removeOwnedEntry(root: string, path: string): void { throw new Error(`owned directory resolves outside the config root: ${path}`); } for (const name of readdirSync(path)) { - removeOwnedEntry(root, join(path, name)); + removeOwnedEntry(root, join(path, name), refreshLease); } rmdirSync(path); } -export function recordOwnedConfigPath(configDir: string, candidatePath: string): boolean { +export function recordOwnedConfigPath( + configDir: string, + candidatePath: string, + options: ConfigOwnershipLockOptions = {}, +): boolean { const rel = manifestRelativePath(configDir, candidatePath); if (!rel) return false; - const cacheKey = ownershipCacheKey(configDir); - if (!existsSync(configDir)) { - ownershipCache.delete(cacheKey); - mkdirSync(configDir, { recursive: true, mode: 0o700 }); - } - let ownership = ownershipCache.get(cacheKey); - if (ownership === undefined) { - ownership = loadOwnership(configDir) ?? createOwnership(configDir); - ownershipCache.set(cacheKey, ownership); - } - if (!ownership) return false; - if (ownership.manifest.paths.includes(rel)) return true; - const manifest = { - ...ownership.manifest, - paths: [...ownership.manifest.paths, rel].sort(), - }; - writeManifest(configDir, manifest); - ownershipCache.set(cacheKey, { owner: ownership.owner, manifest }); - return true; + try { + let mayCreateOwnership: boolean; + if (!existsSync(configDir)) { + mkdirSync(configDir, { recursive: true, mode: 0o700 }); + mayCreateOwnership = true; + } else { + mayCreateOwnership = readdirSync(configDir) + .every(isOwnershipInfrastructureName); + } + return withOwnershipLock(configDir, () => { + const ownership = loadOwnership(configDir) + ?? (mayCreateOwnership ? createOwnership(configDir) : null); + if (!ownership) return false; + if (ownership.manifest.paths.includes(rel)) return true; + if (ownership.manifest.paths.length >= MANIFEST_MAX_PATHS) return false; + const manifest = { + ...ownership.manifest, + paths: [...ownership.manifest.paths, rel].sort(), + }; + writeManifest(configDir, manifest); + return true; + }, options); + } catch { + return false; + } } -export function removeOwnedConfigState(configDir: string): ConfigRemovalResult { - ownershipCache.delete(ownershipCacheKey(configDir)); - if (!existsSync(configDir)) return { status: "absent", residualPaths: [] }; - const root = lstatSync(configDir); - if (!root.isDirectory() || root.isSymbolicLink()) { +function removeOwnedConfigStateLocked( + configDir: string, + refreshLease: () => void, +): ConfigRemovalResult { + const ownership = loadOwnership(configDir); + if (!ownership) { return { status: "refused", - reason: "config ownership root is not a real directory", + reason: "config ownership metadata is missing or invalid", residualPaths: [configDir], }; } - const ownership = loadOwnership(configDir); - if (!ownership) { + const recoveryLock = acquireRecoveryLock(configDir); + if (!recoveryLock) { return { status: "refused", - reason: "config ownership metadata is missing or invalid", + reason: "config ownership recovery lock is active or invalid", residualPaths: [configDir], }; } + releaseRecoveryLock(configDir, recoveryLock.token); for (const rel of ownership.manifest.paths) { + refreshLease(); const path = manifestRelativePath(configDir, join(configDir, ...rel.split("/"))); if (path !== rel) { return { @@ -283,10 +703,11 @@ export function removeOwnedConfigState(configDir: string): ConfigRemovalResult { const rootPath = canonicalRoot(configDir); for (const rel of ownership.manifest.paths) { + refreshLease(); const path = join(configDir, ...rel.split("/")); if (!existsSync(path)) continue; try { - removeOwnedEntry(rootPath, path); + removeOwnedEntry(rootPath, path, refreshLease); } catch (error) { return { status: "partial", @@ -297,6 +718,7 @@ export function removeOwnedConfigState(configDir: string): ConfigRemovalResult { } try { + refreshLease(); unlinkSync(join(configDir, CONFIG_UNINSTALL_MANIFEST)); unlinkSync(join(configDir, CONFIG_OWNER_FILE)); } catch (error) { @@ -306,7 +728,9 @@ export function removeOwnedConfigState(configDir: string): ConfigRemovalResult { residualPaths: readdirSync(configDir).map(name => join(configDir, name)), }; } - const residualPaths = readdirSync(configDir).map(name => join(configDir, name)); + const residualPaths = readdirSync(configDir) + .filter(name => !isOwnershipInfrastructureName(name)) + .map(name => join(configDir, name)); if (residualPaths.length > 0) { return { status: "partial", @@ -314,6 +738,39 @@ export function removeOwnedConfigState(configDir: string): ConfigRemovalResult { residualPaths, }; } + return { status: "removed", residualPaths: [] }; +} + +export function removeOwnedConfigState( + configDir: string, + options: ConfigOwnershipLockOptions = {}, +): ConfigRemovalResult { + if (!existsSync(configDir)) return { status: "absent", residualPaths: [] }; + const root = lstatSync(configDir); + if (!root.isDirectory() || root.isSymbolicLink()) { + return { + status: "refused", + reason: "config ownership root is not a real directory", + residualPaths: [configDir], + }; + } + + let result: ConfigRemovalResult; + try { + result = withOwnershipLock( + configDir, + refreshLease => removeOwnedConfigStateLocked(configDir, refreshLease), + options, + ); + } catch (error) { + return { + status: "refused", + reason: `could not acquire config ownership lock: ${error instanceof Error ? error.message : String(error)}`, + residualPaths: [configDir], + }; + } + if (result.status !== "removed") return result; + try { rmdirSync(configDir); } catch (error) { diff --git a/src/lib/destination-policy.ts b/src/lib/destination-policy.ts index 0b060fb90..fb4bb38fc 100644 --- a/src/lib/destination-policy.ts +++ b/src/lib/destination-policy.ts @@ -184,7 +184,8 @@ export async function providerDestinationResolvedError( for (const { address } of addresses) { const ipKind = isIP(address); const assessment = ipKind === 4 ? classifyIpv4(address) : ipKind === 6 ? classifyIpv6(normalizeHostname(address)) : null; - if (!assessment || assessment.kind === "public") continue; + if (!assessment) return `baseUrl hostname ${hostname} resolves to an unsafe address (${address})`; + if (assessment.kind === "public") continue; // Clash fake-IP only: 198.18/19 benchmark detail. Mixed dangerous sets still reject. if ( options?.allowBenchmarkAddresses @@ -270,10 +271,10 @@ export async function resolvePublicAddresses( throw new DestinationDnsResolutionError(`${context} hostname ${hostname} could not be resolved`); } const validatedAddresses: { address: string; family: number }[] = []; - for (const { address, family } of addresses) { + for (const { address } of addresses) { // Prefer classifying from the address string itself — do not trust a mislabeled // resolver `family` that could skip IPv4/IPv6 private checks. - const ipKind = isIP(address) || (family === 4 || family === 6 ? family : 0); + const ipKind = isIP(address); const assessment = ipKind === 4 ? classifyIpv4(address) : ipKind === 6 ? classifyIpv6(normalizeHostname(address)) : null; if (!assessment || assessment.kind !== "public") { const allowedPrivateAddress = privateNetworkAllowed @@ -284,7 +285,7 @@ export async function resolvePublicAddresses( } privateNetwork = true; } - validatedAddresses.push({ address, family: ipKind === 4 || ipKind === 6 ? ipKind : (family || 4) }); + validatedAddresses.push({ address, family: ipKind }); } return { hostname, addresses: validatedAddresses, privateNetwork }; } diff --git a/src/lib/pinned-http.ts b/src/lib/pinned-http.ts index 0a6ecf291..cb4928d46 100644 --- a/src/lib/pinned-http.ts +++ b/src/lib/pinned-http.ts @@ -1,41 +1,75 @@ -import http, { type IncomingMessage, type RequestOptions } from "node:http"; +import http, { type ClientRequest, type IncomingMessage, type RequestOptions } from "node:http"; import https from "node:https"; export type PinnedAddress = { address: string; family: number }; -export interface PinnedHttpGetOptions { +export interface PinnedHttpRequestOptions { headers?: HeadersInit; maxBytes?: number; idleTimeoutMs?: number; rejectUnauthorized?: boolean; context?: string; + /** Internal GET behavior: return non-success metadata without consuming its body. */ + discardNonSuccessBody?: boolean; +} + +export type PinnedHttpGetOptions = PinnedHttpRequestOptions; + +function waitForDrainOrClose(req: ClientRequest): Promise { + if (req.destroyed) return Promise.resolve(false); + return new Promise((resolve, reject) => { + const cleanup = () => { + req.off("drain", onDrain); + req.off("close", onClose); + req.off("error", onError); + }; + const onDrain = () => { + cleanup(); + resolve(true); + }; + const onClose = () => { + cleanup(); + resolve(false); + }; + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + req.once("drain", onDrain); + req.once("close", onClose); + req.once("error", onError); + if (req.destroyed) onClose(); + }); } /** - * GET a URL through one previously validated address. The original hostname - * remains authoritative for Host, SNI, and certificate verification. + * Send one request through a previously validated address. The original hostname + * remains authoritative for Host, SNI, and certificate verification, and redirects + * are returned to the caller without being followed. */ -export function pinnedHttpGet( - url: string, +export function pinnedHttpRequest( + request: Request, pinned: PinnedAddress, - signal?: AbortSignal, - options?: PinnedHttpGetOptions, + options?: PinnedHttpRequestOptions, ): Promise { - const parsed = new URL(url); + const parsed = new URL(request.url); if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { throw new Error(`${options?.context ?? "request"} must use HTTP or HTTPS, got ${parsed.protocol}`); } const context = options?.context ?? "request"; const idleTimeoutMs = options?.idleTimeoutMs ?? 60_000; const maxBytes = options?.maxBytes; - const headers = new Headers(options?.headers); + const headers = new Headers(request.headers); + if (options?.headers) { + new Headers(options.headers).forEach((value, key) => headers.set(key, value)); + } headers.set("host", parsed.host); const requestHeaders: Record = {}; headers.forEach((value, key) => { requestHeaders[key] = value; }); return new Promise((resolve, reject) => { - if (signal?.aborted) { - reject(signal.reason instanceof Error ? signal.reason : new Error("aborted")); + if (request.signal.aborted) { + reject(request.signal.reason instanceof Error ? request.signal.reason : new Error("aborted")); return; } @@ -51,7 +85,7 @@ export function pinnedHttpGet( hostname: parsed.hostname, port: parsed.port || (parsed.protocol === "https:" ? 443 : 80), path: `${parsed.pathname}${parsed.search}`, - method: "GET", + method: request.method, headers: requestHeaders, ...(parsed.protocol === "https:" ? { @@ -90,11 +124,11 @@ export function pinnedHttpGet( } } - if (status < 200 || status >= 300) { - try { response.destroy(); } catch { /* ignore */ } - try { req.destroy(); } catch { /* ignore */ } + if (options?.discardNonSuccessBody && (status < 200 || status >= 300)) { if (settled) return; settled = true; + try { response.destroy(); } catch { /* ignore */ } + try { req.destroy(); } catch { /* ignore */ } resolve(new Response(null, { status, headers: responseHeaders })); return; } @@ -138,14 +172,58 @@ export function pinnedHttpGet( const requestFn = parsed.protocol === "https:" ? https.request : http.request; const req = requestFn(requestOptions, onResponse); - const onAbort = () => fail(signal?.reason instanceof Error ? signal.reason : new Error("aborted")); - signal?.addEventListener("abort", onAbort, { once: true }); + const onAbort = () => fail(request.signal.reason instanceof Error ? request.signal.reason : new Error("aborted")); + request.signal.addEventListener("abort", onAbort, { once: true }); req.setTimeout(idleTimeoutMs, () => fail(new Error(`${context} timed out`))); req.on("error", error => { - signal?.removeEventListener("abort", onAbort); + request.signal.removeEventListener("abort", onAbort); fail(error); }); - req.on("close", () => signal?.removeEventListener("abort", onAbort)); - req.end(); + let bodyReader: ReadableStreamDefaultReader | undefined; + req.on("close", () => { + request.signal.removeEventListener("abort", onAbort); + void bodyReader?.cancel().catch(() => {}); + }); + void (async () => { + try { + const reader = request.body?.getReader(); + bodyReader = reader; + if (reader) { + if (req.destroyed) { + await reader.cancel(); + return; + } + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (req.destroyed) break; + if (!req.write(Buffer.from(value)) && !await waitForDrainOrClose(req)) break; + } + } + if (!req.destroyed) req.end(); + } catch (error) { + fail(error); + } finally { + bodyReader = undefined; + } + })(); }); } + +/** + * GET a URL through one previously validated address. The original hostname + * remains authoritative for Host, SNI, and certificate verification. + */ +export async function pinnedHttpGet( + url: string, + pinned: PinnedAddress, + signal?: AbortSignal, + options?: PinnedHttpGetOptions, +): Promise { + const response = await pinnedHttpRequest(new Request(url, { + method: "GET", + headers: options?.headers, + signal, + }), pinned, { ...options, discardNonSuccessBody: true }); + return response; +} diff --git a/src/lib/process-control.ts b/src/lib/process-control.ts index f55028460..cd973543c 100644 --- a/src/lib/process-control.ts +++ b/src/lib/process-control.ts @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { loadConfig, readRuntimePort } from "../config"; +import { loadConfig, readRuntimePort, resolveConfigDir } from "../config"; import { configuredAdminToken } from "./admin-secrets"; export function isProcessAlive(pid: number): boolean { @@ -67,7 +67,7 @@ export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}): if (!runtime?.port) return false; const env = io.env ?? process.env; const headers: Record = {}; - const token = configuredAdminToken(env.OPENCODEX_HOME?.trim() || undefined, env as NodeJS.ProcessEnv); + const token = configuredAdminToken(resolveConfigDir(env), env as NodeJS.ProcessEnv); if (token) headers["x-opencodex-api-key"] = token; const fetchFn = io.fetchFn ?? fetch; try { diff --git a/src/lib/provider-outbound.ts b/src/lib/provider-outbound.ts index e4f7d0a5c..d29bcc044 100644 --- a/src/lib/provider-outbound.ts +++ b/src/lib/provider-outbound.ts @@ -5,8 +5,14 @@ import { providerDestinationConfigError, resolvePublicAddresses, } from "./destination-policy"; -import { pinnedHttpGet } from "./pinned-http"; -import { outboundProxyConfigured } from "./proxy-env"; +import { pinnedHttpGet, pinnedHttpRequest } from "./pinned-http"; +import { + bunProxyForUrl, + proxyEnvPresent, + selectedProxyEnv, + type ProxyEnvMap, + type SelectedProxyEnv, +} from "./proxy-env"; import { publicProviderBaseUrl } from "./provider-url"; type ProviderGetInit = Omit; @@ -16,6 +22,8 @@ type ProviderOutboundConfig = Pick return addresses.find(address => address.family === 4) ?? addresses[0]!; } -function configuredProxyFor(): boolean { - return outboundProxyConfigured(); +function configuredProxyFor(url: URL, env: ProxyEnvMap): SelectedProxyEnv | undefined { + return bunProxyForUrl(url, env); +} + +function fetchThroughProxy( + url: string, + init: ProviderGetInit, + proxy: SelectedProxyEnv, +): Promise { + const proxyInit = { + ...init, + method: "GET", + redirect: "manual", + proxy: proxy.value, + } as RequestInit & { proxy: string }; + return globalThis.fetch(url, proxyInit); +} + +function requestThroughProxy(request: Request, proxy: SelectedProxyEnv): Promise { + return globalThis.fetch(request, { + redirect: "manual", + proxy: proxy.value, + } as RequestInit & { proxy: string }); } function normalizeProxyHostname(hostname: string): string { @@ -37,8 +66,8 @@ function normalizeProxyHostname(hostname: string): string { : normalized; } -function noProxyMatches(url: URL): boolean { - const raw = process.env.NO_PROXY ?? process.env.no_proxy ?? ""; +function noProxyMatches(url: URL, env: ProxyEnvMap): boolean { + const raw = selectedProxyEnv("NO_PROXY", env)?.value ?? ""; const hostname = normalizeProxyHostname(url.hostname); const port = url.port || (url.protocol === "https:" ? "443" : "80"); for (const rawEntry of raw.split(",")) { @@ -126,7 +155,16 @@ export async function providerOutboundGet( return provider.fetch(url, { ...init, method: "GET", redirect: "manual" }); } const parsed = new URL(url); - const proxyConfigured = configuredProxyFor(); + const proxyEnv = dependencies.proxyEnv ?? process.env; + const configuredProxy = configuredProxyFor(parsed, proxyEnv); + const proxyConfigured = configuredProxy !== undefined; + if (!configuredProxy && proxyEnvPresent("ALL_PROXY", proxyEnv)) { + const supportedKey = parsed.protocol === "https:" ? "HTTPS_PROXY" : "HTTP_PROXY"; + throw new ProviderOutboundPolicyError( + `ALL_PROXY is not supported by the bundled Bun provider transport; set ${supportedKey} for this provider URL`, + ); + } + const bypassProxy = proxyConfigured && noProxyMatches(parsed, proxyEnv); const resolveAddresses = dependencies.resolveAddresses ?? resolvePublicAddresses; const pinnedGet = dependencies.pinnedGet ?? pinnedHttpGet; let resolved: Awaited>; @@ -141,18 +179,18 @@ export async function providerOutboundGet( if (!dnsResolutionFailed) { throw new ProviderOutboundPolicyError(error instanceof Error ? error.message : "provider destination was blocked"); } - if (!proxyConfigured) throw error; + if (!proxyConfigured || bypassProxy) throw error; warnProxyBoundaryOnce(); warnProxyDnsDegradationOnce(); - return globalThis.fetch(url, { ...init, method: "GET", redirect: "manual" }); + return fetchThroughProxy(url, init, configuredProxy!); } - if (proxyConfigured && !resolved.privateNetwork) { + if (proxyConfigured && !bypassProxy && !resolved.privateNetwork) { warnProxyBoundaryOnce(); - return globalThis.fetch(url, { ...init, method: "GET", redirect: "manual" }); + return fetchThroughProxy(url, init, configuredProxy!); } - if (proxyConfigured && resolved.privateNetwork && !noProxyMatches(parsed)) { + if (proxyConfigured && resolved.privateNetwork && !bypassProxy) { const hostname = normalizeProxyHostname(parsed.hostname); - throw new Error( + throw new ProviderOutboundPolicyError( `provider URL resolves to a private-network destination; add ${hostname} to NO_PROXY before using allowPrivateNetwork with an outbound proxy`, ); } @@ -162,3 +200,65 @@ export async function providerOutboundGet( context: "provider response", }); } + +/** + * Build the transport used only by management model probes. Each actual request + * resolves immediately before dispatch and passes that result to the fixed-peer + * transport. Caller-owned provider fetch overrides are intentionally ignored here. + */ +export function createProviderModelProbeFetch( + name: string, + provider: Pick, + dependencies: ProviderOutboundDependencies = {}, +): typeof globalThis.fetch { + const executor = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const request = input instanceof Request && init === undefined + ? input + : new Request(input, init); + const parsed = new URL(request.url); + const proxyEnv = dependencies.proxyEnv ?? process.env; + const configuredProxy = configuredProxyFor(parsed, proxyEnv); + const proxyConfigured = configuredProxy !== undefined; + if (!configuredProxy && proxyEnvPresent("ALL_PROXY", proxyEnv)) { + const supportedKey = parsed.protocol === "https:" ? "HTTPS_PROXY" : "HTTP_PROXY"; + throw new ProviderOutboundPolicyError( + `ALL_PROXY is not supported by the bundled Bun provider transport; set ${supportedKey} for this provider URL`, + ); + } + const bypassProxy = proxyConfigured && noProxyMatches(parsed, proxyEnv); + const resolveAddresses = dependencies.resolveAddresses ?? resolvePublicAddresses; + let resolved: Awaited>; + try { + resolved = await resolveAddresses(request.url, { + context: "provider URL", + allowPrivateNetwork: provider.allowPrivateNetwork, + }); + } catch (error) { + const dnsResolutionFailed = error instanceof DestinationDnsResolutionError + || (error instanceof Error && error.name === "DestinationDnsResolutionError"); + if (!dnsResolutionFailed) { + throw new ProviderOutboundPolicyError(error instanceof Error ? error.message : "provider destination was blocked"); + } + if (!proxyConfigured || bypassProxy) throw error; + warnProxyBoundaryOnce(); + warnProxyDnsDegradationOnce(); + return requestThroughProxy(request, configuredProxy!); + } + if (proxyConfigured && !bypassProxy && !resolved.privateNetwork) { + warnProxyBoundaryOnce(); + return requestThroughProxy(request, configuredProxy!); + } + if (proxyConfigured && resolved.privateNetwork && !bypassProxy) { + const hostname = normalizeProxyHostname(parsed.hostname); + throw new ProviderOutboundPolicyError( + `provider URL resolves to a private-network destination; add ${hostname} to NO_PROXY before using allowPrivateNetwork with an outbound proxy`, + ); + } + const pinnedRequest = dependencies.pinnedRequest ?? pinnedHttpRequest; + return pinnedRequest(request, pickPinnedAddress(resolved.addresses), { + rejectUnauthorized: true, + context: `${name} model probe response`, + }); + }; + return Object.assign(executor, { preconnect: () => undefined }) as typeof globalThis.fetch; +} diff --git a/src/lib/proxy-env.ts b/src/lib/proxy-env.ts index d34688ab9..393bf233a 100644 --- a/src/lib/proxy-env.ts +++ b/src/lib/proxy-env.ts @@ -1,18 +1,63 @@ -export const OUTBOUND_PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"] as const; +export const BUN_OUTBOUND_PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY"] as const; +export const OUTBOUND_PROXY_ENV_KEYS = [...BUN_OUTBOUND_PROXY_ENV_KEYS, "ALL_PROXY"] as const; export const PROXY_ENV_KEYS = [...OUTBOUND_PROXY_ENV_KEYS, "NO_PROXY"] as const; export type ProxyEnvKey = typeof PROXY_ENV_KEYS[number]; export type ProxyEnvMap = Record; +export type SelectedProxyEnv = { + key: string; + value: string; +}; + +export function selectedProxyEnv( + key: ProxyEnvKey, + env: ProxyEnvMap = process.env, +): SelectedProxyEnv | undefined { + const lowercaseKey = key.toLowerCase(); + const lowercaseValue = env[lowercaseKey]; + if (lowercaseValue !== undefined) { + return { key: lowercaseKey, value: lowercaseValue }; + } + const uppercaseValue = env[key]; + return uppercaseValue === undefined ? undefined : { key, value: uppercaseValue }; +} + +function selectedNonBlankProxyEnv( + key: Exclude, + env: ProxyEnvMap, +): SelectedProxyEnv | undefined { + const lowercaseKey = key.toLowerCase(); + const lowercaseValue = env[lowercaseKey]; + if (lowercaseValue?.trim()) return { key: lowercaseKey, value: lowercaseValue }; + const uppercaseValue = env[key]; + return uppercaseValue?.trim() ? { key, value: uppercaseValue } : undefined; +} export function proxyEnvPresent( key: ProxyEnvKey, env: ProxyEnvMap = process.env, ): boolean { - return Boolean(env[key]?.trim() || env[key.toLowerCase()]?.trim()); + const selected = key === "NO_PROXY" + ? selectedProxyEnv(key, env) + : selectedNonBlankProxyEnv(key, env); + return Boolean(selected?.value.trim()); +} + +export function bunProxyForUrl( + url: URL, + env: ProxyEnvMap = process.env, +): SelectedProxyEnv | undefined { + const key = url.protocol === "http:" + ? "HTTP_PROXY" + : url.protocol === "https:" + ? "HTTPS_PROXY" + : undefined; + if (!key) return undefined; + return selectedNonBlankProxyEnv(key, env); } export function outboundProxyConfigured( env: ProxyEnvMap = process.env, ): boolean { - return OUTBOUND_PROXY_ENV_KEYS.some(key => proxyEnvPresent(key, env)); + return BUN_OUTBOUND_PROXY_ENV_KEYS.some(key => proxyEnvPresent(key, env)); } diff --git a/src/lib/service-secrets.ts b/src/lib/service-secrets.ts index 71edbd733..a73bb25cc 100644 --- a/src/lib/service-secrets.ts +++ b/src/lib/service-secrets.ts @@ -1,9 +1,10 @@ import { join } from "node:path"; -import { readFileSync } from "node:fs"; +import { readFileSync, unlinkSync } from "node:fs"; import { getConfigDir } from "../config"; +import { serviceAdminTokenFilePath } from "./admin-secrets"; -export function serviceApiTokenFilePath(): string { - return join(getConfigDir(), "service-api-token"); +export function serviceApiTokenFilePath(configDir = getConfigDir()): string { + return join(configDir, "service-api-token"); } /** @@ -23,3 +24,41 @@ export function loadServiceTokenFromFile(env: Record return null; } } + +/** + * Load the service-delivered data-plane credential before the server starts. The management + * credential file is read directly by management-auth so it never enters process.env. + */ +export function loadServiceTokensIntoEnv( + env: Record, + configDir = getConfigDir(), +): void { + const dataToken = loadServiceTokenFromFile( + env.OCX_API_TOKEN_FILE?.trim() + ? env + : { ...env, OCX_API_TOKEN_FILE: serviceApiTokenFilePath(configDir) }, + ); + if (dataToken) env.OPENCODEX_API_AUTH_TOKEN = dataToken; +} + +export function removeServiceTokenFiles( + configDir = getConfigDir(), + remove: (path: string) => void = unlinkSync, +): string[] { + const residual: string[] = []; + const files = [ + ["service-api-token", serviceApiTokenFilePath(configDir)], + ["service-admin-token", serviceAdminTokenFilePath(configDir)], + ] as const; + for (const [name, path] of files) { + try { + remove(path); + } catch (error) { + const code = error && typeof error === "object" && "code" in error + ? String((error as NodeJS.ErrnoException).code) + : ""; + if (code !== "ENOENT") residual.push(name); + } + } + return residual; +} diff --git a/src/lib/winsw.ts b/src/lib/winsw.ts index 5c00e47af..77fcd8c2c 100644 --- a/src/lib/winsw.ts +++ b/src/lib/winsw.ts @@ -22,6 +22,10 @@ import { expandUserPath, getConfigDir, loadConfig } from "../config"; import { recordOwnedConfigPath } from "./config-ownership"; import { durableBunPath } from "./bun-runtime"; import { serviceApiTokenFilePath } from "./service-secrets"; +import { + serviceAdminTokenFileForDefinition, + type ServiceTokenDefinitionState, +} from "./admin-secrets"; export const WINSW_VERSION = "2.12.0"; export const WINSW_URL = `https://github.com/winsw/winsw/releases/download/v${WINSW_VERSION}/WinSW.NET461.exe`; @@ -67,13 +71,20 @@ export interface WinswEntry { cli: string; } +export type WinswServiceTokenDefinitionState = ServiceTokenDefinitionState; + /** * Build the WinSW v2 XML. Never embeds the API token value — the app loads it from * OCX_API_TOKEN_FILE at startup (cli handleStart). PATH is baked for parity with the * Task Scheduler wrapper / launchd / systemd: the SCM service environment lacks the * user's interactive PATH, which provider subprocesses may need. */ -export function buildWinswXml(entry: WinswEntry, env: NodeJS.ProcessEnv = process.env, port?: number): string { +export function buildWinswXml( + entry: WinswEntry, + env: NodeJS.ProcessEnv = process.env, + port?: number, + state?: WinswServiceTokenDefinitionState, +): string { const domain = env.USERDOMAIN?.trim() || "."; const user = env.USERNAME?.trim() || ""; const listenPort = (() => { @@ -87,9 +98,13 @@ export function buildWinswXml(entry: WinswEntry, env: NodeJS.ProcessEnv = proces })(); // Services never bake `--port 0` (parsePortOption rejects it); treat as default. const safeListenPort = listenPort > 0 && listenPort <= 65535 ? listenPort : 10100; + const adminTokenFile = serviceAdminTokenFileForDefinition(state); const envLines = [ ` `, ` `, + adminTokenFile + ? ` ` + : null, ` `, env.CODEX_HOME?.trim() ? ` ` : null, env.OPENCODEX_HOME?.trim() ? ` ` : null, @@ -278,7 +293,11 @@ export interface WinswInstallDeps { * `install /p` — assets are rewritten and the service restarted without re-prompting * credentials (WinSW `install` fails with "service already exists"). */ -export async function installWinswService(entry: WinswEntry, deps: WinswInstallDeps = {}): Promise { +export async function installWinswService( + entry: WinswEntry, + deps: WinswInstallDeps = {}, + state?: WinswServiceTokenDefinitionState, +): Promise { const ensureBinary = deps.ensureBinary ?? ensureWinswBinary; const writeXml = deps.writeXml ?? ((path: string, content: string) => writeFileSync(path, content, "utf8")); const interactive = deps.interactive ?? runWinswInteractive; @@ -287,7 +306,7 @@ export async function installWinswService(entry: WinswEntry, deps: WinswInstallD const status = deps.status ?? statusWinswRaw; await ensureBinary(); - writeXml(winswXmlPath(), buildWinswXml(entry)); + writeXml(winswXmlPath(), buildWinswXml(entry, process.env, undefined, state)); const existing = status(); if (existing === "unknown") { throw new Error( diff --git a/src/oauth/health.ts b/src/oauth/health.ts index d25acd508..f845e8082 100644 --- a/src/oauth/health.ts +++ b/src/oauth/health.ts @@ -4,7 +4,7 @@ import { isAccountNeedsReauth } from "../codex/account-runtime-state"; import { getCodexAccountCredential, listCodexAccountIds } from "../codex/account-store"; import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account"; import { maskAccountId } from "../lib/privacy"; -import { loadServiceTokenFromFile } from "../lib/service-secrets"; +import { configuredAdminToken } from "../lib/admin-secrets"; import { findLiveProxy, probeHostname } from "../server/proxy-liveness"; import { loadAuthStore, peekAuthStore, peekOAuthRefreshIntent, readOAuthRefreshIntent } from "./store"; import type { ProviderAccount } from "./types"; @@ -326,7 +326,7 @@ async function fetchCodexHealthFromLiveProxy( ): Promise { const live = await findLiveProxyImpl(); if (!live) return null; - const token = process.env.OPENCODEX_API_AUTH_TOKEN ?? loadServiceTokenFromFile(process.env); + const token = configuredAdminToken(); const headers: Record = {}; if (token) headers.Authorization = `Bearer ${token}`; try { @@ -358,7 +358,7 @@ export type OAuthCliHealthReport = { /** Shown by `ocx status` / `ocx doctor` when the proxy management API is unreachable. */ export const CODEX_HEALTH_UNAVAILABLE_NOTE = - "Codex health: unavailable (proxy not running; live cooldown/reauth requires the management API)"; + "Codex health: unavailable (proxy not running or authenticated management API unavailable; live cooldown/reauth requires management access)"; /** * CLI/doctor collector: observe-only OAuth store reads, and Codex health only from the diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 5b4d48c21..4ff10782e 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -12,6 +12,7 @@ import { reasoningSummaryDeliveryRecordConfigError, } from "../config"; import { providerDestinationConfigError } from "../lib/destination-policy"; +import { activeAdminToken } from "../lib/admin-secrets"; import { getProviderRegistryEntry, providerCodexAccountMode, providerMatchesRegistryTransport } from "../providers/registry"; import { providerConfigSeed } from "../providers/derive"; import type { OcxConfig, OcxProviderConfig } from "../types"; @@ -221,10 +222,13 @@ export function isDataPlaneAdmissionSecret(token: string, config: OcxConfig): bo return false; } -/** Whether `token` is the environment-provided management secret. */ -export function isManagementAdmissionSecret(token: string): boolean { +/** Whether `token` is an explicit or active management secret for this config instance. */ +export function isManagementAdmissionSecret(token: string, config?: OcxConfig): boolean { const actual = token.trim(); - return !!actual && secretEquals(actual, configuredAdminAuthToken()); + return !!actual && ( + secretEquals(actual, configuredAdminAuthToken()) + || (!!config && secretEquals(actual, activeAdminToken(config))) + ); } /** Whether `token` is one of the proxy's own admission secrets and must never reach an upstream. */ @@ -232,7 +236,7 @@ export function isProxyAdmissionSecret(token: string, config: OcxConfig): boolea const actual = token.trim(); if (!actual) return false; if (/^ocx_(?:data|admin|session)_/.test(actual) || /^ocx_[0-9a-f]{40}$/.test(actual)) return true; - return isDataPlaneAdmissionSecret(actual, config) || isManagementAdmissionSecret(actual); + return isDataPlaneAdmissionSecret(actual, config) || isManagementAdmissionSecret(actual, config); } export class ForwardAdmissionCredentialError extends Error { diff --git a/src/server/gui-static.ts b/src/server/gui-static.ts index e314c1582..198433676 100644 --- a/src/server/gui-static.ts +++ b/src/server/gui-static.ts @@ -56,13 +56,22 @@ function isFile(path: string): boolean { } } +function escapeHtmlAttribute(value: string): string { + return value + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(/'/g, "'") + .replace(//g, ">"); +} + function htmlResponse(path: string, session?: GuiSessionBootstrap): Response { let html = readFileSync(path, "utf8"); if (session) { const bootstrap = [ - ``, - ``, - ``, + ``, + ``, + ``, ].join(""); html = html.includes("") ? html.replace("", `${bootstrap}`) : `${bootstrap}${html}`; } diff --git a/src/server/index.ts b/src/server/index.ts index 0df808b2a..6cd970513 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -737,7 +737,7 @@ export function startServer(port?: number) { return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, config); } - const guiSessionCandidate = req.method === "GET" && (url.pathname === "/" || !url.pathname.includes(".")) + const guiSessionCandidate = req.method === "GET" && url.pathname === "/" ? issueGuiSession(req, config, managementAuth) : null; const guiFile = serveGuiFile(url.pathname, undefined, guiSessionCandidate ?? undefined); diff --git a/src/server/management-auth.ts b/src/server/management-auth.ts index 52fd46401..1b22f08c3 100644 --- a/src/server/management-auth.ts +++ b/src/server/management-auth.ts @@ -2,17 +2,29 @@ import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; import { chmodSync, closeSync, + existsSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, + readdirSync, unlinkSync, writeFileSync, } from "node:fs"; import { dirname, join } from "node:path"; -import { adminApiTokenFilePath } from "../lib/admin-secrets"; +import { + adminApiTokenFilePath, + clearActiveAdminToken, + registerActiveAdminToken, + serviceAdminTokenFilePath, +} from "../lib/admin-secrets"; +import { + CONFIG_OWNER_FILE, + CONFIG_UNINSTALL_MANIFEST, + recordOwnedConfigPath, +} from "../lib/config-ownership"; import { hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl"; import type { OcxConfig } from "../types"; import { @@ -26,6 +38,10 @@ import { const GUI_SESSION_TTL_MS = 5 * 60_000; const GUI_SESSION_LIMIT = 128; +const MANAGEMENT_AUTH_UNAVAILABLE_WARNING = + "[opencodex] Management API disabled: its credential could not be initialized securely. " + + "Check the protected management token file and its permissions, then restart OpenCodex. " + + "Data-plane routes and /healthz remain available."; interface GuiSessionRecord { csrfToken: string; @@ -50,33 +66,55 @@ function fail(reason: string): ManagementAuthState { return { available: false, reason }; } -function assertSafeDirectory(path: string): void { +export interface ManagementAuthDependencies { + hardenSecretDir?: typeof hardenSecretDir; + hardenSecretPath?: typeof hardenSecretPath; +} + +function assertSafeDirectory(path: string, dependencies: ManagementAuthDependencies): void { mkdirSync(path, { recursive: true, mode: 0o700 }); const stat = lstatSync(path); if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("management token directory is not a regular directory"); chmodSync(path, 0o700); - const hardened = hardenSecretDir(path, { required: true }); + const hardened = (dependencies.hardenSecretDir ?? hardenSecretDir)(path, { required: true }); if (!hardened.ok) throw new Error("management token directory ACL hardening did not complete"); } -function readExistingToken(path: string): string { +function readExistingToken( + path: string, + kind: "primary" | "service" = "primary", + dependencies: ManagementAuthDependencies = {}, +): string { const stat = lstatSync(path); if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 512) { throw new Error("management token path is not a regular secret file"); } chmodSync(path, 0o600); - const hardened = hardenSecretPath(path, { required: true }); + const hardened = (dependencies.hardenSecretPath ?? hardenSecretPath)(path, { required: true }); if (!hardened.ok) throw new Error("management token file ACL hardening did not complete"); const token = readFileSync(path, "utf8").trim(); - if (!/^ocx_admin_[A-Za-z0-9_-]{43}$/.test(token)) throw new Error("management token file is invalid"); + const valid = kind === "primary" + ? /^ocx_admin_[A-Za-z0-9_-]{43}$/.test(token) + : !!token && !/[\r\n\0]/.test(token); + if (!valid) throw new Error("management token file is invalid"); return token; } +function secretPathExists(path: string): boolean { + try { + lstatSync(path); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } +} + function removeBestEffort(path: string): void { try { unlinkSync(path); } catch { /* fail-closed state is preserved by the caller */ } } -function createTokenFile(path: string): string { +function createTokenFile(path: string, dependencies: ManagementAuthDependencies): string { const directory = dirname(path); const token = `ocx_admin_${randomBytes(32).toString("base64url")}`; const temporary = join(directory, `.${randomUUID()}.admin-token.tmp`); @@ -89,16 +127,17 @@ function createTokenFile(path: string): string { closeSync(fd); fd = null; chmodSync(temporary, 0o600); - const temporaryHardened = hardenSecretPath(temporary, { required: true }); + const hardenPath = dependencies.hardenSecretPath ?? hardenSecretPath; + const temporaryHardened = hardenPath(temporary, { required: true }); if (!temporaryHardened.ok) throw new Error("management token temporary ACL hardening did not complete"); try { linkSync(temporary, path); linked = true; } catch (error) { - if ((error as NodeJS.ErrnoException).code === "EEXIST") return readExistingToken(path); + if ((error as NodeJS.ErrnoException).code === "EEXIST") return readExistingToken(path, "primary", dependencies); throw error; } - const finalHardened = hardenSecretPath(path, { required: true }); + const finalHardened = hardenPath(path, { required: true }); if (!finalHardened.ok) throw new Error("management token file ACL hardening did not complete"); return token; } catch (error) { @@ -114,28 +153,61 @@ function createTokenFile(path: string): string { function ready(token: string, source: "environment" | "file", config: OcxConfig): ManagementAuthState { if (isDataPlaneAdmissionSecret(token, config)) { + clearActiveAdminToken(config); return fail("management credential conflicts with a data-plane credential"); } + registerActiveAdminToken(config, token); return { available: true, token, source, sessions: new Map() }; } -export function initializeManagementAuthState(config: OcxConfig): ManagementAuthState { +function isLegacyUnownedConfigDirectory(path: string): boolean { + if (!existsSync(path)) return false; + const stat = lstatSync(path); + if (!stat.isDirectory() || stat.isSymbolicLink()) return false; + const entries = readdirSync(path); + return entries.length > 0 + && !entries.includes(CONFIG_OWNER_FILE) + && !entries.includes(CONFIG_UNINSTALL_MANIFEST); +} + +export function initializeManagementAuthState( + config: OcxConfig, + dependencies: ManagementAuthDependencies = {}, +): ManagementAuthState { const environmentToken = process.env.OPENCODEX_ADMIN_AUTH_TOKEN?.trim(); if (environmentToken) { return ready(environmentToken, "environment", config); } try { const path = adminApiTokenFilePath(); - assertSafeDirectory(dirname(path)); - let token: string; - try { - token = readExistingToken(path); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; - token = createTokenFile(path); + const directory = dirname(path); + if (secretPathExists(path)) { + const legacyUnownedDirectory = isLegacyUnownedConfigDirectory(directory); + if (!recordOwnedConfigPath(directory, path) && !legacyUnownedDirectory) { + throw new Error("management token ownership registration failed"); + } + assertSafeDirectory(directory, dependencies); + return ready(readExistingToken(path, "primary", dependencies), "file", config); } - return ready(token, "file", config); + + const configuredServicePath = process.env.OCX_ADMIN_TOKEN_FILE?.trim(); + const servicePath = configuredServicePath || serviceAdminTokenFilePath(); + if (configuredServicePath || secretPathExists(servicePath)) { + assertSafeDirectory(dirname(servicePath), dependencies); + return ready(readExistingToken(servicePath, "service", dependencies), "file", config); + } + + // Fresh installs establish ownership before the token makes the directory non-empty. + // Legacy non-owned directories deliberately remain unclaimed and continue fail-closed on uninstall. + const legacyUnownedDirectory = isLegacyUnownedConfigDirectory(directory); + if (!recordOwnedConfigPath(directory, path) && !legacyUnownedDirectory) { + throw new Error("management token ownership registration failed"); + } + assertSafeDirectory(directory, dependencies); + return ready(createTokenFile(path, dependencies), "file", config); } catch (error) { + clearActiveAdminToken(config); + console.warn(MANAGEMENT_AUTH_UNAVAILABLE_WARNING); return fail(error instanceof Error ? error.message : "management token initialization failed"); } } @@ -157,12 +229,27 @@ function randomSessionSecret(prefix: "ocx_session_"): string { return `${prefix}${randomBytes(32).toString("base64url")}`; } +function isLocalGuiDocumentNavigation(req: Request): boolean { + const url = new URL(req.url); + const site = req.headers.get("Sec-Fetch-Site")?.toLowerCase(); + return url.pathname === "/" + && req.headers.get("Sec-Fetch-Dest")?.toLowerCase() === "document" + && req.headers.get("Sec-Fetch-Mode")?.toLowerCase() === "navigate" + && (site === "none" || site === "same-origin"); +} + export function issueGuiSession( req: Request, config: OcxConfig, state: ManagementAuthState, ): GuiSessionBootstrap | null { - if (isApiAuthRequired(config) || !state.available || req.method !== "GET" || !isAllowedManagementOrigin(req, config)) return null; + if ( + isApiAuthRequired(config) + || !state.available + || req.method !== "GET" + || !isLocalGuiDocumentNavigation(req) + || !isAllowedManagementOrigin(req, config) + ) return null; const host = parseHttpHost(req.headers.get("Host")); if (!host || !isLoopbackHostname(host.hostname)) return null; const origin = managementRequestOrigin(req, config); @@ -196,7 +283,8 @@ export function requireManagementAuth( || req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); if (actual && equalSecret(actual, state.token)) return null; if (actual && config) { - removeExpiredSessions(state); + const now = Date.now(); + removeExpiredSessions(state, now); const session = state.sessions.get(actual); if (session) { const requestOrigin = managementRequestOrigin(req, config); @@ -208,6 +296,7 @@ export function requireManagementAuth( const safeMethod = req.method === "GET" || req.method === "HEAD"; const csrf = req.headers.get("x-opencodex-csrf-token")?.trim(); if (sameOrigin && (safeMethod || (browserOrigin === session.origin && !!csrf && equalSecret(csrf, session.csrfToken)))) { + session.expiresAt = now + GUI_SESSION_TTL_MS; return null; } } diff --git a/src/server/management/context.ts b/src/server/management/context.ts index a646d68ee..b8863a21b 100644 --- a/src/server/management/context.ts +++ b/src/server/management/context.ts @@ -1,4 +1,6 @@ import type { OcxConfig } from "../../types"; +import type { providerDestinationResolvedError } from "../../lib/destination-policy"; +import type { createProviderModelProbeFetch } from "../../lib/provider-outbound"; import type { StartupInstallAction } from "../startup-action-control"; export interface ManagementApiDeps { @@ -11,6 +13,8 @@ export interface ManagementApiDeps { action: StartupInstallAction, options?: { repair?: boolean }, ) => Promise<{ message: string }>; + providerDestinationResolvedError?: typeof providerDestinationResolvedError; + createProviderModelProbeFetch?: typeof createProviderModelProbeFetch; } diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts index ce845d5f3..10759e027 100644 --- a/src/server/management/model-routes.ts +++ b/src/server/management/model-routes.ts @@ -24,15 +24,20 @@ import { } from "../../oauth"; import { removeCredential } from "../../oauth/store"; import { providerDestinationResolvedError } from "../../lib/destination-policy"; +import { createProviderModelProbeFetch } from "../../lib/provider-outbound"; +import { signalWithTimeout } from "../../lib/abort"; +import { decodeServerSentEvents } from "../../lib/sse-decoder"; import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers"; import { deriveProviderPresets } from "../../providers/derive"; import { providerCodexAccountMode } from "../../providers/registry"; import { routedSlug, slugEquals } from "../../providers/slug-codec"; -import { COMBO_NAMESPACE, comboModelId, comboPublicModelId, preservesPhysicalComboProvider } from "../../combos"; +import { COMBO_NAMESPACE, comboModelId, comboPublicModelId, preservesPhysicalComboProvider, resolveComboId } from "../../combos"; import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; import { clearThreadAccountMap } from "../../codex/routing"; import { primeCodexPoolQuotas } from "../../codex/auth-api"; +import { redactSecretString } from "../../lib/redact"; +import { routeModel, type RouteResult } from "../../router"; import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap"; import { resolveCodexHomeDir } from "../../codex/home"; import { readUsageEntries } from "../../usage/log"; @@ -61,9 +66,229 @@ import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostR import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared"; import type { ManagementContext } from "./context"; +export const MODEL_PROBE_TIMEOUT_MS = 15_000; + +function modelProbeFailureStatus(status: number): number { + if (status === 408 || status === 504) return 504; + if (status === 429) return 429; + if (status >= 400 && status < 500 && status !== 401 && status !== 403) return status; + return 502; +} + +function modelProbeAbortedByCaller(request: Request, signal: AbortSignal): boolean { + return request.signal.aborted + && signal.aborted + && signal.reason === request.signal.reason; +} + +function modelProbeTimedOut(request: Request, signal: AbortSignal): boolean { + if (!signal.aborted || modelProbeAbortedByCaller(request, signal)) return false; + const reason = signal.reason as { name?: unknown } | null | undefined; + return reason?.name === "TimeoutError"; +} + +async function modelProbeHasOutputDelta( + response: Response, + signal: AbortSignal, + stop: AbortController, +): Promise { + if (!response.body) return false; + for await (const event of decodeServerSentEvents(response.body, { signal })) { + if (event.data === "[DONE]") continue; + let payload: unknown; + try { + payload = JSON.parse(event.data); + } catch { + continue; + } + if ( + isPlainRecord(payload) + && payload.type === "response.output_text.delta" + && typeof payload.delta === "string" + && payload.delta.length > 0 + ) { + stop.abort(new DOMException("Model probe received output", "AbortError")); + return true; + } + } + return false; +} + +function providerManagementTestable(config: OcxConfig, providerName: string): boolean { + const provider = config.providers[providerName]; + if (!provider || provider.disabled === true) return false; + return !( + provider.adapter === "openai-responses" + && providerCodexAccountMode(providerName, provider) === "direct" + ); +} + +function managementModelTestable(config: OcxConfig, model: string): boolean { + if (!preservesPhysicalComboProvider(config)) { + const comboId = resolveComboId(config, model); + if (comboId) { + const combo = config.combos?.[comboId]; + return Boolean( + combo + && combo.targets.length > 0 + && combo.targets.every(target => providerManagementTestable(config, target.provider)), + ); + } + } + try { + const route = routeModel(config, model); + return providerManagementTestable(config, route.providerName); + } catch { + return false; + } +} + +function managementModelProbeRoutes(config: OcxConfig, model: string): RouteResult[] { + if (!preservesPhysicalComboProvider(config)) { + const comboId = resolveComboId(config, model); + if (comboId) { + const combo = config.combos?.[comboId]; + if (!combo) return []; + return combo.targets.map(target => routeModel( + config, + `${target.provider}/${target.model}`, + )); + } + } + return [routeModel(config, model)]; +} + export async function handleModelRoutes(ctx: ManagementContext): Promise { const { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort } = ctx; + if (url.pathname === "/api/models/test" && req.method === "POST") { + let body: unknown; + try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); } + if (!isPlainRecord(body) || typeof body.model !== "string") { + return jsonResponse({ error: "model is required" }, 400); + } + const model = body.model.trim(); + if (!model || model.length > 512) return jsonResponse({ error: "invalid model" }, 400); + if (!managementModelTestable(config, model)) { + return jsonResponse({ + ok: false, + error: "The model route graph includes an OpenAI Direct or unavailable target and cannot be tested from the management API.", + }, 409); + } + + const resolveDestination = deps.providerDestinationResolvedError + ?? providerDestinationResolvedError; + let probeRoutes: RouteResult[]; + try { + probeRoutes = managementModelProbeRoutes(config, model); + } catch { + return jsonResponse({ + ok: false, + error: "The selected model route is unavailable and cannot be tested from the management API.", + }, 409); + } + for (const route of probeRoutes) { + const destinationError = await resolveDestination( + route.providerName, + route.provider, + { + allowBenchmarkAddresses: route.providerName === "openai" + && isCanonicalOpenAiForwardProvider(route.provider), + }, + ); + if (destinationError) { + return jsonResponse({ + ok: false, + error: `provider ${route.providerName} ${destinationError}`, + }, 400); + } + } + + const createProbeFetch = deps.createProviderModelProbeFetch + ?? createProviderModelProbeFetch; + const probeConfig: OcxConfig = { + ...config, + providers: { ...config.providers }, + }; + for (const route of probeRoutes) { + const provider = probeConfig.providers[route.providerName]; + if (!provider) continue; + probeConfig.providers[route.providerName] = { + ...provider, + fetch: createProbeFetch(route.providerName, provider), + } as OcxProviderConfig; + } + + const deadline = signalWithTimeout(MODEL_PROBE_TIMEOUT_MS, req.signal); + const stop = new AbortController(); + const probeSignal = AbortSignal.any([deadline.signal, stop.signal]); + const probeRequest = new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model, + input: [{ + role: "user", + content: [{ type: "input_text", text: "ping" }], + }], + max_output_tokens: 1, + stream: true, + }), + signal: probeSignal, + }); + try { + const { handleResponses } = await import("../responses"); + const response = await handleResponses( + probeRequest, + probeConfig, + { model, provider: "" }, + { abortSignal: probeSignal }, + ); + if (response.ok) { + const sawOutput = await modelProbeHasOutputDelta(response, probeSignal, stop); + if (sawOutput) return jsonResponse({ ok: true }); + if (modelProbeAbortedByCaller(req, probeSignal)) { + return jsonResponse({ ok: false, error: "Model test cancelled" }, 499); + } + if (modelProbeTimedOut(req, probeSignal)) { + return jsonResponse({ ok: false, error: "Model test timed out" }, 504); + } + return jsonResponse({ ok: false, error: "Model test returned no output" }, 502); + } + if (modelProbeAbortedByCaller(req, probeSignal)) { + return jsonResponse({ ok: false, error: "Model test cancelled" }, 499); + } + if (modelProbeTimedOut(req, probeSignal)) { + return jsonResponse({ ok: false, error: "Model test timed out" }, 504); + } + + let error = `Model test failed (${response.status})`; + try { + const payload = await response.json() as { error?: { message?: unknown } | unknown }; + const message = isPlainRecord(payload.error) ? payload.error.message : undefined; + if (typeof message === "string" && message.trim()) error = message; + } catch { /* keep status-only fallback */ } + return jsonResponse( + { ok: false, error: redactSecretString(error).slice(0, 400) }, + modelProbeFailureStatus(response.status), + ); + } catch (err) { + if (modelProbeAbortedByCaller(req, probeSignal)) { + return jsonResponse({ ok: false, error: "Model test cancelled" }, 499); + } + if (modelProbeTimedOut(req, probeSignal)) { + return jsonResponse({ ok: false, error: "Model test timed out" }, 504); + } + const message = err instanceof Error ? err.message : "Model test failed"; + return jsonResponse({ ok: false, error: redactSecretString(message).slice(0, 400) }, 502); + } finally { + if (!stop.signal.aborted) { + stop.abort(new DOMException("Model probe finished", "AbortError")); + } + deadline.cleanup(); + } + } + if (url.pathname === "/api/models" && req.method === "GET") { const models = await fetchAllModels(config); const disabled = new Set(config.disabledModels ?? []); @@ -75,6 +300,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise { @@ -87,6 +313,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise ( stored === namespaced || slugEquals(stored, m.provider, m.id) )), diff --git a/src/service.ts b/src/service.ts index b38305fa1..c9920e2ba 100644 --- a/src/service.ts +++ b/src/service.ts @@ -6,9 +6,9 @@ * restore it via the command. */ import { execFileSync, execSync } from "node:child_process"; -import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { dirname, join, resolve, win32 } from "node:path"; import { expandUserPath, getConfigDir, readPid, removePid, removeRuntimePort } from "./config"; import { loadConfig } from "./config"; import { restoreNativeCodex } from "./codex/inject"; @@ -16,8 +16,13 @@ import { stripGrokConfig } from "./grok/inject"; import { isWslRuntime } from "./codex/home"; import { durableBunPath, durableBunRuntime } from "./lib/bun-runtime"; import { isProcessAlive, stopProxy } from "./lib/process-control"; -import { serviceApiTokenFilePath } from "./lib/service-secrets"; -import { randomUUID } from "node:crypto"; +import { removeServiceTokenFiles, serviceApiTokenFilePath } from "./lib/service-secrets"; +import { + serviceAdminTokenFileForDefinition, + serviceAdminTokenFilePath, + type ServiceTokenDefinitionState, +} from "./lib/admin-secrets"; +import { randomUUID, timingSafeEqual } from "node:crypto"; import { ELEVATION_REQUEST_TIMEOUT_MS, OCX_ELEVATED_PROTOCOL_FAILED, @@ -31,15 +36,24 @@ import { type ElevatedSchtasksCreateAndRunExecution, type ElevatedSchtasksCreateAndRunResult, } from "./lib/windows-elevation"; -import { defaultWinswEntry, installWinswService, startWinswService, stopWinswService, statusWinswRaw, uninstallWinswService, winswStatusSummary, WINSW_SERVICE_ID, WINSW_SHA256, WINSW_VERSION } from "./lib/winsw"; +import { defaultWinswEntry, installWinswService, startWinswService, stopWinswService, statusWinswRaw, uninstallWinswService, winswStatusSummary, winswXmlPath, WINSW_SERVICE_ID, WINSW_SHA256, WINSW_VERSION } from "./lib/winsw"; import { hardenSecretDir, hardenSecretPath } from "./lib/windows-secret-acl"; import { windowsEnvIndirectBatchPathList, windowsEnvIndirectBatchValue } from "./lib/win-paths"; -import { recordOwnedConfigPath } from "./lib/config-ownership"; +import { + CONFIG_OWNER_FILE, + CONFIG_UNINSTALL_MANIFEST, + isOwnershipInfrastructureName, + recordOwnedConfigPath, +} from "./lib/config-ownership"; +import { assertServerAuthConfig } from "./server/auth-cors"; +import type { OcxConfig } from "./types"; import { maybeShowStarPrompt } from "./cli/star-prompt"; const LABEL = "com.opencodex.proxy"; const TASK = "opencodex-proxy"; +export type { ServiceTokenDefinitionState } from "./lib/admin-secrets"; + export type ServiceBackend = "scheduler" | "native"; function cliEntry(): { bun: string; cli: string } { @@ -237,48 +251,573 @@ function plistString(value: string): string { .replace(/'/g, "'"); } -function isLoopbackHostname(hostname: string | undefined): boolean { - const normalized = (hostname ?? "127.0.0.1").trim().toLowerCase(); - return normalized === "" || normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1" || normalized === "[::1]"; +export function assertServiceAuthEnvironment(): void { + assertServerAuthConfig(loadConfig()); } -export function assertServiceAuthEnvironment(): void { - const config = loadConfig(); - if (isLoopbackHostname(config.hostname)) return; - if (process.env.OPENCODEX_API_AUTH_TOKEN?.trim()) return; - throw new Error( - "OPENCODEX_API_AUTH_TOKEN is required before installing a service for non-loopback hostname. " + - "Set it in the same shell, then rerun `ocx service install`.", +export interface ServiceApiTokenWriteOptions { + configDir?: string; + env?: Record; + platform?: string; + recordOwnedPath?: typeof recordOwnedConfigPath; + hardenDir?: typeof hardenSecretDir; + hardenPath?: typeof hardenSecretPath; +} + +interface ValidatedServiceApiToken { + token: string; + payload: string; +} + +function validatedServiceApiToken( + env: Record, +): ValidatedServiceApiToken | null { + const rawToken = env.OPENCODEX_API_AUTH_TOKEN; + if (rawToken === undefined) return null; + if (/[\x00-\x1f\x7f]/.test(rawToken)) { + throw new Error( + "OPENCODEX_API_AUTH_TOKEN cannot contain CR, LF, or NUL, or other ASCII control characters when installing a service", + ); + } + const token = rawToken.trim(); + if (!token) { + if (rawToken.length > 0) { + throw new Error("OPENCODEX_API_AUTH_TOKEN cannot be whitespace-only when installing a service"); + } + return null; + } + return { token, payload: `${token}\n` }; +} + +function writePlannedServiceApiTokenFile( + plannedToken: ValidatedServiceApiToken | null, + options: ServiceApiTokenWriteOptions, +): string | null { + const dir = options.configDir ?? getConfigDir(); + const path = serviceApiTokenFilePath(dir); + if (!plannedToken) { + try { + unlinkSync(path); + } catch (error) { + const code = error && typeof error === "object" && "code" in error + ? String((error as NodeJS.ErrnoException).code) + : ""; + if (code !== "ENOENT") { + throw new Error("stale data service token could not be removed; service install aborted", { cause: error }); + } + } + return null; + } + const recordOwnedPath = options.recordOwnedPath ?? recordOwnedConfigPath; + const legacyUnowned = isLegacyUnownedConfigDir(dir); + const recorded = recordOwnedPath(dir, path); + if (!recorded) { + const ownershipMetadataExists = existsSync(join(dir, CONFIG_OWNER_FILE)) + || existsSync(join(dir, CONFIG_UNINSTALL_MANIFEST)); + if (!legacyUnowned || ownershipMetadataExists) { + throw new Error("data service token could not be registered in the config ownership manifest"); + } + } + if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); + if ((options.platform ?? process.platform) === "win32") { + const hardened = (options.hardenDir ?? hardenSecretDir)(dir, { required: true }); + if (!hardened.ok) throw new Error("data service token directory ACL hardening did not complete"); + } + const tempPath = `${path}.tmp.${process.pid}.${randomUUID()}`; + try { + writeFileSync(tempPath, plannedToken.payload, { encoding: "utf8", flag: "wx", mode: 0o600 }); + try { chmodSync(tempPath, 0o600); } catch { /* best-effort on platforms that ignore chmod */ } + if ((options.platform ?? process.platform) === "win32") { + const hardened = (options.hardenPath ?? hardenSecretPath)(tempPath, { required: true }); + if (!hardened.ok) throw new Error("data service token file ACL hardening did not complete"); + } + renameSync(tempPath, path); + } catch (error) { + try { + if (existsSync(tempPath)) unlinkSync(tempPath); + } catch (cleanupError) { + throw new Error( + "data service token write failed and its temporary file could not be removed", + { cause: cleanupError }, + ); + } + throw error; + } + return path; +} + +export function writeServiceApiTokenFile(options: ServiceApiTokenWriteOptions = {}): string | null { + return writePlannedServiceApiTokenFile( + validatedServiceApiToken(options.env ?? process.env), + options, ); } -function writeServiceApiTokenFile(): string | null { - const token = process.env.OPENCODEX_API_AUTH_TOKEN?.trim(); +export interface ServiceAdminTokenWriteOptions { + configDir?: string; + env?: Record; + platform?: string; + recordOwnedPath?: typeof recordOwnedConfigPath; + hardenDir?: typeof hardenSecretDir; + hardenPath?: typeof hardenSecretPath; +} + +function validatedServiceAdminToken( + env: Record, +): { token: string; payload: string } | null { + const token = env.OPENCODEX_ADMIN_AUTH_TOKEN?.trim(); if (!token) return null; - const path = serviceApiTokenFilePath(); - const dir = getConfigDir(); - recordOwnedConfigPath(dir, path); + if (/[\r\n\0]/.test(token)) { + throw new Error("OPENCODEX_ADMIN_AUTH_TOKEN cannot contain line breaks or NUL when installing a service"); + } + const payload = `${token}\n`; + if (Buffer.byteLength(payload, "utf8") > 512) { + throw new Error("OPENCODEX_ADMIN_AUTH_TOKEN cannot exceed 512 UTF-8 bytes when installing a service"); + } + return { token, payload }; +} + +function isLegacyUnownedConfigDir(dir: string): boolean { + if (!existsSync(dir)) return false; + if ( + existsSync(join(dir, CONFIG_OWNER_FILE)) + || existsSync(join(dir, CONFIG_UNINSTALL_MANIFEST)) + ) return false; + try { + return readdirSync(dir).some(name => !isOwnershipInfrastructureName(name)); + } catch { + return false; + } +} + +export function writeServiceAdminTokenFile(options: ServiceAdminTokenWriteOptions = {}): string | null { + const env = options.env ?? process.env; + const plannedToken = validatedServiceAdminToken(env); + const dir = options.configDir ?? getConfigDir(); + const path = serviceAdminTokenFilePath(dir); + if (!plannedToken) { + try { + unlinkSync(path); + } catch (error) { + const code = error && typeof error === "object" && "code" in error + ? String((error as NodeJS.ErrnoException).code) + : ""; + if (code !== "ENOENT") { + throw new Error( + "stale management service token could not be removed; service install aborted", + { cause: error }, + ); + } + } + return null; + } + const { payload } = plannedToken; + const recordOwnedPath = options.recordOwnedPath ?? recordOwnedConfigPath; + const legacyUnowned = isLegacyUnownedConfigDir(dir); + const recorded = recordOwnedPath(dir, path); + if (!recorded) { + const ownershipMetadataExists = existsSync(join(dir, CONFIG_OWNER_FILE)) + || existsSync(join(dir, CONFIG_UNINSTALL_MANIFEST)); + if (!legacyUnowned || ownershipMetadataExists) { + throw new Error("management service token could not be registered in the config ownership manifest"); + } + } if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); - if (process.platform === "win32") hardenSecretDir(dir, { required: true }); - writeFileSync(path, `${token}\n`, { encoding: "utf8", mode: 0o600 }); - try { chmodSync(path, 0o600); } catch { /* best-effort */ } - if (process.platform === "win32") hardenSecretPath(path, { required: true }); + if ((options.platform ?? process.platform) === "win32") { + const hardened = (options.hardenDir ?? hardenSecretDir)(dir, { required: true }); + if (!hardened.ok) throw new Error("management service token directory ACL hardening did not complete"); + } + const tempPath = `${path}.tmp.${process.pid}.${randomUUID()}`; + try { + writeFileSync(tempPath, payload, { encoding: "utf8", flag: "wx", mode: 0o600 }); + try { chmodSync(tempPath, 0o600); } catch { /* best-effort on platforms that ignore chmod */ } + if ((options.platform ?? process.platform) === "win32") { + const hardened = (options.hardenPath ?? hardenSecretPath)(tempPath, { required: true }); + if (!hardened.ok) throw new Error("management service token file ACL hardening did not complete"); + } + renameSync(tempPath, path); + } catch (error) { + try { + if (existsSync(tempPath)) unlinkSync(tempPath); + } catch (cleanupError) { + throw new Error( + "management service token write failed and its temporary file could not be removed", + { cause: cleanupError }, + ); + } + throw error; + } return path; } -export function buildPlist(): string { +export interface ServiceTokenInstallTransactionOptions + extends ServiceAdminTokenWriteOptions { + env?: Record; + config?: Pick; + serviceAssetPaths?: string[]; + restoreServiceDefinition?: () => void; +} + +interface ServiceFileSnapshot { + path: string; + contents: Buffer | null; + mode: number; + label: string; + secret: boolean; +} + +interface ServiceTokenInstallPlan { + dataAction: + | { kind: "write"; token: ValidatedServiceApiToken } + | { kind: "preserve"; token: string | null } + | { kind: "remove" }; + adminAction: + | { kind: "write"; token: string } + | { kind: "preserve"; token: string } + | { kind: "remove" }; + definitionState: ServiceTokenDefinitionState; +} + +function errorCode(error: unknown): string { + return error && typeof error === "object" && "code" in error + ? String((error as NodeJS.ErrnoException).code) + : ""; +} + +function normalizedPathEquals(left: string, right: string, platform: string): boolean { + const normalizedLeft = resolve(left); + const normalizedRight = resolve(right); + return platform === "win32" + ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() + : normalizedLeft === normalizedRight; +} + +function snapshotServiceFile(path: string, label: string, secret = false): ServiceFileSnapshot { + try { + const stat = lstatSync(path); + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error(`service install snapshot refused a non-regular file at ${path}`); + } + return { path, contents: Buffer.from(readFileSync(path)), mode: stat.mode & 0o777, label, secret }; + } catch (error) { + if (errorCode(error) === "ENOENT") return { path, contents: null, mode: 0o600, label, secret }; + throw error; + } +} + +function serviceSnapshotToken(snapshot: ServiceFileSnapshot): string | null { + const token = snapshot.contents?.toString("utf8").trim() ?? ""; + return token || null; +} + +function constantTimeSecretEquals(left: string, right: string): boolean { + const leftBytes = Buffer.from(left, "utf8"); + const rightBytes = Buffer.from(right, "utf8"); + return leftBytes.byteLength === rightBytes.byteLength + && timingSafeEqual(leftBytes, rightBytes); +} + +function validatedServiceTokenInstallValues( + env: Record, + configDir: string, + platform: string, +): { + dataToken: ValidatedServiceApiToken | null; + preserveDataToken: boolean; + explicitAdmin: { token: string; payload: string } | null; + preserveAdminPointer: boolean; + preserveExistingAdmin: boolean; +} { + const dataToken = validatedServiceApiToken(env); + const preserveDataToken = env.OPENCODEX_API_AUTH_TOKEN === undefined; + const explicitAdmin = validatedServiceAdminToken(env); + const explicitAdminVariable = env.OPENCODEX_ADMIN_AUTH_TOKEN !== undefined; + const adminPointer = env.OCX_ADMIN_TOKEN_FILE?.trim(); + if (explicitAdmin || explicitAdminVariable) { + return { + dataToken, + preserveDataToken, + explicitAdmin, + preserveAdminPointer: false, + preserveExistingAdmin: false, + }; + } + if (!adminPointer) { + return { + dataToken, + preserveDataToken, + explicitAdmin: null, + preserveAdminPointer: false, + preserveExistingAdmin: true, + }; + } + const expectedPath = serviceAdminTokenFilePath(configDir); + if (!normalizedPathEquals(adminPointer, expectedPath, platform)) { + throw new Error( + "OCX_ADMIN_TOKEN_FILE must resolve to the current service-admin-token before reinstalling the service", + ); + } + return { + dataToken, + preserveDataToken, + explicitAdmin: null, + preserveAdminPointer: true, + preserveExistingAdmin: false, + }; +} + +function buildServiceTokenInstallPlan( + values: ReturnType, + dataSnapshot: ServiceFileSnapshot, + adminSnapshot: ServiceFileSnapshot, + config: Pick, +): ServiceTokenInstallPlan { + const dataAction: ServiceTokenInstallPlan["dataAction"] = values.preserveDataToken + ? { kind: "preserve", token: serviceSnapshotToken(dataSnapshot) } + : values.dataToken + ? { kind: "write", token: values.dataToken } + : { kind: "remove" }; + let plan: ServiceTokenInstallPlan; + if (values.explicitAdmin) { + plan = { + dataAction, + adminAction: { kind: "write", token: values.explicitAdmin.token }, + definitionState: { adminTokenFile: adminSnapshot.path }, + }; + } else if (!values.preserveAdminPointer && !values.preserveExistingAdmin) { + plan = { + dataAction, + adminAction: { kind: "remove" }, + definitionState: { adminTokenFile: null }, + }; + } else { + const contents = adminSnapshot.contents; + if (!contents && values.preserveExistingAdmin) { + return { + dataAction, + adminAction: { kind: "remove" }, + definitionState: { adminTokenFile: null }, + }; + } + const token = contents?.toString("utf8").trim() ?? ""; + if ( + !contents + || contents.byteLength > 512 + || !token + || /[\r\n\0]/.test(token) + ) { + throw new Error( + "OCX_ADMIN_TOKEN_FILE does not reference a valid current service-admin-token", + ); + } + plan = { + dataAction, + adminAction: { kind: "preserve", token }, + definitionState: { adminTokenFile: adminSnapshot.path }, + }; + } + const adminToken = plan.adminAction.kind === "remove" + ? null + : plan.adminAction.token; + const dataToken = plan.dataAction.kind === "write" + ? plan.dataAction.token.token + : plan.dataAction.kind === "preserve" + ? plan.dataAction.token + : null; + if ( + adminToken + && ( + (dataToken !== null && constantTimeSecretEquals(adminToken, dataToken)) + || config.apiKeys?.some(entry => constantTimeSecretEquals(entry.key, adminToken)) + ) + ) { + throw new Error("management credential conflicts with a data-plane credential"); + } + return plan; +} + +function restoreServiceFileSnapshot( + snapshot: ServiceFileSnapshot, + options: ServiceTokenInstallTransactionOptions, +): void { + const label = snapshot.label; + if (!snapshot.contents) { + try { + unlinkSync(snapshot.path); + } catch (error) { + if (errorCode(error) !== "ENOENT") throw error; + } + return; + } + const dir = dirname(snapshot.path); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); + const platform = options.platform ?? process.platform; + if (platform === "win32" && snapshot.secret) { + const hardened = (options.hardenDir ?? hardenSecretDir)(dir, { required: true }); + if (!hardened.ok) throw new Error(`${label} rollback directory ACL hardening did not complete`); + } + const tempPath = `${snapshot.path}.restore.${process.pid}.${randomUUID()}`; + try { + writeFileSync(tempPath, snapshot.contents, { flag: "wx", mode: snapshot.mode }); + try { chmodSync(tempPath, snapshot.mode); } catch { /* best-effort on platforms that ignore chmod */ } + if (platform === "win32" && snapshot.secret) { + const hardened = (options.hardenPath ?? hardenSecretPath)(tempPath, { required: true }); + if (!hardened.ok) throw new Error(`${label} rollback file ACL hardening did not complete`); + } + renameSync(tempPath, snapshot.path); + } catch (error) { + try { + unlinkSync(tempPath); + } catch (cleanupError) { + if (errorCode(cleanupError) !== "ENOENT") { + throw new Error(`${label} rollback failed and its temporary file could not be removed`, { + cause: cleanupError, + }); + } + } + throw error; + } +} + +function rollbackServiceInstall( + snapshots: ServiceFileSnapshot[], + options: ServiceTokenInstallTransactionOptions, + installError: unknown, + restoreDefinition: boolean, +): never { + const failures: string[] = []; + for (const snapshot of snapshots) { + try { + restoreServiceFileSnapshot(snapshot, options); + } catch (error) { + failures.push(`${snapshot.label}: ${error instanceof Error ? error.message : String(error)}`); + } + } + if (restoreDefinition && options.restoreServiceDefinition) { + try { + options.restoreServiceDefinition(); + } catch (error) { + failures.push(`service definition: ${error instanceof Error ? error.message : String(error)}`); + } + } + if (failures.length > 0) { + const original = installError instanceof Error ? installError.message : String(installError); + throw new Error( + `${original}; service token rollback also failed (${failures.join("; ")})`, + { cause: installError }, + ); + } + throw installError; +} + +function beginServiceTokenInstallTransaction( + options: ServiceTokenInstallTransactionOptions, +): { + definitionState: ServiceTokenDefinitionState; + snapshots: ServiceFileSnapshot[]; +} { + const env = options.env ?? process.env; + const configDir = options.configDir ?? getConfigDir(); + const platform = options.platform ?? process.platform; + const values = validatedServiceTokenInstallValues(env, configDir, platform); + const tokenSnapshots = [ + snapshotServiceFile(serviceApiTokenFilePath(configDir), "data service token", true), + snapshotServiceFile(serviceAdminTokenFilePath(configDir), "management service token", true), + ]; + const tokenPaths = new Set(tokenSnapshots.map(snapshot => normalizePathForCompare(snapshot.path))); + const assetSnapshots = [...new Set(options.serviceAssetPaths ?? [])] + .filter(path => !tokenPaths.has(normalizePathForCompare(path))) + .map(path => snapshotServiceFile(path, `service asset ${path}`)); + const snapshots = [...tokenSnapshots, ...assetSnapshots]; + const config = options.config ?? ( + normalizedPathEquals(configDir, getConfigDir(), platform) + ? loadConfig() + : { apiKeys: [] } + ); + const plan = buildServiceTokenInstallPlan(values, tokenSnapshots[0]!, tokenSnapshots[1]!, config); + if (plan.adminAction.kind === "preserve" && platform === "win32") { + const hardenedDir = (options.hardenDir ?? hardenSecretDir)(configDir, { required: true }); + if (!hardenedDir.ok) { + throw new Error( + "preserved management service token directory ACL hardening did not complete", + ); + } + const hardenedFile = (options.hardenPath ?? hardenSecretPath)( + plan.definitionState.adminTokenFile!, + { required: true }, + ); + if (!hardenedFile.ok) { + throw new Error( + "preserved management service token file ACL hardening did not complete", + ); + } + } + try { + if (plan.dataAction.kind !== "preserve") { + writePlannedServiceApiTokenFile( + plan.dataAction.kind === "write" ? plan.dataAction.token : null, + { + ...options, + configDir, + platform, + }, + ); + } + if (plan.adminAction.kind !== "preserve") { + writeServiceAdminTokenFile({ + ...options, + configDir, + env: { + OPENCODEX_ADMIN_AUTH_TOKEN: plan.adminAction.kind === "write" + ? plan.adminAction.token + : undefined, + }, + platform, + }); + } + } catch (error) { + rollbackServiceInstall(snapshots, options, error, false); + } + return { definitionState: plan.definitionState, snapshots }; +} + +export function withServiceTokenInstallTransaction( + install: (state: ServiceTokenDefinitionState) => T, + options: ServiceTokenInstallTransactionOptions = {}, +): T { + const transaction = beginServiceTokenInstallTransaction(options); + try { + return install(transaction.definitionState); + } catch (error) { + rollbackServiceInstall(transaction.snapshots, options, error, true); + } +} + +export async function withServiceTokenInstallTransactionAsync( + install: (state: ServiceTokenDefinitionState) => Promise, + options: ServiceTokenInstallTransactionOptions = {}, +): Promise { + const transaction = beginServiceTokenInstallTransaction(options); + try { + return await install(transaction.definitionState); + } catch (error) { + rollbackServiceInstall(transaction.snapshots, options, error, true); + } +} + +export function buildPlist(state?: ServiceTokenDefinitionState): string { const { bun, cli } = cliEntry(); const log = logPath(); const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin"; const codexHome = process.env.CODEX_HOME?.trim(); - const opencodexHome = process.env.OPENCODEX_HOME?.trim(); + const opencodexHome = process.env.OPENCODEX_HOME?.trim() ? getConfigDir() : undefined; const envLines = [ ` OCX_SERVICE1`, ` PATH${plistString(path)}`, codexHome ? ` CODEX_HOME${plistString(codexHome)}` : null, opencodexHome ? ` OPENCODEX_HOME${plistString(opencodexHome)}` : null, ].filter((line): line is string => Boolean(line)).join("\n"); - const command = buildServiceShellCommand(bun, cli); + const command = buildServiceShellCommand(bun, cli, resolveServiceListenPort(), state); return ` @@ -327,9 +866,18 @@ export function resolveServiceListenPort(override?: number): number { return 10100; } -function buildServiceShellCommand(bun: string, cli: string, port = resolveServiceListenPort()): string { +function buildServiceShellCommand( + bun: string, + cli: string, + port = resolveServiceListenPort(), + state?: ServiceTokenDefinitionState, +): string { const tokenFile = serviceApiTokenFilePath(); - return `if [ -f ${shellQuote(tokenFile)} ]; then OPENCODEX_API_AUTH_TOKEN="$(cat ${shellQuote(tokenFile)})"; export OPENCODEX_API_AUTH_TOKEN; fi; exec ${shellQuote(bun)} ${shellQuote(cli)} start --port ${port}`; + const adminTokenFile = serviceAdminTokenFileForDefinition(state); + const adminTokenSetup = adminTokenFile + ? `OCX_ADMIN_TOKEN_FILE=${shellQuote(adminTokenFile)}; export OCX_ADMIN_TOKEN_FILE; ` + : ""; + return `${adminTokenSetup}if [ -f ${shellQuote(tokenFile)} ]; then OPENCODEX_API_AUTH_TOKEN="$(cat ${shellQuote(tokenFile)})"; export OPENCODEX_API_AUTH_TOKEN; fi; exec ${shellQuote(bun)} ${shellQuote(cli)} start --port ${port}`; } function systemdQuote(value: string): string { @@ -920,6 +1468,16 @@ function windowsBatchValue(value: string): string { .replace(/[\r\n]/g, ""); } +function isWindowsServicePath(value: string): boolean { + return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\"); +} + +function normalizeWindowsServicePath(value: string): string { + return isWindowsServicePath(value) + ? win32.normalize(value) + : value; +} + type WindowsBatchValueKind = "raw" | "path" | "pathList"; function windowsBatchSet(name: string, value: string | undefined, kind: WindowsBatchValueKind = "raw"): string | null { @@ -956,10 +1514,21 @@ function taskXmlRunLevelAcceptable(principal: string): boolean { return value === "leastprivilege" || value === "highestavailable"; } -export function buildWindowsServiceScript(entry = cliEntry(), port = resolveServiceListenPort()): string { +export function buildWindowsServiceScript( + entry = cliEntry(), + port = resolveServiceListenPort(), + state?: ServiceTokenDefinitionState, +): string { const { bun, cli } = entry; const bunRuntime = durableBunRuntime(); const path = process.env.PATH ?? ""; + const configuredOpenCodexHome = process.env.OPENCODEX_HOME?.trim(); + const openCodexHome = configuredOpenCodexHome && isWindowsServicePath(configuredOpenCodexHome) + ? win32.resolve(configuredOpenCodexHome) + : getConfigDir(); + const apiTokenFile = normalizeWindowsServicePath(serviceApiTokenFilePath(openCodexHome)); + const adminTokenFile = serviceAdminTokenFileForDefinition(state); + const logFile = normalizeWindowsServicePath(join(openCodexHome, "service.log")); const lines = [ "@echo off", "setlocal", @@ -969,9 +1538,16 @@ export function buildWindowsServiceScript(entry = cliEntry(), port = resolveServ windowsBatchSet("OCX_SERVICE", "1"), windowsBatchSet("PATH", path, "pathList"), windowsBatchSet("CODEX_HOME", process.env.CODEX_HOME?.trim(), "path"), - windowsBatchSet("OPENCODEX_HOME", process.env.OPENCODEX_HOME?.trim(), "path"), - windowsBatchSet("OCX_API_TOKEN_FILE", serviceApiTokenFilePath(), "path"), - windowsBatchSet("OCX_SERVICE_LOG", serviceLogPath(), "path"), + windowsBatchSet( + "OPENCODEX_HOME", + configuredOpenCodexHome ? openCodexHome : undefined, + "path", + ), + windowsBatchSet("OCX_API_TOKEN_FILE", apiTokenFile, "path"), + adminTokenFile + ? windowsBatchSet("OCX_ADMIN_TOKEN_FILE", normalizeWindowsServicePath(adminTokenFile), "path") + : "", + windowsBatchSet("OCX_SERVICE_LOG", logFile, "path"), windowsBatchSet("OCX_BUN", bun, "path"), windowsBatchSet("OCX_CLI", cli, "path"), 'if exist "%OCX_API_TOKEN_FILE%" (', @@ -1215,16 +1791,25 @@ export function readWindowsSchedulerXmlState( // ── macOS (launchd) ── function installLaunchd(): void { - const dir = join(homedir(), "Library", "LaunchAgents"); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - recordOwnedConfigPath(getConfigDir(), serviceStatePath()); - if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true }); - writeServiceApiTokenFile(); const p = plistPath(); - writeFileSync(p, buildPlist(), "utf8"); - try { sh(`launchctl unload "${p}" 2>/dev/null`); } catch { /* not loaded */ } - sh(`launchctl load -w "${p}"`); - writeServiceInstallState(); + const hadDefinition = existsSync(p); + const wasLoaded = statusLaunchd().includes(LABEL); + withServiceTokenInstallTransaction(state => { + const dir = join(homedir(), "Library", "LaunchAgents"); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + recordOwnedConfigPath(getConfigDir(), serviceStatePath()); + if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true }); + writeFileSync(p, buildPlist(state), "utf8"); + try { sh(`launchctl unload "${p}" 2>/dev/null`); } catch { /* not loaded */ } + sh(`launchctl load -w "${p}"`); + writeServiceInstallState(); + }, { + serviceAssetPaths: [p, ...serviceStatePaths()], + restoreServiceDefinition: () => { + try { sh(`launchctl unload "${p}" 2>/dev/null`); } catch { /* not loaded */ } + if (hadDefinition && wasLoaded) sh(`launchctl load -w "${p}"`); + }, + }); } function startLaunchd(): void { sh(`launchctl load -w "${plistPath()}"`); } function stopLaunchd(): void { try { sh(`launchctl unload "${plistPath()}"`); } catch { /* not loaded */ } } @@ -1257,11 +1842,13 @@ function writeServiceAssetWithRetry(path: string, content: string, encoding: "ut * Rewrite on-disk scheduler assets (script/VBS/XML) without re-registering the task. * Used by fresh install (before schtasks /create) and by repair (no elevation). */ -function writeWindowsSchedulerAssets(): void { +function writeWindowsSchedulerAssets(state?: ServiceTokenDefinitionState): void { if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true }); - writeServiceApiTokenFile(); const script = windowsServiceScriptPath(); - writeServiceAssetWithRetry(script, buildWindowsServiceScript(), "utf8"); + writeServiceAssetWithRetry(script, + buildWindowsServiceScript(undefined, undefined, state), + "utf8", + ); // UTF-16LE + BOM: a BOM-less UTF-8 VBS mis-decodes non-ASCII (e.g. Korean) profile // paths on some WSH/codepage combinations — same contract as the task XML below. writeServiceAssetWithRetry(windowsLauncherVbsPath(), `\uFEFF${buildWindowsLauncherVbs(script)}`, "utf16le"); @@ -1269,27 +1856,60 @@ function writeWindowsSchedulerAssets(): void { } function installWindows(): void { - recordOwnedConfigPath(getConfigDir(), serviceStatePath()); - // Transactional backend switch: installing the scheduler backend removes a native - // service first — two live managers would both respawn the proxy (conflict). - if (statusWinswRaw() !== "nonexistent") { - console.log("🔁 Removing the native (WinSW) service before installing the Task Scheduler backend..."); - try { - uninstallWinswService(); - } catch (err) { - throw new Error(`Cannot remove the native service before switching to Task Scheduler: ${err instanceof Error ? err.message : String(err)}. Remove it manually with 'sc delete ${WINSW_SERVICE_ID}' or retry.`); - } + const previousTaskXml = statusWindowsXml(); + withServiceTokenInstallTransaction(state => { + recordOwnedConfigPath(getConfigDir(), serviceStatePath()); + // Transactional backend switch: installing the scheduler backend removes a native + // service first — two live managers would both respawn the proxy (conflict). if (statusWinswRaw() !== "nonexistent") { - throw new Error(`Native service registration could not be re-verified after the removal attempt — aborting switch. Check 'sc.exe query ${WINSW_SERVICE_ID}' and remove it manually if present.`); + console.log("🔁 Removing the native (WinSW) service before installing the Task Scheduler backend..."); + try { + uninstallWinswService(); + } catch (err) { + throw new Error(`Cannot remove the native service before switching to Task Scheduler: ${err instanceof Error ? err.message : String(err)}. Remove it manually with 'sc delete ${WINSW_SERVICE_ID}' or retry.`); + } + if (statusWinswRaw() !== "nonexistent") { + throw new Error(`Native service registration could not be re-verified after the removal attempt — aborting switch. Check 'sc.exe query ${WINSW_SERVICE_ID}' and remove it manually if present.`); + } } - } - // End a running task BEFORE rewriting the assets it is executing — cmd.exe reading the - // script mid-rewrite runs a torn batch file, and its open handle can fail the write. - try { stopWindows(); } catch { /* not running */ } - writeWindowsSchedulerAssets(); - schtasks(buildWindowsSchtasksCreateArgs(windowsServiceScriptPath())); - schtasks(["/run", "/tn", TASK]); - writeServiceInstallState("scheduler"); + // End a running task BEFORE rewriting the assets it is executing — cmd.exe reading the + // script mid-rewrite runs a torn batch file, and its open handle can fail the write. + try { stopWindows(); } catch { /* not running */ } + writeWindowsSchedulerAssets(state); + const script = windowsServiceScriptPath(); + schtasks(buildWindowsSchtasksCreateArgs(script)); + schtasks(["/run", "/tn", TASK]); + writeServiceInstallState("scheduler"); + }, { + serviceAssetPaths: [ + windowsServiceScriptPath(), + windowsLauncherVbsPath(), + windowsTaskXmlPath(), + ...serviceStatePaths(), + ], + restoreServiceDefinition: () => { + try { stopWindows(); } catch { /* not running */ } + if (!previousTaskXml) { + try { schtasks(["/delete", "/tn", TASK, "/f"]); } catch { /* absent */ } + return; + } + let definitionPath = windowsTaskXmlPath(); + let temporaryPath: string | null = null; + if (!existsSync(definitionPath)) { + temporaryPath = `${definitionPath}.restore.${process.pid}.${randomUUID()}`; + writeFileSync(temporaryPath, `\uFEFF${previousTaskXml}`, "utf16le"); + definitionPath = temporaryPath; + } + try { + schtasks(["/create", "/tn", TASK, "/xml", definitionPath, "/f"]); + schtasks(["/run", "/tn", TASK]); + } finally { + if (temporaryPath) { + try { unlinkSync(temporaryPath); } catch { /* rollback error is reported by registration */ } + } + } + }, + }); } export interface RepairServiceDeps { @@ -1364,38 +1984,41 @@ export async function repairService(deps: RepairServiceDeps = {}): Promise * reported) — never a silent fallback to the scheduler. */ async function installWindowsNative(): Promise { - recordOwnedConfigPath(getConfigDir(), serviceStatePath()); - if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true }); - writeServiceApiTokenFile(); - let hadScheduler = false; - try { - hadScheduler = schtasks(["/query", "/tn", TASK]).includes(TASK); - } catch { /* task absent */ } - if (hadScheduler) { - console.log("🔁 Removing the Task Scheduler backend before installing the native (WinSW) service..."); - try { stopWindows(); } catch { /* not running */ } + await withServiceTokenInstallTransactionAsync(async state => { + recordOwnedConfigPath(getConfigDir(), serviceStatePath()); + if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true }); + let hadScheduler = false; try { - uninstallWindows(); - } catch (err) { - throw new Error(`Cannot remove the Task Scheduler backend before switching to native: ${err instanceof Error ? err.message : String(err)}`); + hadScheduler = schtasks(["/query", "/tn", TASK]).includes(TASK); + } catch { /* task absent */ } + if (hadScheduler) { + console.log("🔁 Removing the Task Scheduler backend before installing the native (WinSW) service..."); + try { stopWindows(); } catch { /* not running */ } + try { + uninstallWindows(); + } catch (err) { + throw new Error(`Cannot remove the Task Scheduler backend before switching to native: ${err instanceof Error ? err.message : String(err)}`); + } + // Verify removal — schtasks /delete can silently fail if UAC or policy blocks it. + try { + if (schtasks(["/query", "/tn", TASK]).includes(TASK)) { + throw new Error("Task Scheduler backend still present after removal — aborting switch."); + } + } catch (e) { + if (e instanceof Error && e.message.includes("still present")) throw e; + /* query failure = task absent, which is what we want */ + } } - // Verify removal — schtasks /delete can silently fail if UAC or policy blocks it. try { - if (schtasks(["/query", "/tn", TASK]).includes(TASK)) { - throw new Error("Task Scheduler backend still present after removal — aborting switch."); - } - } catch (e) { - if (e instanceof Error && e.message.includes("still present")) throw e; - /* query failure = task absent, which is what we want */ + await installWinswService(defaultWinswEntry(import.meta.dir), {}, state); + } catch (err) { + if (hadScheduler) console.error("⚠️ Native install failed AFTER removing the Task Scheduler backend — no service is installed now. Run `ocx service install` to restore the scheduler backend, or retry `--native`."); + throw err; } - } - try { - await installWinswService(defaultWinswEntry(import.meta.dir)); - } catch (err) { - if (hadScheduler) console.error("⚠️ Native install failed AFTER removing the Task Scheduler backend — no service is installed now. Run `ocx service install` to restore the scheduler backend, or retry `--native`."); - throw err; - } - writeServiceInstallState("native"); + writeServiceInstallState("native"); + }, { + serviceAssetPaths: [winswXmlPath(), ...serviceStatePaths()], + }); } function startWindows(): void { schtasks(["/run", "/tn", TASK]); } function stopWindows(): void { try { schtasks(["/end", "/tn", TASK]); } catch { /* not running */ } } @@ -1435,12 +2058,15 @@ function unitPath(): string { return join(unitDir(), `${TASK}.service`); } -export function buildUnit(): string { +export function buildUnit(state?: ServiceTokenDefinitionState): string { const { bun, cli } = cliEntry(); const log = logPath(); const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin"; const codexHome = systemdEnvironmentAssignment("CODEX_HOME", process.env.CODEX_HOME?.trim()); - const opencodexHome = systemdEnvironmentAssignment("OPENCODEX_HOME", process.env.OPENCODEX_HOME?.trim()); + const opencodexHome = systemdEnvironmentAssignment( + "OPENCODEX_HOME", + process.env.OPENCODEX_HOME?.trim() ? getConfigDir() : undefined, + ); const envLines = [ systemdEnvironmentAssignment("OCX_SERVICE", "1"), systemdEnvironmentAssignment("PATH", path), @@ -1454,7 +2080,7 @@ Wants=network-online.target [Service] Type=simple -ExecStart=${systemdQuote("/bin/sh")} -lc ${systemdQuote(buildServiceShellCommand(bun, cli))} +ExecStart=${systemdQuote("/bin/sh")} -lc ${systemdQuote(buildServiceShellCommand(bun, cli, resolveServiceListenPort(), state))} Restart=on-failure RestartSec=5 ${envLines} @@ -1500,17 +2126,38 @@ function isSystemd(): boolean { } function installSystemd(): void { - ensureUserBusEnv(); // reach the user bus over a bare SSH session (F9) - const dir = unitDir(); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - recordOwnedConfigPath(getConfigDir(), serviceStatePath()); - if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true }); - writeServiceApiTokenFile(); - writeFileSync(unitPath(), buildUnit(), "utf8"); - sh("systemctl --user daemon-reload"); - sh(`systemctl --user enable ${TASK}`); - sh(`systemctl --user restart ${TASK}`); - writeServiceInstallState(); + ensureUserBusEnv(); + const hadDefinition = existsSync(unitPath()); + const wasEnabled = hadDefinition && (() => { + try { return sh(`systemctl --user is-enabled ${TASK}`) === "enabled"; } catch { return false; } + })(); + const wasActive = hadDefinition && (() => { + try { return sh(`systemctl --user is-active ${TASK}`) === "active"; } catch { return false; } + })(); + withServiceTokenInstallTransaction(state => { + const dir = unitDir(); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + recordOwnedConfigPath(getConfigDir(), serviceStatePath()); + if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true }); + writeFileSync(unitPath(), buildUnit(state), "utf8"); + sh("systemctl --user daemon-reload"); + sh(`systemctl --user enable ${TASK}`); + sh(`systemctl --user restart ${TASK}`); + writeServiceInstallState(); + }, { + serviceAssetPaths: [unitPath(), ...serviceStatePaths()], + restoreServiceDefinition: () => { + sh("systemctl --user daemon-reload"); + if (!hadDefinition) { + try { sh(`systemctl --user disable --now ${TASK}`); } catch { /* absent */ } + return; + } + if (wasEnabled) sh(`systemctl --user enable ${TASK}`); + else try { sh(`systemctl --user disable ${TASK}`); } catch { /* already disabled */ } + if (wasActive) sh(`systemctl --user restart ${TASK}`); + else try { sh(`systemctl --user stop ${TASK}`); } catch { /* already stopped */ } + }, + }); } function startSystemd(): void { ensureUserBusEnv(); @@ -1927,8 +2574,18 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise 0) { + console.error( + `❌ service manager removed, but credential cleanup failed for: ${residualTokens.join(", ")}. ` + + "Remove these files from OPENCODEX_HOME manually.", + ); + process.exitCode = 1; + } else { + console.log("✅ service uninstalled."); + } + } break; default: console.error("Usage: ocx service [install|repair|start|stop|status|uninstall|remove] [--native|--scheduler]"); diff --git a/src/update/npm-invocation.d.mts b/src/update/npm-invocation.d.mts index f7cf3e3a2..b53abf4a4 100644 --- a/src/update/npm-invocation.d.mts +++ b/src/update/npm-invocation.d.mts @@ -1,12 +1,16 @@ export interface NpmInvocationDeps { cwd?: string; exists?: (path: string) => boolean; + isExecutable?: (path: string) => boolean; } export interface NpmInvocation { file: string; args: string[]; - options: { windowsVerbatimArguments?: boolean }; + options: { + windowsVerbatimArguments?: boolean; + env?: NodeJS.ProcessEnv; + }; } export declare function resolveNpmCommand( diff --git a/src/update/npm-invocation.mjs b/src/update/npm-invocation.mjs index 38abb375f..cc8c1f697 100644 --- a/src/update/npm-invocation.mjs +++ b/src/update/npm-invocation.mjs @@ -1,7 +1,13 @@ -import { existsSync } from "node:fs"; -import { win32 } from "node:path"; +import { accessSync, constants, existsSync, statSync } from "node:fs"; +import { posix, win32 } from "node:path"; const CMD_META = /([()%!^"`<>&|;, *?])/g; +const NPM_CHILD_STRIPPED_ENV_KEYS = new Set([ + "OPENCODEX_ADMIN_AUTH_TOKEN", + "OCX_ADMIN_TOKEN_FILE", + "OPENCODEX_API_AUTH_TOKEN", + "OCX_API_TOKEN_FILE", +]); function escapeCmdArg(arg) { let out = String(arg).replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, "$1$1"); @@ -35,25 +41,75 @@ function cleanPathEntry(entry) { return trimmed; } +function safePosixPathEntries(env, cwd) { + const resolvedCwd = posix.resolve(cwd); + return (env.PATH ?? "") + .split(posix.delimiter) + .filter(entry => entry && posix.isAbsolute(entry) && posix.resolve(entry) !== resolvedCwd); +} + +function safeWindowsPathEntries(env, cwd) { + return (env.PATH ?? env.Path ?? "") + .split(win32.delimiter) + .map(cleanPathEntry) + .filter(entry => entry && win32.isAbsolute(entry) && !isCurrentDirectory(cwd, entry)); +} + +function sanitizedNpmChildEnv(platform, env, cwd) { + const childEnv = { ...env }; + for (const key of Object.keys(childEnv)) { + const admissionKey = platform === "win32" ? key.toUpperCase() : key; + if (NPM_CHILD_STRIPPED_ENV_KEYS.has(admissionKey)) delete childEnv[key]; + if (key.toLowerCase() === "path") delete childEnv[key]; + if (platform === "win32" && key.toLowerCase() === "nodefaultcurrentdirectoryinexepath") { + delete childEnv[key]; + } + } + childEnv.PATH = (platform === "win32" + ? safeWindowsPathEntries(env, cwd) + : safePosixPathEntries(env, cwd)).join(platform === "win32" ? win32.delimiter : posix.delimiter); + if (platform === "win32") { + // npm.cmd shims can fall back to a bare `node` command. Prevent cmd.exe from + // searching the launch directory before the sanitized absolute PATH entries. + childEnv.NoDefaultCurrentDirectoryInExePath = "1"; + } + return childEnv; +} + +function resolvePosixNpmCommand(env, deps) { + const isExecutable = deps.isExecutable ?? (candidate => { + try { + if (!statSync(candidate).isFile()) return false; + accessSync(candidate, constants.X_OK); + return true; + } catch { + return false; + } + }); + const cwd = deps.cwd ?? process.cwd(); + const pathEntries = safePosixPathEntries(env, cwd); + + for (const entry of pathEntries) { + const candidate = posix.join(entry, "npm"); + if (isExecutable(candidate)) return posix.resolve(candidate); + } + return null; +} + export function resolveNpmCommand( platform = process.platform, env = process.env, deps = {}, ) { - if (platform !== "win32") return "npm"; + if (platform !== "win32") return resolvePosixNpmCommand(env, deps); const exists = deps.exists ?? existsSync; const cwd = deps.cwd ?? process.cwd(); const extensions = (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD") .split(";") .filter(Boolean); - const pathEntries = (env.PATH ?? env.Path ?? "") - .split(win32.delimiter) - .map(cleanPathEntry) - .filter(Boolean); + const pathEntries = safeWindowsPathEntries(env, cwd); for (const entry of pathEntries) { - if (!win32.isAbsolute(entry)) continue; - if (isCurrentDirectory(cwd, entry)) continue; for (const extension of extensions) { const candidate = win32.join(entry, `npm${extension.toLowerCase()}`); if (exists(candidate)) return win32.resolve(candidate); @@ -79,8 +135,9 @@ export function npmInvocation( ) { const npm = resolveNpmCommand(platform, env, deps); if (!npm) return null; + const childEnv = sanitizedNpmChildEnv(platform, env, deps.cwd ?? process.cwd()); if (platform !== "win32" || !/\.(cmd|bat)$/i.test(npm)) { - return { file: npm, args: [...args], options: {} }; + return { file: npm, args: [...args], options: { env: childEnv } }; } const commandProcessor = systemCommandProcessor(env); @@ -89,6 +146,6 @@ export function npmInvocation( return { file: commandProcessor, args: ["/d", "/s", "/c", `"${line}"`], - options: { windowsVerbatimArguments: true }, + options: { windowsVerbatimArguments: true, env: childEnv }, }; } diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index fec2c1380..c47b5128b 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -4,8 +4,11 @@ Provider connection tests and live model discovery share the GET-only provider outbound wrapper. Direct HTTP(S) resolves once and pins the validated address; HTTPS preserves the original Host/SNI -and always verifies certificates. Proxy-configured requests stay on Bun fetch so HTTP(S)_PROXY, -ALL_PROXY, and NO_PROXY semantics remain authoritative. The wrapper classifies successful local DNS answers, but +and always verifies certificates. Proxy-configured requests stay on Bun fetch so its protocol-specific +HTTP_PROXY / HTTPS_PROXY and NO_PROXY casing precedence remains authoritative. The bundled Bun 1.3.14 +does not route through ALL_PROXY; when it is the only effective proxy declaration for the URL protocol, +connection tests and model discovery fail closed and name the corresponding protocol variable to set. +The wrapper classifies successful local DNS answers, but only a typed DNS-resolution failure degrades to proxy resolution; every literal, metadata, and resolved-address policy error still rejects. Proxy mode logs once that the proxy-selected peer cannot be pinned. Private destinations additionally require allowPrivateNetwork plus NO_PROXY. diff --git a/tests/anthropic-account-pool.test.ts b/tests/anthropic-account-pool.test.ts index c13f59bd1..1b93ed320 100644 --- a/tests/anthropic-account-pool.test.ts +++ b/tests/anthropic-account-pool.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, setDefaultTimeout, test } from "bun:test"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -18,6 +18,8 @@ import { getAccountSet, saveCredential, setActiveAccount } from "../src/oauth/st import { clearAccountQuotaCache, setCachedProviderAccountQuotaForTests } from "../src/providers/quota"; import type { OcxAccountPoolRotationStrategy, OcxConfig } from "../src/types"; +setDefaultTimeout(process.platform === "win32" ? 15_000 : 5_000); + const originalHome = process.env.OPENCODEX_HOME; let home: string; @@ -378,5 +380,5 @@ describe("anthropic account pool", () => { await setActiveAccount("anthropic", cId); resetAnthropicRoutingForManualSelection(cId); expect(resolveAnthropicAccountForSession("seed-3", config).accountId).toBe(cId); - }); + }, 15_000); }); diff --git a/tests/ci-workflows.test.ts b/tests/ci-workflows.test.ts index f5b6374d6..33d8036d3 100644 --- a/tests/ci-workflows.test.ts +++ b/tests/ci-workflows.test.ts @@ -30,13 +30,20 @@ function count(text: string, fragment: string): number { describe("GitHub Actions hardening", () => { test("cross-platform CI keeps bounded jobs and immutable action references", async () => { const workflow = await readText(".github/workflows/ci.yml"); + const parsed = Bun.YAML.parse(workflow) as { + jobs?: Record } }; + }>; + }; - // The cross-platform `test` job sits at 20 minutes: a green Windows run measured - // 11.8 min against 4.6 on Linux, and the previous 12-minute ceiling left ~12s of - // margin, so runner variance rather than the code decided the verdict (#717). - // `npm-global-smoke` stays at 8; it finishes in 1-2 minutes. - expect(count(workflow, "timeout-minutes: 20")).toBe(1); - expect(count(workflow, "timeout-minutes: 8")).toBe(1); + expect(parsed.jobs?.test?.["timeout-minutes"]).toBe("${{ matrix.timeout }}"); + expect(parsed.jobs?.test?.strategy?.matrix?.include).toEqual([ + { os: "ubuntu-latest", timeout: 12 }, + { os: "macos-latest", timeout: 12 }, + { os: "windows-latest", timeout: 20 }, + ]); + expect(parsed.jobs?.["npm-global-smoke"]?.["timeout-minutes"]).toBe(8); // Both jobs must stay bounded — an unbounded job can hang a queue for hours. expect(count(workflow, "timeout-minutes:")).toBe(2); expect(workflow).toContain("actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0"); diff --git a/tests/claude-outbound.test.ts b/tests/claude-outbound.test.ts index 4e370e9d6..1f645f5fd 100644 --- a/tests/claude-outbound.test.ts +++ b/tests/claude-outbound.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, jest, test } from "bun:test"; import { anthropicErrorBody, anthropicErrorType, @@ -341,20 +341,27 @@ describe("claude outbound SSE", () => { }); test("idle keepalive pings flow during upstream silence", async () => { - // Upstream: created frame, 90ms of silence, then a clean completion. - const encoder = new TextEncoder(); - const upstream = new ReadableStream({ - async start(controller) { - controller.enqueue(encoder.encode(sse("response.created", { response: {} }))); - await new Promise(r => setTimeout(r, 90)); - controller.enqueue(encoder.encode(sse("response.completed", { response: { status: "completed", usage: { input_tokens: 1, output_tokens: 1 } } }))); - controller.close(); - }, - }); - const events = await collectEvents(responsesSseToAnthropicSse(upstream, "m", { pingIntervalMs: 25 })); - const pings = events.filter(e => e.name === "ping").length; - expect(pings).toBeGreaterThanOrEqual(3); // startup ping + >=2 idle pings - expect(events.at(-1)!.name).toBe("message_stop"); + jest.useFakeTimers(); + try { + // Upstream: created frame, 90ms of silence, then a clean completion. + const encoder = new TextEncoder(); + const upstream = new ReadableStream({ + async start(controller) { + controller.enqueue(encoder.encode(sse("response.created", { response: {} }))); + await new Promise(r => setTimeout(r, 90)); + controller.enqueue(encoder.encode(sse("response.completed", { response: { status: "completed", usage: { input_tokens: 1, output_tokens: 1 } } }))); + controller.close(); + }, + }); + const eventsPromise = collectEvents(responsesSseToAnthropicSse(upstream, "m", { pingIntervalMs: 25 })); + jest.advanceTimersByTime(90); + const events = await eventsPromise; + const pings = events.filter(e => e.name === "ping").length; + expect(pings).toBeGreaterThanOrEqual(3); // startup ping + >=2 idle pings + expect(events.at(-1)!.name).toBe("message_stop"); + } finally { + jest.useRealTimers(); + } }); test("no-output completed still emits a valid empty message", async () => { diff --git a/tests/cli-restore-back.test.ts b/tests/cli-restore-back.test.ts index 42a2fc2f6..fbd375fec 100644 --- a/tests/cli-restore-back.test.ts +++ b/tests/cli-restore-back.test.ts @@ -66,7 +66,7 @@ describe("ocx restore back", () => { rmSync(codexHome, { recursive: true, force: true }); rmSync(ocxHome, { recursive: true, force: true }); } - }); + }, 15_000); test("help documents both directions of the switch", () => { expect(helpSource).toContain("ocx restore [back]"); diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index 0b7fa9654..ea5ed7f48 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -2321,7 +2321,8 @@ describe("codex-auth API", () => { test("OAuth pool login waits for the current flow to finish, not stale credentials", async () => { const source = await Bun.file("src/codex/auth-api.ts").text(); - expect(source).toContain("st.done && st.loggedIn"); + expect(source).toContain('codexAuthLoginState.get(flowId)?.status !== "pending"'); + expect(source).toContain("st?.done && st.loggedIn"); expect(source).toContain("Login timed out before OAuth completed."); }); diff --git a/tests/config-ownership-uninstall.test.ts b/tests/config-ownership-uninstall.test.ts index dca1ca677..d048840e0 100644 --- a/tests/config-ownership-uninstall.test.ts +++ b/tests/config-ownership-uninstall.test.ts @@ -1,16 +1,38 @@ import { describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { spawn, spawnSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + symlinkSync, + utimesSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { CONFIG_OWNER_FILE, CONFIG_UNINSTALL_MANIFEST, + isOwnershipInfrastructureName, recordOwnedConfigPath, removeOwnedConfigState, } from "../src/lib/config-ownership"; import { getDefaultConfig, saveConfig } from "../src/config"; describe("owned config uninstall", () => { + test("uses one rule for every ownership infrastructure filename", () => { + expect(isOwnershipInfrastructureName(".opencodex-owner.lock")).toBe(true); + expect(isOwnershipInfrastructureName(".opencodex-owner-recovery.lock")).toBe(true); + expect(isOwnershipInfrastructureName(".opencodex-owner-recovery.lock.claim-successor")).toBe(true); + expect(isOwnershipInfrastructureName( + ".opencodex-owner-lock-publish-99999999-00000000-0000-4000-8000-000000000012.tmp", + )).toBe(true); + expect(isOwnershipInfrastructureName("config.json")).toBe(false); + }); + test("first owned write creates a missing config root and its metadata", () => { const parent = mkdtempSync(join(tmpdir(), "ocx-config-first-owned-path-")); const dir = join(parent, "config"); @@ -24,6 +46,1048 @@ describe("owned config uninstall", () => { } }); + test("preserves ownership paths registered by another process", () => { + const parent = mkdtempSync(join(tmpdir(), "ocx-config-cross-process-")); + const dir = join(parent, "config"); + const firstPath = join(dir, "first.json"); + const childPath = join(dir, "child.json"); + const lastPath = join(dir, "last.json"); + const ownershipModule = new URL("../src/lib/config-ownership.ts", import.meta.url).href; + + try { + expect(recordOwnedConfigPath(dir, firstPath)).toBe(true); + const child = spawnSync(process.execPath, [ + "-e", + `import { recordOwnedConfigPath } from ${JSON.stringify(ownershipModule)}; + if (!recordOwnedConfigPath(process.env.OCX_TEST_CONFIG_DIR, process.env.OCX_TEST_OWNED_PATH)) process.exit(42);`, + ], { + encoding: "utf8", + env: { + ...process.env, + OCX_TEST_CONFIG_DIR: dir, + OCX_TEST_OWNED_PATH: childPath, + }, + }); + expect(child.status, child.stderr || child.stdout).toBe(0); + expect(recordOwnedConfigPath(dir, lastPath)).toBe(true); + + const manifest = JSON.parse( + readFileSync(join(dir, CONFIG_UNINSTALL_MANIFEST), "utf8"), + ) as { paths: string[] }; + expect(manifest.paths).toEqual(expect.arrayContaining([ + "first.json", + "child.json", + "last.json", + ])); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }); + + test("refuses registration without corrupting a full ownership manifest", () => { + const parent = mkdtempSync(join(tmpdir(), "ocx-config-manifest-limit-")); + const dir = join(parent, "config"); + const firstPath = join(dir, "limit-0.json"); + + try { + expect(recordOwnedConfigPath(dir, firstPath)).toBe(true); + const manifestPath = join(dir, CONFIG_UNINSTALL_MANIFEST); + const initial = JSON.parse(readFileSync(manifestPath, "utf8")) as { paths: string[] }; + const fullManifest = { + ...initial, + paths: [ + ...initial.paths, + ...Array.from( + { length: 1024 - initial.paths.length }, + (_, index) => `limit-${index + 1}.json`, + ), + ].sort(), + }; + writeFileSync(manifestPath, `${JSON.stringify(fullManifest, null, 2)}\n`); + + expect(recordOwnedConfigPath(dir, join(dir, "over-limit.json"))).toBe(false); + const persisted = JSON.parse(readFileSync(manifestPath, "utf8")) as { paths: string[] }; + expect(persisted.paths).toHaveLength(1024); + expect(persisted.paths).not.toContain("over-limit.json"); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }); + + test("serializes simultaneous ownership registrations across processes", async () => { + const parent = mkdtempSync(join(tmpdir(), "ocx-config-simultaneous-")); + const dir = join(parent, "config"); + const ownershipModule = new URL("../src/lib/config-ownership.ts", import.meta.url).href; + const childPaths = Array.from({ length: 8 }, (_, index) => join(dir, `child-${index}.json`)); + const startAt = Date.now() + 1_000; + + try { + expect(recordOwnedConfigPath(dir, join(dir, "seed.json"))).toBe(true); + const results = await Promise.all(childPaths.map(childPath => new Promise<{ + code: number | null; + stderr: string; + }>((resolveChild, rejectChild) => { + const child = spawn(process.execPath, [ + "-e", + `import { recordOwnedConfigPath } from ${JSON.stringify(ownershipModule)}; + const delay = Number(process.env.OCX_TEST_START_AT) - Date.now(); + if (delay > 0) await new Promise(resolve => setTimeout(resolve, delay)); + if (!recordOwnedConfigPath(process.env.OCX_TEST_CONFIG_DIR, process.env.OCX_TEST_OWNED_PATH)) process.exit(42);`, + ], { + env: { + ...process.env, + OCX_TEST_CONFIG_DIR: dir, + OCX_TEST_OWNED_PATH: childPath, + OCX_TEST_START_AT: String(startAt), + }, + stdio: ["ignore", "ignore", "pipe"], + }); + let stderr = ""; + child.stderr?.setEncoding("utf8"); + child.stderr?.on("data", chunk => { stderr += String(chunk); }); + child.once("error", rejectChild); + child.once("exit", code => resolveChild({ code, stderr })); + }))); + + for (const result of results) expect(result.code, result.stderr).toBe(0); + const manifest = JSON.parse( + readFileSync(join(dir, CONFIG_UNINSTALL_MANIFEST), "utf8"), + ) as { paths: string[] }; + for (const childPath of childPaths) { + expect(manifest.paths).toContain(childPath.slice(dir.length + 1).replaceAll("\\", "/")); + } + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }, 10_000); + + test("serializes simultaneous recovery of a stale ownership lock", async () => { + const parent = mkdtempSync(join(tmpdir(), "ocx-config-stale-lock-race-")); + const dir = join(parent, "config"); + const ownershipModule = new URL("../src/lib/config-ownership.ts", import.meta.url).href; + const childPaths = Array.from({ length: 24 }, (_, index) => join(dir, `stale-${index}.json`)); + const startAt = Date.now() + 1_000; + + try { + expect(recordOwnedConfigPath(dir, join(dir, "seed.json"))).toBe(true); + const lockPath = join(dir, ".opencodex-owner.lock"); + writeFileSync(lockPath, "99999999:00000000-0000-4000-8000-000000000000\n", { flag: "wx" }); + const old = new Date(Date.now() - 60_000); + utimesSync(lockPath, old, old); + + const results = await Promise.all(childPaths.map(childPath => new Promise<{ + code: number | null; + stderr: string; + }>((resolveChild, rejectChild) => { + const child = spawn(process.execPath, [ + "-e", + `import { recordOwnedConfigPath } from ${JSON.stringify(ownershipModule)}; + const delay = Number(process.env.OCX_TEST_START_AT) - Date.now(); + if (delay > 0) await new Promise(resolve => setTimeout(resolve, delay)); + if (!recordOwnedConfigPath( + process.env.OCX_TEST_CONFIG_DIR, + process.env.OCX_TEST_OWNED_PATH, + { lockTimeoutMs: 20_000 }, + )) process.exit(42);`, + ], { + env: { + ...process.env, + OCX_TEST_CONFIG_DIR: dir, + OCX_TEST_OWNED_PATH: childPath, + OCX_TEST_START_AT: String(startAt), + }, + stdio: ["ignore", "ignore", "pipe"], + }); + let stderr = ""; + child.stderr?.setEncoding("utf8"); + child.stderr?.on("data", chunk => { stderr += String(chunk); }); + child.once("error", rejectChild); + child.once("exit", code => resolveChild({ code, stderr })); + }))); + + for (const result of results) expect(result.code, result.stderr).toBe(0); + const manifest = JSON.parse( + readFileSync(join(dir, CONFIG_UNINSTALL_MANIFEST), "utf8"), + ) as { paths: string[] }; + for (const childPath of childPaths) { + expect(manifest.paths).toContain(childPath.slice(dir.length + 1).replaceAll("\\", "/")); + } + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }, 30_000); + + test("recovers stale main and recovery locks left by dead processes", () => { + const parent = mkdtempSync(join(tmpdir(), "ocx-config-stale-recovery-lock-")); + const dir = join(parent, "config"); + const nextPath = join(dir, "next.json"); + const mainLockPath = join(dir, ".opencodex-owner.lock"); + const recoveryLockPath = join(dir, ".opencodex-owner-recovery.lock"); + + try { + expect(recordOwnedConfigPath(dir, join(dir, "seed.json"))).toBe(true); + writeFileSync(mainLockPath, "99999999:00000000-0000-4000-8000-000000000001\n", { flag: "wx" }); + writeFileSync(recoveryLockPath, "99999999:00000000-0000-4000-8000-000000000002\n", { flag: "wx" }); + const old = new Date(Date.now() - 60_000); + utimesSync(mainLockPath, old, old); + utimesSync(recoveryLockPath, old, old); + + expect(recordOwnedConfigPath(dir, nextPath)).toBe(true); + const manifest = JSON.parse( + readFileSync(join(dir, CONFIG_UNINSTALL_MANIFEST), "utf8"), + ) as { paths: string[] }; + expect(manifest.paths).toContain("next.json"); + expect(existsSync(recoveryLockPath)).toBe(false); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }, 10_000); + + test("does not bypass or delete a live recovery successor claim", () => { + const parent = mkdtempSync(join(tmpdir(), "ocx-config-live-recovery-claim-")); + const dir = join(parent, "config"); + const nextPath = join(dir, "next.json"); + const mainLockPath = join(dir, ".opencodex-owner.lock"); + const recoveryClaimPath = join( + dir, + ".opencodex-owner-recovery.lock.claim-live-successor", + ); + + try { + expect(recordOwnedConfigPath(dir, join(dir, "seed.json"))).toBe(true); + const mainToken = "99999999:00000000-0000-4000-8000-000000000003\n"; + const claimToken = `${process.pid}:00000000-0000-4000-8000-000000000004\n`; + writeFileSync(mainLockPath, mainToken, { flag: "wx" }); + writeFileSync(recoveryClaimPath, claimToken, { flag: "wx" }); + const old = new Date(Date.now() - 60_000); + utimesSync(mainLockPath, old, old); + utimesSync(recoveryClaimPath, old, old); + + expect(recordOwnedConfigPath(dir, nextPath, { lockTimeoutMs: 25 })).toBe(false); + expect(readFileSync(mainLockPath, "utf8")).toBe(mainToken); + expect(readFileSync(recoveryClaimPath, "utf8")).toBe(claimToken); + const manifest = JSON.parse( + readFileSync(join(dir, CONFIG_UNINSTALL_MANIFEST), "utf8"), + ) as { paths: string[] }; + expect(manifest.paths).not.toContain("next.json"); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }, 10_000); + + test("does not delete a live fixed recovery lock", () => { + const parent = mkdtempSync(join(tmpdir(), "ocx-config-live-recovery-lock-")); + const dir = join(parent, "config"); + const nextPath = join(dir, "next.json"); + const mainLockPath = join(dir, ".opencodex-owner.lock"); + const recoveryLockPath = join(dir, ".opencodex-owner-recovery.lock"); + + try { + expect(recordOwnedConfigPath(dir, join(dir, "seed.json"))).toBe(true); + const mainToken = "99999999:00000000-0000-4000-8000-000000000005\n"; + const recoveryToken = `${process.pid}:00000000-0000-4000-8000-000000000006\n`; + writeFileSync(mainLockPath, mainToken, { flag: "wx" }); + writeFileSync(recoveryLockPath, recoveryToken, { flag: "wx" }); + const old = new Date(Date.now() - 60_000); + utimesSync(mainLockPath, old, old); + utimesSync(recoveryLockPath, old, old); + + expect(recordOwnedConfigPath(dir, nextPath, { lockTimeoutMs: 25 })).toBe(false); + expect(readFileSync(mainLockPath, "utf8")).toBe(mainToken); + expect(readFileSync(recoveryLockPath, "utf8")).toBe(recoveryToken); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }, 10_000); + + test("does not reclaim an old lock held by a live process", () => { + const parent = mkdtempSync(join(tmpdir(), "ocx-config-live-lock-")); + const dir = join(parent, "config"); + const nextPath = join(dir, "next.json"); + const lockPath = join(dir, ".opencodex-owner.lock"); + + try { + expect(recordOwnedConfigPath(dir, join(dir, "seed.json"))).toBe(true); + const lockToken = `${process.pid}:00000000-0000-4000-8000-000000000000\n`; + writeFileSync(lockPath, lockToken, { flag: "wx" }); + const old = new Date(Date.now() - 60_000); + utimesSync(lockPath, old, old); + + expect(recordOwnedConfigPath(dir, nextPath, { lockTimeoutMs: 25 })).toBe(false); + expect(readFileSync(lockPath, "utf8")).toBe(lockToken); + const manifest = JSON.parse( + readFileSync(join(dir, CONFIG_UNINSTALL_MANIFEST), "utf8"), + ) as { paths: string[] }; + expect(manifest.paths).not.toContain("next.json"); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }, 10_000); + + test("refuses uninstall while the ownership registration lock is held", () => { + const parent = mkdtempSync(join(tmpdir(), "ocx-config-locked-uninstall-")); + const dir = join(parent, "config"); + const ownedPath = join(dir, "config.json"); + const lockPath = join(dir, ".opencodex-owner.lock"); + + try { + expect(recordOwnedConfigPath(dir, ownedPath)).toBe(true); + writeFileSync(ownedPath, '{"owned":true}\n'); + const lockToken = `${process.pid}:00000000-0000-4000-8000-000000000000\n`; + writeFileSync(lockPath, lockToken, { flag: "wx" }); + + const result = removeOwnedConfigState(dir, { lockTimeoutMs: 25 }); + expect(result.status).toBe("refused"); + expect(readFileSync(ownedPath, "utf8")).toBe('{"owned":true}\n'); + expect(existsSync(join(dir, CONFIG_OWNER_FILE))).toBe(true); + expect(existsSync(join(dir, CONFIG_UNINSTALL_MANIFEST))).toBe(true); + expect(readFileSync(lockPath, "utf8")).toBe(lockToken); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }, 10_000); + + test("does not expose or reclaim a lock holder paused before atomic publication", async () => { + const parent = mkdtempSync(join(tmpdir(), "ocx-config-prepublish-pause-")); + const dir = join(parent, "config"); + const ownedPath = join(dir, "config.json"); + const lockPath = join(dir, ".opencodex-owner.lock"); + const publisherReadyPath = join(parent, "publisher-ready"); + const publisherReleasePath = join(parent, "publisher-release"); + const ownershipModule = new URL("../src/lib/config-ownership.ts", import.meta.url).href; + mkdirSync(dir); + + const child = spawn(process.execPath, [ + "-e", + `import { mock } from "bun:test"; + const actualFs = await import("node:fs"); + const actualLinkSync = actualFs.linkSync; + mock.module("node:fs", () => ({ + ...actualFs, + linkSync(source, target) { + actualFs.writeFileSync(process.env.OCX_TEST_PUBLISH_READY, "ready"); + const wait = new Int32Array(new SharedArrayBuffer(4)); + const deadline = Date.now() + 3000; + while (!actualFs.existsSync(process.env.OCX_TEST_PUBLISH_RELEASE) && Date.now() < deadline) { + Atomics.wait(wait, 0, 0, 1); + } + if (!actualFs.existsSync(process.env.OCX_TEST_PUBLISH_RELEASE)) process.exit(43); + return actualLinkSync(source, target); + }, + })); + const { recordOwnedConfigPath } = await import(${JSON.stringify(ownershipModule)}); + if (recordOwnedConfigPath(process.env.OCX_TEST_CONFIG_DIR, process.env.OCX_TEST_OWNED_PATH)) { + process.exit(42); + } + process.exit(0);`, + ], { + env: { + ...process.env, + OCX_TEST_CONFIG_DIR: dir, + OCX_TEST_OWNED_PATH: ownedPath, + OCX_TEST_PUBLISH_READY: publisherReadyPath, + OCX_TEST_PUBLISH_RELEASE: publisherReleasePath, + }, + stdio: ["ignore", "ignore", "pipe"], + }); + let stderr = ""; + child.stderr?.setEncoding("utf8"); + child.stderr?.on("data", chunk => { stderr += String(chunk); }); + + try { + const readyDeadline = Date.now() + 2_000; + while (!existsSync(publisherReadyPath) && Date.now() < readyDeadline) await Bun.sleep(1); + expect(existsSync(publisherReadyPath), stderr).toBe(true); + expect(existsSync(lockPath)).toBe(false); + const publishTemps = readdirSync(dir) + .filter(name => name.startsWith(".opencodex-owner-lock-publish-")); + expect(publishTemps).toHaveLength(1); + expect(readFileSync(join(dir, publishTemps[0]!), "utf8")) + .toMatch(/^[1-9]\d*:[0-9a-f-]+\n$/i); + + const competingToken = `${process.pid}:00000000-0000-4000-8000-000000000010\n`; + writeFileSync(lockPath, competingToken, { flag: "wx" }); + const old = new Date(Date.now() - 60_000); + utimesSync(lockPath, old, old); + writeFileSync(publisherReleasePath, "release"); + + const exitCode = await new Promise((resolveChild, rejectChild) => { + child.once("error", rejectChild); + child.once("exit", resolveChild); + }); + expect(exitCode, stderr).toBe(0); + expect(readFileSync(lockPath, "utf8")).toBe(competingToken); + expect(existsSync(join(dir, CONFIG_OWNER_FILE))).toBe(false); + expect(readdirSync(dir).some( + name => name.startsWith(".opencodex-owner-lock-publish-"), + )).toBe(false); + } finally { + child.kill(); + rmSync(parent, { recursive: true, force: true }); + } + }, 10_000); + + test("fails closed when atomic hard-link publication is unavailable", async () => { + const parent = mkdtempSync(join(tmpdir(), "ocx-config-link-failure-")); + const dir = join(parent, "config"); + const ownedPath = join(dir, "config.json"); + const lockPath = join(dir, ".opencodex-owner.lock"); + const ownershipModule = new URL("../src/lib/config-ownership.ts", import.meta.url).href; + mkdirSync(dir); + + const child = spawn(process.execPath, [ + "-e", + `import { mock } from "bun:test"; + const actualFs = await import("node:fs"); + mock.module("node:fs", () => ({ + ...actualFs, + linkSync() { + const error = new Error("hard links unavailable"); + error.code = "EPERM"; + throw error; + }, + })); + const { recordOwnedConfigPath } = await import(${JSON.stringify(ownershipModule)}); + process.exit(recordOwnedConfigPath( + process.env.OCX_TEST_CONFIG_DIR, + process.env.OCX_TEST_OWNED_PATH, + ) ? 42 : 0);`, + ], { + env: { + ...process.env, + OCX_TEST_CONFIG_DIR: dir, + OCX_TEST_OWNED_PATH: ownedPath, + }, + stdio: ["ignore", "ignore", "pipe"], + }); + let stderr = ""; + child.stderr?.setEncoding("utf8"); + child.stderr?.on("data", chunk => { stderr += String(chunk); }); + + try { + const exitCode = await new Promise((resolveChild, rejectChild) => { + child.once("error", rejectChild); + child.once("exit", resolveChild); + }); + expect(exitCode, stderr).toBe(0); + expect(existsSync(lockPath)).toBe(false); + expect(existsSync(join(dir, CONFIG_OWNER_FILE))).toBe(false); + expect(readdirSync(dir).some( + name => name.startsWith(".opencodex-owner-lock-publish-"), + )).toBe(false); + } finally { + child.kill(); + rmSync(parent, { recursive: true, force: true }); + } + }, 10_000); + + test("rejects a source replacement injected immediately before publication", async () => { + const parent = mkdtempSync(join(tmpdir(), "ocx-config-publish-source-replaced-")); + const dir = join(parent, "config"); + const ownedPath = join(dir, "config.json"); + const lockPath = join(dir, ".opencodex-owner.lock"); + const attackerSourcePath = join(parent, "attacker-source"); + const attackerToken = `${process.pid}:00000000-0000-4000-8000-000000000014\n`; + const ownershipModule = new URL("../src/lib/config-ownership.ts", import.meta.url).href; + mkdirSync(dir); + + const child = spawn(process.execPath, [ + "-e", + `import { mock } from "bun:test"; + const actualFs = await import("node:fs"); + const actualLinkSync = actualFs.linkSync; + mock.module("node:fs", () => ({ + ...actualFs, + linkSync(source, target) { + actualFs.writeFileSync( + process.env.OCX_TEST_ATTACKER_SOURCE, + process.env.OCX_TEST_ATTACKER_TOKEN, + { flag: "wx" }, + ); + return actualLinkSync(process.env.OCX_TEST_ATTACKER_SOURCE, target); + }, + })); + const { recordOwnedConfigPath } = await import(${JSON.stringify(ownershipModule)}); + process.exit(recordOwnedConfigPath( + process.env.OCX_TEST_CONFIG_DIR, + process.env.OCX_TEST_OWNED_PATH, + ) ? 42 : 0);`, + ], { + env: { + ...process.env, + OCX_TEST_CONFIG_DIR: dir, + OCX_TEST_OWNED_PATH: ownedPath, + OCX_TEST_ATTACKER_SOURCE: attackerSourcePath, + OCX_TEST_ATTACKER_TOKEN: attackerToken, + }, + stdio: ["ignore", "ignore", "pipe"], + }); + let stderr = ""; + child.stderr?.setEncoding("utf8"); + child.stderr?.on("data", chunk => { stderr += String(chunk); }); + + try { + const exitCode = await new Promise((resolveChild, rejectChild) => { + child.once("error", rejectChild); + child.once("exit", resolveChild); + }); + expect(exitCode, stderr).toBe(0); + expect(readFileSync(lockPath, "utf8")).toBe(attackerToken); + expect(existsSync(join(dir, CONFIG_OWNER_FILE))).toBe(false); + } finally { + child.kill(); + rmSync(parent, { recursive: true, force: true }); + } + }, 10_000); + + test("preserves a competing lock that replaces the fixed name after linking", async () => { + const parent = mkdtempSync(join(tmpdir(), "ocx-config-published-lock-replaced-")); + const dir = join(parent, "config"); + const ownedPath = join(dir, "config.json"); + const lockPath = join(dir, ".opencodex-owner.lock"); + const competingToken = `${process.pid}:00000000-0000-4000-8000-000000000015\n`; + const ownershipModule = new URL("../src/lib/config-ownership.ts", import.meta.url).href; + mkdirSync(dir); + + const child = spawn(process.execPath, [ + "-e", + `import { mock } from "bun:test"; + const actualFs = await import("node:fs"); + const actualLinkSync = actualFs.linkSync; + mock.module("node:fs", () => ({ + ...actualFs, + linkSync(source, target) { + actualLinkSync(source, target); + actualFs.unlinkSync(target); + actualFs.writeFileSync(target, process.env.OCX_TEST_COMPETING_TOKEN, { flag: "wx" }); + }, + })); + const { recordOwnedConfigPath } = await import(${JSON.stringify(ownershipModule)}); + process.exit(recordOwnedConfigPath( + process.env.OCX_TEST_CONFIG_DIR, + process.env.OCX_TEST_OWNED_PATH, + ) ? 42 : 0);`, + ], { + env: { + ...process.env, + OCX_TEST_CONFIG_DIR: dir, + OCX_TEST_OWNED_PATH: ownedPath, + OCX_TEST_COMPETING_TOKEN: competingToken, + }, + stdio: ["ignore", "ignore", "pipe"], + }); + let stderr = ""; + child.stderr?.setEncoding("utf8"); + child.stderr?.on("data", chunk => { stderr += String(chunk); }); + + try { + const exitCode = await new Promise((resolveChild, rejectChild) => { + child.once("error", rejectChild); + child.once("exit", resolveChild); + }); + expect(exitCode, stderr).toBe(0); + expect(readFileSync(lockPath, "utf8")).toBe(competingToken); + expect(existsSync(join(dir, CONFIG_OWNER_FILE))).toBe(false); + } finally { + child.kill(); + rmSync(parent, { recursive: true, force: true }); + } + }, 10_000); + + test("revalidates the open fixed lock after the final path identity check", async () => { + const parent = mkdtempSync(join(tmpdir(), "ocx-config-final-lock-revalidation-")); + const dir = join(parent, "config"); + const ownedPath = join(dir, "config.json"); + const lockPath = join(dir, ".opencodex-owner.lock"); + const injectionMarker = join(parent, "final-revalidation-injected"); + const ownershipModule = new URL("../src/lib/config-ownership.ts", import.meta.url).href; + mkdirSync(dir); + + const child = spawn(process.execPath, [ + "-e", + `import { mock } from "bun:test"; + const actualFs = await import("node:fs"); + const actualLstatSync = actualFs.lstatSync; + let fixedNameChecks = 0; + mock.module("node:fs", () => ({ + ...actualFs, + lstatSync(path, options) { + if (path === process.env.OCX_TEST_LOCK_PATH) { + fixedNameChecks += 1; + if (fixedNameChecks === 2) { + actualFs.writeFileSync(process.env.OCX_TEST_INJECTION_MARKER, "injected\\n"); + const token = actualFs.readFileSync(path, "utf8"); + const changed = token.slice(0, -2) + (token.at(-2) === "a" ? "b" : "a") + "\\n"; + actualFs.writeFileSync(path, changed); + } + } + return actualLstatSync(path, options); + }, + })); + const { recordOwnedConfigPath } = await import(${JSON.stringify(ownershipModule)}); + process.exit(recordOwnedConfigPath( + process.env.OCX_TEST_CONFIG_DIR, + process.env.OCX_TEST_OWNED_PATH, + ) ? 42 : 0);`, + ], { + env: { + ...process.env, + OCX_TEST_CONFIG_DIR: dir, + OCX_TEST_OWNED_PATH: ownedPath, + OCX_TEST_LOCK_PATH: lockPath, + OCX_TEST_INJECTION_MARKER: injectionMarker, + }, + stdio: ["ignore", "ignore", "pipe"], + }); + let stderr = ""; + child.stderr?.setEncoding("utf8"); + child.stderr?.on("data", chunk => { stderr += String(chunk); }); + + try { + const exitCode = await new Promise((resolveChild, rejectChild) => { + child.once("error", rejectChild); + child.once("exit", resolveChild); + }); + expect(exitCode, stderr).toBe(0); + expect(existsSync(injectionMarker)).toBe(true); + expect(existsSync(join(dir, CONFIG_OWNER_FILE))).toBe(false); + } finally { + child.kill(); + rmSync(parent, { recursive: true, force: true }); + } + }, 10_000); + + test("leaves a fail-closed fixed lock when its published token changes", async () => { + const parent = mkdtempSync(join(tmpdir(), "ocx-config-published-token-changed-")); + const dir = join(parent, "config"); + const ownedPath = join(dir, "config.json"); + const lockPath = join(dir, ".opencodex-owner.lock"); + const ownershipModule = new URL("../src/lib/config-ownership.ts", import.meta.url).href; + mkdirSync(dir); + + const child = spawn(process.execPath, [ + "-e", + `import { mock } from "bun:test"; + const actualFs = await import("node:fs"); + const actualLinkSync = actualFs.linkSync; + mock.module("node:fs", () => ({ + ...actualFs, + linkSync(source, target) { + actualLinkSync(source, target); + const token = actualFs.readFileSync(target, "utf8"); + const changed = token.slice(0, -2) + (token.at(-2) === "a" ? "b" : "a") + "\\n"; + actualFs.writeFileSync(target, changed); + }, + })); + const { recordOwnedConfigPath } = await import(${JSON.stringify(ownershipModule)}); + process.exit(recordOwnedConfigPath( + process.env.OCX_TEST_CONFIG_DIR, + process.env.OCX_TEST_OWNED_PATH, + ) ? 42 : 0);`, + ], { + env: { + ...process.env, + OCX_TEST_CONFIG_DIR: dir, + OCX_TEST_OWNED_PATH: ownedPath, + }, + stdio: ["ignore", "ignore", "pipe"], + }); + let stderr = ""; + child.stderr?.setEncoding("utf8"); + child.stderr?.on("data", chunk => { stderr += String(chunk); }); + + try { + const exitCode = await new Promise((resolveChild, rejectChild) => { + child.once("error", rejectChild); + child.once("exit", resolveChild); + }); + expect(exitCode, stderr).toBe(0); + expect(existsSync(lockPath)).toBe(true); + expect(readFileSync(lockPath, "utf8")).toMatch(/^[1-9]\d*:[0-9a-f-]+\n$/i); + expect(existsSync(join(dir, CONFIG_OWNER_FILE))).toBe(false); + expect(readdirSync(dir).some( + name => name.startsWith(".opencodex-owner-lock-publish-"), + )).toBe(false); + } finally { + child.kill(); + rmSync(parent, { recursive: true, force: true }); + } + }, 10_000); + + test("publishes a complete recovery lock through the same atomic path", async () => { + const parent = mkdtempSync(join(tmpdir(), "ocx-config-recovery-publish-")); + const dir = join(parent, "config"); + const nextPath = join(dir, "next.json"); + const mainLockPath = join(dir, ".opencodex-owner.lock"); + const recoveryObservationPath = join(parent, "recovery-observed"); + const ownershipModule = new URL("../src/lib/config-ownership.ts", import.meta.url).href; + + try { + expect(recordOwnedConfigPath(dir, join(dir, "seed.json"))).toBe(true); + writeFileSync( + mainLockPath, + "99999999:00000000-0000-4000-8000-000000000013\n", + { flag: "wx" }, + ); + const old = new Date(Date.now() - 60_000); + utimesSync(mainLockPath, old, old); + + const child = spawn(process.execPath, [ + "-e", + `import { mock } from "bun:test"; + const actualFs = await import("node:fs"); + const actualLinkSync = actualFs.linkSync; + mock.module("node:fs", () => ({ + ...actualFs, + linkSync(source, target) { + if (target.endsWith(".opencodex-owner-recovery.lock")) { + if (actualFs.existsSync(target)) process.exit(43); + const token = actualFs.readFileSync(source, "utf8"); + if (!/^[1-9]\\d*:[0-9a-f-]+\\n$/i.test(token)) process.exit(44); + actualFs.writeFileSync(process.env.OCX_TEST_RECOVERY_OBSERVED, token); + } + return actualLinkSync(source, target); + }, + })); + const { recordOwnedConfigPath } = await import(${JSON.stringify(ownershipModule)}); + process.exit(recordOwnedConfigPath( + process.env.OCX_TEST_CONFIG_DIR, + process.env.OCX_TEST_OWNED_PATH, + ) ? 0 : 42);`, + ], { + env: { + ...process.env, + OCX_TEST_CONFIG_DIR: dir, + OCX_TEST_OWNED_PATH: nextPath, + OCX_TEST_RECOVERY_OBSERVED: recoveryObservationPath, + }, + stdio: ["ignore", "ignore", "pipe"], + }); + let stderr = ""; + child.stderr?.setEncoding("utf8"); + child.stderr?.on("data", chunk => { stderr += String(chunk); }); + const exitCode = await new Promise((resolveChild, rejectChild) => { + child.once("error", rejectChild); + child.once("exit", resolveChild); + }); + + expect(exitCode, stderr).toBe(0); + expect(readFileSync(recoveryObservationPath, "utf8")) + .toMatch(/^[1-9]\d*:[0-9a-f-]+\n$/i); + const manifest = JSON.parse( + readFileSync(join(dir, CONFIG_UNINSTALL_MANIFEST), "utf8"), + ) as { paths: string[] }; + expect(manifest.paths).toContain("next.json"); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }, 10_000); + + test("ignores but never deletes a crash-left publication temp file", () => { + const parent = mkdtempSync(join(tmpdir(), "ocx-config-publish-temp-remnant-")); + const dir = join(parent, "config"); + const ownedPath = join(dir, "config.json"); + const tempPath = join( + dir, + ".opencodex-owner-lock-publish-99999999-00000000-0000-4000-8000-000000000012.tmp", + ); + mkdirSync(dir); + writeFileSync(tempPath, "incomplete pre-publication state"); + + try { + expect(recordOwnedConfigPath(dir, ownedPath)).toBe(true); + expect(existsSync(join(dir, CONFIG_OWNER_FILE))).toBe(true); + expect(readFileSync(tempPath, "utf8")).toBe("incomplete pre-publication state"); + + const result = removeOwnedConfigState(dir); + expect(result.status).toBe("partial"); + expect(result.residualPaths).toContain(dir); + expect(readFileSync(tempPath, "utf8")).toBe("incomplete pre-publication state"); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }); + + test("refuses a stale empty fixed lock because its owner cannot be proven dead", () => { + const parent = mkdtempSync(join(tmpdir(), "ocx-config-stale-empty-lock-")); + const dir = join(parent, "config"); + const ownedPath = join(dir, "config.json"); + const lockPath = join(dir, ".opencodex-owner.lock"); + mkdirSync(dir); + writeFileSync(lockPath, ""); + const old = new Date(Date.now() - 60_000); + utimesSync(lockPath, old, old); + + try { + expect(recordOwnedConfigPath(dir, ownedPath, { lockTimeoutMs: 25 })).toBe(false); + expect(existsSync(join(dir, CONFIG_OWNER_FILE))).toBe(false); + expect(existsSync(join(dir, CONFIG_UNINSTALL_MANIFEST))).toBe(false); + expect(existsSync(lockPath)).toBe(true); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }, 10_000); + + test("refuses a stale truncated recovery lock because its owner cannot be proven dead", () => { + const parent = mkdtempSync(join(tmpdir(), "ocx-config-stale-truncated-recovery-")); + const dir = join(parent, "config"); + const ownedPath = join(dir, "config.json"); + const recoveryLockPath = join(dir, ".opencodex-owner-recovery.lock"); + + try { + expect(recordOwnedConfigPath(dir, ownedPath)).toBe(true); + writeFileSync(ownedPath, '{"owned":true}\n'); + writeFileSync(recoveryLockPath, "99999999:00000000-0000-4"); + const old = new Date(Date.now() - 60_000); + utimesSync(recoveryLockPath, old, old); + + expect(removeOwnedConfigState(dir).status).toBe("refused"); + expect(readFileSync(recoveryLockPath, "utf8")).toBe("99999999:00000000-0000-4"); + expect(readFileSync(ownedPath, "utf8")).toBe('{"owned":true}\n'); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }); + + test("does not reclaim arbitrary stale recovery-lock content", () => { + const parent = mkdtempSync(join(tmpdir(), "ocx-config-stale-invalid-recovery-")); + const dir = join(parent, "config"); + const ownedPath = join(dir, "config.json"); + const recoveryLockPath = join(dir, ".opencodex-owner-recovery.lock"); + + try { + expect(recordOwnedConfigPath(dir, ownedPath)).toBe(true); + writeFileSync(ownedPath, '{"owned":true}\n'); + writeFileSync(recoveryLockPath, "not an opencodex lock\n"); + const old = new Date(Date.now() - 60_000); + utimesSync(recoveryLockPath, old, old); + + expect(removeOwnedConfigState(dir).status).toBe("refused"); + expect(readFileSync(recoveryLockPath, "utf8")).toBe("not an opencodex lock\n"); + expect(readFileSync(ownedPath, "utf8")).toBe('{"owned":true}\n'); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }); + + test("removes a stale fixed recovery lock before uninstalling owned state", () => { + const parent = mkdtempSync(join(tmpdir(), "ocx-config-stale-recovery-uninstall-")); + const dir = join(parent, "config"); + const ownedPath = join(dir, "config.json"); + const recoveryLockPath = join(dir, ".opencodex-owner-recovery.lock"); + + try { + expect(recordOwnedConfigPath(dir, ownedPath)).toBe(true); + writeFileSync(ownedPath, '{"owned":true}\n'); + writeFileSync(recoveryLockPath, "99999999:00000000-0000-4000-8000-000000000009\n"); + const old = new Date(Date.now() - 60_000); + utimesSync(recoveryLockPath, old, old); + + expect(removeOwnedConfigState(dir)).toEqual({ + status: "removed", + residualPaths: [], + }); + expect(existsSync(dir)).toBe(false); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }); + + test("removes a stale recovery claim before uninstalling owned state", () => { + const parent = mkdtempSync(join(tmpdir(), "ocx-config-stale-claim-uninstall-")); + const dir = join(parent, "config"); + const ownedPath = join(dir, "config.json"); + const recoveryClaimPath = join( + dir, + ".opencodex-owner-recovery.lock.claim-abandoned", + ); + + try { + expect(recordOwnedConfigPath(dir, ownedPath)).toBe(true); + writeFileSync(ownedPath, '{"owned":true}\n'); + writeFileSync(recoveryClaimPath, "99999999:00000000-0000-4000-8000-00000000000a\n"); + const old = new Date(Date.now() - 60_000); + utimesSync(recoveryClaimPath, old, old); + + expect(removeOwnedConfigState(dir)).toEqual({ + status: "removed", + residualPaths: [], + }); + expect(existsSync(dir)).toBe(false); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }); + + test("refuses a stale truncated recovery claim because its owner cannot be proven dead", () => { + const parent = mkdtempSync(join(tmpdir(), "ocx-config-stale-truncated-claim-")); + const dir = join(parent, "config"); + const ownedPath = join(dir, "config.json"); + const recoveryClaimPath = join( + dir, + ".opencodex-owner-recovery.lock.claim-abandoned-truncated", + ); + + try { + expect(recordOwnedConfigPath(dir, ownedPath)).toBe(true); + writeFileSync(ownedPath, '{"owned":true}\n'); + writeFileSync(recoveryClaimPath, "99999999:00000000-0000-4"); + const old = new Date(Date.now() - 60_000); + utimesSync(recoveryClaimPath, old, old); + + expect(removeOwnedConfigState(dir).status).toBe("refused"); + expect(readFileSync(recoveryClaimPath, "utf8")).toBe("99999999:00000000-0000-4"); + expect(readFileSync(ownedPath, "utf8")).toBe('{"owned":true}\n'); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }); + + test("refuses uninstall and preserves metadata while a recovery lock is active", () => { + const parent = mkdtempSync(join(tmpdir(), "ocx-config-live-recovery-uninstall-")); + const dir = join(parent, "config"); + const ownedPath = join(dir, "config.json"); + const recoveryLockPath = join(dir, ".opencodex-owner-recovery.lock"); + + try { + expect(recordOwnedConfigPath(dir, ownedPath)).toBe(true); + writeFileSync(ownedPath, '{"owned":true}\n'); + const recoveryToken = `${process.pid}:00000000-0000-4000-8000-00000000000b\n`; + writeFileSync(recoveryLockPath, recoveryToken, { flag: "wx" }); + const old = new Date(Date.now() - 60_000); + utimesSync(recoveryLockPath, old, old); + + const result = removeOwnedConfigState(dir, { lockTimeoutMs: 25 }); + expect(result.status).toBe("refused"); + expect(readFileSync(ownedPath, "utf8")).toBe('{"owned":true}\n'); + expect(existsSync(join(dir, CONFIG_OWNER_FILE))).toBe(true); + expect(existsSync(join(dir, CONFIG_UNINSTALL_MANIFEST))).toBe(true); + expect(readFileSync(recoveryLockPath, "utf8")).toBe(recoveryToken); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }); + + test("preserves a live recovery claim that replaces a verified stale claim", () => { + const parent = mkdtempSync(join(tmpdir(), "ocx-config-recovery-claim-swap-")); + const dir = join(parent, "config"); + const ownedPath = join(dir, "config.json"); + const claimPath = join( + dir, + ".opencodex-owner-recovery.lock.claim-race", + ); + const ownershipModule = new URL("../src/lib/config-ownership.ts", import.meta.url).href; + const staleToken = "99999999:00000000-0000-4000-8000-000000000013\n"; + const successorToken = `${process.pid}:00000000-0000-4000-8000-000000000014\n`; + + try { + expect(recordOwnedConfigPath(dir, ownedPath)).toBe(true); + writeFileSync(ownedPath, '{"owned":true}\n'); + writeFileSync(claimPath, staleToken); + const old = new Date(Date.now() - 60_000); + utimesSync(claimPath, old, old); + + const child = spawnSync(process.execPath, [ + "-e", + `import { mock } from "bun:test"; + const actualFs = await import("node:fs"); + const actualReadFileSync = actualFs.readFileSync; + let claimReads = 0; + mock.module("node:fs", () => ({ + ...actualFs, + readFileSync(path, options) { + const contents = actualReadFileSync(path, options); + if (path === process.env.OCX_TEST_CLAIM_PATH) { + claimReads += 1; + if (claimReads === 2) { + actualFs.unlinkSync(path); + actualFs.writeFileSync(path, process.env.OCX_TEST_SUCCESSOR_TOKEN); + } + } + return contents; + }, + })); + const { removeOwnedConfigState } = await import(${JSON.stringify(ownershipModule)}); + const result = removeOwnedConfigState(process.env.OCX_TEST_CONFIG_DIR); + if (result.status !== "refused") process.exit(42);`, + ], { + encoding: "utf8", + env: { + ...process.env, + OCX_TEST_CONFIG_DIR: dir, + OCX_TEST_CLAIM_PATH: claimPath, + OCX_TEST_SUCCESSOR_TOKEN: successorToken, + }, + }); + + expect(child.status, child.stderr || child.stdout).toBe(0); + expect(readFileSync(claimPath, "utf8")).toBe(successorToken); + expect(readFileSync(ownedPath, "utf8")).toBe('{"owned":true}\n'); + expect(existsSync(join(dir, CONFIG_OWNER_FILE))).toBe(true); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }); + + test("refreshes the ownership lease during a manifest removal walk", () => { + const parent = mkdtempSync(join(tmpdir(), "ocx-config-lock-heartbeat-")); + const dir = join(parent, "config"); + const ownedDir = join(dir, "artifacts"); + const markerPath = join(parent, "lease-refreshed"); + const ownershipModule = new URL("../src/lib/config-ownership.ts", import.meta.url).href; + + try { + const child = spawnSync(process.execPath, [ + "-e", + `import { mock } from "bun:test"; + const actualFs = await import("node:fs"); + const actualFutimesSync = actualFs.futimesSync; + mock.module("node:fs", () => ({ + ...actualFs, + futimesSync(descriptor, atime, mtime) { + actualFs.writeFileSync(process.env.OCX_TEST_HEARTBEAT_MARKER, "refreshed\\n"); + return actualFutimesSync(descriptor, atime, mtime); + }, + })); + const { recordOwnedConfigPath, removeOwnedConfigState } = await import(${JSON.stringify(ownershipModule)}); + if (!recordOwnedConfigPath(process.env.OCX_TEST_CONFIG_DIR, process.env.OCX_TEST_OWNED_DIR)) process.exit(41); + actualFs.mkdirSync(process.env.OCX_TEST_OWNED_DIR, { recursive: true }); + actualFs.writeFileSync(process.env.OCX_TEST_OWNED_FILE, "owned\\n"); + const result = removeOwnedConfigState(process.env.OCX_TEST_CONFIG_DIR, { leaseRefreshMs: 0 }); + if (result.status !== "removed") process.exit(42);`, + ], { + encoding: "utf8", + env: { + ...process.env, + OCX_TEST_CONFIG_DIR: dir, + OCX_TEST_OWNED_DIR: ownedDir, + OCX_TEST_OWNED_FILE: join(ownedDir, "entry.txt"), + OCX_TEST_HEARTBEAT_MARKER: markerPath, + }, + }); + + expect(child.status, child.stderr || child.stdout).toBe(0); + expect(existsSync(markerPath)).toBe(true); + expect(existsSync(dir)).toBe(false); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }); + + test("returns false instead of throwing when ownership storage is inaccessible", () => { + const parent = mkdtempSync(join(tmpdir(), "ocx-config-registration-error-")); + const blocker = join(parent, "not-a-directory"); + writeFileSync(blocker, "blocked\n"); + const dir = join(blocker, "config"); + + try { + expect(() => recordOwnedConfigPath(dir, join(dir, "config.json"))).not.toThrow(); + expect(recordOwnedConfigPath(dir, join(dir, "config.json"))).toBe(false); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }); + test("refuses a legacy config directory without ownership metadata", () => { const dir = mkdtempSync(join(tmpdir(), "ocx-uninstall-legacy-")); const configPath = join(dir, "config.json"); @@ -39,6 +1103,56 @@ describe("owned config uninstall", () => { } }); + test("does not clean a recovery lock from an unowned legacy directory", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-uninstall-legacy-recovery-")); + const recoveryLockPath = join(dir, ".opencodex-owner-recovery.lock"); + const recoveryToken = "99999999:00000000-0000-4000-8000-00000000000c\n"; + writeFileSync(recoveryLockPath, recoveryToken); + const old = new Date(Date.now() - 60_000); + utimesSync(recoveryLockPath, old, old); + + try { + expect(removeOwnedConfigState(dir).status).toBe("refused"); + expect(readFileSync(recoveryLockPath, "utf8")).toBe(recoveryToken); + expect(existsSync(join(dir, CONFIG_OWNER_FILE))).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("treats a recovery lock as ownership infrastructure rather than legacy content", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-config-legacy-recovery-lock-")); + const recoveryLock = join(dir, ".opencodex-owner-recovery.lock"); + writeFileSync(recoveryLock, "legacy content\n"); + + try { + expect(recordOwnedConfigPath(dir, join(dir, "config.json"))).toBe(true); + expect(existsSync(join(dir, CONFIG_OWNER_FILE))).toBe(true); + expect(readFileSync(recoveryLock, "utf8")).toBe("legacy content\n"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("claims an infrastructure-only directory after recovering abandoned locks", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-config-legacy-stale-locks-")); + const mainLockPath = join(dir, ".opencodex-owner.lock"); + const recoveryLockPath = join(dir, ".opencodex-owner-recovery.lock"); + writeFileSync(mainLockPath, "99999999:00000000-0000-4000-8000-000000000007\n"); + writeFileSync(recoveryLockPath, "99999999:00000000-0000-4000-8000-000000000008\n"); + const old = new Date(Date.now() - 60_000); + utimesSync(mainLockPath, old, old); + utimesSync(recoveryLockPath, old, old); + + try { + expect(recordOwnedConfigPath(dir, join(dir, "config.json"))).toBe(true); + expect(existsSync(join(dir, CONFIG_OWNER_FILE))).toBe(true); + expect(existsSync(join(dir, CONFIG_UNINSTALL_MANIFEST))).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + test("removes manifest-owned state and the empty config directory", () => { const dir = mkdtempSync(join(tmpdir(), "ocx-uninstall-owned-")); const configPath = join(dir, "config.json"); @@ -116,6 +1230,29 @@ describe("owned config uninstall", () => { } }); + test("refuses an owned file reached through a linked parent directory", () => { + const parent = mkdtempSync(join(tmpdir(), "ocx-uninstall-linked-parent-")); + const dir = join(parent, "config"); + const external = join(parent, "external"); + const externalFile = join(external, "keep.txt"); + const linkedParent = join(dir, "linked-parent"); + mkdirSync(dir); + mkdirSync(external); + writeFileSync(externalFile, "external\n"); + + try { + expect(recordOwnedConfigPath(dir, join(dir, "seed.json"))).toBe(true); + symlinkSync(external, linkedParent, process.platform === "win32" ? "junction" : "dir"); + expect(recordOwnedConfigPath(dir, join(linkedParent, "keep.txt"))).toBe(true); + + const result = removeOwnedConfigState(dir); + expect(result.status).toBe("partial"); + expect(readFileSync(externalFile, "utf8")).toBe("external\n"); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }); + test("rejects a manifest path that escapes the config directory", () => { const parent = mkdtempSync(join(tmpdir(), "ocx-uninstall-traversal-")); const dir = join(parent, "config"); diff --git a/tests/destination-policy-resolved.test.ts b/tests/destination-policy-resolved.test.ts index 2ae103c13..2eea96d5f 100644 --- a/tests/destination-policy-resolved.test.ts +++ b/tests/destination-policy-resolved.test.ts @@ -80,6 +80,14 @@ describe("providerDestinationResolvedError — DNS-resolved SSRF check (activati expect(await providerDestinationResolvedError("custom", provider("https://api.example.com/v1"))).toBeNull(); }); + test("rejects resolver answers that are not valid IP literals", async () => { + lookupMock.mockResolvedValueOnce([{ address: "not-an-ip", family: 4 }]); + expect(await providerDestinationResolvedError( + "custom", + provider("https://invalid-answer.example/v1"), + )).toContain("resolves to an unsafe address (not-an-ip)"); + }); + test("respects allowPrivateNetwork opt-in (no DNS enforcement)", async () => { lookupMock.mockClear(); expect(await providerDestinationResolvedError("custom", provider("https://lan.example.com/v1", true))).toBeNull(); @@ -166,6 +174,15 @@ describe("providerDestinationResolvedError — canonical openai Clash fake-IP ex }); describe("resolvePublicAddresses — caller-specific diagnostics", () => { + test("rejects resolver answers that are not valid IP literals", async () => { + lookupMock.mockResolvedValueOnce([{ address: "not-an-ip", family: 4 }]); + + await expect(resolvePublicAddresses( + "https://invalid-answer.example/v1/models", + { context: "provider URL" }, + )).rejects.toThrow("resolves to an unsafe address (not-an-ip)"); + }); + test("provider callers do not receive image-URL DNS errors", async () => { lookupMock.mockRejectedValueOnce(Object.assign(new Error("ENOTFOUND"), { code: "ENOTFOUND" })); diff --git a/tests/forward-admission-separation.test.ts b/tests/forward-admission-separation.test.ts index 1d698f797..09f61cd6d 100644 --- a/tests/forward-admission-separation.test.ts +++ b/tests/forward-admission-separation.test.ts @@ -1,16 +1,20 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveConfig } from "../src/config"; import { resolveFirstUsableOpenAiSidecar } from "../src/providers/openai-sidecar"; import { startServer } from "../src/server"; +import { serviceAdminTokenFilePath } from "../src/lib/admin-secrets"; +import { initializeManagementAuthState } from "../src/server/management-auth"; import type { OcxConfig } from "../src/types"; const originalFetch = globalThis.fetch; const previousHome = process.env.OPENCODEX_HOME; const previousDataToken = process.env.OPENCODEX_API_AUTH_TOKEN; const previousAdminToken = process.env.OPENCODEX_ADMIN_AUTH_TOKEN; +const previousAdminTokenFile = process.env.OCX_ADMIN_TOKEN_FILE; +const SERVICE_ADMIN_SECRET = "service-admin-without-prefix"; let testHome = ""; let upstreamAttempts: string[] = []; @@ -35,7 +39,9 @@ beforeEach(() => { testHome = mkdtempSync(join(tmpdir(), "ocx-forward-admission-")); process.env.OPENCODEX_HOME = testHome; delete process.env.OPENCODEX_API_AUTH_TOKEN; - process.env.OPENCODEX_ADMIN_AUTH_TOKEN = "admin-secret"; + delete process.env.OPENCODEX_ADMIN_AUTH_TOKEN; + process.env.OCX_ADMIN_TOKEN_FILE = serviceAdminTokenFilePath(testHome); + writeFileSync(serviceAdminTokenFilePath(testHome), `${SERVICE_ADMIN_SECRET}\n`, { mode: 0o600 }); upstreamAttempts = []; globalThis.fetch = (async (input, init) => { const raw = input instanceof Request ? input.url : String(input); @@ -56,6 +62,8 @@ afterEach(() => { else process.env.OPENCODEX_API_AUTH_TOKEN = previousDataToken; if (previousAdminToken === undefined) delete process.env.OPENCODEX_ADMIN_AUTH_TOKEN; else process.env.OPENCODEX_ADMIN_AUTH_TOKEN = previousAdminToken; + if (previousAdminTokenFile === undefined) delete process.env.OCX_ADMIN_TOKEN_FILE; + else process.env.OCX_ADMIN_TOKEN_FILE = previousAdminTokenFile; if (testHome) rmSync(testHome, { recursive: true, force: true }); testHome = ""; }); @@ -63,10 +71,11 @@ afterEach(() => { describe("management credentials never leave data-plane forwarding paths", () => { test("the shared OpenAI sidecar refuses a management bearer", async () => { const config = forwardConfig(); + expect(initializeManagementAuthState(config).available).toBe(true); const provider = config.providers.openai!; const selected = await resolveFirstUsableOpenAiSidecar( [{ providerName: "openai", provider, accountMode: "direct" }], - new Headers({ authorization: "Bearer admin-secret" }), + new Headers({ authorization: `Bearer ${SERVICE_ADMIN_SECRET}` }), config, ); expect(selected).toBeUndefined(); @@ -118,7 +127,7 @@ describe("management credentials never leave data-plane forwarding paths", () => method: "POST", headers: { "content-type": request.contentType, - authorization: "Bearer admin-secret", + authorization: `Bearer ${SERVICE_ADMIN_SECRET}`, }, body: request.body, }); diff --git a/tests/helpers/management-auth.ts b/tests/helpers/management-auth.ts index bd29cbfb8..f26452800 100644 --- a/tests/helpers/management-auth.ts +++ b/tests/helpers/management-auth.ts @@ -31,6 +31,15 @@ export function managementHeaders(initial?: HeadersInit): Headers { return headers; } +/** Strict headers for tests whose setup contract requires initialized management auth. */ +export function requiredManagementHeaders(initial?: HeadersInit): Headers { + const token = configuredAdminToken(); + if (!token) throw new Error("management token was not initialized"); + const headers = new Headers(initial); + headers.set("x-opencodex-api-key", token); + return headers; +} + /** Direct-handler test request with the Host header an actual HTTP server would provide. */ export class ManagementRequest extends globalThis.Request { constructor(input: RequestInfo | URL, init?: RequestInit) { diff --git a/tests/model-test-management-api.test.ts b/tests/model-test-management-api.test.ts new file mode 100644 index 000000000..ae4f00c39 --- /dev/null +++ b/tests/model-test-management-api.test.ts @@ -0,0 +1,868 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveCodexAccountCredential } from "../src/codex/account-store"; +import { clearCodexUpstreamHealth } from "../src/codex/routing"; +import { + handleManagementAPI as handleManagementAPIRaw, + type ManagementApiDeps, +} from "../src/server/management-api"; +import { MODEL_PROBE_TIMEOUT_MS } from "../src/server/management/model-routes"; +import type { OcxConfig, OcxProviderConfig } from "../src/types"; + +function providerFetchStub(handler: () => Response | Promise): typeof fetch { + return Object.assign(async () => handler(), { + preconnect: () => undefined, + }); +} + +function handleManagementAPI( + request: Request, + url: URL, + config: OcxConfig, + deps: ManagementApiDeps = {}, +): ReturnType { + return handleManagementAPIRaw(request, url, config, { + createProviderModelProbeFetch: (_name, provider) => ( + (provider as OcxProviderConfig & { fetch?: typeof fetch }).fetch ?? globalThis.fetch + ), + ...deps, + }); +} + +function directOpenAiConfig(): OcxConfig { + return { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "direct", + }, + }, + } as OcxConfig; +} + +test("management model catalog marks OpenAI Direct models as unavailable for management probes", async () => { + const config = directOpenAiConfig(); + const url = new URL("http://127.0.0.1/api/models"); + const response = await handleManagementAPI(new Request(url, { + headers: { host: "127.0.0.1" }, + }), url, config); + expect(response?.status).toBe(200); + const models = await response!.json() as Array<{ native?: boolean; managementTestable?: boolean }>; + const nativeModels = models.filter(model => model.native === true); + expect(nativeModels.length).toBeGreaterThan(0); + expect(nativeModels.every(model => model.managementTestable === false)).toBe(true); +}); + +test("management model catalog marks routed and custom OpenAI Direct models as unavailable for management probes", async () => { + const config = directOpenAiConfig(); + config.providers.openai!.authMode = "key"; + config.providers.openai!.apiKey = "provider-key"; + config.providers.openai!.baseUrl = "https://provider.example.test/v1"; + config.providers.openai!.liveModels = false; + config.providers.openai!.models = ["routed-direct"]; + config.customModels = [{ + id: "custom-direct-id", + provider: "openai", + modelId: "custom-direct", + displayName: "Custom Direct", + addedAt: "2026-07-29T00:00:00.000Z", + }]; + const url = new URL("http://127.0.0.1/api/models"); + const response = await handleManagementAPI(new Request(url, { + headers: { host: "127.0.0.1" }, + }), url, config); + expect(response?.status).toBe(200); + const models = await response!.json() as Array<{ + namespaced?: string; + custom?: boolean; + managementTestable?: boolean; + }>; + expect(models.find(model => model.namespaced === "openai/routed-direct")).toMatchObject({ + managementTestable: false, + }); + expect(models.find(model => model.namespaced === "openai/custom-direct" && model.custom === true)).toMatchObject({ + managementTestable: false, + }); +}); + +test("management model catalog only marks a combo testable when every selectable target is testable", async () => { + const config = directOpenAiConfig(); + config.providers.openai = { + ...config.providers.openai!, + authMode: "key", + apiKey: "provider-key", + baseUrl: "https://provider.example.test/v1", + liveModels: false, + models: ["routed-direct"], + contextWindow: 128_000, + }; + config.providers.safe = { + adapter: "openai-chat", + baseUrl: "https://safe.example.test/v1", + apiKey: "safe-key", + liveModels: false, + models: ["safe-model"], + contextWindow: 128_000, + }; + config.combos = { + direct: { + targets: [{ provider: "openai", model: "routed-direct" }], + }, + mixed: { + targets: [ + { provider: "openai", model: "routed-direct" }, + { provider: "safe", model: "safe-model" }, + ], + }, + safe: { + targets: [{ provider: "safe", model: "safe-model" }], + }, + unresolved: { + targets: [ + { provider: "safe", model: "safe-model" }, + { provider: "missing", model: "unknown-model" }, + ], + }, + }; + const url = new URL("http://127.0.0.1/api/models"); + const response = await handleManagementAPI(new Request(url, { + headers: { host: "127.0.0.1" }, + }), url, config); + expect(response?.status).toBe(200); + const models = await response!.json() as Array<{ + provider?: string; + id?: string; + managementTestable?: boolean; + }>; + const combo = (id: string) => models.find(model => model.provider === "combo" && model.id === id); + expect(combo("direct")).toMatchObject({ managementTestable: false }); + expect(combo("mixed")).toMatchObject({ managementTestable: false }); + expect(combo("safe")).toMatchObject({ managementTestable: true }); + expect(combo("unresolved")?.managementTestable).not.toBe(true); +}); + +test("management model probe blocks a resolved unsafe physical route before upstream dispatch", async () => { + let upstreamCalls = 0; + const config = { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "mock", + providers: { + mock: { + adapter: "openai-chat", + baseUrl: "https://rebind.example.test/v1", + apiKey: "provider-secret", + liveModels: false, + models: ["model-one"], + fetch: providerFetchStub(async () => { + upstreamCalls += 1; + return Response.json({ error: "must not be reached" }, { status: 500 }); + }), + }, + }, + } as OcxConfig; + const checked: string[] = []; + const url = new URL("http://127.0.0.1/api/models/test"); + + const response = await handleManagementAPI(new Request(url, { + method: "POST", + headers: { host: "127.0.0.1", "content-type": "application/json" }, + body: JSON.stringify({ model: "mock/model-one" }), + }), url, config, { + providerDestinationResolvedError: async name => { + checked.push(name); + return "baseUrl resolves to a private network address"; + }, + }); + + expect(response?.status).toBe(400); + expect(await response!.json()).toEqual({ + ok: false, + error: "provider mock baseUrl resolves to a private network address", + }); + expect(checked).toEqual(["mock"]); + expect(upstreamCalls).toBe(0); +}); + +test("management model probe validates every selectable combo destination before dispatch", async () => { + let upstreamCalls = 0; + const provider = (baseUrl: string): OcxProviderConfig & { fetch: typeof fetch } => ({ + adapter: "openai-chat", + baseUrl, + apiKey: "provider-secret", + liveModels: false, + models: ["model-one"], + fetch: providerFetchStub(async () => { + upstreamCalls += 1; + return Response.json({ error: "must not be reached" }, { status: 500 }); + }), + }); + const config = { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "safe", + providers: { + safe: provider("https://safe.example.test/v1"), + blocked: provider("https://rebind.example.test/v1"), + }, + combos: { + guarded: { + targets: [ + { provider: "safe", model: "model-one" }, + { provider: "blocked", model: "model-one" }, + ], + }, + }, + } as OcxConfig; + const checked: string[] = []; + const url = new URL("http://127.0.0.1/api/models/test"); + + const response = await handleManagementAPI(new Request(url, { + method: "POST", + headers: { host: "127.0.0.1", "content-type": "application/json" }, + body: JSON.stringify({ model: "combo/guarded" }), + }), url, config, { + providerDestinationResolvedError: async name => { + checked.push(name); + return name === "blocked" ? "baseUrl resolves to a private network address" : null; + }, + }); + + expect(response?.status).toBe(400); + expect(checked).toEqual(["safe", "blocked"]); + expect(upstreamCalls).toBe(0); +}); + +test("management model probe installs a safe executor for every selectable combo destination", async () => { + const factoryCalls: string[] = []; + const upstreamCalls: string[] = []; + const provider = (name: string): OcxProviderConfig & { fetch: typeof fetch } => ({ + adapter: "openai-chat", + baseUrl: `https://${name}.example.test/v1`, + apiKey: `${name}-secret`, + liveModels: false, + models: ["model-one"], + fetch: providerFetchStub(() => { + upstreamCalls.push(name); + return new Response([ + 'data: {"choices":[{"index":0,"delta":{"content":"pong"}}]}\n\n', + "data: [DONE]\n\n", + ].join(""), { headers: { "content-type": "text/event-stream" } }); + }), + }); + const config = { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "first", + providers: { + first: provider("first"), + second: provider("second"), + }, + combos: { + guarded: { + targets: [ + { provider: "first", model: "model-one" }, + { provider: "second", model: "model-one" }, + ], + }, + }, + } as OcxConfig; + const url = new URL("http://127.0.0.1/api/models/test"); + + const response = await handleManagementAPI(new Request(url, { + method: "POST", + headers: { host: "127.0.0.1", "content-type": "application/json" }, + body: JSON.stringify({ model: "combo/guarded" }), + }), url, config, { + providerDestinationResolvedError: async () => null, + createProviderModelProbeFetch: (name, configured) => { + factoryCalls.push(name); + return (configured as OcxProviderConfig & { fetch: typeof fetch }).fetch; + }, + }); + + expect(response?.status).toBe(200); + expect(factoryCalls).toEqual(["first", "second"]); + expect(upstreamCalls).toHaveLength(1); +}); + +test("management model catalog fails closed for missing or disabled providers across model types", async () => { + let upstreamCalls = 0; + const config = directOpenAiConfig(); + config.providers.openai = { + ...config.providers.openai!, + disabled: true, + authMode: "key", + apiKey: "provider-key", + baseUrl: "https://provider.example.test/v1", + liveModels: false, + models: ["disabled-routed"], + fetch: (async () => { + upstreamCalls += 1; + return Response.json({ error: "must not be reached" }, { status: 500 }); + }) as typeof fetch, + }; + config.customModels = [ + { + id: "disabled-custom-id", + provider: "openai", + modelId: "disabled-custom", + addedAt: "2026-07-29T00:00:00.000Z", + }, + { + id: "missing-custom-id", + provider: "missing", + modelId: "missing-custom", + addedAt: "2026-07-29T00:00:00.000Z", + }, + ]; + config.combos = { + disabled: { + targets: [{ provider: "openai", model: "disabled-routed" }], + }, + missing: { + targets: [{ provider: "missing", model: "missing-routed" }], + }, + }; + + const url = new URL("http://127.0.0.1/api/models"); + const response = await handleManagementAPI(new Request(url, { + headers: { host: "127.0.0.1" }, + }), url, config); + + expect(response?.status).toBe(200); + const models = await response!.json() as Array<{ + namespaced?: string; + native?: boolean; + managementTestable?: boolean; + }>; + const bySlug = (slug: string) => models.find(model => model.namespaced === slug); + expect(models.filter(model => model.native === true).every(model => model.managementTestable === false)).toBe(true); + expect(bySlug("openai/disabled-custom")).toMatchObject({ managementTestable: false }); + expect(bySlug("missing/missing-custom")).toMatchObject({ managementTestable: false }); + + const probeUrl = new URL("http://127.0.0.1/api/models/test"); + for (const model of [ + "gpt-5.4", + "openai/disabled-routed", + "openai/disabled-custom", + "missing/missing-routed", + "missing/missing-custom", + "combo/disabled", + "combo/missing", + ]) { + const probeResponse = await handleManagementAPI(new Request(probeUrl, { + method: "POST", + headers: { host: "127.0.0.1", "content-type": "application/json" }, + body: JSON.stringify({ model }), + }), probeUrl, config); + expect(probeResponse?.status).toBe(409); + } + expect(upstreamCalls).toBe(0); +}); + +test("management model probe fails closed for a missing or disabled physical combo provider", async () => { + let upstreamCalls = 0; + const disabledCombo = { + adapter: "openai-chat", + baseUrl: "https://combo.example.test/v1", + apiKey: "combo-key", + disabled: true, + fetch: (async () => { + upstreamCalls += 1; + return Response.json({ error: "must not be reached" }, { status: 500 }); + }) as typeof fetch, + } satisfies OcxProviderConfig & { fetch: typeof fetch }; + const configs = [ + { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "safe", + providers: {}, + }, + { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "combo", + providers: { combo: disabledCombo }, + }, + ] as OcxConfig[]; + const url = new URL("http://127.0.0.1/api/models/test"); + + for (const config of configs) { + const response = await handleManagementAPI(new Request(url, { + method: "POST", + headers: { host: "127.0.0.1", "content-type": "application/json" }, + body: JSON.stringify({ model: "combo/model-one" }), + }), url, config); + expect(response?.status).toBe(409); + } + expect(upstreamCalls).toBe(0); +}); + +test("management model probe rejects OpenAI Direct models before attempting a caller-less request", async () => { + const config = directOpenAiConfig(); + const modelsUrl = new URL("http://127.0.0.1/api/models"); + const modelsResponse = await handleManagementAPI(new Request(modelsUrl, { + headers: { host: "127.0.0.1" }, + }), modelsUrl, config); + const models = await modelsResponse!.json() as Array<{ namespaced?: string; native?: boolean }>; + const model = models.find(row => row.native === true)?.namespaced; + expect(typeof model).toBe("string"); + + const probeUrl = new URL("http://127.0.0.1/api/models/test"); + const response = await handleManagementAPI(new Request(probeUrl, { + method: "POST", + headers: { host: "127.0.0.1", "content-type": "application/json" }, + body: JSON.stringify({ model }), + }), probeUrl, config); + expect(response?.status).toBe(409); + const body = await response!.json() as { ok?: boolean; error?: string }; + expect(body.ok).toBe(false); + expect(body.error).toContain("Direct"); +}); + +test("management model probe rejects every unsafe or unresolved route graph before any upstream request", async () => { + let upstreamCalls = 0; + const safeFetch = (async () => { + upstreamCalls += 1; + return new Response([ + 'data: {"choices":[{"index":0,"delta":{"content":"pong"}}]}\n\n', + 'data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n', + "data: [DONE]\n\n", + ].join(""), { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + const config = directOpenAiConfig(); + config.providers.openai = { + ...config.providers.openai!, + authMode: "key", + apiKey: "provider-key", + baseUrl: "https://provider.example.test/v1", + liveModels: false, + models: ["routed-direct"], + }; + config.providers.safe = { + adapter: "openai-chat", + baseUrl: "https://safe.example.test/v1", + apiKey: "safe-key", + liveModels: false, + models: ["safe-model"], + fetch: safeFetch, + } as OcxProviderConfig & { fetch: typeof fetch }; + config.customModels = [{ + id: "custom-direct-id", + provider: "openai", + modelId: "custom-direct", + addedAt: "2026-07-29T00:00:00.000Z", + }]; + config.combos = { + direct: { + targets: [{ provider: "openai", model: "routed-direct" }], + }, + mixed: { + targets: [ + { provider: "safe", model: "safe-model" }, + { provider: "openai", model: "routed-direct" }, + ], + }, + unresolved: { + targets: [ + { provider: "safe", model: "safe-model" }, + { provider: "missing", model: "unknown-model" }, + ], + }, + }; + const url = new URL("http://127.0.0.1/api/models/test"); + for (const model of [ + "gpt-5.4", + "openai/routed-direct", + "openai/custom-direct", + "combo/direct", + "combo/mixed", + "combo/unresolved", + ]) { + const response = await handleManagementAPI(new Request(url, { + method: "POST", + headers: { host: "127.0.0.1", "content-type": "application/json" }, + body: JSON.stringify({ model }), + }), url, config); + expect(response?.status).toBe(409); + } + expect(upstreamCalls).toBe(0); +}); + +test("management model probe uses provider credentials without forwarding admission tokens", async () => { + let upstreamAuthorization: string | null = null; + let upstreamAdmission: string | null = null; + let upstreamBody: Record | undefined; + const providerFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init); + upstreamAuthorization = request.headers.get("authorization"); + upstreamAdmission = request.headers.get("x-opencodex-api-key"); + upstreamBody = await request.clone().json() as Record; + const frames = [ + `data: ${JSON.stringify({ choices: [{ index: 0, delta: { role: "assistant", content: "pong" } }] })}\n\n`, + `data: ${JSON.stringify({ choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 1, completion_tokens: 1 } })}\n\n`, + "data: [DONE]\n\n", + ]; + return new Response(frames.join(""), { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + + const provider: OcxProviderConfig & { fetch: typeof fetch } = { + adapter: "openai-chat", + baseUrl: "https://provider.example.test/v1", + apiKey: "provider-secret", + liveModels: false, + models: ["model-one"], + fetch: providerFetch, + }; + const config = { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "mock", + providers: { mock: provider }, + } as OcxConfig; + const url = new URL("http://127.0.0.1/api/models/test"); + const response = await handleManagementAPI(new Request(url, { + method: "POST", + headers: { + "content-type": "application/json", + host: "127.0.0.1", + authorization: `Bearer ${["ocx_admin", "browser-secret"].join("_")}`, + "x-opencodex-api-key": "ocx_session_browser-secret", + }, + body: JSON.stringify({ model: "mock/model-one" }), + }), url, config); + + expect(response).not.toBeNull(); + expect(response!.status).toBe(200); + expect(await response!.json()).toEqual({ ok: true }); + expect(upstreamBody?.model).toBe("model-one"); + expect(upstreamBody?.max_tokens).toBe(1); + expect(upstreamBody).not.toHaveProperty("max_output_tokens"); + expect(upstreamAuthorization).toBe("Bearer provider-secret"); + expect(upstreamAdmission).toBeNull(); +}); + +test("management model probe preserves the Responses output limit for key providers", async () => { + let upstreamBody: Record | undefined; + const provider = { + adapter: "openai-responses", + baseUrl: "https://provider.example.test/v1", + authMode: "key", + apiKey: "provider-secret", + liveModels: false, + models: ["model-one"], + fetch: (async (input: RequestInfo | URL, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init); + upstreamBody = await request.clone().json() as Record; + return new Response( + `event: response.output_text.delta\ndata: ${JSON.stringify({ + type: "response.output_text.delta", + delta: "pong", + })}\n\n`, + { headers: { "content-type": "text/event-stream" } }, + ); + }) as typeof fetch, + } satisfies OcxProviderConfig & { fetch: typeof fetch }; + const config = { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "mock", + providers: { mock: provider }, + } as OcxConfig; + const url = new URL("http://127.0.0.1/api/models/test"); + const response = await handleManagementAPI(new Request(url, { + method: "POST", + headers: { host: "127.0.0.1", "content-type": "application/json" }, + body: JSON.stringify({ model: "mock/model-one" }), + }), url, config); + + expect(response?.status).toBe(200); + expect(await response!.json()).toEqual({ ok: true }); + expect(upstreamBody?.model).toBe("model-one"); + expect(upstreamBody?.max_output_tokens).toBe(1); + expect(upstreamBody).not.toHaveProperty("max_tokens"); +}); + +test("management model probe stops a native account-pool stream after its first output delta", async () => { + const testDir = mkdtempSync(join(tmpdir(), "ocx-management-probe-")); + const previousOpencodexHome = process.env.OPENCODEX_HOME; + const previousCodexHome = process.env.CODEX_HOME; + const originalFetch = globalThis.fetch; + let upstreamBody: Record | undefined; + let upstreamSignal: AbortSignal | undefined; + let fallbackDelivered = false; + let upstreamCancelled = false; + let fallbackTimer: ReturnType | undefined; + try { + process.env.OPENCODEX_HOME = testDir; + process.env.CODEX_HOME = testDir; + clearCodexUpstreamHealth(); + saveCodexAccountCredential("management-pool", { + accessToken: "pool-access-token", + refreshToken: "pool-refresh-token", + expiresAt: Date.now() + 300_000, + chatgptAccountId: "management_pool_account", + }); + const config = { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "openai", + activeCodexAccountId: "management-pool", + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "pool", + }, + }, + codexAccounts: [{ + id: "management-pool", + email: "pool@example.test", + isMain: false, + chatgptAccountId: "management_pool_account", + }], + } as OcxConfig; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init); + upstreamBody = await request.clone().json() as Record; + upstreamSignal = request.signal; + const encoder = new TextEncoder(); + return new Response(new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode( + `event: response.output_text.delta\ndata: ${JSON.stringify({ + type: "response.output_text.delta", + delta: "pong", + })}\n\n`, + )); + }, + pull(controller) { + return new Promise(resolve => { + const finish = () => { + request.signal.removeEventListener("abort", onAbort); + resolve(); + }; + const onAbort = () => { + upstreamCancelled = true; + if (fallbackTimer !== undefined) clearTimeout(fallbackTimer); + finish(); + }; + request.signal.addEventListener("abort", onAbort, { once: true }); + fallbackTimer = setTimeout(() => { + fallbackDelivered = true; + controller.enqueue(encoder.encode( + `event: response.completed\ndata: ${JSON.stringify({ + type: "response.completed", + response: { status: "completed", output: [] }, + })}\n\n`, + )); + controller.close(); + finish(); + }, 150); + }); + }, + cancel() { + upstreamCancelled = true; + if (fallbackTimer !== undefined) clearTimeout(fallbackTimer); + }, + }), { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + }) as typeof fetch; + + const url = new URL("http://127.0.0.1/api/models/test"); + const response = await handleManagementAPI(new Request(url, { + method: "POST", + headers: { host: "127.0.0.1", "content-type": "application/json" }, + body: JSON.stringify({ model: "gpt-5.4" }), + }), url, config); + + expect(response?.status).toBe(200); + expect(await response!.json()).toEqual({ ok: true }); + expect(upstreamBody).toBeDefined(); + expect(upstreamBody).not.toHaveProperty("max_output_tokens"); + expect(upstreamSignal?.aborted).toBe(true); + expect(upstreamCancelled).toBe(true); + expect(fallbackDelivered).toBe(false); + } finally { + if (fallbackTimer !== undefined) clearTimeout(fallbackTimer); + globalThis.fetch = originalFetch; + clearCodexUpstreamHealth(); + rmSync(testDir, { recursive: true, force: true }); + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + } +}); + +test("management model probe maps upstream authentication failures to 502", async () => { + const provider = { + adapter: "openai-chat", + baseUrl: "https://provider.example.test/v1", + apiKey: "provider-secret", + liveModels: false, + models: ["model-one"], + fetch: (async () => Response.json({ + error: { message: "provider rejected credentials", type: "authentication_error" }, + }, { status: 401 })) as typeof fetch, + } satisfies OcxProviderConfig & { fetch: typeof fetch }; + const config = { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "mock", + providers: { mock: provider }, + } as OcxConfig; + const url = new URL("http://127.0.0.1/api/models/test"); + const response = await handleManagementAPI(new Request(url, { + method: "POST", + headers: { host: "127.0.0.1", "content-type": "application/json" }, + body: JSON.stringify({ model: "mock/model-one" }), + }), url, config); + + expect(response?.status).toBe(502); + const body = await response!.json() as { ok?: boolean; error?: string }; + expect(body.ok).toBe(false); + expect(body.error).toContain("provider rejected credentials"); +}); + +test("management model probe propagates caller cancellation to the upstream request", async () => { + const caller = new AbortController(); + let upstreamSignal: AbortSignal | undefined; + let markFetchStarted!: () => void; + const fetchStarted = new Promise(resolve => { markFetchStarted = resolve; }); + const provider = { + adapter: "openai-chat", + baseUrl: "https://provider.example.test/v1", + apiKey: "provider-secret", + liveModels: false, + models: ["model-one"], + fetch: (async (input: RequestInfo | URL, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init); + upstreamSignal = request.signal; + markFetchStarted(); + return await new Promise((resolve, reject) => { + const fallback = setTimeout(() => { + resolve(Response.json({ error: { message: "caller cancellation did not propagate" } }, { status: 500 })); + }, 200); + const onAbort = () => { + clearTimeout(fallback); + reject(request.signal.reason); + }; + if (request.signal.aborted) onAbort(); + else request.signal.addEventListener("abort", onAbort, { once: true }); + }); + }) as typeof fetch, + } satisfies OcxProviderConfig & { fetch: typeof fetch }; + const config = { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "mock", + providers: { mock: provider }, + } as OcxConfig; + const url = new URL("http://127.0.0.1/api/models/test"); + const pending = handleManagementAPI(new Request(url, { + method: "POST", + headers: { host: "127.0.0.1", "content-type": "application/json" }, + body: JSON.stringify({ model: "mock/model-one" }), + signal: caller.signal, + }), url, config); + + await fetchStarted; + caller.abort(new DOMException("caller cancelled", "AbortError")); + const response = await pending; + + expect(response?.status).toBe(499); + expect(await response!.json()).toEqual({ ok: false, error: "Model test cancelled" }); + expect(upstreamSignal?.aborted).toBe(true); + expect(upstreamSignal?.reason).toBe(caller.signal.reason); +}); + +test("management model probe maps transport timeouts to 504", async () => { + const originalSetTimeout = globalThis.setTimeout; + Object.defineProperty(globalThis, "setTimeout", { + configurable: true, + value: ((handler: TimerHandler, timeout?: number, ...args: unknown[]) => ( + originalSetTimeout(handler, timeout === MODEL_PROBE_TIMEOUT_MS ? 0 : timeout, ...args) + )) as typeof setTimeout, + }); + const provider = { + adapter: "openai-chat", + baseUrl: "https://provider.example.test/v1", + apiKey: "provider-secret", + liveModels: false, + models: ["model-one"], + fetch: (async (input: RequestInfo | URL, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init); + await new Promise((_resolve, reject) => { + const onAbort = () => reject(request.signal.reason); + if (request.signal.aborted) onAbort(); + else request.signal.addEventListener("abort", onAbort, { once: true }); + }); + throw new Error("unreachable"); + }) as typeof fetch, + } satisfies OcxProviderConfig & { fetch: typeof fetch }; + const config = { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "mock", + providers: { mock: provider }, + } as OcxConfig; + const url = new URL("http://127.0.0.1/api/models/test"); + try { + const response = await handleManagementAPI(new Request(url, { + method: "POST", + headers: { host: "127.0.0.1", "content-type": "application/json" }, + body: JSON.stringify({ model: "mock/model-one" }), + }), url, config); + + expect(response?.status).toBe(504); + const body = await response!.json() as { ok?: boolean; error?: string }; + expect(body.ok).toBe(false); + expect(body.error?.toLowerCase()).toContain("timed out"); + } finally { + Object.defineProperty(globalThis, "setTimeout", { configurable: true, value: originalSetTimeout }); + } +}); + +test("management model probe preserves provider rate limiting as 429", async () => { + const provider = { + adapter: "openai-chat", + baseUrl: "https://provider.example.test/v1", + apiKey: "provider-secret", + liveModels: false, + models: ["model-one"], + fetch: (async () => Response.json({ + error: { message: "rate limit exceeded", type: "rate_limit_error" }, + }, { status: 429 })) as typeof fetch, + } satisfies OcxProviderConfig & { fetch: typeof fetch }; + const config = { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "mock", + providers: { mock: provider }, + } as OcxConfig; + const url = new URL("http://127.0.0.1/api/models/test"); + const response = await handleManagementAPI(new Request(url, { + method: "POST", + headers: { host: "127.0.0.1", "content-type": "application/json" }, + body: JSON.stringify({ model: "mock/model-one" }), + }), url, config); + + expect(response?.status).toBe(429); + const body = await response!.json() as { ok?: boolean; error?: string }; + expect(body.ok).toBe(false); + expect(body.error).toContain("rate limit exceeded"); +}); diff --git a/tests/oauth-health.test.ts b/tests/oauth-health.test.ts index dcc57986e..458486ccc 100644 --- a/tests/oauth-health.test.ts +++ b/tests/oauth-health.test.ts @@ -168,6 +168,28 @@ describe("collectOAuthHealthEntries", () => { }); describe("collectOAuthHealthEntriesForCli", () => { + test("authenticates live Codex health with the management credential", async () => { + const previousAdmin = process.env.OPENCODEX_ADMIN_AUTH_TOKEN; + const previousData = process.env.OPENCODEX_API_AUTH_TOKEN; + process.env.OPENCODEX_ADMIN_AUTH_TOKEN = "admin-health-secret"; + process.env.OPENCODEX_API_AUTH_TOKEN = "data-health-secret"; + try { + const report = await collectOAuthHealthEntriesForCli(Date.now(), { + findLiveProxyImpl: async () => ({ hostname: "127.0.0.1", port: 19191, pid: null }), + fetchImpl: async (_url, init) => { + expect(new Headers(init?.headers).get("authorization")).toBe("Bearer admin-health-secret"); + return new Response(JSON.stringify({ accounts: [] }), { status: 200 }); + }, + }); + expect(report.codexHealthSource).toBe("management-api"); + } finally { + if (previousAdmin === undefined) delete process.env.OPENCODEX_ADMIN_AUTH_TOKEN; + else process.env.OPENCODEX_ADMIN_AUTH_TOKEN = previousAdmin; + if (previousData === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN; + else process.env.OPENCODEX_API_AUTH_TOKEN = previousData; + } + }); + test("uses management API Codex health and does not read CLI process maps", async () => { markCodexAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); const report = await collectOAuthHealthEntriesForCli(Date.now(), { @@ -207,6 +229,20 @@ describe("collectOAuthHealthEntriesForCli", () => { expect(text).not.toContain(MAIN_CODEX_ACCOUNT_ID); }); + test.each([401, 503])( + "does not claim the proxy stopped when the live management API returns %i", + async status => { + const report = await collectOAuthHealthEntriesForCli(Date.now(), { + findLiveProxyImpl: async () => ({ hostname: "127.0.0.1", port: 19191, pid: null }), + fetchImpl: async () => new Response(null, { status }), + }); + expect(report.codexHealthSource).toBe("unavailable"); + expect(formatOAuthHealthForStatus(report)).toContain( + "proxy not running or authenticated management API unavailable", + ); + }, + ); + test("malformed remote health is re-derived instead of rendering undefined", async () => { const report = await collectOAuthHealthEntriesForCli(Date.now(), { findLiveProxyImpl: async () => ({ hostname: "127.0.0.1", port: 19191, pid: null }), diff --git a/tests/opencode-cli.test.ts b/tests/opencode-cli.test.ts index 66a50cd6b..3e44d788d 100644 --- a/tests/opencode-cli.test.ts +++ b/tests/opencode-cli.test.ts @@ -21,6 +21,7 @@ import { opencodeCatalogFromProxyRows, opencodeGlobalConfigPath, opencodeLaunchNativeSlugs, + opencodeManagementApiKey, opencodeModelKey, opencodeNotFoundHint, opencodeProviderOverridePath, @@ -499,6 +500,23 @@ describe("ocx opencode env assembly", () => { }); describe("ocx opencode admission key", () => { + test("keeps the management catalog credential separate from the data-plane admission key", () => { + const previousAdmin = process.env.OPENCODEX_ADMIN_AUTH_TOKEN; + const previousData = process.env.OPENCODEX_API_AUTH_TOKEN; + process.env.OPENCODEX_ADMIN_AUTH_TOKEN = "admin-catalog-secret"; + process.env.OPENCODEX_API_AUTH_TOKEN = "data-catalog-secret"; + try { + const config = cfg(); + expect(opencodeManagementApiKey()).toBe("admin-catalog-secret"); + expect(opencodeApiKey(config)).toBe("data-catalog-secret"); + } finally { + if (previousAdmin === undefined) delete process.env.OPENCODEX_ADMIN_AUTH_TOKEN; + else process.env.OPENCODEX_ADMIN_AUTH_TOKEN = previousAdmin; + if (previousData === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN; + else process.env.OPENCODEX_API_AUTH_TOKEN = previousData; + } + }); + test("the environment token wins over a configured API key", () => { const config = cfg({ apiKeys: [{ id: "1", name: "main", key: "sk-cfg", createdAt: "2026-01-01" }] }); expect(opencodeApiKey(config, { OPENCODEX_API_AUTH_TOKEN: "sk-env" })).toBe("sk-env"); diff --git a/tests/process-control-graceful.test.ts b/tests/process-control-graceful.test.ts index 54c9ea6b9..6e882ccaf 100644 --- a/tests/process-control-graceful.test.ts +++ b/tests/process-control-graceful.test.ts @@ -1,4 +1,7 @@ import { describe, expect, test } from "bun:test"; +import { mkdtempSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, join } from "node:path"; import { gracefulStopHost, stopProxyGracefully } from "../src/lib/process-control"; function okResponse(): Response { @@ -70,6 +73,31 @@ describe("stopProxyGracefully", () => { expect(headers?.["x-opencodex-api-key"]).toBe("admin-secret"); }); + test("resolves a tilde OPENCODEX_HOME before reading the management token", async () => { + const configDir = mkdtempSync(join(homedir(), ".opencodex-process-control-")); + const tokenPath = join(configDir, "admin-api-token"); + const adminToken = `ocx_admin_${"a".repeat(43)}`; + writeFileSync(tokenPath, `${adminToken}\n`, { mode: 0o600 }); + + try { + let headers: Record | undefined; + await stopProxyGracefully(1, { + readRuntime: () => ({ port: 10100 }), + fetchFn: (async (_url: string | URL | Request, init?: RequestInit) => { + headers = init?.headers as Record; + return okResponse(); + }) as typeof fetch, + waitExit: () => true, + env: { OPENCODEX_HOME: `~/${basename(configDir)}` }, + }); + + expect(headers?.["x-opencodex-api-key"]).toBe(adminToken); + } finally { + unlinkSync(tokenPath); + rmdirSync(configDir); + } + }); + test("returns false when no runtime port is recorded (caller falls back to killProxy)", async () => { const result = await stopProxyGracefully(7, { readRuntime: () => null, diff --git a/tests/provider-outbound.test.ts b/tests/provider-outbound.test.ts index de9b78d90..bce6cfdf4 100644 --- a/tests/provider-outbound.test.ts +++ b/tests/provider-outbound.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, mock, test } from "bun:test"; import { mkdtempSync, rmSync } from "node:fs"; +import { createServer } from "node:http"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { ProviderOutboundDependencies } from "../src/lib/provider-outbound"; @@ -7,6 +8,7 @@ import { PROXY_ENV_KEYS } from "../src/lib/proxy-env"; const proxyKeys = PROXY_ENV_KEYS.flatMap(key => [key, key.toLowerCase()]); const originalProxyEnv = Object.fromEntries(proxyKeys.map(key => [key, process.env[key]])); +const originalFetch = globalThis.fetch; afterEach(() => { for (const key of proxyKeys) { @@ -14,6 +16,7 @@ afterEach(() => { if (previous === undefined) delete process.env[key]; else process.env[key] = previous; } + globalThis.fetch = originalFetch; }); function directDependencies( @@ -43,7 +46,248 @@ function directDependencies( }; } +async function runIsolatedBun( + script: string, + env: Record, +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const child = Bun.spawn([process.execPath, "-e", script], { + cwd: process.cwd(), + env, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + return { stdout, stderr, exitCode }; +} + describe("provider outbound GET transport", () => { + test("pins a POST to the validated peer and never follows its redirect", async () => { + for (const key of proxyKeys) delete process.env[key]; + let safeRequests = 0; + let dangerousRequests = 0; + let receivedBody = ""; + const dangerous = createServer((_request, response) => { + dangerousRequests += 1; + response.writeHead(200).end("dangerous target reached"); + }); + const safe = createServer(async (request, response) => { + safeRequests += 1; + for await (const chunk of request) receivedBody += chunk.toString(); + const dangerousAddress = dangerous.address(); + if (!dangerousAddress || typeof dangerousAddress === "string") throw new Error("missing dangerous port"); + response.writeHead(307, { + location: `http://127.0.0.1:${dangerousAddress.port}/metadata`, + }).end(); + }); + const listen = (server: typeof safe) => new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") reject(new Error("missing server port")); + else resolve(address.port); + }); + }); + const close = (server: typeof safe) => new Promise(resolve => server.close(() => resolve())); + + try { + const [safePort] = await Promise.all([listen(safe), listen(dangerous)]); + const requestUrl = `http://provider.example:${safePort}/v1/chat/completions`; + const { createProviderModelProbeFetch } = await import("../src/lib/provider-outbound"); + const executor = createProviderModelProbeFetch("custom", { + baseUrl: `http://provider.example:${safePort}/v1`, + }, { + proxyEnv: {}, + resolveAddresses: mock(async () => ({ + hostname: "provider.example", + addresses: [{ address: "127.0.0.1", family: 4 }], + privateNetwork: false, + })), + }); + + const response = await executor(requestUrl, { + method: "POST", + headers: { + authorization: "Bearer provider-secret", + "content-type": "application/json", + }, + body: JSON.stringify({ model: "model-one", stream: true }), + }); + + expect(response.status).toBe(307); + await response.body?.cancel(); + await Bun.sleep(20); + expect(safeRequests).toBe(1); + expect(JSON.parse(receivedBody)).toEqual({ model: "model-one", stream: true }); + expect(dangerousRequests).toBe(0); + } finally { + await Promise.all([close(safe), close(dangerous)]); + } + }); + + test("cancels a backpressured POST body when the peer rejects it early", async () => { + for (const key of proxyKeys) delete process.env[key]; + const server = createServer((request, response) => { + request.pause(); + response.writeHead(413).end(); + }); + const port = await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") reject(new Error("missing server port")); + else resolve(address.port); + }); + }); + const closed = () => new Promise(resolve => server.close(() => resolve())); + const cancelled = Promise.withResolvers(); + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(1024 * 1024)); + }, + cancel() { + cancelled.resolve(); + }, + }); + + try { + const { pinnedHttpRequest } = await import("../src/lib/pinned-http"); + const response = await pinnedHttpRequest(new Request( + `http://provider.example:${port}/v1/chat/completions`, + { method: "POST", body }, + ), { address: "127.0.0.1", family: 4 }, { + discardNonSuccessBody: true, + idleTimeoutMs: 1_000, + }); + + expect(response.status).toBe(413); + const bodyReaderCancelled = await Promise.race([ + cancelled.promise.then(() => true), + Bun.sleep(1_000).then(() => false), + ]); + expect(bodyReaderCancelled).toBe(true); + } finally { + await closed(); + } + }); + + test("pins direct transport when Bun has no proxy for the request protocol", async () => { + const { providerOutboundGet } = await import("../src/lib/provider-outbound"); + const cases = [ + { + name: "HTTP ignores HTTPS_PROXY", + url: "http://provider.example/v1/models", + env: { HTTPS_PROXY: "http://127.0.0.1:9" }, + }, + { + name: "HTTPS ignores HTTP_PROXY", + url: "https://provider.example/v1/models", + env: { HTTP_PROXY: "http://127.0.0.1:9" }, + }, + ] as const; + + for (const routeCase of cases) { + for (const key of proxyKeys) delete process.env[key]; + Object.assign(process.env, routeCase.env); + const fetchMock = mock(async () => new Response("unexpected native fetch")); + globalThis.fetch = fetchMock as typeof fetch; + const { dependencies, captured } = directDependencies(new Response(null, { status: 204 })); + + const response = await providerOutboundGet( + routeCase.name, + { baseUrl: new URL(routeCase.url).origin }, + routeCase.url, + {}, + dependencies, + ); + + expect(response.status).toBe(204); + expect(captured.address).toBe("93.184.216.34"); + expect(fetchMock).not.toHaveBeenCalled(); + } + }); + + test("fails closed when only unsupported ALL_PROXY is configured", async () => { + for (const key of proxyKeys) delete process.env[key]; + const { + providerOutboundGet, + ProviderOutboundPolicyError, + } = await import("../src/lib/provider-outbound"); + const cases = [ + { + url: "http://provider.example/v1/models", + env: { ALL_PROXY: "http://127.0.0.1:9" }, + expected: "set HTTP_PROXY", + }, + { + url: "https://provider.example/v1/models", + env: { all_proxy: "http://127.0.0.1:9" }, + expected: "set HTTPS_PROXY", + }, + ] as const; + + for (const routeCase of cases) { + const direct = directDependencies(new Response(null, { status: 204 })); + const dependencies = { + ...direct.dependencies, + proxyEnv: routeCase.env, + } as ProviderOutboundDependencies & { + proxyEnv: Record; + }; + + let error: unknown; + try { + await providerOutboundGet( + "custom", + { baseUrl: new URL(routeCase.url).origin }, + routeCase.url, + {}, + dependencies, + ); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(ProviderOutboundPolicyError); + expect((error as Error).message).toContain("ALL_PROXY is not supported"); + expect((error as Error).message).toContain(routeCase.expected); + expect(direct.captured.address).toBeUndefined(); + } + }); + + test("uses Bun's lowercase no_proxy precedence before deciding to bypass a proxy", async () => { + for (const key of proxyKeys) delete process.env[key]; + const fetchMock = mock(async () => new Response(null, { status: 202 })); + globalThis.fetch = fetchMock as typeof fetch; + const { providerOutboundGet } = await import("../src/lib/provider-outbound"); + const direct = directDependencies(new Response(null, { status: 204 })); + const dependencies = { + ...direct.dependencies, + proxyEnv: { + HTTP_PROXY: "http://127.0.0.1:9", + NO_PROXY: "provider.example", + no_proxy: "other.example", + }, + } as ProviderOutboundDependencies & { + proxyEnv: Record; + }; + + const response = await providerOutboundGet( + "custom", + { baseUrl: "http://provider.example/v1" }, + "http://provider.example/v1/models", + {}, + dependencies, + ); + + expect(response.status).toBe(202); + expect(direct.captured.address).toBeUndefined(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + test("direct HTTPS connects only to the validated address with TLS verification", async () => { for (const key of proxyKeys) delete process.env[key]; const { providerOutboundGet } = await import("../src/lib/provider-outbound"); @@ -68,25 +312,84 @@ describe("provider outbound GET transport", () => { }); }); + test("a public NO_PROXY destination stays on the pinned direct transport", async () => { + for (const key of proxyKeys) delete process.env[key]; + process.env.HTTPS_PROXY = "http://127.0.0.1:9"; + process.env.NO_PROXY = "provider.example"; + const fetchMock = mock(async () => new Response("unexpected proxy transport")); + globalThis.fetch = fetchMock as typeof fetch; + const { providerOutboundGet } = await import("../src/lib/provider-outbound"); + const { dependencies, captured } = directDependencies(new Response('{"data":[]}', { + status: 200, + headers: { "content-type": "application/json" }, + })); + + const response = await providerOutboundGet( + "custom", + { baseUrl: "https://provider.example/v1" }, + "https://provider.example/v1/models", + {}, + dependencies, + ); + + expect(response.status).toBe(200); + expect(captured.address).toBe("93.184.216.34"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("a NO_PROXY destination keeps DNS failures fail-closed", async () => { + for (const key of proxyKeys) delete process.env[key]; + process.env.HTTPS_PROXY = "http://127.0.0.1:9"; + process.env.NO_PROXY = "provider.example"; + const fetchMock = mock(async () => new Response("unexpected proxy transport")); + globalThis.fetch = fetchMock as typeof fetch; + const { + DestinationDnsResolutionError, + } = await import("../src/lib/destination-policy"); + const { providerOutboundGet } = await import("../src/lib/provider-outbound"); + const dependencies: ProviderOutboundDependencies = { + resolveAddresses: mock(async () => { + throw new DestinationDnsResolutionError("provider URL hostname provider.example could not be resolved"); + }), + pinnedGet: mock(async () => new Response(null, { status: 200 })), + }; + + await expect(providerOutboundGet( + "custom", + { baseUrl: "https://provider.example/v1" }, + "https://provider.example/v1/models", + {}, + dependencies, + )).rejects.toBeInstanceOf(DestinationDnsResolutionError); + expect(fetchMock).not.toHaveBeenCalled(); + }); + test("private providers behind a configured proxy require an explicit NO_PROXY match", async () => { const proxyUrl = "http://127.0.0.1:9"; process.env.HTTPS_PROXY = proxyUrl; process.env.https_proxy = proxyUrl; process.env.NO_PROXY = "localhost,127.0.0.1,::1,[::1]"; process.env.no_proxy = "localhost,127.0.0.1,::1,[::1]"; - const { providerOutboundGet } = await import("../src/lib/provider-outbound"); + const { providerOutboundGet, ProviderOutboundPolicyError } = await import("../src/lib/provider-outbound"); const { dependencies, captured } = directDependencies(new Response(null, { status: 200 }), { privateNetwork: true, address: "192.168.1.50", }); - await expect(providerOutboundGet( - "ollama-lan", - { baseUrl: "https://ollama.lan:11434/v1", allowPrivateNetwork: true }, - "https://ollama.lan:11434/v1/models", - {}, - dependencies, - )).rejects.toThrow(/add ollama\.lan to NO_PROXY/); + let error: unknown; + try { + await providerOutboundGet( + "ollama-lan", + { baseUrl: "https://ollama.lan:11434/v1", allowPrivateNetwork: true }, + "https://ollama.lan:11434/v1/models", + {}, + dependencies, + ); + } catch (caught) { + error = caught; + } + expect(error).toBeInstanceOf(ProviderOutboundPolicyError); + expect((error as Error).message).toMatch(/add ollama\.lan to NO_PROXY/); expect(captured.address).toBeUndefined(); }); @@ -142,33 +445,281 @@ describe("provider outbound GET transport", () => { expect(override).toHaveBeenCalledTimes(1); }); + test("matches Bun's real protocol, casing, and NO_PROXY routing table", async () => { + const childEnv = { ...process.env }; + for (const key of proxyKeys) delete childEnv[key]; + const script = String.raw` + import { createServer } from "node:http"; + + const events = { A: [], B: [] }; + function proxy(label) { + const server = createServer((request, response) => { + events[label].push("request:" + request.method + ":" + request.url); + response.writeHead(204); + response.end(); + }); + server.on("connect", (request, socket) => { + events[label].push("connect:" + request.url); + socket.end("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n"); + }); + return server; + } + function listen(server) { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve(server.address().port)); + }); + } + function close(server) { + return new Promise(resolve => server.close(() => resolve())); + } + + const proxyA = proxy("A"); + const proxyB = proxy("B"); + const ports = await Promise.all([listen(proxyA), listen(proxyB)]); + const proxyAUrl = "http://127.0.0.1:" + ports[0]; + const proxyBUrl = "http://127.0.0.1:" + ports[1]; + const proxyKeys = [ + "HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy", + "ALL_PROXY", "all_proxy", "NO_PROXY", "no_proxy", + ]; + const cleanEnv = { ...process.env }; + for (const key of proxyKeys) delete cleanEnv[key]; + const cases = [ + ["HTTP uses HTTP_PROXY", "http://route-target.invalid/path", { HTTP_PROXY: proxyAUrl }], + ["HTTP uses http_proxy", "http://route-target.invalid/path", { http_proxy: proxyAUrl }], + ["HTTP ignores HTTPS_PROXY", "http://route-target.invalid/path", { HTTPS_PROXY: proxyAUrl }], + ["HTTP ignores ALL_PROXY", "http://route-target.invalid/path", { ALL_PROXY: proxyAUrl }], + ["HTTP ignores all_proxy", "http://route-target.invalid/path", { all_proxy: proxyAUrl }], + ["HTTPS uses HTTPS_PROXY", "https://route-target.invalid/path", { HTTPS_PROXY: proxyAUrl }], + ["HTTPS uses https_proxy", "https://route-target.invalid/path", { https_proxy: proxyAUrl }], + ["HTTPS ignores HTTP_PROXY", "https://route-target.invalid/path", { HTTP_PROXY: proxyAUrl }], + ["HTTPS ignores ALL_PROXY", "https://route-target.invalid/path", { ALL_PROXY: proxyAUrl }], + ["HTTP lowercase proxy wins", "http://route-target.invalid/path", { + HTTP_PROXY: proxyAUrl, + http_proxy: proxyBUrl, + }], + ["HTTPS lowercase proxy wins", "https://route-target.invalid/path", { + HTTPS_PROXY: proxyAUrl, + https_proxy: proxyBUrl, + }], + ["lowercase no_proxy miss wins", "http://route-target.invalid/path", { + HTTP_PROXY: proxyAUrl, + NO_PROXY: "route-target.invalid", + no_proxy: "other.invalid", + }], + ["lowercase no_proxy match wins", "http://route-target.invalid/path", { + HTTP_PROXY: proxyAUrl, + NO_PROXY: "other.invalid", + no_proxy: "route-target.invalid", + }], + ["empty lowercase proxy handling is runtime-specific", "http://route-target.invalid/path", { + HTTP_PROXY: proxyAUrl, + http_proxy: "", + }], + ]; + const fetchTemplate = 'const target = "URL"; try { await fetch(target, { signal: AbortSignal.timeout(2500) }); } catch {}'; + const routes = []; + + try { + for (const [name, url, routeEnv] of cases) { + const beforeA = events.A.length; + const beforeB = events.B.length; + const fetchScript = fetchTemplate.replace('"URL"', JSON.stringify(url)); + const child = Bun.spawn([process.execPath, "-e", fetchScript], { + env: { ...cleanEnv, ...routeEnv }, + stdout: "ignore", + stderr: "ignore", + }); + const exitCode = await child.exited; + await Bun.sleep(20); + routes.push({ + name, + route: events.A.length > beforeA ? "A" : events.B.length > beforeB ? "B" : "direct", + exitCode, + }); + } + } finally { + await Promise.all([close(proxyA), close(proxyB)]); + } + console.log(JSON.stringify(routes)); + `; + + const result = await runIsolatedBun(script, childEnv); + + expect({ exitCode: result.exitCode, stderr: result.stderr }).toMatchObject({ exitCode: 0 }); + expect(JSON.parse(result.stdout.trim())).toEqual([ + { name: "HTTP uses HTTP_PROXY", route: "A", exitCode: 0 }, + { name: "HTTP uses http_proxy", route: "A", exitCode: 0 }, + { name: "HTTP ignores HTTPS_PROXY", route: "direct", exitCode: 0 }, + { name: "HTTP ignores ALL_PROXY", route: "direct", exitCode: 0 }, + { name: "HTTP ignores all_proxy", route: "direct", exitCode: 0 }, + { name: "HTTPS uses HTTPS_PROXY", route: "A", exitCode: 0 }, + { name: "HTTPS uses https_proxy", route: "A", exitCode: 0 }, + { name: "HTTPS ignores HTTP_PROXY", route: "direct", exitCode: 0 }, + { name: "HTTPS ignores ALL_PROXY", route: "direct", exitCode: 0 }, + { name: "HTTP lowercase proxy wins", route: "B", exitCode: 0 }, + { name: "HTTPS lowercase proxy wins", route: "B", exitCode: 0 }, + { name: "lowercase no_proxy miss wins", route: "A", exitCode: 0 }, + { name: "lowercase no_proxy match wins", route: "direct", exitCode: 0 }, + { + name: "empty lowercase proxy handling is runtime-specific", + route: process.platform === "win32" ? "direct" : "A", + exitCode: 0, + }, + ]); + }, process.platform === "win32" ? 60_000 : 30_000); + test("proxy mode reaches one real proxy across outbound, connection-test, and model-discovery paths", async () => { const childHome = mkdtempSync(join(tmpdir(), "ocx-provider-proxy-e2e-")); - const child = Bun.spawn([ - process.execPath, - "tests/fixtures/provider-outbound-e2e.ts", - ], { - cwd: process.cwd(), - env: { - ...process.env, - OPENCODEX_HOME: childHome, - }, - stdout: "pipe", - stderr: "pipe", - }); + const childEnv = { ...process.env }; + for (const key of proxyKeys) delete childEnv[key]; + const script = String.raw` + import { createServer } from "node:http"; + import { saveConfig } from "./src/config.ts"; + import { fetchProviderModels } from "./src/codex/catalog/provider-fetch.ts"; + import { providerOutboundGet } from "./src/lib/provider-outbound.ts"; + import { handleManagementAPI } from "./src/server/management-api.ts"; + import { ManagementRequest } from "./tests/helpers/management-auth.ts"; + + function listen(server) { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve(server.address().port)); + }); + } + function close(server) { + return new Promise(resolve => server.close(() => resolve())); + } + async function probe(config, name) { + saveConfig(config); + const request = new ManagementRequest( + "http://127.0.0.1/api/providers/test?name=" + encodeURIComponent(name), + { method: "POST" }, + ); + const response = await handleManagementAPI(request, new URL(request.url), config, {}); + if (!response) throw new Error("handler returned no response"); + return await response.json(); + } + + const proxyRequests = []; + const providerRequests = []; + const redirectTarget = new URL("http://final.example/v1/models?token=secret#fragment"); + redirectTarget.username = "user"; + redirectTarget.password = "password"; + const proxy = createServer((request, response) => { + proxyRequests.push(request.url || ""); + if (request.url && request.url.startsWith("http://connection-proxy.invalid/")) { + response.writeHead(302, { location: redirectTarget.toString() }); + response.end(); + return; + } + response.writeHead(200, { "content-type": "application/json" }); + response.end(request.url && request.url.startsWith("http://proxy-models.invalid/") + ? '{"data":[{"id":"proxy-discovered-model"}]}' + : '{"data":[{"id":"proxied-model"}]}'); + }); + const provider = createServer((request, response) => { + providerRequests.push(request.url || ""); + response.writeHead(200, { "content-type": "application/json" }); + response.end('{"data":[{"id":"local-model"}]}'); + }); + + try { + const ports = await Promise.all([listen(proxy), listen(provider)]); + const proxyUrl = "http://127.0.0.1:" + ports[0]; + const providerUrl = "http://127.0.0.1:" + ports[1] + "/v1"; + process.env.HTTP_PROXY = proxyUrl; + process.env.http_proxy = proxyUrl; + process.env.NO_PROXY = "localhost,127.0.0.1,::1,[::1]"; + process.env.no_proxy = "localhost,127.0.0.1,::1,[::1]"; + + const outboundResponse = await providerOutboundGet( + "proxied", + { baseUrl: "http://proxy-only.invalid/v1", allowPrivateNetwork: false }, + "http://proxy-only.invalid/v1/models", + {}, + { + // A plain object can represent the conflicting casing that process.env cannot + // express on Windows; the request must still reach the real proxy above. + proxyEnv: { HTTP_PROXY: proxyUrl, http_proxy: "" }, + }, + ); + const outbound = { + status: outboundResponse.status, + body: await outboundResponse.text(), + }; + const managementProxy = await probe({ + port: 0, + hostname: "127.0.0.1", + defaultProvider: "proxied", + providers: { + proxied: { + adapter: "openai-chat", + baseUrl: "http://connection-proxy.invalid/v1", + apiKey: "sk-x", + }, + }, + }, "proxied"); + const proxyModels = await fetchProviderModels("proxy-discovery-e2e", { + baseUrl: "http://proxy-models.invalid/v1", + adapter: "openai-chat", + apiKey: "sk-test", + models: [], + }, 0); + + const localConfig = { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "local", + providers: { + local: { + adapter: "openai-chat", + baseUrl: providerUrl, + apiKey: "sk-x", + allowPrivateNetwork: true, + }, + }, + }; + const managementNoProxy = await probe(localConfig, "local"); + for (const key of [ + "HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy", + "ALL_PROXY", "all_proxy", "NO_PROXY", "no_proxy", + ]) delete process.env[key]; + const managementDirect = await probe(localConfig, "local"); + const directModels = await fetchProviderModels("direct-discovery-e2e", { + baseUrl: providerUrl, + adapter: "openai-chat", + apiKey: "sk-test", + allowPrivateNetwork: true, + models: [], + }, 0); + + console.log(JSON.stringify({ + outbound, + managementProxy, + proxyModels: proxyModels.map(model => model.id), + managementNoProxy, + managementDirect, + directModels: directModels.map(model => model.id), + proxyRequests, + providerRequests, + })); + } finally { + await Promise.all([close(proxy), close(provider)]); + } + `; try { - const [stdout, stderr, exitCode] = await Promise.all([ - new Response(child.stdout).text(), - new Response(child.stderr).text(), - child.exited, - ]); - if (exitCode !== 0) { - throw new Error(`provider outbound fixture exited ${exitCode}: ${stderr.trim()}`); + const isolated = await runIsolatedBun(script, { + ...childEnv, + OPENCODEX_HOME: childHome, + }); + if (isolated.exitCode !== 0) { + throw new Error(`provider outbound fixture exited ${isolated.exitCode}: ${isolated.stderr.trim()}`); } - const result = JSON.parse(stdout.trim()) as { + const result = JSON.parse(isolated.stdout.trim()) as { outbound: { status: number; body: string }; - allProxy: { status: number; body: string }; managementProxy: Record; proxyModels: string[]; managementNoProxy: Record; @@ -182,10 +733,6 @@ describe("provider outbound GET transport", () => { status: 200, body: '{"data":[{"id":"proxied-model"}]}', }); - expect(result.allProxy).toEqual({ - status: 200, - body: '{"data":[{"id":"proxied-model"}]}', - }); expect(result.managementProxy.ok).toBe(false); expect(String(result.managementProxy.error)).toContain("returned 302 redirect"); expect(String(result.managementProxy.error)).toContain("http://final.example/v1/models"); @@ -199,10 +746,9 @@ describe("provider outbound GET transport", () => { "http://proxy-only.invalid/v1/models", "http://connection-proxy.invalid/v1/models", "http://proxy-models.invalid/v1/models", - "http://all-proxy-only.invalid/v1/models", ]); expect(result.providerRequests).toEqual(["/v1/models", "/v1/models", "/v1/models"]); - expect(stderr).toContain("cannot be pinned locally"); + expect(isolated.stderr).toContain("cannot be pinned locally"); } finally { rmSync(childHome, { recursive: true, force: true }); } diff --git a/tests/proxy-env.test.ts b/tests/proxy-env.test.ts index 246cbfbd2..56c424e95 100644 --- a/tests/proxy-env.test.ts +++ b/tests/proxy-env.test.ts @@ -1,20 +1,24 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { applyProxyEnv } from "../src/config"; +import { PROXY_ENV_KEYS } from "../src/lib/proxy-env"; import type { OcxConfig } from "../src/types"; -const PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy", "OCX_TEST_PROXY_REF"] as const; +const PROXY_ENV_CASED_KEYS = [ + ...PROXY_ENV_KEYS.flatMap(key => [key, key.toLowerCase()]), + "OCX_TEST_PROXY_REF", +] as const; let saved: Record; beforeEach(() => { saved = {}; - for (const key of PROXY_ENV_KEYS) { + for (const key of PROXY_ENV_CASED_KEYS) { saved[key] = process.env[key]; delete process.env[key]; } }); afterEach(() => { - for (const key of PROXY_ENV_KEYS) { + for (const key of PROXY_ENV_CASED_KEYS) { if (saved[key] === undefined) delete process.env[key]; else process.env[key] = saved[key]; } @@ -32,6 +36,28 @@ describe("applyProxyEnv", () => { expect(process.env.NO_PROXY).toBeUndefined(); }); + test("adds loopback exclusions when the proxy comes only from the environment", () => { + process.env.HTTPS_PROXY = "http://environment-proxy:3128"; + + applyProxyEnv(configWithProxy(undefined)); + + expect(process.env.HTTPS_PROXY).toBe("http://environment-proxy:3128"); + expect(process.env.HTTP_PROXY).toBeUndefined(); + expect(process.env.NO_PROXY).toBe("localhost,127.0.0.1,::1,[::1]"); + }); + + test("does not treat unsupported ALL_PROXY as an active Bun proxy", () => { + const env: Record = { + ALL_PROXY: "http://environment-proxy:3128", + }; + + applyProxyEnv(configWithProxy(undefined), env); + + expect(env.ALL_PROXY).toBe("http://environment-proxy:3128"); + expect(env.NO_PROXY).toBeUndefined(); + expect(env.no_proxy).toBeUndefined(); + }); + test("mirrors config.proxy into HTTP(S)_PROXY and excludes loopback (IPv4 + IPv6)", () => { applyProxyEnv(configWithProxy("http://proxy.corp:8080")); expect(process.env.HTTP_PROXY).toBe("http://proxy.corp:8080"); @@ -39,6 +65,19 @@ describe("applyProxyEnv", () => { expect(process.env.NO_PROXY).toBe("localhost,127.0.0.1,::1,[::1]"); }); + test("writes config.proxy to lowercase keys when empty lowercase values shadow uppercase", () => { + const env: Record = { + http_proxy: "", + https_proxy: "", + }; + applyProxyEnv(configWithProxy("http://proxy.corp:8080"), env); + + expect(env.http_proxy).toBe("http://proxy.corp:8080"); + expect(env.https_proxy).toBe("http://proxy.corp:8080"); + expect(env.HTTP_PROXY).toBeUndefined(); + expect(env.HTTPS_PROXY).toBeUndefined(); + }); + test("user-set env vars win over config", () => { process.env.HTTPS_PROXY = "http://user-proxy:3128"; applyProxyEnv(configWithProxy("http://proxy.corp:8080")); @@ -52,6 +91,19 @@ describe("applyProxyEnv", () => { expect(process.env.NO_PROXY).toBe("internal.corp,localhost,127.0.0.1,::1,[::1]"); }); + test("appends loopback entries to the lowercase no_proxy selected by Bun", () => { + const env: Record = { + HTTP_PROXY: "http://environment-proxy:3128", + NO_PROXY: "upper.example", + no_proxy: "lower.example", + }; + + applyProxyEnv(configWithProxy(undefined), env); + + expect(env.NO_PROXY).toBe("upper.example"); + expect(env.no_proxy).toBe("lower.example,localhost,127.0.0.1,::1,[::1]"); + }); + test("dedup is case-insensitive against existing entries", () => { process.env.NO_PROXY = "LOCALHOST,[::1]"; applyProxyEnv(configWithProxy("http://proxy.corp:8080")); diff --git a/tests/server-auth.test.ts b/tests/server-auth.test.ts index 1576f6d5d..56e09eb15 100644 --- a/tests/server-auth.test.ts +++ b/tests/server-auth.test.ts @@ -32,6 +32,7 @@ import { handleManagementAPI } from "../src/server/management-api"; import type { OcxConfig } from "../src/types"; import { fakeChatGptJwt } from "./helpers/fake-chatgpt-jwt"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; +import { requiredManagementHeaders as managementHeaders } from "./helpers/management-auth"; import { configuredAdminToken } from "../src/lib/admin-secrets"; const previousApiToken = process.env.OPENCODEX_API_AUTH_TOKEN; @@ -66,14 +67,6 @@ function config(hostname?: string): OcxConfig { }; } -function managementHeaders(initial?: HeadersInit): Headers { - const token = configuredAdminToken(); - if (!token) throw new Error("management token was not initialized"); - const headers = new Headers(initial); - headers.set("x-opencodex-api-key", token); - return headers; -} - const canonicalDirect = { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", @@ -2017,7 +2010,7 @@ describe("server local API auth", () => { await stopPoolRetryHarness(harness); } } - }); + }, 12_000); test("alternate account resolution failure preserves the original 400", async () => { const body = unsupportedModelBody(); diff --git a/tests/server-key-failover-e2e.test.ts b/tests/server-key-failover-e2e.test.ts index b17fff718..076978d67 100644 --- a/tests/server-key-failover-e2e.test.ts +++ b/tests/server-key-failover-e2e.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, setDefaultTimeout, test } from "bun:test"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -14,6 +14,8 @@ let previousHome: string | undefined; let isolatedCodexHome: IsolatedCodexHome | null = null; let upstream: ReturnType | null = null; +setDefaultTimeout(30_000); + beforeEach(() => { previousHome = process.env.OPENCODEX_HOME; isolatedCodexHome = installIsolatedCodexHome("ocx-keyfail-e2e-codex-"); diff --git a/tests/server-kiro-oauth-401-replay.test.ts b/tests/server-kiro-oauth-401-replay.test.ts index c063169eb..8b49e310d 100644 --- a/tests/server-kiro-oauth-401-replay.test.ts +++ b/tests/server-kiro-oauth-401-replay.test.ts @@ -257,7 +257,7 @@ describe("Kiro OAuth upstream 401 replay", () => { } finally { server.stop(true); } - }); + }, 15_000); test("a second 401 is propagated without a second refresh or replay", async () => { await seedOAuth(); diff --git a/tests/server-management-auth.test.ts b/tests/server-management-auth.test.ts index 1484eb2eb..dc1e72bcc 100644 --- a/tests/server-management-auth.test.ts +++ b/tests/server-management-auth.test.ts @@ -1,5 +1,5 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveConfig } from "../src/config"; @@ -17,10 +17,14 @@ import { setIcaclsRunnerForTests, setPlatformForTests, } from "../src/lib/windows-secret-acl"; +import { removeOwnedConfigState } from "../src/lib/config-ownership"; +import { requiredManagementHeaders } from "./helpers/management-auth"; const previousHome = process.env.OPENCODEX_HOME; const previousDataToken = process.env.OPENCODEX_API_AUTH_TOKEN; const previousAdminToken = process.env.OPENCODEX_ADMIN_AUTH_TOKEN; +const WEBSOCKET_HANDSHAKE_GUARD_MS = 2_000; +const WEBSOCKET_AUTH_TEST_TIMEOUT_MS = 10_000; let testHome = ""; function remoteConfig(): OcxConfig { @@ -57,7 +61,7 @@ function websocketHandshakeOpens(url: URL, token: string): Promise { socket.addEventListener("open", () => finish(true)); socket.addEventListener("error", () => finish(false)); socket.addEventListener("close", () => finish(false)); - const timer = setTimeout(() => finish(false), 5_000); + const timer = setTimeout(() => finish(false), WEBSOCKET_HANDSHAKE_GUARD_MS); }); } @@ -83,6 +87,12 @@ afterEach(() => { }); describe("management and data-plane credential separation", () => { + test("strict management headers fail fast when no token is initialized", () => { + delete process.env.OPENCODEX_ADMIN_AUTH_TOKEN; + + expect(() => requiredManagementHeaders()).toThrow("management token was not initialized"); + }); + test("data and management environment tokens authorize only their own planes", async () => { saveConfig(remoteConfig()); const server = startServer(0); @@ -163,6 +173,10 @@ describe("management and data-plane credential separation", () => { try { const adminToken = readFileSync(join(testHome, "admin-api-token"), "utf8").trim(); expect(adminToken).toMatch(/^ocx_admin_[A-Za-z0-9_-]{43}$/); + const uninstallManifest = JSON.parse( + readFileSync(join(testHome, ".opencodex-uninstall.json"), "utf8"), + ) as { paths: string[] }; + expect(uninstallManifest.paths).toContain("admin-api-token"); const management = await fetch(new URL("/api/config", server.url), { headers: { "x-opencodex-api-key": adminToken }, @@ -178,6 +192,38 @@ describe("management and data-plane credential separation", () => { } }); + test("a valid ownership manifest that cannot register the token keeps management unavailable", () => { + delete process.env.OPENCODEX_ADMIN_AUTH_TOKEN; + const ownerId = "00000000-0000-4000-8000-000000000001"; + const root = realpathSync.native(testHome); + const owner = { version: 1, ownerId, root }; + const manifest = { + ...owner, + paths: Array.from({ length: 1_024 }, (_, index) => `legacy-${index}`), + }; + writeFileSync(join(testHome, ".opencodex-owner.json"), `${JSON.stringify(owner)}\n`, { mode: 0o600 }); + writeFileSync(join(testHome, ".opencodex-uninstall.json"), `${JSON.stringify(manifest)}\n`, { mode: 0o600 }); + + const state = initializeManagementAuthState(remoteConfig()); + + expect(state.available).toBe(false); + expect(existsSync(join(testHome, "admin-api-token"))).toBe(false); + }); + + test("a legacy nonempty config directory remains unclaimed while management initializes", () => { + delete process.env.OPENCODEX_ADMIN_AUTH_TOKEN; + writeFileSync(join(testHome, "legacy-config.json"), "{}\n", { mode: 0o600 }); + + const state = initializeManagementAuthState(remoteConfig()); + + expect(state.available).toBe(true); + expect(existsSync(join(testHome, "admin-api-token"))).toBe(true); + expect(existsSync(join(testHome, ".opencodex-owner.json"))).toBe(false); + expect(existsSync(join(testHome, ".opencodex-uninstall.json"))).toBe(false); + expect(readFileSync(join(testHome, "legacy-config.json"), "utf8")).toBe("{}\n"); + expect(removeOwnedConfigState(testHome).status).toBe("refused"); + }); + test("an icacls timeout keeps the management plane closed without stopping the data plane", async () => { delete process.env.OPENCODEX_ADMIN_AUTH_TOKEN; saveConfig(remoteConfig()); @@ -269,7 +315,12 @@ describe("management and data-plane credential separation", () => { config.hostname = "127.0.0.1"; const state = initializeManagementAuthState(config); const pageRequest = new Request("http://localhost:10100/", { - headers: { Host: "localhost:10100" }, + headers: { + Host: "localhost:10100", + "Sec-Fetch-Dest": "document", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "none", + }, }); const session = issueGuiSession(pageRequest, config, state); expect(session).not.toBeNull(); @@ -283,6 +334,15 @@ describe("management and data-plane credential separation", () => { const html = await page?.text(); expect(html).toContain(`name="opencodex-session-token" content="${session?.token}"`); expect(html).toContain(`name="opencodex-session-csrf" content="${session?.csrfToken}"`); + const escapedPage = serveGuiFile("/", guiDist, { + token: `ocx_session_\">`, + csrfToken: `csrf\" onfocus=\"unsafe`, + origin: `http://localhost:10100/\">`, + expiresAt: Date.now() + 60_000, + }); + const escapedHtml = await escapedPage?.text(); + expect(escapedHtml).not.toContain(""); + expect(escapedHtml).toContain(""><script>unsafe</script>"); const sameOriginRead = new Request("http://localhost:10100/api/config", { headers: { @@ -330,6 +390,54 @@ describe("management and data-plane credential separation", () => { headers: { Host: "attacker.test" }, }), config, state)).toBeNull(); expect(issueGuiSession(new Request("http://localhost:10100/"), config, state)).toBeNull(); + expect(issueGuiSession(new Request("http://localhost:10100/anything", { + headers: { + Host: "localhost:10100", + "Sec-Fetch-Dest": "document", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "same-origin", + }, + }), config, state)).toBeNull(); + expect(issueGuiSession(new Request("http://localhost:10100/", { + headers: { + Host: "localhost:10100", + "Sec-Fetch-Dest": "image", + "Sec-Fetch-Mode": "no-cors", + "Sec-Fetch-Site": "cross-site", + }, + }), config, state)).toBeNull(); + }); + + test("an active local GUI session expires after inactivity instead of page age", () => { + const now = spyOn(Date, "now").mockReturnValue(1_700_000_000_000); + try { + const config = remoteConfig(); + config.hostname = "127.0.0.1"; + const state = initializeManagementAuthState(config); + const session = issueGuiSession(new Request("http://localhost:10100/", { + headers: { + Host: "localhost:10100", + "Sec-Fetch-Dest": "document", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "none", + }, + }), config, state); + expect(session).not.toBeNull(); + + const request = () => new Request("http://localhost:10100/api/config", { + headers: { + Host: "localhost:10100", + "x-opencodex-api-key": session?.token ?? "", + "x-opencodex-gui-origin": "http://localhost:10100", + }, + }); + now.mockReturnValue(1_700_000_000_000 + 4 * 60_000); + expect(requireManagementAuth(request(), state, config)).toBeNull(); + now.mockReturnValue(1_700_000_000_000 + 6 * 60_000); + expect(requireManagementAuth(request(), state, config)).toBeNull(); + } finally { + now.mockRestore(); + } }); test("a non-loopback binding never issues a GUI session from a forged loopback Host", () => { @@ -393,12 +501,16 @@ describe("management and data-plane credential separation", () => { } finally { await server.stop(true); } - }); + }, WEBSOCKET_AUTH_TEST_TIMEOUT_MS); test("an invalid existing management token file keeps management unavailable", async () => { delete process.env.OPENCODEX_ADMIN_AUTH_TOKEN; saveConfig(remoteConfig()); writeFileSync(join(testHome, "admin-api-token"), "corrupt-token\n", { mode: 0o600 }); + const warnings: string[] = []; + const warning = spyOn(console, "warn").mockImplementation((...args: unknown[]) => { + warnings.push(args.map(String).join(" ")); + }); const server = startServer(0); try { const management = await fetch(new URL("/api/config", server.url), { @@ -407,8 +519,16 @@ describe("management and data-plane credential separation", () => { expect(management.status).toBe(503); expect(readFileSync(join(testHome, "admin-api-token"), "utf8")).toBe("corrupt-token\n"); expect((await fetch(new URL("/healthz", server.url))).status).toBe(200); + expect(warnings).toContain( + "[opencodex] Management API disabled: its credential could not be initialized securely. " + + "Check the protected management token file and its permissions, then restart OpenCodex. " + + "Data-plane routes and /healthz remain available.", + ); + expect(warnings.join("\n")).not.toContain(testHome); + expect(warnings.join("\n")).not.toContain("corrupt-token"); } finally { await server.stop(true); + warning.mockRestore(); } }); diff --git a/tests/service-admin-token.test.ts b/tests/service-admin-token.test.ts new file mode 100644 index 000000000..4a2052d74 --- /dev/null +++ b/tests/service-admin-token.test.ts @@ -0,0 +1,1466 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { + adminApiTokenFilePath, + configuredAdminToken, + loadServiceAdminTokenFromFile, + serviceAdminTokenFilePath, +} from "../src/lib/admin-secrets"; +import { recordOwnedConfigPath } from "../src/lib/config-ownership"; +import { + loadServiceTokensIntoEnv, + removeServiceTokenFiles, + serviceApiTokenFilePath, +} from "../src/lib/service-secrets"; +import { buildWinswXml } from "../src/lib/winsw"; +import { initializeManagementAuthState } from "../src/server/management-auth"; +import { + isProxyAdmissionSecret, + validateForwardAdmissionCredential, +} from "../src/server/auth-cors"; +import type { OcxConfig } from "../src/types"; +import { + resetHardenedStateForTests, + setIcaclsRunnerForTests, + setPlatformForTests, +} from "../src/lib/windows-secret-acl"; +import { + buildPlist, + buildUnit, + buildWindowsServiceScript, + withServiceTokenInstallTransaction, + withServiceTokenInstallTransactionAsync, + writeServiceApiTokenFile, + writeServiceAdminTokenFile, +} from "../src/service"; + +const tempRoots: string[] = []; +const originalOpenCodexHome = process.env.OPENCODEX_HOME; +const originalAdminToken = process.env.OPENCODEX_ADMIN_AUTH_TOKEN; +const originalAdminTokenFile = process.env.OCX_ADMIN_TOKEN_FILE; +const originalUsername = process.env.USERNAME; +const originalUserDomain = process.env.USERDOMAIN; + +function tempRoot(): string { + const root = mkdtempSync(join(tmpdir(), "ocx-service-admin-")); + tempRoots.push(root); + return root; +} + +const recordOwnedPathForTransactionTest = () => true; + +afterEach(() => { + setIcaclsRunnerForTests(null); + setPlatformForTests(null); + resetHardenedStateForTests(); + if (originalOpenCodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = originalOpenCodexHome; + if (originalAdminToken === undefined) delete process.env.OPENCODEX_ADMIN_AUTH_TOKEN; + else process.env.OPENCODEX_ADMIN_AUTH_TOKEN = originalAdminToken; + if (originalAdminTokenFile === undefined) delete process.env.OCX_ADMIN_TOKEN_FILE; + else process.env.OCX_ADMIN_TOKEN_FILE = originalAdminTokenFile; + if (originalUsername === undefined) delete process.env.USERNAME; + else process.env.USERNAME = originalUsername; + if (originalUserDomain === undefined) delete process.env.USERDOMAIN; + else process.env.USERDOMAIN = originalUserDomain; + for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("service management token delivery", () => { + test("an automatic reinstall preserves both existing service credentials", () => { + const root = tempRoot(); + const dataPath = serviceApiTokenFilePath(root); + const adminPath = serviceAdminTokenFilePath(root); + const originalData = Buffer.from("data-for-automation\n", "utf8"); + const originalAdmin = Buffer.from("admin-for-automation\n", "utf8"); + writeFileSync(dataPath, originalData); + writeFileSync(adminPath, originalAdmin); + + const state = withServiceTokenInstallTransaction(definitionState => definitionState, { + configDir: root, + env: {}, + platform: "linux", + recordOwnedPath: recordOwnedPathForTransactionTest, + config: { apiKeys: [] }, + }); + + expect(state.adminTokenFile).toBe(adminPath); + expect(readFileSync(dataPath)).toEqual(originalData); + expect(readFileSync(adminPath)).toEqual(originalAdmin); + }); + + test("rejects an admin credential matching the preserved service data credential", () => { + const root = tempRoot(); + const dataPath = serviceApiTokenFilePath(root); + const adminPath = serviceAdminTokenFilePath(root); + writeFileSync(dataPath, "shared-service-secret\n", "utf8"); + writeFileSync(adminPath, "original-admin\n", "utf8"); + let platformCalled = false; + + expect(() => withServiceTokenInstallTransaction(() => { + platformCalled = true; + }, { + configDir: root, + env: { OPENCODEX_ADMIN_AUTH_TOKEN: "shared-service-secret" }, + platform: "linux", + recordOwnedPath: recordOwnedPathForTransactionTest, + config: { apiKeys: [] }, + })).toThrow(/management credential conflicts with a data-plane credential/); + + expect(platformCalled).toBe(false); + expect(readFileSync(dataPath, "utf8")).toBe("shared-service-secret\n"); + expect(readFileSync(adminPath, "utf8")).toBe("original-admin\n"); + }); + + test("a failed install restores service assets together with both credentials", () => { + const root = tempRoot(); + const dataPath = serviceApiTokenFilePath(root); + const adminPath = serviceAdminTokenFilePath(root); + const assetPath = join(root, "service-definition.xml"); + writeFileSync(dataPath, "original-data\n", "utf8"); + writeFileSync(adminPath, "original-admin\n", "utf8"); + writeFileSync(assetPath, "original-definition\n", "utf8"); + let restoredDefinition = ""; + + expect(() => withServiceTokenInstallTransaction(() => { + writeFileSync(assetPath, "replacement-definition\n", "utf8"); + throw new Error("platform install failed"); + }, { + configDir: root, + env: { + OPENCODEX_API_AUTH_TOKEN: "replacement-data", + OPENCODEX_ADMIN_AUTH_TOKEN: "replacement-admin", + }, + platform: "linux", + recordOwnedPath: recordOwnedPathForTransactionTest, + serviceAssetPaths: [assetPath], + restoreServiceDefinition: () => { + restoredDefinition = readFileSync(assetPath, "utf8"); + }, + })).toThrow(/platform install failed/); + + expect(readFileSync(dataPath, "utf8")).toBe("original-data\n"); + expect(readFileSync(adminPath, "utf8")).toBe("original-admin\n"); + expect(readFileSync(assetPath, "utf8")).toBe("original-definition\n"); + expect(restoredDefinition).toBe("original-definition\n"); + }); + + test("rejects conflicting explicit service credentials before install mutation", () => { + const root = tempRoot(); + const dataPath = serviceApiTokenFilePath(root); + const adminPath = serviceAdminTokenFilePath(root); + writeFileSync(dataPath, "original-data\n", "utf8"); + writeFileSync(adminPath, "original-admin\n", "utf8"); + let platformCalled = false; + + expect(() => withServiceTokenInstallTransaction(() => { + platformCalled = true; + }, { + configDir: root, + env: { + OPENCODEX_API_AUTH_TOKEN: "shared-secret", + OPENCODEX_ADMIN_AUTH_TOKEN: "shared-secret", + }, + platform: "linux", + recordOwnedPath: recordOwnedPathForTransactionTest, + config: { apiKeys: [] }, + })).toThrow(/management credential conflicts with a data-plane credential/); + + expect(platformCalled).toBe(false); + expect(readFileSync(dataPath, "utf8")).toBe("original-data\n"); + expect(readFileSync(adminPath, "utf8")).toBe("original-admin\n"); + }); + + test("rejects an explicit service management credential matching config apiKeys", () => { + const root = tempRoot(); + let platformCalled = false; + + expect(() => withServiceTokenInstallTransaction(() => { + platformCalled = true; + }, { + configDir: root, + env: { OPENCODEX_ADMIN_AUTH_TOKEN: "configured-data-secret" }, + platform: "linux", + recordOwnedPath: recordOwnedPathForTransactionTest, + config: { + apiKeys: [{ + id: "data-key", + name: "Configured data key", + key: "configured-data-secret", + createdAt: "2026-07-29T00:00:00.000Z", + }], + }, + })).toThrow(/management credential conflicts with a data-plane credential/); + + expect(platformCalled).toBe(false); + expect(existsSync(serviceApiTokenFilePath(root))).toBe(false); + expect(existsSync(serviceAdminTokenFilePath(root))).toBe(false); + }); + + test("rejects control characters in a file-delivered service management token", () => { + const root = tempRoot(); + const path = serviceAdminTokenFilePath(root); + for (const token of ["line-one\nline-two", "line-one\rline-two", "token\0suffix"]) { + writeFileSync(path, token, "utf8"); + expect(loadServiceAdminTokenFromFile({}, root)).toBeNull(); + } + }); + + test("fails closed when a fresh data service token cannot be registered as owned", () => { + const root = tempRoot(); + + expect(() => writeServiceApiTokenFile({ + configDir: root, + env: { OPENCODEX_API_AUTH_TOKEN: "fresh-data" }, + platform: "linux", + recordOwnedPath: () => false, + })).toThrow(/ownership manifest/); + expect(existsSync(serviceApiTokenFilePath(root))).toBe(false); + }); + + test("does not treat an ownership-infrastructure-only directory as legacy", () => { + const root = tempRoot(); + writeFileSync( + join(root, ".opencodex-owner-recovery.lock"), + `${process.pid}:00000000-0000-4000-8000-000000000015\n`, + "utf8", + ); + + expect(() => writeServiceAdminTokenFile({ + configDir: root, + env: { OPENCODEX_ADMIN_AUTH_TOKEN: "fresh-admin" }, + platform: "linux", + recordOwnedPath: () => false, + })).toThrow(/ownership manifest/); + expect(existsSync(serviceAdminTokenFilePath(root))).toBe(false); + }); + + test("a fresh explicit install failure leaves no service token files behind", () => { + const root = tempRoot(); + + expect(() => withServiceTokenInstallTransaction(() => { + throw new Error("platform install failed"); + }, { + configDir: root, + env: { + OPENCODEX_API_AUTH_TOKEN: "fresh-data", + OPENCODEX_ADMIN_AUTH_TOKEN: "fresh-admin", + }, + platform: "linux", + recordOwnedPath: recordOwnedPathForTransactionTest, + })).toThrow(/platform install failed/); + + expect(existsSync(serviceApiTokenFilePath(root))).toBe(false); + expect(existsSync(serviceAdminTokenFilePath(root))).toBe(false); + }); + + test("a failed explicit rotation restores both service token files byte-for-byte", () => { + const root = tempRoot(); + const dataPath = serviceApiTokenFilePath(root); + const adminPath = serviceAdminTokenFilePath(root); + const originalData = Buffer.from(" original-data \r\n", "utf8"); + const originalAdmin = Buffer.from(" original-admin \n", "utf8"); + writeFileSync(dataPath, originalData); + writeFileSync(adminPath, originalAdmin); + + expect(() => withServiceTokenInstallTransaction(() => { + expect(readFileSync(dataPath, "utf8")).toBe("replacement-data\n"); + expect(readFileSync(adminPath, "utf8")).toBe("replacement-admin\n"); + throw new Error("platform install failed"); + }, { + configDir: root, + env: { + OPENCODEX_API_AUTH_TOKEN: "replacement-data", + OPENCODEX_ADMIN_AUTH_TOKEN: "replacement-admin", + }, + platform: "linux", + recordOwnedPath: recordOwnedPathForTransactionTest, + })).toThrow(/platform install failed/); + + expect(readFileSync(dataPath)).toEqual(originalData); + expect(readFileSync(adminPath)).toEqual(originalAdmin); + }); + + test("an asynchronous native-service failure restores both service token files", async () => { + const root = tempRoot(); + const dataPath = serviceApiTokenFilePath(root); + const adminPath = serviceAdminTokenFilePath(root); + writeFileSync(dataPath, "original-data\n", "utf8"); + writeFileSync(adminPath, "original-admin\n", "utf8"); + + await expect(withServiceTokenInstallTransactionAsync(async () => { + await Promise.resolve(); + throw new Error("async platform install failed"); + }, { + configDir: root, + env: { + OPENCODEX_API_AUTH_TOKEN: "replacement-data", + OPENCODEX_ADMIN_AUTH_TOKEN: "replacement-admin", + }, + platform: "linux", + recordOwnedPath: recordOwnedPathForTransactionTest, + })).rejects.toThrow(/async platform install failed/); + + expect(readFileSync(dataPath, "utf8")).toBe("original-data\n"); + expect(readFileSync(adminPath, "utf8")).toBe("original-admin\n"); + }); + + test("a failed install restores a service admin token that the plan revoked", () => { + const root = tempRoot(); + const adminPath = serviceAdminTokenFilePath(root); + const originalAdmin = Buffer.from("admin-before-revocation\n", "utf8"); + writeFileSync(adminPath, originalAdmin); + + expect(() => withServiceTokenInstallTransaction(() => { + expect(existsSync(adminPath)).toBe(false); + throw new Error("platform install failed"); + }, { + configDir: root, + env: { OPENCODEX_ADMIN_AUTH_TOKEN: "" }, + platform: "linux", + recordOwnedPath: recordOwnedPathForTransactionTest, + })).toThrow(/platform install failed/); + + expect(readFileSync(adminPath)).toEqual(originalAdmin); + }); + + test("an oversized admin plan cannot partially update the data service token", () => { + const root = tempRoot(); + const dataPath = serviceApiTokenFilePath(root); + const adminPath = serviceAdminTokenFilePath(root); + writeFileSync(dataPath, "original-data\n", "utf8"); + writeFileSync(adminPath, "original-admin\n", "utf8"); + let platformCalled = false; + + expect(() => withServiceTokenInstallTransaction(() => { + platformCalled = true; + }, { + configDir: root, + env: { + OPENCODEX_API_AUTH_TOKEN: "replacement-data", + OPENCODEX_ADMIN_AUTH_TOKEN: "\u00e9".repeat(256), + }, + platform: "linux", + recordOwnedPath: recordOwnedPathForTransactionTest, + })).toThrow(/512 UTF-8 bytes/); + + expect(platformCalled).toBe(false); + expect(readFileSync(dataPath, "utf8")).toBe("original-data\n"); + expect(readFileSync(adminPath, "utf8")).toBe("original-admin\n"); + }); + + test("a trailing newline in the raw data token is rejected before any install mutation", () => { + const root = tempRoot(); + const dataPath = serviceApiTokenFilePath(root); + const adminPath = serviceAdminTokenFilePath(root); + writeFileSync(dataPath, "original-data\n", "utf8"); + writeFileSync(adminPath, "original-admin\n", "utf8"); + let platformCalled = false; + + expect(() => withServiceTokenInstallTransaction(() => { + platformCalled = true; + }, { + configDir: root, + env: { + OPENCODEX_API_AUTH_TOKEN: "replacement-data\r\n", + OPENCODEX_ADMIN_AUTH_TOKEN: "replacement-admin", + }, + platform: "linux", + recordOwnedPath: recordOwnedPathForTransactionTest, + })).toThrow(/OPENCODEX_API_AUTH_TOKEN cannot contain CR, LF, or NUL/); + + expect(platformCalled).toBe(false); + expect(readFileSync(dataPath, "utf8")).toBe("original-data\n"); + expect(readFileSync(adminPath, "utf8")).toBe("original-admin\n"); + }); + + test("an embedded newline in the raw data token is rejected before any install mutation", () => { + const root = tempRoot(); + const dataPath = serviceApiTokenFilePath(root); + const adminPath = serviceAdminTokenFilePath(root); + writeFileSync(dataPath, "original-data\n", "utf8"); + writeFileSync(adminPath, "original-admin\n", "utf8"); + let platformCalled = false; + + expect(() => withServiceTokenInstallTransaction(() => { + platformCalled = true; + }, { + configDir: root, + env: { + OPENCODEX_API_AUTH_TOKEN: "replacement\ndata", + OPENCODEX_ADMIN_AUTH_TOKEN: "replacement-admin", + }, + platform: "linux", + recordOwnedPath: recordOwnedPathForTransactionTest, + })).toThrow(/OPENCODEX_API_AUTH_TOKEN cannot contain CR, LF, or NUL/); + + expect(platformCalled).toBe(false); + expect(readFileSync(dataPath, "utf8")).toBe("original-data\n"); + expect(readFileSync(adminPath, "utf8")).toBe("original-admin\n"); + }); + + test("a NUL in the raw data token is rejected before any install mutation", () => { + const root = tempRoot(); + const dataPath = serviceApiTokenFilePath(root); + const adminPath = serviceAdminTokenFilePath(root); + writeFileSync(dataPath, "original-data\n", "utf8"); + writeFileSync(adminPath, "original-admin\n", "utf8"); + let platformCalled = false; + + expect(() => withServiceTokenInstallTransaction(() => { + platformCalled = true; + }, { + configDir: root, + env: { + OPENCODEX_API_AUTH_TOKEN: "replacement\0data", + OPENCODEX_ADMIN_AUTH_TOKEN: "replacement-admin", + }, + platform: "linux", + recordOwnedPath: recordOwnedPathForTransactionTest, + })).toThrow(/OPENCODEX_API_AUTH_TOKEN cannot contain CR, LF, or NUL/); + + expect(platformCalled).toBe(false); + expect(readFileSync(dataPath, "utf8")).toBe("original-data\n"); + expect(readFileSync(adminPath, "utf8")).toBe("original-admin\n"); + }); + + test("control-only raw data tokens are rejected instead of revoking existing tokens", () => { + for (const rawToken of ["\t", "\x01"]) { + const root = tempRoot(); + const dataPath = serviceApiTokenFilePath(root); + const adminPath = serviceAdminTokenFilePath(root); + writeFileSync(dataPath, "original-data\n", "utf8"); + writeFileSync(adminPath, "original-admin\n", "utf8"); + let platformCalled = false; + + expect(() => withServiceTokenInstallTransaction(() => { + platformCalled = true; + }, { + configDir: root, + env: { + OPENCODEX_API_AUTH_TOKEN: rawToken, + OPENCODEX_ADMIN_AUTH_TOKEN: "replacement-admin", + }, + platform: "linux", + recordOwnedPath: recordOwnedPathForTransactionTest, + })).toThrow(/ASCII control characters/); + + expect(platformCalled).toBe(false); + expect(readFileSync(dataPath, "utf8")).toBe("original-data\n"); + expect(readFileSync(adminPath, "utf8")).toBe("original-admin\n"); + } + }); + + test("a non-empty whitespace-only data token is rejected instead of revoking existing tokens", () => { + const root = tempRoot(); + const dataPath = serviceApiTokenFilePath(root); + const adminPath = serviceAdminTokenFilePath(root); + writeFileSync(dataPath, "original-data\n", "utf8"); + writeFileSync(adminPath, "original-admin\n", "utf8"); + let platformCalled = false; + + expect(() => withServiceTokenInstallTransaction(() => { + platformCalled = true; + }, { + configDir: root, + env: { + OPENCODEX_API_AUTH_TOKEN: " ", + OPENCODEX_ADMIN_AUTH_TOKEN: "replacement-admin", + }, + platform: "linux", + recordOwnedPath: recordOwnedPathForTransactionTest, + })).toThrow(/cannot be whitespace-only/); + + expect(platformCalled).toBe(false); + expect(readFileSync(dataPath, "utf8")).toBe("original-data\n"); + expect(readFileSync(adminPath, "utf8")).toBe("original-admin\n"); + }); + + test("an explicitly empty data token keeps the existing revoke behavior", () => { + const root = tempRoot(); + const dataPath = serviceApiTokenFilePath(root); + const adminPath = serviceAdminTokenFilePath(root); + writeFileSync(dataPath, "original-data\n", "utf8"); + let platformCalled = false; + + withServiceTokenInstallTransaction(() => { + platformCalled = true; + expect(() => readFileSync(dataPath, "utf8")).toThrow(); + }, { + configDir: root, + env: { + OPENCODEX_API_AUTH_TOKEN: "", + OPENCODEX_ADMIN_AUTH_TOKEN: "replacement-admin", + }, + platform: "linux", + recordOwnedPath: recordOwnedPathForTransactionTest, + }); + + expect(platformCalled).toBe(true); + expect(() => readFileSync(dataPath, "utf8")).toThrow(); + expect(readFileSync(adminPath, "utf8")).toBe("replacement-admin\n"); + }); + + test("a long single-line data token remains supported", () => { + const root = tempRoot(); + const dataPath = serviceApiTokenFilePath(root); + const longToken = "x".repeat(4096); + let platformCalled = false; + + withServiceTokenInstallTransaction(() => { + platformCalled = true; + expect(readFileSync(dataPath, "utf8")).toBe(`${longToken}\n`); + }, { + configDir: root, + env: { + OPENCODEX_API_AUTH_TOKEN: longToken, + OPENCODEX_ADMIN_AUTH_TOKEN: "replacement-admin", + }, + platform: "linux", + recordOwnedPath: recordOwnedPathForTransactionTest, + }); + + expect(platformCalled).toBe(true); + expect(readFileSync(dataPath, "utf8")).toBe(`${longToken}\n`); + }); + + test("an admin ACL failure rolls back an already-updated data service token", () => { + const root = tempRoot(); + const dataPath = serviceApiTokenFilePath(root); + const adminPath = serviceAdminTokenFilePath(root); + writeFileSync(dataPath, "original-data\n", "utf8"); + writeFileSync(adminPath, "original-admin\n", "utf8"); + process.env.USERNAME = "ocx-test-user"; + process.env.USERDOMAIN = "OCX-TEST"; + setPlatformForTests("win32"); + resetHardenedStateForTests(); + setIcaclsRunnerForTests(args => (args[0] ?? "").includes("service-admin-token.tmp") + ? { success: false, exitCode: null, timedOut: true, stdout: "" } + : { success: true, exitCode: 0, timedOut: false, stdout: "" }); + + expect(() => withServiceTokenInstallTransaction(() => { + throw new Error("platform should not run"); + }, { + configDir: root, + env: { + OPENCODEX_API_AUTH_TOKEN: "replacement-data", + OPENCODEX_ADMIN_AUTH_TOKEN: "replacement-admin", + }, + platform: "win32", + recordOwnedPath: recordOwnedPathForTransactionTest, + })).toThrow(/management service token file ACL hardening did not complete/); + + expect(readFileSync(dataPath, "utf8")).toBe("original-data\n"); + expect(readFileSync(adminPath, "utf8")).toBe("original-admin\n"); + }); + + test("a service-file-only reinstall preserves the current admin token and definition path", () => { + const root = tempRoot(); + const adminPath = serviceAdminTokenFilePath(root); + const originalAdmin = Buffer.from("service-admin-for-update\n", "utf8"); + writeFileSync(adminPath, originalAdmin); + const env: Record = { + OPENCODEX_API_AUTH_TOKEN: "service-data-for-update", + OCX_ADMIN_TOKEN_FILE: join(root, ".", "service-admin-token"), + }; + + const state = withServiceTokenInstallTransaction(definitionState => definitionState, { + configDir: root, + env, + platform: "linux", + recordOwnedPath: recordOwnedPathForTransactionTest, + }); + + expect(state.adminTokenFile).toBe(adminPath); + expect(readFileSync(adminPath)).toEqual(originalAdmin); + expect(env.OPENCODEX_ADMIN_AUTH_TOKEN).toBeUndefined(); + }); + + test("a failed service-file-only reinstall preserves the current admin token byte-for-byte", () => { + const root = tempRoot(); + const adminPath = serviceAdminTokenFilePath(root); + const originalAdmin = Buffer.from(" service-admin-for-update \n", "utf8"); + writeFileSync(adminPath, originalAdmin); + const env: Record = { + OPENCODEX_API_AUTH_TOKEN: "service-data-for-update", + OCX_ADMIN_TOKEN_FILE: adminPath, + }; + + expect(() => withServiceTokenInstallTransaction(() => { + throw new Error("platform install failed"); + }, { + configDir: root, + env, + platform: "linux", + recordOwnedPath: recordOwnedPathForTransactionTest, + })).toThrow(/platform install failed/); + + expect(readFileSync(adminPath)).toEqual(originalAdmin); + expect(env.OPENCODEX_ADMIN_AUTH_TOKEN).toBeUndefined(); + }); + + test("a preserved service admin token ACL timeout blocks reinstall before any data update", () => { + const root = tempRoot(); + const dataPath = serviceApiTokenFilePath(root); + const adminPath = serviceAdminTokenFilePath(root); + writeFileSync(dataPath, "original-data\n", "utf8"); + writeFileSync(adminPath, "service-admin-for-update\n", "utf8"); + process.env.USERNAME = "ocx-test-user"; + process.env.USERDOMAIN = "OCX-TEST"; + setPlatformForTests("win32"); + resetHardenedStateForTests(); + setIcaclsRunnerForTests(args => args[0] === adminPath + ? { success: false, exitCode: null, timedOut: true, stdout: "" } + : { success: true, exitCode: 0, timedOut: false, stdout: "" }); + let platformCalled = false; + + expect(() => withServiceTokenInstallTransaction(() => { + platformCalled = true; + }, { + configDir: root, + env: { + OPENCODEX_API_AUTH_TOKEN: "replacement-data", + OCX_ADMIN_TOKEN_FILE: adminPath, + }, + platform: "win32", + recordOwnedPath: recordOwnedPathForTransactionTest, + })).toThrow(/preserved management service token file ACL hardening did not complete/); + + expect(platformCalled).toBe(false); + expect(readFileSync(dataPath, "utf8")).toBe("original-data\n"); + expect(readFileSync(adminPath, "utf8")).toBe("service-admin-for-update\n"); + }); + + test("a reinstall rejects an admin token pointer outside the current config directory", () => { + const root = tempRoot(); + const externalRoot = tempRoot(); + const dataPath = serviceApiTokenFilePath(root); + const externalAdminPath = serviceAdminTokenFilePath(externalRoot); + writeFileSync(dataPath, "original-data\n", "utf8"); + writeFileSync(externalAdminPath, "external-admin\n", "utf8"); + + expect(() => withServiceTokenInstallTransaction(() => { + throw new Error("platform should not run"); + }, { + configDir: root, + env: { + OPENCODEX_API_AUTH_TOKEN: "replacement-data", + OCX_ADMIN_TOKEN_FILE: externalAdminPath, + }, + platform: "linux", + recordOwnedPath: recordOwnedPathForTransactionTest, + })).toThrow(/must resolve to the current service-admin-token/); + + expect(readFileSync(dataPath, "utf8")).toBe("original-data\n"); + expect(readFileSync(externalAdminPath, "utf8")).toBe("external-admin\n"); + }); + + test("a reinstall rejects the current service admin pointer when its file is invalid", () => { + const root = tempRoot(); + const dataPath = serviceApiTokenFilePath(root); + const adminPath = serviceAdminTokenFilePath(root); + writeFileSync(dataPath, "original-data\n", "utf8"); + writeFileSync(adminPath, " \n", "utf8"); + + expect(() => withServiceTokenInstallTransaction(() => { + throw new Error("platform should not run"); + }, { + configDir: root, + env: { + OPENCODEX_API_AUTH_TOKEN: "replacement-data", + OCX_ADMIN_TOKEN_FILE: adminPath, + }, + platform: "linux", + recordOwnedPath: recordOwnedPathForTransactionTest, + })).toThrow(/does not reference a valid current service-admin-token/); + + expect(readFileSync(dataPath, "utf8")).toBe("original-data\n"); + expect(readFileSync(adminPath, "utf8")).toBe(" \n"); + }); + + test("a reinstall rejects a preserved service admin token with an embedded line break", () => { + const root = tempRoot(); + const dataPath = serviceApiTokenFilePath(root); + const adminPath = serviceAdminTokenFilePath(root); + writeFileSync(dataPath, "original-data\n", "utf8"); + writeFileSync(adminPath, "admin-line-one\nadmin-line-two\n", "utf8"); + + expect(() => withServiceTokenInstallTransaction(() => { + throw new Error("platform should not run"); + }, { + configDir: root, + env: { + OPENCODEX_API_AUTH_TOKEN: "replacement-data", + OCX_ADMIN_TOKEN_FILE: adminPath, + }, + platform: "linux", + recordOwnedPath: recordOwnedPathForTransactionTest, + })).toThrow(/does not reference a valid current service-admin-token/); + + expect(readFileSync(dataPath, "utf8")).toBe("original-data\n"); + expect(readFileSync(adminPath, "utf8")).toBe("admin-line-one\nadmin-line-two\n"); + }); + + test("all four service definitions use transaction state instead of re-reading file existence", () => { + const root = tempRoot(); + const adminPath = serviceAdminTokenFilePath(root); + process.env.OPENCODEX_HOME = root; + process.env.OPENCODEX_ADMIN_AUTH_TOKEN = "definition-admin"; + + const definitions = withServiceTokenInstallTransaction(state => { + const payload = readFileSync(adminPath); + unlinkSync(adminPath); + try { + return [ + buildPlist(state), + buildUnit(state), + buildWindowsServiceScript( + { bun: "C:\\ocx\\bun.exe", cli: "C:\\ocx\\cli.ts" }, + 10100, + state, + ), + buildWinswXml( + { bun: "C:\\ocx\\bun.exe", cli: "C:\\ocx\\cli.ts" }, + { USERDOMAIN: "OCX", USERNAME: "tester", OPENCODEX_HOME: root }, + 10100, + state, + ), + ]; + } finally { + writeFileSync(adminPath, payload, { mode: 0o600 }); + } + }, { + configDir: root, + env: { + OPENCODEX_API_AUTH_TOKEN: "definition-data", + OPENCODEX_ADMIN_AUTH_TOKEN: "definition-admin", + }, + platform: "linux", + recordOwnedPath: recordOwnedPathForTransactionTest, + }); + + for (const definition of definitions) { + expect(definition).toContain("OCX_ADMIN_TOKEN_FILE"); + const escapedAdminPath = adminPath.replace(/\\/g, "\\\\"); + expect(definition.includes(adminPath) || definition.includes(escapedAdminPath)).toBe(true); + } + }); + + test("all four service definitions keep a revoked admin token omitted despite file reappearance", () => { + const root = tempRoot(); + const adminPath = serviceAdminTokenFilePath(root); + process.env.OPENCODEX_HOME = root; + + const definitions = withServiceTokenInstallTransaction(state => { + expect(state.adminTokenFile).toBeNull(); + writeFileSync(adminPath, "unexpected-admin\n", { mode: 0o600 }); + try { + return [ + buildPlist(state), + buildUnit(state), + buildWindowsServiceScript( + { bun: "C:\\ocx\\bun.exe", cli: "C:\\ocx\\cli.ts" }, + 10100, + state, + ), + buildWinswXml( + { bun: "C:\\ocx\\bun.exe", cli: "C:\\ocx\\cli.ts" }, + { USERDOMAIN: "OCX", USERNAME: "tester", OPENCODEX_HOME: root }, + 10100, + state, + ), + ]; + } finally { + unlinkSync(adminPath); + } + }, { + configDir: root, + env: {}, + platform: "linux", + recordOwnedPath: recordOwnedPathForTransactionTest, + }); + + for (const definition of definitions) { + expect(definition).not.toContain("OCX_ADMIN_TOKEN_FILE"); + } + }); + + test("a rollback hardening failure is disclosed without hiding the platform failure", () => { + const root = tempRoot(); + writeFileSync(serviceApiTokenFilePath(root), "original-data\n", "utf8"); + writeFileSync(serviceAdminTokenFilePath(root), "original-admin\n", "utf8"); + process.env.USERNAME = "ocx-test-user"; + process.env.USERDOMAIN = "OCX-TEST"; + setPlatformForTests("win32"); + resetHardenedStateForTests(); + setIcaclsRunnerForTests(args => (args[0] ?? "").includes(".restore.") + ? { success: false, exitCode: null, timedOut: true, stdout: "" } + : { success: true, exitCode: 0, timedOut: false, stdout: "" }); + + expect(() => withServiceTokenInstallTransaction(() => { + throw new Error("platform install failed"); + }, { + configDir: root, + env: { + OPENCODEX_API_AUTH_TOKEN: "replacement-data", + OPENCODEX_ADMIN_AUTH_TOKEN: "replacement-admin", + }, + platform: "win32", + recordOwnedPath: recordOwnedPathForTransactionTest, + })).toThrow(/platform install failed; service token rollback also failed/); + }); + + test("startup loading keeps the data service token behavior without exporting the admin token", () => { + const root = tempRoot(); + const dataPath = serviceApiTokenFilePath(root); + const adminPath = serviceAdminTokenFilePath(root); + writeFileSync(dataPath, "service-data\n", "utf8"); + writeFileSync(adminPath, "service-admin\n", "utf8"); + const env: Record = { + OCX_API_TOKEN_FILE: dataPath, + OCX_ADMIN_TOKEN_FILE: adminPath, + }; + + loadServiceTokensIntoEnv(env, root); + + expect(env.OPENCODEX_API_AUTH_TOKEN).toBe("service-data"); + expect(env.OPENCODEX_ADMIN_AUTH_TOKEN).toBeUndefined(); + }); + + test("startup loading discovers the data service token from the supplied config directory", () => { + const root = tempRoot(); + writeFileSync(serviceApiTokenFilePath(root), "directory-data-token\n", "utf8"); + const env: Record = {}; + + loadServiceTokensIntoEnv(env, root); + + expect(env.OPENCODEX_API_AUTH_TOKEN).toBe("directory-data-token"); + }); + + test("a data-token ACL timeout preserves the previous token and removes the unsecured replacement", () => { + const root = tempRoot(); + const tokenPath = serviceApiTokenFilePath(root); + writeFileSync(tokenPath, "previous-data\n", "utf8"); + process.env.USERNAME = "ocx-test-user"; + process.env.USERDOMAIN = "OCX-TEST"; + setPlatformForTests("win32"); + resetHardenedStateForTests(); + setIcaclsRunnerForTests(args => args[0] === root + ? { success: true, exitCode: 0, timedOut: false, stdout: "" } + : { success: false, exitCode: null, timedOut: true, stdout: "" }); + + expect(() => writeServiceApiTokenFile({ + configDir: root, + env: { OPENCODEX_API_AUTH_TOKEN: "replacement-data" }, + platform: "win32", + })).toThrow(/ACL hardening did not complete/); + expect(readFileSync(tokenPath, "utf8")).toBe("previous-data\n"); + expect(readdirSync(root).filter(name => name.includes("service-api-token.tmp"))).toEqual([]); + }); + + test("an install without an environment data token removes a stale service token", () => { + const root = tempRoot(); + const tokenPath = serviceApiTokenFilePath(root); + writeFileSync(tokenPath, "stale-data\n", "utf8"); + + expect(writeServiceApiTokenFile({ configDir: root, env: {}, platform: "linux" })).toBeNull(); + expect(existsSync(tokenPath)).toBe(false); + }); + + test("an install aborts when a stale service data token cannot be removed", () => { + const root = tempRoot(); + const tokenPath = serviceApiTokenFilePath(root); + mkdirSync(tokenPath); + + expect(() => writeServiceApiTokenFile({ + configDir: root, + env: {}, + platform: "linux", + })).toThrow(/stale data service token could not be removed/); + expect(existsSync(tokenPath)).toBe(true); + }); + + test("an install without an explicit admin token removes a stale service admin token", () => { + const root = tempRoot(); + const tokenPath = serviceAdminTokenFilePath(root); + writeFileSync(tokenPath, "stale-admin\n", "utf8"); + + expect(writeServiceAdminTokenFile({ + configDir: root, + env: {}, + platform: "linux", + })).toBeNull(); + expect(existsSync(tokenPath)).toBe(false); + }); + + test("an install aborts when a stale service admin token cannot be removed", () => { + const root = tempRoot(); + const tokenPath = serviceAdminTokenFilePath(root); + mkdirSync(tokenPath); + + expect(() => writeServiceAdminTokenFile({ + configDir: root, + env: {}, + platform: "linux", + })).toThrow(/stale management service token could not be removed/); + expect(existsSync(tokenPath)).toBe(true); + }); + + test("fails closed and removes the token file when icacls times out without throwing", () => { + const root = tempRoot(); + const tokenPath = serviceAdminTokenFilePath(root); + process.env.USERNAME = "ocx-test-user"; + process.env.USERDOMAIN = "OCX-TEST"; + setPlatformForTests("win32"); + resetHardenedStateForTests(); + setIcaclsRunnerForTests(args => args[0] === root + ? { success: true, exitCode: 0, timedOut: false, stdout: "" } + : { success: false, exitCode: null, timedOut: true, stdout: "" }); + + expect(() => writeServiceAdminTokenFile({ + configDir: root, + env: { OPENCODEX_ADMIN_AUTH_TOKEN: "admin-for-service" }, + platform: "win32", + })).toThrow(/ACL hardening did not complete/); + expect(existsSync(tokenPath)).toBe(false); + }); + + test("an ACL timeout preserves the previous token and removes the unsecured replacement", () => { + const root = tempRoot(); + const tokenPath = serviceAdminTokenFilePath(root); + writeFileSync(tokenPath, "previous-admin\n", "utf8"); + process.env.USERNAME = "ocx-test-user"; + process.env.USERDOMAIN = "OCX-TEST"; + setPlatformForTests("win32"); + resetHardenedStateForTests(); + setIcaclsRunnerForTests(args => args[0] === root + ? { success: true, exitCode: 0, timedOut: false, stdout: "" } + : { success: false, exitCode: null, timedOut: true, stdout: "" }); + + expect(() => writeServiceAdminTokenFile({ + configDir: root, + env: { OPENCODEX_ADMIN_AUTH_TOKEN: "replacement-admin" }, + platform: "win32", + })).toThrow(/ACL hardening did not complete/); + expect(readFileSync(tokenPath, "utf8")).toBe("previous-admin\n"); + expect(readdirSync(root).filter(name => name.includes("service-admin-token.tmp"))).toEqual([]); + }); + + test("replaces an existing token only after the secured replacement is ready", () => { + const root = tempRoot(); + const tokenPath = serviceAdminTokenFilePath(root); + writeFileSync(tokenPath, "previous-admin\n", "utf8"); + + expect(writeServiceAdminTokenFile({ + configDir: root, + env: { OPENCODEX_ADMIN_AUTH_TOKEN: "replacement-admin" }, + platform: "linux", + })).toBe(tokenPath); + expect(readFileSync(tokenPath, "utf8")).toBe("replacement-admin\n"); + expect(readdirSync(root).filter(name => name.includes("service-admin-token.tmp"))).toEqual([]); + }); + + test("rejects a service admin token over 512 UTF-8 bytes before writing it", () => { + const root = tempRoot(); + const oversized = "\u00e9".repeat(256) + "x"; + + expect(() => writeServiceAdminTokenFile({ + configDir: root, + env: { OPENCODEX_ADMIN_AUTH_TOKEN: oversized }, + platform: "linux", + })).toThrow(/512 UTF-8 bytes/); + expect(existsSync(serviceAdminTokenFilePath(root))).toBe(false); + }); + + test("rejects a 512-byte service admin token because the serialized file adds a newline", () => { + const root = tempRoot(); + const boundary = "\u00e9".repeat(256); + + expect(() => writeServiceAdminTokenFile({ + configDir: root, + env: { OPENCODEX_ADMIN_AUTH_TOKEN: boundary }, + platform: "linux", + })).toThrow(/512 UTF-8 bytes/); + expect(existsSync(serviceAdminTokenFilePath(root))).toBe(false); + }); + + test("accepts a 511-byte service admin token whose serialized file is exactly 512 bytes", () => { + const root = tempRoot(); + const boundary = "\u00e9".repeat(255) + "x"; + const tokenPath = serviceAdminTokenFilePath(root); + + expect(writeServiceAdminTokenFile({ + configDir: root, + env: { OPENCODEX_ADMIN_AUTH_TOKEN: boundary }, + platform: "linux", + })).toBe(tokenPath); + const serialized = readFileSync(tokenPath); + expect(serialized.byteLength).toBe(512); + expect(serialized.toString("utf8")).toBe(`${boundary}\n`); + }); + + test("keeps legacy nonempty config directories usable without claiming ownership", () => { + const root = tempRoot(); + writeFileSync(join(root, "legacy-config.json"), "{}\n", "utf8"); + + expect(writeServiceAdminTokenFile({ + configDir: root, + env: { OPENCODEX_ADMIN_AUTH_TOKEN: "legacy-admin" }, + platform: "linux", + })).toBe(serviceAdminTokenFilePath(root)); + expect(readFileSync(serviceAdminTokenFilePath(root), "utf8")).toBe("legacy-admin\n"); + expect(existsSync(join(root, ".opencodex-owner.json"))).toBe(false); + expect(existsSync(join(root, ".opencodex-uninstall.json"))).toBe(false); + }); + + test("fails closed when a valid ownership manifest cannot register the service token", () => { + const root = tempRoot(); + expect(recordOwnedConfigPath(root, join(root, "seed.json"))).toBe(true); + + expect(() => writeServiceAdminTokenFile({ + configDir: root, + env: { OPENCODEX_ADMIN_AUTH_TOKEN: "owned-admin" }, + platform: "linux", + recordOwnedPath: () => false, + })).toThrow(/ownership manifest/); + expect(existsSync(serviceAdminTokenFilePath(root))).toBe(false); + }); + + test("fails closed when ownership registration fails for a fresh directory", () => { + const root = tempRoot(); + + expect(() => writeServiceAdminTokenFile({ + configDir: root, + env: { OPENCODEX_ADMIN_AUTH_TOKEN: "fresh-admin" }, + platform: "linux", + recordOwnedPath: () => false, + })).toThrow(/ownership manifest/); + expect(existsSync(serviceAdminTokenFilePath(root))).toBe(false); + }); + + test("keeps environment, primary file, and service file precedence", () => { + const root = tempRoot(); + const primary = `ocx_admin_${"a".repeat(43)}`; + writeFileSync(adminApiTokenFilePath(root), `${primary}\n`, "utf8"); + writeFileSync(serviceAdminTokenFilePath(root), "service-admin\n", "utf8"); + + expect(configuredAdminToken(root, { + OPENCODEX_ADMIN_AUTH_TOKEN: "environment-admin", + OCX_ADMIN_TOKEN_FILE: serviceAdminTokenFilePath(root), + })).toBe("environment-admin"); + expect(configuredAdminToken(root, { + OCX_ADMIN_TOKEN_FILE: serviceAdminTokenFilePath(root), + })).toBe(primary); + unlinkSync(adminApiTokenFilePath(root)); + expect(configuredAdminToken(root, { + OCX_ADMIN_TOKEN_FILE: serviceAdminTokenFilePath(root), + })).toBe("service-admin"); + }); + + test("server initialization keeps explicit environment, primary file, and service file precedence", () => { + const config = () => ({ + port: 10100, + hostname: "0.0.0.0", + defaultProvider: "test", + providers: {}, + } as OcxConfig); + + const explicitRoot = tempRoot(); + mkdirSync(adminApiTokenFilePath(explicitRoot)); + writeFileSync(serviceAdminTokenFilePath(explicitRoot), "service-explicit\n", "utf8"); + process.env.OPENCODEX_HOME = explicitRoot; + process.env.OCX_ADMIN_TOKEN_FILE = serviceAdminTokenFilePath(explicitRoot); + process.env.OPENCODEX_ADMIN_AUTH_TOKEN = "explicit-admin"; + expect(initializeManagementAuthState(config())).toMatchObject({ available: true, token: "explicit-admin" }); + + const primaryRoot = tempRoot(); + const primary = `ocx_admin_${"p".repeat(43)}`; + writeFileSync(adminApiTokenFilePath(primaryRoot), `${primary}\n`, "utf8"); + writeFileSync(serviceAdminTokenFilePath(primaryRoot), "service-primary\n", "utf8"); + process.env.OPENCODEX_HOME = primaryRoot; + process.env.OCX_ADMIN_TOKEN_FILE = serviceAdminTokenFilePath(primaryRoot); + delete process.env.OPENCODEX_ADMIN_AUTH_TOKEN; + expect(initializeManagementAuthState(config())).toMatchObject({ available: true, token: primary }); + + const serviceRoot = tempRoot(); + writeFileSync(serviceAdminTokenFilePath(serviceRoot), "service-only\n", "utf8"); + process.env.OPENCODEX_HOME = serviceRoot; + process.env.OCX_ADMIN_TOKEN_FILE = serviceAdminTokenFilePath(serviceRoot); + expect(initializeManagementAuthState(config())).toMatchObject({ available: true, token: "service-only" }); + }); + + test("service startup keeps the primary admin file ahead of the service delivery file", () => { + const root = tempRoot(); + const primary = `ocx_admin_${"b".repeat(43)}`; + writeFileSync(adminApiTokenFilePath(root), `${primary}\n`, "utf8"); + writeFileSync(serviceAdminTokenFilePath(root), "service-admin\n", "utf8"); + const env: Record = { + OCX_ADMIN_TOKEN_FILE: serviceAdminTokenFilePath(root), + }; + + loadServiceTokensIntoEnv(env, root); + + expect(env.OPENCODEX_ADMIN_AUTH_TOKEN).toBeUndefined(); + expect(configuredAdminToken(root, env)).toBe(primary); + }); + + test("service startup preserves an explicit token without loading the service token into the environment", () => { + const root = tempRoot(); + const servicePath = serviceAdminTokenFilePath(root); + writeFileSync(servicePath, "service-admin\n", "utf8"); + const explicitEnv: Record = { + OPENCODEX_ADMIN_AUTH_TOKEN: "explicit-admin", + OCX_ADMIN_TOKEN_FILE: servicePath, + }; + + loadServiceTokensIntoEnv(explicitEnv, root); + expect(explicitEnv.OPENCODEX_ADMIN_AUTH_TOKEN).toBe("explicit-admin"); + + process.env.OPENCODEX_HOME = root; + process.env.OCX_ADMIN_TOKEN_FILE = servicePath; + delete process.env.OPENCODEX_ADMIN_AUTH_TOKEN; + loadServiceTokensIntoEnv(process.env, root); + expect(process.env.OPENCODEX_ADMIN_AUTH_TOKEN).toBeUndefined(); + + const state = initializeManagementAuthState({ + port: 10100, + hostname: "0.0.0.0", + defaultProvider: "test", + providers: {}, + } as OcxConfig); + expect(state).toMatchObject({ available: true, token: "service-admin" }); + }); + + test("active service admin secrets stay isolated by config and failed reinitialization clears only one", () => { + delete process.env.OPENCODEX_ADMIN_AUTH_TOKEN; + const rootA = tempRoot(); + const rootB = tempRoot(); + const secretA = "service-admin-without-prefix-a"; + const secretB = "service-admin-without-prefix-b"; + const configA = { + port: 10100, + hostname: "0.0.0.0", + defaultProvider: "test", + providers: {}, + } as OcxConfig; + const configB = { ...configA } as OcxConfig; + + writeFileSync(serviceAdminTokenFilePath(rootA), `${secretA}\n`, "utf8"); + process.env.OPENCODEX_HOME = rootA; + process.env.OCX_ADMIN_TOKEN_FILE = serviceAdminTokenFilePath(rootA); + expect(initializeManagementAuthState(configA)).toMatchObject({ available: true, token: secretA }); + + writeFileSync(serviceAdminTokenFilePath(rootB), `${secretB}\n`, "utf8"); + process.env.OPENCODEX_HOME = rootB; + process.env.OCX_ADMIN_TOKEN_FILE = serviceAdminTokenFilePath(rootB); + expect(initializeManagementAuthState(configB)).toMatchObject({ available: true, token: secretB }); + + expect(isProxyAdmissionSecret(secretA, configA)).toBe(true); + expect(isProxyAdmissionSecret(secretA, configB)).toBe(false); + expect(isProxyAdmissionSecret(secretB, configB)).toBe(true); + expect(isProxyAdmissionSecret(secretB, configA)).toBe(false); + expect(() => validateForwardAdmissionCredential( + new Headers({ authorization: `Bearer ${secretA}` }), + configA, + )).toThrow("OpenCodex admission credentials cannot be forwarded upstream"); + + mkdirSync(adminApiTokenFilePath(rootB)); + expect(initializeManagementAuthState(configB).available).toBe(false); + expect(isProxyAdmissionSecret(secretB, configB)).toBe(false); + expect(isProxyAdmissionSecret(secretA, configA)).toBe(true); + }); + + test("service startup does not fall back when the primary admin path is invalid", () => { + const root = tempRoot(); + mkdirSync(adminApiTokenFilePath(root)); + writeFileSync(serviceAdminTokenFilePath(root), "service-admin\n", "utf8"); + process.env.OPENCODEX_HOME = root; + process.env.OCX_ADMIN_TOKEN_FILE = serviceAdminTokenFilePath(root); + delete process.env.OPENCODEX_ADMIN_AUTH_TOKEN; + + loadServiceTokensIntoEnv(process.env, root); + const state = initializeManagementAuthState({ + port: 10100, + hostname: "0.0.0.0", + defaultProvider: "test", + providers: {}, + } as OcxConfig); + + expect(process.env.OPENCODEX_ADMIN_AUTH_TOKEN).toBeUndefined(); + expect(state.available).toBe(false); + expect(readFileSync(serviceAdminTokenFilePath(root), "utf8")).toBe("service-admin\n"); + }); + + test("service startup does not follow or fall back from a primary admin symlink", () => { + const root = tempRoot(); + const target = join(root, "outside-token-directory"); + mkdirSync(target); + symlinkSync(target, adminApiTokenFilePath(root), process.platform === "win32" ? "junction" : "dir"); + writeFileSync(serviceAdminTokenFilePath(root), "service-admin\n", "utf8"); + process.env.OPENCODEX_HOME = root; + process.env.OCX_ADMIN_TOKEN_FILE = serviceAdminTokenFilePath(root); + delete process.env.OPENCODEX_ADMIN_AUTH_TOKEN; + + const state = initializeManagementAuthState({ + port: 10100, + hostname: "0.0.0.0", + defaultProvider: "test", + providers: {}, + } as OcxConfig); + + expect(state.available).toBe(false); + expect(readFileSync(serviceAdminTokenFilePath(root), "utf8")).toBe("service-admin\n"); + }); + + test("service startup cannot bypass primary-file ACL timeout handling", () => { + const root = tempRoot(); + const primary = `ocx_admin_${"d".repeat(43)}`; + const primaryPath = adminApiTokenFilePath(root); + writeFileSync(primaryPath, `${primary}\n`, "utf8"); + writeFileSync(serviceAdminTokenFilePath(root), "service-admin\n", "utf8"); + process.env.OPENCODEX_HOME = root; + process.env.OCX_ADMIN_TOKEN_FILE = serviceAdminTokenFilePath(root); + delete process.env.OPENCODEX_ADMIN_AUTH_TOKEN; + const hardenedTargets: string[] = []; + + loadServiceTokensIntoEnv(process.env, root); + const state = initializeManagementAuthState({ + port: 10100, + hostname: "0.0.0.0", + defaultProvider: "test", + providers: {}, + } as OcxConfig, { + hardenSecretDir: target => { + hardenedTargets.push(target); + return { ok: true }; + }, + hardenSecretPath: target => { + hardenedTargets.push(target); + return target === primaryPath + ? { ok: false, diagnostics: "icacls file hardening timed out" } + : { ok: true }; + }, + }); + + expect(process.env.OPENCODEX_ADMIN_AUTH_TOKEN).toBeUndefined(); + expect(hardenedTargets).toContain(primaryPath); + expect(state.available).toBe(false); + }); + + test("a service admin token ACL timeout closes management without deleting or exporting the token", () => { + const root = tempRoot(); + const servicePath = serviceAdminTokenFilePath(root); + const secret = "service-admin-without-prefix"; + writeFileSync(servicePath, `${secret}\n`, "utf8"); + process.env.OPENCODEX_HOME = root; + process.env.OCX_ADMIN_TOKEN_FILE = servicePath; + delete process.env.OPENCODEX_ADMIN_AUTH_TOKEN; + const hardenedTargets: string[] = []; + + loadServiceTokensIntoEnv(process.env, root); + const state = initializeManagementAuthState({ + port: 10100, + hostname: "0.0.0.0", + defaultProvider: "test", + providers: {}, + } as OcxConfig, { + hardenSecretDir: target => { + hardenedTargets.push(target); + return { ok: true }; + }, + hardenSecretPath: target => { + hardenedTargets.push(target); + return target === servicePath + ? { ok: false, diagnostics: "icacls file hardening timed out" } + : { ok: true }; + }, + }); + + expect(state.available).toBe(false); + expect(process.env.OPENCODEX_ADMIN_AUTH_TOKEN).toBeUndefined(); + expect(readFileSync(servicePath, "utf8")).toBe(`${secret}\n`); + expect(hardenedTargets).toContain(root); + expect(hardenedTargets).toContain(servicePath); + }); + + test("a service admin token directory closes management without generating a primary token", () => { + const root = tempRoot(); + const servicePath = serviceAdminTokenFilePath(root); + mkdirSync(servicePath); + process.env.OPENCODEX_HOME = root; + process.env.OCX_ADMIN_TOKEN_FILE = servicePath; + delete process.env.OPENCODEX_ADMIN_AUTH_TOKEN; + + const state = initializeManagementAuthState({ + port: 10100, + hostname: "0.0.0.0", + defaultProvider: "test", + providers: {}, + } as OcxConfig); + + expect(state.available).toBe(false); + expect(existsSync(adminApiTokenFilePath(root))).toBe(false); + }); + + test("an oversized service admin token closes management without replacing the file", () => { + const root = tempRoot(); + const servicePath = serviceAdminTokenFilePath(root); + const oversized = "x".repeat(513); + writeFileSync(servicePath, oversized, "utf8"); + process.env.OPENCODEX_HOME = root; + process.env.OCX_ADMIN_TOKEN_FILE = servicePath; + delete process.env.OPENCODEX_ADMIN_AUTH_TOKEN; + + const state = initializeManagementAuthState({ + port: 10100, + hostname: "0.0.0.0", + defaultProvider: "test", + providers: {}, + } as OcxConfig); + + expect(state.available).toBe(false); + expect(readFileSync(servicePath, "utf8")).toBe(oversized); + expect(existsSync(adminApiTokenFilePath(root))).toBe(false); + }); + + test("a missing explicitly configured service admin token does not generate a primary token", () => { + const root = tempRoot(); + process.env.OPENCODEX_HOME = root; + process.env.OCX_ADMIN_TOKEN_FILE = serviceAdminTokenFilePath(root); + delete process.env.OPENCODEX_ADMIN_AUTH_TOKEN; + + const state = initializeManagementAuthState({ + port: 10100, + hostname: "0.0.0.0", + defaultProvider: "test", + providers: {}, + } as OcxConfig); + + expect(state.available).toBe(false); + expect(existsSync(adminApiTokenFilePath(root))).toBe(false); + expect(existsSync(serviceAdminTokenFilePath(root))).toBe(false); + }); + + test("omits the service admin token file from all four definitions when it does not exist", () => { + const root = tempRoot(); + process.env.OPENCODEX_HOME = root; + delete process.env.OPENCODEX_ADMIN_AUTH_TOKEN; + + const definitions = [ + buildPlist(), + buildUnit(), + buildWindowsServiceScript({ bun: "C:\\ocx\\bun.exe", cli: "C:\\ocx\\cli.ts" }, 10100), + buildWinswXml( + { bun: "C:\\ocx\\bun.exe", cli: "C:\\ocx\\cli.ts" }, + { USERDOMAIN: "OCX", USERNAME: "tester", OPENCODEX_HOME: root }, + 10100, + ), + ]; + + for (const definition of definitions) { + expect(definition).not.toContain("OCX_ADMIN_TOKEN_FILE"); + } + }); + + test("stores only the service token path in all four service definitions", () => { + const root = tempRoot(); + const secret = "admin-value-must-not-be-serialized"; + process.env.OPENCODEX_HOME = root; + process.env.OPENCODEX_ADMIN_AUTH_TOKEN = secret; + const tokenPath = serviceAdminTokenFilePath(root); + expect(writeServiceAdminTokenFile({ + configDir: root, + env: process.env, + platform: "linux", + })).toBe(tokenPath); + const definitions = [ + buildPlist(), + buildUnit(), + buildWindowsServiceScript({ bun: "C:\\ocx\\bun.exe", cli: "C:\\ocx\\cli.ts" }, 10100), + buildWinswXml( + { bun: "C:\\ocx\\bun.exe", cli: "C:\\ocx\\cli.ts" }, + { USERDOMAIN: "OCX", USERNAME: "tester", OPENCODEX_ADMIN_AUTH_TOKEN: secret }, + 10100, + ), + ]; + + const escapedTokenPath = tokenPath.replace(/\\/g, "\\\\"); + for (const definition of definitions) { + expect(definition.includes(tokenPath) || definition.includes(escapedTokenPath)).toBe(true); + expect(definition).toContain("OCX_ADMIN_TOKEN_FILE"); + expect(definition).not.toContain("OPENCODEX_ADMIN_AUTH_TOKEN"); + expect(definition).not.toContain(secret); + } + }); + + test("persists the resolved OpenCodex home in all four service definitions", () => { + const relativeHome = join("tests", ".tmp-relative-service-home"); + const absoluteHome = resolve(relativeHome); + tempRoots.push(absoluteHome); + process.env.OPENCODEX_HOME = relativeHome; + + const plist = buildPlist(); + const unit = buildUnit(); + const windows = buildWindowsServiceScript( + { bun: "C:\\ocx\\bun.exe", cli: "C:\\ocx\\cli.ts" }, + 10100, + ); + const winsw = buildWinswXml( + { bun: "C:\\ocx\\bun.exe", cli: "C:\\ocx\\cli.ts" }, + { USERDOMAIN: "OCX", USERNAME: "tester", OPENCODEX_HOME: relativeHome }, + 10100, + ); + + expect(plist).toContain(`OPENCODEX_HOME${absoluteHome}`); + expect(unit).toContain(`Environment="OPENCODEX_HOME=${absoluteHome.replace(/\\/g, "\\\\")}"`); + expect(windows).toContain(`set "OPENCODEX_HOME=${absoluteHome}"`); + expect(winsw).toContain(``); + }); + + test("service uninstall cleanup removes both service-delivered token files", () => { + const root = tempRoot(); + const dataPath = join(root, "service-api-token"); + const adminPath = serviceAdminTokenFilePath(root); + const primaryPath = adminApiTokenFilePath(root); + writeFileSync(dataPath, "data-admin\n", "utf8"); + writeFileSync(adminPath, "service-admin\n", "utf8"); + writeFileSync(primaryPath, `ocx_admin_${"c".repeat(43)}\n`, "utf8"); + + expect(removeServiceTokenFiles(root)).toEqual([]); + + expect(existsSync(dataPath)).toBe(false); + expect(existsSync(adminPath)).toBe(false); + expect(existsSync(primaryPath)).toBe(true); + expect(removeServiceTokenFiles(root)).toEqual([]); + }); + + test("service token cleanup reports a residual secret without exposing its path", () => { + const root = tempRoot(); + const dataPath = join(root, "service-api-token"); + const adminPath = serviceAdminTokenFilePath(root); + writeFileSync(dataPath, "data-admin\n", "utf8"); + writeFileSync(adminPath, "service-admin\n", "utf8"); + + const residual = removeServiceTokenFiles(root, path => { + if (path === adminPath) throw new Error("locked"); + unlinkSync(path); + }); + + expect(residual).toEqual(["service-admin-token"]); + expect(residual.join(" ")).not.toContain(root); + expect(existsSync(dataPath)).toBe(false); + expect(existsSync(adminPath)).toBe(true); + }); +}); diff --git a/tests/service.test.ts b/tests/service.test.ts index 9bdb6d0fc..1853d78fd 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { saveConfig } from "../src/config"; +import { getConfigDir, saveConfig } from "../src/config"; import { windowsEnvIndirectBatchValue } from "../src/lib/win-paths"; import { assertServiceAuthEnvironment, assertServiceEnvironmentMatchesInstall, bakedServicePathsDiagnostic, buildPlist, buildUnit, buildWindowsLauncherVbs, buildWindowsSchtasksCreateArgs, buildWindowsServiceScript, buildWindowsTaskXml, deriveWindowsServiceDiagnostic, normalizeServiceSubcommand, parseServiceInstallState, readWindowsSchedulerXmlState, repairService, resolveServiceListenPort, serviceLogPath, serviceStartableFromTray, serviceStatusSummary, windowsTaskRegistrationHealthy } from "../src/service"; import { serviceApiTokenFilePath } from "../src/lib/service-secrets"; @@ -114,7 +114,9 @@ describe("systemd service unit", () => { process.env.OPENCODEX_API_AUTH_TOKEN = "local-secret"; const unit = buildUnit(); expect(unit).toContain('Environment="CODEX_HOME=/tmp/codex-home"'); - expect(unit).toContain('Environment="OPENCODEX_HOME=/tmp/opencodex-home"'); + expect(unit).toContain( + `Environment="OPENCODEX_HOME=${getConfigDir().replace(/\\/g, "\\\\")}"`, + ); expectTextToContainPath(unit, serviceApiTokenFilePath()); expect(unit).not.toContain("local-secret"); expect(unit).not.toContain("Environment=\"OPENCODEX_API_AUTH_TOKEN="); @@ -141,7 +143,7 @@ describe("systemd service unit", () => { expect(startSystemd).toContain("ocx service install"); expect(startSystemd).toContain("process.exit(1)"); - const writeAt = installSystemd.indexOf('writeFileSync(unitPath(), buildUnit(), "utf8")'); + const writeAt = installSystemd.indexOf('writeFileSync(unitPath(), buildUnit(state), "utf8")'); const reloadAt = installSystemd.indexOf("systemctl --user daemon-reload"); const enableAt = installSystemd.indexOf("systemctl --user enable"); const restartAt = installSystemd.indexOf("systemctl --user restart"); @@ -185,6 +187,27 @@ describe("service install auth preflight", () => { expect(() => assertServiceAuthEnvironment()).not.toThrow(); }); + test("allows non-loopback service install when config has a data-plane key", () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + delete process.env.OPENCODEX_API_AUTH_TOKEN; + saveConfig({ + port: 10100, + hostname: "0.0.0.0", + providers: { openai: { adapter: "openai-chat", baseUrl: "https://api.example.test/v1" } }, + defaultProvider: "openai", + apiKeys: [{ + id: "configured", + name: "Configured data key", + key: "ocx_data_configured-secret", + createdAt: "2026-07-29T00:00:00.000Z", + }], + } as OcxConfig); + + expect(() => assertServiceAuthEnvironment()).not.toThrow(); + }); + test("rejects restore operations from a different CODEX_HOME than service install", () => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true }); @@ -468,18 +491,23 @@ describe("Windows service task", () => { const oldPath = process.env.PATH; const oldOpenCodexHome = process.env.OPENCODEX_HOME; const oldApiAuthToken = process.env.OPENCODEX_API_AUTH_TOKEN; + const oldAdminAuthToken = process.env.OPENCODEX_ADMIN_AUTH_TOKEN; try { process.env.PATH = 'C:\\safe" & echo PWNED & rem "'; process.env.OPENCODEX_HOME = 'C:\\ocx" & del C:\\important & rem "'; process.env.OPENCODEX_API_AUTH_TOKEN = 'token" & echo LEAK & rem "'; - const script = buildWindowsServiceScript(); + process.env.OPENCODEX_ADMIN_AUTH_TOKEN = 'admin" & echo ADMIN_LEAK & rem "'; + const script = buildWindowsServiceScript(undefined, undefined, { adminTokenFile: null }); expect(script).toContain('set "PATH=C:\\safe & echo PWNED & rem "'); expect(script).toContain('set "OPENCODEX_HOME=C:\\ocx & del C:\\important & rem "'); expect(script).toContain('set "OCX_API_TOKEN_FILE='); expect(script).toContain('set /p OPENCODEX_API_AUTH_TOKEN=<"%OCX_API_TOKEN_FILE%"'); + expect(script).not.toContain('set "OCX_ADMIN_TOKEN_FILE='); + expect(script).not.toContain('set /p OPENCODEX_ADMIN_AUTH_TOKEN=<"%OCX_ADMIN_TOKEN_FILE%"'); expect(script).not.toContain('set "PATH=C:\\safe" & echo PWNED'); expect(script).not.toContain('set "OPENCODEX_HOME=C:\\ocx" & del'); expect(script).not.toContain("token & echo LEAK"); + expect(script).not.toContain("ADMIN_LEAK"); } finally { if (oldPath === undefined) delete process.env.PATH; else process.env.PATH = oldPath; @@ -487,6 +515,8 @@ describe("Windows service task", () => { else process.env.OPENCODEX_HOME = oldOpenCodexHome; if (oldApiAuthToken === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN; else process.env.OPENCODEX_API_AUTH_TOKEN = oldApiAuthToken; + if (oldAdminAuthToken === undefined) delete process.env.OPENCODEX_ADMIN_AUTH_TOKEN; + else process.env.OPENCODEX_ADMIN_AUTH_TOKEN = oldAdminAuthToken; } }); @@ -581,7 +611,7 @@ describe("launchd service plist", () => { process.env.OPENCODEX_API_AUTH_TOKEN = "local-secret"; const plist = buildPlist(); expect(plist).toContain("CODEX_HOME/tmp/codex-home"); - expect(plist).toContain("OPENCODEX_HOME/tmp/opencodex-home"); + expect(plist).toContain(`OPENCODEX_HOME${getConfigDir()}`); expectTextToContainPath(plist, serviceApiTokenFilePath()); expect(plist).not.toContain("local-secret"); expect(plist).not.toContain("OPENCODEX_API_AUTH_TOKEN"); @@ -624,13 +654,13 @@ describe("service lifecycle cleanup ordering", () => { test("Windows service install ends the running task before rewriting its assets, with write retry", async () => { const service = await readText("src/service.ts"); const assetsHelper = service.slice( - service.indexOf("function writeWindowsSchedulerAssets()"), + service.indexOf("function writeWindowsSchedulerAssets("), service.indexOf("function installWindows()"), ); const installWindows = service.slice(service.indexOf("function installWindows()"), service.indexOf("async function installWindowsNative()")); const stopAt = installWindows.indexOf("stopWindows();"); - const assetsAt = installWindows.indexOf("writeWindowsSchedulerAssets();"); + const assetsAt = installWindows.indexOf("writeWindowsSchedulerAssets(state);"); const createAt = installWindows.indexOf("buildWindowsSchtasksCreateArgs"); expect(stopAt).toBeGreaterThan(-1); expect(assetsAt).toBeGreaterThan(-1); @@ -640,6 +670,7 @@ describe("service lifecycle cleanup ordering", () => { expect(installWindows).not.toContain("writeFileSync(script"); expect(assetsHelper).toContain("writeServiceAssetWithRetry(script"); expect(assetsHelper).toContain("writeServiceAssetWithRetry(windowsTaskXmlPath()"); + expect(assetsHelper).not.toContain("writeServiceApiTokenFile()"); // Retry helper tolerates transient Windows file locks from the just-ended task. expect(service).toContain('code !== "EBUSY" && code !== "EPERM" && code !== "EACCES"'); }); diff --git a/tests/test-runner.test.ts b/tests/test-runner.test.ts index 08062d745..e30a2a97a 100644 --- a/tests/test-runner.test.ts +++ b/tests/test-runner.test.ts @@ -1,14 +1,14 @@ import { describe, expect, test } from "bun:test"; import { existsSync } from "node:fs"; -import { join } from "node:path"; -import { createIsolatedTestEnvironment } from "../scripts/test"; +import { delimiter, dirname, join } from "node:path"; +import { buildTestInvocations, createIsolatedTestEnvironment } from "../scripts/test"; describe("test runner isolation", () => { test("redirects user homes to a disposable root", () => { - const isolated = createIsolatedTestEnvironment({ PATH: "/test/bin", HOME: "/real/home" }); + const isolated = createIsolatedTestEnvironment({ Path: "/test/bin", HOME: "/real/home" }); try { expect(isolated.env).toMatchObject({ - PATH: "/test/bin", + PATH: [dirname(process.execPath), "/test/bin"].join(delimiter), HOME: isolated.root, USERPROFILE: isolated.root, OPENCODEX_HOME: join(isolated.root, ".opencodex"), @@ -16,9 +16,22 @@ describe("test runner isolation", () => { }); expect(existsSync(isolated.env.OPENCODEX_HOME!)).toBe(true); expect(existsSync(isolated.env.CODEX_HOME!)).toBe(true); + expect(isolated.env).not.toHaveProperty("Path"); } finally { isolated.cleanup(); } expect(existsSync(isolated.root)).toBe(false); }); + + test("shards the full Windows suite but leaves other runs unchanged", () => { + const windows = buildTestInvocations([], "win32", "bun"); + expect(windows).toHaveLength(8); + expect(windows[0]).toEqual(["bun", "test", "--isolate", "--shard=1/8", "./tests/"]); + expect(windows[7]).toEqual(["bun", "test", "--isolate", "--shard=8/8", "./tests/"]); + + expect(buildTestInvocations([], "linux", "bun")) + .toEqual([["bun", "test", "--isolate", "./tests/"]]); + expect(buildTestInvocations(["./tests/config.test.ts"], "win32", "bun")) + .toEqual([["bun", "test", "--isolate", "./tests/config.test.ts"]]); + }); }); diff --git a/tests/update-npm-invocation.test.ts b/tests/update-npm-invocation.test.ts index 6a0956906..9181a29de 100644 --- a/tests/update-npm-invocation.test.ts +++ b/tests/update-npm-invocation.test.ts @@ -76,6 +76,46 @@ describe("Windows npm update invocation", () => { })).toBe(`${home}\\AppData\\Roaming\\npm\\npm.cmd`); }); + test("prevents a trusted npm.cmd shim from resolving node out of the launch directory", () => { + const home = "C:\\Users\\dev"; + const project = `${home}\\untrusted`; + const appDataNpmDir = `${home}\\AppData\\Roaming\\npm`; + const appDataNpm = `${appDataNpmDir}\\npm.cmd`; + const nodeDir = "C:\\Program Files\\nodejs"; + const env = { + PATH: `${project};.;${appDataNpmDir};${nodeDir}`, + PATHEXT: ".CMD", + SystemRoot: "C:\\Windows", + OCX_TEST_SENTINEL: "preserved", + OPENCODEX_ADMIN_AUTH_TOKEN: "admin-secret", + OCX_ADMIN_TOKEN_FILE: "C:\\secrets\\admin-token", + OPENCODEX_API_AUTH_TOKEN: "data-secret", + OCX_API_TOKEN_FILE: "C:\\secrets\\data-token", + }; + + const invocation = npmInvocation(["view", "pkg@latest", "version"], "win32", env, { + cwd: project, + exists: path => path === appDataNpm, + }); + + expect(invocation?.options).toMatchObject({ + windowsVerbatimArguments: true, + env: { + PATH: `${appDataNpmDir};${nodeDir}`, + NoDefaultCurrentDirectoryInExePath: "1", + OCX_TEST_SENTINEL: "preserved", + }, + }); + for (const key of [ + "OPENCODEX_ADMIN_AUTH_TOKEN", + "OCX_ADMIN_TOKEN_FILE", + "OPENCODEX_API_AUTH_TOKEN", + "OCX_API_TOKEN_FILE", + ]) { + expect(invocation?.options.env).not.toHaveProperty(key); + } + }); + test("fails closed when npm is available only from the current directory", () => { const env = { PATH: `${cwd};.`, @@ -93,3 +133,73 @@ describe("Windows npm update invocation", () => { })).toBeNull(); }); }); + +describe("POSIX npm update invocation", () => { + test("ignores unsafe PATH entries and resolves npm from an absolute directory", () => { + const project = "/work/untrusted-project"; + const trustedNpm = "/usr/local/bin/npm"; + const existing = new Set([ + `${project}/npm`, + trustedNpm, + ]); + const env = { + PATH: `${project}::.:relative-bin:/usr/local/bin`, + OCX_TEST_SENTINEL: "preserved", + OPENCODEX_ADMIN_AUTH_TOKEN: "admin-secret", + OCX_ADMIN_TOKEN_FILE: "/run/secrets/admin-token", + OPENCODEX_API_AUTH_TOKEN: "data-secret", + OCX_API_TOKEN_FILE: "/run/secrets/data-token", + }; + + expect(resolveNpmCommand("linux", env, { + cwd: project, + isExecutable: path => existing.has(path), + })).toBe(trustedNpm); + + expect(npmInvocation(["view", "pkg@latest", "version"], "linux", env, { + cwd: project, + isExecutable: path => existing.has(path), + })).toEqual({ + file: trustedNpm, + args: ["view", "pkg@latest", "version"], + options: { + env: { + PATH: "/usr/local/bin", + OCX_TEST_SENTINEL: "preserved", + }, + }, + }); + }); + + test("continues past an unusable npm candidate to a later executable", () => { + const project = "/work/untrusted-project"; + const blockedNpm = "/opt/blocked/npm"; + const trustedNpm = "/usr/local/bin/npm"; + const checked: string[] = []; + + expect(resolveNpmCommand("linux", { + PATH: "/opt/blocked:/usr/local/bin", + }, { + cwd: project, + isExecutable: path => { + checked.push(path); + return path === trustedNpm; + }, + })).toBe(trustedNpm); + expect(checked).toEqual([blockedNpm, trustedNpm]); + }); + + test("fails closed when PATH contains only relative or current-directory entries", () => { + const project = "/work/untrusted-project"; + const env = { + PATH: `${project}::.:relative-bin`, + }; + const deps = { + cwd: project, + isExecutable: () => true, + }; + + expect(resolveNpmCommand("darwin", env, deps)).toBeNull(); + expect(npmInvocation(["view", "pkg@latest", "version"], "darwin", env, deps)).toBeNull(); + }); +}); diff --git a/tests/usage-debug.test.ts b/tests/usage-debug.test.ts index fba52cd36..df41640bc 100644 --- a/tests/usage-debug.test.ts +++ b/tests/usage-debug.test.ts @@ -147,7 +147,7 @@ describe("appendUsageDebug", () => { const first = JSON.parse(lines[0]) as { requestId: string }; expect(last.requestId).toBe(`ocx-${total - 1}`); expect(first.requestId).toBe(`ocx-${total - USAGE_DEBUG_KEEP_LINES}`); - }); + }, 15_000); test("keeps file size bounded by MAX_LINES across long runs", () => { // Cross the rotate threshold twice — enough to prove the bound holds across @@ -156,8 +156,8 @@ describe("appendUsageDebug", () => { // These 325 appends still cost ~1,950 synchronous fs calls: six per append // (mkdir + chmod from ensureUsageDebugDir, then append, chmod, exists, read), // plus a write/chmod pair on each of the two rotations. On windows-latest under - // full-suite load that measured 13.6s — past the 5s default — while ubuntu and - // macos stay well under it. The cost is per-open (Defender scans each handle), + // full-suite load has exceeded 16s while ubuntu and macos stay well under it. + // The cost is per-open (Defender scans each handle), // not per-byte, so shaving one of the six calls would not move it enough to // matter. Give the test the time it needs instead of trading away coverage: // 325 is already the minimum that crosses the rotate threshold twice. @@ -169,5 +169,5 @@ describe("appendUsageDebug", () => { const lines = readFileSync(path, "utf-8").split(/\r?\n/).filter(Boolean); expect(lines.length).toBeLessThanOrEqual(USAGE_DEBUG_MAX_LINES); expect(lines.length).toBeGreaterThanOrEqual(USAGE_DEBUG_KEEP_LINES); - }, 15_000); + }, process.platform === "win32" ? 30_000 : 15_000); }); diff --git a/tests/windows-deploy-close-regressions.test.ts b/tests/windows-deploy-close-regressions.test.ts index 46742f3a5..657f35d2c 100644 --- a/tests/windows-deploy-close-regressions.test.ts +++ b/tests/windows-deploy-close-regressions.test.ts @@ -44,7 +44,12 @@ describe("systemd detection tolerates a no-DBUS SSH session (F9)", () => { expect(src).toMatch(/catch \{ \/\* no user bus in this session \*\/ \}\s*\n\s*return userRuntimeDir\(\) !== null;/); }); test("install ensures the user-bus env before touching systemctl --user", () => { - expect(src).toMatch(/function installSystemd\(\): void \{\s*\n\s*ensureUserBusEnv\(\);/); + const install = src.slice( + src.indexOf("function installSystemd(): void"), + src.indexOf("function startSystemd(): void"), + ); + expect(install).toMatch(/^function installSystemd\(\): void \{\s*\n\s*ensureUserBusEnv\(\);/); + expect(install.indexOf("ensureUserBusEnv();")).toBeLessThan(install.indexOf("systemctl --user")); }); }); diff --git a/tests/winsw.test.ts b/tests/winsw.test.ts index 2f728a8b8..c90dccf8d 100644 --- a/tests/winsw.test.ts +++ b/tests/winsw.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { buildWinswXml, ensureWinswBinary, parseWinswStatus, probeScmRegistration, sha256Hex, installWinswService, statusWinswRaw, WINSW_SHA256, WINSW_SERVICE_ID } from "../src/lib/winsw"; import { parseServiceArgs, serviceReinstallArgs } from "../src/service"; import { loadServiceTokenFromFile } from "../src/lib/service-secrets"; +import { configuredAdminToken, loadServiceAdminTokenFromFile } from "../src/lib/admin-secrets"; import { mkdtempSync, readFileSync, writeFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -29,9 +30,11 @@ describe("winsw xml", () => { expect(xml).toContain(''); expect(xml).toContain(''); // The token VALUE never lands in the XML — only the file pointer. expect(xml).not.toContain("OPENCODEX_API_AUTH_TOKEN"); + expect(xml).not.toContain("OPENCODEX_ADMIN_AUTH_TOKEN"); }); test("escapes executable/arguments and configures restart + graceful stop", () => { @@ -225,4 +228,21 @@ describe("app-side service token loading", () => { rmSync(dir, { recursive: true, force: true }); } }); + + test("loads the management token from a protected service file only when the env token is empty", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-admin-token-")); + const file = join(dir, "service-admin-token"); + writeFileSync(file, " admin-from-file \n"); + try { + expect(loadServiceAdminTokenFromFile({ OCX_ADMIN_TOKEN_FILE: file }, dir)).toBe("admin-from-file"); + expect(configuredAdminToken(dir, {})).toBe("admin-from-file"); + expect(loadServiceAdminTokenFromFile({ + OCX_ADMIN_TOKEN_FILE: file, + OPENCODEX_ADMIN_AUTH_TOKEN: "already", + }, dir)).toBeNull(); + expect(loadServiceAdminTokenFromFile({ OCX_ADMIN_TOKEN_FILE: join(dir, "missing") }, dir)).toBeNull(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); });