diff --git a/docs-site/src/content/docs/fr/reference/cli/agents.md b/docs-site/src/content/docs/fr/reference/cli/agents.md index 1337a76d0ce..d7a613a731f 100644 --- a/docs-site/src/content/docs/fr/reference/cli/agents.md +++ b/docs-site/src/content/docs/fr/reference/cli/agents.md @@ -169,6 +169,39 @@ Les familles sont `opus`, `fable`, `sonnet` et `haiku` ; les nouveaux itinérai seulement quand cette famille est vide. Les anciens indicateurs d'application `--static`, `--hybrid` et `--discovery-only` restent soutenus. Utilisez `ocx claude config ...` pour les réglages Claude Code. +### `ocx codex [codex args...]` + +La clé est fournie uniquement si la configuration et ses espaces de noms sont protégés contre les modifications par d’autres principaux ; l’utilisateur courant et les autorités privilégiées du système sont de confiance. Les processus du même utilisateur sont de confiance : un acteur disposant de vos droits d’écriture peut déjà lire votre fichier de clé OpenCodex. Le remplacement de configuration par ce même utilisateur entre vérification et démarrage natif est hors de ce modèle de menace ; le lanceur ne fige pas la configuration lors du transfert. + +Sur une installation Windows normale, les droits ProgramData par défaut permettent à d’autres utilisateurs de créer des dossiers. Les identifiants automatiques sont donc retenus même si `ProgramData\OpenAI\Codex` est absent. Le dossier de configuration système de Codex sous ProgramData doit exister et être accessible en écriture uniquement à Administrators/SYSTEM ; ses dossiers ancêtres doivent empêcher leur remplacement par d’autres utilisateurs. Demandez à un administrateur de vérifier la politique des dossiers. opencodex ne modifie jamais lui-même les ACL et ne fournit aucune commande de préparation. + +Vous pouvez aussi fournir la clé vous-même, par exemple en exportant `OPENCODEX_API_AUTH_TOKEN` ; `ocx codex` conserve sa valeur. Les fichiers de configuration système existants empêchent toujours les identifiants automatiques et nécessitent un examen séparé. Chaque motif de refus produit une courte ligne stderr précise, sans valeur de clé, contenu ni chemin de fichier de clé. + +Placez `--` avant une invite d’un seul mot pour éviter la sélection d’une sous-commande Codex inconnue. + +L’espace de configuration machine doit empêcher un autre utilisateur de créer les fichiers système absents. Un espace Windows ProgramData/OpenAI/Codex non protégé bloque l’injection sans créer de répertoire. macOS accepte les ACL de refus seules et les autorisations en lecture seule. Toute autorisation d’écriture, ajout, suppression, modification d’attributs, de sécurité ou de propriétaire est refusée, même après un refus. Les tags inconnus et erreurs d’inspection entraînent un refus. + +Un nouveau jeton de service est chargé uniquement si argv respecte la liste ci-dessous, si aucune variable non vide de proxy HTTP(S)/ALL ou `CODEX_*`/`OPENAI_*`/`AZURE_OPENAI_*` ne surcharge l’environnement (`CODEX_HOME` canonique est permis), et si les espaces home/config/système passent les contrôles de propriété. La configuration doit contenir uniquement les clés auditées et le fournisseur loopback géré exact, sans autre consommateur de clé ni couche de configuration. Tout argument hors liste, dont `-c`/`--config`, `-p`/`--profile`, les options inconnues et `resume`/`fork`, retient le jeton avec un diagnostic sans valeurs ; les identifiants explicites et les arguments sont préservés. + +Le répertoire canonique et `config.toml` doivent appartenir à l’utilisateur courant. POSIX refuse les écritures group/other ; Windows vérifie le SID propriétaire et refuse les droits d’écriture DACL hors utilisateur courant, SYSTEM et Administrators. Les droits de remplacement des ancêtres sont vérifiés. macOS exige aussi l’absence de préférences Codex forcées. Ces contrôles n’isolent pas un processus malveillant du même utilisateur ou d’un administrateur. + +| Allowlist | Flags | +| --- | --- | +| Switches | `--no-daemon`, `--json`, `--ephemeral`, `--skip-git-repo-check`, `--no-alt-screen`, `--full-auto`, `--dangerously-bypass-approvals-and-sandbox`, `--search` | +| One value | `--model`/`-m`, `--sandbox`/`-s`, `--ask-for-approval`/`-a`, `--image`/`-i` | + + + +Le chargement automatique interprète `CODEX_HOME` comme un chemin littéral, y compris `~` et les espaces, le vérifie et transmet le même chemin absolu canonique au processus enfant. Un répertoire illisible empêche le chargement. Toute valeur non vide de `HTTP_PROXY`, `HTTPS_PROXY` ou `ALL_PROXY`, quelle que soit la casse, empêche aussi le chargement indépendamment de `NO_PROXY` ; les lancements refusés et les identifiants exportés par l’utilisateur restent inchangés. Avec un nouveau jeton, `NO_PROXY` et `no_proxy` du processus enfant incluent `127.0.0.1` et `::1` en conservant les exclusions existantes. Activer `features.respect_system_proxy` ou transmettre `--enable`/`--disable` empêche le chargement automatique. + +Le lanceur tente de démarrer le proxy puis transmet les arguments d’origine à Codex, sans réécrire sa configuration. Les routes natives et les passerelles tierces sautent le démarrage. Une route inconnue avertit puis tente le démarrage ; un échec avertit et continue. L’aide, la version et les commandes internes comme `login`, `doctor` et `help` sautent le démarrage et la lecture automatique du jeton. + +Un `OPENCODEX_API_AUTH_TOKEN` absent est lu dans le `service-api-token` de cette installation uniquement si toute la configuration de transport du provider sélectionné correspond au bloc géré pour son écouteur loopback ou sa connexion client locale enregistrée. Le nom et les commentaires ne suffisent pas. Destinations inconnues ou distantes, profile par défaut, configuration de projet, `OPENAI_BASE_URL` et options transmises `-c`/`--config`, `-p`/`--profile`, `-C`/`--cd`, `--remote` désactivent cette lecture. Les identifiants exportés et les arguments sont conservés ; les remplacements du fichier et les collisions avec les jetons d’administration sont refusés. + +Seul un wrapper autostart correspondant (`matched`) lance directement son Codex sous-jacent. Un état inconnu, y compris une exception d’inspection, exige un contournement vérifié ; les wrappers CMD Windows gérés doivent correspondre au modèle généré complet avant leur exécution avec `OCX_SHIM_BYPASS=1`. Les lanceurs npm ordinaires restent pris en charge. Un wrapper invérifiable ou obsolète entraîne le code de sortie 1 sans démarrer Codex ; actualisez-le avec `ocx codex-shim install`. Le lancement ne répare pas les shims ; le contrôle de contenu ne garantit ni la provenance du fichier Windows sous-jacent ni l’absence de remplacement concurrent. + +Aide du lanceur : `ocx help codex` ; aide de Codex : `ocx codex --help`. Le comportement ultérieur de `codex` seul est conservé. + ### `ocx opencode [opencode args...]` Vérifiez que le proxy est actif, puis lancez opencode avec les blocs `provider.opencodex` et `providers.opencodex` générés dans la couche d’exécution intégrée d’OpenCode (`OPENCODE_CONFIG_CONTENT`). La configuration intégrée existante est préservée et seules ces deux clés sont remplacées pour ce lancement. Les fichiers `opencode.json` globaux ou propres au projet peuvent être lus afin de signaler une substitution existante, mais les fichiers sur disque ne sont jamais modifiés. Les modèles routés apparaissent sous la forme `opencodex//`. Un lancement ultérieur de `opencode` sans intermédiaire se comporte exactement comme auparavant. diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index eec26ef3049..102bab1aa8a 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -1032,6 +1032,12 @@ its two modes: It never substitutes a Pool or provider credential for the assertion, and it never forwards or logs the assertion. Use `dual_header`, or turn the feature off, when Codex runs through opencodex. +### Launch with `ocx codex` + +The key is supplied only when the configuration and its namespaces are protected from other principals; the current user and privileged OS authorities are trusted. Same-user processes are trusted: an actor with your write access can already read your OpenCodex key file. Same-user configuration replacement between verification and native startup is outside this threat model; the launcher does not freeze configuration at handoff. + +On Windows, default ProgramData creation rights withhold the key even when `ProgramData\OpenAI\Codex` is absent. The system Codex configuration folder under ProgramData must exist and be writable only by Administrators/SYSTEM, with its ancestors protected against replacement by other users. Ask an administrator to review the directory policy. opencodex never changes ACLs itself and provides no provisioning command. Alternatively, supply your own key by exporting `OPENCODEX_API_AUTH_TOKEN`; `ocx codex` leaves it untouched. See [launcher requirements and diagnostics](/reference/cli/agents/#ocx-codex-codex-args). Other refusals also produce one short specific stderr line without key values, key-file contents or key-file paths. + ### Codex autostart shim Run `ocx codex-shim install` to install the optional launcher wrapper. It runs diff --git a/docs-site/src/content/docs/ja/reference/cli/agents.md b/docs-site/src/content/docs/ja/reference/cli/agents.md index ebe43edfffc..5bd50f5acce 100644 --- a/docs-site/src/content/docs/ja/reference/cli/agents.md +++ b/docs-site/src/content/docs/ja/reference/cli/agents.md @@ -130,6 +130,39 @@ ocx claude desktop import [--apply] Validate and import JSON ファミリは `opus`、`fable`、`sonnet`、および `haiku` です。新しいルートは `opus` で始まります。 `none` は、そのファミリーが空の場合にのみ有効です。従来の適用フラグ `--static`、`--hybrid`、および `--discovery-only` は引き続きサポートされます。クロードコードの設定には`ocx claude config ...`を使用してください。 +### `ocx codex [codex args...]` + +キーは設定とその名前空間が他の主体による変更から保護されている場合にのみ渡します。現在の利用者と特権 OS 主体を信頼します。同じ利用者のプロセスは信頼対象です。その利用者の書き込み権限を持つ主体は、既にその利用者の OpenCodex キーファイルも読めます。検証からネイティブ起動までの同一利用者による設定置換はこの脅威モデルの対象外で、引き渡し時に設定を固定しません。 + +通常の Windows の既定 ProgramData 権限では他の利用者がディレクトリを作成できます。そのため `ProgramData\OpenAI\Codex` がなくても自動認証情報を保留します。ProgramData 配下のシステム Codex 設定フォルダーは存在し、Administrators/SYSTEM だけが書き込める必要があり、他の利用者による祖先ディレクトリの置換も防ぐ必要があります。管理者にディレクトリポリシーの確認を依頼してください。opencodex 自身は ACL を変更せず、準備用のコマンドも提供しません。 + +代わりに利用者が自分でキーを指定できます。例えば `OPENCODEX_API_AUTH_TOKEN` を環境変数としてエクスポートすると、`ocx codex` はその値を変更しません。既存のシステム設定ファイルがあれば引き続き自動認証情報を保留し、別途確認が必要です。各保留理由はキー値、キーファイルの内容やパスを含まない短い具体的な stderr 診断一行で示されます。 + +単語一つのプロンプトの前に `--` を置き、未知の Codex サブコマンド選択を防いでください。 + +設定ファイルがなくても、machine 設定パスに他の利用者が作成できない必要があります。Windows ProgramData/OpenAI/Codex の保護を確認できなければ注入を保留し、ディレクトリを作成しません。macOS は拒否のみの ACL と読み取り専用の許可を受け入れます。書き込み・追記・削除・属性・セキュリティ・所有者の変更を許可するエントリは、先行する拒否があっても拒否します。不明なタグや検査エラーも拒否します。 + +新しいサービストークンを読み込むのは、argv が下記の許可リスト内で、空でない HTTP(S)/ALL proxy や `CODEX_*`/`OPENAI_*`/`AZURE_OPENAI_*` 環境変数の上書きがなく(正規の `CODEX_HOME` は許可)、ホーム・設定・システムの名前空間が所有権検査を通過した場合だけです。設定は監査済みキーと正確な管理 loopback provider のみで、追加の資格情報利用先や設定層がない必要があります。`-c`/`--config`、`-p`/`--profile`、未知のフラグ、`resume`/`fork` など許可外の引数ではトークンを保留し、値を含まない診断を出します。明示した資格情報と元の引数は維持します。 + +正規ホームと `config.toml` は現在の利用者所有である必要があります。POSIX は group/other の書き込みを、Windows は現在の利用者・SYSTEM・Administrators 以外への DACL 書き込み許可を拒否し、所有者 SID を確認します。祖先パスの置換権限も確認し、macOS は強制 Codex 設定 preference がないことも要求します。同じ利用者や管理者の悪意あるプロセスを隔離するものではありません。 + +| Allowlist | Flags | +| --- | --- | +| Switches | `--no-daemon`, `--json`, `--ephemeral`, `--skip-git-repo-check`, `--no-alt-screen`, `--full-auto`, `--dangerously-bypass-approvals-and-sandbox`, `--search` | +| One value | `--model`/`-m`, `--sandbox`/`-s`, `--ask-for-approval`/`-a`, `--image`/`-i` | + + + +自動認証情報の読み込みでは `CODEX_HOME` の `~` と空白をリテラルなパスとして解釈して検証し、同じ正規絶対パスを子プロセスに渡します。ホームを読めない場合は読み込みません。大文字小文字を問わず、空でない `HTTP_PROXY`、`HTTPS_PROXY`、`ALL_PROXY` があれば `NO_PROXY` に関係なく自動読み込みを省略します。拒否した起動とユーザーがエクスポートした認証情報は変更しません。新しいトークンを渡す場合、既存の除外項目を保持し、子の `NO_PROXY` と `no_proxy` に `127.0.0.1` と `::1` を追加します。`features.respect_system_proxy` の有効化や `--enable`/`--disable` の転送では自動読み込みを省略します。 + +元の引数で Codex を起動する前にプロキシの起動を試みます。ルーティングは Codex 設定が所有し、ランチャーは書き換えません。ネイティブと外部ゲートウェイでは起動を省略します。不明なルートでは警告して起動を試み、失敗しても警告後に続行します。ヘルプ、バージョン、`login`、`doctor`、`help` などの内部コマンドは起動と自動トークン読み込みを省略します。 + +選択した provider の全転送設定が、このインストールのループバックリスナーまたは保存されたローカルクライアント接続向けの管理ブロックと一致する場合だけ、不足する `OPENCODEX_API_AUTH_TOKEN` をこのインストールの `service-api-token` から読みます。名前や注入コメントだけでは許可しません。不明・リモートの接続先、既定 profile、プロジェクト設定、`OPENAI_BASE_URL`、転送した `-c`/`--config`、`-p`/`--profile`、`-C`/`--cd`、`--remote` は自動読み込みを省略します。ユーザーがエクスポートした認証情報と引数を保持し、トークンファイルの上書きと管理トークンの衝突を拒否します。 + +一致を確認した(`matched`)autostart wrapper だけが背後の Codex を直接実行します。検査例外を含む不明な状態では検証済みのバイパスが必要で、管理対象の Windows CMD wrapper は生成テンプレート全体と一致した場合だけ `OCX_SHIM_BYPASS=1` で実行します。通常の npm ランチャーは利用できます。検証不能または古い wrapper は終了コード 1 で拒否し、Codex は開始しません。`ocx codex-shim install` で更新してください。この起動は shim を修復せず、内容検査は Windows backing ファイルの出所や並行したパス置換を保証しません。 + +ランチャーのヘルプは `ocx help codex`、Codex のヘルプは `ocx codex --help` です。後で通常の `codex` を起動する動作は変わりません。 + ### `ocx opencode [opencode args...]` プロキシが実行されていることを確認し、OpenCode のインライン ランタイム層 (`OPENCODE_CONFIG_CONTENT`) で生成された `provider.opencodex` および `providers.opencodex` ブロックを使用してオープンコードを起動します。既存のインライン設定は保持され、今回の起動ではこの 2 つのキーのみが置き換えられます。グローバルまたはプロジェクトの `opencode.json` ファイルは、既存の上書きについて警告するために読み取られることがありますが、ディスク上のファイルは変更されません。ルーティングされたモデルは `opencodex//` として表示されます。後でプレーン `opencode` を起動すると、以前とまったく同じように動作します。 diff --git a/docs-site/src/content/docs/ko/reference/cli/agents.md b/docs-site/src/content/docs/ko/reference/cli/agents.md index c8bb7e53731..8c6ae99e5a9 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -162,6 +162,39 @@ ocx claude desktop import [--apply] Validate and import JSON family는 `opus`, `fable`, `sonnet`, `haiku`이며, 새 route는 `opus`에서 시작합니다. `none`은 해당 family가 비어 있을 때만 유효합니다. 레거시 apply 플래그인 `--static`, `--hybrid`, `--discovery-only`도 계속 지원합니다. Claude Code 설정은 `ocx claude config ...`를 사용하십시오. +### `ocx codex [codex args...]` + +키는 설정과 해당 경로가 다른 보안 주체의 변경으로부터 보호될 때만 전달합니다. 현재 사용자와 권한 있는 OS 주체는 신뢰합니다. 같은 사용자로 실행되는 프로세스는 신뢰 대상입니다. 사용자의 쓰기 권한을 가진 행위자는 사용자의 OpenCodex 키 파일도 이미 읽을 수 있습니다. 검증과 네이티브 시작 사이에 같은 사용자가 설정을 교체하는 시나리오는 이 위협 모델의 범위 밖이며, 런처는 인계 시 설정을 고정하지 않습니다. + +일반적인 Windows 설치의 기본 ProgramData 권한은 다른 사용자에게 디렉터리 생성 권한을 줍니다. 따라서 `ProgramData\OpenAI\Codex`가 없어도 자동 자격 증명을 보류합니다. ProgramData 아래의 시스템 Codex 설정 폴더는 존재해야 하고 Administrators/SYSTEM만 쓸 수 있어야 하며, 다른 사용자가 상위 디렉터리를 교체할 수 없어야 합니다. 관리자에게 디렉터리 정책 검토를 요청하세요. opencodex는 직접 ACL을 변경하지 않으며 프로비저닝 명령을 제공하지 않습니다. + +대신 사용자가 직접 키를 제공할 수 있습니다. 예를 들어 `OPENCODEX_API_AUTH_TOKEN` 환경 변수를 내보내면 `ocx codex`는 그 값을 그대로 유지합니다. 기존 시스템 설정 파일은 여전히 자동 자격 증명을 보류하며 별도 검토가 필요합니다. 각 보류 사유는 키 값, 키 파일 내용이나 경로 없이 짧고 구체적인 stderr 진단 한 줄로 표시됩니다. + +한 단어 프롬프트 앞에는 `--`를 넣어 알 수 없는 Codex 하위 명령 선택을 막으세요. + +설정 파일이 없어도 machine 설정 경로에 다른 사용자가 파일을 만들 수 없어야 합니다. Windows ProgramData/OpenAI/Codex 경로의 보호를 증명할 수 없으면 자동 토큰을 보류하며 디렉터리를 만들지 않습니다. macOS는 거부 전용 ACL과 읽기 전용 허용 항목을 수락합니다. 쓰기·추가·삭제·속성·보안·소유자 변경을 허용하는 항목은 앞에 거부 항목이 있어도 거부합니다. 알 수 없는 태그나 검사 오류도 거부합니다. + +새 서비스 토큰은 argv가 아래 허용 목록 안에 있고, 비어 있지 않은 HTTP(S)/ALL proxy 및 `CODEX_*`/`OPENAI_*`/`AZURE_OPENAI_*` 환경 재정의가 없으며(정규 `CODEX_HOME`은 허용), 홈·설정·시스템 경로가 소유권 검사를 통과할 때만 읽습니다. 설정에는 검증된 키와 정확한 관리 loopback provider만 있어야 하며 추가 자격 증명 소비자나 설정층이 없어야 합니다. `-c`/`--config`, `-p`/`--profile`, 알 수 없는 플래그, `resume`/`fork` 등 허용 목록 밖의 인자는 토큰을 보류하고 값 없는 진단을 출력합니다. 명시적인 사용자 자격 증명과 원래 인자는 유지합니다. + +정규 홈 디렉터리와 `config.toml`은 현재 사용자 소유여야 합니다. POSIX에서는 group/other 쓰기를, Windows에서는 현재 사용자·SYSTEM·Administrators 이외의 DACL 쓰기 권한을 거부하고 소유자 SID를 확인합니다. 상위 경로 교체 권한도 확인합니다. macOS에서는 강제 Codex 설정 preference가 없어야 합니다. 같은 사용자나 관리자의 악성 프로세스로부터 격리하는 기능은 아닙니다. + +| Allowlist | Flags | +| --- | --- | +| Switches | `--no-daemon`, `--json`, `--ephemeral`, `--skip-git-repo-check`, `--no-alt-screen`, `--full-auto`, `--dangerously-bypass-approvals-and-sandbox`, `--search` | +| One value | `--model`/`-m`, `--sandbox`/`-s`, `--ask-for-approval`/`-a`, `--image`/`-i` | + + + +자동 자격 증명 전달은 `CODEX_HOME`의 `~`와 공백을 리터럴 경로로 해석하고 검증한 뒤, 동일한 정규 절대 경로를 자식에게 전달합니다. 홈을 읽을 수 없으면 토큰을 읽지 않습니다. 대소문자와 관계없이 `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY` 중 비어 있지 않은 값이 있으면 `NO_PROXY`와 무관하게 자동 로딩을 건너뜁니다. 거부된 실행과 사용자가 내보낸 자격 증명은 변경하지 않습니다. 새 토큰을 전달할 때 자식의 `NO_PROXY`와 `no_proxy`에 기존 우회 항목을 보존하면서 `127.0.0.1`과 `::1`을 추가합니다. `features.respect_system_proxy`를 켜거나 `--enable`/`--disable`을 전달하면 자동 로딩을 건너뜁니다. + +원래 인자로 Codex를 실행하기 전에 프록시 시작을 시도합니다. 라우팅은 Codex 설정이 소유하며 런처가 재작성하지 않습니다. 네이티브 및 다른 게이트웨이 경로에서는 시작을 생략합니다. 알 수 없는 라우팅은 경고 후 시작을 시도하며, 시작 실패 시에도 경고 후 실행합니다. 도움말·버전 및 `login`, `doctor`, `help` 같은 내부 명령은 시작과 자동 토큰 읽기를 생략합니다. + +선택한 provider의 전체 전송 설정이 이 설치의 로컬 리스너 또는 저장된 로컬 클라이언트 연결에 대해 생성한 관리 블록과 일치할 때만, 누락된 `OPENCODEX_API_AUTH_TOKEN`을 이 설치의 `service-api-token`에서 읽습니다. 이름이나 주입 주석만으로는 허용하지 않습니다. 알 수 없거나 원격인 목적지, 기본 profile, 프로젝트 설정, `OPENAI_BASE_URL`, 전달된 `-c`/`--config`, `-p`/`--profile`, `-C`/`--cd`, `--remote`는 자동 읽기를 생략합니다. 사용자가 export한 자격 증명과 인자는 보존하며, 토큰 파일 경로 재정의와 관리 토큰 충돌은 거부합니다. + +일치가 확인된(`matched`) autostart wrapper만 backing Codex를 직접 실행합니다. 검사 예외를 포함한 알 수 없는 상태는 검증된 bypass가 필요하며, 관리되는 Windows CMD wrapper는 현재 생성 템플릿 전체와 일치해야 `OCX_SHIM_BYPASS=1`로 실행합니다. 일반 npm 런처는 지원합니다. 검증할 수 없거나 오래된 wrapper는 종료 코드 1로 거부하며 Codex를 시작하지 않습니다. `ocx codex-shim install`로 갱신하세요. 이 실행은 shim을 수리하지 않으며, 내용 검사는 Windows backing 파일 출처나 동시 경로 교체를 보장하지 않습니다. + +런처 도움말은 `ocx help codex`, Codex 도움말은 `ocx codex --help`입니다. 이후 plain `codex`의 동작은 기존과 같습니다. + ### `ocx opencode [opencode args...]` 프록시가 실행 중인지 확인한 뒤, OpenCode의 인라인 런타임 계층(`OPENCODE_CONFIG_CONTENT`)에 생성된 `provider.opencodex` 및 `providers.opencodex` 블록을 넣어 opencode를 실행합니다. 기존 인라인 config는 유지되고, 이번 실행에서는 이 두 키만 교체됩니다. 전역 또는 프로젝트 `opencode.json` 파일은 기존 override가 있는지 경고하기 위해 읽을 수 있지만, 디스크상의 파일은 절대 수정하지 않습니다. 라우팅된 model은 `opencodex//`로 나타납니다. 이후 plain `opencode`를 실행하면 이전과 정확히 같은 방식으로 동작합니다. diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 7c4d66be919..bd99b024048 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -963,6 +963,39 @@ native-mode flags are rejected; failed runtime save has no local fallback. Apply is a separate existing Desktop action on its machine, so verify host/context before choosing it. Rendered models are not proof of native application. +### `ocx codex [codex args...]` + +The key is supplied only when the configuration and its namespaces are protected from other principals; the current user and privileged OS authorities are trusted. Same-user processes are trusted: an actor with your write access can already read your OpenCodex key file. Same-user configuration replacement between verification and native startup is outside this threat model; the launcher does not freeze configuration at handoff. + +On a normal Windows installation, default ProgramData permissions allow other users to create directories. Automatic credentials are therefore withheld even when `ProgramData\OpenAI\Codex` is absent. The system Codex configuration folder under ProgramData must exist and be writable only by Administrators/SYSTEM, and its ancestors must prevent replacement by other users. Ask an administrator to review the directory policy. opencodex never changes ACLs itself and provides no provisioning command. + +Alternatively, supply the key yourself, for example by exporting `OPENCODEX_API_AUTH_TOKEN`; `ocx codex` leaves it untouched. Existing system configuration files still withhold automatic credentials and require separate review. Each withholding reason produces one short, specific stderr diagnostic without key values, key-file contents or key-file paths. + +Use `--` before a single-word prompt so it cannot select an unknown Codex subcommand. + +The machine configuration namespace must also prevent other users from creating missing system files. An unprotected Windows ProgramData/OpenAI/Codex namespace withholds automatic credentials; the launcher does not create it. macOS accepts deny-only and read-only allow ACLs, but refuses any allow entry granting write, append, delete, attribute/security changes or ownership changes, even after a deny entry. Unknown tags and inspection errors refuse. + +A fresh service token is loaded only when argv is within the allowlist below, the environment has no nonempty HTTP(S)/ALL proxy or `CODEX_*`/`OPENAI_*`/`AZURE_OPENAI_*` overrides (canonical `CODEX_HOME` is allowed), and the home/config/system namespaces pass ownership checks. The configuration must contain only audited keys and the exact managed loopback provider, with no additional credential consumers or configuration layers. Every unsupported argument, including `-c`/`--config`, `-p`/`--profile`, unknown flags and `resume`/`fork`, withholds the token with a value-free diagnostic; explicit user credentials and original arguments are preserved. + +The canonical home directory and `config.toml` must belong to the current user. POSIX denies group/other writes; Windows checks owner SID and rejects DACL write grants outside the current user, SYSTEM and Administrators. Ancestor replacement permissions are checked too. macOS additionally requires no forced Codex configuration preferences. These checks exclude other-user writers; they do not isolate a malicious process running as the same user or an administrator. + +| Allowlist | Flags | +| --- | --- | +| Switches | `--no-daemon`, `--json`, `--ephemeral`, `--skip-git-repo-check`, `--no-alt-screen`, `--full-auto`, `--dangerously-bypass-approvals-and-sandbox`, `--search` | +| One value | `--model`/`-m`, `--sandbox`/`-s`, `--ask-for-approval`/`-a`, `--image`/`-i` | + + + +Automatic credential delivery resolves `CODEX_HOME` as a literal path (including `~` and whitespace), verifies that home, and passes its canonical absolute path to the child. An unreadable home skips loading. Any nonempty `HTTP_PROXY`, `HTTPS_PROXY`, or `ALL_PROXY` variable, in any letter case, also skips loading regardless of `NO_PROXY`; denied launches and user-exported credentials remain unchanged. With a newly loaded token, both child `NO_PROXY` and `no_proxy` include `127.0.0.1` and `::1`, preserving existing bypass entries. Enabling `features.respect_system_proxy` or forwarding `--enable`/`--disable` skips automatic loading. + +Ensure the proxy is running, then launch Codex with the original arguments. Routing remains in Codex configuration; the launcher never rewrites it. Native and foreign routes skip startup. Unknown routing warns and attempts startup; a failed startup warns and continues. Help/version and internal commands such as `login`, `doctor`, and `help` skip startup and automatic token loading. + +A missing `OPENCODEX_API_AUTH_TOKEN` is read from this installation's `service-api-token` only when the selected provider's complete transport configuration matches the managed block for the configured loopback listener or persisted local client connection. Names and injection comments alone grant no credential access. Unknown or remote destinations, default profiles, project configuration, `OPENAI_BASE_URL`, and forwarded `-c`/`--config`, `-p`/`--profile`, `-C`/`--cd`, or `--remote` options skip automatic loading. User-exported credentials and arguments are preserved; token-file overrides cannot select a different credential, and management-token collisions are refused. + +Only a matched autostart wrapper launches its backing Codex directly. Unknown state, including an inspection exception, requires a verified bypass; Windows CMD managed wrappers must match the complete current generated template before the selected absolute command runs with `OCX_SHIM_BYPASS=1`. Ordinary npm launchers remain supported. Unverifiable or obsolete wrappers refuse with exit 1 and start no Codex; run `ocx codex-shim install` to refresh them. This launch does not repair shims. The content check does not establish Windows backing-file provenance or prevent concurrent pathname replacement. + +Use `ocx help codex` for launcher help and `ocx codex --help` for Codex help. Launching plain `codex` later retains its existing behavior. + ### `ocx opencode [opencode args...]` Ensure the proxy is running, then launch opencode with the generated `provider.opencodex` and diff --git a/docs-site/src/content/docs/ru/reference/cli/agents.md b/docs-site/src/content/docs/ru/reference/cli/agents.md index 1c24581fb08..2c0953c8650 100644 --- a/docs-site/src/content/docs/ru/reference/cli/agents.md +++ b/docs-site/src/content/docs/ru/reference/cli/agents.md @@ -151,6 +151,39 @@ ocx claude desktop import [--apply] Validate and import JSON `--hybrid` и `--discovery-only` для apply по-прежнему поддерживаются. Для настроек Claude Code используйте `ocx claude config ...`. +### `ocx codex [codex args...]` + +Ключ передаётся только когда конфигурация и её пространства имён защищены от изменения другими субъектами; текущий пользователь и привилегированные субъекты ОС считаются доверенными. Процессы того же пользователя доверенные: субъект с вашими правами записи уже может прочитать ваш файл ключа OpenCodex. Замена конфигурации тем же пользователем между проверкой и нативным запуском находится вне этой модели угроз; конфигурация при передаче управления не фиксируется. + +При обычной установке Windows права ProgramData по умолчанию позволяют другим пользователям создавать каталоги. Поэтому автоматические учётные данные не передаются даже при отсутствии `ProgramData\OpenAI\Codex`. Папка системной конфигурации Codex в ProgramData должна существовать и быть доступна для записи только Administrators/SYSTEM; родительские каталоги должны быть защищены от замены другими пользователями. Попросите администратора проверить политику каталогов. opencodex никогда не меняет ACL самостоятельно и не предоставляет команду подготовки. + +Вместо этого можно передать ключ самостоятельно, например экспортировав `OPENCODEX_API_AUTH_TOKEN`; `ocx codex` оставляет его значение без изменений. Существующие системные файлы конфигурации по-прежнему блокируют автоматические учётные данные и требуют отдельной проверки. Для каждой причины отказа выводится одна короткая конкретная строка stderr без значений ключей, содержимого или путей файлов ключей. + +Перед подсказкой из одного слова укажите `--`, чтобы исключить выбор неизвестной подкоманды Codex. + +Пространство машинной конфигурации должно запрещать другим пользователям создание отсутствующих системных файлов. Непроверенный Windows ProgramData/OpenAI/Codex запрещает передачу токена; каталог не создаётся. macOS допускает ACL только с запретами и разрешения только на чтение. Любое разрешение записи, добавления, удаления, изменения атрибутов, безопасности или владельца блокирует передачу даже после запрещающей записи. Неизвестные теги и ошибки проверки также блокируют её. + +Новый служебный токен читается только если argv входит в список ниже, нет непустых переменных прокси HTTP(S)/ALL или переопределений `CODEX_*`/`OPENAI_*`/`AZURE_OPENAI_*` (канонический `CODEX_HOME` разрешён), а пространства home/config/system проходят проверку владельца. Нужны только проверенные ключи и точный управляемый loopback-провайдер, без дополнительных потребителей учётных данных и слоёв конфигурации. Любой аргумент вне списка, включая `-c`/`--config`, `-p`/`--profile`, неизвестные флаги и `resume`/`fork`, блокирует токен с диагностикой без значений; явные учётные данные и исходные аргументы сохраняются. + +Канонический каталог и `config.toml` должны принадлежать текущему пользователю. POSIX запрещает запись group/other; Windows проверяет SID владельца и запрещает запись DACL всем, кроме текущего пользователя, SYSTEM и Administrators. Проверяются права замены родительских путей. На macOS также не должно быть принудительных настроек Codex. Эти проверки не изолируют вредоносный процесс того же пользователя или администратора. + +| Allowlist | Flags | +| --- | --- | +| Switches | `--no-daemon`, `--json`, `--ephemeral`, `--skip-git-repo-check`, `--no-alt-screen`, `--full-auto`, `--dangerously-bypass-approvals-and-sandbox`, `--search` | +| One value | `--model`/`-m`, `--sandbox`/`-s`, `--ask-for-approval`/`-a`, `--image`/`-i` | + + + +При автоматическом чтении учётных данных `CODEX_HOME` трактуется как буквальный путь, включая `~` и пробелы. После проверки дочерний процесс получает тот же канонический абсолютный путь. Недоступный каталог отключает чтение. Любое непустое значение `HTTP_PROXY`, `HTTPS_PROXY` или `ALL_PROXY` без учёта регистра также отключает чтение независимо от `NO_PROXY`; отклонённые запуски и экспортированные пользователем учётные данные не меняются. При передаче нового токена в обе переменные дочернего процесса `NO_PROXY` и `no_proxy` добавляются `127.0.0.1` и `::1` с сохранением прежних исключений. Включение `features.respect_system_proxy` или передача `--enable`/`--disable` отключает автоматическое чтение. + +Лаунчер пытается запустить прокси и передаёт Codex исходные аргументы, не переписывая конфигурацию. Нативные маршруты и чужие шлюзы пропускают запуск прокси. Неизвестный маршрут предупреждает и пытается запустить прокси; неудача предупреждает и продолжает выполнение. Справка, версия и внутренние команды `login`, `doctor`, `help` пропускают запуск и автоматическое чтение токена. + +Отсутствующий `OPENCODEX_API_AUTH_TOKEN` читается из `service-api-token` этой установки только при полном совпадении транспортной конфигурации выбранного provider с управляемым блоком для настроенного loopback-слушателя или сохранённого локального клиентского подключения. Имя и комментарии не дают разрешения. Неизвестные или удалённые адреса, profile по умолчанию, конфигурация проекта, `OPENAI_BASE_URL` и переданные `-c`/`--config`, `-p`/`--profile`, `-C`/`--cd`, `--remote` отключают автоматическое чтение. Экспортированные пользователем учётные данные и аргументы сохраняются; подмена файла токена и совпадения с административными токенами отклоняются. + +Только совпавшая (`matched`) обёртка autostart запускает свой базовый Codex напрямую. Неизвестное состояние, включая исключение проверки, требует проверенного обхода; управляемая CMD-обёртка Windows должна полностью совпадать с текущим шаблоном для запуска с `OCX_SHIM_BYPASS=1`. Обычные npm-лаунчеры поддерживаются. Непроверяемая или устаревшая обёртка отклоняется с кодом 1 без запуска Codex; обновите её через `ocx codex-shim install`. Запуск не исправляет shim; проверка содержимого не доказывает происхождение базового файла Windows и не предотвращает одновременную замену пути. + +Справка лаунчера: `ocx help codex`; справка Codex: `ocx codex --help`. Последующий обычный запуск `codex` сохраняет прежнее поведение. + ### `ocx opencode [opencode args...]` Убедиться, что прокси запущен, и затем запустить opencode со сгенерированными блоками diff --git a/docs-site/src/content/docs/tr/reference/cli/agents.md b/docs-site/src/content/docs/tr/reference/cli/agents.md index 132a02705d3..924b9c6a1ce 100644 --- a/docs-site/src/content/docs/tr/reference/cli/agents.md +++ b/docs-site/src/content/docs/tr/reference/cli/agents.md @@ -187,6 +187,39 @@ başlar. `none` yalnızca o aile boş olduğunda geçerlidir. Eski uygulama bayrakları `--static`, `--hybrid` ve `--discovery-only` desteklenmeye devam eder. Claude Code ayarları için `ocx claude config ...` kullanın. +### `ocx codex [codex args...]` + +Anahtar yalnızca yapılandırma ve ad alanları diğer güvenlik sorumlularının değişikliklerine karşı korunduğunda sağlanır; geçerli kullanıcı ve ayrıcalıklı işletim sistemi yetkilileri güvenilirdir. Aynı kullanıcının süreçlerine güvenilir: yazma yetkinize sahip bir aktör OpenCodex anahtar dosyanızı zaten okuyabilir. Doğrulama ile yerel başlatma arasında aynı kullanıcının yapılandırmayı değiştirmesi bu tehdit modelinin kapsamı dışındadır; başlatıcı aktarım sırasında yapılandırmayı dondurmaz. + +Normal Windows kurulumunda varsayılan ProgramData izinleri diğer kullanıcıların dizin oluşturmasına izin verir. Bu nedenle `ProgramData\OpenAI\Codex` yokken bile otomatik kimlik bilgileri verilmez. ProgramData altındaki sistem Codex yapılandırma klasörü mevcut olmalı ve yalnızca Administrators/SYSTEM tarafından yazılabilir olmalıdır; üst dizinler de diğer kullanıcıların değiştirmesine karşı korunmalıdır. Bir yöneticiden dizin politikasını incelemesini isteyin. opencodex ACL’leri kendisi hiçbir zaman değiştirmez ve hazırlık komutu sağlamaz. + +Alternatif olarak anahtarı kendiniz sağlayabilirsiniz; örneğin `OPENCODEX_API_AUTH_TOKEN` ortam değişkenini dışa aktarabilirsiniz. `ocx codex` bu değeri değiştirmez. Mevcut sistem yapılandırma dosyaları otomatik kimlik bilgilerini engellemeye devam eder ve ayrıca incelenmelidir. Her engelleme nedeni, anahtar değeri, anahtar dosyası içeriği veya yolu içermeyen kısa ve belirli bir stderr satırı üretir. + +Bilinmeyen bir Codex alt komutu seçilmesini önlemek için tek sözcüklü istemden önce `--` kullanın. + +Makine yapılandırma alanı, diğer kullanıcıların eksik sistem dosyalarını oluşturmasını da engellemelidir. Korunmayan Windows ProgramData/OpenAI/Codex alanı otomatik belirteci engeller; dizin oluşturulmaz. macOS yalnızca ret içeren ACL’leri ve salt okunur izinleri kabul eder. Yazma, ekleme, silme, öznitelik, güvenlik veya sahip değişikliği veren her izin, önce bir ret olsa da reddedilir. Bilinmeyen etiketler ve inceleme hataları da reddedilir. + +Yeni hizmet belirteci yalnızca argv aşağıdaki izin listesinde olduğunda, boş olmayan HTTP(S)/ALL proxy veya `CODEX_*`/`OPENAI_*`/`AZURE_OPENAI_*` ortam geçersiz kılmaları bulunmadığında (kanonik `CODEX_HOME` izinlidir) ve home/config/system ad alanları sahiplik denetimlerini geçtiğinde okunur. Yapılandırma yalnızca denetlenmiş anahtarları ve tam yönetilen loopback sağlayıcısını içermeli; ek kimlik bilgisi tüketicisi veya yapılandırma katmanı bulunmamalıdır. `-c`/`--config`, `-p`/`--profile`, bilinmeyen bayraklar ve `resume`/`fork` dahil liste dışındaki her argüman belirteci engeller ve değer içermeyen tanı üretir; açık kimlik bilgileri ve özgün argümanlar korunur. + +Kanonik dizin ve `config.toml` geçerli kullanıcıya ait olmalıdır. POSIX group/other yazmasını reddeder; Windows sahip SID’sini doğrular ve geçerli kullanıcı, SYSTEM ve Administrators dışındaki DACL yazma izinlerini reddeder. Üst yol değiştirme izinleri de denetlenir. macOS ayrıca zorunlu Codex tercihlerinin bulunmamasını gerektirir. Bu kontroller aynı kullanıcı veya yönetici olarak çalışan kötü amaçlı işlemleri yalıtmaz. + +| Allowlist | Flags | +| --- | --- | +| Switches | `--no-daemon`, `--json`, `--ephemeral`, `--skip-git-repo-check`, `--no-alt-screen`, `--full-auto`, `--dangerously-bypass-approvals-and-sandbox`, `--search` | +| One value | `--model`/`-m`, `--sandbox`/`-s`, `--ask-for-approval`/`-a`, `--image`/`-i` | + + + +Otomatik kimlik bilgisi yükleme, `CODEX_HOME` içindeki `~` ve boşlukları değişmez yol olarak yorumlar, doğrular ve aynı kurallı mutlak yolu alt sürece aktarır. Okunamayan dizinde yükleme atlanır. Büyük/küçük harfe bakılmaksızın boş olmayan `HTTP_PROXY`, `HTTPS_PROXY` veya `ALL_PROXY` değeri varsa `NO_PROXY` ne olursa olsun yükleme atlanır; reddedilen başlatmalar ve kullanıcının dışa aktardığı kimlik bilgileri değiştirilmez. Yeni token aktarılırken alt sürecin `NO_PROXY` ve `no_proxy` değerlerine mevcut istisnalar korunarak `127.0.0.1` ve `::1` eklenir. `features.respect_system_proxy` etkinleştirilirse veya `--enable`/`--disable` aktarılırsa otomatik yükleme atlanır. + +Başlatıcı proxy'yi başlatmayı dener ve özgün argümanları Codex'e aktarır; yapılandırmayı yeniden yazmaz. Yerel Codex ve yabancı ağ geçidi yolları proxy başlangıcını atlar. Bilinmeyen yol uyarıp başlangıcı dener; başarısızlık uyarır ve devam eder. Yardım, sürüm ve `login`, `doctor`, `help` gibi iç komutlar başlangıcı ve otomatik belirteç okumayı atlar. + +Eksik `OPENCODEX_API_AUTH_TOKEN`, yalnızca seçili provider'ın tüm taşıma yapılandırması bu kurulumun yapılandırılmış loopback dinleyicisi veya kayıtlı yerel istemci bağlantısı için yönetilen blokla eşleştiğinde bu kurulumun `service-api-token` dosyasından okunur. Adlar ve açıklamalar yetki vermez. Bilinmeyen veya uzak hedefler, varsayılan profile, proje yapılandırması, `OPENAI_BASE_URL` ve iletilen `-c`/`--config`, `-p`/`--profile`, `-C`/`--cd`, `--remote` otomatik okumayı atlar. Kullanıcının dışa aktardığı kimlik bilgileri ve argümanlar korunur; belirteç dosyası geçersiz kılmaları ve yönetim belirteci çakışmaları reddedilir. + +Yalnızca eşleşen (`matched`) autostart wrapper arkasındaki Codex’i doğrudan başlatır. İnceleme istisnası dahil bilinmeyen durum doğrulanmış bypass gerektirir; yönetilen Windows CMD wrapper’ları `OCX_SHIM_BYPASS=1` ile çalışmadan önce güncel şablonun tamamıyla eşleşmelidir. Normal npm başlatıcıları desteklenir. Doğrulanamayan veya eski wrapper, Codex başlamadan çıkış kodu 1 ile reddedilir; `ocx codex-shim install` ile yenileyin. Bu başlatma shim’i onarmaz; içerik denetimi Windows backing dosyasının kaynağını veya eşzamanlı yol değişimini güvenceye almaz. + +Başlatıcı yardımı: `ocx help codex`; Codex yardımı: `ocx codex --help`. Sonraki düz `codex` çalıştırmasının davranışı korunur. + ### `ocx opencode [opencode argumanlari...]` Proxy'nin çalıştığından emin olun, ardından OpenCode'un satır içi çalışma zamanı diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md index f3b95ff0f58..405529dc3e3 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md @@ -134,6 +134,39 @@ ocx claude desktop import [--apply] Validate and import JSON 这些 family 是 `opus`、`fable`、`sonnet` 和 `haiku`;新路由默认进入 `opus`。只有在该 family 为空时,`none` 才有效。旧版 apply 标志 `--static`、`--hybrid` 和 `--discovery-only` 仍受支持。Claude Code 设置请使用 `ocx claude config ...`。 +### `ocx codex [codex args...]` + +仅当配置及其命名空间受到保护、其他主体无法修改时才提供密钥;当前用户和特权 OS 主体被信任。同一用户的进程被信任:拥有该用户写入权限的主体已经能读取该用户的 OpenCodex 密钥文件。验证与原生启动之间由同一用户替换配置不在此威胁模型内,启动器不会在交接时冻结配置。 + +正常 Windows 安装的默认 ProgramData 权限允许其他用户创建目录。因此即使 `ProgramData\OpenAI\Codex` 不存在,也会暂缓自动凭据。ProgramData 下的系统 Codex 配置文件夹必须存在,且仅 Administrators/SYSTEM 可写;其祖先目录也必须防止其他用户替换。请管理员审核目录策略。opencodex 从不自行修改 ACL,也不提供配置目录的命令。 + +也可以自行提供密钥,例如导出 `OPENCODEX_API_AUTH_TOKEN` 环境变量;`ocx codex` 会保留其值不变。已有系统配置文件仍会阻止自动凭据,需另行审核。每种暂缓原因都会输出一条简短明确的 stderr 诊断,不包含密钥值、密钥文件内容或路径。 + +单词提示前请加 `--`,以免选择未知的 Codex 子命令。 + +即使系统配置文件不存在,也必须阻止其他用户创建这些文件。无法验证 Windows ProgramData/OpenAI/Codex 路径保护时不自动注入令牌,也不创建目录。macOS 接受仅拒绝的 ACL 和只读允许项。任何允许写入、追加、删除、修改属性、安全或所有者的项都会被拒绝,即使前面已有拒绝项。未知标签和检查错误也会被拒绝。 + +只有 argv 位于下方允许列表内、环境中没有非空 HTTP(S)/ALL proxy 或 `CODEX_*`/`OPENAI_*`/`AZURE_OPENAI_*` 覆盖(允许规范的 `CODEX_HOME`),且主目录、配置和系统命名空间通过所有权检查时,才读取新服务令牌。配置必须只包含已审核键和准确的托管 loopback provider,没有额外凭据消费者或配置层。`-c`/`--config`、`-p`/`--profile`、未知标志和 `resume`/`fork` 等列表外参数都会暂缓令牌,并输出不含值的诊断;明确提供的用户凭据和原始参数保持不变。 + +规范主目录和 `config.toml` 必须属于当前用户。POSIX 拒绝 group/other 写权限;Windows 验证所有者 SID,并拒绝当前用户、SYSTEM、Administrators 之外的 DACL 写入授权。还会检查祖先路径替换权限。macOS 还要求不存在强制 Codex 配置 preference。这些检查不能隔离同一用户或管理员运行的恶意进程。 + +| Allowlist | Flags | +| --- | --- | +| Switches | `--no-daemon`, `--json`, `--ephemeral`, `--skip-git-repo-check`, `--no-alt-screen`, `--full-auto`, `--dangerously-bypass-approvals-and-sandbox`, `--search` | +| One value | `--model`/`-m`, `--sandbox`/`-s`, `--ask-for-approval`/`-a`, `--image`/`-i` | + + + +自动凭证读取会将 `CODEX_HOME` 中的 `~` 与空白视为字面路径,验证后将同一个规范绝对路径传给子进程。无法读取主目录时不读取令牌。只要存在非空的 `HTTP_PROXY`、`HTTPS_PROXY` 或 `ALL_PROXY`(不区分大小写),就会跳过自动读取,不受 `NO_PROXY` 影响;拒绝的启动和用户导出的凭证保持不变。传递新令牌时,子进程的 `NO_PROXY` 与 `no_proxy` 会保留已有排除项并加入 `127.0.0.1` 和 `::1`。启用 `features.respect_system_proxy` 或传入 `--enable`/`--disable` 会跳过自动读取。 + +使用原始参数启动 Codex 前尝试启动代理。路由仍归 Codex 配置所有,启动器不会改写。本机原生及其他网关路由跳过代理启动;未知路由会警告并尝试启动,失败后仍警告并继续。帮助、版本及 `login`、`doctor`、`help` 等内部命令跳过代理启动和自动令牌读取。 + +只有所选 provider 的完整传输配置与本次安装的回环监听器或已保存的本地客户端连接对应的管理块一致时,才从本次安装的 `service-api-token` 读取缺失的 `OPENCODEX_API_AUTH_TOKEN`。名称或注入注释本身不构成授权。未知或远程目标、默认 profile、项目配置、`OPENAI_BASE_URL` 以及传入的 `-c`/`--config`、`-p`/`--profile`、`-C`/`--cd`、`--remote` 都会跳过自动读取。保留用户导出的凭据和参数,拒绝令牌文件覆盖和管理令牌冲突。 + +只有匹配成功(`matched`)的 autostart wrapper 才直接启动背后的 Codex。未知状态(包括检查异常)必须通过 bypass 验证;托管 Windows CMD wrapper 必须与当前生成模板完整一致,才以 `OCX_SHIM_BYPASS=1` 执行。普通 npm 启动器仍受支持。无法验证或过时的 wrapper 以退出码 1 拒绝,且不启动 Codex;请用 `ocx codex-shim install` 更新。本次启动不修复 shim;内容检查不证明 Windows backing 文件来源,也不防止并发路径替换。 + +启动器帮助为 `ocx help codex`,Codex 帮助为 `ocx codex --help`。以后直接运行普通 `codex` 的行为保持不变。 + ### `ocx opencode [opencode args...]` 确保代理正在运行,然后在 OpenCode 的内联运行时层(`OPENCODE_CONFIG_CONTENT`)中启动 opencode,并注入生成的 `provider.opencodex` 和 `providers.opencodex` 块。现有的内联配置会被保留,仅本次启动会替换这两个键。可能会读取全局或项目级 `opencode.json` 文件以警告已有覆盖,但不会修改磁盘上的文件。路由后的模型会显示为 `opencodex//`。稍后再次启动普通 `opencode` 时,行为与之前完全一致。 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md index 4728a5f131b..ab30eedb539 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md @@ -131,6 +131,39 @@ ocx claude desktop import [--apply] 驗證並匯入 JSON 家族為 `opus`、`fable`、`sonnet` 與 `haiku`;新路由從 `opus` 開始。`none` 僅在該家族為空時有效。舊版套用旗標 `--static`、`--hybrid` 與 `--discovery-only` 仍受支援。請用 `ocx claude config ...` 管理 Claude Code 設定。 +### `ocx codex [codex args...]` + +只有設定及其命名空間受到保護、其他主體無法修改時才提供金鑰;目前使用者和特權 OS 主體受到信任。同一使用者的程序受到信任:具有該使用者寫入權限的主體已能讀取該使用者的 OpenCodex 金鑰檔案。驗證與原生啟動之間由同一使用者替換設定不在此威脅模型內,啟動器不會在交接時凍結設定。 + +一般 Windows 安裝的預設 ProgramData 權限允許其他使用者建立目錄。因此即使 `ProgramData\OpenAI\Codex` 不存在,也會暫緩自動憑證。ProgramData 下的系統 Codex 設定資料夾必須存在,且僅 Administrators/SYSTEM 可寫入;其祖先目錄也必須防止其他使用者替換。請管理員檢查目錄原則。opencodex 絕不自行修改 ACL,也不提供設定目錄的命令。 + +您也可以自行提供金鑰,例如匯出 `OPENCODEX_API_AUTH_TOKEN` 環境變數;`ocx codex` 會保留其值不變。既有系統設定檔仍會阻止自動憑證,需另行檢查。每種暫緩原因都會輸出一行簡短明確的 stderr 診斷,不含金鑰值、金鑰檔案內容或路徑。 + +單字提示前請加 `--`,以免選取未知的 Codex 子命令。 + +即使系統設定檔不存在,也必須阻止其他使用者建立這些檔案。無法驗證 Windows ProgramData/OpenAI/Codex 路徑保護時不自動注入權杖,也不建立目錄。macOS 接受僅拒絕的 ACL 與唯讀允許項。任何允許寫入、附加、刪除、修改屬性、安全或擁有者的項目都會被拒絕,即使前面已有拒絕項。未知標籤與檢查錯誤也會被拒絕。 + +只有 argv 位於下方允許清單內、環境中沒有非空 HTTP(S)/ALL proxy 或 `CODEX_*`/`OPENAI_*`/`AZURE_OPENAI_*` 覆寫(允許標準的 `CODEX_HOME`),且主目錄、設定與系統命名空間通過擁有權檢查時,才讀取新服務權杖。設定必須只包含已稽核鍵與準確的受管 loopback provider,沒有額外憑證消費者或設定層。`-c`/`--config`、`-p`/`--profile`、未知旗標和 `resume`/`fork` 等清單外引數都會暫緩權杖,並輸出不含值的診斷;明確提供的使用者憑證與原始引數保持不變。 + +標準主目錄與 `config.toml` 必須屬於目前使用者。POSIX 拒絕 group/other 寫入權限;Windows 驗證擁有者 SID,並拒絕目前使用者、SYSTEM、Administrators 以外的 DACL 寫入授權。也會檢查祖先路徑替換權限。macOS 還要求不存在強制 Codex 設定 preference。這些檢查無法隔離相同使用者或管理員執行的惡意程序。 + +| Allowlist | Flags | +| --- | --- | +| Switches | `--no-daemon`, `--json`, `--ephemeral`, `--skip-git-repo-check`, `--no-alt-screen`, `--full-auto`, `--dangerously-bypass-approvals-and-sandbox`, `--search` | +| One value | `--model`/`-m`, `--sandbox`/`-s`, `--ask-for-approval`/`-a`, `--image`/`-i` | + + + +自動憑證讀取會將 `CODEX_HOME` 中的 `~` 與空白視為字面路徑,驗證後將同一個正規絕對路徑傳給子程序。無法讀取主目錄時不讀取權杖。只要存在非空的 `HTTP_PROXY`、`HTTPS_PROXY` 或 `ALL_PROXY`(不分大小寫),就會略過自動讀取,不受 `NO_PROXY` 影響;拒絕的啟動和使用者匯出的憑證保持不變。傳遞新權杖時,子程序的 `NO_PROXY` 與 `no_proxy` 會保留既有排除項目並加入 `127.0.0.1` 和 `::1`。啟用 `features.respect_system_proxy` 或傳入 `--enable`/`--disable` 會略過自動讀取。 + +使用原始引數啟動 Codex 前嘗試啟動代理。路由仍由 Codex 設定擁有,啟動器不會改寫。原生及其他閘道路由略過代理啟動;未知路由會警告並嘗試啟動,失敗後仍警告並繼續。說明、版本及 `login`、`doctor`、`help` 等內部命令略過代理啟動和自動權杖讀取。 + +只有所選 provider 的完整傳輸設定與本次安裝的回環監聽器或已儲存的本機用戶端連線對應的管理區塊一致時,才從本次安裝的 `service-api-token` 讀取缺少的 `OPENCODEX_API_AUTH_TOKEN`。名稱或注入註解本身不構成授權。未知或遠端目的地、預設 profile、專案設定、`OPENAI_BASE_URL` 及傳入的 `-c`/`--config`、`-p`/`--profile`、`-C`/`--cd`、`--remote` 都會略過自動讀取。保留使用者匯出的憑證與引數,拒絕權杖檔案覆寫和管理權杖衝突。 + +只有比對成功(`matched`)的 autostart wrapper 才直接啟動背後的 Codex。未知狀態(包括檢查例外)必須通過 bypass 驗證;受管 Windows CMD wrapper 必須與目前產生的範本完整一致,才以 `OCX_SHIM_BYPASS=1` 執行。一般 npm 啟動器仍受支援。無法驗證或過時的 wrapper 以結束代碼 1 拒絕,且不啟動 Codex;請用 `ocx codex-shim install` 更新。本次啟動不修復 shim;內容檢查不證明 Windows backing 檔案來源,也不防止並行路徑替換。 + +啟動器說明為 `ocx help codex`,Codex 說明為 `ocx codex --help`。之後直接執行一般 `codex` 的行為保持不變。 + ### `ocx opencode [opencode args...]` 確保代理正在執行,然後在 OpenCode 的內嵌執行階段層(`OPENCODE_CONFIG_CONTENT`)中以生成的 `provider.opencodex` 與 `providers.opencodex` 區塊啟動 opencode。既有的內嵌設定會被保留,本次啟動僅替換這兩個鍵。全域或專案的 `opencode.json` 檔案可能被讀取以警告既有的覆寫,但磁碟上的檔案永不修改。路由模型以 diff --git a/scripts/test-layout/layout-codex-collision.json b/scripts/test-layout/layout-codex-collision.json index 632823be628..126ae7dfbcb 100644 --- a/scripts/test-layout/layout-codex-collision.json +++ b/scripts/test-layout/layout-codex-collision.json @@ -1,4 +1,12 @@ { + "codex-launcher-diagnostics.test.ts": "codex-integration", + "codex-launcher-darwin-acl.test.ts": "codex-integration", + "codex-launcher-darwin-identity.test.ts": "codex-integration", + "codex-launcher-admission.test.ts": "codex-integration", + "codex-launcher.test.ts": "codex-integration", + "codex-launcher-process.test.ts": "codex-integration", + "codex-launcher-security.test.ts": "codex-integration", + "codex-launcher-env.test.ts": "codex-integration", "codex-cli-update-artifacts.test.ts": "codex-integration", "codex-cli-update-apply.test.ts": "codex-integration", "codex-cli-update-paths.test.ts": "codex-integration", diff --git a/src/cli/codex-launch-auth.ts b/src/cli/codex-launch-auth.ts new file mode 100644 index 00000000000..9d825b81454 --- /dev/null +++ b/src/cli/codex-launch-auth.ts @@ -0,0 +1,93 @@ +import { lstatSync, realpathSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { isDeepStrictEqual } from "node:util"; +import { readConfigDiagnostics, websocketsEnabled } from "../config"; +import { readBoundedCodexConfig } from "../codex/inject/bounded-config-reader"; +import { buildProviderTableBlockForTarget } from "../codex/inject/config-toml"; +import { standaloneCodexRoutingTarget, validateCodexRoutingTarget } from "../codex/inject/routing-target"; +import { readClientConnectionState } from "../client/state"; +import { codexHasOtherTokenConsumers, codexTokenConfigShapeIsSafe } from "./codex-launch-policy"; +import { codexLaunchHomeIsOwned, codexLaunchProjectAliasIsOwned, codexLaunchSystemNamespaceIsSafe } from "./codex-launch-ownership"; +import type { CodexTokenRefusal, CodexTokenRefusalReporter } from "./codex-launch-diagnostics"; + +/** Resolve once; the credential verifier and child must use this exact directory. */ +export function resolveCodexLaunchHome(env: Record): string | null { + try { + // Object spread loses Windows process.env's case-insensitive lookup. Refuse + // noncanonical aliases rather than pass conflicting names to CreateProcess. + if (process.platform === "win32" && Object.keys(env).some(key => key !== "CODEX_HOME" && key.toUpperCase() === "CODEX_HOME")) return null; + // Explicit values are native literal paths: no tilde expansion or whitespace trim. + // An implicit managed home is safe only when also made explicit for the child. + const userHome = (process.platform === "win32" ? env.USERPROFILE : env.HOME) || homedir(); + const raw = env.CODEX_HOME === undefined ? join(userHome, ".codex") : env.CODEX_HOME; + if (!raw.trim()) return null; + const home = realpathSync(resolve(raw)); + return statSync(home).isDirectory() ? home : null; + } catch { return null; } +} + +/** Config classification is a startup hint; credentials need an independently bound destination. */ +export function codexCredentialDestinationIsTrusted(home: string, systemNamespaceDirectory?: string, report?: CodexTokenRefusalReporter): boolean { + const refuse = (reason: CodexTokenRefusal): false => { report?.(reason); return false; }; + try { + if (!codexLaunchHomeIsOwned(home)) return refuse("ownership"); + if (!codexLaunchSystemNamespaceIsSafe(systemNamespaceDirectory, report)) return false; + const additionalLayers = [join(home, "auth.json"), join(home, "managed_config.toml"), join(home, "requirements.toml")]; + if (process.platform !== "win32") additionalLayers.push("/etc/codex/config.toml", "/etc/codex/managed_config.toml", "/etc/codex/requirements.toml"); + for (const path of additionalLayers) { + try { lstatSync(path); return refuse("additionalLayer"); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") return refuse("layerInspection"); } + } + const diagnostics = readConfigDiagnostics(); + if (diagnostics.error || diagnostics.source === "fallback") return refuse("installationConfig"); + const cfg = diagnostics.config; + let target = standaloneCodexRoutingTarget(cfg.port, cfg); + if (cfg.runtimeRole === "client") { + // Direct clients bind to persisted connection state; link clients use an authless relay. + const connection = readClientConnectionState(); + if (connection.kind !== "connected" || connection.value.transport === "link") return refuse("connection"); + target = validateCodexRoutingTarget({ baseUrl: `${connection.value.serverUrl}/v1`, requiresAdmissionToken: true, tokenEnv: "OPENCODEX_API_AUTH_TOKEN" }); + } + const url = new URL(target.baseUrl); + if (!target.requiresAdmissionToken) return refuse("admission"); + if (url.protocol !== "http:" || !["127.0.0.1", "[::1]"].includes(url.hostname)) return refuse("loopback"); + const content = readBoundedCodexConfig(join(home, "config.toml")); + if (content === null) return refuse("configRead"); + const config = Bun.TOML.parse(content) as Record; + if (codexHasOtherTokenConsumers(config)) return refuse("consumers"); + if (!codexTokenConfigShapeIsSafe(config)) return refuse("configShape"); + // This native feature resolves OS/PAC routes without honoring NO_PROXY. + const features = config.features as Record | undefined; + if (features?.respect_system_proxy !== undefined && features.respect_system_proxy !== false) return refuse("systemProxy"); + // Codex layers project configs and profiles after the home. Without proving those layers, + // conservatively leave authentication to the user's environment. + if (config.profile !== undefined || config.model_provider !== "opencodex") return refuse("provider"); + for (const key of ["openai_base_url", "experimental_realtime_ws_base_url"]) { + if (config[key] !== undefined && config[key] !== target.baseUrl) return refuse("rootUrl"); + } + for (let directory = process.cwd(); ; directory = dirname(directory)) { + const projectConfig = join(directory, ".codex", "config.toml"); + try { + lstatSync(projectConfig); + // Same-file aliases qualify only when their lexical entries and redirect + // targets are protected against replacement by another principal. + try { if (realpathSync(projectConfig) !== realpathSync(join(home, "config.toml")) + || !codexLaunchProjectAliasIsOwned(projectConfig)) return refuse("project"); } + catch { return refuse("project"); } + } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") return refuse("layerInspection"); } + // A nested .git need not be Codex's root: custom project_root_markers can select + // an outer workspace. Without proving that selection, inspect every ancestor. + if (dirname(directory) === directory) break; + } + const providers = config.model_providers as Record | undefined; + const provider = providers?.opencodex as Record | undefined; + const expected = (Bun.TOML.parse(buildProviderTableBlockForTarget(target, websocketsEnabled(cfg))) as { model_providers: { opencodex: Record } }).model_providers.opencodex; + // Display names are cosmetic; every transport and credential-bearing field must match + // the block emitted by our writer for this installation/connection. + if (!provider || typeof provider.name !== "string") return refuse("providerTransport"); + const { name: _name, ...transport } = provider; + const { name: _expectedName, ...expectedTransport } = expected; + return isDeepStrictEqual(transport, expectedTransport) || refuse("providerTransport"); + } catch { return refuse("inspection"); } +} diff --git a/src/cli/codex-launch-command.ts b/src/cli/codex-launch-command.ts new file mode 100644 index 00000000000..7cbf87e9aea --- /dev/null +++ b/src/cli/codex-launch-command.ts @@ -0,0 +1,32 @@ +import { extname, win32 } from "node:path"; +import { readBoundedProjectConfig } from "../codex/project-config-warnings"; +import { buildWindowsCodexShim, SHIM_MARKER } from "../codex/shim-templates"; +import type { BunRuntimeSource } from "../lib/bun-runtime"; + +export const CODEX_UNVERIFIED_SHIM = "opencodex: cannot verify Codex shim bypass; launch refused. Run 'ocx codex-shim install' to refresh an obsolete shim, or select the native Codex executable."; + +/** A content check for the generated CMD control flow, not Windows backing-file provenance. */ +export function codexCommandCanBypass(selected: string, env: Record, platform = process.platform): boolean { + if (platform !== "win32" || ![".cmd", ".bat"].includes(extname(selected).toLowerCase())) return true; + const content = readBoundedProjectConfig(selected); + if (content === null) return false; + // Ordinary npm launchers remain ordinary executable code. Managed/obsolete wrappers must + // prove the whole emitted control flow, including the unique terminal bypass destination. + if (!content.includes(SHIM_MARKER) && !/OCX_|OPENCODEX_API_AUTH_TOKEN|service-api-token/i.test(content)) return true; + const source = /^set "OCX_BUN_RUNTIME_SOURCE=(override|bundled|process|standalone)"\r?$/m.exec(content)?.[1] as BunRuntimeSource | undefined; + if (!source) return false; + const normalize = (text: string): string | null => { + let invalid = false; + const normalized = text.replace(/\r\n/g, "\n").replace(/^set "(OCX_REAL_CODEX|OCX_BUN|OCX_CLI|OCX_API_TOKEN_FILE|OCX_BUN_RUNTIME_PATH)=([^"\n]*)"$/gm, (_line, key: string, raw: string) => { + const expanded = raw.replace(/^%(USERPROFILE|LOCALAPPDATA|APPDATA)%/i, (_prefix, variable: string) => env[variable.toUpperCase()] ?? ""); + // Restrict substitutions to literal local paths. Refuse CMD expansion, separators, + // delayed expansion and control bytes rather than interpreting batch escaping. + if (!/^[a-z]:[\\/]/i.test(expanded) || !win32.isAbsolute(expanded) || /[%!^&|<>"\x00-\x1f\x7f]/.test(expanded)) invalid = true; + return `set "${key}="`; + }); + return invalid ? null : normalized; + }; + const expected = buildWindowsCodexShim("C:\\ocx\\codex.cmd", "C:\\ocx\\bun.exe", "C:\\ocx\\cli.ts", source); + const actual = normalize(content); + return actual !== null && actual === normalize(expected); +} diff --git a/src/cli/codex-launch-diagnostics.ts b/src/cli/codex-launch-diagnostics.ts new file mode 100644 index 00000000000..f29d3d4d77b --- /dev/null +++ b/src/cli/codex-launch-diagnostics.ts @@ -0,0 +1,37 @@ +/** Fixed reasons only: never interpolate configuration, exceptions or credential paths. */ +export const CODEX_TOKEN_REFUSALS = { + args: "arguments are outside the audited allowlist", + environment: "environment contains a proxy or unaudited credential control", + home: "Codex home cannot be resolved unambiguously", + ownership: "Codex home/config ownership or mutation protection could not be verified", + systemConfig: "machine Codex configuration adds an unaudited system layer", + systemNamespace: "machine Codex namespace protection could not be verified", + systemNamespaceWritable: "machine Codex namespace allows other principals to create or replace configuration", + additionalLayer: "home or system configuration adds an unaudited authentication/managed/requirements layer", + layerInspection: "additional configuration layers could not be inspected", + installationConfig: "OpenCodex configuration could not be verified", + connection: "client connection is unavailable or uses an authless link relay", + admission: "configured route does not require an admission credential", + loopback: "configured destination is not an HTTP loopback listener", + configRead: "Codex configuration could not be read within the inspection limit", + consumers: "configuration has additional credential consumers", + configShape: "configuration contains unaudited keys or values", + systemProxy: "configuration enables an unaudited system proxy transport", + provider: "configuration selects a profile or a different provider", + rootUrl: "configuration overrides the managed loopback URL", + project: "project configuration adds an unaudited layer", + providerTransport: "provider transport does not match the managed loopback provider", + inspection: "credential destination inspection failed", + destination: "credential destination could not be verified", + credential: "installation service credential is unavailable or invalid", + collision: "service credential matches a management credential", +} as const; +export type CodexTokenRefusal = keyof typeof CODEX_TOKEN_REFUSALS; +export type CodexTokenRefusalReporter = (reason: CodexTokenRefusal) => void; + +export function codexTokenWithheldDiagnostic(reason: CodexTokenRefusal): string { + const remedy = process.platform === "win32" && reason === "systemNamespaceWritable" + ? ". Default ProgramData creation rights are insufficient. The system Codex configuration folder under ProgramData must exist and be writable only by Administrators/SYSTEM, with its ancestors protected against replacement by other users. Ask an administrator to review the directory policy. opencodex never changes ACLs itself. Alternatively, supply your own key by exporting OPENCODEX_API_AUTH_TOKEN, which ocx codex leaves untouched. Launching without a fresh token." + : ". Launching without a fresh token."; + return `OpenCodex: automatic service token withheld; ${CODEX_TOKEN_REFUSALS[reason]}${remedy}`; +} diff --git a/src/cli/codex-launch-ownership.ts b/src/cli/codex-launch-ownership.ts new file mode 100644 index 00000000000..cf78476fe38 --- /dev/null +++ b/src/cli/codex-launch-ownership.ts @@ -0,0 +1,307 @@ +import { closeSync, fstatSync, lstatSync, openSync, readlinkSync, type Stats } from "node:fs"; +import { dirname, isAbsolute, join, resolve, sep } from "node:path"; +import { resolveTrustedWindowsPowerShellExe } from "../lib/windows-elevation"; +import type { CodexTokenRefusal, CodexTokenRefusalReporter } from "./codex-launch-diagnostics"; + +type OwnershipStat = Pick & { + dev: number | bigint; ino: number | bigint; +}; + +function ownershipStat(path: string): OwnershipStat { + if (process.platform !== "darwin") return lstatSync(path); + // Keep 64-bit object identities exact; ownership and permission fields fit u32. + const stat = lstatSync(path, { bigint: true }); + return { dev: stat.dev, ino: stat.ino, uid: Number(stat.uid), mode: Number(stat.mode), + isFile: () => stat.isFile(), isDirectory: () => stat.isDirectory(), isSymbolicLink: () => stat.isSymbolicLink() }; +} + +// Paths are environment data, never interpolated PowerShell source. The token is +// not read until this read-only subprocess and the config checks have succeeded. +const SCRIPT = String.raw` +$ErrorActionPreference='Stop' +try { + $user=[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value + $trusted=@($user,'S-1-5-18','S-1-5-32-544') + $installer='S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464' + $sections=[System.Security.AccessControl.AccessControlSections]'Owner,Access' + $paths=@($env:OCX_CODEX_OWNED_HOME,[System.IO.Path]::Combine($env:OCX_CODEX_OWNED_HOME,'config.toml')) + $exactOwners=2 + $chain=$env:OCX_CODEX_PATH_CHAIN -eq '1' + if ($chain) { + $paths=ConvertFrom-Json -InputObject $env:OCX_CODEX_OWNED_PATHS + if ($paths.Count -eq 0) { exit 1 } + $exactOwners=0 + } + if ($env:OCX_CODEX_NAMESPACE_CHECK -eq '1') { + $systemDir=$env:OCX_CODEX_NAMESPACE_FIXTURE + if ([string]::IsNullOrEmpty($systemDir)) { + $programData=[Environment]::GetFolderPath([Environment+SpecialFolder]::CommonApplicationData) + if ([string]::IsNullOrEmpty($programData)) { exit 1 } + $systemDir=[System.IO.Path]::Combine($programData,'OpenAI','Codex') + } + foreach ($file in @('config.toml','requirements.toml','managed_config.toml')) { + try { $null=[System.IO.File]::GetAttributes([System.IO.Path]::Combine($systemDir,$file)); exit 3 } + catch [System.IO.FileNotFoundException] { } + catch [System.IO.DirectoryNotFoundException] { } + } + $existing=$systemDir + while ($true) { + try { $attributes=[System.IO.File]::GetAttributes($existing); break } + catch [System.IO.FileNotFoundException] { } + catch [System.IO.DirectoryNotFoundException] { } + $existing=[System.IO.Path]::GetDirectoryName($existing) + if ([string]::IsNullOrEmpty($existing)) { exit 1 } + } + if (($attributes -band [System.IO.FileAttributes]::Directory) -eq 0) { exit 1 } + $paths=@($existing) + $exactOwners=0 + } + $parent=[System.IO.Directory]::GetParent($paths[0]) + while ($null -ne $parent) { $paths+= $parent.FullName; $parent=$parent.Parent } + for ($i=0;$i -lt $paths.Count;$i++) { + $path=$paths[$i] + $reparse=([System.IO.File]::GetAttributes($path) -band [System.IO.FileAttributes]::ReparsePoint) -ne 0 + if (!$chain -and $reparse) { exit 1 } + if ([System.IO.Directory]::Exists($path)) { $acl=[System.Security.AccessControl.DirectorySecurity]::new($path,$sections) } + else { $acl=[System.Security.AccessControl.FileSecurity]::new($path,$sections) } + $raw=[System.Security.AccessControl.RawSecurityDescriptor]::new($acl.GetSecurityDescriptorBinaryForm(),0) + if ($i -lt $exactOwners -and $raw.Owner.Value -ne $user) { exit 1 } + if ($i -ge $exactOwners -and $trusted -notcontains $raw.Owner.Value -and $raw.Owner.Value -ne $installer) { exit 1 } + if ($null -eq $raw.DiscretionaryAcl -or ([int]$raw.ControlFlags -band 4) -eq 0) { exit 1 } + foreach ($ace in $raw.DiscretionaryAcl) { + if ($ace -isnot [System.Security.AccessControl.CommonAce] -or $ace.IsCallback) { exit 1 } + if (([int]$ace.AceFlags -band 8) -ne 0) { continue } + if ([int]$ace.AceType -eq 1) { continue } + if ([int]$ace.AceType -ne 0) { exit 1 } + $sid=$ace.SecurityIdentifier.Value + if ($trusted -contains $sid) { continue } + # TrustedInstaller is an OS authority on ancestors only, never on the two owned entries. + if ($i -ge $exactOwners -and $sid -eq $installer) { continue } + # Include generic writes/all, DELETE, WRITE_DAC, WRITE_OWNER, and directory DELETE_CHILD. + $mask=[int64]$ace.AccessMask -band 4294967295 + $mutation=1343029590 + if (($chain -and $i -gt 0 -and !$reparse) -or + (!$chain -and ($i -ge 2 -or ($exactOwners -eq 0 -and $i -ge 1)))) { $mutation=1343029312 } + if (($mask -band $mutation) -ne 0) { + if ($exactOwners -eq 0) { exit 2 } + exit 1 + } + } + } + exit 0 +} catch { exit 1 } +`; +const ENCODED_SCRIPT = Buffer.from(SCRIPT, "utf16le").toString("base64"); + +function windowsOwnershipCheck(home: string, namespace: boolean, fixtureDirectory?: string, report?: CodexTokenRefusalReporter, paths?: readonly string[]): boolean { + // Remove case aliases before constructing a Windows environment block. These + // private protocol inputs must come from this call, never the caller's shell. + const inherited = Object.fromEntries(Object.entries(process.env).filter(([key]) => + !/^(?:OCX_CODEX_OWNED_HOME|OCX_CODEX_NAMESPACE_CHECK|OCX_CODEX_NAMESPACE_FIXTURE|OCX_CODEX_PATH_CHAIN|OCX_CODEX_OWNED_PATHS)$/i.test(key))); + const result = Bun.spawnSync([ + resolveTrustedWindowsPowerShellExe(), "-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", ENCODED_SCRIPT, + ], { env: { ...inherited, OCX_CODEX_OWNED_HOME: home, OCX_CODEX_NAMESPACE_CHECK: namespace ? "1" : "0", + OCX_CODEX_NAMESPACE_FIXTURE: fixtureDirectory, OCX_CODEX_PATH_CHAIN: paths ? "1" : "0", + OCX_CODEX_OWNED_PATHS: JSON.stringify(paths) }, stdin: "ignore", stdout: "ignore", stderr: "ignore", timeout: 5_000, windowsHide: true }); + const safe = result.success && !result.exitedDueToTimeout; + if (!safe && namespace) report?.(result.exitCode === 2 ? "systemNamespaceWritable" : result.exitCode === 3 ? "systemConfig" : "systemNamespace"); + return safe; +} + +/** Inspect lexical entries as well as every link target; realpath alone loses replacement authority. */ +export function codexLaunchProjectAliasIsOwned(path: string): boolean { + try { + const paths = new Map(), uid = process.getuid?.(); + const inspect = (entry: string): boolean => { + if (paths.has(entry)) return true; + const stat = ownershipStat(entry), parent = dirname(entry); + paths.set(entry, stat); + if (!stat.isSymbolicLink() && !stat.isDirectory() && !stat.isFile()) return false; + if (process.platform !== "win32") { + if (uid === undefined || (stat.uid !== uid && stat.uid !== 0)) return false; + // Symlink modes are not access controls; their owner and parent protect the entry. + if (!stat.isSymbolicLink() && (stat.mode & 0o022) !== 0 + && (!stat.isDirectory() || (stat.mode & 0o1000) === 0)) return false; + const container = lstatSync(parent); + if ((container.mode & 0o022) !== 0 && (container.mode & 0o1000) !== 0 && stat.uid !== uid) return false; + } + if (stat.isSymbolicLink()) { + const target = readlinkSync(entry); + // Keep dot segments: collapsing link/../target could hide the link that redirects it. + if (!inspect(isAbsolute(target) ? target : parent + sep + target)) return false; + } + return parent === entry || inspect(parent); + }; + if (!inspect(resolve(path))) return false; + if (process.platform === "win32") return windowsOwnershipCheck("", false, undefined, undefined, [...paths.keys()]); + return process.platform !== "darwin" || macHasNoManagedCodexPreferences(paths); + } catch { return false; } +} + +/** A missing system layer is safe only if another user cannot create it later. */ +export function codexPosixSystemNamespace(platform: NodeJS.Platform = process.platform): string { + // Darwin's /etc is a standard root-owned symlink; inspect its physical namespace. + return platform === "darwin" ? "/private/etc/codex" : "/etc/codex"; +} + +export function codexLaunchSystemNamespaceIsSafe(fixtureDirectory?: string, report?: CodexTokenRefusalReporter): boolean { + const refuse = (reason: CodexTokenRefusal): false => { report?.(reason); return false; }; + try { + if (process.platform === "win32") return windowsOwnershipCheck("", true, fixtureDirectory, report); + let path = fixtureDirectory ?? codexPosixSystemNamespace(); + for (const file of ["config.toml", "requirements.toml", "managed_config.toml"]) { + try { lstatSync(join(path, file)); return refuse("systemConfig"); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") return refuse("systemNamespace"); } + } + for (;;) { + try { lstatSync(path); break; } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") return refuse("systemNamespace"); } + if (dirname(path) === path) return refuse("systemNamespace"); + path = dirname(path); + } + const paths = new Map(); + for (;;) { + const stat = ownershipStat(path); + if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.uid !== process.getuid?.() && stat.uid !== 0) + || ((stat.mode & 0o022) !== 0 && (paths.size === 0 || (stat.mode & 0o1000) === 0))) return refuse("systemNamespace"); + paths.set(path, stat); + if (dirname(path) === path) break; + path = dirname(path); + } + return process.platform !== "darwin" || macHasNoManagedCodexPreferences(paths) || refuse("systemNamespace"); + } catch { return refuse("systemNamespace"); } +} + +/** Only for a descriptor-bound ACL query: absent FILESEC_ACL reports ENOENT. */ +export function darwinAclDescriptorIsAbsent(descriptor: number | bigint | null, errorNumber: number): boolean { + return !descriptor && errorNumber === 2; +} + +/** Darwin's successful end-of-enumeration differs from POSIX. */ +export function darwinAclInspectionIsEmpty(validResult: number, entryResult: number, errorNumber: number): boolean { + // Darwin differs from POSIX: found entry => 0; a valid empty ACL => -1/EINVAL. + return validResult === 0 && entryResult === -1 && errorNumber === 22; +} + +// Tags, permission bits and enumeration constants: Apple's sys/acl.h +// https://github.com/apple-oss-distributions/Libc/blob/main/include/sys/acl.h +// WRITE_DATA/APPEND_DATA are also ADD_FILE/ADD_SUBDIRECTORY, respectively. +const DARWIN_ACL_MUTATION_PERMISSIONS = [2, 4, 5, 6, 8, 10, 12, 13].map(bit => 1 << bit); +const DARWIN_ACL_MUTATION_MASK = DARWIN_ACL_MUTATION_PERMISSIONS.reduce((mask, permission) => mask | permission, 0); + +/** null means incomplete/unreadable enumeration; DENYs never conceal later ALLOWs. */ +export function darwinAclEntriesAreSafe(entries: readonly { tag: number; perms: number }[] | null): boolean { + return entries !== null && entries.every(({ tag, perms }) => + tag === 2 || (tag === 1 && (perms & DARWIN_ACL_MUTATION_MASK) === 0)); +} + +/** Match Codex's CFPreferencesAppValueIsForced test without reading preference contents. */ +function macHasNoManagedCodexPreferences(paths: ReadonlyMap): boolean { + let library: { close(): void } | undefined; + let aclLibrary: { close(): void } | undefined; + try { + const { dlopen, FFIType, ptr, read } = require("bun:ffi") as typeof import("bun:ffi"); + const acl = dlopen("/usr/lib/libSystem.B.dylib", { + acl_get_fd_np: { args: [FFIType.i32, FFIType.i32], returns: FFIType.ptr }, + acl_get_entry: { args: [FFIType.ptr, FFIType.i32, FFIType.ptr], returns: FFIType.i32 }, + acl_get_tag_type: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.i32 }, + acl_get_permset: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.i32 }, + acl_get_perm_np: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.i32 }, + acl_valid: { args: [FFIType.ptr], returns: FFIType.i32 }, + acl_free: { args: [FFIType.ptr], returns: FFIType.i32 }, + __error: { args: [], returns: FFIType.ptr }, + }); + aclLibrary = acl; + for (const [path, expected] of paths) { + // Apple bsd/sys/fcntl.h: O_RDONLY=0, O_NONBLOCK=4, O_NOFOLLOW=0x100, + // O_DIRECTORY=0x100000, O_SYMLINK=0x200000, O_CLOEXEC=0x1000000. + // https://github.com/apple-oss-distributions/xnu/blob/main/bsd/sys/fcntl.h + // O_NOFOLLOW rejects final links even with O_SYMLINK; select one by type. + // O_SYMLINK opens a link itself; fstat below rejects non-link replacements. + // Nonblocking open prevents a replaced regular file/FIFO from stalling admission. + const fd = openSync(path, 0x1000004 | (expected.isSymbolicLink() ? 0x200000 : 0x100 | (expected.isDirectory() ? 0x100000 : 0))); + try { + const actual = fstatSync(fd, { bigint: true }); + if (actual.dev !== expected.dev || actual.ino !== expected.ino + || Number(actual.mode & 0o170000n) !== (expected.mode & 0o170000)) return false; + const entry = Buffer.alloc(8), tag = Buffer.alloc(4), permset = Buffer.alloc(8); + const descriptor = acl.symbols.acl_get_fd_np(fd, 0x100); // ACL_TYPE_EXTENDED + if (!descriptor) { + // Read errno immediately. acl_get_fd_np uses fstatx_np on this open fd, + // with no pathname lookup that could fail ENOENT after a rename/unlink. + // Only filesec_get_property(FILESEC_ACL) can report absence as ENOENT. + // https://github.com/apple-oss-distributions/Libc/blob/main/posix1e/acl_file.c + // https://github.com/apple-oss-distributions/Libc/blob/main/sys/statx_np.c + // https://github.com/apple-oss-distributions/Libc/blob/main/gen/filesec.c + const errno = acl.symbols.__error(); + if (!errno || !darwinAclDescriptorIsAbsent(descriptor, read.i32(errno))) return false; + continue; + } + try { + const valid = acl.symbols.acl_valid(descriptor); + if (valid !== 0) return false; + const entries: { tag: number; perms: number }[] = []; + for (let id = 0; ; id = -1) { // ACL_FIRST_ENTRY=0, ACL_NEXT_ENTRY=-1 + const found = acl.symbols.acl_get_entry(descriptor, id, ptr(entry)); + if (found !== 0) { + const errno = acl.symbols.__error(); + if (!errno || !darwinAclInspectionIsEmpty(valid, found, read.i32(errno))) return false; + break; + } + const entryPointer = read.ptr(ptr(entry)) as import("bun:ffi").Pointer; + if (!entryPointer || acl.symbols.acl_get_tag_type(entryPointer, ptr(tag)) !== 0 + || acl.symbols.acl_get_permset(entryPointer, ptr(permset)) !== 0) return false; + const permissions = read.ptr(ptr(permset)) as import("bun:ffi").Pointer; + if (!permissions) return false; + let perms = 0; + for (const permission of DARWIN_ACL_MUTATION_PERMISSIONS) { + const granted = acl.symbols.acl_get_perm_np(permissions, permission); + if (granted !== 0 && granted !== 1) return false; + if (granted === 1) perms |= permission; + } + entries.push({ tag: tag.readInt32LE(), perms }); + } + if (!darwinAclEntriesAreSafe(entries)) return false; + } finally { if (acl.symbols.acl_free(descriptor) !== 0) return false; } + } finally { closeSync(fd); } + } + const core = dlopen("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation", { + CFStringCreateWithCString: { args: [FFIType.ptr, FFIType.ptr, FFIType.u32], returns: FFIType.ptr }, + CFPreferencesAppValueIsForced: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.bool }, + CFRelease: { args: [FFIType.ptr], returns: FFIType.void }, + }); + library = core; + const strings = ["com.openai.codex", "config_toml_base64", "requirements_toml_base64"].map(value => Buffer.from(value + "\0")); + const refs = strings.map(value => core.symbols.CFStringCreateWithCString(null, ptr(value), 0x08000100)); + try { + if (refs.some(value => !value)) return false; + return !core.symbols.CFPreferencesAppValueIsForced(refs[1]!, refs[0]!) + && !core.symbols.CFPreferencesAppValueIsForced(refs[2]!, refs[0]!); + } finally { for (const ref of refs) if (ref) core.symbols.CFRelease(ref); } + } catch { return false; } + finally { library?.close(); aclLibrary?.close(); } +} + +/** Excludes other-user writers; it cannot isolate processes with the same user identity. */ +export function codexLaunchHomeIsOwned(home: string): boolean { + try { + const directory = ownershipStat(home), config = ownershipStat(join(home, "config.toml")); + if (!directory.isDirectory() || directory.isSymbolicLink() || !config.isFile() || config.isSymbolicLink()) return false; + if (process.platform === "win32") { + return windowsOwnershipCheck(home, false); + } + const uid = process.getuid?.(); + if (uid === undefined || [directory, config].some(stat => stat.uid !== uid || (stat.mode & 0o022) !== 0)) return false; + const paths = new Map([[home, directory], [join(home, "config.toml"), config]]); + for (let parent = dirname(home); ; parent = dirname(parent)) { + const stat = ownershipStat(parent); + if (!stat.isDirectory() || stat.isSymbolicLink()) return false; + if (stat.uid !== uid && stat.uid !== 0) return false; + // Sticky directories (e.g. /tmp) protect entries owned by this user. + if ((stat.mode & 0o022) !== 0 && (stat.mode & 0o1000) === 0) return false; + paths.set(parent, stat); + if (dirname(parent) === parent) break; + } + return process.platform !== "darwin" || macHasNoManagedCodexPreferences(paths); + } catch { return false; } +} diff --git a/src/cli/codex-launch-policy.ts b/src/cli/codex-launch-policy.ts new file mode 100644 index 00000000000..fdb190c3be9 --- /dev/null +++ b/src/cli/codex-launch-policy.ts @@ -0,0 +1,84 @@ +/** Credential authorization is deliberately narrower than Codex's evolving CLI. */ +export const CODEX_TOKEN_SAFE_SWITCHES = [ + "--no-daemon", "--json", "--ephemeral", "--skip-git-repo-check", + "--no-alt-screen", "--full-auto", "--dangerously-bypass-approvals-and-sandbox", "--search", +] as const; +export const CODEX_TOKEN_SAFE_VALUE_FLAGS = ["--model", "-m", "--sandbox", "-s", "--ask-for-approval", "-a", "--image", "-i"] as const; + +export function codexTokenArgsAreSafe(args: readonly string[]): boolean { + let command = "interactive", operands = 0, afterSeparator = false; + for (let i = 0; i < args.length; i++) { + const arg = args[i]!; + if (arg === "--" && !afterSeparator) { afterSeparator = true; continue; } + if (arg.startsWith("-") && arg !== "-") { + if (afterSeparator) return false; + if ((CODEX_TOKEN_SAFE_SWITCHES as readonly string[]).includes(arg)) continue; + const equals = arg.indexOf("="), flag = equals < 0 ? arg : arg.slice(0, equals); + if (!(CODEX_TOKEN_SAFE_VALUE_FLAGS as readonly string[]).includes(flag)) return false; + const value = equals < 0 ? args[++i] : arg.slice(equals + 1); + if (!value || value.startsWith("-")) return false; + if (["--sandbox", "-s"].includes(flag) && !["read-only", "workspace-write", "danger-full-access"].includes(value)) return false; + if (["--ask-for-approval", "-a"].includes(flag) && !["untrusted", "on-failure", "on-request", "never"].includes(value)) return false; + continue; + } + // Restored sessions may restore a different cwd/provider. They need a separate proof. + if (!afterSeparator && operands === 0 && ["resume", "fork", "review", "app", "app-server", "mcp", "mcp-server", "cloud", "apply", "completion", "debug", "features", "sandbox", "login", "logout", "help", "update", "doctor", "e"].includes(arg)) return false; + if (!afterSeparator && command === "interactive" && operands === 0 && arg === "exec") { + command = arg; continue; + } + // A single-word operand can become a subcommand after a native-client upgrade. + // Require an explicit separator for such prompts; whitespace cannot name a subcommand. + if (!afterSeparator && arg !== "-" && !/\s/.test(arg)) return false; + operands++; + if (operands > 1) return false; + } + return true; +} + +// Unknown configuration may introduce a command helper, hook, or new credential consumer. +// Keep this independent from the provider's exact generated-block comparison. +export const CODEX_TOKEN_SAFE_CONFIG_KEYS = [ + "model_provider", "model_providers", "model", "model_reasoning_effort", "model_reasoning_summary", + "model_verbosity", "approval_policy", "sandbox_mode", "openai_base_url", "features", +] as const; + +export function codexTokenConfigShapeIsSafe(config: Record): boolean { + if (Object.keys(config).some(key => !(CODEX_TOKEN_SAFE_CONFIG_KEYS as readonly string[]).includes(key))) return false; + const providers = config.model_providers; + if (!providers || typeof providers !== "object" || Object.keys(providers).some(key => key !== "opencodex")) return false; + for (const [key, value] of Object.entries(config)) { + if (["features", "model_providers"].includes(key)) continue; + if (typeof value !== "string") return false; + } + const features = config.features; + if (features !== undefined && (!features || typeof features !== "object" || Array.isArray(features) + || Object.entries(features).some(([key, value]) => key === "respect_system_proxy" ? value !== false + : key !== "system_proxy_fallback" || typeof value !== "boolean") + || ("system_proxy_fallback" in features && (!("respect_system_proxy" in features) || features.respect_system_proxy !== false)))) return false; + return true; +} + +/** Unknown Codex/OpenAI environment controls need an audit before receiving a fresh secret. */ +export function codexTokenEnvironmentIsSafe(env: Record): boolean { + return !Object.entries(env).some(([key, value]) => { + if (!value) return false; + if (/^(?:https?|all)_proxy$/i.test(key)) return true; + if (/^(?:CODEX_|OPENAI_|AZURE_OPENAI_)/i.test(key) && key !== "CODEX_HOME") return true; + return key !== "OPENCODEX_API_AUTH_TOKEN" && key.toUpperCase() === "OPENCODEX_API_AUTH_TOKEN"; + }); +} + +/** Check even unselected profiles/providers; new credential field names fail closed. */ +export function codexHasOtherTokenConsumers(config: Record): boolean { + function visit(value: unknown): boolean { + if (typeof value === "string") return value.toUpperCase().includes("OPENCODEX_API_AUTH_TOKEN"); + if (!value || typeof value !== "object") return false; + return Object.entries(value).some(([key, child]) => + key.toUpperCase().includes("OPENCODEX_API_AUTH_TOKEN") + // A stdio MCP process can inherit the environment without naming the variable. + || (key === "mcp_servers" && child !== null && typeof child === "object" && Object.keys(child).length > 0) + || visit(child)); + } + const providers = config.model_providers as Record | undefined; + return visit({ ...config, model_providers: { ...providers, opencodex: undefined } }); +} diff --git a/src/cli/codex-launcher.ts b/src/cli/codex-launcher.ts new file mode 100644 index 00000000000..98fc9f103ea --- /dev/null +++ b/src/cli/codex-launcher.ts @@ -0,0 +1,310 @@ +/** + * `ocx codex [codex args...]` — launch Codex wired to the local proxy's routing, + * passing through untouched when Codex routing is disabled or foreign. + * + * Mirrors `ocx claude` (src/cli/claude.ts) and `ocx opencode` (src/cli/opencode.ts): + * ensures the proxy is running, then execs the client with stdio inherited. The wiring + * channel differs again — Codex routing is config-owned (the injected `openai_base_url` + * and model catalog in `~/.codex/config.toml`), so a launch never rewrites a config + * file. What the launcher owns instead is the two add-ons `plain codex` lacks: + * + * - launch-time autostart: the same detached `ocx start` + health poll that + * `ocx ensure` gives service- and shim-less setups, attempted before every routed launch; + * - an honest route verdict before exec: native and foreign-gateway configs are + * announced (and NOT ensured) instead of silently benefiting or fighting them, + * reusing the routing classification the shim and doctor share + * (src/codex/inject/routing-classify.ts). + * + * The admission token is re-exported from `~/.opencodex/service-api-token` when the + * environment does not carry it — the same injection the generated Codex autostart + * shim performs (src/codex/shim-templates.ts), whose argv fast-path grammar + * (--help/-h, --version/-V, internal subcommands, global options with values) is + * mirrored here so both `codex` entry points behave identically for the same argv. + */ +import { spawn } from "node:child_process"; +import { loadConfig } from "../config"; +import { getCodexRoutingKind, type CodexRoutingKind } from "../codex/inject"; +import { inspectCodexShimBackingForCommand } from "../codex/shim"; +import { CODEX_GLOBAL_OPTIONS_WITH_VALUE, CODEX_INTERNAL_COMMANDS, CODEX_SHIM_ENSURE_FAILED_DIAGNOSTIC } from "../codex/shim-templates"; +import { withoutSiblingMarker } from "../codex/sibling-start"; +import { withProcessRuntimeProvenance } from "../lib/bun-runtime"; +import { tokenCollidesWithAdmin } from "../lib/admin-secrets"; +import { selfLaunchArgv } from "../lib/self-launch-argv"; +import { loadServiceTokenFromFile, serviceApiTokenFilePath } from "../lib/service-secrets"; +import { commandInvocation } from "../lib/win-exec"; +import { findExecutableOnPath } from "../remote-control/workspace-executable"; +import { findLiveProxy, type LiveProxy } from "../server/proxy-liveness"; +import { codexCredentialDestinationIsTrusted, resolveCodexLaunchHome } from "./codex-launch-auth"; +import { codexCommandCanBypass, CODEX_UNVERIFIED_SHIM } from "./codex-launch-command"; +import { codexTokenArgsAreSafe, codexTokenEnvironmentIsSafe } from "./codex-launch-policy"; +import { codexTokenWithheldDiagnostic, type CodexTokenRefusal, type CodexTokenRefusalReporter } from "./codex-launch-diagnostics"; + +export interface CodexLaunchEnv { + [key: string]: string | undefined; +} + +type FastPathReason = "help" | "version" | "internal"; + +/** + * Mirrors the generated shim wrapper's argv grammar (src/codex/shim-templates.ts): + * an invocation whose first positional operand is a Codex-internal subcommand, or + * which only asks for help/version, never needs the proxy. The shell scanners + * inline these sets — the exported constants here are the single source they are + * read from — while the option-value rules (`--model x`, `-c k=v`, ...) stay in + * this scanner in sync with the same templates. + */ +export function codexFastPathReason(args: readonly string[]): FastPathReason | null { + let skipNext = false; + for (const arg of args) { + if (skipNext) { skipNext = false; continue; } + if (arg === "--") return null; // the shim's `break`: rest is operands, ensure runs + const options = CODEX_GLOBAL_OPTIONS_WITH_VALUE; + if (options.includes(arg)) { skipNext = true; continue; } + if (arg === "--help" || arg === "-h") return "help"; + if (arg === "--version" || arg === "-V") return "version"; + if (arg.startsWith("-")) continue; + const value = arg; + return (CODEX_INTERNAL_COMMANDS.includes(value)) ? "internal" : null; + } + return null; +} + +export const CODEX_ROUTING_NATIVE_NOTICE = + "ℹ️ Codex does not route through OpenCodex (no injected routing in ~/.codex/config.toml). Launching `codex` unchanged. Run `ocx ensure` to start the proxy and sync Codex config."; + +export const CODEX_ROUTING_UNKNOWN_NOTICE = + "⚠️ Codex routing could not be verified. Ensuring the proxy is up, but the launch may not be redirected; run `ocx doctor` before relying on it."; + +export const CODEX_ROUTING_CUSTOM_LOCAL_NOTICE = + "⚠️ Codex routes through a user-owned local gateway. OpenCodex does not own that route; launching `codex` unchanged. Run `ocx doctor` to verify that gateway."; + +export const CODEX_ROUTING_CUSTOM_REMOTE_NOTICE = + "⚠️ Codex routes through a remote gateway. OpenCodex does not own that route; launching `codex` unchanged."; + +export type CodexLaunchPlan = + | { kind: "routed" } + | { kind: "passthrough"; notice?: string; ensureProxy: boolean }; + +/** + * Pure launch decision from the shared routing classification: + * + * - `opencodex-local` → ensure + exec (the config this launcher exists for); + * - `native` / `custom-*` → exec unchanged, no proxy side effects; + * - `unknown` (unreadable/ambiguous config) → name it out loud and still attempt + * the ensure, because a routed session that starts without its proxy hits the + * configured URL's dead port and every request fails. + */ +export function codexLaunchPlan(routingKind: CodexRoutingKind): CodexLaunchPlan { + switch (routingKind) { + case "opencodex-local": + return { kind: "routed" }; + case "native": + return { kind: "passthrough", ensureProxy: false, notice: CODEX_ROUTING_NATIVE_NOTICE }; + case "custom-local": + return { kind: "passthrough", ensureProxy: false, notice: CODEX_ROUTING_CUSTOM_LOCAL_NOTICE }; + case "custom-remote": + return { kind: "passthrough", ensureProxy: false, notice: CODEX_ROUTING_CUSTOM_REMOTE_NOTICE }; + case "unknown": + return { kind: "passthrough", ensureProxy: true, notice: CODEX_ROUTING_UNKNOWN_NOTICE }; + } +} + +/** Env the detached `ocx start` child inherits (service-owned admission ladder). */ +export function codexProxyStartEnv(base: CodexLaunchEnv = process.env): CodexLaunchEnv { + const stripped = withoutSiblingMarker(base) as CodexLaunchEnv; + const withTokenFile = stripped.OPENCODEX_API_AUTH_TOKEN?.trim() + ? stripped + : { ...stripped, OCX_API_TOKEN_FILE: serviceApiTokenFilePath() }; + return { ...withTokenFile, OCX_SERVICE: "1" } as CodexLaunchEnv; +} + +export type CodexProxyEnsureDeps = { + findLiveProxy?: typeof findLiveProxy; +}; + +/** + * Mirrors ensureProxyForClaude (src/cli/claude.ts) — the same borrowed liveness + * retry budget (#764), detached `ocx start` on the configured port, 8 s health + * poll — so all `ocx`-launched clients share one autostart contract. + */ +export async function ensureProxyForCodex(deps: CodexProxyEnsureDeps = {}): Promise { + const probe = deps.findLiveProxy ?? findLiveProxy; + const live = await probe({ attempts: 3 }); + if (live) return live; + const cfgPort = loadConfig().port; + const pinPort = typeof cfgPort === "number" && cfgPort > 0 ? cfgPort : 10100; + const child = spawn(process.execPath, selfLaunchArgv(["start", "--port", String(pinPort)]), { + detached: true, + stdio: "ignore", + windowsHide: true, + // An ordinary owner: a stray sibling marker would otherwise mark it before any probe. + env: withProcessRuntimeProvenance(codexProxyStartEnv(process.env) as NodeJS.ProcessEnv), + }); + // Without a listener an 'error' (bad argv[1], EMFILE, AV denial) throws synchronously + // and kills this process; the health poll below already reports the failure properly. + child.on("error", () => { /* handled by the deadline loop returning null */ }); + child.unref(); + const deadline = Date.now() + 8_000; + while (Date.now() < deadline) { + const started = await probe(); + if (started) return started; + await new Promise(resolve => setTimeout(resolve, 250)); + } + return null; +} + +export type CodexLaunchEnvDeps = { + routingKind?: CodexRoutingKind; + args?: readonly string[]; + /** Test seam; production reads the hardened service token file. */ + serviceToken?: (base: CodexLaunchEnv) => string | null; + credentialDestination?: (home: string, report?: CodexTokenRefusalReporter) => boolean; +}; + +/** Read the client admission credential from this installation's service-token file. */ +const defaultServiceToken = (base: CodexLaunchEnv): string | null => + loadServiceTokenFromFile({ ...base, OCX_API_TOKEN_FILE: serviceApiTokenFilePath() }); + +/** + * Launch env for a routed exec. The one injection is the shim's own: a missing + * OPENCODEX_API_AUTH_TOKEN is re-exported from the hardened service token file, so + * `ocx codex` from a shell without the system-env snapshot authenticates to the + * loopback proxy like a shimmed launch does. Fresh credentials require explicit + * argv/env/config allowlists, an exclusive managed provider and protected home/system + * configuration namespaces. User exports always win. + */ +export function buildCodexLaunchEnv( + base: CodexLaunchEnv = process.env, + deps: CodexLaunchEnvDeps = {}, +): CodexLaunchEnv { + const env = { ...base }; + if (deps.routingKind !== undefined && deps.routingKind !== "opencodex-local") return env; + if (!env.OPENCODEX_API_AUTH_TOKEN?.trim()) { + const withhold = (reason: CodexTokenRefusal): CodexLaunchEnv => { + console.error(codexTokenWithheldDiagnostic(reason)); + return env; + }; + if (!codexTokenArgsAreSafe(deps.args ?? [])) return withhold("args"); + if (!codexTokenEnvironmentIsSafe(env)) return withhold("environment"); + // NO_PROXY parsing and precedence vary across native HTTP/WebSocket transports. + // Without proving bypass, preserve the user's transport but never load a secret. + const home = resolveCodexLaunchHome(env); + if (!home) return withhold("home"); + let refusal: CodexTokenRefusal = "destination"; + const report: CodexTokenRefusalReporter = reason => { refusal = reason; }; + const trusted = deps.credentialDestination + ? deps.credentialDestination(home, report) + : codexCredentialDestinationIsTrusted(home, undefined, report); + if (!trusted) return withhold(refusal); + const token = (deps.serviceToken ?? defaultServiceToken)(env); + if (!token) return withhold("credential"); + if (tokenCollidesWithAdmin(token, env)) return withhold("collision"); + // Reqwest's default transport can discover OS proxies even without proxy + // env. Bind both bypass spellings to the allowed loopback hosts, preserving + // existing entries and removing conflicting case aliases before handoff. + const bypass = ["127.0.0.1", "::1"]; + for (const key of Object.keys(env)) if (/^no_proxy$/i.test(key)) { + if (env[key]) bypass.push(env[key]!); + delete env[key]; + } + env.NO_PROXY = env.no_proxy = bypass.join(","); + env.CODEX_HOME = home; + env.OPENCODEX_API_AUTH_TOKEN = token; + } + return env; +} + +const CODEX_INSTALL_HINT = "❌ `codex` CLI not found. Install it first: npm install -g @openai/codex"; +class CodexNotFoundError extends Error {} + +/** + * cmd.exe reports command-not-found as exit 9009 (the win32 launcher routes `.cmd` + * shims through cmd.exe, so ENOENT never fires there). Signal exits are not hints. + * Same contract as claudeNotFoundHint (devlog 260715_cross_platform_audit/020). + */ +export function codexNotFoundHint( + code: number | null, + signal: NodeJS.Signals | null, + platform: NodeJS.Platform = process.platform, +): string | null { + return platform === "win32" && code === 9009 && !signal ? CODEX_INSTALL_HINT : null; +} + +export type CodexLaunchCommandDeps = { + findOnPath?: (name: string) => string | null; + inspectShim?: typeof inspectCodexShimBackingForCommand; + verifyBypass?: typeof codexCommandCanBypass; +}; + +export type CodexLaunchCommand = { command: string; env: CodexLaunchEnv }; + +/** + * The launcher has already made the ensure decision, so the generated autostart shim + * must not make it again: on a passthrough plan it would start the proxy and load the + * admission token anyway. When `codex` resolves to a recorded shim wrapper, exec its + * backing Codex directly. Unknown state requires a separate bypass-content check; + * obsolete or unverifiable Windows wrappers fail closed before starting any child. + */ +export function resolveCodexLaunchCommand( + env: CodexLaunchEnv, + deps: CodexLaunchCommandDeps = {}, +): CodexLaunchCommand { + try { + const selected = (deps.findOnPath ?? findExecutableOnPath)("codex"); + if (!selected) throw new CodexNotFoundError(CODEX_INSTALL_HINT); + let shim: ReturnType; + try { shim = (deps.inspectShim ?? inspectCodexShimBackingForCommand)(selected); } + catch { shim = { status: "unknown", reason: "binding_unavailable" }; } + if (shim.status === "matched" && shim.selectedRole === "wrapper") return { command: shim.backingPath, env }; + if (shim.status !== "unknown") return { command: selected, env }; + if (!(deps.verifyBypass ?? codexCommandCanBypass)(selected, env)) throw new Error(CODEX_UNVERIFIED_SHIM); + return { command: selected, env: { ...env, OCX_SHIM_BYPASS: "1" } }; + } catch (error) { + if (error instanceof CodexNotFoundError) throw error; + throw new Error(CODEX_UNVERIFIED_SHIM); + } +} + +/** Launch the selected Codex command with inherited IO and preserve its exit status. */ +function spawnCodex(args: readonly string[], env: CodexLaunchEnv): Promise { + return new Promise(resolve => { + let launch: CodexLaunchCommand; + try { launch = resolveCodexLaunchCommand(env); } + catch (error) { console.error(error instanceof CodexNotFoundError ? CODEX_INSTALL_HINT : CODEX_UNVERIFIED_SHIM); resolve(1); return; } + const inv = commandInvocation(launch.command, args); + const child = spawn(inv.file, inv.args, { stdio: "inherit", env: launch.env as NodeJS.ProcessEnv, ...inv.options }); + child.on("error", (err: NodeJS.ErrnoException) => { + if (err.code === "ENOENT") { + console.error(CODEX_INSTALL_HINT); + } else { + console.error(`❌ Failed to launch codex: ${err.message}`); + } + resolve(1); + }); + child.on("exit", (code, signal) => { + const hint = codexNotFoundHint(code, signal); + if (hint) console.error(hint); + resolve(signal ? 1 : code ?? 0); + }); + }); +} + +/** Apply the argument/routing plan, then hand the original arguments to Codex. */ +export async function cmdCodex(args: string[]): Promise { + if (codexFastPathReason(args) !== null) return spawnCodex(args, { ...process.env }); + const routingKind = getCodexRoutingKind(); + const plan = codexLaunchPlan(routingKind); + const notice = plan.kind === "passthrough" ? plan.notice : undefined; + if (notice) console.error(notice); + const needsProxy = plan.kind === "routed" + || (plan.kind === "passthrough" && plan.ensureProxy); + if (!needsProxy) return spawnCodex(args, { ...process.env }); + const live = await ensureProxyForCodex(); + if (!live) { + // A failed ensure warns and continues, subject to executable verification. + // The already-injected config decides + // what the session does next; `ocx doctor` names the autostart failure. + console.error(CODEX_SHIM_ENSURE_FAILED_DIAGNOSTIC); + } + return spawnCodex(args, buildCodexLaunchEnv(process.env, { routingKind, args })); +} diff --git a/src/cli/codex-shim-autorestore.ts b/src/cli/codex-shim-autorestore.ts index 257d70d1360..86750714f3b 100644 --- a/src/cli/codex-shim-autorestore.ts +++ b/src/cli/codex-shim-autorestore.ts @@ -19,6 +19,8 @@ const DEFAULT_DEPS: CodexShimAutoRestoreCliDeps = { export function skipsCodexShimAutoRestore(command: string | undefined, args: string[]): boolean { if (command === "uninstall" || command === "remove") return true; if (command === "status" || command === "doctor") return true; + // The launcher owns shim handoff after classifying routing, including native fast paths. + if (command === "codex") return true; // `lab` is read-only inspection; it must not trigger shim side effects. if (command === "lab") return true; // Local messaging owns its helpers; even malformed usage must not repair/start anything. diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 0ecba4899ba..17d8435ef1e 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -1038,6 +1038,10 @@ const commandRunners: Record = { } return await cmdClaude(deps.args.slice(1)); }, + codex: async deps => { + const { cmdCodex } = await import("./codex-launcher"); + return await cmdCodex(deps.args.slice(1)); + }, opencode: async deps => { const { cmdOpencode } = await import("./opencode"); return await cmdOpencode(deps.args.slice(1)); diff --git a/src/cli/help.ts b/src/cli/help.ts index cdd3ecd78d7..8015a8658ab 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -106,6 +106,7 @@ Usage: ocx chatgpt Experimental app-server shim: launch|restore|status (macOS) ocx claude [args...] Launch Claude Code wired to the proxy (model discovery on) ocx claude desktop [sub] Manage and apply Claude Desktop's four-family profile + ocx codex [args...] Launch Codex through the proxy's routing (pass-through when disabled) ocx opencode [args...] Launch opencode wired to the proxy (runtime provider config) ocx mcode [args...] Launch MiniMax Code through its managed provider ocx mmx text [args] Launch MiniMax CLI text through the proxy diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 86ac0c2fa05..9e4678206e7 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -605,6 +605,31 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ "Claude Code settings: ocx claude config ...", ], }, + { + name: "codex", + usage: "ocx codex [codex args...]", + summary: "Launch Codex through the local proxy's routing, passing through when Codex routing is disabled.", + details: [ + "Mirrors `ocx claude` on Codex's config-owned wiring channel: ensures the proxy is running,", + "then execs `codex` unchanged. Routing itself stays config-owned (the injected `openai_base_url`", + "in ~/.codex/config.toml); a launch never rewrites a config file.", + "For verified local routing, a missing OPENCODEX_API_AUTH_TOKEN is filled from ~/.opencodex/service-api-token, the same", + "injection the `codex` autostart shim performs.", + "--help/-h, --version/-V, and Codex-internal subcommands (login, doctor, help, ...) skip the", + "proxy ensure and pass the environment through unchanged, keeping the shim's argv grammar.", + "No injected routing: informational notice, native exec, no ensure.", + "Foreign gateway routing (custom-local/custom-remote): warning, native exec, that route untouched.", + "Unverifiable routing: warning, and the proxy ensure is still attempted.", + "Unknown routing never adds a service token; token-file overrides and management credentials are not used for client injection.", + "A fresh token is loaded only for allowlisted argv, no HTTP(S)/ALL proxy or CODEX_*/OPENAI_*/AZURE_OPENAI_* env overrides", + "(canonical CODEX_HOME is allowed), and verified home/config/system ownership and managed transport.", + "Other inputs (including -c/--config, -p/--profile and resume/fork) withhold it with a value-free diagnostic.", + "If the ensure fails, one stderr line is printed and launch continues subject to executable verification.", + "Only a matched autostart wrapper launches its backing Codex directly; unknown state requires a verified bypass.", + "Unverifiable or obsolete wrappers refuse with exit 1 and start no Codex; run `ocx codex-shim install`.", + "Stop using `ocx codex` and plain `codex` behaves exactly as before.", + ], + }, { name: "opencode", usage: "ocx opencode [opencode args...]", diff --git a/src/cli/root.ts b/src/cli/root.ts index 0e664880aed..f34a1cc22ab 100644 --- a/src/cli/root.ts +++ b/src/cli/root.ts @@ -38,6 +38,8 @@ export interface CliHead { export function parseCliHead(argv: string[]): CliHead { const command = argv[0]; const args = argv; + // Codex owns its forwarded grammar; wrapper documentation is `ocx help codex`. + if (command === "codex") return { kind: "command", command, args }; if (command === "--version" || command === "-v" || command === "version") { return { kind: "version", command, args }; } diff --git a/src/codex/shim-templates.ts b/src/codex/shim-templates.ts index 6815201ffc4..f2a01e6ad29 100644 --- a/src/codex/shim-templates.ts +++ b/src/codex/shim-templates.ts @@ -35,7 +35,10 @@ const CODEX_SHIM_REENTRY_DIAGNOSTIC = "opencodex: saved Codex launcher resolved */ export const CODEX_SHIM_ENSURE_FAILED_DIAGNOSTIC = "opencodex: proxy autostart failed; launching Codex anyway. Run 'ocx doctor' for details, or 'ocx restore' to hand Codex back to its own account."; -const CODEX_INTERNAL_COMMANDS = [ +// Shared with launchers that speak the same argv vocabulary (`ocx codex`): the +// generated shell templates inline this grammar, so the sets are the single +// source and the shell text is the consumer, not the definition. +export const CODEX_INTERNAL_COMMANDS = [ "app-server", "archive", "apply", @@ -60,7 +63,7 @@ const CODEX_INTERNAL_COMMANDS = [ // Codex accepts global options before a subcommand. The shim must skip the value belonging to // these options before it decides which first positional token is the real subcommand. Keep this // list aligned with `codex --help`; `--option=value` and attached short forms stay one token. -const CODEX_GLOBAL_OPTIONS_WITH_VALUE = [ +export const CODEX_GLOBAL_OPTIONS_WITH_VALUE = [ "-c", "--config", "--enable", "--disable", "--remote", "--remote-auth-token-env", diff --git a/structure/cli-management.md b/structure/cli-management.md index 820db108abb..77571e20190 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -21,6 +21,22 @@ Pi link/shared-home remedy; this adds a client identity, not a new command or fl ## Head and help navigation +`src/cli/codex-launch-policy.ts` authorizes fresh credentials by explicit argument, environment and configuration allowlists. Only interactive and `exec` modes qualify; restored sessions and every config/profile/provider-selection override withhold fresh credentials. The switches are `--no-daemon`, `--json`, `--ephemeral`, `--skip-git-repo-check`, `--no-alt-screen`, `--full-auto`, `--dangerously-bypass-approvals-and-sandbox`, `--search`; one-value options are `--model`/`-m`, `--sandbox`/`-s`, `--ask-for-approval`/`-a`, `--image`/`-i`. Short clusters and missing values refuse. Only model/provider selection, model reasoning/verbosity strings, approval/sandbox strings, the matching root URL and disabled system-proxy feature are accepted in the home configuration. Alternate providers, MCP servers (including stdio inheritance), hooks and unknown keys refuse. Every token-variable reference outside the verified provider is refused, even in an unselected profile. Nonempty unknown CODEX/OpenAI/Azure environment controls refuse. Additional authentication, system, managed, requirements and project layers are not authorized. Refusals preserve argv and explicit credentials and print a value-free stderr note. + +`src/cli/codex-launch-ownership.ts` checks current-user ownership of the canonical home and config, rejects POSIX group/other writes and Windows DACL mutation grants outside current user/SYSTEM/Administrators, and rejects unsafe ancestor replacement rights. Windows system paths come from the CommonApplicationData known folder; ancestor ownership also permits TrustedInstaller. macOS checks forced CoreFoundation preferences. Read/inspection errors deny. The key is supplied only when configuration and its namespaces are protected from other principals; the current user, SYSTEM/Administrators and the documented OS authorities are trusted. Same-user processes are trusted: an actor with this user's write access can already read this user's OpenCodex key file. Same-user configuration replacement between verification and native startup is outside this threat model; the check does not establish immutable startup configuration or isolate privileged processes. Regression coverage is in `tests/codex-integration/codex-launcher-admission.test.ts`. + +Same-file project aliases additionally require ownership/ACL inspection of the lexical entry, every ancestor and every intermediate symlink target. The Windows checker reuses its ACL policy in a path-chain mode, including the reparse entry itself; POSIX permits only current-user/root ownership and no group/other writes, with sticky directories protecting current-user-owned entries. macOS opens lexical symlinks with O_SYMLINK alone and non-links with O_NOFOLLOW, retaining NONBLOCK/CLOEXEC and directory-only flags where applicable. Descriptor dev/ino/type must match the earlier lstat before fd-based ACL inspection; replacements and inspection errors refuse, and every opened descriptor closes. Unknown or failed inspection withholds the token with reason `project` before reading it. `tests/codex-integration/codex-launcher-env.test.ts` covers protected home aliases, writable alias entries/parents/ancestors/redirects and inspection failure; `tests/codex-integration/codex-launcher-darwin-identity.test.ts` models XNU link-open semantics and replacements, and `tests/codex-integration/codex-launcher-darwin-acl.test.ts` includes native Darwin protected-symlink admission. + +`src/cli/codex-launch-diagnostics.ts` maps each fresh-key withholding reason to one fixed stderr line without shell commands, credential values, key-file paths, config contents or exception text. Default Windows ProgramData rights remain unsafe even when OpenAI/Codex is absent. The diagnostic states that the system Codex configuration folder under ProgramData must exist and be writable only by Administrators/SYSTEM, with ancestors protected against replacement by other users. It requests administrator policy review and states that opencodex never changes ACLs itself; no provisioning command is supplied. Users can instead supply their own key by exporting OPENCODEX_API_AUTH_TOKEN, which the launcher leaves untouched. Existing system configuration still refuses automatic credentials. Tests in `tests/codex-integration/codex-launcher-diagnostics.test.ts` cover command-free, one-line non-disclosure, real missing/writable Windows namespace refusals and explicit credential preservation. Public requirements and the explicit-key alternative live in the [CLI reference](../docs-site/src/content/docs/reference/cli/agents.md#ocx-codex-codex-args). + + +For fresh credentials, the launcher resolves a native literal `CODEX_HOME` once and passes the canonical absolute directory both to the destination verifier and to the child. Missing, unreadable or non-directory homes and ambiguous Windows home casing withhold credentials. Any nonempty inherited HTTP(S)/ALL proxy variable, matched case-insensitively, suppresses token reads irrespective of NO_PROXY; denied and inherited-token launches preserve their environment. Successful injection adds both allowed loopback IPs to identical child NO_PROXY/no_proxy values while preserving prior bypass entries, covering default OS proxy discovery. Configured `features.respect_system_proxy` and feature-changing argv suppress loading because system-selected routes can ignore bypasses. + +`src/cli/codex-launcher.ts` implements `ocx codex` without rewriting routing. Native/foreign routes pass through; local/unknown routes attempt startup and warn on failure. Fast paths skip startup/token loading. A matched wrapper uses its backing directly. Unknown state, including a throwing inspector, requires a separate bypass check via `src/cli/codex-launch-command.ts`; managed Windows CMD wrappers require complete generated-template equality before the selected absolute path executes with bypass. Obsolete, unreadable or ambiguous wrappers and lookup errors refuse launch without exception contents. A missing executable instead reports the Codex install hint, including on help/version fast paths. This content check does not establish Windows backing provenance or prevent concurrent replacement. `src/cli/codex-launch-auth.ts` separately requires a parsed managed transport block matching this installation's loopback listener or persisted local client connection; names/comments alone grant no token. Profiles, distinct project layers scanned through the filesystem root, destination-changing argv and environment overrides prevent automatic loading. An existing ancestor config is excluded only when its realpath matches the home config; a different file or failed identity resolution refuses. Token-file overrides and management-token collisions remain refused; user exports are preserved. No token enters argv or diagnostics. + +The `codex` namespace forwards all help arguments unchanged; `ocx help codex` retains wrapper help. It skips root shim repair so the routing-aware launcher owns the handoff. +Forwarded `-c`/`--config` and `-p`/`--profile` overrides make effective routing unverified for automatic service-token injection. They retain original argv and user-exported credentials but never load a new token from disk. Single-word prompts require `--` so they cannot select an unknown native subcommand. Missing system files also require a protected namespace: the closest existing directory must prevent another user from creating the missing layer, and its ancestors must prevent replacement. A normal Windows ProgramData directory that grants Users directory creation cannot authorize an absent OpenAI/Codex namespace; the launcher withholds credentials without creating or changing that directory. macOS opens each validated entry without following its final link (O_SYMLINK for the link entry itself), keeps dev/ino as bigint and requires descriptor dev/ino/type to match its original lstat, and reads its ACL with acl_get_fd_np. Only NULL with immediately captured ENOENT on this descriptor query means no extended ACL; open/stat/identity/close failures and every other NULL/error refuse. Every opened descriptor closes in finally. For a present ACL it validates and enumerates every entry through the documented -1/EINVAL terminator. Deny-only and read-only allow entries are accepted; any allow granting write/add-file, append/add-subdirectory, delete, delete-child, attribute, extended-attribute, security or ownership mutation refuses regardless of earlier denies. Unknown tags and FFI/enumeration failures refuse. Forced managed-preference checks remain mandatory. Pure policy and Darwin-gated native ACL coverage lives in `tests/codex-integration/codex-launcher-darwin-acl.test.ts`; modeled IO regressions for all three walks live in `tests/codex-integration/codex-launcher-darwin-identity.test.ts`. Tests model a protected empty machine namespace inside their private fixture; they do not modify real system configuration. Windows fixtures probe ancestor ownership before positive assertions; an unavailable protected location requires a proved ownership refusal and an explicit skip reason. Negative mutations and no-token/zero-read assertions execute even with unsafe ancestors; credential-file negatives bypass destination verification independently. Native checks use production defaults. + `src/cli/root.ts`, `src/cli/help.ts`, `src/cli/help-navigation.ts` The pure Bun-head parser retains command argv and derives transient help paths before shim preflight or command dispatch. Bare `help` is recognized only at root or immediately after the root command; nested paths use `--help`/`-h`, and exact `--` ends head help scanning. Later operand values such as `alias set demo help` stay commands. `runCli` exits help and rejects unknown roots before shim/stateful preflight, while preserving `parseCliHead` and its kinds. Registry names/aliases/hidden entries and the unregistered `internal` runner remain valid dispatch targets; hidden/internal names stay outside discovery and suggestions. `printUsage` renders the same compact index for bare `ocx`, `help`, `--help` and `-h`; static navigation groups reference registry names/summaries and add no configuration reads, width probes or color dependence. `printFullUsage` preserves the full reference behind `ocx help --all` and `ocx --help --all`. This contract concerns the Bun head; the Node launcher's updater interception has its own recognition rules. diff --git a/structure/codex-home.md b/structure/codex-home.md index af775f8652f..30459ff663e 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -7,7 +7,7 @@ Catalog HTTP acquisition follows the [proxy-routing contract](catalog.md#remote- A lock in the Codex credential store is governed by [descriptor identity and age](catalog.md#accounts-namespaces-and-pool-rotation), so the mere presence of its filename is neither acquisition nor release authority. Failed path-identity probes leave the lock for stale recovery and preserve the refresh callback outcome. Cooperating lock metadata changes serialize through the existing SQLite mutation transaction; release keeps the descriptor open through identity comparison and any unlink, then closes it. Failed metadata writes remove only a matching owned path after successful coordination; unknown identity, failed probes or unavailable coordination retain the path for stale recovery. Async refresh work holds no metadata transaction. CLI installation inspection reason codes, including Windows deferral, follow the [runtime inspection contract](runtime.md#lifecycle). - +Launcher credentials require a parsed managed provider transport matching the installation's loopback target or persisted local client connection; provider names and injection comments are not destination authority. Default profiles, project layers and destination-changing arguments suppress automatic token loading. The read-only CLI update plan follows the [isolated environment and process-observation contract](codex-cli-update.md). ## Orca source-owned account import @@ -57,7 +57,7 @@ $CODEX_HOME/.opencodex-native-main-profiles/ ``` Never assume macOS-only paths. Windows, service installs, and app-launched Codex can all depend on -the resolved `CODEX_HOME`. +the resolved `CODEX_HOME`. For credential delivery, `src/cli/codex-launch-auth.ts` excludes an ancestor `.codex/config.toml` only when its realpath matches the home config and its lexical alias, ancestors and intermediate link targets pass ownership/ACL inspection. A protected symlinked user `.codex` qualifies; distinct files, replaceable aliases and failed inspections withhold the token as a project layer. Namespace ownership follows the [launcher contract](cli-management.md#head-and-help-navigation). Log Guard reclaim in `src/codex/log-guard/maintenance.ts` refuses a non-regular or redirected database path. It compares canonical path and full-width filesystem device and file identity @@ -196,7 +196,7 @@ prior sibling runtime and `OPENCODEX_HOME`. Connected-client recycle issues it b the listener or removing that runtime record. `handleStart` consumes the record before any probe; a forged port env or replay grants no sibling status. A valid replacement stays a sibling while the owner is down, and its journal recovery remains skipped. -Every other detached `ocx start` (`ocx ensure`, the tray, the `ocx claude`/`opencode`/`minimax` +Every other detached `ocx start` (`ocx ensure`, the tray, the `ocx claude`/`codex`/`opencode`/`minimax` auto-start and the updater's restart) starts an ordinary owner and strips an inherited marker through `withoutSiblingMarker`. diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index b52e7641e29..dbc1810e435 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -237,7 +237,7 @@ deliberate stop. Existing launchd jobs retain their old keepalive definition until `ocx service repair` rewrites and reloads the changed plist. New WinSW XML stamps `OCX_SERVICE_MANAGED=1`; parent command-line inference applies only to legacy registrations whose XML lacks that marker. Bare `OCX_SERVICE=1` -is never the marker because `ocx claude` and `ocx opencode` companions carry it too; a +is never the marker because `ocx claude`, `ocx codex`, and `ocx opencode` companions carry it too; Codex's client-token loading separately requires a managed loopback destination. A marker-less Windows registration is still recognised by the parent's command line naming this install's wrapper script, launcher or WinSW host as a complete token in any position, and an unreadable parent command line is no evidence and proceeds. POSIX keeps the diff --git a/structure/runtime.md b/structure/runtime.md index de02393c164..78f84c28bf5 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -291,7 +291,7 @@ atomic OS guarantee against unobserved PID reuse. > Decision record: [ADR-0003](decisions/ADR-0003-lifecycle.md) -An installed Codex shim is checked on eligible CLI startup with a direct-regular-file/1 MiB state bound and bounded metadata/prefix reads. Changing launchers defer; failures warn without changing the requested command’s exit behavior. +An installed Codex shim is checked on eligible CLI startup with a direct-regular-file/1 MiB state bound and bounded metadata/prefix reads. `ocx codex` skips repair and owns handoff: unknown Windows CMD wrappers require complete bypass-aware template equality, otherwise launch is refused; content equality grants no Windows backing provenance. Other changing launchers defer and repair failures warn without changing their command's exit behavior. All mutations hold the shared atomic-mkdir interprocess lock and reread state before final revalidation, publication and state commit. Unique token-named owner records prevent release from naming a successor. Aged locks are reclaimed only when the owner PID is dead and the same token, directory identity and owner fingerprint remain immediately before deletion; age alone never evicts a live holder. Windows repair retains complete sibling-set preflight, identical fingerprints/prefixes across a 100 ms observation interval, and reverse-order rollback if a later sibling changes. It restores stable external replacements without PATH rediscovery. Windows service/shim writes first admit the frozen selected runtime under the [write-preflight contract](ops/service-and-sidecars.md#windows-selected-runtime-write-preflight); orchestrated installs refuse Desktop supervision before selection and admit once before cleanup. Repair refuses Desktop before diagnosis, then checks ownership and auth before admission and any native repair or scheduler stop. The Node launcher’s validated PATH Bun fallback precedes CLI startup; durable admission never discovers a replacement. The fresh scheduler path claims config ownership after registration and staging removal, then admits before `prepare()`; refusal rolls back the new registration and preserves the claimed config root. Standalone admission uses the current executable in-process, without child isolation or a timeout. diff --git a/tests/cli/cli-head.test.ts b/tests/cli/cli-head.test.ts index 64e8d027a87..52875ffedd6 100644 --- a/tests/cli/cli-head.test.ts +++ b/tests/cli/cli-head.test.ts @@ -4,6 +4,14 @@ import * as autorestore from "../../src/cli/codex-shim-autorestore"; import { DEFAULT_READY_WAIT_TIMEOUT_SECONDS } from "../../src/cli/ready"; describe("parseCliHead (pure CLI head, Phase 1)", () => { + test("codex owns all forwarded help arguments; ocx help codex stays local", () => { + for (const args of [["codex", "--help"], ["codex", "help"], ["codex", "exec", "-h"], ["codex", "-m", "--help"]]) { + expect(parseCliHead(args)).toEqual({ kind: "command", command: "codex", args }); + expect(parseCliHead(args).args).toBe(args); + } + expect(parseCliHead(["help", "codex"])).toMatchObject({ kind: "help", helpTarget: "codex" }); + }); + test("preserves explicit nested help paths and original argv", () => { for (const args of [["help", "models", "context"], ["models", "context", "--help"], ["models", "context", "-h"]]) { const head = parseCliHead(args); diff --git a/tests/codex-integration/codex-launcher-admission.test.ts b/tests/codex-integration/codex-launcher-admission.test.ts new file mode 100644 index 00000000000..364ad3ef864 --- /dev/null +++ b/tests/codex-integration/codex-launcher-admission.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, test } from "bun:test"; +import { chmodSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { buildProviderTableBlockForTarget } from "../../src/codex/inject/config-toml"; +import { standaloneCodexRoutingTarget } from "../../src/codex/inject/routing-target"; +import { resolveTrustedWindowsPowerShellExe } from "../../src/lib/windows-elevation"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { createCodexLaunchFixtureRoot, codexLaunchOwnershipTest, buildCodexPrivateFixtureLaunchEnv as buildCodexLaunchEnv } from "../helpers/codex-launch-private-fixture"; +import { codexLaunchSystemNamespaceIsSafe, codexPosixSystemNamespace, darwinAclInspectionIsEmpty } from "../../src/cli/codex-launch-ownership"; + +const token = "ocx_fixture_admission"; +const cfg = { port: 12199, hostname: "0.0.0.0" }; +const local = 'model_provider="opencodex"\n' + buildProviderTableBlockForTarget(standaloneCodexRoutingTarget(cfg.port, cfg)); + +function fixture(run: (root: string, home: string) => void): void { + const root = createCodexLaunchFixtureRoot("ocx-admission-"), home = join(root, "codex"), ocx = join(root, "ocx"); + mkdirSync(home); mkdirSync(ocx); + const prior = process.env.OPENCODEX_HOME, cwd = process.cwd(); + process.env.OPENCODEX_HOME = ocx; + writeFileSync(join(ocx, "config.json"), JSON.stringify(cfg)); + writeFileSync(join(home, "config.toml"), local); + process.chdir(root); + try { run(root, home); } + finally { + process.chdir(cwd); + if (prior === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = prior; + removeTreeWithRetry(root); + } +} + +function launch(home: string, args: string[] = [], extra: Record = {}) { + let reads = 0; + const notes: string[] = []; + // Capture stderr without introducing a policy test seam. + const original = console.error; + console.error = (...values: unknown[]) => { notes.push(values.join(" ")); }; + try { + const env = buildCodexLaunchEnv({ CODEX_HOME: home, ...extra }, { + routingKind: "opencodex-local", args, serviceToken: () => { reads++; return token; }, + }); + return { env, reads, notes }; + } finally { console.error = original; } +} + +function denied(home: string, args: string[] = [], extra: Record = {}) { + const result = launch(home, args, extra); + expect(result.env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + expect(result.reads).toBe(0); + expect(result.notes.join(" ")).toContain("withheld"); + expect(result.notes.join(" ")).not.toContain(token); +} + +describe("Codex fresh token admission defaults to deny", () => { + for (const args of [ + ["exec", "--oss", "--local-provider", "capture", "prompt"], + ["--oss"], ["--local-provider=capture"], + ["-c", 'model_provider="capture"'], ["--config=model_provider=opencodex"], + ["-c", "unknown_future_key=true"], ["--profile", "capture"], ["-pcapture"], + ["--unknown-future-provider", "capture"], ["exec", "--unknown-switch"], + ["--model"], ["--model", "--oss"], ["future-subcommand", "prompt"], + ["exec", "--", "--oss"], + ["resume"], ["fork"], ["exec", "resume"], ["exec", "fork"], + ["future-subcommand"], ["exec", "future-subcommand"], + ]) test(`withholds for ambiguous argv ${args.join(" ")}`, () => fixture((_root, home) => denied(home, args))); + + for (const key of ["OPENAI_BASE_URL", "openai_base_url", "CODEX_OSS_BASE_URL", "CODEX_OSS_PORT", "CODEX_MODEL_PROVIDER", "CODEX_CONFIG", "CODEX_CONFIG_FILE", "CODEX_MANAGED_CONFIG_PATH", "CODEX_REVIEW_BASE_URL", "OPENAI_API_BASE", "AZURE_OPENAI_ENDPOINT"]) { + test(`withholds for ${key}`, () => fixture((_root, home) => denied(home, [], { [key]: "fixture-override" }))); + } + + for (const [name, addition] of [ + ["MCP bearer", '\n[mcp_servers.capture]\nurl="https://capture.invalid/mcp"\nbearer_token_env_var="OPENCODEX_API_AUTH_TOKEN"\n'], + ["MCP header", '\n[mcp_servers.capture]\nurl="https://capture.invalid/mcp"\nenv_http_headers={Authorization="OPENCODEX_API_AUTH_TOKEN"}\n'], + ["MCP env map", '\n[mcp_servers.capture]\ncommand="fixture"\nenv={COPY="OPENCODEX_API_AUTH_TOKEN"}\n'], + ["MCP process inheritance", '\n[mcp_servers.capture]\ncommand="fixture"\n'], + ["other provider env_key", '\n[model_providers.capture]\nbase_url="https://capture.invalid/v1"\nenv_key="OPENCODEX_API_AUTH_TOKEN"\n'], + ["profile consumer", '\n[profiles.unselected.mcp_servers.capture]\nurl="https://capture.invalid/mcp"\nbearer_token_env_var="OPENCODEX_API_AUTH_TOKEN"\n'], + ["unknown consumer", '\n[future.consumer]\ncredential="OPENCODEX_API_AUTH_TOKEN"\n'], + ]) test(`withholds for ${name}`, () => fixture((_root, home) => { + writeFileSync(join(home, "config.toml"), local + addition); + denied(home); + })); + + test("withholds for project-level MCP consumer", () => fixture((root, home) => { + mkdirSync(join(root, ".codex")); + writeFileSync(join(root, ".codex", "config.toml"), '[mcp_servers.capture]\nurl="https://capture.invalid/mcp"\nbearer_token_env_var="OPENCODEX_API_AUTH_TOKEN"\n'); + denied(home); + })); + + for (const file of ["auth.json", "managed_config.toml", "requirements.toml"]) test(`withholds for additional layer ${file}`, () => fixture((_root, home) => { + writeFileSync(join(home, file), ""); + denied(home); + })); + for (const content of ['notify=["fixture"]\n', '[hooks]\ncommand="fixture"\n', '[features]\nfuture_consumer=true\n']) { + test(`withholds for unknown config ${content.trim()}`, () => fixture((_root, home) => { + writeFileSync(join(home, "config.toml"), content.startsWith("[") ? local + "\n" + content : content + local); + denied(home); + })); + } + + for (const entry of ["directory", "config"] as const) test(`withholds for foreign-writable ${entry}`, () => fixture((_root, home) => { + const path = entry === "directory" ? home : join(home, "config.toml"); + if (process.platform === "win32") { + const script = String.raw`$ErrorActionPreference='Stop' +$path=$env:OCX_TEST_ACL_PATH +$sections=[System.Security.AccessControl.AccessControlSections]'Owner,Access' +if ([System.IO.Directory]::Exists($path)) { $acl=[System.Security.AccessControl.DirectorySecurity]::new($path,$sections) } +else { $acl=[System.Security.AccessControl.FileSecurity]::new($path,$sections) } +$sid=[System.Security.Principal.SecurityIdentifier]::new('S-1-1-0') +$rule=[System.Security.AccessControl.FileSystemAccessRule]::new($sid,[System.Security.AccessControl.FileSystemRights]::Write,[System.Security.AccessControl.AccessControlType]::Allow) +$acl.AddAccessRule($rule) +if ([System.IO.Directory]::Exists($path)) { [System.IO.Directory]::SetAccessControl($path,$acl) } +else { [System.IO.File]::SetAccessControl($path,$acl) }`; + const result = Bun.spawnSync([resolveTrustedWindowsPowerShellExe(), "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], { + env: { ...process.env, OCX_TEST_ACL_PATH: path }, stdout: "pipe", stderr: "pipe", windowsHide: true, + }); + expect(result.exitCode).toBe(0); + } else chmodSync(path, entry === "directory" ? 0o777 : 0o666); + denied(home); + if (entry === "directory") expect(codexLaunchSystemNamespaceIsSafe(join(home, "missing-system"))).toBe(false); + })); + + codexLaunchOwnershipTest("a protected empty system namespace may authorize missing files", () => fixture((_root, home) => { + expect(codexLaunchSystemNamespaceIsSafe(join(home, "missing", "Codex"))).toBe(true); + })); + test.skipIf(process.platform !== "win32")("shell casing aliases cannot replace the system namespace protocol input", () => fixture((_root, home) => { + const actual = codexLaunchSystemNamespaceIsSafe(); + for (const key of ["OCX_CODEX_NAMESPACE_FIXTURE", "ocx_codex_namespace_fixture", "OcX_CoDeX_NaMeSpAcE_FiXtUrE"]) { + const prior = process.env[key]; + try { + process.env[key] = join(home, "missing-system"); + expect(codexLaunchSystemNamespaceIsSafe()).toBe(actual); + } finally { if (prior === undefined) delete process.env[key]; else process.env[key] = prior; } + } + })); + test("an additional system file refuses authorization", () => fixture((_root, home) => { + writeFileSync(join(home, "config.toml"), local); + expect(codexLaunchSystemNamespaceIsSafe(home)).toBe(false); + })); + test("Darwin ACL success, empty and inspection-error results are distinct", () => { + expect(darwinAclInspectionIsEmpty(0, 0, 22)).toBe(false); + expect(darwinAclInspectionIsEmpty(0, -1, 22)).toBe(true); + expect(darwinAclInspectionIsEmpty(0, -1, 13)).toBe(false); + expect(darwinAclInspectionIsEmpty(-1, -1, 22)).toBe(false); + }); + test("production Darwin namespace uses the physical etc path", () => { + expect(codexPosixSystemNamespace("darwin")).toBe("/private/etc/codex"); + expect(codexPosixSystemNamespace("linux")).toBe("/etc/codex"); + }); + for (const entry of ["home", "config", "ancestor"]) test.skipIf(process.platform !== "darwin")(`macOS extended ACL on ${entry} with safe mode bits refuses credentials`, () => fixture((root, home) => { + const path = entry === "config" ? join(home, "config.toml") : entry === "home" ? home : root; + chmodSync(path, entry === "config" ? 0o600 : 0o700); + const result = Bun.spawnSync(["/bin/chmod", "+a", "everyone allow write,delete,writeattr,writeextattr,writesecurity,chown", path]); + expect(result.success).toBe(true); + denied(home); + })); + + for (const args of [[], ["-m", "fixture", "--", "hello"], ["--model=fixture", "--", "hello"], ["--no-daemon", "exec", "--skip-git-repo-check", "--ephemeral", "--json", "fixture prompt"]]) { + codexLaunchOwnershipTest(`managed positive ${args.join(" ")}`, () => fixture((_root, home) => { + const result = launch(home, args); + expect(result.reads).toBe(1); + expect(result.env.OPENCODEX_API_AUTH_TOKEN).toBe(token); + expect(result.notes).toEqual([]); + })); + } +}); diff --git a/tests/codex-integration/codex-launcher-darwin-acl.test.ts b/tests/codex-integration/codex-launcher-darwin-acl.test.ts new file mode 100644 index 00000000000..bf540405685 --- /dev/null +++ b/tests/codex-integration/codex-launcher-darwin-acl.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from "bun:test"; +import { chmodSync, mkdirSync, mkdtempSync, realpathSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as ownership from "../../src/cli/codex-launch-ownership"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +describe("Codex launcher Darwin ACL mutation admission", () => { + test("only a null descriptor with ENOENT means no extended ACL", () => { + expect(ownership.darwinAclDescriptorIsAbsent(null, 2)).toBe(true); + expect(ownership.darwinAclDescriptorIsAbsent(0, 2)).toBe(true); + expect(ownership.darwinAclDescriptorIsAbsent(0n, 2)).toBe(true); + for (const errno of [0, 5, 12, 13, 22, 45]) { + expect(ownership.darwinAclDescriptorIsAbsent(null, errno)).toBe(false); + } + expect(ownership.darwinAclDescriptorIsAbsent(123, 2)).toBe(false); + }); + test("empty ACLs are safe", () => { + expect(ownership.darwinAclEntriesAreSafe([])).toBe(true); + }); + test("deny-only ACLs are safe", () => { + expect(ownership.darwinAclEntriesAreSafe([{ tag: 2, perms: 0xffffffff }])).toBe(true); + }); + test("read-only allow ACLs are safe", () => { + expect(ownership.darwinAclEntriesAreSafe([{ tag: 1, perms: (1 << 1) | (1 << 3) | (1 << 7) | (1 << 9) | (1 << 11) | (1 << 20) }])).toBe(true); + }); + + // Apple sys/acl.h: aliases ADD_FILE/ADD_SUBDIRECTORY share WRITE_DATA/APPEND_DATA bits. + for (const [name, bit] of [ + ["WRITE_DATA / ADD_FILE", 2], ["DELETE", 4], ["APPEND_DATA / ADD_SUBDIRECTORY", 5], + ["DELETE_CHILD", 6], ["WRITE_ATTRIBUTES", 8], ["WRITE_EXTATTRIBUTES", 10], + ["WRITE_SECURITY", 12], ["CHANGE_OWNER", 13], + ] as const) { + test(`allow ${name} refuses`, () => { + expect(ownership.darwinAclEntriesAreSafe([{ tag: 1, perms: 1 << bit }])).toBe(false); + }); + } + + test("a deny entry cannot hide a subsequent mutating allow", () => { + expect(ownership.darwinAclEntriesAreSafe([{ tag: 2, perms: 1 << 2 }, { tag: 1, perms: 1 << 2 }])).toBe(false); + }); + + test("unknown tags and incomplete enumeration refuse", () => { + expect(ownership.darwinAclEntriesAreSafe([{ tag: 0, perms: 0 }])).toBe(false); + expect(ownership.darwinAclEntriesAreSafe([{ tag: 3, perms: 0 }])).toBe(false); + expect(ownership.darwinAclEntriesAreSafe(null)).toBe(false); + }); + + test.skipIf(process.platform !== "darwin")("native protected symlinked user .codex admits", () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "ocx-darwin-link-"))); + const home = join(root, "codex-home"), user = join(root, "user"), alias = join(user, ".codex"); + try { + for (const directory of [root, home, user]) { mkdirSync(directory, { recursive: true }); chmodSync(directory, 0o700); } + writeFileSync(join(home, "config.toml"), 'model = "fixture"\n', { mode: 0o600 }); + symlinkSync(home, alias, "dir"); + expect(ownership.codexLaunchHomeIsOwned(realpathSync(alias))).toBe(true); + expect(ownership.codexLaunchProjectAliasIsOwned(alias)).toBe(true); + expect(ownership.codexLaunchProjectAliasIsOwned(join(alias, "config.toml"))).toBe(true); + } finally { removeTreeWithRetry(root); } + }); + + test.skipIf(process.platform !== "darwin")("native deny-delete ACL accepts; a later allow-write ACL refuses", () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "ocx-darwin-acl-"))); + const home = join(root, "codex"); + try { + mkdirSync(home); chmodSync(root, 0o700); chmodSync(home, 0o700); + writeFileSync(join(home, "config.toml"), 'model = "fixture"\n', { mode: 0o600 }); + expect(ownership.codexLaunchHomeIsOwned(home)).toBe(true); + expect(ownership.codexLaunchSystemNamespaceIsSafe(join(home, "missing-system"))).toBe(true); + expect(ownership.codexLaunchProjectAliasIsOwned(join(home, "config.toml"))).toBe(true); + const chmodAcl = (entry: string) => { + const result = Bun.spawnSync(["/bin/chmod", "+a", entry, root], { stdout: "pipe", stderr: "pipe" }); + expect(result.success).toBe(true); + }; + chmodAcl("everyone deny delete"); + expect(ownership.codexLaunchHomeIsOwned(home)).toBe(true); + chmodAcl("everyone allow write"); + expect(ownership.codexLaunchHomeIsOwned(home)).toBe(false); + } finally { + const cleared = Bun.spawnSync(["/bin/chmod", "-N", root], { stdout: "pipe", stderr: "pipe" }); + expect(cleared.success).toBe(true); + removeTreeWithRetry(root); + } + }); +}); diff --git a/tests/codex-integration/codex-launcher-darwin-identity.test.ts b/tests/codex-integration/codex-launcher-darwin-identity.test.ts new file mode 100644 index 00000000000..4d15f30d900 --- /dev/null +++ b/tests/codex-integration/codex-launcher-darwin-identity.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from "bun:test"; +import { darwinOwnershipModel, type DarwinWalk, type DarwinFault } from "../helpers/codex-launch-darwin-model"; + +for (const walk of ["home", "alias", "namespace"] as DarwinWalk[]) { + for (const fault of ["vanished", "inode", "wide-inode", "device", "type", "stat", "close"] as DarwinFault[]) { + test(`Darwin ${walk}: ${fault} entry refuses and closes every opened descriptor`, () => { + const result = darwinOwnershipModel(walk, fault); + expect(result.reached).toBe(true); + expect(result.admitted).toBe(false); + expect(result.closed.sort()).toEqual(result.opened.sort()); + }); + } + test(`Darwin ${walk}: ${walk === "alias" ? "protected symlink alias" : "the same existing object"} without an ACL admits`, () => { + const result = darwinOwnershipModel(walk, "none"); + expect(result.reached).toBe(true); + expect(result.admitted).toBe(true); + expect(result.aclReads).toBeGreaterThan(0); + expect(result.forcedReads).toBe(2); + expect(result.closed.sort()).toEqual(result.opened.sort()); + // XNU rejects final symlinks with O_NOFOLLOW even alongside O_SYMLINK. + expect(result.flags[0]! & 0x200100).toBe(walk === "alias" ? 0x200000 : 0x100); + expect(result.flags[0]! & 0x1000004).toBe(0x1000004); // NONBLOCK | CLOEXEC + if (walk === "namespace") expect(result.flags[0]! & 0x100000).toBe(0x100000); + }); + for (const errno of [0, 5, 12, 13, 22, 45]) test(`Darwin ${walk}: NULL ACL errno ${errno} refuses`, () => { + const result = darwinOwnershipModel(walk, "errno", errno); + expect(result.admitted).toBe(false); + expect(result.aclReads).toBe(1); + expect(result.closed.sort()).toEqual(result.opened.sort()); + }); + test(`Darwin ${walk}: absent ACL does not bypass forced preferences`, () => { + const result = darwinOwnershipModel(walk, "none", 2, true); + expect(result.admitted).toBe(false); + expect(result.forcedReads).toBe(1); + }); +} + +for (const fault of ["link-file", "link-directory"] as const) { + test(`Darwin alias: ${fault} replacement between lstat and open refuses before ACL read`, () => { + const result = darwinOwnershipModel("alias", fault); + expect(result.reached).toBe(true); + expect(result.flags[0]! & 0x200100).toBe(0x200000); + expect(result.admitted).toBe(false); + expect(result.aclReads).toBe(0); + expect(result.opened).toHaveLength(1); + expect(result.closed).toEqual(result.opened); + }); +} diff --git a/tests/codex-integration/codex-launcher-diagnostics.test.ts b/tests/codex-integration/codex-launcher-diagnostics.test.ts new file mode 100644 index 00000000000..d0532e055ba --- /dev/null +++ b/tests/codex-integration/codex-launcher-diagnostics.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { buildCodexLaunchEnv, type CodexLaunchEnvDeps } from "../../src/cli/codex-launcher"; +import { codexCredentialDestinationIsTrusted } from "../../src/cli/codex-launch-auth"; +import { CODEX_TOKEN_REFUSALS, codexTokenWithheldDiagnostic } from "../../src/cli/codex-launch-diagnostics"; +import { codexLaunchSystemNamespaceIsSafe } from "../../src/cli/codex-launch-ownership"; +import { resolveTrustedWindowsPowerShellExe } from "../../src/lib/windows-elevation"; +import { buildProviderTableBlockForTarget } from "../../src/codex/inject/config-toml"; +import { standaloneCodexRoutingTarget } from "../../src/codex/inject/routing-target"; +import { createCodexLaunchFixtureRoot, codexLaunchFixtureHasProtectedAncestors } from "../helpers/codex-launch-private-fixture"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const secret = "ocx_fixture_diagnostic_secret", contents = "fixture-key-file-content-never-log"; + +function capture(base: Record, deps: CodexLaunchEnvDeps) { + const notes: string[] = [], original = console.error; + console.error = (...values: unknown[]) => { notes.push(values.join(" ")); }; + try { return { env: buildCodexLaunchEnv(base, deps), notes }; } + finally { console.error = original; } +} + +function diagnostic(notes: string[], reason: string) { + expect(notes).toHaveLength(1); + expect(notes[0]).toContain("automatic service token withheld; " + reason); + expect(notes[0]).not.toMatch(/[\r\n]/); + expect(notes[0]).not.toMatch(/powershell|icacls|SetAccessControl|CreateDirectory|Set-Acl|New-Item|\$[a-z]|&\s*\{|[{}]/i); + for (const forbidden of [secret, contents, "service-api-token", "admin-api-token"]) expect(notes[0]).not.toContain(forbidden); +} + +describe("Codex credential withholding diagnostics", () => { + test("every refusal emits only a value-free explanation without a shell command", () => { + for (const reason of Object.keys(CODEX_TOKEN_REFUSALS) as (keyof typeof CODEX_TOKEN_REFUSALS)[]) { + diagnostic([codexTokenWithheldDiagnostic(reason)], CODEX_TOKEN_REFUSALS[reason]); + } + }); + + for (const [base, deps, reason] of [ + [{}, { args: ["--oss"] }, "arguments are outside the audited allowlist"], + [{ HTTPS_PROXY: contents }, {}, "environment contains a proxy or unaudited credential control"], + [{ CODEX_HOME: "" }, {}, "Codex home cannot be resolved unambiguously"], + ] as const) test(reason, () => { + let reads = 0; + const result = capture(base, { routingKind: "opencodex-local", ...deps, serviceToken: () => { reads++; return secret; } }); + diagnostic(result.notes, reason); + expect(reads).toBe(0); + expect(result.env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + }); + + test("missing and colliding service credentials have distinct safe reasons", () => { + for (const [token, extra, reason] of [ + [null, {}, "installation service credential is unavailable or invalid"], + [secret, { OPENCODEX_ADMIN_AUTH_TOKEN: secret }, "service credential matches a management credential"], + ] as const) { + const result = capture({ CODEX_HOME: process.cwd(), ...extra }, { credentialDestination: () => true, serviceToken: () => token }); + diagnostic(result.notes, reason); + expect(result.env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + } + }); + + test("explicit credentials and passthrough launches do not warn or read", () => { + const serviceToken = () => { throw new Error("must not read"); }; + for (const result of [ + capture({ OPENCODEX_API_AUTH_TOKEN: secret }, { args: ["--oss"], serviceToken }), + capture({}, { routingKind: "native", serviceToken }), + ]) expect(result.notes).toEqual([]); + }); + + test.skipIf(process.platform !== "win32")("missing or writable Windows namespace always refuses without reading credentials", () => { + const root = createCodexLaunchFixtureRoot("ocx-namespace-diagnostic-"); + const home = join(root, "codex"), ocx = join(root, "ocx"), machine = join(root, "machine"); + for (const path of [home, ocx, machine]) mkdirSync(path); + const system = join(machine, "OpenAI", "Codex"), prior = process.env.OPENCODEX_HOME, cwd = process.cwd(); + process.env.OPENCODEX_HOME = ocx; + process.chdir(root); + const cfg = { port: 12199, hostname: "0.0.0.0" }; + writeFileSync(join(ocx, "config.json"), JSON.stringify(cfg)); + writeFileSync(join(home, "config.toml"), 'model_provider="opencodex"\n' + buildProviderTableBlockForTarget(standaloneCodexRoutingTarget(cfg.port, cfg))); + const ps = (script: string) => { + const result = Bun.spawnSync([resolveTrustedWindowsPowerShellExe(), "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from("$ErrorActionPreference='Stop'\n" + script, "utf16le").toString("base64")], { + env: { ...process.env, OCX_TEST_MACHINE_ROOT: machine, OCX_TEST_NAMESPACE: system }, stdout: "pipe", stderr: "pipe", windowsHide: true, + }); + expect(result.exitCode).toBe(0); + }; + const grantCreation = String.raw`$p=$env:OCX_TEST_MACHINE_ROOT +$acl=[System.IO.Directory]::GetAccessControl($p) +$rule=[System.Security.AccessControl.FileSystemAccessRule]::new([System.Security.Principal.SecurityIdentifier]::new('S-1-1-0'),[System.Security.AccessControl.FileSystemRights]::CreateDirectories,[System.Security.AccessControl.AccessControlType]::Allow) +$acl.AddAccessRule($rule) +[System.IO.Directory]::SetAccessControl($p,$acl)`; + let reads = 0; + const launch = () => capture({ CODEX_HOME: home }, { + credentialDestination: (path, report) => codexCredentialDestinationIsTrusted(path, system, report), + serviceToken: () => { reads++; return secret; }, + }); + try { + ps(grantCreation); + for (const existing of [false, true]) { + if (existing) ps(String.raw`[System.IO.Directory]::CreateDirectory($env:OCX_TEST_NAMESPACE) | Out-Null +$p=$env:OCX_TEST_NAMESPACE +$acl=[System.IO.Directory]::GetAccessControl($p) +$acl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new([System.Security.Principal.SecurityIdentifier]::new('S-1-1-0'),[System.Security.AccessControl.FileSystemRights]::CreateFiles,[System.Security.AccessControl.AccessControlType]::Allow)) +[System.IO.Directory]::SetAccessControl($p,$acl)`); + let namespaceReason: keyof typeof CODEX_TOKEN_REFUSALS | undefined; + expect(codexLaunchSystemNamespaceIsSafe(system, reason => { namespaceReason = reason; })).toBe(false); + expect(namespaceReason).toBe("systemNamespaceWritable"); + // Direct namespace admission exercises this reason even when home + // admission would stop earlier on an unsafe fixture ancestor. + const note = codexTokenWithheldDiagnostic(namespaceReason!); + diagnostic([note], CODEX_TOKEN_REFUSALS.systemNamespaceWritable); + for (const phrase of ["Default ProgramData creation rights", "must exist", "writable only by Administrators/SYSTEM", + "opencodex never changes ACLs itself", "OPENCODEX_API_AUTH_TOKEN", "leaves untouched"]) expect(note).toContain(phrase); + const result = launch(); + diagnostic(result.notes, CODEX_TOKEN_REFUSALS[codexLaunchFixtureHasProtectedAncestors() ? "systemNamespaceWritable" : "ownership"]); + expect(result.notes[0]).not.toContain(root); + expect(result.env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + expect(reads).toBe(0); + const explicit = capture({ CODEX_HOME: home, OPENCODEX_API_AUTH_TOKEN: secret }, { + serviceToken: () => { throw new Error("must not read"); }, + }); + expect(explicit.notes).toEqual([]); + expect(explicit.env.OPENCODEX_API_AUTH_TOKEN).toBe(secret); + } + expect(reads).toBe(0); + } finally { + process.chdir(cwd); + if (prior === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = prior; + removeTreeWithRetry(root); + } + }); + + test("credential-file missing, malformed and colliding negatives bypass destination ownership", () => { + const root = createCodexLaunchFixtureRoot("ocx-diagnostic-"); + const home = join(root, "codex"), ocx = join(root, "ocx"), system = join(root, "system"); + for (const path of [home, ocx, system]) mkdirSync(path); + const cfg = { port: 12199, hostname: "0.0.0.0" }, prior = process.env.OPENCODEX_HOME, cwd = process.cwd(); + process.env.OPENCODEX_HOME = ocx; + process.chdir(root); + writeFileSync(join(ocx, "config.json"), JSON.stringify(cfg)); + try { + const keyFile = join(ocx, "service-api-token"); + const keyRead = (extra = {}) => capture({ CODEX_HOME: home, ...extra }, { credentialDestination: () => true }); + const missing = keyRead(); + diagnostic(missing.notes, "installation service credential is unavailable or invalid"); + expect(missing.env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + writeFileSync(keyFile, contents + "\n" + secret); + const malformed = keyRead(); + diagnostic(malformed.notes, "installation service credential is unavailable or invalid"); + expect(malformed.notes[0]).not.toContain(keyFile); + expect(malformed.env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + writeFileSync(keyFile, secret + "\n"); + const collision = keyRead({ OPENCODEX_ADMIN_AUTH_TOKEN: secret }); + diagnostic(collision.notes, "service credential matches a management credential"); + expect(collision.env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + const loaded = keyRead(); + expect(loaded.notes).toEqual([]); + expect(loaded.env.OPENCODEX_API_AUTH_TOKEN).toBe(secret); + } finally { + process.chdir(cwd); + if (prior === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = prior; + removeTreeWithRetry(root); + } + }); + + for (const [name, addition, reason] of [ + ["MCP consumer", (local: string) => local + '\n[mcp_servers.fixture]\ncommand="' + contents + '"\n', "consumers"], + ["unknown config", (local: string) => 'unknown="' + contents + '"\n' + local, "configShape"], + ["foreign provider", (local: string) => local.replace("127.0.0.1", "capture.invalid"), "providerTransport"], + ["malformed config", () => 'broken = "' + contents, "inspection"], + ["system config", (local: string) => local, "systemConfig"], + ] as const) test(`${name} mutation always withholds credentials without reading them`, () => { + const root = createCodexLaunchFixtureRoot("ocx-diagnostic-negative-"); + const home = join(root, "codex"), ocx = join(root, "ocx"), system = join(root, "system"); + for (const path of [home, ocx, system]) mkdirSync(path); + const cfg = { port: 12199, hostname: "0.0.0.0" }, prior = process.env.OPENCODEX_HOME, cwd = process.cwd(); + process.env.OPENCODEX_HOME = ocx; process.chdir(root); + const local = 'model_provider="opencodex"\n' + buildProviderTableBlockForTarget(standaloneCodexRoutingTarget(cfg.port, cfg)); + writeFileSync(join(ocx, "config.json"), JSON.stringify(cfg)); + try { + writeFileSync(join(home, "config.toml"), addition(local)); + if (name === "system config") { + writeFileSync(join(system, "requirements.toml"), contents); + let namespaceReason: string | undefined; + expect(codexLaunchSystemNamespaceIsSafe(system, value => { namespaceReason = value; })).toBe(false); + expect(namespaceReason).toBe("systemConfig"); + } + let reads = 0; + const result = capture({ CODEX_HOME: home }, { + credentialDestination: (path, report) => codexCredentialDestinationIsTrusted(path, system, report), + serviceToken: () => { reads++; return secret; }, + }); + // Diagnostic reachability needs protected ancestors; the mutation and + // no-token/zero-read assertions above and below run on every host. + diagnostic(result.notes, CODEX_TOKEN_REFUSALS[codexLaunchFixtureHasProtectedAncestors() ? reason : "ownership"]); + expect(reads).toBe(0); + expect(result.env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + expect(result.notes[0]).not.toContain(root); + } finally { + process.chdir(cwd); + if (prior === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = prior; + removeTreeWithRetry(root); + } + }); +}); diff --git a/tests/codex-integration/codex-launcher-env.test.ts b/tests/codex-integration/codex-launcher-env.test.ts new file mode 100644 index 00000000000..505ce6ffc3e --- /dev/null +++ b/tests/codex-integration/codex-launcher-env.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import * as fs from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, realpathSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { buildProviderTableBlockForTarget } from "../../src/codex/inject/config-toml"; +import { standaloneCodexRoutingTarget } from "../../src/codex/inject/routing-target"; +import { codexCredentialDestinationIsTrusted } from "../../src/cli/codex-launch-auth"; +import { codexLaunchHomeIsOwned } from "../../src/cli/codex-launch-ownership"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { fixturePath } from "../helpers/repo-root"; +import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; +import { createCodexLaunchFixtureRoot, codexLaunchOwnershipTest, codexLaunchProjectRefusalTest, buildCodexPrivateFixtureLaunchEnv as buildCodexLaunchEnv } from "../helpers/codex-launch-private-fixture"; + +const token = "ocx_fixture_launch_env"; +const installation = { port: 12199, hostname: "0.0.0.0" }; +const local = 'model_provider = "opencodex"\n' + buildProviderTableBlockForTarget(standaloneCodexRoutingTarget(12199, installation)); +const foreign = local.replace("http://127.0.0.1:12199/v1", "https://foreign.invalid/v1"); +const proxyKeys = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy", "Http_Proxy", "All_Proxy"]; + +async function fixture(run: (root: string, home: string) => void | Promise): Promise { + // Avoid the real user's ancestor .codex layer, which deliberately denies injection. + const root = createCodexLaunchFixtureRoot("ocx-launch-env-"); + const saved = { ...process.env }, cwd = process.cwd(); + const user = join(root, "user"), home = join(root, "codex-home"), ocx = join(root, "ocx"); + for (const path of [user, home, ocx]) mkdirSync(path); + process.env.HOME = user; process.env.USERPROFILE = user; + process.env.CODEX_HOME = home; process.env.OPENCODEX_HOME = ocx; + for (const key of Object.keys(process.env)) { + if ((key !== "CODEX_HOME" && /^(?:CODEX_|OPENAI_|AZURE_OPENAI_)/i.test(key)) + || /^(?:(?:https?|all|no)_proxy|OPENCODEX_API_AUTH_TOKEN|OPENCODEX_ADMIN_AUTH_TOKEN|OCX_API_TOKEN_FILE)$/i.test(key)) delete process.env[key]; + } + writeFileSync(join(ocx, "config.json"), JSON.stringify(installation)); + writeFileSync(join(home, "config.toml"), local); + process.chdir(root); + try { await run(root, home); } + finally { + process.chdir(cwd); + for (const key of Object.keys(process.env)) if (!(key in saved)) delete process.env[key]; + Object.assign(process.env, saved); + removeTreeWithRetry(root); + } +} + +function launch(base = { ...process.env }) { + let reads = 0, reason: string | undefined; + const env = buildCodexLaunchEnv(base, { + routingKind: "opencodex-local", serviceToken: () => { reads++; return token; }, + credentialDestination: (home, report) => codexCredentialDestinationIsTrusted(home, join(home, "system-namespace-fixture"), r => { reason = r; report?.(r); }), + }); + return { env, reads, reason }; +} + +function canCreateDirectorySymlink(): boolean { + const root = mkdtempSync(join(tmpdir(), "ocx-symlink-probe-")); + try { symlinkSync(root, join(root, "link"), "dir"); return true; } + catch (error) { + if (["EPERM", "EACCES", "ENOSYS", "ENOTSUP"].includes((error as NodeJS.ErrnoException).code ?? "")) return false; + throw error; + } finally { removeTreeWithRetry(root); } +} + +describe("Codex launcher native home and proxy environment", () => { + (canCreateDirectorySymlink() ? codexLaunchOwnershipTest : test.skip)("a symlinked user .codex is the home config, not a project layer", () => fixture((root, home) => { + const user = join(root, "user"), cwd = join(user, "project"); + mkdirSync(cwd); + symlinkSync(join("..", "codex-home"), join(user, ".codex"), "dir"); + process.chdir(cwd); + const base = { ...process.env }; delete base.CODEX_HOME; + const result = launch(base); + expect(result.reads).toBe(1); + expect(result.env.OPENCODEX_API_AUTH_TOKEN).toBe(token); + expect(result.env.CODEX_HOME).toBe(realpathSync(home)); + })); + + for (const entry of ["parent", "ancestor", "redirect"] as const) { + (canCreateDirectorySymlink() ? codexLaunchProjectRefusalTest : test.skip)(`a writable project alias ${entry} refuses before token read`, (expectedReason) => fixture((root, home) => { + const shared = join(root, "shared"), project = join(shared, "project"); + mkdirSync(project, { recursive: true }); + if (entry === "redirect") { + const bridge = join(project, "bridge"); + symlinkSync(home, bridge, "dir"); + symlinkSync(bridge, join(root, ".codex"), "dir"); + } else { + symlinkSync(home, join(project, ".codex"), "dir"); + process.chdir(project); + } + const writable = entry === "ancestor" ? shared : project; + if (process.platform === "win32") { + const result = spawnSync("icacls.exe", [writable, "/grant", "*S-1-1-0:(F)"], { encoding: "utf8", windowsHide: true }); + expect(result.status).toBe(0); + } else chmodSync(writable, 0o707); + try { + const result = launch(); + expect(result.reads).toBe(0); + expect(result.reason).toBe(expectedReason); + expect(result.env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + } finally { if (process.platform !== "win32") chmodSync(writable, 0o700); } + })); + } + + (canCreateDirectorySymlink() ? codexLaunchProjectRefusalTest : test.skip)("project alias inspection failure refuses before token read", (expectedReason) => fixture((root, home) => { + symlinkSync(home, join(root, ".codex"), "dir"); + const inspect = spyOn(fs, "readlinkSync").mockImplementation(() => { throw new Error("fixture inspection unavailable"); }); + try { + const result = launch(); + expect(result.reads).toBe(0); + expect(result.reason).toBe(expectedReason); + expect(result.env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + } finally { inspect.mockRestore(); } + })); + + (process.platform === "win32" && canCreateDirectorySymlink() ? codexLaunchProjectRefusalTest : test.skip)("a writable Windows alias entry refuses while its target stays protected", (expectedReason) => fixture((root, home) => { + const link = join(root, ".codex"); + symlinkSync(home, link, "dir"); + const grant = spawnSync("icacls.exe", [link, "/L", "/grant", "*S-1-1-0:(F)"], { encoding: "utf8", windowsHide: true }); + expect(grant.status).toBe(0); + expect(codexLaunchHomeIsOwned(home)).toBe(expectedReason === "project"); + const result = launch(); + expect(result.reads).toBe(0); + expect(result.reason).toBe(expectedReason); + expect(result.env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + })); + + test("literal tilde home cannot borrow authority from the expanded user home", () => fixture((root) => { + const expanded = join(root, "user", "trusted"), literal = join(root, "~", "trusted"); + mkdirSync(expanded); mkdirSync(literal, { recursive: true }); + writeFileSync(join(expanded, "config.toml"), local); + writeFileSync(join(literal, "config.toml"), foreign); + process.env.CODEX_HOME = join("~", "trusted"); + const result = launch(); + expect(result.reads).toBe(0); + expect(result.env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + })); + + test("whitespace in a native literal home is not trimmed for authorization", () => fixture((root) => { + for (const name of ["trusted", " trusted"]) mkdirSync(join(root, name)); + writeFileSync(join(root, "trusted", "config.toml"), local); + writeFileSync(join(root, " trusted", "config.toml"), foreign); + process.env.CODEX_HOME = " trusted"; + expect(launch().reads).toBe(0); + })); + + test("the actual child env home, rather than the parent home, owns authorization", () => fixture((root) => { + const other = join(root, "other"); mkdirSync(other); + writeFileSync(join(other, "config.toml"), foreign); + expect(launch({ ...process.env, CODEX_HOME: other }).reads).toBe(0); + })); + + test.skipIf(process.platform !== "win32")("ambiguous Windows home casing withholds credentials", () => fixture((_root, home) => { + const base = { ...process.env, CODEX_HOME: home, codex_home: "unverified-home" }; + const result = launch(base); + expect(result.reads).toBe(0); + expect(result.env).toEqual(base); + })); + + codexLaunchOwnershipTest("relative and literal tilde local homes are bound to the verified absolute directory", () => fixture((root) => { + const literal = join(root, "~", "local"); mkdirSync(literal, { recursive: true }); + writeFileSync(join(literal, "config.toml"), local); + for (const raw of ["codex-home", join("~", "local")]) { + process.env.CODEX_HOME = raw; + const result = launch(); + expect(result.reads).toBe(1); + expect(result.env.OPENCODEX_API_AUTH_TOKEN).toBe(token); + // Native-semantics fixture: the child opens CODEX_HOME literally under cwd. + expect(result.env.CODEX_HOME).toBe(realpathSync(join(root, raw))); + const child = spawnSync(process.execPath, [fixturePath("codex-launch-native-env.ts")], { + cwd: root, env: result.env, encoding: "utf8", timeout: SPAWN_BUDGET_MS - 5_000, windowsHide: true, + }); + expect(child.error).toBeUndefined(); + expect(child.status).toBe(0); + expect(JSON.parse(child.stdout)).toEqual({ home: result.env.CODEX_HOME, baseUrl: "http://127.0.0.1:12199/v1", hasToken: true }); + } + })); + + codexLaunchOwnershipTest("an implicit home is resolved from the child environment and bound explicitly", () => fixture((root) => { + const implicit = join(root, "user", ".codex"); mkdirSync(implicit); + writeFileSync(join(implicit, "config.toml"), local); + const base = { ...process.env }; delete base.CODEX_HOME; + const result = launch(base); + expect(result.reads).toBe(1); + expect(result.env.CODEX_HOME).toBe(realpathSync(implicit)); + })); + + test("missing and non-directory homes withhold credentials", () => fixture((root) => { + const file = join(root, "file"); writeFileSync(file, "fixture"); + for (const raw of [join(root, "absent"), file, " "]) { + process.env.CODEX_HOME = raw; + expect(launch().reads).toBe(0); + } + })); + + codexLaunchOwnershipTest("junction or symlink homes bind to the physical verified directory", () => fixture((root, home) => { + const link = join(root, "linked"); symlinkSync(home, link, process.platform === "win32" ? "junction" : "dir"); + process.env.CODEX_HOME = link; + const result = launch(); + expect(result.reads).toBe(1); + expect(result.env.CODEX_HOME).toBe(realpathSync(home)); + })); + + test("junction or symlink homes with a foreign destination withhold credentials", () => fixture((root, home) => { + const link = join(root, "linked"); symlinkSync(home, link, process.platform === "win32" ? "junction" : "dir"); + process.env.CODEX_HOME = link; + writeFileSync(join(home, "config.toml"), foreign); + const result = launch(); + expect(result.reads).toBe(0); + expect(result.env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + })); + + for (const key of proxyKeys) for (const bypass of [undefined, "", "localhost", "*"]) { + test(`${key} with NO_PROXY=${String(bypass)} withholds new credentials`, () => fixture(() => { + const base = { ...process.env, [key]: "http://127.0.0.1:19999", NO_PROXY: bypass, no_proxy: "127.0.0.1" }; + const result = launch(base); + expect(result.reads).toBe(0); + expect(result.env).toEqual(base); + })); + } + + codexLaunchOwnershipTest("empty proxy variables preserve normal local credential delivery", () => fixture((_root, home) => { + const result = launch({ ...process.env, HTTP_PROXY: "", HTTPS_PROXY: "", ALL_PROXY: "" }); + expect(result.reads).toBe(1); + expect(result.env.OPENCODEX_API_AUTH_TOKEN).toBe(token); + expect(result.env.CODEX_HOME).toBe(realpathSync(home)); + expect(result.env.NO_PROXY).toContain("127.0.0.1"); + expect(result.env.no_proxy).toContain("::1"); + })); + + test("system proxy opt-in without proxy env withholds credentials", () => fixture((_root, home) => { + writeFileSync(join(home, "config.toml"), local + "\n[features]\nrespect_system_proxy = true\nsystem_proxy_fallback = true\n"); + expect(launch().reads).toBe(0); + })); + + test("feature argv cannot enable a bypass-ignoring system proxy", () => fixture(() => { + for (const args of [["--enable", "respect_system_proxy"], ["--enable=respect_system_proxy"]]) { + let reads = 0; + buildCodexLaunchEnv(process.env, { routingKind: "opencodex-local", args, serviceToken: () => { reads++; return token; } }); + expect(reads).toBe(0); + } + })); + + codexLaunchOwnershipTest("default transport gets identical loopback bypasses and preserves existing entries", () => fixture((_root, home) => { + writeFileSync(join(home, "config.toml"), local + "\n[features]\nrespect_system_proxy = false\nsystem_proxy_fallback = true\n"); + const result = launch({ ...process.env, NO_PROXY: "example.invalid", no_proxy: "other.invalid", No_Proxy: "third.invalid" }); + expect(result.reads).toBe(1); + expect(result.env.NO_PROXY).toBe(result.env.no_proxy); + for (const entry of ["127.0.0.1", "::1", "example.invalid", "other.invalid", "third.invalid"]) expect(result.env.NO_PROXY!.split(",")).toContain(entry); + expect(result.env.No_Proxy).toBeUndefined(); + })); + + test("an observing proxy receives no freshly loaded credential", () => fixture(async (root) => { + const received: boolean[] = []; + const proxy = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch(request) { + received.push(request.headers.has("authorization")); + return new Response("fixture refusal", { status: 401 }); + } }); + try { + const result = launch({ ...process.env, HTTP_PROXY: `http://127.0.0.1:${proxy.port}`, NO_PROXY: "", OCX_FIXTURE_REQUEST: "1" }); + expect(result.reads).toBe(0); + const child = Bun.spawn([process.execPath, fixturePath("codex-launch-native-env.ts")], { cwd: root, env: result.env, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, status] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]); + expect(stderr).toBe(""); expect(status).toBe(0); + expect(JSON.parse(stdout)).toMatchObject({ hasToken: false, status: 401 }); + expect(received).toEqual([false]); + } finally { await proxy.stop(true); } + }), SPAWN_BUDGET_MS); + + test("a user-exported token and proxy settings remain untouched", () => fixture(() => { + const base = { ...process.env, OPENCODEX_API_AUTH_TOKEN: token, HTTP_PROXY: "http://127.0.0.1:19999" }; + const result = launch(base); + expect(result.reads).toBe(0); + expect(result.env).toEqual(base); + })); +}); diff --git a/tests/codex-integration/codex-launcher-process.test.ts b/tests/codex-integration/codex-launcher-process.test.ts new file mode 100644 index 00000000000..87e1b7390bd --- /dev/null +++ b/tests/codex-integration/codex-launcher-process.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { delimiter, join } from "node:path"; +import { repoPath, repoRoot } from "../helpers/repo-root"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; + +describe("ocx codex real CLI handoff", () => { + for (const arg of ["--help", "--version"]) test(`missing Codex ${arg} reports the install hint`, () => { + const home = mkdtempSync(join(tmpdir(), "ocx-codex-missing-")); + try { + const result = spawnSync(process.execPath, [repoPath("src", "cli", "index.ts"), "codex", arg], { + cwd: home, env: { ...process.env, PATH: "", OPENCODEX_HOME: home }, + encoding: "utf8", timeout: SPAWN_BUDGET_MS - 5_000, windowsHide: true, + }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(1); + expect(result.stderr).toContain("npm install -g @openai/codex"); + expect(result.stderr).not.toContain("cannot verify Codex shim bypass"); + } finally { removeTreeWithRetry(home); } + }, SPAWN_BUDGET_MS); + + for (const args of [["--help"], ["exec", "--help"], ["help"], ["--version"], ["resume", "two words"]]) { + test(`forwards ${args.join(" ")} without loading a service token`, () => { + const home = mkdtempSync(join(tmpdir(), "ocx-codex-process-")); + try { + const bin = join(home, "bin"); + const codexHome = join(home, "codex-home"); + const ocxHome = join(home, "ocx-home"); + for (const dir of [bin, codexHome, ocxHome]) mkdirSync(dir); + const probe = join(home, "probe.mjs"); + writeFileSync(probe, 'console.log(JSON.stringify({args:process.argv.slice(2),token:process.env.OPENCODEX_API_AUTH_TOKEN??null}));process.exit(23);\n'); + const command = join(bin, process.platform === "win32" ? "codex.cmd" : "codex"); + const shellQuote = (value: string) => `'${value.replace(/'/g, "'\\''")}'`; + writeFileSync(command, process.platform === "win32" + ? `@echo off\r\n"${process.execPath}" "${probe}" %*\r\nexit /b %errorlevel%\r\n` + : `#!/bin/sh\nexec ${shellQuote(process.execPath)} ${shellQuote(probe)} "$@"\n`); + if (process.platform !== "win32") chmodSync(command, 0o755); + const config = 'model = "native-fixture"\n'; + const configPath = join(codexHome, "config.toml"); + writeFileSync(configPath, config); + writeFileSync(join(ocxHome, "service-api-token"), "ocx_fixture_service_token"); + const env: NodeJS.ProcessEnv = { + ...process.env, HOME: home, USERPROFILE: home, CODEX_HOME: codexHome, OPENCODEX_HOME: ocxHome, + PATH: `${bin}${delimiter}${process.env.PATH ?? ""}`, PATHEXT: ".EXE;.CMD", + }; + delete env.OPENCODEX_API_AUTH_TOKEN; + delete env.OPENCODEX_ADMIN_AUTH_TOKEN; + delete env.OCX_API_TOKEN_FILE; + const result = spawnSync(process.execPath, [repoPath("src", "cli", "index.ts"), "codex", ...args], { + cwd: repoRoot(), env, encoding: "utf8", timeout: SPAWN_BUDGET_MS - 5_000, windowsHide: true, + }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(23); + expect(JSON.parse(result.stdout.trim())).toEqual({ args, token: null }); + expect(result.stderr).not.toContain("ocx_fixture_service_token"); + expect(readFileSync(configPath, "utf8")).toBe(config); + } finally { + removeTreeWithRetry(home); + } + }, SPAWN_BUDGET_MS); + } +}); diff --git a/tests/codex-integration/codex-launcher-security.test.ts b/tests/codex-integration/codex-launcher-security.test.ts new file mode 100644 index 00000000000..0837aad73dc --- /dev/null +++ b/tests/codex-integration/codex-launcher-security.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { delimiter, join, parse } from "node:path"; +import { spawnSync } from "node:child_process"; +import { codexCommandCanBypass } from "../../src/cli/codex-launch-command"; +import { codexCredentialDestinationIsTrusted } from "../../src/cli/codex-launch-auth"; +import { resolveCodexLaunchCommand } from "../../src/cli/codex-launcher"; +import { commandInvocation } from "../../src/lib/win-exec"; +import { classifyCodexRouting } from "../../src/codex/inject/routing-classify"; +import { buildProviderTableBlockForTarget } from "../../src/codex/inject/config-toml"; +import { standaloneCodexRoutingTarget } from "../../src/codex/inject/routing-target"; +import { buildWindowsCodexShim, WINDOWS_SHIM_REVISION_MARKER } from "../../src/codex/shim-templates"; +import { repoPath, repoRoot } from "../helpers/repo-root"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; +import { createCodexLaunchFixtureRoot, codexLaunchOwnershipTest, codexLaunchProjectRefusalTest, buildCodexPrivateFixtureLaunchEnv as buildCodexLaunchEnv } from "../helpers/codex-launch-private-fixture"; + +const fixtureToken = "ocx_fixture_launcher_security"; +const installation = { port: 12199, hostname: "0.0.0.0" }; +const local = 'model_provider = "opencodex"\n' + buildProviderTableBlockForTarget(standaloneCodexRoutingTarget(12199, installation)); + +function fixture(run: (home: string, codexHome: string, ocxHome: string) => void): void { + const home = createCodexLaunchFixtureRoot("ocx-launch-security-"); + const cwd = process.cwd(); + const saved = { CODEX_HOME: process.env.CODEX_HOME, OPENCODEX_HOME: process.env.OPENCODEX_HOME }; + const codexHome = join(home, "codex-home"), ocxHome = join(home, "ocx-home"); + mkdirSync(codexHome); mkdirSync(ocxHome); + process.env.CODEX_HOME = codexHome; process.env.OPENCODEX_HOME = ocxHome; + writeFileSync(join(ocxHome, "config.json"), JSON.stringify(installation)); + writeFileSync(join(ocxHome, "service-api-token"), fixtureToken); + process.chdir(home); + try { run(home, codexHome, ocxHome); } + finally { + process.chdir(cwd); + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; else process.env[key] = value; + } + removeTreeWithRetry(home); + } +} + +describe("codex launcher credential destination", () => { + // Keep the shared positive control separate so unsafe hosts still execute all negatives. + codexLaunchOwnershipTest("the managed starting configuration reads the service token exactly once", () => fixture((_home, codexHome) => { + writeFileSync(join(codexHome, "config.toml"), local); + let reads = 0; + const env = buildCodexLaunchEnv({ CODEX_HOME: codexHome }, { routingKind: "opencodex-local", serviceToken: () => { reads++; return fixtureToken; } }); + expect(reads).toBe(1); + expect(env.OPENCODEX_API_AUTH_TOKEN).toBe(fixtureToken); + })); + for (const [name, content] of [ + ["remote reserved provider", local.replace("http://127.0.0.1:12199/v1", "https://gateway.invalid/v1")], + ["remote root URL retaining injection comment", '# Auto-injected by opencodex\nopenai_base_url = "https://gateway.invalid/v1"\n'], + ["wrong local port", local.replace(":12199/", ":12200/")], + ["default profile redirects provider", 'profile = "foreign"\n' + local + '\n[profiles.foreign]\nmodel_provider = "foreign"\n'], + ["extra credential header", local + '\n[model_providers.opencodex.env_http_headers]\nx-key = "OPENCODEX_API_AUTH_TOKEN"\n'], + ["malformed TOML", local + '\nmodel_provider = [\n'], + ]) { + test(`does not load a token for ${name}`, () => fixture((_home, codexHome) => { + writeFileSync(join(codexHome, "config.toml"), content!); + let reads = 0; + const env = buildCodexLaunchEnv({ CODEX_HOME: codexHome }, { routingKind: classifyCodexRouting(content!), serviceToken: () => { reads++; return fixtureToken; } }); + expect(env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + expect(reads).toBe(0); + })); + } + codexLaunchOwnershipTest("loads the service token for the managed provider on this installation's local listener", () => fixture((home, codexHome) => { + writeFileSync(join(codexHome, "config.toml"), local); + // This isolated Codex home must not inherit project configs beside the real user's + // home/worktree. The filesystem root has no caller ancestor configuration layers. + const originalDirectory = process.cwd(); + try { + process.chdir(parse(home).root); + expect(buildCodexLaunchEnv({ CODEX_HOME: codexHome }, { routingKind: "opencodex-local" }).OPENCODEX_API_AUTH_TOKEN).toBe(fixtureToken); + } finally { process.chdir(originalDirectory); } + })); + test("directory and remote overrides never read a service token", () => fixture((_home, codexHome) => { + writeFileSync(join(codexHome, "config.toml"), local); + for (const args of [["-C", "other"], ["-Cother"], ["--cd=other"], ["--cd", "other"], ["--remote", "remote"], ["--remote=remote"]]) { + let reads = 0; + expect(buildCodexLaunchEnv({ CODEX_HOME: codexHome }, { routingKind: "opencodex-local", args, serviceToken: () => { reads++; return fixtureToken; } })).toEqual({ CODEX_HOME: codexHome }); + expect(reads).toBe(0); + } + })); + test("project configuration and environment destination overrides suppress loading", () => fixture((home, codexHome) => { + writeFileSync(join(codexHome, "config.toml"), local); + expect(buildCodexLaunchEnv({ CODEX_HOME: codexHome, OPENAI_BASE_URL: "https://gateway.invalid/v1" }, { routingKind: "opencodex-local" }).OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + const project = join(home, "project"); mkdirSync(project); + mkdirSync(join(project, ".codex")); + writeFileSync(join(project, ".git"), "gitdir: fixture\n"); + writeFileSync(join(project, ".codex", "config.toml"), 'model_provider = "foreign"\n'); + const originalDirectory = process.cwd(); + try { + process.chdir(project); + let reads = 0; + expect(buildCodexLaunchEnv({ CODEX_HOME: codexHome }, { routingKind: "opencodex-local", serviceToken: () => { reads++; return fixtureToken; } })).toEqual({ CODEX_HOME: codexHome }); + expect(reads).toBe(0); + } finally { process.chdir(originalDirectory); } + })); + codexLaunchProjectRefusalTest("custom project roots cannot hide an outer override behind a nested .git", (expectedReason) => fixture((home, codexHome) => { + writeFileSync(join(codexHome, "config.toml"), local); + const workspace = join(home, "workspace"), nested = join(workspace, "nested"), cwd = join(nested, "src"); + mkdirSync(cwd, { recursive: true }); + mkdirSync(join(workspace, ".codex")); + writeFileSync(join(workspace, ".workspace-root"), ""); + writeFileSync(join(nested, ".git"), "gitdir: fixture\n"); + writeFileSync(join(workspace, ".codex", "config.toml"), '[model_providers.opencodex]\nbase_url = "https://gateway.invalid/v1"\n'); + writeFileSync(join(codexHome, "config.toml"), local); + const originalDirectory = process.cwd(); + try { + process.chdir(cwd); + let reads = 0, reason: string | undefined; + const env = buildCodexLaunchEnv({ CODEX_HOME: codexHome }, { + routingKind: "opencodex-local", serviceToken: () => { reads++; return fixtureToken; }, + credentialDestination: (home, report) => codexCredentialDestinationIsTrusted(home, join(home, "system-namespace-fixture"), r => { reason = r; report?.(r); }), + }); + expect(reads).toBe(0); + expect(reason).toBe(expectedReason); + expect(env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + } finally { process.chdir(originalDirectory); } + })); +}); + +describe.skipIf(process.platform !== "win32")("codex launcher installed Windows shim handoff", () => { + for (const args of [["--help"], ["resume"], ["-c", "model_provider=foreign", "resume"]]) { + for (const managementCollision of [false, true]) for (const obsolete of [true, false]) { + test(`${obsolete ? "obsolete shim fails closed" : "current shim bypasses token loading"}: ${args.join(" ")} collision=${managementCollision}`, () => fixture((home, codexHome, ocxHome) => { + const bin = join(home, "bin"); mkdirSync(bin); + const probe = join(home, "probe.mjs"); + writeFileSync(probe, 'console.log(JSON.stringify({token:process.env.OPENCODEX_API_AUTH_TOKEN??null}));process.exit(23);\n'); + const backing = join(bin, "backing.cmd"); + writeFileSync(backing, `@echo off\r\n"${process.execPath}" "${probe}" %*\r\n`); + const config = args.includes("-c") + ? 'model_provider = "foreign"\n[model_providers.foreign]\nbase_url = "https://gateway.invalid/v1"\n' + : 'model = "native-fixture"\n'; + writeFileSync(join(codexHome, "config.toml"), config); + if (managementCollision) writeFileSync(join(ocxHome, "admin-api-token"), fixtureToken); + const current = buildWindowsCodexShim(backing, process.execPath, repoPath("src", "cli", "index.ts"), "process"); + const lines = current.split("\n").filter(line => !line.includes(WINDOWS_SHIM_REVISION_MARKER)); + const bypass = lines.findIndex(line => line.includes('if not "%OCX_SHIM_BYPASS%"=="" goto run_codex')); + const load = lines.findIndex(line => line.includes("set /p OPENCODEX_API_AUTH_TOKEN=")); + expect(bypass).toBeGreaterThan(0); expect(load).toBeGreaterThan(bypass); + [lines[bypass], lines[load]] = [lines[load]!, lines[bypass]!]; + const legacy = lines.join("\n"); + const installed = obsolete ? legacy : current; + const wrapper = join(bin, "codex.cmd"); writeFileSync(wrapper, installed); + const env: NodeJS.ProcessEnv = { ...process.env, HOME: home, PATH: `${bin}${delimiter}${process.env.PATH ?? ""}`, PATHEXT: ".EXE;.CMD" }; + for (const key of ["OPENCODEX_API_AUTH_TOKEN", "OPENCODEX_ADMIN_AUTH_TOKEN", "OCX_API_TOKEN_FILE", "OCX_SHIM_BYPASS"]) delete env[key]; + const resolveWithThrowingInspector = () => resolveCodexLaunchCommand(env, { + findOnPath: () => wrapper, + inspectShim: () => { throw new Error("fixture inspection failure"); }, + }); + if (obsolete) expect(resolveWithThrowingInspector).toThrow("cannot verify Codex shim bypass"); + else { + const launch = resolveWithThrowingInspector(); + expect(launch.command).toBe(wrapper); + expect(launch.env.OCX_SHIM_BYPASS).toBe("1"); + const invocation = commandInvocation(launch.command, args); + const bypassed = spawnSync(invocation.file, invocation.args, { ...invocation.options, + env: launch.env as NodeJS.ProcessEnv, encoding: "utf8", timeout: SPAWN_BUDGET_MS - 5_000, windowsHide: true }); + expect(bypassed.status).toBe(23); + expect(JSON.parse(bypassed.stdout.trim())).toEqual({ token: null }); + } + const result = spawnSync(process.execPath, [repoPath("src", "cli", "index.ts"), "codex", ...args], { + cwd: repoRoot(), env, encoding: "utf8", timeout: SPAWN_BUDGET_MS - 5_000, windowsHide: true, + }); + expect(result.error).toBeUndefined(); + expect(result.stdout).not.toContain(fixtureToken); + expect(result.status).toBe(obsolete ? 1 : 23); + if (obsolete) expect(result.stderr).toContain("shim"); + else expect(JSON.parse(result.stdout.trim())).toEqual({ token: null }); + expect(readFileSync(wrapper, "utf8")).toBe(installed); + expect(readFileSync(join(codexHome, "config.toml"), "utf8")).toBe(config); + }), SPAWN_BUDGET_MS); + } + } + test("a revision marker cannot bless inserted commands, duplicate labels or trailing code", () => fixture((home) => { + const wrapper = join(home, "codex.cmd"); + const current = buildWindowsCodexShim(join(home, "backing.cmd"), process.execPath, repoPath("src", "cli", "index.ts"), "process"); + for (const text of [ + current.replace("setlocal", "echo inserted\r\nsetlocal"), + current.replace(":run_codex", ":run_codex\r\necho inserted\r\n:run_codex"), + current + "echo trailing\r\n", + current.replace('if not "%OCX_SHIM_BYPASS%"=="" goto run_codex', "rem bypass disabled"), + ]) { + writeFileSync(wrapper, text); + expect(codexCommandCanBypass(wrapper, process.env)).toBe(false); + } + })); +}); diff --git a/tests/codex-integration/codex-launcher.test.ts b/tests/codex-integration/codex-launcher.test.ts new file mode 100644 index 00000000000..96f888fef4d --- /dev/null +++ b/tests/codex-integration/codex-launcher.test.ts @@ -0,0 +1,347 @@ +/** + * `ocx codex [args...]` (src/cli/codex-launcher.ts): the `ocx claude`/`ocx opencode` + * launcher pattern on Codex's config-owned wiring channel. + * + * Covers the decision surface a launch lives on: the shim-grammar fast path, the + * routing-kind launch plan, the service-token env assembly for a routed exec, and the + * detached-start environment. Each is pure; process semantics (stdio inherit, exit-code + * propagation, ENOENT hints) are the shared spawn shape already exercised by + * tests/claude-integration/claude-cli.test.ts and the opencode launcher tests. + * + * The fast-path grammar is pinned against the sets exported by + * src/codex/shim-templates.ts, because the generated shell wrapper inlines the same + * decision: both `codex` entry points must autostart (or fast-path) identically. + */ +import { describe, expect, test } from "bun:test"; +import { + buildCodexLaunchEnv as buildEnv, + CODEX_ROUTING_CUSTOM_LOCAL_NOTICE, + CODEX_ROUTING_CUSTOM_REMOTE_NOTICE, + CODEX_ROUTING_NATIVE_NOTICE, + CODEX_ROUTING_UNKNOWN_NOTICE, + codexFastPathReason, + codexLaunchPlan, + codexNotFoundHint, + codexProxyStartEnv, + ensureProxyForCodex, + resolveCodexLaunchCommand, +} from "../../src/cli/codex-launcher"; +import { CODEX_GLOBAL_OPTIONS_WITH_VALUE, CODEX_INTERNAL_COMMANDS } from "../../src/codex/shim-templates"; +import { SIBLING_HANDOFF_NONCE_ENV, SIBLING_OF_PORT_ENV } from "../../src/codex/sibling-start"; +import type { LiveProxy } from "../../src/server/proxy-liveness"; +import { maybeAutoRestoreCodexShim } from "../../src/cli/codex-shim-autorestore"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { repoRoot } from "../helpers/repo-root"; + +// These env-assembly tests supply destination authority explicitly; real config authority +// (including the positive managed block) is covered in codex-launcher-security.test.ts. +const buildCodexLaunchEnv: typeof buildEnv = (base, deps) => buildEnv({ CODEX_HOME: repoRoot(), ...base }, { + credentialDestination: () => true, ...deps, +}); + +function withTokenFiles(run: (home: string) => void): void { + const home = mkdtempSync(join(tmpdir(), "ocx-codex-launch-token-")); + const prior = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = home; + try { run(home); } + finally { + if (prior === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = prior; + removeTreeWithRetry(home); + } +} + +test("codex launch defers shim handling to its routing plan without root repair", () => { + for (const args of [["codex", "--version"], ["codex", "login"], ["codex", "resume"]]) { + let restored = false; + maybeAutoRestoreCodexShim("codex", args, { + env: {}, + warn: () => {}, + readConfig: () => { throw new Error("launcher preflight must not read config"); }, + restore: () => { restored = true; return { status: "disabled" }; }, + }); + expect(restored).toBe(false); + } +}); + +describe("codex launcher fast path", () => { + test("help/version and internal subcommands skip the proxy ensure", () => { + expect(codexFastPathReason(["--version"])).toBe("version"); + expect(codexFastPathReason(["-V"])).toBe("version"); + expect(codexFastPathReason(["--help"])).toBe("help"); + expect(codexFastPathReason(["-h"])).toBe("help"); + expect(codexFastPathReason(["login"])).toBe("internal"); + expect(codexFastPathReason(["doctor", "--json"])).toBe("internal"); + }); + + test("global options with values are skipped before the first positional, matching the shim", () => { + expect(codexFastPathReason(["-m", "opencode-go/x", "login"])).toBe("internal"); + expect(codexFastPathReason(["--model", "opencode-go/x", "doctor"])).toBe("internal"); + expect(codexFastPathReason(["--profile", "p", "--config", "k=v", "--help"])).toBe("help"); + expect(codexFastPathReason(["--config=k=v", "logout"])).toBe("internal"); + expect(codexFastPathReason(["-m", "x", "--version"])).toBe("version"); + }); + + test("session-shaped argv autostarts", () => { + expect(codexFastPathReason([])).toBeNull(); + expect(codexFastPathReason(["resume"])).toBeNull(); + expect(codexFastPathReason(["-m", "opencode-go/x"])).toBeNull(); + expect(codexFastPathReason(["--model=opencode-go/x"])).toBeNull(); + expect(codexFastPathReason(["--dangerously-bypass-approvals-and-sandbox"])).toBeNull(); + expect(codexFastPathReason(["--"])).toBeNull(); + expect(codexFastPathReason(["--", "doctor"])).toBeNull(); + }); + + test("stays aligned with the generated shim's internal-command and option sets", () => { + for (const command of ["login", "logout", "doctor", "mcp", "help", "update", "app-server"]) { + expect(CODEX_INTERNAL_COMMANDS).toContain(command); + } + expect(CODEX_INTERNAL_COMMANDS).not.toContain("resume"); + for (const option of ["-m", "--model", "-c", "--config", "-p", "--profile"]) { + expect(CODEX_GLOBAL_OPTIONS_WITH_VALUE).toContain(option); + } + }); +}); + +describe("codex launcher plan", () => { + test("opencodex-local routing ensures the proxy and execs", () => { + expect(codexLaunchPlan("opencodex-local")).toEqual({ kind: "routed" }); + }); + + test("native routing passes through untouched and says so", () => { + const plan = codexLaunchPlan("native"); + expect(plan).toEqual({ + kind: "passthrough", + ensureProxy: false, + notice: CODEX_ROUTING_NATIVE_NOTICE, + }); + }); + + test("foreign gateways pass through untouched; that route is not ours to own", () => { + expect(codexLaunchPlan("custom-local")).toEqual({ + kind: "passthrough", + ensureProxy: false, + notice: CODEX_ROUTING_CUSTOM_LOCAL_NOTICE, + }); + expect(codexLaunchPlan("custom-remote")).toEqual({ + kind: "passthrough", + ensureProxy: false, + notice: CODEX_ROUTING_CUSTOM_REMOTE_NOTICE, + }); + }); + + test("unverifiable routing still attempts the proxy ensure", () => { + expect(codexLaunchPlan("unknown")).toEqual({ + kind: "passthrough", + ensureProxy: true, + notice: CODEX_ROUTING_UNKNOWN_NOTICE, + }); + }); +}); + +describe("codex launch env assembly", () => { + test("forwarded config and profile overrides never authorize a newly loaded token", () => { + for (const args of [ + ["-c", 'model_providers.opencodex.base_url="https://foreign.example/v1"', "hello"], + ['-cmodel_provider="foreign"', "hello"], ["--config", 'model_provider="foreign"'], + ['--config=model_provider="foreign"'], ["-p", "foreign"], ["-pforeign"], + ["--profile", "foreign"], ["--profile=foreign"], + ]) { + let consulted = false; + expect(buildCodexLaunchEnv({}, { + routingKind: "opencodex-local", args, + serviceToken: () => { consulted = true; return "ocx_fixture_service_token"; }, + })).toEqual({ CODEX_HOME: repoRoot() }); + expect(consulted).toBe(false); + const inherited = { CODEX_HOME: repoRoot(), OPENCODEX_API_AUTH_TOKEN: "ocx_fixture_user_export" }; + expect(buildCodexLaunchEnv(inherited, { routingKind: "opencodex-local", args })).toEqual(inherited); + } + }); + + test("an inherited token-file override cannot select the admin credential", () => { + withTokenFiles(home => { + const admin = `ocx_admin_${"a".repeat(43)}`; + writeFileSync(join(home, "admin-api-token"), admin); + writeFileSync(join(home, "service-api-token"), "ocx_fixture_service_token"); + const env = buildCodexLaunchEnv({ OCX_API_TOKEN_FILE: join(home, "admin-api-token") }); + expect(env.OPENCODEX_API_AUTH_TOKEN).toBe("ocx_fixture_service_token"); + }); + }); + + test("a service file containing a management credential is never injected", () => { + withTokenFiles(home => { + for (const admin of [`ocx_admin_${"a".repeat(43)}`, "ocx_fixture_custom_admin"]) { + writeFileSync(join(home, "service-api-token"), admin); + const env = buildCodexLaunchEnv({ OPENCODEX_ADMIN_AUTH_TOKEN: admin }); + expect(env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + } + }); + }); + + test("unknown and foreign routing never read or inject the service token", () => { + for (const routingKind of ["unknown", "custom-local", "custom-remote", "native"] as const) { + let consulted = false; + const base = { CODEX_HOME: repoRoot(), PATH: "/usr/bin" }; + const env = buildCodexLaunchEnv(base, { + routingKind, + serviceToken: () => { consulted = true; return "ocx_fixture_service_token"; }, + }); + expect(env).toEqual(base); + expect(consulted).toBe(false); + } + }); + + test("a missing admission token comes from the service token ladder", () => { + const env = buildCodexLaunchEnv({ PATH: "/usr/bin" }, { serviceToken: () => "tok-from-file" }); + expect(env.OPENCODEX_API_AUTH_TOKEN).toBe("tok-from-file"); + }); + + test("a user export always wins; the resolver is not even consulted", () => { + let consulted = false; + const env = buildCodexLaunchEnv( + { OPENCODEX_API_AUTH_TOKEN: "user-token" }, + { serviceToken: () => { consulted = true; return "other"; } }, + ); + expect(env.OPENCODEX_API_AUTH_TOKEN).toBe("user-token"); + expect(consulted).toBe(false); + }); + + test("a blank inherited token counts as absent", () => { + const env = buildCodexLaunchEnv( + { OPENCODEX_API_AUTH_TOKEN: " " }, + { serviceToken: () => "tok-from-file" }, + ); + expect(env.OPENCODEX_API_AUTH_TOKEN).toBe("tok-from-file"); + }); + + test("an absent token resolves to nothing and stays absent", () => { + const env = buildCodexLaunchEnv({ PATH: "/usr/bin" }, { serviceToken: () => null }); + expect(env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + }); +}); + +describe("codex detached-start env", () => { + test("carries the service marker and defaults the hardened token file", () => { + const env = codexProxyStartEnv({ PATH: "/usr/bin" }); + expect(env.OCX_SERVICE).toBe("1"); + expect(env.OCX_API_TOKEN_FILE!.endsWith("service-api-token")).toBe(true); + }); + + test("an inherited admission token avoids the token-file lookup", () => { + const env = codexProxyStartEnv({ OPENCODEX_API_AUTH_TOKEN: "env-token" }); + expect(env.OCX_API_TOKEN_FILE).toBeUndefined(); + expect(env.OCX_SERVICE).toBe("1"); + }); + + test("sibling markers never leak into a detached start child", () => { + const env = codexProxyStartEnv({ + [SIBLING_HANDOFF_NONCE_ENV]: "n", + [SIBLING_OF_PORT_ENV]: "10100", + OCX_SERVICE: "0", + }); + expect(env[SIBLING_HANDOFF_NONCE_ENV]).toBeUndefined(); + expect(env[SIBLING_OF_PORT_ENV]).toBeUndefined(); + expect(env.OCX_SERVICE).toBe("1"); + }); +}); + +describe("codex launcher proxy ensure", () => { + test("borrows the claude launcher's freshly-bound retry budget (#764)", async () => { + const attempts: Array = []; + const live: LiveProxy = { pid: 4242, port: 10100, source: "runtime" }; + const result = await ensureProxyForCodex({ + findLiveProxy: async io => { + attempts.push(io?.attempts); + return live; + }, + }); + expect(result).toEqual(live); + expect(attempts).toEqual([3]); + }); +}); + +describe("codex launcher not-found hints", () => { + test("cmd.exe's 9009 on win32 names the install path", () => { + expect(codexNotFoundHint(9009, null, "win32")).toContain("npm install -g @openai/codex"); + }); + + test("POSIX exits and any signal exit are not hints", () => { + expect(codexNotFoundHint(127, null, "linux")).toBeNull(); + expect(codexNotFoundHint(1, "SIGINT", "win32")).toBeNull(); + expect(codexNotFoundHint(null, null, "linux")).toBeNull(); + }); +}); + +describe("codex launcher shim handoff", () => { + const env = { PATH: "/usr/bin" }; + const onPath = () => "/home/test/.local/bin/codex"; + + test("a recorded autostart shim is skipped in favour of its backing Codex", () => { + const launch = resolveCodexLaunchCommand(env, { + findOnPath: onPath, + inspectShim: () => ({ status: "matched", selectedRole: "wrapper", backingPath: "/opt/codex/bin/codex.real", backingKind: "backup" }), + }); + expect(launch).toEqual({ command: "/opt/codex/bin/codex.real", env }); + }); + + test("an unknown shim state uses the verified selected path with bypass", () => { + const launch = resolveCodexLaunchCommand(env, { + findOnPath: onPath, + inspectShim: () => ({ status: "unknown", reason: "binding_unavailable" }), + verifyBypass: () => true, + }); + expect(launch.command).toBe(onPath()); + expect(launch.env.OCX_SHIM_BYPASS).toBe("1"); + expect(env).not.toHaveProperty("OCX_SHIM_BYPASS"); + }); + + test("a PATH lookup error fails closed before any child handoff", () => { + let inspected = false; + expect(() => resolveCodexLaunchCommand(env, { + findOnPath: () => { throw new Error("PATH lookup unavailable"); }, + inspectShim: () => { inspected = true; return { status: "not-tracked" }; }, + })).toThrow("cannot verify Codex shim bypass"); + expect(inspected).toBe(false); + expect(env).not.toHaveProperty("OCX_SHIM_BYPASS"); + }); + + test("a throwing shim inspector requires a verified bypass", () => { + let verified = false; + const launch = resolveCodexLaunchCommand(env, { + findOnPath: onPath, + inspectShim: () => { throw new Error("fixture inspection failure"); }, + verifyBypass: (selected, inherited) => { + expect(selected).toBe(onPath()); expect(inherited).toBe(env); + verified = true; return true; + }, + }); + expect(verified).toBe(true); + expect(launch).toEqual({ command: onPath(), env: { ...env, OCX_SHIM_BYPASS: "1" } }); + expect(env).not.toHaveProperty("OCX_SHIM_BYPASS"); + }); + + test("an unverifiable bypass after a shim filesystem error fails closed without changing the input environment", () => { + const base = { ...env, OPENCODEX_API_AUTH_TOKEN: "ocx_fixture_inherited_token", OCX_SHIM_BYPASS: "" }; + let verified = false; + expect(() => resolveCodexLaunchCommand(base, { + findOnPath: onPath, + inspectShim: () => { throw Object.assign(new Error("shim state read failed"), { code: "EIO" }); }, + verifyBypass: () => { verified = true; return false; }, + })).toThrow("cannot verify Codex shim bypass"); + expect(verified).toBe(true); + expect(base.OCX_SHIM_BYPASS).toBe(""); + }); + + test("an untracked or backing codex launches unchanged", () => { + expect(resolveCodexLaunchCommand(env, { findOnPath: onPath, inspectShim: () => ({ status: "not-tracked" }) })) + .toEqual({ command: onPath(), env }); + expect(resolveCodexLaunchCommand(env, { + findOnPath: onPath, + inspectShim: () => ({ status: "matched", selectedRole: "backing", backingPath: "/home/test/.local/bin/codex", backingKind: "real" }), + })).toEqual({ command: onPath(), env }); + expect(() => resolveCodexLaunchCommand(env, { findOnPath: () => null })).toThrow("npm install -g @openai/codex"); + }); +}); diff --git a/tests/fixtures/codex-launch-native-env.ts b/tests/fixtures/codex-launch-native-env.ts new file mode 100644 index 00000000000..32cc7d07ecc --- /dev/null +++ b/tests/fixtures/codex-launch-native-env.ts @@ -0,0 +1,23 @@ +// Native-semantics consumer: CODEX_HOME is a literal path, never shell-expanded. +// This fixture observes the child boundary without requiring an installed Codex. +import { readFileSync, realpathSync } from "node:fs"; +import { join, resolve } from "node:path"; + +const home = realpathSync(resolve(process.env.CODEX_HOME!)); +const config = Bun.TOML.parse(readFileSync(join(home, "config.toml"), "utf8")) as { + model_provider: string; + model_providers: Record; +}; +const provider = config.model_providers[config.model_provider]!; +const token = process.env[provider.env_key]; +let status: number | undefined; +if (process.env.OCX_FIXTURE_REQUEST === "1") { + // Mirror the native transport's relevant HTTP_PROXY behavior for the observer. + const proxy = process.env.HTTP_PROXY || process.env.http_proxy || process.env.ALL_PROXY || process.env.all_proxy; + const response = await fetch(`${provider.base_url}/responses`, { + method: "POST", proxy, headers: token ? { Authorization: `Bearer ${token}` } : {}, + body: "{}", + }); + status = response.status; +} +console.log(JSON.stringify({ home, baseUrl: provider.base_url, hasToken: Boolean(token), status })); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index a2930ac1697..455d6e780db 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -608,6 +608,14 @@ "codex-runtime.test.ts": "codex-integration", "codex-runtime-macos-desktop.test.ts": "codex-integration", "codex-runtime-wsl-desktop.test.ts": "codex-integration", "codex-clamp-notice.test.ts": "codex-integration", "codex-service-manager-probe-hardening.test.ts": "codex-integration", "codex-service-manager-probe.test.ts": "codex-integration", "codex-systemd-env-probe.test.ts": "codex-integration", + "codex-launcher-diagnostics.test.ts": "codex-integration", + "codex-launcher-darwin-acl.test.ts": "codex-integration", + "codex-launcher-darwin-identity.test.ts": "codex-integration", + "codex-launcher-admission.test.ts": "codex-integration", + "codex-launcher.test.ts": "codex-integration", + "codex-launcher-process.test.ts": "codex-integration", + "codex-launcher-security.test.ts": "codex-integration", + "codex-launcher-env.test.ts": "codex-integration", "codex-shim-autorestore.test.ts": "codex-integration", "codex-shim-destroyed-probe.test.ts": "codex-integration", "codex-shim-bypass-token.test.ts": "codex-integration", "codex-shim-windows-revision.test.ts": "codex-integration", diff --git a/tests/helpers/codex-launch-darwin-model.ts b/tests/helpers/codex-launch-darwin-model.ts new file mode 100644 index 00000000000..253c8863193 --- /dev/null +++ b/tests/helpers/codex-launch-darwin-model.ts @@ -0,0 +1,82 @@ +import { readFileSync } from "node:fs"; +import * as path from "node:path/posix"; +import { repoPath } from "./repo-root"; + +// Run the production walks and FFI adapter on every host. Only imported IO, +// platform identity and the FFI implementation are modeled; no policy is copied. +const source = readFileSync(repoPath("src/cli/codex-launch-ownership.ts"), "utf8") + .replace(/^import .*;\r?\n/gm, "") + .replace('require("bun:ffi")', "ffi") + .replace(/export function /g, "function "); +const js = new Bun.Transpiler({ loader: "ts", target: "bun" }).transformSync(source); + +export type DarwinWalk = "home" | "alias" | "namespace"; +export type DarwinFault = "none" | "vanished" | "inode" | "device" | "type" | "stat" | "close" | "errno" | "wide-inode" | "link-file" | "link-directory"; + +export function darwinOwnershipModel(walk: DarwinWalk, fault: DarwinFault, errno = 13, preferencesForced = false) { + const home = "/owned/home", config = home + "/config.toml", link = "/owned/alias", system = "/owned/system"; + const target = walk === "home" ? config : walk === "alias" ? link : system; + const opened = new Map(), closed: number[] = [], flags: number[] = []; + const descriptors = new Map>(); + let reached = false, nextFd = 10, aclReads = 0, forcedReads = 0; + const missing = () => Object.assign(new Error("modeled missing entry"), { code: "ENOENT" }); + function stat(entry: string, options?: { bigint?: boolean }, kind = entry === link ? 0o120000 : entry === config ? 0o100000 : 0o040000) { + if (/\/(?:config|requirements|managed_config)\.toml$/.test(entry) && entry !== config) throw missing(); + const ino = fault === "wide-inode" && entry === target ? 9007199254740992n : BigInt(entry.length); + return { dev: options?.bigint ? 1n : 1, ino: options?.bigint ? ino : Number(ino), + mode: options?.bigint ? BigInt(kind | 0o700) : kind | 0o700, uid: options?.bigint ? 101n : 101, + isFile: () => kind === 0o100000, isDirectory: () => kind === 0o040000, isSymbolicLink: () => kind === 0o120000 }; + } + const io = { + lstatSync: stat, + readlinkSync: () => config, + openSync(entry: string, value: number) { + if (entry === target) { reached = true; flags.push(value); if (fault === "vanished") throw missing(); } + // XNU vn_authorize_open_existing: VLNK + O_NOFOLLOW is ELOOP, + // including when O_SYMLINK is also set (bsd/vfs/vfs_subr.c). + const kind = entry === target && fault === "link-file" ? 0o100000 + : entry === target && fault === "link-directory" ? 0o040000 : undefined; + let actual = stat(entry, { bigint: true }, kind); + if (actual.isSymbolicLink()) { + if (value & 0x100) throw Object.assign(new Error("modeled final symlink"), { code: "ELOOP" }); + if (!(value & 0x200000)) actual = stat(config, { bigint: true }); + } + // O_SYMLINK also opens non-links. Retain that object for fstat even + // when a replacement has the old dev/ino; its type must still differ. + const fd = nextFd++; opened.set(fd, entry); descriptors.set(fd, actual); return fd; + }, + fstatSync(fd: number, options?: { bigint?: boolean }) { + const entry = opened.get(fd)!, value = descriptors.get(fd)!; + if (entry !== target) return value; + if (fault === "stat") throw new Error("modeled fstat failure"); + const ino = BigInt(value.ino) + (fault === "inode" || fault === "wide-inode" ? 1n : 0n); + return { ...value, ino: options?.bigint ? ino : Number(ino), + dev: fault === "device" ? (options?.bigint ? 2n : 2) : value.dev, + mode: fault === "type" ? (options?.bigint ? BigInt(Number(value.mode) ^ 0o140000) : Number(value.mode) ^ 0o140000) : value.mode }; + }, + closeSync(fd: number) { closed.push(fd); if (fault === "close" && opened.get(fd) === target) throw new Error("modeled close failure"); }, + }; + const symbols = { + // These pathname APIs intentionally model the old ambiguous NULL/ENOENT. + acl_get_file: pathnameAcl, acl_get_link_np: pathnameAcl, + acl_get_fd_np(fd: number) { aclReads++; return null; }, + __error: () => 1, + CFStringCreateWithCString: () => 1, + CFPreferencesAppValueIsForced: () => { forcedReads++; return preferencesForced; }, + CFRelease() {}, + }; + function pathnameAcl(value: Buffer) { + const entry = value.toString().replace(/\0$/, ""); + if (entry === target) reached = true; + aclReads++; return null; + } + const ffi = { FFIType: { ptr: 1, i32: 2, u32: 3, bool: 4, void: 5 }, ptr: (value: Buffer) => value, + read: { i32: () => fault === "errno" ? errno : 2 }, dlopen: () => ({ symbols, close() {} }) }; + const { dirname, isAbsolute, join, resolve, sep } = path; + const inputs = { ...io, dirname, isAbsolute, join, resolve, sep, process: { platform: "darwin", getuid: () => 101 }, ffi }; + const api = new Function(...Object.keys(inputs), js + + "\nreturn { codexLaunchHomeIsOwned, codexLaunchProjectAliasIsOwned, codexLaunchSystemNamespaceIsSafe };")(...Object.values(inputs)); + const admitted = walk === "home" ? api.codexLaunchHomeIsOwned(home) + : walk === "alias" ? api.codexLaunchProjectAliasIsOwned(link) : api.codexLaunchSystemNamespaceIsSafe(system); + return { admitted, reached, flags, opened: [...opened.keys()], closed, aclReads, forcedReads }; +} diff --git a/tests/helpers/codex-launch-private-fixture.ts b/tests/helpers/codex-launch-private-fixture.ts new file mode 100644 index 00000000000..ad959f83e2d --- /dev/null +++ b/tests/helpers/codex-launch-private-fixture.ts @@ -0,0 +1,119 @@ +import { expect, test } from "bun:test"; +import { chmodSync, existsSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolveTrustedWindowsPowerShellExe } from "../../src/lib/windows-elevation"; +import { dirname, join } from "node:path"; +import { buildCodexLaunchEnv } from "../../src/cli/codex-launcher"; +import { codexCredentialDestinationIsTrusted } from "../../src/cli/codex-launch-auth"; +import { codexLaunchHomeIsOwned } from "../../src/cli/codex-launch-ownership"; +import { removeTreeWithRetry } from "./remove-tree"; + +let location: { base: string; owned: boolean; refusals: string[] } | undefined; + +function fixtureLocation() { + if (location) return location; + if (process.platform !== "win32") return location = { base: tmpdir(), owned: true, refusals: [] }; + // The suite rewrites USERPROFILE/TEMP beneath its sandbox, which may live on + // a writable CI work drive. Try real profile Temp without touching client data. + // The explicit override is for reproducing that unsafe ancestor environment. + const candidates = process.env.OCX_CODEX_TEST_FIXTURE_ROOT ? [process.env.OCX_CODEX_TEST_FIXTURE_ROOT] : [ + join(process.env.SystemRoot ?? "C:/Windows", "Temp"), + ...(process.env.OCX_REAL_HOME ? [join(process.env.OCX_REAL_HOME, "AppData", "Local", "Temp")] : []), + tmpdir(), + ]; + const refusals: string[] = []; + let fallback: string | undefined; + for (const base of new Set(candidates)) { + if (!existsSync(base)) continue; + let root: string; + try { root = realpathSync(mkdtempSync(join(base, "ocx-ownership-probe-"))); } + catch (error) { + if (["EACCES", "EPERM"].includes((error as NodeJS.ErrnoException).code ?? "")) continue; + throw error; + } + try { + makeCodexLaunchFixturePrivate(root); + writeFileSync(join(root, "config.toml"), 'model="fixture"\n'); + if (!codexLaunchHomeIsOwned(root)) { + let reason: string | undefined; + expect(codexCredentialDestinationIsTrusted(root, join(root, "system"), value => { reason = value; })).toBe(false); + expect(reason).toBe("ownership"); + refusals.push(base); fallback ??= base; + continue; + } + // A real profile can contain a distinct project config; never borrow it. + let clean = true; + for (let parent = base; ; parent = dirname(parent)) { + if (existsSync(join(parent, ".codex", "config.toml"))) clean = false; + if (dirname(parent) === parent) break; + } + if (clean) return location = { base, owned: true, refusals }; + } finally { removeTreeWithRetry(root); } + } + if (!fallback) throw new Error("No isolated Codex fixture location; no ownership refusal proved"); + return location = { base: fallback, owned: false, refusals }; +} + +export function createCodexLaunchFixtureRoot(prefix: string): string { + const root = realpathSync(mkdtempSync(join(fixtureLocation().base, prefix))); + makeCodexLaunchFixturePrivate(root); + return root; +} + +export function codexLaunchFixtureHasProtectedAncestors(): boolean { + return fixtureLocation().owned; +} + +/** Gate only assertions that need a protected starting point, with an executed refusal proof. */ +export function codexLaunchOwnershipTest(name: string, body: () => void | Promise, timeout?: number): void { + const support = fixtureLocation(); + if (support.owned) { test(name, body, timeout); return; } + test(`${name}: unsafe Windows fixture refuses before token read`, () => { + const root = createCodexLaunchFixtureRoot("ocx-refused-fixture-"); + try { + writeFileSync(join(root, "config.toml"), 'model="fixture"\n'); + expect(codexLaunchHomeIsOwned(root)).toBe(false); + let reason: string | undefined, reads = 0; + const env = buildCodexLaunchEnv({ CODEX_HOME: root }, { + credentialDestination: (home, report) => codexCredentialDestinationIsTrusted(home, join(home, "system"), value => { reason = value; report?.(value); }), + serviceToken: () => { reads++; return "ocx_fixture_must_not_read"; }, + }); + expect(reason).toBe("ownership"); + expect(reads).toBe(0); + expect(env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + expect(support.refusals.length).toBeGreaterThan(0); + } finally { removeTreeWithRetry(root); } + }); + test.skip(`${name} (no protected Windows fixture ancestor chain; ownership refusal proved)`, body); +} + +/** Still exercise the mutating negative scenario when its later diagnostic is unreachable. */ +export function codexLaunchProjectRefusalTest(name: string, body: (reason: "project" | "ownership") => void | Promise): void { + // This is a negative scenario, so its body must never be replaced or skipped. + test(name, () => body(fixtureLocation().owned ? "project" : "ownership")); +} + +// Model a protected, empty machine configuration namespace inside this fixture. +// Every other authority check uses real config/ownership IO. Native probes use production defaults. +export const buildCodexPrivateFixtureLaunchEnv: typeof buildCodexLaunchEnv = (base, deps) => buildCodexLaunchEnv(base, { + credentialDestination: (home, report) => codexCredentialDestinationIsTrusted(home, join(home, "system-namespace-fixture"), report), ...deps, +}); + +/** Windows Temp grants Users create rights to descendants; model a private Codex home. */ +export function makeCodexLaunchFixturePrivate(path: string): void { + if (process.platform !== "win32") { chmodSync(path, 0o700); return; } + const script = String.raw`$ErrorActionPreference='Stop' +$user=[System.Security.Principal.WindowsIdentity]::GetCurrent().User +$acl=[System.Security.AccessControl.DirectorySecurity]::new() +$acl.SetOwner($user) +$acl.SetAccessRuleProtection($true,$false) +foreach ($sid in @($user,[System.Security.Principal.SecurityIdentifier]::new('S-1-5-18'),[System.Security.Principal.SecurityIdentifier]::new('S-1-5-32-544'))) { + $rule=[System.Security.AccessControl.FileSystemAccessRule]::new($sid,[System.Security.AccessControl.FileSystemRights]::FullControl,[System.Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit',[System.Security.AccessControl.PropagationFlags]::None,[System.Security.AccessControl.AccessControlType]::Allow) + $acl.AddAccessRule($rule) +} +[System.IO.Directory]::SetAccessControl($env:OCX_TEST_PRIVATE_DIRECTORY,$acl)`; + const result = Bun.spawnSync([resolveTrustedWindowsPowerShellExe(), "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], { + env: { ...process.env, OCX_TEST_PRIVATE_DIRECTORY: path }, stdout: "pipe", stderr: "pipe", windowsHide: true, + }); + if (!result.success) throw new Error("Cannot create private Codex launch fixture"); +}