diff --git a/docs-site/src/content/docs/fr/reference/management-api.md b/docs-site/src/content/docs/fr/reference/management-api.md index 9c5a23bcfed..0ce8ea8003b 100644 --- a/docs-site/src/content/docs/fr/reference/management-api.md +++ b/docs-site/src/content/docs/fr/reference/management-api.md @@ -103,6 +103,53 @@ Pour comprendre la liste de modèles et le comportement chiffré des tâches con | `GET /api/client-integrations/journal?client=...` | Lister les opérations de restauration, éventuellement pour un seul client. Chaque ligne contient le champ `deletable` calculé par le serveur. | 400 client invalide | | `DELETE /api/client-integrations/journal?opId=...` | Retirer une ancienne opération et supprimer son instantané si possible. La réponse contient `snapshotRemoved` ; `false` conserve le nettoyage pour une nouvelle tentative de maintenance. | 400 `opId` absent ; 404 opération absente ou déjà retirée ; 409 opération la plus récente du client | +## Prévisualiser une modification d'intégration + +Une prévisualisation montre ce qu'une modification ferait sans la faire. Ces routes n'écrivent +rien : ni instantané, ni enregistrement de propriété, ni ligne de journal, ni verrou, ni +maintenance, ni récupération. + +| Méthode et chemin | Objet | Erreurs notables | +| --- | --- | --- | +| `POST /api/client-integrations/preview` | Planifier `apply`, `overwrite` ou `disable` pour un client ; corps `{ "clientId": "...", "operation": "..." }` | 400 client ou opération invalide ; 400 `invalid_aside_profile_path`; 409 `integration_preview_unavailable` | +| `POST /api/client-integrations/restore/preview` | Planifier une annulation ; corps `{ "opId": "...", "confirmDrift": false }` | 404 opération inconnue ; 400 `invalid_aside_profile_path`; 409 `integration_preview_unavailable` | +| `POST /api/client-integrations/aside/profiles/{profileId}/preview` | Planifier la modification d'un seul profil Aside ; `restore` exige un `opId` | 400 corps invalide ou profil non précisé ; 404 profil ou opération inconnus; 409 `integration_preview_unavailable` | + +Un plan contient `version`, `clientId`, `operation`, `state`, `foreignEdit`, une liste `changes` +de paires `kind` et `path`, une empreinte `fingerprint` opaque, `canApply`, `willChange`, ainsi +que `refusalReason` et `profileId` facultatifs. Les chemins sont des chemins de schéma gérés ou +les marqueurs fixes `$snapshot`, `$ownership` et `$journal` ; une position déterminée à +l'exécution apparaît comme `*`. Aucune valeur de configuration, aucun emplacement de fichier et +aucune identité d'élément sélectionné n'est renvoyé. + +`canApply` à vrai avec `willChange` à faux signifie que l'opération réussit sans rien changer +dans le document client géré, par exemple appliquer ce qui est déjà appliqué. + +Une modification de profil Aside enregistre tout de même une chose dans ce cas : la confirmation +enregistre la préférence de synchronisation du profil avant de toucher au moindre document client. +Désactiver un profil dont le bloc géré est déjà absent enregistre donc la préférence et laisse le +document et son historique intacts. + +`integration_preview_unavailable` indique qu'aucune liste de modèles utilisable n'est actuellement +conservée : un proxy qui vient de démarrer est un cas, une liste abandonnée parce que la +configuration ou le cache de fournisseurs a changé en est un autre. Lire +`GET /api/client-integrations` en établit une lorsque la découverte réussit et que la +configuration peut être identifiée ; c'est le remède habituel, pas une garantie. + +## Confirmer une modification prévisualisée + +Les routes de modification acceptent `operation` et `planFingerprint` à côté de leur corps +habituel. Envoyez les deux ou aucun : une requête n'en portant qu'un est rejetée, tout comme une +requête dont l'`operation` contredit la modification demandée. Une liaison Aside porte sur un seul +profil, car une empreinte ne peut pas décrire plusieurs fichiers qui changent indépendamment. + +Le serveur replanifie avant d'écrire et renvoie `409 integration_preview_stale` avec un `plan` +recalculé lorsque la confirmation ne décrit plus ce qui se produirait. Décidez de nouveau d'après +ce plan ; la requête n'est pas réessayée automatiquement. + +Une empreinte est une vérification optimiste, jamais une autorisation. L'authentification de l'API +d'administration et les règles de propriété décident seules si une modification peut avoir lieu. + La suppression ajoute une pierre tombale au lieu de réécrire le journal. Le serveur protège l'opération la plus récente de chaque client afin de conserver le point d'annulation actuel. diff --git a/docs-site/src/content/docs/ja/reference/management-api.md b/docs-site/src/content/docs/ja/reference/management-api.md index 52521cf68c4..3aab78aa88c 100644 --- a/docs-site/src/content/docs/ja/reference/management-api.md +++ b/docs-site/src/content/docs/ja/reference/management-api.md @@ -82,6 +82,49 @@ Authorization: Bearer | `GET /api/client-integrations/journal?client=...` | ロールバック操作を一覧表示します。任意でクライアントを指定でき、各行にはサーバー計算の `deletable` が含まれます。 | 400 無効なクライアント | | `DELETE /api/client-integrations/journal?opId=...` | 古いロールバック操作を廃止し、可能ならスナップショットも削除します。成功時の `snapshotRemoved` が `false` の場合、保守処理で再試行されます。 | 400 `opId` なし、404 存在しないか廃止済み、409 そのクライアントの最新操作 | +## 統合変更のプレビュー + +プレビューは変更を適用せずに内容だけを示します。スナップショットも所有権記録もジャーナルも +ロックも復旧も、いっさい書き込みません。 + +| メソッドとパス | 目的 | 主なエラー | +| --- | --- | --- | +| `POST /api/client-integrations/preview` | クライアント 1 つの `apply`、`overwrite`、`disable` を計画します。本文は `{ "clientId": "...", "operation": "..." }` | 400 不正なクライアントまたは操作、400 `invalid_aside_profile_path`、409 `integration_preview_unavailable` | +| `POST /api/client-integrations/restore/preview` | 取り消しを計画します。本文は `{ "opId": "...", "confirmDrift": false }` | 404 該当操作なし、400 `invalid_aside_profile_path`、409 `integration_preview_unavailable` | +| `POST /api/client-integrations/aside/profiles/{profileId}/preview` | Aside プロファイル 1 つの変更を計画します。`restore` には `opId` が必要です | 400 不正な本文またはプロファイル未指定、404 該当プロファイルまたは操作なし、409 `integration_preview_unavailable` | + +計画には `version`、`clientId`、`operation`、`state`、`foreignEdit`、`kind` と `path` の組からなる +`changes`、不透明な `fingerprint`、`canApply`、`willChange` が含まれ、`refusalReason` と +`profileId` は任意です。パスは管理対象スキーマのパスか、`$snapshot`、`$ownership`、`$journal` +の固定表記で、実行時に決まる位置は `*` になります。設定値やファイルの場所、選ばれた項目の +名前は返しません。 + +`canApply` が真で `willChange` が偽なら、操作は成功しますが管理対象のクライアント文書は何も +変わりません。すでに適用済みのものを適用した場合などです。 + +Aside プロファイルの変更はこの場合でも一つだけ保存します。確認を送ると、クライアント文書に触れる +前にそのプロファイルの同期設定が記録されるため、管理ブロックがすでにないプロファイルを無効にすると +設定だけが保存され、文書とその履歴はそのまま残ります。 + +`integration_preview_unavailable` は今使えるモデル一覧がないという意味です。プロキシを起動した +直後もそうですし、設定やプロバイダーキャッシュが変わって以前の一覧を破棄した場合もそうです。 +`GET /api/client-integrations` を読むと、取得に成功し設定を特定できたときに用意されるので、 +通常はこれで解決しますが、必ず用意されるとはかぎりません。 + +## プレビューした変更の確定 + +変更要求の本文に `operation` と `planFingerprint` を一緒に送ります。両方送るか両方省くかの +どちらかで、片方だけ、または要求と異なる操作を書いた場合は拒否します。Aside はプロファイル +1 つにしか結び付けられません。1 つのフィンガープリントで複数ファイルの変更は説明できないから +です。 + +サーバーは書き込む前に計画し直し、確認した内容がもう当てはまらなければ新しい計画を添えて +`409 integration_preview_stale` を返します。自動で再試行はしないので、新しい計画を見て判断し +直してください。 + +フィンガープリントは楽観的な確認であって権限ではありません。変更してよいかどうかは管理 API の +認証と所有権の規則が決めます。 + 削除時はジャーナルを書き換えず、トゥームストーンを追記します。現在の取り消し地点を 残すため、各クライアントの最新操作はサーバー側で保護されます。 diff --git a/docs-site/src/content/docs/ko/reference/management-api.md b/docs-site/src/content/docs/ko/reference/management-api.md index 0f277092406..eb153c9caeb 100644 --- a/docs-site/src/content/docs/ko/reference/management-api.md +++ b/docs-site/src/content/docs/ko/reference/management-api.md @@ -86,6 +86,48 @@ Authorization: Bearer | `GET /api/client-integrations/journal?client=...` | 롤백 작업을 조회합니다. 특정 클라이언트로 제한할 수 있으며 각 행에는 서버가 계산한 `deletable` 값이 포함됩니다. | 400 잘못된 클라이언트 | | `DELETE /api/client-integrations/journal?opId=...` | 이전 롤백 작업을 폐기하고 가능한 경우 스냅샷도 제거합니다. 성공 응답의 `snapshotRemoved`가 `false`면 유지보수 재시도를 위해 정리 작업이 보존됩니다. | 400 `opId` 누락, 404 없거나 이미 폐기된 작업, 409 해당 클라이언트의 최신 작업 | +## 통합 변경 미리 보기 + +미리 보기는 변경 내용을 적용하지 않고 보여 줍니다. 스냅샷도, 소유권 기록도, 저널도, 잠금도, +복구도 남기지 않습니다. + +| 메서드 및 경로 | 목적 | 주요 오류 | +| --- | --- | --- | +| `POST /api/client-integrations/preview` | 클라이언트 하나의 `apply`, `overwrite`, `disable`을 계획합니다. 본문은 `{ "clientId": "...", "operation": "..." }` | 400 잘못된 클라이언트나 작업, 400 `invalid_aside_profile_path`, 409 `integration_preview_unavailable` | +| `POST /api/client-integrations/restore/preview` | 실행 취소를 계획합니다. 본문은 `{ "opId": "...", "confirmDrift": false }` | 404 없는 작업, 400 `invalid_aside_profile_path`, 409 `integration_preview_unavailable` | +| `POST /api/client-integrations/aside/profiles/{profileId}/preview` | Aside 프로필 하나의 변경을 계획합니다. `restore`에는 `opId`가 필요합니다 | 400 잘못된 본문이나 프로필 미지정, 404 없는 프로필이나 작업, 409 `integration_preview_unavailable` | + +계획에는 `version`, `clientId`, `operation`, `state`, `foreignEdit`, `kind`와 `path` 쌍으로 이루어진 +`changes`, 불투명한 `fingerprint`, `canApply`, `willChange`가 담기고 `refusalReason`과 +`profileId`는 선택 항목입니다. 경로는 관리 대상 스키마 경로이거나 `$snapshot`, `$ownership`, +`$journal` 표식이며, 실행 중에 정해지는 자리는 `*`로 적습니다. 설정 값이나 파일 위치, 선택된 +항목의 이름은 돌려주지 않습니다. + +`canApply`가 참인데 `willChange`가 거짓이면 작업은 성공하지만 관리 대상 클라이언트 문서에서는 +아무것도 바뀌지 않습니다. 이미 적용된 것을 다시 적용하는 경우가 그렇습니다. + +Aside 프로필 변경은 이때도 한 가지를 저장합니다. 확인을 보내면 클라이언트 문서에 손대기 전에 그 +프로필의 동기화 설정이 먼저 기록되므로, 관리 블록이 이미 없는 프로필을 끄면 설정만 저장되고 +문서와 기록은 그대로 남습니다. + +`integration_preview_unavailable`은 지금 쓸 수 있는 모델 목록이 없다는 뜻입니다. 프록시를 막 +시작했을 때도 그렇고, 설정이나 공급자 캐시가 바뀌어 기존 목록을 버린 경우에도 그렇습니다. +`GET /api/client-integrations`를 읽으면 조회가 성공하고 설정을 확인할 수 있을 때 목록이 +준비되므로, 보통은 이렇게 해결되지만 항상 보장되지는 않습니다. + +## 미리 본 변경 확정하기 + +변경 요청 본문에 `operation`과 `planFingerprint`를 함께 보냅니다. 둘 다 보내거나 둘 다 +생략해야 하며, 하나만 보내거나 요청과 다른 작업을 적으면 거부합니다. Aside는 프로필 하나에만 +묶을 수 있습니다. 지문 하나로 여러 파일의 변경을 설명할 수는 없기 때문입니다. + +서버는 쓰기 전에 다시 계획해 보고, 확인한 내용이 더 이상 맞지 않으면 새 계획을 담아 +`409 integration_preview_stale`을 돌려줍니다. 자동으로 다시 시도하지 않으니 새 계획을 보고 +다시 결정하면 됩니다. + +지문은 낙관적 확인일 뿐 권한이 아닙니다. 변경을 허용할지는 관리 인증과 소유권 규칙이 +결정합니다. + 삭제는 저널을 다시 쓰지 않고 툼스톤을 추가합니다. 현재 실행 취소 지점을 유지하기 위해 각 클라이언트의 최신 작업은 서버에서 삭제하지 못하게 보호합니다. diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 3a30ed72c8f..4f47ad8bbc1 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -124,6 +124,51 @@ nonnegative integer account ID returned by the profile list; it is not a browser | `POST /api/client-integrations/aside/profiles/{profileId}/restore` | Undo an operation using `{ "opId": "..." }`; optional `confirmDrift: true` permits replacing later edits | 404 missing operation/profile; 409 busy, mismatch, or required drift confirmation; 410 expired snapshot; 500 save/write failure | | `POST /api/client-integrations/aside/sync` | Refresh enabled profiles through the server's mutation owner; body `{}` | 400 nonempty body/profile selector; 409 busy; 207 per-profile refusals | +## Previewing an integration change + +A preview reports what a change would do without doing it. These routes write nothing: no +snapshot, no ownership record, no journal row, no lock, and no maintenance or recovery. + +| Route | Purpose | Notable responses | +| --- | --- | --- | +| `POST /api/client-integrations/preview` | Plan `apply`, `overwrite` or `disable` for one client; body `{ "clientId": "...", "operation": "..." }` | 400 invalid client or operation; 400 `invalid_aside_profile_path`; 409 `integration_preview_unavailable` | +| `POST /api/client-integrations/restore/preview` | Plan an undo; body `{ "opId": "...", "confirmDrift": false }` | 404 unknown operation; 400 `invalid_aside_profile_path`; 409 `integration_preview_unavailable` | +| `POST /api/client-integrations/aside/profiles/{profileId}/preview` | Plan one Aside profile's change, including `restore` with an `opId` | 400 invalid body or unscoped request; 404 unknown profile or operation, 409 `integration_preview_unavailable` | + +A plan carries `version`, `clientId`, `operation`, `state`, `foreignEdit`, a `changes` list of +`kind` and `path` pairs, an opaque `fingerprint`, `canApply`, `willChange`, and an optional +`refusalReason` and `profileId`. Paths are managed schema paths or the fixed `$snapshot`, +`$ownership` and `$journal` markers; a position chosen at runtime appears as `*`. No +configuration value, file location or selected member identity is returned. + +`canApply: true` with `willChange: false` means the operation succeeds and changes nothing in +the managed client document, such as applying what is already applied. + +An Aside profile change still saves one thing in that case. Confirming it records the desired sync +preference for that profile before any client document is touched, so disabling a profile whose +managed block is already absent stores the preference and leaves the document and its history +untouched. + +`integration_preview_unavailable` means no usable model roster is currently retained. A freshly +started proxy is one way to be in that state; a roster retired because the configuration or the +provider cache moved is another. Reading `GET /api/client-integrations` establishes one when +discovery succeeds and the configuration can be identified, so it is the usual remedy rather than +a guarantee. + +## Confirming a previewed change + +Mutation routes accept `operation` and `planFingerprint` alongside their existing body. Send both +or neither: a request carrying one is rejected, as is one whose `operation` disagrees with the +change being requested. Aside bindings apply to a single profile, because one fingerprint cannot +describe several independently changing files. + +The server re-plans before writing anything and returns `409 integration_preview_stale` with a +freshly computed `plan` when the confirmation no longer describes what would happen. Decide again +against the new plan; the request is not retried automatically. + +A fingerprint is an optimistic check, never authorization. Management authentication and every +ownership rule still decide whether a change may happen at all. + Bulk PUT returns `{ ok, clientId, changed, state, message, results }`; each result identifies its `profileId` and reports the writer outcome. Sync returns `{ ok, clientId, results }`, with per-profile refresh outcomes. Both return HTTP 200 when all returned attempts succeed and diff --git a/docs-site/src/content/docs/ru/reference/management-api.md b/docs-site/src/content/docs/ru/reference/management-api.md index a4c78d53288..793b6e9a612 100644 --- a/docs-site/src/content/docs/ru/reference/management-api.md +++ b/docs-site/src/content/docs/ru/reference/management-api.md @@ -104,6 +104,51 @@ GUI-сессия в стиле loopback не выпускается. | `GET /api/client-integrations/journal?client=...` | Получить операции отката, при необходимости только для одного клиента. Каждая запись содержит вычисленный сервером флаг `deletable`. | 400 неверный клиент | | `DELETE /api/client-integrations/journal?opId=...` | Исключить старую операцию отката и по возможности удалить её снимок. В успешном ответе `snapshotRemoved: false` означает, что очистка сохранена для повторной попытки обслуживания. | 400 нет `opId`; 404 операция отсутствует или уже исключена; 409 последняя операция клиента | +## Предварительный просмотр изменения интеграции + +Предпросмотр показывает, что сделало бы изменение, не делая его. Эти маршруты ничего не пишут: +ни снимка, ни записи о владении, ни строки журнала, ни блокировки, ни обслуживания, ни +восстановления. + +| Метод и путь | Назначение | Основные ошибки | +| --- | --- | --- | +| `POST /api/client-integrations/preview` | Спланировать `apply`, `overwrite` или `disable` для одного клиента; тело `{ "clientId": "...", "operation": "..." }` | 400 неверный клиент или операция; 400 `invalid_aside_profile_path`; 409 `integration_preview_unavailable` | +| `POST /api/client-integrations/restore/preview` | Спланировать отмену; тело `{ "opId": "...", "confirmDrift": false }` | 404 операция не найдена; 400 `invalid_aside_profile_path`; 409 `integration_preview_unavailable` | +| `POST /api/client-integrations/aside/profiles/{profileId}/preview` | Спланировать изменение одного профиля Aside; для `restore` нужен `opId` | 400 неверное тело или профиль не указан; 404 профиль или операция не найдены; 409 `integration_preview_unavailable` | + +План содержит `version`, `clientId`, `operation`, `state`, `foreignEdit`, список `changes` из пар +`kind` и `path`, непрозрачный `fingerprint`, `canApply` и `willChange`; `refusalReason` и +`profileId` необязательны. Пути — это управляемые пути схемы либо фиксированные метки +`$snapshot`, `$ownership` и `$journal`; позиция, определяемая во время работы, показывается как +`*`. Ни значения конфигурации, ни расположение файлов, ни имя выбранного элемента не возвращаются. + +`canApply` истинно при `willChange` ложном означает, что операция завершится успешно и ничего не +изменит в управляемом клиентском документе, например повторное применение уже применённого. + +Изменение профиля Aside в этом случае всё же кое-что сохраняет: подтверждение записывает настройку +синхронизации профиля до того, как будет затронут хоть один клиентский документ. Поэтому отключение +профиля, управляемый блок которого уже отсутствует, сохраняет настройку и оставляет документ и его +историю нетронутыми. + +`integration_preview_unavailable` означает, что сейчас нет пригодного списка моделей: только что +запущенный прокси — один такой случай, список, отброшенный из-за изменения конфигурации или кэша +провайдеров, — другой. Чтение `GET /api/client-integrations` формирует его, когда обнаружение +прошло успешно и конфигурацию удалось определить; это обычное решение, а не гарантия. + +## Подтверждение просмотренного изменения + +Маршруты изменения принимают `operation` и `planFingerprint` рядом с обычным телом. Отправляйте +оба или ни одного: запрос только с одним отклоняется, как и запрос, `operation` которого не +совпадает с запрошенным изменением. Привязка Aside относится к одному профилю, поскольку один +отпечаток не может описать несколько независимо меняющихся файлов. + +Перед записью сервер планирует заново и возвращает `409 integration_preview_stale` с заново +вычисленным `plan`, когда подтверждение больше не описывает происходящее. Примите решение +заново по новому плану; запрос не повторяется автоматически. + +Отпечаток — это оптимистичная проверка, а не разрешение. Возможность изменения по-прежнему +определяют аутентификация управляющего API и правила владения. + Удаление добавляет отметку вместо перезаписи журнала. Сервер защищает последнюю операцию каждого клиента, чтобы сохранить текущую точку отмены. diff --git a/docs-site/src/content/docs/tr/reference/management-api.md b/docs-site/src/content/docs/tr/reference/management-api.md index e2c0a962787..63beca196dc 100644 --- a/docs-site/src/content/docs/tr/reference/management-api.md +++ b/docs-site/src/content/docs/tr/reference/management-api.md @@ -110,6 +110,49 @@ bakın. | `GET /api/client-integrations/journal?client=...` | Geri alma işlemlerini, isteğe bağlı olarak tek bir istemci için listeler. Her satır sunucunun hesapladığı `deletable` alanını içerir. | 400 geçersiz istemci | | `DELETE /api/client-integrations/journal?opId=...` | Eski bir geri alma işlemini kullanımdan kaldırır ve mümkünse anlık görüntüsünü siler. Başarılı yanıtta `snapshotRemoved: false`, temizliğin bakım yeniden denemesi için saklandığını belirtir. | 400 eksik `opId`; 404 bulunmayan veya zaten kaldırılmış işlem; 409 istemcinin en yeni işlemi | +## Entegrasyon değişikliğini önizleme + +Önizleme, bir değişikliğin ne yapacağını yapmadan gösterir. Bu yollar hiçbir şey yazmaz: anlık +görüntü, sahiplik kaydı, günlük satırı, kilit, bakım ve kurtarma yoktur. + +| Yöntem ve yol | Amaç | Önemli hatalar | +| --- | --- | --- | +| `POST /api/client-integrations/preview` | Tek bir istemci için `apply`, `overwrite` veya `disable` planlar; gövde `{ "clientId": "...", "operation": "..." }` | 400 geçersiz istemci veya işlem; 400 `invalid_aside_profile_path`; 409 `integration_preview_unavailable` | +| `POST /api/client-integrations/restore/preview` | Bir geri almayı planlar; gövde `{ "opId": "...", "confirmDrift": false }` | 404 işlem bulunamadı; 400 `invalid_aside_profile_path`; 409 `integration_preview_unavailable` | +| `POST /api/client-integrations/aside/profiles/{profileId}/preview` | Tek bir Aside profilinin değişikliğini planlar; `restore` için `opId` gerekir | 400 geçersiz gövde veya profil belirtilmemiş; 404 profil veya işlem bulunamadı; 409 `integration_preview_unavailable` | + +Plan; `version`, `clientId`, `operation`, `state`, `foreignEdit`, `kind` ve `path` çiftlerinden +oluşan bir `changes` listesi, opak bir `fingerprint`, `canApply` ve `willChange` içerir; +`refusalReason` ile `profileId` isteğe bağlıdır. Yollar ya yönetilen şema yollarıdır ya da sabit +`$snapshot`, `$ownership` ve `$journal` işaretleridir; çalışma sırasında belirlenen bir konum `*` +olarak görünür. Hiçbir yapılandırma değeri, dosya konumu veya seçilen öğenin adı döndürülmez. + +`canApply` doğru ve `willChange` yanlışsa işlem başarılı olur ama yönetilen istemci belgesinde +hiçbir şey değişmez; örneğin zaten uygulanmış olanı yeniden uygulamak. + +Aside profil değişikliği bu durumda yine de bir şey kaydeder: onay, herhangi bir istemci belgesine +dokunulmadan önce o profilin eşitleme tercihini yazar. Bu yüzden yönetilen bloğu zaten bulunmayan +bir profili kapatmak yalnızca tercihi saklar, belgeyi ve geçmişini olduğu gibi bırakır. + +`integration_preview_unavailable`, şu anda kullanılabilir bir model listesi tutulmadığını belirtir: +yeni başlamış bir vekil bunun bir hâlidir, yapılandırma ya da sağlayıcı önbelleği değiştiği için +bırakılmış bir liste de öyle. `GET /api/client-integrations` okunduğunda keşif başarılı olur ve +yapılandırma belirlenebilirse liste oluşur; bu her zamanki çözümdür, bir güvence değildir. + +## Önizlenen değişikliği onaylama + +Değişiklik yolları, olağan gövdenin yanında `operation` ve `planFingerprint` kabul eder. İkisini +birlikte gönderin ya da hiçbirini: yalnızca birini taşıyan bir istek reddedilir, `operation` değeri +istenen değişiklikle çelişen bir istek de öyle. Aside bağlaması tek bir profile içindir; çünkü bir +parmak izi birbirinden bağımsız değişen birden çok dosyayı anlatamaz. + +Sunucu yazmadan önce yeniden planlar ve onay artık olacak olanı anlatmıyorsa yeniden hesaplanmış +bir `plan` ile `409 integration_preview_stale` döndürür. Yeni plana bakarak yeniden karar verin; +istek kendiliğinden yinelenmez. + +Parmak izi iyimser bir denetimdir, yetkilendirme değildir. Bir değişikliğin yapılıp +yapılamayacağına yönetim API kimlik doğrulaması ve sahiplik kuralları karar verir. + Silme, günlüğü yeniden yazmak yerine bir silme kaydı ekler. Geçerli geri alma noktasını korumak için her istemcinin en yeni işlemi sunucu tarafında korunur. diff --git a/docs-site/src/content/docs/zh-cn/reference/management-api.md b/docs-site/src/content/docs/zh-cn/reference/management-api.md index b6fc1006e3b..cc4396173f6 100644 --- a/docs-site/src/content/docs/zh-cn/reference/management-api.md +++ b/docs-site/src/content/docs/zh-cn/reference/management-api.md @@ -86,6 +86,43 @@ Authorization: Bearer | `GET /api/client-integrations/journal?client=...` | 列出回滚操作,也可限定为单个客户端。每一项都包含由服务器计算的 `deletable` 字段。 | 400 客户端无效 | | `DELETE /api/client-integrations/journal?opId=...` | 停用一条较旧的回滚操作,并在可能时删除其快照。成功响应中的 `snapshotRemoved: false` 表示清理任务已保留,等待维护重试。 | 400 缺少 `opId`;404 操作不存在或已停用;409 该客户端的最新操作 | +## 预览集成变更 + +预览只展示变更会做什么,而不会执行它。这些路由不写入任何内容:不留快照、不留归属记录、不留 +日志行、不加锁、不做维护和恢复。 + +| 方法与路径 | 用途 | 主要错误 | +| --- | --- | --- | +| `POST /api/client-integrations/preview` | 为一个客户端规划 `apply`、`overwrite` 或 `disable`;请求体为 `{ "clientId": "...", "operation": "..." }` | 400 客户端或操作无效;400 `invalid_aside_profile_path`;409 `integration_preview_unavailable` | +| `POST /api/client-integrations/restore/preview` | 规划一次撤销;请求体为 `{ "opId": "...", "confirmDrift": false }` | 404 操作不存在;400 `invalid_aside_profile_path`;409 `integration_preview_unavailable` | +| `POST /api/client-integrations/aside/profiles/{profileId}/preview` | 规划单个 Aside 配置档的变更;`restore` 需要 `opId` | 400 请求体无效或未指定配置档;404 配置档或操作不存在;409 `integration_preview_unavailable` | + +计划包含 `version`、`clientId`、`operation`、`state`、`foreignEdit`,由 `kind` 与 `path` 组成的 +`changes` 列表,不透明的 `fingerprint`,以及 `canApply` 和 `willChange`;`refusalReason` 与 +`profileId` 为可选。路径要么是受管理的结构路径,要么是 `$snapshot`、`$ownership`、`$journal` +这三个固定标记;运行时才确定的位置显示为 `*`。不会返回任何配置值、文件位置或所选条目的名称。 + +`canApply` 为真而 `willChange` 为假,表示操作会成功,但受管理的客户端文档不会有任何改动,例如 +重复应用已经应用过的内容。 + +Aside 配置档的变更在这种情况下仍会保存一件事:确认之后,会先记录该配置档的同步偏好,然后才去动 +客户端文档。因此关闭一个受管理区块已经不存在的配置档,只会保存偏好,文档及其历史保持不变。 + +`integration_preview_unavailable` 表示当前没有可用的模型清单:代理刚启动是一种情况,因配置或 +提供方缓存变化而弃用了原有清单也是一种情况。读取 `GET /api/client-integrations` 会在探测成功 +且能确认配置时建立清单,因此这通常是解决办法,但并非必然建立。 + +## 确认已预览的变更 + +变更路由接受与原有请求体并列的 `operation` 和 `planFingerprint`。两者要么都发送,要么都省略: +只带其中一个会被拒绝,`operation` 与所请求的变更不一致也会被拒绝。Aside 的绑定只针对单个配置 +档,因为一个指纹无法描述多个各自独立变化的文件。 + +服务器在写入前会重新规划,若确认的内容已不再符合实际,会返回 `409 integration_preview_stale` +并附上重新计算的 `plan`。请根据新计划再做一次决定;请求不会自动重试。 + +指纹只是乐观校验,不是授权。变更是否被允许,仍由管理 API 的认证和归属规则决定。 + 删除操作会追加墓碑记录,而不会重写日志。服务器会保护每个客户端的最新操作, 以保留当前的撤销点。 diff --git a/docs-site/src/content/docs/zh-tw/reference/management-api.md b/docs-site/src/content/docs/zh-tw/reference/management-api.md index 353b09305e8..10094e71816 100644 --- a/docs-site/src/content/docs/zh-tw/reference/management-api.md +++ b/docs-site/src/content/docs/zh-tw/reference/management-api.md @@ -82,6 +82,43 @@ Session 簽發在需要 data-plane 認證時停用,這包含遠端綁定。遠 | `GET /api/client-integrations/journal?client=...` | 列出復原操作,也可限定為單一用戶端。每一項都包含由伺服器計算的 `deletable` 欄位。 | 400 用戶端無效 | | `DELETE /api/client-integrations/journal?opId=...` | 停用一筆較舊的復原操作,並在可能時刪除其快照。成功回應中的 `snapshotRemoved: false` 表示清理工作已保留,等待維護重試。 | 400 缺少 `opId`;404 操作不存在或已停用;409 該用戶端的最新操作 | +## 預覽整合變更 + +預覽只呈現變更會做什麼,而不會執行。這些路由不寫入任何內容:不留快照、不留歸屬紀錄、不留 +日誌列、不加鎖、不做維護與復原。 + +| 方法與路徑 | 用途 | 主要錯誤 | +| --- | --- | --- | +| `POST /api/client-integrations/preview` | 為單一用戶端規劃 `apply`、`overwrite` 或 `disable`;請求內容為 `{ "clientId": "...", "operation": "..." }` | 400 用戶端或操作無效;400 `invalid_aside_profile_path`;409 `integration_preview_unavailable` | +| `POST /api/client-integrations/restore/preview` | 規劃一次復原;請求內容為 `{ "opId": "...", "confirmDrift": false }` | 404 操作不存在;400 `invalid_aside_profile_path`;409 `integration_preview_unavailable` | +| `POST /api/client-integrations/aside/profiles/{profileId}/preview` | 規劃單一 Aside 設定檔的變更;`restore` 需要 `opId` | 400 請求內容無效或未指定設定檔;404 設定檔或操作不存在;409 `integration_preview_unavailable` | + +計畫包含 `version`、`clientId`、`operation`、`state`、`foreignEdit`,由 `kind` 與 `path` 組成的 +`changes` 清單,不透明的 `fingerprint`,以及 `canApply` 與 `willChange`;`refusalReason` 與 +`profileId` 為選用。路徑要麼是受管理的結構路徑,要麼是 `$snapshot`、`$ownership`、`$journal` +這三個固定標記;執行時才決定的位置會顯示為 `*`。不會回傳任何設定值、檔案位置或所選項目的名稱。 + +`canApply` 為真而 `willChange` 為假,表示操作會成功,但受管理的用戶端文件不會有任何變動,例如 +重複套用已經套用過的內容。 + +Aside 設定檔的變更在這種情況下仍會儲存一件事:確認之後,會先記錄該設定檔的同步偏好,之後才會動 +用戶端文件。因此關閉一個受管理區塊已經不存在的設定檔,只會儲存偏好,文件與其歷史維持不變。 + +`integration_preview_unavailable` 表示目前沒有可用的模型清單:代理剛啟動是一種情況,因設定或 +供應方快取變動而捨棄原有清單也是一種情況。讀取 `GET /api/client-integrations` 會在探測成功 +且能確認設定時建立清單,因此這通常是解決方式,但並非必然建立。 + +## 確認已預覽的變更 + +變更路由接受與原有請求內容並列的 `operation` 與 `planFingerprint`。兩者要麼都送出,要麼都省略: +只帶其中一個會被拒絕,`operation` 與所請求的變更不一致也會被拒絕。Aside 的綁定只針對單一設定 +檔,因為一個指紋無法描述多個各自獨立變動的檔案。 + +伺服器在寫入前會重新規劃,若確認的內容已不再符合實際,會回傳 `409 integration_preview_stale` +並附上重新計算的 `plan`。請依新計畫再做一次決定;請求不會自動重試。 + +指紋只是樂觀檢查,不是授權。變更是否被允許,仍由管理 API 的驗證與歸屬規則決定。 + 刪除操作會附加墓碑記錄,而不會重寫日誌。伺服器會保護每個用戶端的最新操作, 以保留目前的復原點。 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 1d3b191fbc5..4aa3ff909cb 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -821,6 +821,7 @@ "integrations-merge.test.ts": "clients", "integrations-serialize.test.ts": "clients", "integrations-state.test.ts": "clients", + "integrations-writer-frozen-config.test.ts": "clients", "integrations-writer.test.ts": "clients", "interactive-confirm.test.ts": "cli", "issue-452-empty-503.test.ts": "codex-integration", @@ -930,6 +931,8 @@ "management-client-config-route.test.ts": "server", "management-integration-journal-delete.test.ts": "server", "management-integration-routes.test.ts": "server", + "management-model-roster-gather-race.test.ts": "server", + "management-model-roster.test.ts": "server", "management-origin-tls.test.ts": "server", "management-provider-pinsless-validation.test.ts": "server", "management-provider-proto-override.test.ts": "server", @@ -970,6 +973,7 @@ "muse-spark-web-search-compat.test.ts": "providers", "muse-subscription-usage.test.ts": "providers", "muse-tool-name-alias.test.ts": "providers", + "mutation-plan.test.ts": "clients", "namespace-tool-compat.test.ts": "responses", "native-alias-maintainer-regressions.test.ts": "codex-integration", "native-claude-code-toggle.test.ts": "codex-integration", diff --git a/src/codex/catalog/provider-models.ts b/src/codex/catalog/provider-models.ts index ced0cb939ed..21451f1eb5c 100644 --- a/src/codex/catalog/provider-models.ts +++ b/src/codex/catalog/provider-models.ts @@ -11,6 +11,7 @@ import { clearModelCache, clearProviderDiscoveryStatus, captureModelCacheGeneration, + observeModelCacheRevision, DEFAULT_MODEL_CACHE_TTL_MS, getFreshCached, getStaleCached, @@ -108,6 +109,14 @@ import { captureProviderGather, materializeCapturedHeaders } from "./gather-capt export interface ProviderModelsResult { readonly models: CatalogModel[]; readonly outcome: CatalogGatherProviderModelOutcome; + /** + * The provider's cache content revision as it stood when these rows were chosen. + * + * Sampled here rather than by the caller because only this point is synchronous with the + * choice. A caller reading it after its own await can observe a revision another flight + * published in between, and would then record these rows under that flight's identity. + */ + readonly contentRevision: string; } export const refreshingModelsAuthResolver: ModelsAuthResolver = { kind: "refreshing" }; @@ -145,7 +154,11 @@ export async function fetchProviderModelsWithAuth( const observed = ( models: CatalogModel[], state: CatalogGatherProviderModelOutcome["state"], - ): ProviderModelsResult => ({ models, outcome: { provider: name, state } }); + ): ProviderModelsResult => ({ + models, + outcome: { provider: name, state }, + contentRevision: observeModelCacheRevision(name), + }); // Capture before any credential refresh or outbound await. OAuth account changes clear this // generation, so a request started with the former account cannot later publish its result. const cacheGeneration = captureModelCacheGeneration(name); diff --git a/src/codex/catalog/routed-gather.ts b/src/codex/catalog/routed-gather.ts index c610ab45d41..24ef744dc56 100644 --- a/src/codex/catalog/routed-gather.ts +++ b/src/codex/catalog/routed-gather.ts @@ -113,6 +113,15 @@ export interface GatherRoutedModelsOptions { providerModelOutcomes?: CatalogGatherProviderModelOutcome[]; /** Internal convergence sink for the immutable policy that produced the returned rows. */ discoveryPolicySnapshots?: CatalogProviderDiscoveryPolicySnapshot[]; + /** + * Each provider's cache content revision as of the moment its rows were chosen. + * + * A caller that derives something from these rows and wants to know later whether the rows are + * still current must use this, not a revision sampled after the gather returns: another flight + * can publish between the choice and the sample, and the derived value would then carry that + * flight's identity while holding these rows. + */ + providerContentRevisions?: Map; } interface GatherFlightResult { @@ -121,6 +130,7 @@ interface GatherFlightResult { providerAuthOutcomes: readonly CatalogGatherProviderAuthOutcome[]; providerModelOutcomes: readonly CatalogGatherProviderModelOutcome[]; discoveryPolicySnapshots: readonly CatalogProviderDiscoveryPolicySnapshot[]; + providerContentRevisions: ReadonlyMap; } interface GatherInflightEntry { readonly discoveryPolicyIdentity: string; @@ -256,6 +266,7 @@ async function gatherRoutedModelsWithAuth( providerAuthOutcomes, providerModelOutcomes, discoveryPolicySnapshots, + providerContentRevisions, } = await entry.promise; if (options?.comboOmissions) { options.comboOmissions.length = 0; @@ -269,6 +280,12 @@ async function gatherRoutedModelsWithAuth( options.providerModelOutcomes.length = 0; options.providerModelOutcomes.push(...providerModelOutcomes); } + if (options?.providerContentRevisions) { + options.providerContentRevisions.clear(); + for (const [provider, revision] of providerContentRevisions) { + options.providerContentRevisions.set(provider, revision); + } + } if (options?.discoveryPolicySnapshots) { options.discoveryPolicySnapshots.length = 0; options.discoveryPolicySnapshots.push(...discoveryPolicySnapshots); @@ -763,6 +780,8 @@ async function gatherRoutedModelsUncached( providerAuthOutcomes: localProviderAuthOutcomes, providerModelOutcomes, discoveryPolicySnapshots: capture.discoveryPolicySnapshots, + // Stamped by each provider at the moment it chose its rows, not sampled here. + providerContentRevisions: new Map(providerResults.map(result => [result.outcome.provider, result.contentRevision])), }; } diff --git a/src/codex/model-cache.ts b/src/codex/model-cache.ts index fe790715fed..c69d15abc9e 100644 --- a/src/codex/model-cache.ts +++ b/src/codex/model-cache.ts @@ -71,6 +71,8 @@ function deleteCachedProvider(provider: string): number { cache.delete(provider); cacheBytes = Math.max(0, cacheBytes - entry.sizeBytes); if (oldestCachedProvider === provider) recomputeOldestCachedProvider(); + // A removal changes this provider's content as surely as a publication does. + bumpProviderCacheRevision(provider); return entry.sizeBytes; } @@ -177,6 +179,34 @@ export function isModelCacheGenerationCurrent(provider: string, generation: stri return generation === captureModelCacheGeneration(provider); } +/** + * How many times this provider's cached content has actually changed. + * + * Deliberately separate from the generation. A generation revokes an in-flight discovery's right + * to publish, so it advances on an authority clear and must not be repurposed: moving it on a + * successful publication would cancel writes that are still legitimate. This counts accepted + * publications and removals instead, which is what a reader holding a derived roster needs to + * know, and it advances on exactly the event a generation does not: a discovery that succeeded + * and changed the rows. + * + * Reading is passive. An unseen provider reads as `0` rather than seeding an entry, so observing + * one cannot alter what a later capture or publication sees. + */ +const providerCacheRevisions = new Map(); +let globalContentRevision = 0; + +function bumpProviderCacheRevision(provider: string): void { + providerCacheRevisions.set(provider, (providerCacheRevisions.get(provider) ?? 0) + 1); +} + +export function observeModelCacheRevision(provider: string): string { + // The global term covers a clear that empties the map wholesale, which per-provider counters + // cannot express: without it, wiping every entry and republishing identical-looking rows would + // read as unchanged. It also survives pruning, so a retired provider cannot come back with a + // counter that matches a roster built before it left. + return `${globalContentRevision}:${providerCacheRevisions.get(provider) ?? 0}`; +} + /** * Store a live result unless the cache was cleared while that asynchronous discovery was running. * The optional generation keeps existing direct cache writers unchanged while discovery callers can @@ -200,6 +230,8 @@ export function setCached( oldestCachedAt = now; } enforceAppOwnedMemoryBudget(); + // Published and accepted, so anything derived from this provider's rows is now out of date. + bumpProviderCacheRevision(provider); return true; } @@ -220,6 +252,11 @@ export function clearModelCache( } else { if (revokesInFlightDiscovery) globalCacheGeneration += 1; cache.clear(); + // A wholesale clear changes every provider's content at once, and clearing the map means no + // per-provider counter can record it. Advancing the global term retires every derived roster + // and lets the per-provider entries be dropped without an ABA on the way back. + globalContentRevision += 1; + providerCacheRevisions.clear(); cacheBytes = 0; oldestCachedProvider = undefined; oldestCachedAt = null; @@ -237,6 +274,7 @@ export function reconcileModelCacheProviders( const removedProviders = new Set(); const trackedProviders = new Set([ ...providerCacheGenerations.keys(), + ...providerCacheRevisions.keys(), ...failureAt.keys(), ...discoveryStatus.keys(), ...liveModelCounts.keys(), @@ -247,6 +285,10 @@ export function reconcileModelCacheProviders( if (validProviders.has(provider)) continue; if (!revokedRemovedProviderAuthority) { globalCacheGeneration += 1; + // Advanced BEFORE any revision entry is dropped, so a provider that comes back cannot + // present the counter a roster was built against. Without this the delete below is an ABA: + // the entry returns at zero and an old stamp matches again. + globalContentRevision += 1; revokedRemovedProviderAuthority = true; } providerCacheGenerations.set(provider, (providerCacheGenerations.get(provider) ?? 0) + 1); @@ -255,6 +297,11 @@ export function reconcileModelCacheProviders( failureAt.delete(provider); discoveryStatus.delete(provider); liveModelCounts.delete(provider); + // AFTER the cache deletion, which bumps this provider's revision and would otherwise recreate + // the entry we just removed. Dropped rather than left behind: a provider the configuration no + // longer has must not keep an entry alive for the life of the process merely because nothing + // cleared the whole cache. The global epoch advanced above, so the removal is not an ABA. + providerCacheRevisions.delete(provider); removedProviders.add(provider); } lastReconciledGeneration = generation; diff --git a/src/config/admitted-identity.ts b/src/config/admitted-identity.ts new file mode 100644 index 00000000000..eaca3be7cc9 --- /dev/null +++ b/src/config/admitted-identity.ts @@ -0,0 +1,222 @@ +import { readConfigAdmissionSnapshot } from "./diagnostics"; +import { getConfigPath } from "./paths"; +import { canonicalPlainData, copyPlainData, isPlainObject, ownDataKeys } from "../lib/plain-data"; +import type { OcxConfig } from "../types"; + +/** + * A configuration a roster may be built from, detached from the object the caller holds. + * + * The detachment is the point. A gather suspends, and while it is suspended a management route can + * edit the resident configuration in place; gathering from one state and projecting from another + * would produce rows that belong to neither. Every authoritative pass uses this copy from + * beginning to end, so what the resident object does in the meantime becomes a question about + * whether the result may be retained rather than a question about what the result is. + * + * What this deliberately does NOT do is require the resident configuration to equal the file on + * disk. The proxy routes by the configuration it is holding, so that is the configuration a + * preview and the mutation it authorizes must both describe. A file the operator has edited and + * the process has not adopted is a supported state rather than a fault: live reconciliation + * (src/config/live-reconcile.ts) merges persisted state while deliberately retaining live changes + * and the active listener binding, and can persist a binding the resident object does not have. + * Demanding equality would make preview permanently unavailable on exactly those configurations + * while proving nothing about the rows, which come from the resident object either way. + */ +export interface ExportConfigAdmission { + readonly config: OcxConfig; +} + +/** + * What was true when an admission was captured, kept here rather than on the admission object. + * + * None of it is data a consumer has any business reading: the file term is a digest of the + * operator's configuration file and the canonical form contains their credentials. Holding it in a + * module WeakMap means an admission can be passed around, and even serialized by a careless + * caller, without carrying any of it. + */ +interface AdmissionEvidence { + readonly path: string; + readonly file: string; + readonly data: string; + readonly executors: ReadonlyMap; +} + +const evidence = new WeakMap(); + +/** + * Detach the configuration a roster is about to be built from, and record what it was. + * + * Two things are recorded because two things can move independently. The resident configuration is + * what the rows are derived from, so its complete structure is captured: structurally rather than + * as a list of the fields that seemed to matter, because such a list is only as complete as + * whoever last thought about it and this one had already missed export-affecting configuration. + * The configuration file is recorded beside it, so a roster does not outlive an operator editing + * the configuration under a process that has not adopted it yet. + * + * Null when the file cannot be read, when it is there but the loader would have had to salvage it, + * and when the configuration object cannot be copied as plain data. Every caller fails closed on + * it: refusing a preview costs an ordinary load, and serving one that describes a configuration + * nobody has costs a file the operator did not ask for. + */ +export function captureExportConfigAdmission(live: OcxConfig): ExportConfigAdmission | null { + const path = getConfigPath(); + const file = admittedFileTerm(); + if (file === null) return null; + const resident = detachConfig(live); + if (resident === null) return null; + const config = withExecutors(resident); + if (config === null) return null; + const admission: ExportConfigAdmission = { config }; + evidence.set(admission, { path, file, data: canonicalPlainData(resident.data), executors: resident.executors }); + return admission; +} + +/** + * Whether an admission still describes the configuration in hand and the file it was taken beside. + * + * Three things are checked because three things can move: the file can be rewritten, the resident + * object can be edited in place, and a consumer of the detached copy can mutate what it was given. + * The last matters as much as the others, because a pass that edited its own input and then + * published would be retaining a roster under a state that no longer describes even that input. + * + * Passive: it reads the configuration file and nothing else. No credential is resolved, no + * provider is contacted, no path is hardened and nothing is written. + */ +export function isExportConfigAdmissionCurrent(admission: ExportConfigAdmission, live: OcxConfig): boolean { + const captured = evidence.get(admission); + if (captured === undefined) return false; + // A different configuration home is a different question, not a stale answer to this one. + if (getConfigPath() !== captured.path) return false; + const file = admittedFileTerm(); + if (file === null || file !== captured.file) return false; + const resident = detachConfig(live); + if (resident === null || canonicalPlainData(resident.data) !== captured.data) return false; + if (!sameExecutors(resident.executors, captured.executors)) return false; + const working = detachConfig(admission.config); + return working !== null + && canonicalPlainData(working.data) === captured.data + && sameExecutors(working.executors, captured.executors); +} + +/** + * A plain-data copy of a configuration for a consumer that must not observe later edits, or null. + * + * The integration writer is the case this exists for. It freezes every other resolution seam + * before its first await and then held the configuration by reference, so a plan checked under one + * configuration could be written from another: the check and the document it authorized were + * reading the same object at two different moments. One copy taken before the await gives both of + * them the same configuration. + * + * Null rather than the caller's object when the copy cannot be made. Handing back the reference + * would have been a copy in name only, and the caller would have gone on to describe it as the + * configuration it checked. + */ +export function detachedConfigSnapshot(config: OcxConfig): OcxConfig | null { + const detached = detachConfig(config); + return detached === null ? null : withExecutors(detached); +} + +/** + * The configuration file as an opaque term: its exact bytes, or the distinguished absence of one. + * + * This is a byte observation and nothing more. It says the operator's configuration file has not + * been rewritten since a roster was built; it is not a claim about whether the configuration the + * process is holding agrees with that file. + * + * Null for a file that cannot be read, because then a later read cannot tell whether it changed. + * Null too for one that is there and does not load cleanly, which is the existing contract for a + * derived roster rather than an inference about the resident configuration. Before this, a digest + * was accepted ahead of any look at what the parse produced. + * + * Absence is a configuration rather than the lack of one. No file means defaults, which is an + * ordinary fresh install and the ordinary state in CI. + */ +function admittedFileTerm(): string | null { + const snapshot = readConfigAdmissionSnapshot(); + const { source, error } = snapshot.diagnostics; + if (snapshot.kind === "read") return source === "file" && error === null ? snapshot.contentSha256 : null; + return source === "default" && error === null ? "absent" : null; +} + +interface DetachedConfig { + readonly data: Record; + readonly executors: ReadonlyMap; +} + +/** + * A configuration as plain data, with the transport executors kept out of it. + * + * The copier refuses everything JSON could not have produced, so the one thing that needs handling + * here is a provider's fetch executor: a caller owns it and the gather uses it instead of the + * global transport. It is held by reference for the detached copy and compared by reference + * afterwards, so replacing it invalidates the binding while it is never serialized. + */ +function detachConfig(live: OcxConfig): DetachedConfig | null { + const executors = new Map(); + const root = live as unknown; + if (!isPlainObject(root)) return null; + const data: Record = {}; + for (const key of ownDataKeys(root)) { + if (key === null) return null; + const value = root[key]; + if (value === undefined) continue; + if (key !== "providers") { + const copied = copyPlainData(value); + if (!copied.ok) return null; + data[key] = copied.value; + continue; + } + if (!isPlainObject(value)) return null; + const providers: Record = {}; + for (const name of ownDataKeys(value)) { + if (name === null) return null; + const provider = value[name]; + if (provider === undefined) continue; + if (!isPlainObject(provider)) return null; + const copiedProvider: Record = {}; + for (const field of ownDataKeys(provider)) { + if (field === null) return null; + const fieldValue = provider[field]; + if (fieldValue === undefined) continue; + // The executor exception applies to an executor. A provider entry keeps unknown + // configuration keys, so a fetch value that is not a function is something an operator + // wrote into the file, and it is copied and compared as the data it is. Discovery reads it + // the same way: the outbound transport takes its built-in path unless the value is + // callable. + if (field === "fetch" && typeof fieldValue === "function") { + executors.set(name, fieldValue); + continue; + } + const copied = copyPlainData(fieldValue); + if (!copied.ok) return null; + copiedProvider[field] = copied.value; + } + providers[name] = copiedProvider; + } + data[key] = providers; + } + return { data, executors }; +} + +/** The copy a consumer runs against, with the executors put back by reference. */ +function withExecutors(detached: DetachedConfig): OcxConfig | null { + // A second copy, so what is compared later is never the object handed to a consumer. + const copied = copyPlainData(detached.data); + if (!copied.ok) return null; + const config = copied.value; + const providers = config.providers; + if (isPlainObject(providers)) { + for (const [name, executor] of detached.executors) { + const provider = providers[name]; + if (isPlainObject(provider)) provider.fetch = executor; + } + } + return config as unknown as OcxConfig; +} + +function sameExecutors(left: ReadonlyMap, right: ReadonlyMap): boolean { + if (left.size !== right.size) return false; + for (const [name, executor] of left) { + if (!right.has(name) || right.get(name) !== executor) return false; + } + return true; +} diff --git a/src/integrations/aside-profile-context.ts b/src/integrations/aside-profile-context.ts index d0bd9536cb8..65ee091c06f 100644 --- a/src/integrations/aside-profile-context.ts +++ b/src/integrations/aside-profile-context.ts @@ -2,6 +2,8 @@ import { lstatSync, readdirSync, realpathSync } from "node:fs"; import { join, relative, resolve, sep } from "node:path"; import { ClientPathError, type ExportModel } from "../clients/config-export"; import { assertAsideProfileBoundary, guardAsideProfileIO, listAsideProfiles, type AsideProfile } from "../clients/aside-profiles"; +import { detachedConfigSnapshot } from "../config/admitted-identity"; +import { copyPlainData } from "../lib/plain-data"; import type { OcxConfig } from "../types"; import { type IntegrationIO } from "./config-io"; import type { JournalEntry } from "./journal"; @@ -46,6 +48,17 @@ export interface AsideProfileContext { defaultEnabled: boolean; models: () => Promise; scopes: Map; + /** + * The configuration every profile write in this action is built from, copied when the context + * was created. + * + * An Aside mutation checks its confirmation, then awaits the preference write, and only then + * builds each profile's write input. The preference write edits the live configuration itself, + * so reading it again afterwards guaranteed the document was serialized from a configuration + * the check never saw. This copy is taken before the check and carried through every write, so + * the two describe the same thing. + */ + boundConfig: OcxConfig; } function storeUnsafe(): never { @@ -142,10 +155,28 @@ export function createAsideProfileContext(input: AsideProfilesInput): AsideProfi ? matched?.id ?? null : profiles.find(profile => profile.configPath === newest?.configPath)?.id ?? null; let loaded: Promise | undefined; + const boundConfig = detachedConfigSnapshot(input.config); + if (boundConfig === null) { + throw new AsideProfileError("aside_profiles_unavailable", 409, "The proxy configuration could not be captured for this change"); + } return { input: { ...input, env: { ...(input.env ?? process.env) } }, profiles, rootStore, legacyProfileId, scopes: new Map(), defaultEnabled: input.config.asideProfileSync?.allProfiles ?? Boolean(matched), - models: () => loaded ??= Promise.resolve().then(() => typeof input.models === "function" ? input.models() : input.models), + /* + * Resolved once and copied, so every profile in one action writes the same roster and a + * caller editing the model objects it passed cannot change what a checked plan described. + * The copy is taken here rather than at each write because the check reads it too. + */ + models: () => loaded ??= Promise.resolve() + .then(() => typeof input.models === "function" ? input.models() : input.models) + .then(rows => { + const copied = copyPlainData([...rows]); + if (!copied.ok) { + throw new AsideProfileError("aside_profiles_unavailable", 409, "The model roster could not be captured for this change"); + } + return copied.value; + }), + boundConfig, }; } @@ -160,7 +191,10 @@ export function selectAsideProfiles(ctx: AsideProfileContext, profileId?: number } export function asideProfileEnabled(ctx: AsideProfileContext, id: number): boolean { - return ctx.input.config.asideProfileSync?.profiles?.[String(id)] ?? ctx.defaultEnabled; + // The bound copy, like every other policy read in this context: the preference write inside an + // action edits the live configuration, and a decision taken afterwards would be answering from + // a policy the action itself installed. + return ctx.boundConfig.asideProfileSync?.profiles?.[String(id)] ?? ctx.defaultEnabled; } export function asideProfileScope(ctx: AsideProfileContext, profile: AsideProfile): AsideProfileScope { @@ -207,7 +241,7 @@ export async function asideWriteInput(ctx: AsideProfileContext, scope: AsideProf const models = await ctx.models(); scope.assertBoundary(); return { - clientId: "aside", config: ctx.input.config, models, port: ctx.input.port, + clientId: "aside", config: ctx.boundConfig, models, port: ctx.input.port, env: ctx.input.env, home: ctx.input.home, store: scope.store, io: scope.io, resolvedPaths: { configPath: scope.profile.configPath, detectDir: scope.profile.detectDir }, }; diff --git a/src/integrations/aside-profile-journal.ts b/src/integrations/aside-profile-journal.ts index 0fd7f471834..2d22588a646 100644 --- a/src/integrations/aside-profile-journal.ts +++ b/src/integrations/aside-profile-journal.ts @@ -167,10 +167,32 @@ function importOperation(row: AsideOperation, scope: AsideProfileScope): void { export function restoreAsideProfile( input: AsideProfilesInput, - request: { opId: string; profileId?: number; confirmDrift?: boolean }, + request: { + opId: string; + profileId?: number; + confirmDrift?: boolean; + /** + * The row a caller already selected. Aside can hold more than one valid copy, so resolving + * again here could act on a different one than the plan the operator confirmed described. + */ + selectedOperation?: AsideOperation; + }, + options?: { + revalidate?: (prepared: IntegrationWriteInput) => Promise; + /** + * Checked after the preference write, once before the selected history is copied into this + * profile's store and once more immediately before the restore itself. + * + * The preference write and that copy both sit between the first check and the restore they + * authorize, and Aside takes no writer lock. The snapshot checks below still hold, but they + * say nothing about the target file, which can be edited in either window; without this, a + * confirmation about the earlier file still overwrote the later one. + */ + revalidateBeforeWrite?: (prepared: IntegrationWriteInput) => Promise; + }, ): Promise { return runAsideProfileAction(input, request.profileId, `restore:${request.opId}:${Boolean(request.confirmDrift)}`, async ctx => { - const row = requiredOperation(ctx, request.opId, request.profileId); + const row = request.selectedOperation ?? requiredOperation(ctx, request.opId, request.profileId); const profile = selectAsideProfiles(ctx, row.profileId)[0]!; const scope = asideProfileScope(ctx, profile); assertAsideSnapshotEntry(row.entry); @@ -188,6 +210,16 @@ export function restoreAsideProfile( return { clientId: "aside", profileId: profile.id, ok: false, reason: "drift_requires_confirm", state: "conflict", message: "This profile changed after that operation; confirm to replace it" }; } const restoredText = snapshot.kind === "stored" ? snapshot.text : null; + /* + * Checked before the preference write and before importOperation, which captures a snapshot + * and appends a journal row. Both happen ahead of the coordinated restore, so a confirmation + * checked under the writer lock would already have rewritten the user's preference and their + * history by the time it was consulted. + */ + // The input this restore will actually write from, so the check cannot be answering about a + // view it rebuilt from the live configuration while the write uses this one. + const stale = await options?.revalidate?.(bound); + if (stale) return stale; await persistAsidePolicy(ctx, { profileId: profile.id, enabled: snapshotWasOwned(row.entry, restoredText, bound) }); try { scope.assertBoundary(); @@ -197,8 +229,41 @@ export function restoreAsideProfile( || (currentSnapshot.kind === "stored" && currentSnapshot.text !== restoredText)) { throw new AsideProfileError("aside_operation_changed", 409, "Aside operation or snapshot changed while saving preferences"); } + /* + * Looked at twice, because the two moments answer different questions and neither covers + * the other. + * + * Here, before the import, so that a confirmation already stale by this point is refused + * without copying anything: the import writes the selected row and its snapshot into this + * profile's own store, and a refusal from inside the coordinated restore returns before the + * restore transaction begins, so nothing would compensate that copy. + */ + const beforeImport = await options?.revalidateBeforeWrite?.(bound); + if (beforeImport) return beforeImport; importOperation(row, scope); - return { ...await restoreIntegrationCoordinated({ ...bound, opId: request.opId, confirmDrift: request.confirmDrift }, { lockSeams: input.lockSeams }), profileId: profile.id }; + return { + ...await restoreIntegrationCoordinated( + { ...bound, opId: request.opId, confirmDrift: request.confirmDrift }, + { + lockSeams: input.lockSeams, + /* + * And again inside the coordinated restore, immediately before it runs. The import + * sits between the two, and Aside takes no writer lock, so that path still awaits + * before the restore begins: a target edited after the copy would otherwise be + * rewritten by a confirmation that never described it. + * + * A refusal here leaves the copied history and the saved preference in place. That is + * deliberate: the copy is this operation's own history rather than a change to the + * operator's file, and unwinding it would need a transaction across two stores and a + * configuration write for no gain the operator can see. + */ + ...(options?.revalidateBeforeWrite + ? { revalidate: (frozen: IntegrationWriteInput) => options.revalidateBeforeWrite!(frozen) } + : {}), + }, + ), + profileId: profile.id, + }; } catch (error) { return asideProfileFailure(profile.id, error); } }); } diff --git a/src/integrations/aside-profiles.ts b/src/integrations/aside-profiles.ts index 94514f253a8..2d5e2a8e9df 100644 --- a/src/integrations/aside-profiles.ts +++ b/src/integrations/aside-profiles.ts @@ -2,20 +2,65 @@ import type { AsideProfile } from "../clients/aside-profiles"; import { asideHomeDir } from "../clients/config-export"; import { join } from "node:path"; import type { OwnedIntegrationRefreshOutcome } from "./owned-refresh"; +import type { JournalEntry } from "./journal"; +import type { IntegrationStateStore } from "./store"; import { readIntegrationState, type IntegrationState, type IntegrationStatus } from "./state"; import { applyIntegrationCoordinated, disableIntegrationCoordinated, overwriteIntegrationCoordinated, refreshIntegrationCoordinated, + type IntegrationWriteInput, } from "./writer"; import { asideProfileEnabled, asideProfileFailure, asideProfileScope, asideWriteInput, asideRootStore, createAsideProfileContext, persistAsidePolicy, runAsideProfileAction, selectAsideProfiles, type AsideProfileContext, type AsideProfilesInput, type AsideProfileWriteOutcome, } from "./aside-profile-context"; +import { AsideProfileError as AsideProfileErrorClass } from "./aside-profile-context"; +import { + previewIntegration, + type IntegrationMutationPlan, + type IntegrationPlanOperation, +} from "./mutation-plan"; export { AsideProfileError } from "./aside-profile-context"; export type { AsideProfilesInput, AsideProfileWriteOutcome } from "./aside-profile-context"; +/** + * Plan one profile's change without performing it. + * + * A profile is the unit here because a fingerprint can only honestly describe one independently + * changing file. The scope this builds is the same scope the mutation will use — that profile's + * store, IO and resolved path pair — so the plan describes the thing that would actually happen + * rather than an approximation of it. + * + * The roster comes from whatever the caller injected, so a preview inherits the caller's no-gather + * guarantee instead of reaching for a second source of models. + */ +export async function previewAsideProfile( + input: AsideProfilesInput, + request: { + profileId: number; + operation: IntegrationPlanOperation; + opId?: string; + confirmDrift?: boolean; + /** The row and store the route already selected; re-resolving could pick a different copy. */ + resolved?: { entry: JournalEntry; store: IntegrationStateStore }; + }, +): Promise { + const ctx = createAsideProfileContext(input); + const profile = selectAsideProfiles(ctx, request.profileId)[0]; + if (!profile) throw new AsideProfileErrorClass("aside_profile_not_found", 404, "That Aside profile is not available"); + const scope = asideProfileScope(ctx, profile); + const bound = await asideWriteInput(ctx, scope); + return previewIntegration(bound, { + operation: request.operation, + profileId: request.profileId, + ...(request.opId === undefined ? {} : { opId: request.opId }), + ...(request.confirmDrift === undefined ? {} : { confirmDrift: request.confirmDrift }), + ...(request.resolved === undefined ? {} : { resolved: request.resolved }), + }); +} + export interface AsideProfileState extends IntegrationStatus { profileId: number; name?: string; @@ -104,6 +149,18 @@ export async function getAsideProfileState(input: AsideProfilesInput, id: number export function mutateAsideProfiles( input: AsideProfilesInput, change: { enabled: boolean; profileId?: number; overwriteConflict?: boolean }, + options?: { + revalidate?: (prepared: IntegrationWriteInput) => Promise; + /** + * Checked again immediately before the client document is written. + * + * Aside takes no writer lock, and the preference write sits between the first check and the + * write it authorizes. Nothing stops the target file from being edited in that window, and the + * writer's own comparison reads the file as it is now, so a confirmation about the earlier + * file would otherwise still overwrite the later one. + */ + revalidateBeforeWrite?: (prepared: IntegrationWriteInput) => Promise; + }, ): Promise { return runAsideProfileAction(input, change.profileId, `${change.enabled ? "enable" : "disable"}:${Boolean(change.overwriteConflict)}`, async (ctx, profiles) => { const refused = new Map(); @@ -111,6 +168,66 @@ export function mutateAsideProfiles( try { asideProfileScope(ctx, profile); } catch (error) { refused.set(profile.id, asideProfileFailure(profile.id, error)); } } + /* + * The input each profile will be written from, prepared before the check and handed to it. + * + * A check that built its own view from the live configuration was answering about a different + * input than the one that would be written: the context copies the configuration when it is + * created, so live could be edited to something else and then back again while this action was + * in flight, and a check reading live would agree with a plan this write never described. + * + * Only a checked change prepares early. Preparing resolves the roster, and an unchecked change + * whose preference write fails must not have done any model work by then, which is a contract + * of its own. There is nothing to bring forward when there is no check to read it. + */ + const prepared = new Map(); + if (options?.revalidate) { + /* + * Refused before anything is prepared. Preparing resolves the roster, and a supported loader + * reaches providers and can finalize an initial model selection, so a confirmation this + * cannot check at all must not cause that work first. A confirmation describes one profile, + * and HTTP refuses one that names none; guessing which prepared input a bindingless + * confirmation meant would invent the thing the check exists to verify. + */ + if (change.profileId === undefined) { + throw new AsideProfileErrorClass("invalid_aside_profile", 400, "a confirmed change applies to one profile"); + } + for (const profile of profiles) { + if (refused.has(profile.id)) continue; + try { prepared.set(profile.id, await asideWriteInput(ctx, asideProfileScope(ctx, profile))); } + catch (error) { refused.set(profile.id, asideProfileFailure(profile.id, error)); } + } + } + /* + * A confirmation is checked HERE, not under the writer lock. + * + * The await below persists the user's Aside preference before any writer runs, so a check + * that waited for the lock would fire after the thing it was meant to prevent. Profile and + * path selection is frozen by this point, which is everything the check needs. + */ + if (options?.revalidate) { + const guarded = change.profileId === undefined ? undefined : prepared.get(change.profileId); + // Nothing prepared is nothing to check and nothing to write; refusing keeps a binding from + // being dropped on the way to a write that would then be unchecked. + const stale: AsideProfileWriteOutcome | null = guarded === undefined + ? refused.get(change.profileId ?? -1) ?? { + ok: false, reason: "unsafe", state: "conflict", clientId: "aside", + message: "that profile cannot be prepared for this change", + profileId: change.profileId ?? profiles[0]?.id ?? 0, + } + : await options.revalidate(guarded); + if (stale) { + return { + ok: false, + clientId: "aside", + changed: false, + state: "conflict", + message: "that confirmation no longer describes this profile", + results: [stale], + result: stale, + }; + } + } // This await precedes model loading, writer preflight, snapshots and all client writes. await persistAsidePolicy(ctx, change); const results: AsideProfileWriteOutcome[] = []; @@ -118,11 +235,19 @@ export function mutateAsideProfiles( const refusal = refused.get(profile.id); if (refusal) { results.push(refusal); continue; } try { - const scope = asideProfileScope(ctx, profile); - const bound = await asideWriteInput(ctx, scope); + // The prepared input when a check read one, so the write is the thing that was checked. + const bound = prepared.get(profile.id) ?? await asideWriteInput(ctx, asideProfileScope(ctx, profile)); const operation = !change.enabled ? disableIntegrationCoordinated : change.overwriteConflict ? overwriteIntegrationCoordinated : applyIntegrationCoordinated; - results.push({ ...await operation(bound, { lockSeams: input.lockSeams }), profileId: profile.id }); + results.push({ + ...await operation(bound, { + lockSeams: input.lockSeams, + ...(options?.revalidateBeforeWrite + ? { revalidate: (frozen: IntegrationWriteInput) => options.revalidateBeforeWrite!(frozen) } + : {}), + }), + profileId: profile.id, + }); } catch (error) { results.push(asideProfileFailure(profile.id, error)); } } const ok = results.every(result => result.ok); diff --git a/src/integrations/mutation-plan.ts b/src/integrations/mutation-plan.ts new file mode 100644 index 00000000000..20cc91c9f6a --- /dev/null +++ b/src/integrations/mutation-plan.ts @@ -0,0 +1,815 @@ +/** + * Value-free planning for integration mutations. + * + * An operator confirming "apply", "overwrite", "disable" or "undo" is agreeing to consequences + * nobody has shown them. This module computes those consequences as bounded managed schema paths + * and closed change kinds, and binds them to a fingerprint over every input the decision rested + * on, so a confirmation can be refused when the state it described has moved. + * + * Two rules give the output its safety. Nothing here is a value: paths are structural, and a + * segment that is not representable in the managed grammar is dropped rather than echoed, because + * an ownership record on disk accepts arbitrary strings and is not a validation authority. And + * nothing here writes: this module owns no IO, takes no lock, and must never import the writer. + * The dependency direction is state/ownership/merge into here, and here into the writer and the + * preview route. + */ +import { createHash } from "node:crypto"; +import { canonicalContribution, fingerprint, type OwnershipRecord } from "./ownership"; +import { ClientPathError, EXPORT_CLIENTS, type ExportModel, type ManagedContribution } from "../clients/config-export"; +import { OPENCODE_PROVIDER_ID } from "../clients/config-export/constants"; +import { createClineIO, ClineTransactionError } from "./cline-io"; +import { parseClineDocument } from "./cline-document"; +import { PARSE_FAILED, defaultIntegrationIO, loadTarget, parseConfig, type IntegrationIO } from "./config-io"; +import { INTEGRATION_CLIENTS, isLoopbackOnly, resolveIntegrationPaths, type IntegrationClientId } from "./registry"; +import { shouldInjectApiAuthHeader } from "../codex/inject"; +import { classifyIntegration, exportContextOf, readPath, type IntegrationState, type StateReason } from "./state"; +import { createIntegrationStateStore, type IntegrationStateStore } from "./store"; +import type { OcxConfig } from "../types"; +import { matchesOperationResult, type JournalEntry } from "./journal"; + +/** + * Why a mutation refused. Declared here rather than in the writer so the planner can report a + * refusal without depending on the module that performs writes; the writer re-exports it, so this + * is a move rather than a second vocabulary. + */ +export type RefusalReason = + | "not_installed" + | "conflict" + | "unsafe" + | "non_loopback" + | "drift_requires_confirm" + | "snapshot_expired" + | "write_failed"; + +export type IntegrationPlanOperation = "apply" | "overwrite" | "disable" | "restore"; +export type IntegrationPlanChangeKind = "add" | "replace" | "remove" | "snapshot" | "ownership" | "journal"; +export type IntegrationPlanForeignEdit = "none" | "unowned" | "foreign-edit" | "drift"; + +/** + * Effects that are not places in the client's document. They carry no disk location and no value, + * so an operator learns that history will be written without learning where it lives. + */ +export const PLAN_SNAPSHOT_PATH = "$snapshot"; +export const PLAN_OWNERSHIP_PATH = "$ownership"; +export const PLAN_JOURNAL_PATH = "$journal"; + +/** + * Upper bound on reported changes. Above the largest contribution any registered client builds and + * well below a response worth truncating, so the cap is a guard rather than a routine limit. + */ +export const PLAN_CHANGE_LIMIT = 256; + +export interface IntegrationPlanChange { + readonly kind: IntegrationPlanChangeKind; + readonly path: string; +} + +export interface IntegrationMutationPlan { + readonly version: 1; + readonly clientId: IntegrationClientId; + readonly operation: IntegrationPlanOperation; + readonly state: IntegrationState; + readonly foreignEdit: IntegrationPlanForeignEdit; + readonly changes: readonly IntegrationPlanChange[]; + readonly fingerprint: string; + readonly canApply: boolean; + /** + * Whether confirming would write at all. + * + * An apply against an already-current file and a disable against an absent one both succeed + * while writing nothing, so reporting a snapshot and a journal row for them would describe + * consequences that never happen. + */ + readonly willChange: boolean; + readonly refusalReason?: RefusalReason; + readonly profileId?: number; +} + +/** A position whose observed value is never published, only its presence. */ +export const DYNAMIC_SEGMENT = "*"; + +/** + * Where each client's managed fragments live, declared rather than inferred. + * + * A general "looks like a plain key" rule is not good enough, and Kimi is the proof: it writes one + * fragment per model at `models.`, so an alphanumeric allowlist would publish a user's + * model identifier verbatim. The same rule would accept any plain path sitting in an ownership + * record, and a record on disk is not a validation authority. + * + * So a path is published only when it matches one of these templates exactly. Static segments must + * match literally, a DYNAMIC_SEGMENT position accepts any observed segment, and the string that + * leaves this module is the TEMPLATE rather than the observed path. That is what makes publishing + * a value structurally impossible instead of merely unlikely. + * + * The satisfies clause makes a new client a type error here, so nobody can add one whose managed + * paths silently have no declaration. + */ +const CLIENT_MANAGED_PATHS = { + opencode: [["provider", OPENCODE_PROVIDER_ID], ["providers", OPENCODE_PROVIDER_ID]], + pi: [["providers", OPENCODE_PROVIDER_ID]], + omp: [["providers", OPENCODE_PROVIDER_ID]], + hermes: [["providers", OPENCODE_PROVIDER_ID]], + openclaw: [["models", "providers", OPENCODE_PROVIDER_ID]], + kimi: [["providers", OPENCODE_PROVIDER_ID], ["models", DYNAMIC_SEGMENT]], + gajae: [["providers", OPENCODE_PROVIDER_ID]], + dsh: [["llm-pi-ai", "providers", OPENCODE_PROVIDER_ID]], + mcode: [["custom_provider", OPENCODE_PROVIDER_ID]], + zcode: [["provider", OPENCODE_PROVIDER_ID]], + prime: [["providers", OPENCODE_PROVIDER_ID]], + aside: [["providers", OPENCODE_PROVIDER_ID]], + raycast: [["providers", `[id=${OPENCODE_PROVIDER_ID}]`]], + omo: [["providers", OPENCODE_PROVIDER_ID]], + cline: [ + ["settings", "providers", OPENCODE_PROVIDER_ID], + ["catalog", "providers", OPENCODE_PROVIDER_ID], + ], +} satisfies Record; + +/** Not a configuration surface. Exported so a parity case can compare it against the shipped clients. */ +export const MANAGED_PATH_TEMPLATES: Readonly> = CLIENT_MANAGED_PATHS; + +function matchesTemplate(template: readonly string[], path: readonly string[]): boolean { + if (template.length !== path.length) return false; + return template.every((segment, index) => { + const observed = path[index]; + if (observed === undefined || observed.length === 0) return false; + return segment === DYNAMIC_SEGMENT || segment === observed; + }); +} + +/** + * The managed schema path this change touches, or null when the path is outside the client's + * declared grammar. + * + * Null is not an error to work around. A path nobody declared is either a record written by a + * different version or something arbitrary, and neither is safe to name, so the caller reports the + * fixed ownership pseudo-path or a refusal instead of inventing a description. + */ +export function canonicalSchemaPath(clientId: IntegrationClientId, path: readonly string[]): string | null { + if (path.length === 0) return null; + for (const template of CLIENT_MANAGED_PATHS[clientId]) { + if (matchesTemplate(template, path)) return template.join("."); + } + return null; +} + +const KIND_ORDER: readonly IntegrationPlanChangeKind[] = ["add", "replace", "remove", "snapshot", "ownership", "journal"]; + +/** + * Deterministic, deduplicated and capped. Deterministic because the fingerprint is taken over this + * projection, so an unstable order would stale a plan that did not change. + */ +export function orderPlanChanges(changes: readonly IntegrationPlanChange[]): readonly IntegrationPlanChange[] { + const seen = new Set(); + const unique: IntegrationPlanChange[] = []; + for (const change of changes) { + const key = `${change.kind}\u0000${change.path}`; + if (seen.has(key)) continue; + seen.add(key); + unique.push(change); + } + unique.sort((left, right) => { + const byKind = KIND_ORDER.indexOf(left.kind) - KIND_ORDER.indexOf(right.kind); + if (byKind !== 0) return byKind; + return left.path < right.path ? -1 : left.path > right.path ? 1 : 0; + }); + return Object.freeze(unique.slice(0, PLAN_CHANGE_LIMIT)); +} + +/** + * Every input the plan's authority rests on. + * + * `models` is here because the desired contribution is derived from it, so a model roster that + * changed between preview and commit changes what would be written. `snapshot` carries a digest of + * the bytes a restore would actually publish rather than only the operation id, because the id + * names the row and the bytes are what lands in the user's file. + */ +export interface PlanFingerprintInput { + readonly operation: IntegrationPlanOperation; + readonly clientId: IntegrationClientId; + readonly profileId?: number; + readonly configPath: string; + readonly detectDir: string; + /** + * What the detect directory actually was when observed, not merely where it is. + * + * Binding only the path leaves a confirmation valid across an uninstall: the contribution is + * unchanged, so every other component matches, while the answer to "is this client installed" + * has flipped. The observed kind is the input the not_installed refusal is derived from. + */ + readonly installKind: string; + /** + * Whether admission policy blocks this integration, which is the non_loopback refusal's input. + * + * Config eligibility can change without touching the file, the record or the contribution, so a + * plan that did not bind it could be confirmed after the proxy stopped being a legal target. + */ + readonly admissionBlocked: boolean; + /** Exact current bytes, or null when the target is missing. Missing and empty are not equal. */ + readonly before: string | null; + readonly contribution: ManagedContribution | null; + readonly record: OwnershipRecord | null; + readonly models: readonly ExportModel[]; + readonly restore?: { + readonly opId: string; + readonly entry: JournalEntry; + readonly snapshotKind: string; + /** Digest of the snapshot's exact text, or null when it holds none. */ + readonly snapshotText: string | null; + readonly confirmDrift: boolean; + /** + * Whether the target has changed since the operation being undone, which is the + * drift_requires_confirm predicate. Passed in rather than recomputed so the plan and the + * mutation read drift from the same comparison. + */ + readonly driftsFromResult: boolean; + }; +} + +const PLAN_FINGERPRINT_VERSION = "p1"; + +function digest(value: string): string { + return createHash("sha256").update(value).digest("hex").slice(0, 32); +} + +/** + * An opaque optimistic-concurrency token, not authorization. + * + * Longer than the 16-hex ownership fingerprint because this one is supplied by a caller and + * compared for equality, so accidental collision matters more than it does for a stored digest. + * The version prefix means a future input set invalidates old tokens instead of silently + * comparing two different meanings. + */ +export function planFingerprint(input: PlanFingerprintInput): string { + const restore = input.restore; + const components = [ + PLAN_FINGERPRINT_VERSION, + input.operation, + input.clientId, + input.profileId === undefined ? null : input.profileId, + input.configPath, + input.detectDir, + input.installKind, + input.admissionBlocked, + input.before === null ? "\u0000absent" : fingerprint(input.before), + input.contribution === null ? null : fingerprint(canonicalContribution(input.contribution)), + input.record === null ? null : fingerprint(JSON.stringify(input.record)), + fingerprint(JSON.stringify(input.models)), + restore === undefined ? null : [ + restore.opId, + fingerprint(JSON.stringify(restore.entry)), + restore.snapshotKind, + restore.snapshotText === null ? "\u0000none" : fingerprint(restore.snapshotText), + restore.confirmDrift, + restore.driftsFromResult, + ], + ]; + return `${PLAN_FINGERPRINT_VERSION}:${digest(JSON.stringify(components))}`; +} + +/** The observation facts a plan is derived from, beside the fingerprint inputs. */ +export interface PlanInput extends PlanFingerprintInput { + readonly classified: { readonly state: IntegrationState; readonly reason?: StateReason }; + /** + * The parsed target document. Whether a managed place is occupied is a fact about the file, not + * about our record: an overwrite of a key somebody else wrote replaces a value even though no + * record of ours mentions it. + */ + readonly parsed: unknown; +} + +type PlanOutcome = + | { readonly kind: "refuse"; readonly reason: RefusalReason } + | { readonly kind: "noop" } + | { readonly kind: "change" }; + +const CHANGE: PlanOutcome = { kind: "change" }; +const NOOP: PlanOutcome = { kind: "noop" }; +const deny = (reason: RefusalReason): PlanOutcome => ({ kind: "refuse", reason }); + +function foreignEditOf(input: PlanInput): IntegrationPlanForeignEdit { + if (input.restore?.driftsFromResult) return "drift"; + if (input.classified.reason === "unowned-key") return "unowned"; + if (input.classified.reason === "foreign-edit") return "foreign-edit"; + return "none"; +} + +/** + * Why this operation would refuse, in the writer's own order. + * + * The order is not cosmetic. An uninstalled client is reported as not installed rather than as + * whatever its leftover file happens to classify as, and an unreadable or unparseable file is + * reported before either, because that is the sequence the writer itself refuses in. A plan that + * named a different reason than the mutation would name is worse than no plan. + */ +function applyOutcome(input: PlanInput): PlanOutcome { + if (input.installKind !== "dir") return deny("not_installed"); + if (input.admissionBlocked) return deny("non_loopback"); + // Overwrite exists precisely to proceed through a conflict the operator has been shown. + if (input.classified.state === "conflict" && input.operation !== "overwrite") return deny("conflict"); + if (input.classified.state === "unsafe") return deny("unsafe"); + if (input.classified.state === "current") return NOOP; + return CHANGE; +} + +/** + * Disable answers a different question, so it asks different ones. + * + * It never checks installation or admission: removing what we wrote from a file that still exists + * is meaningful whether or not the client is installed now, and it emits nothing that admission + * policy could object to. An absent block is a success that writes nothing rather than a refusal. + */ +function disableOutcome(input: PlanInput): PlanOutcome { + if (input.classified.state === "absent") return NOOP; + if (input.classified.state === "conflict") return deny("conflict"); + if (input.classified.state === "unsafe") return deny("unsafe"); + return CHANGE; +} + +function restoreOutcome(input: PlanInput): PlanOutcome { + if (input.restore === undefined) return deny("unsafe"); + if (input.restore.snapshotKind === "expired") return deny("snapshot_expired"); + if (input.restore.driftsFromResult && !input.restore.confirmDrift) return deny("drift_requires_confirm"); + return CHANGE; +} + +/** + * What this operation would do, decided the way the operation itself decides it. + * + * Applying one global sequence to all four was wrong: it reported disable as refused on an + * uninstalled client the writer would have accepted, and it ranked the classifier's unsafe ahead + * of a conflict that apply reports first. A plan is only useful if it reaches the same verdict, + * for the same reason, as the mutation it describes. + */ +function outcomeOf(input: PlanInput): PlanOutcome { + if (input.operation === "restore") return restoreOutcome(input); + if (input.operation === "disable") return disableOutcome(input); + return applyOutcome(input); +} + + +/** + * The managed places this operation would touch, plus the history it would write. + * + * A path that does not canonicalize is omitted rather than guessed at. For a shipped client that + * cannot happen, and the parity case proves it; what it does cover is a record written by another + * version, where declining to describe a path is the honest answer and the fixed ownership entry + * still tells the operator that ownership changes. + */ +function changesOf(input: PlanInput): readonly IntegrationPlanChange[] { + const changes: IntegrationPlanChange[] = []; + if (input.operation === "apply" || input.operation === "overwrite") { + for (const fragment of input.contribution?.fragments ?? []) { + const path = canonicalSchemaPath(input.clientId, fragment.path); + if (path === null) continue; + // Occupied is a fact about the document. Deciding from our own record instead would call an + // overwrite of somebody else's key an addition, which is the one case overwrite exists for. + const occupied = readPath(input.parsed, fragment.path) !== undefined; + changes.push({ kind: occupied ? "replace" : "add", path }); + } + } + if (input.operation === "disable") { + for (const owned of input.record?.fragmentPaths ?? []) { + const path = canonicalSchemaPath(input.clientId, owned); + if (path === null) continue; + changes.push({ kind: "remove", path }); + } + } + if (input.operation === "restore") { + /* + * An undo replaces the whole document, so what it changes is the difference between the + * places that are ours now and the places the row recorded as ours before that operation ran. + * + * Reading only the prior record got both common undos wrong. Undoing an initial apply has no + * prior record, so the plan described a change to nothing at all while the undo removed the + * managed block. Undoing a disable has a prior record and an empty document, so the plan said + * it would replace paths the file does not currently have. + * + * Provenance is still restored rather than re-derived: the prior record is what says which + * places are ours afterwards. The document only answers whether each of them is there now. + */ + const prior = new Map(); + for (const fragment of input.restore?.entry.priorRecord?.fragmentPaths ?? []) { + const path = canonicalSchemaPath(input.clientId, fragment); + if (path !== null) prior.set(path, fragment); + } + /* + * A document we could not read says nothing about whether a place is there, and restore does + * not need it read: eligibility is a question about bytes. Where it cannot be read, each place + * is reported as a replacement, which is what this list said before any of it was derived. + */ + const documentKnown = input.parsed !== PARSE_FAILED; + for (const [path, fragment] of prior) { + const absent = documentKnown && readPath(input.parsed, fragment) === undefined; + changes.push({ kind: absent ? "add" : "replace", path }); + } + for (const fragment of input.record?.fragmentPaths ?? []) { + const path = canonicalSchemaPath(input.clientId, fragment); + if (path === null || prior.has(path)) continue; + changes.push({ kind: "remove", path }); + } + } + changes.push({ kind: "snapshot", path: PLAN_SNAPSHOT_PATH }); + changes.push({ kind: "ownership", path: PLAN_OWNERSHIP_PATH }); + changes.push({ kind: "journal", path: PLAN_JOURNAL_PATH }); + return orderPlanChanges(changes); +} + +/** + * The whole plan, value-free. + * + * A refused plan is still worth returning: knowing that undo is blocked because the backup expired + * is the answer an operator needs, and it carries no more detail than an allowed one. + */ +export function buildMutationPlan(input: PlanInput): IntegrationMutationPlan { + const outcome = outcomeOf(input); + return Object.freeze({ + version: 1 as const, + clientId: input.clientId, + operation: input.operation, + state: input.classified.state, + foreignEdit: foreignEditOf(input), + // Only a plan that would actually write describes places to write. + changes: outcome.kind === "change" ? changesOf(input) : Object.freeze([]), + fingerprint: planFingerprint(input), + canApply: outcome.kind !== "refuse", + willChange: outcome.kind === "change", + ...(outcome.kind === "refuse" ? { refusalReason: outcome.reason } : {}), + ...(input.profileId === undefined ? {} : { profileId: input.profileId }), + }); +} + +/** + * The token a plan carries when observation itself refused. + * + * Such a plan never read the state it would have bound, so there is nothing to bind. It is safe + * for every one of them to share this value because `canApply` is false, and a mutation may only + * be bound to a plan that could apply. + */ +export const PLAN_UNBOUND_FINGERPRINT = `${PLAN_FINGERPRINT_VERSION}:unbound`; + +function unboundPlan( + clientId: IntegrationClientId, + operation: IntegrationPlanOperation, + failure: IntegrationObservationFailure, + profileId?: number, +): IntegrationMutationPlan { + return Object.freeze({ + version: 1 as const, + clientId, + operation, + state: failure.state, + foreignEdit: "none" as const, + changes: Object.freeze([]), + fingerprint: PLAN_UNBOUND_FINGERPRINT, + canApply: false, + willChange: false, + refusalReason: failure.reason, + ...(profileId === undefined ? {} : { profileId }), + }); +} + +/** + * Restore reads a different specification, so it gets its own observation. + * + * The writer's undo path never parses and never classifies: it compares the resolved config path + * against the one the journal row was recorded for, reads the snapshot, and reads the target's + * BYTES. Routing a preview through the general observation therefore refused an undo of a file + * that was readable but unparseable, which is the state that most needs restoring, and accepted a + * row recorded against a previous home, which is the single case path equality exists to refuse. + */ +export function observeRestore( + input: IntegrationWriteInput, + opId: string, + effects: ObservationEffects, + /** + * The row a caller already selected, with the store it came from. + * + * Aside can hold more than one valid copy of the same operation, so re-resolving here could + * legitimately pick a different row than the mutation will. The plan would then describe an + * operation the confirmation was never about. A caller that has resolved one passes it in, and + * neither side resolves again. + */ + selectedOperation?: { entry: JournalEntry; store: IntegrationStateStore }, +) { + const store = selectedOperation?.store ?? input.store ?? createIntegrationStateStore(); + let io = input.io ?? defaultIntegrationIO(store); + const clientId = input.clientId; + let resolved: { configPath: string; detectDir: string }; + try { + resolved = input.resolvedPaths ?? resolveIntegrationPaths(clientId, input.env, input.home); + } catch (error) { + if (!(error instanceof ClientPathError)) throw error; + return { failed: observationFailure("unsafe", "unsafe", error.message) } as const; + } + // Coordinated restore refuses this before it looks at the row at all: an undo will not create + // the client's home, so a writer-lock client without one has nothing to restore into. + if (INTEGRATION_CLIENTS[clientId].writerLock && io.statKind(resolved.detectDir) !== "dir") { + return { + failed: observationFailure("unsafe", "unsafe", "the client home is missing; restore will not create it"), + } as const; + } + const entry = selectedOperation?.entry ?? store.findOperation(opId); + if (!entry || entry.clientId !== clientId) { + return { failed: observationFailure("unsafe", "unsafe", "that operation cannot be undone") } as const; + } + const configPath = entry.configPath; + // An undo acts on the path the operation was journaled against. A row recorded for one home must + // never be allowed to rewrite a file in another. + if (resolved.configPath !== configPath) { + return { + failed: observationFailure("conflict", "conflict", "that operation was recorded for a different location"), + } as const; + } + if (clientId === "cline") { + try { io = createClineIO(io, configPath, store, effects.recover); } + catch (error) { + if (!(error instanceof ClineTransactionError)) throw error; + return { failed: { ...observationFailure("unsafe", "unsafe", error.message, error.snapshotPath), residual: true } } as const; + } + } + const snapshot = store.readSnapshot(entry); + /* + * Expiry is decided before the target is read, exactly as the writer decides it. Reading first + * let an expired backup over an unreadable file report the file as the problem, when the answer + * the operator needs is that the backup is gone. + */ + if (snapshot.kind === "expired") { + return { failed: observationFailure("snapshot_expired", "absent", "that backup has expired") } as const; + } + const target = loadTarget(io, configPath); + if (!target.ok) { + return { failed: observationFailure("unsafe", "unsafe", "the target cannot be read safely") } as const; + } + const before = target.before; + return { + failed: undefined, + clientId, + configPath, + detectDir: resolved.detectDir, + installKind: io.statKind(resolved.detectDir), + entry, + snapshotKind: snapshot.kind, + snapshotText: snapshot.kind === "stored" ? snapshot.text : null, + before, + // Bytes only. Parsing here is exactly what must not happen. + driftsFromResult: !matchesOperationResult(entry, before), + } as const; +} + +export interface PreviewRequest { + readonly operation: IntegrationPlanOperation; + /** Required for restore; names the journalled operation being undone. */ + readonly opId?: string; + readonly confirmDrift?: boolean; + readonly profileId?: number; + /** A row and store the caller already selected, so neither side resolves it twice. */ + readonly resolved?: { entry: JournalEntry; store: IntegrationStateStore }; +} + +/** + * Plan an operation without performing it. + * + * Observation runs with both write-capable effects off, so this path prunes nothing, recovers + * nothing, takes no lock and enters no mutation flight. Everything it reads is a read: the target + * file, the ownership records, and for restore the journal row and its snapshot. + */ +export function previewIntegration(input: IntegrationWriteInput, request: PreviewRequest): IntegrationMutationPlan { + // Restore never reaches the general observation, because the writer's undo path never parses + // or classifies and a preview that did would answer a different question. + if (request.operation === "restore") return previewRestore(input, request); + const observed = observeIntegration(input, { maintenance: false, recover: false }); + if (observed.failed) return unboundPlan(input.clientId, request.operation, observed.failed, request.profileId); + + const shared = { + operation: request.operation, + clientId: observed.clientId, + configPath: observed.configPath, + detectDir: observed.detectDir, + installKind: observed.io.statKind(observed.detectDir), + // Loopback-only clients cannot carry the admission header a non-loopback bind requires. + admissionBlocked: isLoopbackOnly(observed.clientId) && shouldInjectApiAuthHeader(input.config), + before: observed.before, + contribution: observed.contribution, + record: observed.record, + models: input.models, + classified: observed.classified, + parsed: observed.parsed, + ...(request.profileId === undefined ? {} : { profileId: request.profileId }), + }; + + return buildMutationPlan(shared); +} + +/** + * Which places are ours in the file this undo would rewrite, read the same way the general + * observation reads them. + * + * Read from the store bound to the target being rewritten, never from the store a historical row + * was selected out of. Aside keeps a copy of an operation in the root store while the profile's + * own store holds the ownership for its file, so asking the selected row's store would have + * described the wrong file's ownership. The selected store stays what it is for: the row and its + * snapshot. A record written for another location grants nothing here either, which is the same + * rule the writer applies. + */ +function currentRecordFor( + input: IntegrationWriteInput, + clientId: IntegrationClientId, + configPath: string, +): OwnershipRecord | null { + const store = input.store ?? createIntegrationStateStore(); + const stored = store.readRecords()[clientId] ?? null; + return stored && stored.clientId === clientId && stored.configPath === configPath ? stored : null; +} + +/** + * Plan an undo the way the writer performs one. + * + * State is derived from bytes alone: a missing target is absent, a target that no longer matches + * the row's recorded result is a conflict, and anything else is current. Admission is not asked + * about, because restore emits nothing an admission policy could object to and the writer does not + * ask either. + */ +function previewRestore(input: IntegrationWriteInput, request: PreviewRequest): IntegrationMutationPlan { + const refusal = (message: string): IntegrationMutationPlan => + unboundPlan(input.clientId, "restore", { reason: "unsafe", state: "unsafe", message }, request.profileId); + if (request.opId === undefined) return refusal("that operation cannot be undone"); + + const observed = observeRestore( + input, + request.opId, + { maintenance: false, recover: false }, + request.resolved, + ); + if (observed.failed) return unboundPlan(input.clientId, "restore", observed.failed, request.profileId); + + /* + * Drift decides first. A row that recorded a file and now finds none has drifted, and calling + * that absent would report a missing file as an ordinary undo while the writer refuses it + * pending confirmation. Absent is only honest when the recorded result was absence too. + */ + const state: IntegrationState = observed.driftsFromResult + ? "conflict" + : observed.before === null ? "absent" : "current"; + + return buildMutationPlan({ + operation: "restore", + clientId: observed.clientId, + configPath: observed.configPath, + detectDir: observed.detectDir, + installKind: observed.installKind, + admissionBlocked: false, + before: observed.before, + contribution: null, + // Descriptive, never decisive. The record says which places are ours now and the document + // says which of them the file holds, so the change list can distinguish a place this undo + // adds back from one it replaces and one it takes away. Neither is allowed to refuse: an + // unreadable document is reported as PARSE_FAILED and leaves every place a replacement, and + // restore eligibility stays the byte comparison it was. + record: currentRecordFor(input, observed.clientId, observed.configPath), + models: input.models, + classified: { state }, + parsed: observed.before === null + ? {} + : observed.clientId === "cline" + ? parseClineDocument(observed.before) + : parseConfig(observed.before, EXPORT_CLIENTS[observed.clientId].format), + restore: { + opId: observed.entry.opId, + entry: observed.entry, + snapshotKind: observed.snapshotKind, + snapshotText: observed.snapshotText, + confirmDrift: request.confirmDrift === true, + driftsFromResult: observed.driftsFromResult, + }, + ...(request.profileId === undefined ? {} : { profileId: request.profileId }), + }); +} + +/** + * What a mutation is asked to do. Declared here because the observation below consumes it and the + * planner must not depend on the writer; the writer re-exports it, so callers are unaffected. + */ +export interface IntegrationWriteInput { + clientId: IntegrationClientId; + models: readonly ExportModel[]; + config: OcxConfig; + port: number; + env?: NodeJS.ProcessEnv; + home?: string; + store?: IntegrationStateStore; + io?: IntegrationIO; + /** Frozen once by the async coordinator; synchronous callers may omit it. */ + resolvedPaths?: { configPath: string; detectDir: string }; +} + +/** A refusal in the planner's own vocabulary, so observation does not depend on the writer's result type. */ +export interface IntegrationObservationFailure { + readonly reason: RefusalReason; + readonly state: IntegrationState; + readonly message: string; + readonly snapshotPath?: string; + /** A Cline transaction left residue that only a mutation may clear. */ + readonly residual?: boolean; +} + +function observationFailure( + reason: RefusalReason, + state: IntegrationState, + message: string, + snapshotPath?: string, +): IntegrationObservationFailure { + return { reason, state, message, ...(snapshotPath ? { snapshotPath } : {}) }; +} + +/** + * What preview and mutation are allowed to touch while looking. + * + * Both are false for a preview and both are true for a mutation, and neither defaults, because the + * difference is the whole safety argument. `maintenance` runs pending snapshot pruning, which + * writes; `recover` lets the Cline adapter repair a pending transaction, which also writes. A + * preview that quietly inherited either would be a mutation wearing a read's name. + */ +export interface ObservationEffects { + readonly maintenance: boolean; + readonly recover: boolean; +} + +/** + * Detect, gate, read, parse and classify, once, for both preview and mutation. + * + * Extracted from the writer so a plan and the mutation it authorizes rest on the same + * classification rather than two independent reads that can disagree. The ordering of refusals is + * load-bearing and is preserved exactly as the writer had it. + */ +export function observeIntegration(input: IntegrationWriteInput, effects: ObservationEffects) { + const store = input.store ?? createIntegrationStateStore(); + let io = input.io ?? defaultIntegrationIO(store); + const clientId = input.clientId; + const spec = INTEGRATION_CLIENTS[clientId]; + const exportSpec = EXPORT_CLIENTS[clientId]; + /* + * Resolution itself can refuse: a relative OPENCLAW_* selector is rejected + * because we cannot know the gateway's working directory. That is a refusal + * about the user's configuration, not an internal fault, so it must not + * escape as an exception — the collection route would answer 500 for the + * whole Integrations page because one client is misconfigured. + */ + let configPath: string; + let detectDir: string; + try { + /* + * Resolve the PAIR, never one half. + * + * The coordinated path hands us a frozen pair, but applyIntegration, + * refreshIntegration and disableIntegration are public and may be called + * without one. Resolving configPath here and detectDir separately later let + * an Aside account switch land between the two, so a direct apply could + * verify account 1 was installed and then write account 0's catalog. + */ + const resolved = input.resolvedPaths ?? resolveIntegrationPaths(clientId, input.env, input.home); + configPath = resolved.configPath; + detectDir = resolved.detectDir; + if (clientId === "cline") io = createClineIO(io, configPath, store, effects.recover); + } catch (error) { + if (error instanceof ClineTransactionError) { + return { failed: { ...observationFailure("unsafe", "unsafe", error.message, error.snapshotPath), residual: true } } as const; + } + if (!(error instanceof ClientPathError)) throw error; + return { failed: observationFailure("unsafe", "unsafe", error.message) } as const; + } + // Pruning writes, so only a mutation may perform it. Preview reports the state it finds. + if (effects.maintenance) store.retryPendingPrunes(); + + const target = loadTarget(io, configPath); + if (!target.ok) { + return { + failed: observationFailure("unsafe", "unsafe", + target.why === "read-failed" + ? `${configPath} exists but could not be read` + : `${configPath} is not a regular file`), + } as const; + } + const before = target.before; + const parsed = clientId === "cline" ? parseClineDocument(before) : parseConfig(before, exportSpec.format); + if (parsed === PARSE_FAILED) { + return { failed: observationFailure("unsafe", "unsafe", + `${configPath} could not be parsed, or holds something opencodex cannot rewrite without changing it (a non-finite number, a large integer or a tiny one a rewrite would round, -0, a duplicate member, or nesting deeper than 1000 levels)`) } as const; + } + const contribution = exportSpec.buildContribution(exportContextOf(input)); + // A record proves ownership of the file it was written FOR. Matching only by + // client id let a record for one home authorize a write to another whose + // bytes happened to hash the same — which deleted a config we never touched. + const stored = store.readRecords()[clientId] ?? null; + const record = stored && stored.clientId === clientId && stored.configPath === configPath + ? stored + : null; + // `configPath`/`clientId` are load-bearing, not decoration: a record proves + // ownership of ONE file, and the writer mutates whatever path resolves NOW. + // Without them a record written for another home directory would grant + // ownership here and disable would delete fragments it never wrote. + const classified = classifyIntegration({ + fileText: before, fileIsRegular: true, parsed, record, contribution, configPath, clientId, + }); + return { failed: undefined, store, io, clientId, spec, exportSpec, configPath, detectDir, before, parsed, contribution, record, classified } as const; +} diff --git a/src/integrations/writer.ts b/src/integrations/writer.ts index 0bac4da13a7..fa783c37057 100644 --- a/src/integrations/writer.ts +++ b/src/integrations/writer.ts @@ -11,12 +11,14 @@ */ import { homedir } from "node:os"; import { createClineIO, ClineTransactionError } from "./cline-io"; -import { parseClineDocument, serializeClineDocument, preserveClineSelection } from "./cline-document"; +import { serializeClineDocument, preserveClineSelection } from "./cline-document"; import { dirname } from "node:path"; import { EXPORT_CLIENTS, type ExportModel, type ManagedContribution } from "../clients/config-export"; import { shouldInjectApiAuthHeader } from "../codex/inject"; +import { detachedConfigSnapshot } from "../config/admitted-identity"; +import { copyPlainData } from "../lib/plain-data"; import type { OcxConfig } from "../types"; -import { PARSE_FAILED, defaultIntegrationIO, loadTarget, parseConfig, type IntegrationIO } from "./config-io"; +import { defaultIntegrationIO, loadTarget, type IntegrationIO } from "./config-io"; import { fingerprint, canonicalContribution, @@ -31,11 +33,12 @@ import { } from "./ownership-policy"; import { AmbiguousSelectorError, createdContainerPaths, mergeContribution, removeFragments } from "./merge"; import { INTEGRATION_CLIENTS, isLoopbackOnly, resolveIntegrationPaths, type IntegrationClientId } from "./registry"; -import { classifyIntegration, exportContextOf } from "./state"; +import { exportContextOf } from "./state"; import type { IntegrationState } from "./state"; import { serializeDocument, UnserializableValueError } from "./serialize"; import { ClientPathError } from "../clients/config-export"; import { matchesOperationResult, newOpId, type JournalEntry } from "./journal"; +import { observeIntegration, type IntegrationWriteInput, type RefusalReason } from "./mutation-plan"; import { createIntegrationStateStore, type IntegrationStateStore } from "./store"; import { patchYamlFragmentSource, @@ -62,14 +65,12 @@ function yamlRefusalReason( } import { withIntegrationWriterLock, type IntegrationWriterLockSeams } from "./writer-lock"; -export type RefusalReason = - | "not_installed" - | "conflict" - | "unsafe" - | "non_loopback" - | "drift_requires_confirm" - | "snapshot_expired" - | "write_failed"; +/** + * Owned by the planner so a plan can report a refusal without depending on the writer, and + * re-exported here because this module was its original home and every caller imports it from + * the writer. + */ +export type { IntegrationWriteInput, RefusalReason }; export interface WriteOk { ok: true; @@ -94,19 +95,6 @@ export interface WriteRefused { export type WriteOutcome = WriteOk | WriteRefused; -export interface IntegrationWriteInput { - clientId: IntegrationClientId; - models: readonly ExportModel[]; - config: OcxConfig; - port: number; - env?: NodeJS.ProcessEnv; - home?: string; - store?: IntegrationStateStore; - io?: IntegrationIO; - /** Frozen once by the async coordinator; synchronous callers may omit it. */ - resolvedPaths?: { configPath: string; detectDir: string }; -} - export interface IntegrationRestoreInput extends IntegrationWriteInput { opId: string; confirmDrift?: boolean; @@ -227,76 +215,20 @@ function sourcePreservingFragmentValue( return fragment?.value; } -/** Shared preflight: detect, gate, read, parse and classify. */ +/** + * Mutation's view of the shared observation. + * + * The read, parse and classify live in the planner so a preview and the mutation it authorizes + * cannot disagree. Mutation keeps the effects a preview must not have: pending-prune maintenance + * and Cline transaction recovery both write. Translating the planner's refusal into WriteRefused + * here keeps the planner free of any dependency on this module's result type. + */ function preflight(input: IntegrationWriteInput) { - const store = input.store ?? createIntegrationStateStore(); - let io = input.io ?? defaultIntegrationIO(store); - const clientId = input.clientId; - const spec = INTEGRATION_CLIENTS[clientId]; - const exportSpec = EXPORT_CLIENTS[clientId]; - /* - * Resolution itself can refuse: a relative OPENCLAW_* selector is rejected - * because we cannot know the gateway's working directory. That is a refusal - * about the user's configuration, not an internal fault, so it must not - * escape as an exception — the collection route would answer 500 for the - * whole Integrations page because one client is misconfigured. - */ - let configPath: string; - let detectDir: string; - try { - /* - * Resolve the PAIR, never one half. - * - * The coordinated path hands us a frozen pair, but applyIntegration, - * refreshIntegration and disableIntegration are public and may be called - * without one. Resolving configPath here and detectDir separately later let - * an Aside account switch land between the two, so a direct apply could - * verify account 1 was installed and then write account 0's catalog. - */ - const resolved = input.resolvedPaths ?? resolveIntegrationPaths(clientId, input.env, input.home); - configPath = resolved.configPath; - detectDir = resolved.detectDir; - if (clientId === "cline") io = createClineIO(io, configPath, store, true); - } catch (error) { - if (error instanceof ClineTransactionError) { - return { failed: { ...refuse(clientId, "unsafe", "unsafe", error.message, error.snapshotPath), residual: true } } as const; - } - if (!(error instanceof ClientPathError)) throw error; - return { failed: refuse(clientId, "unsafe", "unsafe", error.message) } as const; - } - store.retryPendingPrunes(); - - const target = loadTarget(io, configPath); - if (!target.ok) { - return { - failed: refuse(clientId, "unsafe", "unsafe", - target.why === "read-failed" - ? `${configPath} exists but could not be read` - : `${configPath} is not a regular file`), - } as const; - } - const before = target.before; - const parsed = clientId === "cline" ? parseClineDocument(before) : parseConfig(before, exportSpec.format); - if (parsed === PARSE_FAILED) { - return { failed: refuse(clientId, "unsafe", "unsafe", - `${configPath} could not be parsed, or holds something opencodex cannot rewrite without changing it (a non-finite number, a large integer or a tiny one a rewrite would round, -0, a duplicate member, or nesting deeper than 1000 levels)`) } as const; - } - const contribution = exportSpec.buildContribution(exportContextOf(input)); - // A record proves ownership of the file it was written FOR. Matching only by - // client id let a record for one home authorize a write to another whose - // bytes happened to hash the same — which deleted a config we never touched. - const stored = store.readRecords()[clientId] ?? null; - const record = stored && stored.clientId === clientId && stored.configPath === configPath - ? stored - : null; - // `configPath`/`clientId` are load-bearing, not decoration: a record proves - // ownership of ONE file, and the writer mutates whatever path resolves NOW. - // Without them a record written for another home directory would grant - // ownership here and disable would delete fragments it never wrote. - const classified = classifyIntegration({ - fileText: before, fileIsRegular: true, parsed, record, contribution, configPath, clientId, - }); - return { failed: undefined, store, io, clientId, spec, exportSpec, configPath, detectDir, before, parsed, contribution, record, classified } as const; + const observed = observeIntegration(input, { maintenance: true, recover: true }); + if (!observed.failed) return observed; + const { reason, state, message, snapshotPath, residual } = observed.failed; + const refused = refuse(input.clientId, reason, state, message, snapshotPath); + return { failed: residual ? { ...refused, residual: true } : refused } as const; } /** @@ -748,6 +680,15 @@ export function restoreIntegration(input: IntegrationRestoreInput): WriteOutcome export interface CoordinatedIntegrationOptions { lockSeams?: IntegrationWriterLockSeams; + /** + * Checked after the input is frozen and the writer lock is held, before any side effect. + * + * A confirmation is a statement about state the operator was shown. Checking it out here, where + * the lock already excludes cooperating writers, is what makes it a decision about the same + * state the mutation is about to change rather than about state from a moment earlier. A + * non-null result refuses without writing anything. + */ + revalidate?: (frozen: IntegrationWriteInput) => Promise; } /** Freeze all mutable resolution seams before the first lock await. */ @@ -759,6 +700,16 @@ type FrozenIntegrationInput = IntegrationWriteInput & { resolvedPaths: { configPath: string; detectDir: string }; }; +/** + * An input this write cannot hold still. + * + * A coordinated write checks a plan and then writes a document from the same input, with an await + * in between. Anything it cannot copy would have to be read from the caller's object twice, and + * the second read is not the one that was checked. Refusing is bounded and says so; carrying the + * reference and calling it a frozen input would not be. + */ +class UncopyableIntegrationInputError extends Error {} + function freezeIntegrationInput(input: IntegrationWriteInput): FrozenIntegrationInput { const env = { ...(input.env ?? process.env) }; const home = input.home ?? homedir(); @@ -773,7 +724,23 @@ function freezeIntegrationInput(input: IntegrationWriteInput): FrozenIntegration const resolvedPaths = input.resolvedPaths ? { ...input.resolvedPaths } : resolveIntegrationPaths(input.clientId, env, home); - return { ...input, env, home, store, io, resolvedPaths }; + /* + * The configuration and the roster are seams like the others, and they were the two still held + * by reference. A coordinated write plans from this input, awaits the writer lock and a + * revalidation, and only then serializes the document from it. A management route editing the + * live configuration, or a caller editing the model objects it passed in, would have been + * checked in one state and written from another, which is the substitution the fingerprint + * exists to prevent. Copying both here gives the plan and the document one input. + */ + const config = detachedConfigSnapshot(input.config); + if (config === null) { + throw new UncopyableIntegrationInputError("the proxy configuration could not be captured for this write"); + } + const models = copyPlainData(input.models); + if (!models.ok) { + throw new UncopyableIntegrationInputError("the model roster could not be captured for this write"); + } + return { ...input, config, models: models.value, env, home, store, io, resolvedPaths }; } function tryFreezeIntegrationInput(input: IntegrationWriteInput): @@ -782,6 +749,9 @@ function tryFreezeIntegrationInput(input: IntegrationWriteInput): try { return { ok: true, value: freezeIntegrationInput(input) }; } catch (error) { + if (error instanceof UncopyableIntegrationInputError) { + return { ok: false, refusal: refuse(input.clientId, "unsafe", "unsafe", error.message) }; + } if (!(error instanceof ClientPathError)) throw error; return { ok: false, @@ -799,15 +769,22 @@ async function coordinatedWrite( if (!prepared.ok) return prepared.refusal; const frozen = prepared.value; const spec = INTEGRATION_CLIENTS[frozen.clientId]; - if (!spec.writerLock) return operation(frozen); + if (!spec.writerLock) { + const refused = await options?.revalidate?.(frozen); + return refused ?? operation(frozen); + } // An absent client home is not created merely to acquire a sibling lock. if (frozen.io.statKind(frozen.resolvedPaths.detectDir) !== "dir") { - return operation(frozen); + const refused = await options?.revalidate?.(frozen); + return refused ?? operation(frozen); } return withIntegrationWriterLock( frozen.resolvedPaths.configPath, - async () => operation(frozen), + async () => { + const refused = await options?.revalidate?.(frozen); + return refused ?? operation(frozen); + }, options?.lockSeams, spec.writerLock.suffix, ); @@ -849,7 +826,11 @@ export async function restoreIntegrationCoordinated( if (!prepared.ok) return prepared.refusal; const frozen = prepared.value; const spec = INTEGRATION_CLIENTS[frozen.clientId]; - if (!spec.writerLock) return restoreIntegration({ ...frozen, opId: input.opId, confirmDrift: input.confirmDrift }); + const run = () => restoreIntegration({ ...frozen, opId: input.opId, confirmDrift: input.confirmDrift }); + if (!spec.writerLock) { + const refused = await options?.revalidate?.(frozen); + return refused ?? run(); + } if (frozen.io.statKind(frozen.resolvedPaths.detectDir) !== "dir") { return refuse( frozen.clientId, @@ -860,7 +841,12 @@ export async function restoreIntegrationCoordinated( } return withIntegrationWriterLock( frozen.resolvedPaths.configPath, - async () => restoreIntegration({ ...frozen, opId: input.opId, confirmDrift: input.confirmDrift }), + async () => { + // An undo is bound like any other confirmation, and this is the only place where that check + // happens with the lock held and before the snapshot, the write and the journal row. + const refused = await options?.revalidate?.(frozen); + return refused ?? run(); + }, options?.lockSeams, spec.writerLock.suffix, ); diff --git a/src/lib/plain-data.ts b/src/lib/plain-data.ts new file mode 100644 index 00000000000..e2f705abc92 --- /dev/null +++ b/src/lib/plain-data.ts @@ -0,0 +1,103 @@ +/** + * Copying a value the way a consumer that must not observe later edits needs it copied. + * + * Serializing with JSON is not a copier and using it as one is a quiet hole: it drops functions + * without saying so, and it invokes getters and toJSON, so the object being copied gets to decide + * what the copy contains. Every read here goes through a property descriptor instead, including + * array elements, which an exotic array can define as accessors just as an object can. + * + * The contract is narrow on purpose. This copies what JSON could have produced and refuses + * everything else rather than approximating it: an accessor, a cycle, a function, a class instance, + * a Map, a Date, a symbol value, a bigint, a non-finite number. A caller that is refused has to + * decide what to do about it, which is the point; a copier that silently degraded would leave the + * caller believing it held a snapshot of something it never read. + * + * Symbol-keyed properties are skipped rather than refused. Process bookkeeping rides on symbols by + * convention here, and none of it is the data a consumer is being given a copy of. + */ + +/** A copy, or a refusal. A union rather than a nullable value, because null is copyable data. */ +export type PlainDataCopy = { readonly ok: true; readonly value: T } | { readonly ok: false }; + +const REFUSED = Symbol("refused"); + +export function copyPlainData(value: T): PlainDataCopy { + const copied = copyValue(value, new Set()); + return copied === REFUSED ? { ok: false } : { ok: true, value: copied as T }; +} + +/** + * A stable string for comparing two plain-data values. + * + * Key-sorted entry pairs rather than objects, because property order is observable through + * serialization and two values that differ only in it are the same value. + */ +export function canonicalPlainData(value: unknown): string { + return JSON.stringify(sorted(value)); +} + +/** Own enumerable string keys, with null in the position of any key that is an accessor. */ +export function ownDataKeys(value: Record): Array { + return Object.keys(value).map(key => { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor !== undefined && descriptor.get === undefined && descriptor.set === undefined ? key : null; + }); +} + +export function isPlainObject(value: unknown): value is Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function copyValue(value: unknown, seen: Set): unknown { + if (value === null) return null; + const type = typeof value; + if (type === "string" || type === "boolean") return value; + if (type === "number") return Number.isFinite(value as number) ? value : REFUSED; + if (type !== "object") return REFUSED; + const object = value as object; + if (seen.has(object)) return REFUSED; + seen.add(object); + try { + if (Array.isArray(object)) return copyArray(object, seen); + if (!isPlainObject(object)) return REFUSED; + const copied: Record = {}; + for (const key of ownDataKeys(object)) { + if (key === null) return REFUSED; + const entry = (object as Record)[key]; + if (entry === undefined) continue; + const item = copyValue(entry, seen); + if (item === REFUSED) return REFUSED; + copied[key] = item; + } + return copied; + } finally { + seen.delete(object); + } +} + +function copyArray(source: unknown[], seen: Set): unknown { + const copied: unknown[] = []; + for (let index = 0; index < source.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(source, index); + // A hole is written as null, which is what a reader of the serialized value would find there. + if (descriptor === undefined) { copied.push(null); continue; } + if (descriptor.get !== undefined || descriptor.set !== undefined) return REFUSED; + if (descriptor.value === undefined) { copied.push(null); continue; } + const item = copyValue(descriptor.value, seen); + if (item === REFUSED) return REFUSED; + copied.push(item); + } + return copied; +} + +function sorted(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sorted); + if (value !== null && typeof value === "object") { + return Object.keys(value as Record) + .sort() + .map(key => [key, sorted((value as Record)[key])]); + } + return value; +} diff --git a/src/lib/provider-outbound.ts b/src/lib/provider-outbound.ts index 7b4a3b2cd4c..799c8d69fa6 100644 --- a/src/lib/provider-outbound.ts +++ b/src/lib/provider-outbound.ts @@ -160,7 +160,11 @@ async function providerOutboundRequest( if (postUrl?.protocol !== undefined && postUrl.protocol !== "https:") { throw new ProviderOutboundPolicyError("provider POST URL must use HTTPS"); } - if (provider.fetch) { + // A provider entry keeps unknown configuration keys, so `fetch` can arrive as a value the + // operator wrote into the file rather than an executor a caller attached. Calling that would + // throw inside discovery and fail the provider for a reason nothing in its configuration + // explains; the built-in transport is what a configured value means. + if (typeof provider.fetch === "function") { // A caller-owned executor cannot be peer-pinned here. This branch keeps literal/config // checks and redirect blocking, but does not provide the resolved-address guarantees of // the built-in transport. Main-request migration must define that executor contract first. diff --git a/src/server/management/aside-profile-routes.ts b/src/server/management/aside-profile-routes.ts index 8047e6c7a85..956dc8eafe0 100644 --- a/src/server/management/aside-profile-routes.ts +++ b/src/server/management/aside-profile-routes.ts @@ -3,14 +3,19 @@ import { ClientPathError } from "../../clients/config-export"; import { IntegrationMutationBusyError } from "../../integrations/mutation-flight"; import { IntegrationWriterLockBusyError } from "../../integrations/writer-lock"; import { - getAsideProfileState, listAsideProfileStates, mutateAsideProfiles, refreshAsideProfiles, - type AsideProfilesInput, + getAsideProfileState, listAsideProfileStates, mutateAsideProfiles, previewAsideProfile, refreshAsideProfiles, + type AsideProfilesInput, type AsideProfileWriteOutcome, } from "../../integrations/aside-profiles"; import { listAsideOperations, findAsideOperation, restoreAsideProfile, deleteAsideOperation, asideOperationMatchesCurrent, } from "../../integrations/aside-profile-journal"; +import type { AsideOperation } from "../../integrations/aside-profile-journal"; import type { WriteRefused } from "../../integrations/writer"; +import type { IntegrationWriteInput } from "../../integrations/writer"; +import type { IntegrationMutationPlan, IntegrationPlanOperation } from "../../integrations/mutation-plan"; +import { previewIntegration } from "../../integrations/mutation-plan"; +import { exportSnapshotIdentity, previewExportSnapshot } from "./model-rows"; import type { ManagementContext } from "./context"; import { readManagementJsonBody, readOptionalManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; import { jsonResponse } from "../auth-cors"; @@ -22,6 +27,142 @@ export interface AsideProfileRouteOptions { class ProfileQueryError extends Error { readonly status = 400; readonly code = "invalid_aside_profile"; } +/** + * The confirmed plan carried with this request, or null when there is none. + * + * Both fields or neither, and the operation must be the one being requested. Dropping a supplied + * binding would be the worst available behaviour: the caller believes their confirmation is being + * checked while the mutation proceeds unchecked. + */ +function asideBinding( + body: { operation?: unknown; planFingerprint?: unknown }, + expected: IntegrationPlanOperation, +): { operation: IntegrationPlanOperation; fingerprint: string } | null { + const { operation, planFingerprint } = body; + if (operation === undefined && planFingerprint === undefined) return null; + if (operation === undefined || typeof planFingerprint !== "string" || planFingerprint.length === 0) { + throw new ProfileQueryError("operation and planFingerprint must be sent together"); + } + if (operation !== expected) throw new ProfileQueryError("operation does not match the requested change"); + return { operation: expected, fingerprint: planFingerprint }; +} + +/** + * The guard a bound profile change runs before it writes anything. + * + * It re-plans the same profile scope and compares. The hooks that call it sit ahead of the + * preference write and the journal import, which is the only placement that helps here: those two + * happen before any writer lock is taken, so a check under that lock would fire after the thing it + * was meant to prevent. + */ +/** + * The check a bound Aside change runs, against the input that change is about to write from. + * + * It plans the prepared input it is given rather than building one of its own. Rebuilding meant + * reading the live configuration a second time, so a configuration edited to something else and + * back again while this action was in flight produced a check that agreed with a plan the write + * never described. + * + * Exported so the check itself can be exercised with a real prepared input and a roster that + * actually moved. The window it closes needs an ordinary load to complete while a mutation is + * preparing, which no test seam reaches from outside; a copy of the guard would prove nothing + * about the one the route installs. It takes only the configuration from the request context. + */ +export function asideGuardFor( + ctx: Pick, + capturedIdentity: string, + profileIdValue: number, + binding: { operation: IntegrationPlanOperation; fingerprint: string }, + request: { opId?: string; confirmDrift?: boolean; resolved?: AsideOperation }, + capture: { plan: IntegrationMutationPlan | null }, +): (prepared: IntegrationWriteInput) => Promise { + return async prepared => { + const plan = planFor(prepared, profileIdValue, binding, request); + /* + * The roster this confirmation was planned against has to still be the retained one. Only its + * rows were carried into the mutation, so an ordinary load completing while this action + * prepared could have replaced or retired the snapshot, and fingerprinting the carried rows + * would then accept a confirmation for a roster the operator no longer has. The non-Aside + * guard verifies the same identity; this one verifies it before the preference write, which is + * the last moment that still precedes every effect this action has. + */ + const rosterMoved = exportSnapshotIdentity(ctx.config) !== capturedIdentity; + if (!rosterMoved && plan.canApply && plan.fingerprint === binding.fingerprint) return null; + capture.plan = plan; + return { + ok: false, + reason: "conflict", + state: plan.state, + clientId: "aside", + message: rosterMoved + ? "the model roster changed while confirming" + : "that confirmation no longer describes this profile", + profileId: profileIdValue, + }; + }; +} + +/** + * The same comparison, run again immediately before the document is written. + * + * Aside takes no writer lock, and its preference write and journal import sit between the first + * check and the write that check authorizes. A target edited in that window is read fresh by the + * writer, which then compares it against itself and finds nothing to object to, so a confirmation + * about the earlier file would still overwrite the later one. + * + * The roster is deliberately not re-examined here. This action has just written the operator's + * preference into the configuration, which retires the retained roster by design, so asking that + * question at this point would refuse every confirmation on principle. What is asked is the one + * thing this moment can answer: does the plan the operator confirmed still describe this file. + */ +export function asideLateGuardFor( + profileIdValue: number, + binding: { operation: IntegrationPlanOperation; fingerprint: string }, + request: { opId?: string; confirmDrift?: boolean; resolved?: AsideOperation }, + capture: { plan: IntegrationMutationPlan | null }, +): (prepared: IntegrationWriteInput) => Promise { + return async prepared => { + const plan = planFor(prepared, profileIdValue, binding, request); + if (plan.canApply && plan.fingerprint === binding.fingerprint) return null; + capture.plan = plan; + return { + ok: false, + reason: "conflict", + state: plan.state, + clientId: "aside", + message: "that confirmation no longer describes this profile", + profileId: profileIdValue, + }; + }; +} + +function planFor( + prepared: IntegrationWriteInput, + profileIdValue: number, + binding: { operation: IntegrationPlanOperation; fingerprint: string }, + request: { opId?: string; confirmDrift?: boolean; resolved?: AsideOperation }, +): IntegrationMutationPlan { + return previewIntegration(prepared, { + profileId: profileIdValue, + operation: binding.operation, + ...(request.opId === undefined ? {} : { opId: request.opId }), + ...(request.confirmDrift === undefined ? {} : { confirmDrift: request.confirmDrift }), + // The row the route selected, so the guard and the mutation mean the same operation even + // when more than one valid copy exists. + ...(request.resolved === undefined + ? {} + : { resolved: { entry: request.resolved.entry, store: request.resolved.store } }), + }); +} + +function stalePlanResponse(ctx: ManagementContext, plan: IntegrationMutationPlan): Response { + return jsonResponse({ + error: "integration preview is stale", + code: "integration_preview_stale", + plan, + }, 409, ctx.req, ctx.config); +} + const ASIDE_INTEGRATION_PATH = "/api/client-integrations/aside"; const ASIDE_PROFILES_PATH = "/api/client-integrations/aside/profiles"; @@ -75,7 +216,7 @@ function nestedProfileContext(ctx: ManagementContext): { ctx: ManagementContext; url.searchParams.set("client", "aside"); return { ctx: { ...ctx, url }, action: "journal" }; } - if (parts.length > 2 || !parts[0] || (parts[1] !== undefined && !["journal", "restore"].includes(parts[1]))) { + if (parts.length > 2 || !parts[0] || (parts[1] !== undefined && !["journal", "restore", "preview"].includes(parts[1]))) { throw new ProfileQueryError("Invalid Aside profile path"); } const prior = url.searchParams.get("profile"); @@ -107,12 +248,62 @@ export async function handleAsideProfileRoutes( } return null; } + if (normalized.action === "preview") { + if (req.method !== "POST") return null; + // A plan describes one profile, so an unscoped preview has nothing to describe. + if (id === undefined) throw new ProfileQueryError("a plan applies to one profile"); + const body = await readProfileBody(req); + if (!isObject(body)) throw new ProfileQueryError("preview body must be an object"); + const operation = body.operation; + if (operation !== "apply" && operation !== "overwrite" && operation !== "disable" && operation !== "restore") { + throw new ProfileQueryError("operation must be apply, overwrite, disable or restore"); + } + if (operation === "restore" && (typeof body.opId !== "string" || !body.opId.trim())) { + throw new ProfileQueryError("opId must be a non-empty string"); + } + // The preview resolves the row the same way the mutation will, once, here. + const selected = operation === "restore" + ? findAsideOperation(options.input(), String(body.opId).trim(), id) + : null; + if (operation === "restore" && selected === null) { + return jsonResponse({ + error: "integration operation not found", + code: "integration_operation_not_found", + }, 404, req, ctx.config); + } + const roster = previewExportSnapshot(ctx.config); + if (roster === null) { + return jsonResponse({ + error: "no model roster is cached yet, so this change cannot be planned", + code: "integration_preview_unavailable", + }, 409, req, ctx.config); + } + const plan = await previewAsideProfile({ ...options.input(), models: roster.models }, { + profileId: id, + operation, + ...(operation === "restore" + ? { + opId: String(body.opId).trim(), + confirmDrift: body.confirmDrift === true, + ...(selected === null ? {} : { resolved: { entry: selected.entry, store: selected.store } }), + } + : {}), + }); + return jsonResponse(plan, 200, req, ctx.config); + } if (normalized.action === "restore") { if (req.method !== "POST") return null; const body = await readProfileBody(req); if (!isObject(body) || typeof body.opId !== "string" || !body.opId.trim() || (body.confirmDrift !== undefined && typeof body.confirmDrift !== "boolean")) throw new ProfileQueryError("Invalid Aside restore request"); - return asideRestoreResponse(ctx, { opId: body.opId.trim(), confirmDrift: body.confirmDrift === true }, options); + // Rebuilding the body here previously dropped any binding, so a bound nested restore + // reached the writer with its confirmation unexamined. Forward the fields as sent. + return asideRestoreResponse(ctx, { + opId: body.opId.trim(), + confirmDrift: body.confirmDrift === true, + ...(body.operation === undefined ? {} : { operation: body.operation }), + ...(body.planFingerprint === undefined ? {} : { planFingerprint: body.planFingerprint }), + }, options); } if (url.pathname === "/api/client-integrations/aside/sync") { if (req.method !== "POST") return null; @@ -134,7 +325,38 @@ export async function handleAsideProfileRoutes( if (!isObject(body) || typeof body.enabled !== "boolean") throw new ProfileQueryError("enabled must be a boolean"); if (body.overwriteConflict !== undefined && typeof body.overwriteConflict !== "boolean") throw new ProfileQueryError("overwriteConflict must be a boolean"); if (body.overwriteConflict === true && !body.enabled) throw new ProfileQueryError("overwriteConflict applies only to enabling an integration"); - const batch = await mutateAsideProfiles(options.input(), { enabled: body.enabled, profileId: id, overwriteConflict: body.overwriteConflict === true }); + // An exact profile is required before a binding could ever mean anything: one fingerprint + // cannot honestly describe several independently changing files. + const expected: IntegrationPlanOperation = body.enabled + ? (body.overwriteConflict === true ? "overwrite" : "apply") + : "disable"; + if ((body.operation !== undefined || body.planFingerprint !== undefined) && id === undefined) { + throw new ProfileQueryError("a confirmed plan applies to one profile"); + } + const binding = asideBinding(body, expected); + const capture: { plan: IntegrationMutationPlan | null } = { plan: null }; + let mutationInput = options.input(); + let revalidate: ((prepared: IntegrationWriteInput) => Promise) | undefined; + let revalidateBeforeWrite: ((prepared: IntegrationWriteInput) => Promise) | undefined; + if (binding !== null && id !== undefined) { + const roster = previewExportSnapshot(ctx.config); + if (roster === null) { + return jsonResponse({ + error: "no model roster is cached yet, so this change cannot be planned", + code: "integration_preview_unavailable", + }, 409, req, ctx.config); + } + // One roster for the guard and the mutation, so they cannot disagree by construction. + mutationInput = { ...mutationInput, models: roster.models }; + revalidate = asideGuardFor(ctx, roster.identity, id, binding, {}, capture); + revalidateBeforeWrite = asideLateGuardFor(id, binding, {}, capture); + } + const batch = await mutateAsideProfiles( + mutationInput, + { enabled: body.enabled, profileId: id, overwriteConflict: body.overwriteConflict === true }, + revalidate ? { revalidate, ...(revalidateBeforeWrite ? { revalidateBeforeWrite } : {}) } : undefined, + ); + if (capture.plan) return stalePlanResponse(ctx, capture.plan); if (id !== undefined) { const result = batch.results[0]; if (!result) throw new Error("Aside profile mutation returned no result"); @@ -180,7 +402,9 @@ export async function asideJournalResponse( } export async function asideRestoreResponse( - ctx: ManagementContext, body: { opId: string; confirmDrift?: boolean }, options: AsideProfileRouteOptions, + ctx: ManagementContext, + body: { opId: string; confirmDrift?: boolean; operation?: unknown; planFingerprint?: unknown }, + options: AsideProfileRouteOptions, ): Promise { try { validateClientSelector(ctx); @@ -196,7 +420,42 @@ export async function asideRestoreResponse( if (id === undefined) return null; return jsonResponse({ error: "integration operation not found", code: "integration_operation_not_found", opId: body.opId }, 404, ctx.req, ctx.config); } - const result = await restoreAsideProfile(input, { ...body, profileId: operation.profileId }); + if ((body.operation !== undefined || body.planFingerprint !== undefined) && id === undefined) { + throw new ProfileQueryError("a confirmed plan applies to one profile"); + } + const restoreBinding = asideBinding(body, "restore"); + const capture: { plan: IntegrationMutationPlan | null } = { plan: null }; + let restoreInput = input; + let revalidate: ((prepared: IntegrationWriteInput) => Promise) | undefined; + let revalidateBeforeWrite: ((prepared: IntegrationWriteInput) => Promise) | undefined; + if (restoreBinding !== null) { + const roster = previewExportSnapshot(ctx.config); + if (roster === null) { + return jsonResponse({ + error: "no model roster is cached yet, so this change cannot be planned", + code: "integration_preview_unavailable", + }, 409, ctx.req, ctx.config); + } + restoreInput = { ...restoreInput, models: roster.models }; + revalidate = asideGuardFor(ctx, roster.identity, operation.profileId, restoreBinding, { + opId: body.opId, + confirmDrift: body.confirmDrift === true, + resolved: operation, + }, capture); + revalidateBeforeWrite = asideLateGuardFor(operation.profileId, restoreBinding, { + opId: body.opId, + confirmDrift: body.confirmDrift === true, + resolved: operation, + }, capture); + } + const result = await restoreAsideProfile( + restoreInput, + // The same row the preview and guard used. Letting the mutation resolve its own copy is how + // a confirmation ends up bound to an operation other than the one that runs. + { ...body, profileId: operation.profileId, selectedOperation: operation }, + revalidate ? { revalidate, ...(revalidateBeforeWrite ? { revalidateBeforeWrite } : {}) } : undefined, + ); + if (capture.plan) return stalePlanResponse(ctx, capture.plan); return result.ok ? jsonResponse(result, 200, ctx.req, ctx.config) : options.failure(result); } catch (error) { if (error instanceof ClientPathError && !ctx.url.searchParams.has("profile") diff --git a/src/server/management/integration-routes.ts b/src/server/management/integration-routes.ts index 2363e745d5d..7ae8a16d466 100644 --- a/src/server/management/integration-routes.ts +++ b/src/server/management/integration-routes.ts @@ -31,6 +31,7 @@ import { disableIntegrationCoordinated, overwriteIntegrationCoordinated, restoreIntegrationCoordinated, + type CoordinatedIntegrationOptions, type IntegrationRestoreInput, type IntegrationWriteInput, type WriteRefused, @@ -45,7 +46,13 @@ import { import { jsonResponse } from "../auth-cors"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; import type { ManagementContext } from "./context"; -import { loadExportModels } from "./model-rows"; +import { exportSnapshotIdentity, loadExportModels, previewExportSnapshot } from "./model-rows"; +import { + previewIntegration, + type IntegrationMutationPlan, + type IntegrationPlanOperation, + type PreviewRequest, +} from "../../integrations/mutation-plan"; const INTEGRATION_ROUTE_PREFIX = "/api/client-integrations/"; @@ -246,6 +253,37 @@ async function buildIntegrationWriteInput( }; } +/** + * The same input a mutation would build, from the read-only roster. + * + * It differs from the mutation's in exactly one way, and the difference is deliberate: the roster + * comes from `previewExportModels`, which gathers without running the initial-selection finalizer + * that persists configuration. Everything else is shared, so a preview and the commit that follows + * it cannot disagree for any reason except the state genuinely moving. + */ +async function buildIntegrationPreviewInput( + clientId: IntegrationClientId, + ctx: ManagementContext, + store: IntegrationStateStore, +): Promise<{ input: IntegrationWriteInput; identity: string } | null> { + const snapshot = previewExportSnapshot(ctx.config); + // No cached roster means no honest snapshot to plan against. Gathering one here would make a + // read refresh credentials and write the provider cache, which is the thing preview must not do. + if (snapshot === null) return null; + return { + identity: snapshot.identity, + input: { + clientId, + models: snapshot.models, + config: ctx.config, + port: Number(ctx.url.port) || ctx.config.port, + store, + io: integrationMutationTestHooks?.io, + ...pathOverrides(), + }, + }; +} + /** * The file's current bytes, or `null` when it is missing. * @@ -271,6 +309,97 @@ function invalidClientResponse(ctx: ManagementContext): Response { }, 400, ctx.req, ctx.config); } +/** + * No cached model roster, so there is nothing honest to plan against. + * + * Answered as a bounded refusal rather than by gathering one: discovery refreshes credentials and + * writes the provider cache, and a preview that did either would be a write wearing a read's name. + * The caller opens the models view or performs the mutation directly. + */ +function previewUnavailableResponse(ctx: ManagementContext): Response { + return jsonResponse({ + error: "no model roster is cached yet, so this change cannot be planned", + code: "integration_preview_unavailable", + }, 409, ctx.req, ctx.config); +} + +/** + * A confirmed plan, or a reason the request cannot carry one. + * + * Both fields or neither. A half-bound request is rejected rather than quietly treated as + * unbound, because dropping one half would answer 200 to a caller who believed their + * confirmation was being checked. + */ +function planBindingOf( + body: Record, +): { operation: IntegrationPlanOperation; fingerprint: string } | "none" | "half" | "unknown-operation" { + const { operation, planFingerprint } = body; + if (operation === undefined && planFingerprint === undefined) return "none"; + if (operation === undefined || typeof planFingerprint !== "string" || planFingerprint.length === 0) return "half"; + if (operation !== "apply" && operation !== "overwrite" && operation !== "disable" && operation !== "restore") { + return "unknown-operation"; + } + return { operation, fingerprint: planFingerprint }; +} + +function halfBoundResponse(ctx: ManagementContext): Response { + return jsonResponse({ + error: "operation and planFingerprint must be sent together", + code: "invalid_preview_binding", + }, 400, ctx.req, ctx.config); +} + +/** + * Re-plan and compare before the mutation runs. + * + * The fingerprint is an optimistic token, never authorization: management authentication and + * every ownership rule still apply. What it adds is that a confirmation stops meaning anything + * the moment the state it described moved, and the refusal carries a fresh plan so the operator + * decides again against what is true now. + */ +function stalePlanGuard( + clientId: IntegrationClientId, + ctx: ManagementContext, + store: IntegrationStateStore, + request: PreviewRequest, + fingerprint: string, + capturedIdentity: string | null, +): { + revalidate: NonNullable; + response: () => Response | null; +} { + let stale: IntegrationMutationPlan | "unavailable" | null = null; + return { + revalidate: async frozen => { + /* + * Plan the coordinator's OWN frozen input, never a freshly built one. Rebuilding here let + * the check validate against one roster while the mutation wrote from another, because an + * ordinary load can replace the snapshot at any time and nothing serialises that against + * this lock. The captured identity is verified separately, so a replacement is detected + * without ever swapping the roster this mutation is about to use. + */ + if (exportSnapshotIdentity(ctx.config) !== capturedIdentity) { + const refreshed = await buildIntegrationPreviewInput(clientId, ctx, store); + stale = refreshed === null ? "unavailable" : previewIntegration(refreshed.input, request); + return { ok: false, reason: "conflict", state: "conflict", clientId, message: "the model roster changed while confirming" }; + } + const plan = previewIntegration(frozen, request); + if (plan.canApply && plan.fingerprint === fingerprint) return null; + stale = plan; + return { ok: false, reason: "conflict", state: plan.state, clientId, message: "that confirmation no longer describes this file" }; + }, + response: () => { + if (stale === null) return null; + if (stale === "unavailable") return previewUnavailableResponse(ctx); + return jsonResponse({ + error: "integration preview is stale", + code: "integration_preview_stale", + plan: stale, + }, 409, ctx.req, ctx.config); + }, + }; +} + function internalErrorResponse(error: unknown, ctx: ManagementContext): Response { return jsonResponse({ error: error instanceof Error ? error.message : String(error), @@ -565,6 +694,88 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise restoreIntegrationCoordinated(restoreInput, { lockSeams: integrationMutationTestHooks?.lockSeams, + ...(restoreGuard ? { revalidate: restoreGuard.revalidate } : {}), }), ); + const restoreStale = restoreGuard?.response(); + if (restoreStale) return restoreStale; if (!result.ok) { /* * Drift is NOT special-cased here. @@ -698,20 +941,59 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise { - const options = { lockSeams: integrationMutationTestHooks?.lockSeams }; + const options = { + lockSeams: integrationMutationTestHooks?.lockSeams, + ...(guard ? { revalidate: guard.revalidate } : {}), + }; if (!parsed.enabled) return disableIntegrationCoordinated(input, options); return parsed.overwriteConflict === true ? overwriteIntegrationCoordinated(input, options) : applyIntegrationCoordinated(input, options); }, ); + const stale = guard?.response(); + if (stale) return stale; if (!result.ok) return writerFailureResponse(requestedClient, result, ctx); return jsonResponse(result satisfies IntegrationToggleEnvelope, 200, req, ctx.config); } catch (error) { diff --git a/src/server/management/model-rows.ts b/src/server/management/model-rows.ts index e406979468a..a78d9ca3bbc 100644 --- a/src/server/management/model-rows.ts +++ b/src/server/management/model-rows.ts @@ -9,6 +9,12 @@ * Bodies are unchanged from their previous home; only `export` was added. */ import type { CatalogModel } from "../../codex/catalog"; +import { observeModelCacheRevision } from "../../codex/model-cache"; +import { + captureExportConfigAdmission, + isExportConfigAdmissionCurrent, + type ExportConfigAdmission, +} from "../../config/admitted-identity"; import { catalogModelSlug, filterCatalogVisibleModels, @@ -28,7 +34,7 @@ import { routedSlug, slugEquals } from "../../providers/slug-codec"; import type { OcxConfig } from "../../types"; import { ensureCodexEntitlementFreshness } from "../../codex/model-entitlements"; import { fetchAllModels } from "./shared"; -import { initialModelSelectionPending } from "../../providers/initial-model-selection"; +import { initialModelSelectionPending, pendingModelSelectionProviders } from "../../providers/initial-model-selection"; import { catalogFastRowEligible, fastRowId } from "../fast-row"; import { knownEffortRowIds } from "../effort-row"; @@ -77,14 +83,29 @@ export function effectiveManagementDisplayName( */ export async function listManagementModelRows( config: OcxConfig, - options: { entitlementWaitMs?: number } = {}, + options: { + entitlementWaitMs?: number; + models?: readonly CatalogModel[]; + /** Filled with each provider's content revision as of the moment its rows were chosen. */ + providerContentRevisions?: Map; + } = {}, ): Promise { - const [models] = await Promise.all([ - fetchAllModels(config), - ensureCodexEntitlementFreshness(config, { - waitMs: options.entitlementWaitMs ?? 3_000, - }), - ]); + /* + * A supplied roster skips the gather, and that is the point rather than an optimization. + * `fetchAllModels` reaches providers and can persist an initial model selection, which a + * read-only caller must not do. Everything below this line is the projection — the disabled + * computation, native and account-bound rows, custom rows and the public list — so a caller + * that brings its own roster still sees exactly what a writer would, and the two cannot + * disagree about the roster for any reason except the roster itself. + */ + const models = options.models === undefined + ? (await Promise.all([ + fetchAllModels(config, options.providerContentRevisions), + ensureCodexEntitlementFreshness(config, { + waitMs: options.entitlementWaitMs ?? 3_000, + }), + ]))[0] + : [...options.models]; const disabled = new Set(config.disabledModels ?? []); // Native GPT passthrough rows lead (provider "openai", bare-slug namespaced ids): sourced // from the static supported set so a disabled model stays listed and re-enableable. @@ -236,10 +257,201 @@ export function toExportModel(row: ManagementModelRow): ExportModel { * tab is absent from `/v1/models` and exporting it would hand the client a * selector the proxy refuses to route. */ -export async function loadExportModels(config: OcxConfig): Promise { - const rows = await listManagementModelRows(config); +export async function loadExportModels( + config: OcxConfig, + models?: readonly CatalogModel[], +): Promise { + // Initial selection adopts into the live configuration and persists it, so it has to finish + // before anything is admitted. Admitting first would bind this roster to bytes the same load is + // about to rewrite, and finalizing against a detached copy would adopt the choices into the copy + // while leaving the live configuration pending. + if (models === undefined && pendingModelSelectionProviders(config).size > 0) { + const { resolvePendingInitialModelSelection } = await import("../../providers/initial-model-selection-runtime"); + await resolvePendingInitialModelSelection(config); + } + // The configuration this pass will use from beginning to end, proved to be the one on disk. + // Without it there is nothing that may be retained, and the caller still gets its rows: only the + // preview authority is withheld. + const admission = captureExportConfigAdmission(config); + const admitted = admission?.config ?? config; + // The gather stamps each provider as it chooses its rows, so the roster and the revisions that + // vouch for it come from the same moment. Sampling afterwards would let a concurrent flight's + // publication be recorded against rows it never produced. + const gathered = new Map(); + // Gathering here rather than through the shared fetch is what keeps the detached copy out of the + // initial-selection finalizer: the projection below takes a roster, and that branch performs no + // discovery and no configuration write. The entitlement refresh keeps the budget it has always + // had, and runs alongside as it did inside the projection. + const roster = models === undefined + ? (await Promise.all([ + (await import("../../codex/catalog")).gatherRoutedModels(admitted, { providerContentRevisions: gathered }), + ensureCodexEntitlementFreshness(admitted, { waitMs: 3_000 }), + ]))[0] + : models; + const rows = await listManagementModelRows(admitted, { models: roster }); // Management deliberately lists the full roster so hidden models can be enabled. // A client picker must also honor the provider selection, not just its blocklist. - const visibleRouted = new Set(filterCatalogVisibleModels(rows.filter(row => !row.native), config)); - return rows.filter(row => !row.disabled && (row.native || visibleRouted.has(row))).map(toExportModel); + const visibleRouted = new Set(filterCatalogVisibleModels(rows.filter(row => !row.native), admitted)); + const exported = rows.filter(row => !row.disabled && (row.native || visibleRouted.has(row))).map(toExportModel); + // Retain the FINAL projection, not an input to it. A preview that rebuilt from raw provider + // caches would miss static and forward providers, which never populate one, and would skip the + // retention, metadata, combo and filtering this function applies afterwards. + // A deep clone, not a frozen view of the caller's array. Freezing the array alone left the model + // objects shared, so a caller mutating one in place would have silently rewritten the roster a + // later preview plans against, and the fingerprint would have moved with it. + // Still the configuration these rows were chosen under, on disk and in hand alike. Revalidating + // rather than re-reading an identity is what makes this fail closed: a configuration that moved + // during the load leaves no snapshot rather than one recorded under a state its rows never had. + if (admission === null || !isExportConfigAdmissionCurrent(admission, config)) { + lastExportSnapshot = null; + return exported; + } + // Prefer the revisions the gather stamped; fall back to observing only when the roster was + // supplied and no gather happened, where there is nothing tighter to use. + const cacheStamp = gathered.size > 0 ? stampFrom(admitted, gathered) : modelCacheStamp(admitted); + const retained = lastExportSnapshot; + /* + * An identical roster keeps the identity it already had. + * + * The generation moved on every load, so an ordinary read that rebuilt the same rows, which the + * Integrations collection does, invalidated a confirmation an operator was in the middle of + * submitting. Nothing about the roster had changed; only the counter had. The rows themselves + * are compared rather than assumed equal from the configuration and the cache stamp, because a + * projection also reads entitlement state neither of those two describes. + */ + const projection = Object.freeze(structuredClone(exported)); + if (retained !== null + && retained.cacheStamp === cacheStamp + && isExportConfigAdmissionCurrent(retained.admission, config) + && JSON.stringify(retained.models) === JSON.stringify(projection)) { + return exported; + } + lastExportSnapshot = { admission, cacheStamp, generation: ++exportSnapshotGeneration, models: projection }; + return exported; +} + +/** + * The completed export roster from the last ordinary load, if it still describes this config. + * + * A preview may not gather, so it reads only what an authoritative load already finished. The + * admission it carries proved, when the roster was built, that the configuration in hand was the + * one on disk; a later read repeats that proof, so a rewritten file, an edited resident object or + * a mutated working copy each retire the snapshot rather than letting a preview plan against a + * configuration nobody has. + * + * A cold process has no snapshot and the caller answers a bounded refusal, and an ordinary load + * populates one: the Integrations collection read calls `loadExportModels`, so the page an + * operator opens before confirming anything is usually the page that fills this in. That is not a + * repair for every refusal. A configuration that disagrees with its file keeps refusing however + * many times the page is opened, because nothing here reloads or reconciles anything; once the + * two agree again the next ordinary read rebuilds the snapshot by itself. + */ +let lastExportSnapshot: + | { admission: ExportConfigAdmission; cacheStamp: string; generation: number; models: readonly ExportModel[] } + | null = null; +let exportSnapshotGeneration = 0; + +/** + * A process-local prefix for the roster identity a caller carries between a preview and the + * mutation that confirms it. + * + * The identity used to be the configuration digest with a counter appended, which handed a + * dashboard an opaque-looking string that was in fact a fingerprint of the operator's + * configuration file. It only has to be unforgeable within this process and distinct across + * restarts, so it says nothing about the configuration at all. + */ +const rosterIdentityPrefix = `r${Math.trunc(Math.random() * 0xffffffff).toString(36)}`; + +function rosterIdentity(generation: number): string { + return `${rosterIdentityPrefix}:${generation}`; +} + +/** + * Where the gathered half of the roster stands, observed without changing it. + * + * The config key cannot see a provider's models changing underneath an unchanged configuration, + * which is exactly what discovery does. This reads the cache's own generation for each configured + * provider through the passive observer, so a completed discovery retires the snapshot and a + * preview stops planning against a roster that no longer reflects the provider. + */ +function modelCacheStamp(config: OcxConfig): string { + return Object.keys(config.providers ?? {}) + .sort() + .map(provider => `${provider}=${observeModelCacheRevision(provider)}`) + .join(","); +} + +/** + * The same stamp shape, built from revisions the gather recorded rather than from observation. + * + * A provider the gather did not report falls back to observation so the stamp stays total; that + * happens for a provider configured after the rows were chosen, and it retires the snapshot on + * the next read rather than pretending the roster covered it. + */ +function stampFrom(config: OcxConfig, gathered: ReadonlyMap): string { + return Object.keys(config.providers ?? {}) + .sort() + .map(provider => `${provider}=${gathered.get(provider) ?? observeModelCacheRevision(provider)}`) + .join(","); +} + +/** + * Opaque identity of the snapshot a caller is holding, or null when there is none for this config. + * + * A fingerprint check that rebuilt its own roster could validate against one snapshot while the + * mutation wrote from another, because an ordinary load can replace the snapshot at any moment and + * nothing about that is serialised against the writer lock. Carrying this identity alongside the + * captured roster lets a revalidation prove the snapshot it captured is still the current one + * without ever swapping the roster the mutation is about to use. + */ +export function exportSnapshotIdentity(config: OcxConfig): string | null { + const snapshot = lastExportSnapshot; + if (snapshot === null) return null; + if (!isExportConfigAdmissionCurrent(snapshot.admission, config)) return null; + if (snapshot.cacheStamp !== modelCacheStamp(config)) return null; + return rosterIdentity(snapshot.generation); +} + +/** Test seam: a fresh process has no snapshot, and suites must be able to reproduce that. */ +export function resetExportSnapshotForTests(): void { + lastExportSnapshot = null; +} + +/** + * The export roster for a read that must change nothing at all, or null when there is not one. + * + * Skipping the initial-selection finalizer was not enough. Discovery itself refreshes credentials + * and writes the provider model cache, so a preview that gathered would still be a write dressed + * as a read, and "the models list already does this" describes what a GET happens to do rather + * than what a preview is allowed to do. + * + * So this reads already-captured per-provider cache entries and never fetches. When no provider + * has a cached roster there is no honest snapshot to plan against, and the caller reports a + * bounded refusal rather than triggering a gather to manufacture one. + */ +export function previewExportSnapshot( + config: OcxConfig, +): { models: readonly ExportModel[]; identity: string } | null { + // One synchronous read of one const. Taking the roster and its identity in two steps let a + // concurrent load publish a new snapshot between them, so a caller could hold one roster while + // believing it held the identity of another. + const snapshot = lastExportSnapshot; + if (snapshot === null) return null; + // The roster was built from a configuration proved to be the one on disk; this asks whether both + // are still that same configuration, and reads nothing but the file to answer. + if (!isExportConfigAdmissionCurrent(snapshot.admission, config)) return null; + // A completed discovery retires the snapshot: the configuration is unchanged, but the models it + // resolves to are not the ones this roster was built from. + if (snapshot.cacheStamp !== modelCacheStamp(config)) return null; + // Cloned on the way out as well as on the way in. The retained copy is the authority, and a + // reader holding its objects could edit the roster every later preview plans against without + // going anywhere near this module. + return { + models: structuredClone(snapshot.models) as readonly ExportModel[], + identity: rosterIdentity(snapshot.generation), + }; +} + +export function previewExportModels(config: OcxConfig): readonly ExportModel[] | null { + return previewExportSnapshot(config)?.models ?? null; } diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index c01de4cebcd..95dc71be749 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -42,6 +42,13 @@ export type ExemptionReason = | "scrape-target" /** Older clients use this alias; the current CLI drives its declared replacement. */ | "compatibility-alias" + /** + * A read-only POST whose purpose is to bind an interactive confirmation to the mutation that + * immediately follows it. There is no standalone thing for a CLI to do with one: the plan is + * only meaningful to the caller that is about to commit it, and a scripted caller drives the + * mutation directly. + */ + | "interactive-preview" /** Unreachable in the live dispatch order; delete rather than expose. */ | "dead" /** @@ -161,6 +168,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "GET", path: "/api/client-integrations/aside/profiles/journal", module: "server/management/aside-profile-routes", mutates: false, mechanism: "prefix-decode" }, { method: "DELETE", path: "/api/client-integrations/aside/profiles/journal", module: "server/management/aside-profile-routes", mutates: true, mechanism: "prefix-decode", exempt: { reason: "deferred-verb", why: "Aside history deletion uses the dashboard journal cleanup; the CLI has history and restore but no deletion verb yet.", owner: "260904_priority65_closeout WP7", ownerDoc: "devlog/_fin/260904_priority65_closeout/060_wp7_rollback_journal_crud.md" } }, { method: "GET", path: "/api/client-integrations/aside/profiles/{profileId}/journal", module: "server/management/aside-profile-routes", mutates: false, mechanism: "prefix-decode" }, + { method: "POST", path: "/api/client-integrations/aside/profiles/{profileId}/preview", module: "server/management/aside-profile-routes", mutates: false, mechanism: "prefix-decode", exempt: { reason: "interactive-preview", why: "Plans one Aside profile's change so a dashboard confirmation can show what would change and bind to it. Read-only, and useful only to the caller about to commit; a scripted caller drives the profile toggle or restore directly." } }, { method: "DELETE", path: "/api/client-integrations/aside/profiles/{profileId}/journal", module: "server/management/aside-profile-routes", mutates: true, mechanism: "prefix-decode", exempt: { reason: "deferred-verb", why: "Aside profile history deletion uses the dashboard journal cleanup; the CLI has scoped history and restore but no deletion verb yet.", owner: "260904_priority65_closeout WP7", ownerDoc: "devlog/_fin/260904_priority65_closeout/060_wp7_rollback_journal_crud.md" } }, { method: "POST", path: "/api/client-integrations/aside/profiles/{profileId}/restore", module: "server/management/aside-profile-routes", mutates: true, mechanism: "prefix-decode" }, // server/management/codex-prompt-routes @@ -199,6 +207,8 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "GET", path: "/api/client-integrations/journal", module: "server/management/integration-routes", mutates: false }, { method: "DELETE", path: "/api/client-integrations/journal", module: "server/management/integration-routes", mutates: true, exempt: { reason: "deferred-verb", why: "Retiring one rollback row is a dashboard-local cleanup; the CLI verb that would drive it is owed by a later work-phase and is not implemented here.", owner: "260904_priority65_closeout WP7", ownerDoc: "devlog/_fin/260904_priority65_closeout/060_wp7_rollback_journal_crud.md" } }, { method: "POST", path: "/api/client-integrations/restore", module: "server/management/integration-routes", mutates: true }, + { method: "POST", path: "/api/client-integrations/preview", module: "server/management/integration-routes", mutates: false, exempt: { reason: "interactive-preview", why: "Plans an apply, overwrite or disable so a dashboard confirmation can show what would change and bind to it. Read-only, and useful only to the caller about to commit; a scripted caller drives PUT /api/client-integrations/{clientId} directly." } }, + { method: "POST", path: "/api/client-integrations/restore/preview", module: "server/management/integration-routes", mutates: false, exempt: { reason: "interactive-preview", why: "Plans an undo so drift is shown before a restore rather than discovered by a rejected mutation. Read-only, and useful only to the caller about to commit; a scripted caller drives POST /api/client-integrations/restore directly." } }, // server/management/lab-automation-routes { method: "GET", path: "/api/lab/automation", module: "server/management/lab-automation-routes", mutates: false, exempt: { reason: "local-transport", why: "ocx lab reads the same rows from the local SQLite projection; src/cli/lab.ts imports ../lab/query directly and never fetches /api/lab." } }, { method: "GET", path: "/api/lab/automation/runs", module: "server/management/lab-automation-routes", mutates: false, exempt: { reason: "local-transport", why: "ocx lab reads the same rows from the local SQLite projection; src/cli/lab.ts imports ../lab/query directly and never fetches /api/lab." } }, diff --git a/src/server/management/shared.ts b/src/server/management/shared.ts index c48f5f53f7a..fa3845250f2 100644 --- a/src/server/management/shared.ts +++ b/src/server/management/shared.ts @@ -245,12 +245,19 @@ export function requestLogDto( * share the same fetch, the same per-provider cache (dedups Codex's frequent /v1/models polling), * and the same stale fallback when a provider blips, instead of a parallel uncached copy. */ -export async function fetchAllModels(config: OcxConfig): Promise { +export async function fetchAllModels( + config: OcxConfig, + /** Filled with each provider's content revision as of the moment its rows were chosen. */ + providerContentRevisions?: Map, +): Promise { const { gatherRoutedModels } = await import("../../codex/catalog"); const baseline = captureInitialSelectionBaseline(config); - if (!baseline) return gatherRoutedModels(config); + if (!baseline) return gatherRoutedModels(config, providerContentRevisions ? { providerContentRevisions } : undefined); const outcomes: Array<{ provider: string; state: "authoritative" | "degraded" }> = []; - const models = await gatherRoutedModels(config, { providerModelOutcomes: outcomes }); + const models = await gatherRoutedModels(config, { + providerModelOutcomes: outcomes, + ...(providerContentRevisions ? { providerContentRevisions } : {}), + }); finalizeInitialModelSelection(config, baseline, uniqueCatalogModelsForPublicList(models), outcomes.filter(outcome => outcome.state === "authoritative").map(outcome => outcome.provider)); return models; diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md index bbdd23646d4..402a60e07f0 100644 --- a/structure/clients/integrations.md +++ b/structure/clients/integrations.md @@ -22,7 +22,8 @@ parsing and ownership rules below. | `src/integrations/state.ts` | The single `absent` / `current` / `stale` / `conflict` / `unsafe` classifier used by status and every writer operation. | | `src/integrations/ownership.ts` | Durable ownership records: file, generated contribution, protected contribution, exact fragment paths, and operation identity. | | `src/integrations/ownership-policy.ts` | Client-scoped declarations for fields a client is documented to derive after apply. It must never contain a broad format-wide exemption. | -| `src/integrations/writer.ts` | Apply, refresh, disable, and restore transactions, including snapshot-first ordering, compare-before-commit, and compensation. | +| `src/integrations/writer.ts` | Apply, refresh, disable, and restore transactions, including snapshot-first ordering, compare-before-commit, and compensation. Freezing an input copies the proxy configuration and the model roster as plain data before the first await, so the plan a revalidation approves and the document that follows it read one input; an input that cannot be copied is refused rather than read twice. Aside captures the same configuration copy when its context is created, because its preference write edits the live configuration between the check and the profile writes. | +| `src/integrations/mutation-plan.ts` | The shared observation both a preview and a mutation read, and the value-free plan an operator confirms. It owns no IO of its own, takes no lock, and must never import `writer.ts`. | | `src/integrations/store.ts` / `journal.ts` | One-root persistence for ownership records, operation history, snapshots, and retention maintenance. | ## Data Flow @@ -43,6 +44,60 @@ Status and mutation must use the same classifier. A special case added only to a would be misleading because refresh or disable could still reject the same file; a special case added only to a writer would let a mutation bypass the state users saw. +## Read-only mutation plans + +An operator confirming apply, overwrite, disable or undo is agreeing to consequences they were +never shown. A plan is what shows them, and it is only trustworthy if it describes the operation +that will actually run. + +One observation serves both. `mutation-plan.ts` owns the read, parse, contribution build, record +selection and classification that the writer used to perform itself, so a preview and the mutation +it authorizes cannot disagree. The direction is strict: state, ownership and merge feed the plan; +the plan feeds the writer and the preview routes. The plan module must never import the writer. + +Preview and mutation differ in exactly two ways, and both are declared rather than implied. +Pending-prune maintenance and client transaction recovery each write, so they are explicit options +with no default that a preview passes as false. And a preview takes its model roster from the +retained export snapshot instead of gathering one, because discovery refreshes credentials and +writes the provider cache. With no usable snapshot the request is refused, which covers a cold +process and equally a snapshot retired because the configuration or the provider cache moved. The +roster is gathered and projected from a detached copy of the configuration +(`src/config/admitted-identity.ts`) taken before the gather, so an edit that lands mid-load changes +neither half of the result; the same admission is revalidated against the resident object and the +configuration file before anything is retained, so such an edit leaves no snapshot rather than one +recorded under a state its rows never had. The identity a caller carries between a preview and the +mutation that confirms it is process-local and opaque, and describes nothing about the +configuration. The +Integrations collection read populates one when discovery succeeds and the configuration can be +identified, so the page an operator opens before confirming anything is the usual way back rather +than a guarantee. + +Each operation is decided the way that operation decides it. Apply checks installation and +admission before the classifier and reports a conflict ahead of unsafe; disable asks neither, +because removing what we wrote from a file that still exists is meaningful regardless of +installation; restore reads the journal row and the target's bytes and never parses, so a file +that is readable but unparseable is still restorable. An operation that would write nothing says +so and names no places. + +Published paths are declared, not inferred. Every client states where its managed fragments live, +a path is emitted only on an exact template match, and the string emitted is the template rather +than the observed path, so a dynamic position renders as a wildcard and a value cannot leak. A +path outside the declaration is refused rather than described: an ownership record accepts +arbitrary strings and is not a validation authority. + +The fingerprint covers every input the decision rests on, including the roster, the observed +install kind and the admission predicate, and for restore the snapshot's actual bytes rather than +only its operation id. A confirmed mutation re-plans the coordinator's frozen input with the lock +held, before any snapshot, write or journal row, and separately checks that the snapshot it +captured is still current; the roster and that identity are read together so the check cannot +validate one snapshot while the mutation writes another. Aside is the exception that proves the +placement: it persists preferences and imports journal rows before any writer lock, so its check +runs once profile and path selection is frozen and before those writes. + +The fingerprint is an optimistic token, never authorization. Management authentication and every +ownership rule still decide whether a mutation may happen, and the writer's own +compare-before-commit guard is unchanged. + Gajae export and managed refresh share the loopback-only provider builder. It writes the non-secret `LOOPBACK_API_KEY_PLACEHOLDER` as `apiKey`, so the client can activate the provider without a separately populated environment variable. The managed contribution owns only the diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 32912ed5f7e..bd7e7febe0e 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -153,6 +153,7 @@ this document owns is which module holds which area and what invariant that area | Models and visibility | `src/server/management/model-routes.ts` — `GET /api/models`, `PUT /api/disabled-models`, `PUT /api/model-visibility`, `PUT /api/selected-models`, `GET/POST /api/custom-models`. Visibility writes trigger catalog sync through the owning server path. | | Effort and fallback | `src/server/management/agent-settings-routes.ts` — `GET/PUT /api/effort-caps`, `/api/subagent-models`, `/api/subagent-model-fallback`. Caps clamp; they do not reject. | | Grok and Claude integrations | `src/server/management/agent-settings-routes.ts` — `GET /api/grok`, `PUT /api/grok/selection`, `POST /api/grok/apply`, `GET/PUT /api/claude-desktop`, `POST /api/claude-desktop/apply`, `GET /api/claude-desktop/status`, `GET/PUT /api/claude-code`. Apply writes an external app's profile, so its status probe must read the same resolved path it writes (see [`responses.md`](transports/responses.md)). | +| File-integration plans | `src/server/management/integration-routes.ts` and `aside-profile-routes.ts` — `POST /api/client-integrations/preview`, `POST /api/client-integrations/restore/preview`, and `POST /api/client-integrations/aside/profiles/{profileId}/preview`. Management-authenticated, declared non-mutating, and they write nothing: no snapshot, no lock, no maintenance, no recovery. They answer `409 integration_preview_unavailable` rather than gathering a model roster, because discovery refreshes credentials and writes the provider cache. Responses carry only declared managed schema paths, closed change kinds and an opaque fingerprint; no value, filesystem location or selected member identity appears. Mutation routes accept `operation` and `planFingerprint` together or not at all, reject a half-bound request and an operation that disagrees with the change, and answer `409 integration_preview_stale` with a freshly computed plan. Binding is an optimistic token, never authorization. [The integration contract](clients/integrations.md) owns the ordering. | | Grok reset coupons | `src/server/management/grok-coupon-routes.ts` — `GET /api/grok/reset-coupons`, `POST /api/grok/reset-coupons/consume`. The dashboard owner is `gui/src/hooks/useGrokResetCoupons.ts` with `gui/src/components/provider-workspace/GrokResetCoupons.tsx`, wired into the xAI OAuth rows of `ProviderAuthPanel`. Redemption truth is the settled ledger `code`, not the HTTP status: a replayed failure returns 200 with `replayed: true`. See [`providers/xai-grok.md`](providers/xai-grok.md). | | Combos | `src/server/management/combo-routes.ts` — `GET/PUT/DELETE /api/combos` own provider combination and failover definitions. | | Workflow budget | `src/server/management/workflow-budget-routes.ts` — `GET /api/workflow-budget` reads the tracked roots or one root, and `POST /api/workflow-budget/clear` clears exactly one. The clear moves the windowed send ring and the child map and nothing else: `active` belongs to turns still in flight, the spend ledger is a token budget an operator did not ask to forgive, and the lifetime send total survives so a clear cannot launder the record. A refusal event carries `spendScope` and `spendLimit` when a token ceiling fired, so the reason is readable without the config open beside it; no scope id is ever attached, because root ids are client thread headers and identity ids are credentials. Both are `deferred-verb` in the route registry — they are owed CLI verbs, and because the ledger is process memory there is no local projection the CLI could read instead. See [`../devlog/_plan/260915_workflow_budget_window/030_wfc_diff_plan.md`](../devlog/_plan/260915_workflow_budget_window/030_wfc_diff_plan.md). | diff --git a/structure/runtime.md b/structure/runtime.md index 62ff28486e0..17aeb5afba5 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -103,6 +103,7 @@ does not perform OAuth, and runtime credential resolution rereads the owned sour | --- | --- | | `bin/ocx.mjs` | Published npm `bin` entry (Node shim). Resolves the bundled or explicit Bun binary before project dotenv can load, stamps its runtime provenance plus a proof-bound Anthropic parent-env snapshot, lazy-runs `bun/install.js` if only the placeholder stub is present, then execs `src/cli/index.ts` under Bun. Lets `npm install -g` work without a separately-installed Bun. The exact `system codex-cli-update` inspection namespace skips both boot repair and lazy Bun installation; missing runtime support fails closed instead of mutating state. | | `src/lib/bun-runtime.ts` | Bundled-Bun resolution: `isRealBunBinary()` (size gate vs the ~450-byte placeholder stub), `bundledBunPath()`, and `durableBunPath()` (path baked into service/shim artifacts). Durable selection accepts only the source/path pair already stamped for the running executable; it never re-reads a project-dotenv `OPENCODEX_BUN_PATH`. | +| `src/lib/plain-data.ts` | Detached copies for a consumer that must not observe later edits. Descriptor-based reads, including array elements, so an accessor is refused rather than invoked; refuses cycles, functions, class instances and anything else JSON could not have produced, and returns a copy-or-refusal union rather than degrading silently. Symbol-keyed process bookkeeping is skipped. | | `src/cli/index.ts` | `ocx` / `opencodex` CLI. Lifecycle: init, start, stop, restart, status, sync, restore/eject, gui, service, update. `restart` refuses an in-place restart requested by a CLI whose version differs from the attested `/healthz` version, because the replacement respawns from the live installation; placeholder versions (unknown/0.0.0) stay incomparable and keep the restart path. Configuration: provider, account, models, combo/route, access, integrations, v2. Client launchers: Claude, OpenCode, MiniMax Code, and MiniMax CLI text. The MMX launcher owns a child-lifetime loopback path bridge from the client's hard-coded `/anthropic/v1/messages` path to the canonical `/v1/messages` data plane; the server does not expose an extra auth surface. Diagnostics: doctor, debug, observe, health. Windows adds tray. The full command surface is `src/cli/help.ts`; this table names the groups, not every verb. After help/version early exits, ordinary commands run the bounded best-effort Codex-shim auto-restore policy before dispatch. `system codex-cli-update` is the deliberate read-only exception and suppresses auto-restore for its whole namespace, including malformed invocations. Keeps the `#!/usr/bin/env bun` shebang for from-source dev (`bun run src/cli/index.ts`). | | `src/server/index.ts` | Bun server entrypoint: `startServer`, `/v1/responses` HTTP + WebSocket routing (compact handled before generic Responses), exact `POST /v1/images/generations` and `POST /v1/images/edits` routing, `/v1/models`, the Anthropic-shaped `/v1/messages` and OpenAI-shaped `/v1/chat/completions` compatibility surfaces, the Live/Realtime surface, the hosted-search relay, artifact serving, `/healthz`, the `/api/*` auth gate, the `/v1/*` JSON 404 guard, GUI fallback, the opt-in loopback-only hub-management listener, and facade re-exports for split server modules. The route table itself is built by `src/server/index/serve-options.ts`; this entry file owns the listener and the startup transaction. | | `src/server/images.ts` | Standalone Images data plane: default OpenAI or explicit custom-provider selection, Codex account affinity, bounded opaque request relay, single-attempt upstream fetch, pool health recording, and safe response/cancellation relay. | @@ -112,6 +113,7 @@ does not perform OAuth, and runtime credential resolution rereads the owned sour | `src/config/paths.ts` | Resolves `OPENCODEX_HOME`, `config.json`, and owner-only directory hardening. | | `src/config/atomic-write.ts` | Shared synchronous/asynchronous temp-harden-rename writer and residual-temp failure contract. The temp is ACL-hardened before it holds a byte and again before the rename, both `required: true`; the second call is a memo hit rather than a second icacls sequence because the writer re-asserts descriptor/path identity after the content write and re-attributes the harden through `reattributeHardenedSecretPath`. Windows takes no `chmod` on that path — it sets the read-only attribute, not the DACL, and its ChangeTime bump is what used to retire the memo. | | `src/config/process-state.ts` | Owns `ocx.pid`, `runtime-port.json`, cheap liveness, full command-line identity verification, and snapshot-guarded cleanup. | +| `src/config/admitted-identity.ts` | Which configuration a derived artifact was built from. Detaches the resident configuration as plain data so one pass cannot gather under one state and project under another, and records the complete structure beside the configuration file's bytes. Refuses an accessor, a cycle, a value JSON could not produce, an unreadable file and a file the loader would have had to salvage; a callable `providers[name].fetch` is the one non-data field, held and compared by reference, while a written one is ordinary data on both sides, as the outbound transport also reads it. It does NOT require the resident configuration to equal the file: the proxy routes by what it holds, and live reconciliation retains live changes and the active listener binding on purpose. Evidence stays in a module WeakMap, never on the config and never in a response. | | `src/server/ports.ts` | Owns bind availability and ephemeral-port selection. Temporary probes dispose accepted peers and wait for listener close before reporting success. | | `src/cli/status.ts` / `src/cli/status-probes.ts` | Status snapshot assembly and the shared read-only health/stale-process probes used by status and doctor. Probe evidence keeps recorded-port choice, before/after snapshots and per-call timer cleanup together. | | `src/cli/doctor.ts` | Read-only environment diagnostics. Sections print through `console.log`; each is a `collect*` helper above `runDoctor` so it is testable without the command. Only a `FAIL`-level condition records a doctor failure — a degraded-but-working install must not break a green pipeline. `collectDefaultModelExposure` compares Codex's root `model` pin against the exposed set, which it READS rather than recomputes: the running proxy's `/v1/models` when one answers, otherwise the on-disk catalog's `visibility: "list"` slugs. It reports exposed, not exposed, or undeterminable, and never the second when it could not read either surface. | diff --git a/tests/clients/aside-profiles.test.ts b/tests/clients/aside-profiles.test.ts index 800ef329f2a..b5b3cad956f 100644 --- a/tests/clients/aside-profiles.test.ts +++ b/tests/clients/aside-profiles.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { ExportModel } from "../../src/clients/config-export"; @@ -247,6 +247,92 @@ describe("Aside profile desired state, ownership and history", () => { expect((await getAsideProfileState(input(), 1)).enabled).toBe(false); }); + test("a captured stored copy survives lookup-only divergence without bypassing source guards", async () => { + // Pin profile 0 to the legacy root: profile 1 must stay on its own child store even after + // its historical row is copied into the root journal. + config.asideProfileSync = { allProfiles: false, legacyProfileId: 0 }; + const original = readFileSync(path(1), "utf8"); + const enabled = await mutateAsideProfiles(input(), { profileId: 1, enabled: true }); + const applied = enabled.results[0]!; + if (!applied.ok || !applied.opId) throw new Error("fixture enable failed"); + const opId = applied.opId; + const profileStore = createIntegrationStateStore(join(store.root, "aside-profiles", "1")); + const entry = profileStore.findOperation(opId); + if (!entry) throw new Error("fixture operation missing from profile store"); + const snapshot = profileStore.readSnapshot(entry); + if (snapshot.kind !== "stored") throw new Error("fixture snapshot is not stored"); + expect(snapshot.text).toBe(original); + expect(store.findOperation(opId)).toBeNull(); + store.captureSnapshot("aside", opId, snapshot.text); + store.appendJournal(structuredClone(entry)); + rmSync(snapshot.path); + expect(profileStore.readSnapshot(entry).kind).toBe("expired"); + + let hideRootCopy = false; + const lookupStore: IntegrationStateStore = { + ...store, + // Only enumeration diverges. Source revalidation and snapshot reads remain real. + listOperations(clientId, limit) { + const rows = store.listOperations(clientId, Number.MAX_SAFE_INTEGER); + return rows.filter(row => !hideRootCopy || row.opId !== opId).slice(0, limit ?? 50); + }, + }; + const fixture = input({ store: lookupStore }); + const captured = findAsideOperation(fixture, opId, 1); + if (!captured) throw new Error("fixture capture failed"); + expect(captured.profileId).toBe(1); + expect(captured.store.root).toBe(store.root); + expect(captured.store.readSnapshot(captured.entry)).toMatchObject({ kind: "stored", text: original }); + + const treeBytes = (dir: string): string => !existsSync(dir) ? "" : readdirSync(dir, { recursive: true }) + .map(String).sort().map(name => { + const full = join(dir, name); + return statSync(full).isDirectory() ? name + "/" : name + ":" + readFileSync(full, "utf8"); + }).join("\u0000"); + const siblingDir = join(store.root, "aside-profiles", "2"); + createIntegrationStateStore(siblingDir).captureSnapshot("aside", "sibling-witness", "untouched"); + const siblingBefore = treeBytes(siblingDir); + const siblingDocuments = [0, 2].map(id => readFileSync(path(id), "utf8")); + const sourceWitness = () => ["journal.jsonl", "records.json", "maintenance.json", join("snapshots", "aside", opId)] + .map(name => existsSync(join(store.root, name)) ? readFileSync(join(store.root, name), "utf8") : null); + const sourceBefore = sourceWitness(); + const profileBefore = treeBytes(profileStore.root); + const appliedBytes = readFileSync(path(1), "utf8"); + const savesBefore = saves; + + hideRootCopy = true; + const fresh = findAsideOperation(fixture, opId, 1); + if (!fresh) throw new Error("fixture fresh lookup failed"); + expect(fresh.store.root).toBe(profileStore.root); + expect(fresh.store.readSnapshot(fresh.entry).kind).toBe("expired"); + expect(captured.store.findOperation(opId)).toEqual(captured.entry); + expect(captured.store.readSnapshot(captured.entry)).toMatchObject({ kind: "stored", text: original }); + // Counterfactual through the real public entry point: re-resolution refuses before any write. + expect(await restoreAsideProfile(fixture, { opId, profileId: 1 })).toMatchObject({ + ok: false, clientId: "aside", profileId: 1, reason: "snapshot_expired", + }); + expect(saves).toBe(savesBefore); + expect(treeBytes(profileStore.root)).toBe(profileBefore); + expect(readFileSync(path(1), "utf8")).toBe(appliedBytes); + expect(sourceWitness()).toEqual(sourceBefore); + + const restored = await restoreAsideProfile(fixture, { opId, profileId: 1, selectedOperation: captured }); + expect(restored).toMatchObject({ ok: true, changed: true, clientId: "aside", profileId: 1 }); + if (!restored.ok || !restored.opId) throw new Error("captured restore did not commit"); + expect(readFileSync(path(1), "utf8")).toBe(original); + const finalRows = profileStore.listOperations("aside"); + expect(finalRows.map(row => ({ kind: row.kind, opId: row.opId }))).toEqual([ + { kind: "restore", opId: restored.opId }, { kind: "apply", opId }, + ]); + expect(finalRows[0]!.configPath).toBe(path(1)); + expect(profileStore.readSnapshot(finalRows[0]!)).toMatchObject({ kind: "stored", text: appliedBytes }); + expect(profileStore.readSnapshot(entry)).toMatchObject({ kind: "stored", text: original }); + expect(sourceWitness()).toEqual(sourceBefore); + expect(captured.store.findOperation(opId)).toEqual(captured.entry); + expect([0, 2].map(id => readFileSync(path(id), "utf8"))).toEqual(siblingDocuments); + expect(treeBytes(siblingDir)).toBe(siblingBefore); + }); + test("disable then Undo restores target intent without changing sibling overrides", async () => { seedLegacy(); await mutateAsideProfiles(input(), { profileId: 2, enabled: false }); diff --git a/tests/clients/integrations-writer-frozen-config.test.ts b/tests/clients/integrations-writer-frozen-config.test.ts new file mode 100644 index 00000000000..34804f505f6 --- /dev/null +++ b/tests/clients/integrations-writer-frozen-config.test.ts @@ -0,0 +1,402 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import type { ExportModel } from "../../src/clients/config-export"; +import { INTEGRATION_CLIENTS } from "../../src/integrations/registry"; +import { createIntegrationStateStore, type IntegrationStateStore } from "../../src/integrations/store"; +import { applyIntegrationCoordinated } from "../../src/integrations/writer"; +import { mutateAsideProfiles, previewAsideProfile } from "../../src/integrations/aside-profiles"; +import { previewIntegration } from "../../src/integrations/mutation-plan"; +import { loadExportModels, previewExportModels, resetExportSnapshotForTests } from "../../src/server/management/model-rows"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +/** + * A coordinated write plans from its input, awaits the writer lock and a revalidation, and only + * then serializes the document. Everything else it resolves is frozen before that await, and the + * configuration was the one thing still held by reference: an edit landing in the window meant the + * plan that authorized the write and the document it produced described different configurations. + */ +let home: string; +let store: IntegrationStateStore; +let priorOcxHome: string | undefined; +const TEST_ENV = {} as NodeJS.ProcessEnv; + +const MODELS: ExportModel[] = [ + { namespaced: "anthropic/claude-opus-4-8", provider: "anthropic", id: "claude-opus-4-8", contextWindow: 200_000 }, +]; + +const CHECKED_HOST = "127.0.0.1"; +const LATER_HOST = "127.0.0.2"; + +beforeEach(() => { + const base = mkdtempSync(join(tmpdir(), "ocx-writer-frozen-config-")); + home = join(base, "home"); + mkdirSync(home, { recursive: true }); + store = createIntegrationStateStore(join(base, "store", "integrations")); + // A roster is admitted beside the configuration file, so these cases need their own + // configuration home rather than whatever the machine running them happens to have. + priorOcxHome = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = join(base, "config"); +}); + +afterEach(() => { + if (priorOcxHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = priorOcxHome; + removeTreeWithRetry(dirname(home)); +}); + +function installHermes(): string { + const spec = INTEGRATION_CLIENTS.hermes; + mkdirSync(spec.detectDir(TEST_ENV, home), { recursive: true }); + const configPath = spec.configPath(TEST_ENV, home); + mkdirSync(dirname(configPath), { recursive: true }); + return configPath; +} + +test("the configuration a coordinated write checked is the one it writes", async () => { + const configPath = installHermes(); + const config = { + port: 10100, + hostname: CHECKED_HOST, + defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } }, + } as unknown as OcxConfig; + + let edited = false; + const result = await applyIntegrationCoordinated( + { clientId: "hermes", models: MODELS, config, port: 10100, store, env: TEST_ENV, home }, + { + revalidate: async () => { + // The revalidation has just agreed that this write may proceed. A management route editing + // the live configuration here is the whole hazard: it is after the check and before the + // document exists. + config.hostname = LATER_HOST; + edited = true; + return null; + }, + }, + ); + + expect(result.ok).toBe(true); + expect(edited).toBe(true); + const written = readFileSync(configPath, "utf8"); + // The document describes the configuration the check passed on. Holding the caller's object + // would have serialized the edit instead, with the plan still vouching for the other one. The + // port form is asserted rather than the checked host itself because a loopback hostname is + // normalized on the way into a client document, while the edited one would arrive verbatim. + expect(written).toContain(":10100/v1"); + expect(written).not.toContain(LATER_HOST); + // And the edit itself is untouched: freezing the input is not an excuse to write it back. + expect(config.hostname).toBe(LATER_HOST); +}); + +/** + * The same rule one layer up, where the window is wider. + * + * An Aside change checks its confirmation, then awaits the preference write, and only then builds + * each profile's write input. The preference write edits the live configuration itself, so reading + * that object again afterwards guaranteed the document came from a configuration the check never + * saw, and anything else editing it during the await arrived the same way. + */ +test("the configuration an Aside change was checked against is the one written", async () => { + mkdirSync(join(home, ".aside", "u", "0"), { recursive: true }); + writeFileSync(join(home, ".aside", "accounts.json"), JSON.stringify({ + currentAccountId: 0, accounts: [{ id: 0, name: "Primary" }], + })); + const profilePath = join(home, ".aside", "u", "0", "models.json"); + writeFileSync(profilePath, JSON.stringify({ theme: "keep", providers: { personal: { models: [] } } })); + + const config = { + port: 10100, + hostname: CHECKED_HOST, + defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } }, + } as unknown as OcxConfig; + + let checked = false; + const result = await mutateAsideProfiles( + { + config, models: MODELS, port: 10100, env: {} as NodeJS.ProcessEnv, home, store, + persistConfig: async () => { + // Both hosts are loopback, so neither is rewritten on the way into a client document and + // the one that appears is the one the write actually read. + config.hostname = LATER_HOST; + // A real suspension, so the profile write below genuinely resumes after this edit rather + // than being ordered ahead of it by chance. + await Promise.resolve(); + }, + }, + { profileId: 0, enabled: true }, + { + revalidate: async () => { + checked = true; + return null; + }, + }, + ); + + expect(checked).toBe(true); + expect(result.ok).toBe(true); + const written = readFileSync(profilePath, "utf8"); + expect(written).toContain(":10100/v1"); + expect(written).not.toContain(LATER_HOST); + // The preference write is a real effect on the live configuration and stays one. + expect(config.hostname).toBe(LATER_HOST); +}); + +/** + * The roster is the other half of the same input. + * + * A caller passes model objects it still owns, and the plan that authorizes the write is computed + * from them before the lock. Spreading the input carried those objects by reference, so an edit + * made after the check was serialized into the document the check had vouched for. + */ +test("the roster a coordinated write checked is the one it writes", async () => { + const configPath = installHermes(); + const config = { + port: 10100, + hostname: CHECKED_HOST, + defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } }, + } as unknown as OcxConfig; + const models: ExportModel[] = [ + { namespaced: "anthropic/claude-opus-4-8", provider: "anthropic", id: "claude-opus-4-8", contextWindow: 200_000 }, + ]; + + const result = await applyIntegrationCoordinated( + { clientId: "hermes", models, config, port: 10100, store, env: TEST_ENV, home }, + { + revalidate: async () => { + models[0]!.id = "edited-after-the-check"; + models[0]!.namespaced = "anthropic/edited-after-the-check"; + return null; + }, + }, + ); + + expect(result.ok).toBe(true); + const written = readFileSync(configPath, "utf8"); + expect(written).toContain("claude-opus-4-8"); + expect(written).not.toContain("edited-after-the-check"); +}); + +/** + * What happens when the input cannot be held still at all. + * + * An accessor is not read here, so there is no copy to check a plan against and no way to promise + * the document matches it. The write is refused before anything is touched, which is bounded and + * honest; returning the caller's object and calling it frozen would have been neither. + */ +test("an input that cannot be copied refuses the write instead of reading it twice", async () => { + const configPath = installHermes(); + let reads = 0; + const config = { + port: 10100, + defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } }, + } as unknown as OcxConfig; + Object.defineProperty(config, "hostname", { + configurable: true, + enumerable: true, + get: () => { reads += 1; return CHECKED_HOST; }, + }); + + let checked = false; + const result = await applyIntegrationCoordinated( + { clientId: "hermes", models: MODELS, config, port: 10100, store, env: TEST_ENV, home }, + { revalidate: async () => { checked = true; return null; } }, + ); + + expect(result.ok).toBe(false); + if (result.ok) throw new Error("an uncopyable input must not be written"); + expect(result.reason).toBe("unsafe"); + // Refused before the plan, the lock and the document: nothing ran and nothing was read. + expect(checked).toBe(false); + expect(reads).toBe(0); + expect(existsSync(configPath)).toBe(false); +}); + +/** + * What a committed Aside change does to the retained roster, stated rather than assumed. + * + * The change writes the operator's Aside preference into the configuration, so the configuration a + * roster was admitted under is no longer the one in hand and the roster is retired. The next bound + * confirmation is answered with "no roster is cached yet" until an ordinary load runs, which is + * what the Integrations collection read does. This is not new behaviour introduced by binding the + * roster to the configuration object: persisting the preference rewrites the configuration file, + * and a roster has always been retired when that file moves. + */ +test("a committed Aside change retires the roster, and an ordinary load brings it back", async () => { + mkdirSync(join(home, ".aside", "u", "0"), { recursive: true }); + writeFileSync(join(home, ".aside", "accounts.json"), JSON.stringify({ + currentAccountId: 0, accounts: [{ id: 0, name: "Primary" }], + })); + const profilePath = join(home, ".aside", "u", "0", "models.json"); + writeFileSync(profilePath, JSON.stringify({ theme: "keep", providers: { personal: { models: [] } } })); + + const config = { + port: 10100, + hostname: CHECKED_HOST, + defaultProvider: "fixture", + providers: { fixture: { adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1", liveModels: false, models: ["one", "two"] } }, + } as unknown as OcxConfig; + + resetExportSnapshotForTests(); + await loadExportModels(config, []); + expect(previewExportModels(config)).not.toBeNull(); + + const result = await mutateAsideProfiles( + { + // A bound confirmation hands the mutation the roster the guard used, exactly as the route + // does, so nothing reloads it in the middle of the change. + config, models: MODELS, port: 10100, env: {} as NodeJS.ProcessEnv, home, store, + persistConfig: () => {}, + }, + { profileId: 0, enabled: true }, + ); + + expect(result.ok).toBe(true); + // The preference is now part of the configuration, and the roster was admitted without it. + expect(config.asideProfileSync).toBeDefined(); + expect(previewExportModels(config)).toBeNull(); + + // Recovery is the ordinary flow rather than a special step. + await loadExportModels(config, []); + expect(previewExportModels(config)).not.toBeNull(); +}); + +function seedAsideProfile(): string { + mkdirSync(join(home, ".aside", "u", "0"), { recursive: true }); + writeFileSync(join(home, ".aside", "accounts.json"), JSON.stringify({ + currentAccountId: 0, accounts: [{ id: 0, name: "Primary" }], + })); + const profilePath = join(home, ".aside", "u", "0", "models.json"); + writeFileSync(profilePath, JSON.stringify({ theme: "keep", providers: { personal: { models: [] } } })); + return profilePath; +} + +function asideFixtureConfig(hostname: string): OcxConfig { + return { + port: 10100, + hostname, + defaultProvider: "fixture", + providers: { fixture: { adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1", liveModels: false, models: ["one", "two"] } }, + } as unknown as OcxConfig; +} + +/** + * The check has to be about the input that will be written, not about whatever the live + * configuration says at the moment it runs. + * + * A configuration can be edited to something else and back again while an action is in flight. A + * check that rebuilt its own view from the live object would then plan the configuration the + * operator confirmed, agree with the confirmation, and let the write proceed from the copy the + * action actually holds, which is the other one. + */ +test("the Aside check plans the prepared input, not the configuration that is live when it runs", async () => { + const profilePath = seedAsideProfile(); + const before = readFileSync(profilePath, "utf8"); + const config = asideFixtureConfig(CHECKED_HOST); + const asideInput = { config, models: MODELS, port: 10100, env: {} as NodeJS.ProcessEnv, home, store, persistConfig: () => {} }; + + // A: what the operator confirmed. + const confirmed = await previewAsideProfile(asideInput, { profileId: 0, operation: "apply" }); + expect(confirmed.canApply).toBe(true); + + // B: what the action will hold, because the context copies the configuration when it is created. + config.hostname = LATER_HOST; + + const checked: string[] = []; + const result = await mutateAsideProfiles( + asideInput, + { profileId: 0, enabled: true }, + { + revalidate: async prepared => { + // Live is A again by the time the check runs. Only the prepared input still says B. + config.hostname = CHECKED_HOST; + const plan = previewIntegration(prepared, { profileId: 0, operation: "apply" }); + checked.push(plan.fingerprint); + return plan.fingerprint === confirmed.fingerprint ? null : { + ok: false, reason: "conflict", state: plan.state, clientId: "aside", + message: "that confirmation no longer describes this profile", profileId: 0, + }; + }, + }, + ); + + expect(checked).toHaveLength(1); + expect(checked[0]).not.toBe(confirmed.fingerprint); + expect(result.ok).toBe(false); + // Refused before the preference write and before any client write. + expect(config.asideProfileSync).toBeUndefined(); + expect(readFileSync(profilePath, "utf8")).toBe(before); +}); + +/** + * The roster is resolved and copied once, before the check. + * + * It used to be resolved lazily at each profile write, which happens after the preference write. A + * caller still holding those model objects could edit them in that window, and the document + * carried the edit while the plan vouched for what it read earlier. + */ +test("a roster edited during the preference write is not the roster written", async () => { + const profilePath = seedAsideProfile(); + const config = asideFixtureConfig(CHECKED_HOST); + const models: ExportModel[] = [ + { namespaced: "anthropic/claude-opus-4-8", provider: "anthropic", id: "claude-opus-4-8", contextWindow: 200_000 }, + ]; + + const result = await mutateAsideProfiles( + { + config, models, port: 10100, env: {} as NodeJS.ProcessEnv, home, store, + persistConfig: () => { + models[0]!.id = "edited-during-the-preference-write"; + models[0]!.namespaced = "anthropic/edited-during-the-preference-write"; + }, + }, + { profileId: 0, enabled: true }, + // A confirmed change, which is what brings the roster forward: the check has to read the same + // rows the write will use. An unconfirmed change resolves them later, because a preference + // write that fails must not have done model work by then. + { revalidate: async () => null }, + ); + + expect(result.ok).toBe(true); + const written = readFileSync(profilePath, "utf8"); + expect(written).toContain("claude-opus-4-8"); + expect(written).not.toContain("edited-during-the-preference-write"); +}); + +/** + * A confirmation describes one profile. + * + * HTTP refuses a bound change that names none, and this refuses it too. Guessing which prepared + * input such a confirmation meant would be inventing the thing the check exists to verify. + */ +test("a confirmed change that names no profile is refused rather than guessed", async () => { + const profilePath = seedAsideProfile(); + const before = readFileSync(profilePath, "utf8"); + const config = asideFixtureConfig(CHECKED_HOST); + + let checked = false; + let loads = 0; + await expect(mutateAsideProfiles( + { + config, + // A loader, not an array: resolving the roster is real work that reaches providers and can + // finalize an initial model selection, and a confirmation this cannot check must not cause + // it. An inert array would hide that. + models: async () => { loads += 1; return MODELS; }, + port: 10100, env: {} as NodeJS.ProcessEnv, home, store, persistConfig: () => {}, + }, + { enabled: true }, + { revalidate: async () => { checked = true; return null; } }, + )).rejects.toThrow(/one profile/); + + expect(checked).toBe(false); + expect(loads).toBe(0); + expect(config.asideProfileSync).toBeUndefined(); + expect(readFileSync(profilePath, "utf8")).toBe(before); +}); diff --git a/tests/clients/mutation-plan.test.ts b/tests/clients/mutation-plan.test.ts new file mode 100644 index 00000000000..4af951a290c --- /dev/null +++ b/tests/clients/mutation-plan.test.ts @@ -0,0 +1,667 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, relative } from "node:path"; +import { + MANAGED_PATH_TEMPLATES, + PLAN_CHANGE_LIMIT, + buildMutationPlan, + canonicalSchemaPath, + orderPlanChanges, + planFingerprint, + previewIntegration, + type IntegrationPlanChange, + type PlanInput, + type PlanFingerprintInput, +} from "../../src/integrations/mutation-plan"; +import { + EXPORT_CLIENTS, + EXPORT_CLIENT_IDS, + type ExportContext, + type ExportModel, + type ManagedContribution, +} from "../../src/clients/config-export"; +import type { OwnershipRecord } from "../../src/integrations/ownership"; +import { fingerprint } from "../../src/integrations/ownership"; +import type { JournalEntry } from "../../src/integrations/journal"; +import type { OcxConfig } from "../../src/types"; +import { createIntegrationStateStore } from "../../src/integrations/store"; +import { applyIntegration, disableIntegration, restoreIntegration } from "../../src/integrations/writer"; +import { clinePendingPath } from "../../src/integrations/cline-io"; +import { resolveIntegrationPaths } from "../../src/integrations/registry"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const FIXTURE_MODELS: ExportModel[] = [ + { namespaced: "anthropic/claude-opus-4-8", provider: "anthropic", id: "claude-opus-4-8", contextWindow: 200_000 }, + { namespaced: "gpt-5.5", provider: "openai", id: "gpt-5.5", native: true, contextWindow: 400_000 }, +]; + +const FIXTURE_CONFIG: OcxConfig = { + port: 10100, + hostname: "127.0.0.1", + defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } }, +} as OcxConfig; + +function fixtureContext(): ExportContext { + return { baseUrl: "http://127.0.0.1:10100/v1", models: FIXTURE_MODELS, config: FIXTURE_CONFIG }; +} + +const CONFIG_PATH = "/home/example/.cline/config.json"; + +const RECORD: OwnershipRecord = { + clientId: "cline", + configPath: CONFIG_PATH, + fileFingerprint: "0123456789abcdef", + blockFingerprint: "fedcba9876543210", + fragmentPaths: [["providers", "opencodex"]], + appliedAt: "2026-01-01T00:00:00.000Z", + opId: "op-base", +}; + +const CANARY = "canary-value-must-not-be-published"; + +const CONTRIBUTION: ManagedContribution = { + clientId: "cline", + fragments: [ + { path: ["settings", "providers", "opencodex"], value: { baseUrl: "http://127.0.0.1:10100", apiKey: CANARY } }, + { path: ["catalog", "providers", "opencodex"], value: { models: [CANARY] } }, + ], +}; + +const ENTRY: JournalEntry = { + opId: "op-base", + clientId: "cline", + kind: "apply", + at: "2026-01-01T00:00:00.000Z", + configPath: CONFIG_PATH, + snapshot: { kind: "stored", relPath: "snapshots/op-base.json" }, + resultFingerprint: "0123456789abcdef", + resultAbsent: false, + priorRecord: null, +}; + +const BASE: PlanFingerprintInput = { + operation: "apply", + clientId: "cline", + configPath: CONFIG_PATH, + detectDir: "/home/example/.cline", + installKind: "dir", + admissionBlocked: false, + before: "{}", + contribution: CONTRIBUTION, + record: RECORD, + models: [{ namespaced: "anthropic/claude", provider: "anthropic", id: "claude" }], +}; + +const RESTORE: NonNullable = { + opId: "op-base", + entry: ENTRY, + snapshotKind: "stored", + snapshotText: "{\"a\":1}", + confirmDrift: false, + driftsFromResult: false, +}; + +const RESTORE_BASE: PlanFingerprintInput = { ...BASE, operation: "restore", restore: RESTORE }; + +describe("integration mutation plan projection", () => { + test("a declared managed path is published as its template", () => { + expect(canonicalSchemaPath("cline", ["settings", "providers", "opencodex"])).toBe("settings.providers.opencodex"); + expect(canonicalSchemaPath("raycast", ["providers", "[id=opencodex]"])).toBe("providers.[id=opencodex]"); + }); + + test("a dynamic position never publishes the member it selected", () => { + // Kimi writes one fragment per model, so this position holds a user's model alias. An + // alphanumeric allowlist would have emitted it verbatim. + const path = canonicalSchemaPath("kimi", ["models", "kimi-k2-private-alias"]); + expect(path).toBe("models.*"); + expect(path).not.toContain("kimi-k2-private-alias"); + }); + + test("a path outside the client's declared grammar is refused, not described", () => { + // An ownership record accepts arbitrary strings, so a path is never published because a record + // carries it. Depth, a foreign static segment and another client's shape all fail closed. + expect(canonicalSchemaPath("kimi", ["models", "alias", "contextWindow"])).toBeNull(); + expect(canonicalSchemaPath("pi", ["providers", "someone-elses-provider"])).toBeNull(); + expect(canonicalSchemaPath("pi", ["settings", "providers", "opencodex"])).toBeNull(); + expect(canonicalSchemaPath("cline", ["..", "..", "etc"])).toBeNull(); + expect(canonicalSchemaPath("cline", [])).toBeNull(); + }); + + test("every path a shipped client actually writes canonicalizes through its own templates", () => { + // The declarations are a second copy of what the exporters do, so the only assertion worth + // making is against real builder output. A template list that merely exists proves nothing. + for (const clientId of EXPORT_CLIENT_IDS) { + expect(MANAGED_PATH_TEMPLATES[clientId].length, clientId).toBeGreaterThan(0); + const contribution = EXPORT_CLIENTS[clientId].buildContribution(fixtureContext()); + expect(contribution.fragments.length, clientId).toBeGreaterThan(0); + for (const fragment of contribution.fragments) { + const canonical = canonicalSchemaPath(clientId, fragment.path); + expect(canonical, `${clientId}: ${fragment.path.join(".")}`).not.toBeNull(); + // A dynamic position must not carry its observed value into the published path. + for (const segment of fragment.path) { + if (!MANAGED_PATH_TEMPLATES[clientId].some(template => template.includes(segment))) { + expect(canonical, `${clientId} leaked ${segment}`).not.toContain(segment); + } + } + } + } + }); + + test("changes are deduplicated and ordered by kind then path", () => { + const input: IntegrationPlanChange[] = [ + { kind: "journal", path: "$journal" }, + { kind: "replace", path: "providers.b" }, + { kind: "add", path: "providers.z" }, + { kind: "replace", path: "providers.a" }, + { kind: "add", path: "providers.z" }, + ]; + expect(orderPlanChanges(input)).toEqual([ + { kind: "add", path: "providers.z" }, + { kind: "replace", path: "providers.a" }, + { kind: "replace", path: "providers.b" }, + { kind: "journal", path: "$journal" }, + ]); + }); + + test("the reported change list is capped and frozen", () => { + const many: IntegrationPlanChange[] = Array.from({ length: PLAN_CHANGE_LIMIT + 5 }, (_unused, index) => ({ + kind: "add" as const, + path: `providers.p${String(index).padStart(4, "0")}`, + })); + const ordered = orderPlanChanges(many); + expect(ordered.length).toBe(PLAN_CHANGE_LIMIT); + // Frozen because a caller that sorted this in place would be editing shared plan state. + expect(() => (ordered as IntegrationPlanChange[]).push({ kind: "add", path: "providers.extra" })).toThrow(); + }); +}); + +describe("integration plan fingerprint", () => { + test("is stable for the same inputs and carries its version", () => { + expect(planFingerprint(BASE)).toBe(planFingerprint({ ...BASE })); + expect(planFingerprint(BASE).startsWith("p1:")).toBe(true); + }); + + test("every authority input changes it", () => { + const variants: PlanFingerprintInput[] = [ + BASE, + { ...BASE, operation: "overwrite" }, + { ...BASE, clientId: "opencode" }, + { ...BASE, profileId: 1 }, + { ...BASE, configPath: "/home/other/.cline/config.json" }, + { ...BASE, detectDir: "/home/other/.cline" }, + // The contribution is identical across an uninstall and across a change in admission + // eligibility, so binding the path alone would keep a stale confirmation valid. + { ...BASE, installKind: "missing" }, + { ...BASE, installKind: "file" }, + { ...BASE, admissionBlocked: true }, + // Different bytes, and absent distinguished from empty: restoring over a missing file and + // over an empty one are different operations. + { ...BASE, before: "{ }" }, + { ...BASE, before: "" }, + { ...BASE, before: null }, + { ...BASE, contribution: null }, + { + ...BASE, + contribution: { + clientId: "cline", + fragments: [{ path: ["providers", "opencodex"], value: { baseUrl: "http://127.0.0.1:10101" } }], + }, + }, + { ...BASE, record: null }, + // The contribution is derived from the model roster, so a changed roster changes what a + // confirmed apply would write. + { ...BASE, models: [{ namespaced: "anthropic/claude", provider: "anthropic", id: "claude-2" }] }, + { ...BASE, models: [] }, + ]; + expect(new Set(variants.map(planFingerprint)).size).toBe(variants.length); + }); + + test("restore binds the selected row and the bytes it would publish", () => { + const variants: PlanFingerprintInput[] = [ + RESTORE_BASE, + { ...RESTORE_BASE, restore: { ...RESTORE, opId: "op-other" } }, + { ...RESTORE_BASE, restore: { ...RESTORE, entry: { ...ENTRY, resultAbsent: true } } }, + { ...RESTORE_BASE, restore: { ...RESTORE, entry: { ...ENTRY, priorRecord: RECORD } } }, + { ...RESTORE_BASE, restore: { ...RESTORE, snapshotKind: "none" } }, + // Same row and same snapshot kind, different snapshot bytes. Binding only the operation id + // would leave the bytes that actually land in the user's file outside the confirmation. + { ...RESTORE_BASE, restore: { ...RESTORE, snapshotText: "{\"a\":2}" } }, + { ...RESTORE_BASE, restore: { ...RESTORE, snapshotText: null } }, + { ...RESTORE_BASE, restore: { ...RESTORE, confirmDrift: true } }, + { ...RESTORE_BASE, restore: { ...RESTORE, driftsFromResult: true } }, + ]; + expect(new Set(variants.map(planFingerprint)).size).toBe(variants.length); + }); +}); + +const PLAN_BASE: PlanInput = { ...BASE, classified: { state: "absent" }, parsed: {} }; + +/** A document that already holds a value where the settings fragment goes. */ +const OCCUPIED = { settings: { providers: { opencodex: { baseUrl: "http://elsewhere" } } } }; + +describe("integration mutation plan", () => { + test("an allowed apply names every managed place and the history it writes", () => { + const plan = buildMutationPlan(PLAN_BASE); + expect(plan.canApply).toBe(true); + expect(plan.willChange).toBe(true); + expect(plan.refusalReason).toBeUndefined(); + expect(plan.changes).toEqual([ + { kind: "add", path: "catalog.providers.opencodex" }, + { kind: "add", path: "settings.providers.opencodex" }, + { kind: "snapshot", path: "$snapshot" }, + { kind: "ownership", path: "$ownership" }, + { kind: "journal", path: "$journal" }, + ]); + }); + + test("an occupied place is a replacement whether or not we own it", () => { + // Deciding from our own record would call an overwrite of somebody else's key an addition, + // which is the one situation overwrite exists for. + const plan = buildMutationPlan({ + ...PLAN_BASE, + operation: "overwrite", + classified: { state: "conflict", reason: "unowned-key" }, + record: null, + parsed: OCCUPIED, + }); + expect(plan.changes).toContainEqual({ kind: "replace", path: "settings.providers.opencodex" }); + expect(plan.changes).toContainEqual({ kind: "add", path: "catalog.providers.opencodex" }); + }); + + test("no configured value reaches the plan", () => { + const plan = buildMutationPlan(PLAN_BASE); + expect(JSON.stringify(plan)).not.toContain(CANARY); + expect(JSON.stringify(plan)).not.toContain(CONFIG_PATH); + }); + + test("apply refuses in the writer's order: installation, admission, conflict, then unsafe", () => { + const conflicted: PlanInput = { ...PLAN_BASE, classified: { state: "conflict", reason: "foreign-edit" } }; + // Installation outranks a conflict the file would otherwise report. + expect(buildMutationPlan({ ...conflicted, installKind: "missing" }).refusalReason).toBe("not_installed"); + expect(buildMutationPlan({ ...conflicted, admissionBlocked: true }).refusalReason).toBe("non_loopback"); + // And a conflict outranks the classifier's unsafe, which apply reports last. + expect(buildMutationPlan(conflicted).refusalReason).toBe("conflict"); + expect(buildMutationPlan({ ...PLAN_BASE, classified: { state: "unsafe", reason: "blocked-container" } }).refusalReason) + .toBe("unsafe"); + const refused = buildMutationPlan({ ...PLAN_BASE, installKind: "missing" }); + expect(refused.canApply).toBe(false); + expect(refused.changes).toEqual([]); + }); + + test("disable asks its own questions and never asks about installation", () => { + const disable: PlanInput = { ...PLAN_BASE, operation: "disable", classified: { state: "current" } }; + // Removing what we wrote from a file that still exists is meaningful whether or not the + // client is installed now, and it emits nothing admission policy could object to. + expect(buildMutationPlan({ ...disable, installKind: "missing" }).canApply).toBe(true); + expect(buildMutationPlan({ ...disable, admissionBlocked: true }).canApply).toBe(true); + expect(buildMutationPlan({ ...disable, classified: { state: "conflict", reason: "foreign-edit" } }).refusalReason) + .toBe("conflict"); + }); + + test("an operation that would write nothing says so and names no places", () => { + // The writer succeeds without writing in both of these, so reporting a snapshot and a journal + // row would describe consequences that never happen. + const applied = buildMutationPlan({ ...PLAN_BASE, classified: { state: "current" } }); + expect(applied.canApply).toBe(true); + expect(applied.willChange).toBe(false); + expect(applied.changes).toEqual([]); + + const disabled = buildMutationPlan({ ...PLAN_BASE, operation: "disable", classified: { state: "absent" } }); + expect(disabled.canApply).toBe(true); + expect(disabled.willChange).toBe(false); + expect(disabled.changes).toEqual([]); + }); + + test("overwrite is the operation allowed through a conflict", () => { + const conflicted: PlanInput = { ...PLAN_BASE, classified: { state: "conflict", reason: "foreign-edit" } }; + expect(buildMutationPlan(conflicted).refusalReason).toBe("conflict"); + expect(buildMutationPlan({ ...conflicted, operation: "overwrite" }).canApply).toBe(true); + expect(buildMutationPlan(conflicted).foreignEdit).toBe("foreign-edit"); + }); + + test("restore reports an expired backup and unconfirmed drift", () => { + const plan: PlanInput = { ...RESTORE_BASE, classified: { state: "current" }, parsed: {} }; + expect(buildMutationPlan({ ...plan, restore: { ...RESTORE, snapshotKind: "expired" } }).refusalReason) + .toBe("snapshot_expired"); + const drifted = buildMutationPlan({ ...plan, restore: { ...RESTORE, driftsFromResult: true } }); + expect(drifted.refusalReason).toBe("drift_requires_confirm"); + expect(drifted.foreignEdit).toBe("drift"); + expect(buildMutationPlan({ ...plan, restore: { ...RESTORE, driftsFromResult: true, confirmDrift: true } }).canApply).toBe(true); + }); + + test("a file that was recorded and is now gone has drifted, it is not simply absent", () => { + // Calling it absent would describe a missing file as an ordinary undo, while the writer + // refuses it pending confirmation. Absence is only honest when the row recorded absence too. + const vanished = buildMutationPlan({ + ...RESTORE_BASE, + classified: { state: "conflict" }, + parsed: {}, + before: null, + restore: { ...RESTORE, driftsFromResult: true }, + }); + expect(vanished.state).toBe("conflict"); + expect(vanished.refusalReason).toBe("drift_requires_confirm"); + + const recordedAbsent = buildMutationPlan({ + ...RESTORE_BASE, + classified: { state: "absent" }, + parsed: {}, + before: null, + restore: { ...RESTORE, driftsFromResult: false }, + }); + expect(recordedAbsent.state).toBe("absent"); + expect(recordedAbsent.canApply).toBe(true); + }); +}); + +/** Every file under a directory with its bytes, so "nothing changed" is a comparison and not a claim. */ +function treeSnapshot(root: string): Record { + const seen: Record = {}; + const walk = (dir: string): void => { + for (const name of readdirSync(dir).sort()) { + const full = join(dir, name); + if (statSync(full).isDirectory()) walk(full); + else seen[relative(root, full)] = readFileSync(full, "utf8"); + } + }; + walk(root); + return seen; +} + +describe("integration preview writes nothing", () => { + test("planning an apply leaves the client file and the whole store untouched", () => { + const home = mkdtempSync(join(tmpdir(), "ocx-preview-home-")); + const storeRoot = mkdtempSync(join(tmpdir(), "ocx-preview-store-")); + try { + const env = {} as NodeJS.ProcessEnv; + const { configPath, detectDir } = resolveIntegrationPaths("opencode", env, home); + // Fail loudly rather than reading somebody's real configuration: every client resolves + // through the home argument, and a client that stopped doing so must not be read here. + expect(configPath.startsWith(home), configPath).toBe(true); + mkdirSync(detectDir, { recursive: true }); + mkdirSync(dirname(configPath), { recursive: true }); + writeFileSync(configPath, "{}\n"); + + const store = createIntegrationStateStore(storeRoot); + // Maintenance a mutation would retry on its way through. A preview must leave it pending. + store.markPruneFailure("opencode", "seeded pending prune"); + const homeBefore = treeSnapshot(home); + const storeBefore = treeSnapshot(storeRoot); + + const plan = previewIntegration( + { clientId: "opencode", models: FIXTURE_MODELS, config: FIXTURE_CONFIG, port: 10100, env, home, store }, + { operation: "apply" }, + ); + + expect(plan.canApply).toBe(true); + expect(plan.changes.some(change => change.kind === "add")).toBe(true); + expect(plan.changes).toContainEqual({ kind: "journal", path: "$journal" }); + + // The whole promise of the feature, checked against the filesystem rather than asserted. + // The entire home rather than the target alone: a writer lock or a marker sibling would + // appear next to the file, not inside it, and that is exactly what must not happen here. + expect(treeSnapshot(home)).toEqual(homeBefore); + expect(treeSnapshot(storeRoot)).toEqual(storeBefore); + // Still pending, so the read path ran no maintenance. + expect(Object.keys(store.readMaintenance().pruneFailures)).toContain("opencode"); + + // A plan describes places, never contents or locations. + const serialized = JSON.stringify(plan); + expect(serialized).not.toContain(home); + expect(serialized).not.toContain("127.0.0.1"); + } finally { + removeTreeWithRetry(home); + removeTreeWithRetry(storeRoot); + } + }); + + test("a pending client transaction is left exactly as found", () => { + const home = mkdtempSync(join(tmpdir(), "ocx-preview-cline-")); + const storeRoot = mkdtempSync(join(tmpdir(), "ocx-preview-cline-store-")); + try { + const env = {} as NodeJS.ProcessEnv; + const { configPath, detectDir } = resolveIntegrationPaths("cline", env, home); + expect(configPath.startsWith(home), configPath).toBe(true); + mkdirSync(detectDir, { recursive: true }); + mkdirSync(dirname(configPath), { recursive: true }); + writeFileSync(configPath, "{}\n"); + + const store = createIntegrationStateStore(storeRoot); + const markerPath = clinePendingPath(store, configPath); + mkdirSync(dirname(markerPath), { recursive: true }); + writeFileSync(markerPath, "{\"pending\":\"left over from an interrupted mutation\"}\n"); + + const homeBefore = treeSnapshot(home); + const storeBefore = treeSnapshot(storeRoot); + + const plan = previewIntegration( + { clientId: "cline", models: FIXTURE_MODELS, config: FIXTURE_CONFIG, port: 10100, env, home, store }, + { operation: "apply" }, + ); + + // Recovery is a mutation. A preview reports what it found and repairs nothing, so the + // marker and its siblings survive byte for byte whatever the plan concluded. + expect(plan.version).toBe(1); + expect(treeSnapshot(home)).toEqual(homeBefore); + expect(treeSnapshot(storeRoot)).toEqual(storeBefore); + } finally { + removeTreeWithRetry(home); + removeTreeWithRetry(storeRoot); + } + }); +}); + +describe("planning an undo reads the writer's specification", () => { + function seedRestore(home: string, storeRoot: string, targetText: string, recordedPath?: string) { + const store = createIntegrationStateStore(storeRoot); + const env = {} as NodeJS.ProcessEnv; + const { configPath, detectDir } = resolveIntegrationPaths("opencode", env, home); + expect(configPath.startsWith(home), configPath).toBe(true); + mkdirSync(detectDir, { recursive: true }); + mkdirSync(dirname(configPath), { recursive: true }); + writeFileSync(configPath, targetText); + const opId = "op-seeded"; + const snapshot = store.captureSnapshot("opencode", opId, "{}\n"); + store.appendJournal({ + opId, + clientId: "opencode", + kind: "apply", + at: "2026-01-01T00:00:00.000Z", + configPath: recordedPath ?? configPath, + snapshot, + resultFingerprint: fingerprint(targetText), + resultAbsent: false, + priorRecord: null, + } satisfies JournalEntry); + return { store, env, opId }; + } + + test("a target that cannot be parsed is exactly the one worth restoring", () => { + const home = mkdtempSync(join(tmpdir(), "ocx-restore-parse-")); + const storeRoot = mkdtempSync(join(tmpdir(), "ocx-restore-parse-store-")); + try { + // The writer's undo reads bytes and never parses, so refusing here would deny an operator + // the backup at the moment the file is in the state that most needs one. + const { store, env, opId } = seedRestore(home, storeRoot, "{ this is not valid json"); + const plan = previewIntegration( + { clientId: "opencode", models: FIXTURE_MODELS, config: FIXTURE_CONFIG, port: 10100, env, home, store }, + { operation: "restore", opId }, + ); + expect(plan.refusalReason).toBeUndefined(); + expect(plan.canApply).toBe(true); + } finally { + removeTreeWithRetry(home); + removeTreeWithRetry(storeRoot); + } + }); + + test("a row recorded against another location is refused rather than planned", () => { + const home = mkdtempSync(join(tmpdir(), "ocx-restore-path-")); + const storeRoot = mkdtempSync(join(tmpdir(), "ocx-restore-path-store-")); + try { + // Path equality is the single thing the writer's undo exists to enforce: a row recorded for + // one home must never rewrite a file in another. + const { store, env, opId } = seedRestore(home, storeRoot, "{}\n", join(home, "elsewhere", "opencode.json")); + const plan = previewIntegration( + { clientId: "opencode", models: FIXTURE_MODELS, config: FIXTURE_CONFIG, port: 10100, env, home, store }, + { operation: "restore", opId }, + ); + expect(plan.canApply).toBe(false); + expect(plan.refusalReason).toBe("conflict"); + expect(plan.changes).toEqual([]); + } finally { + removeTreeWithRetry(home); + removeTreeWithRetry(storeRoot); + } + }); +}); + +const MANAGED_PATHS = [["settings", "providers", "opencodex"]]; + +describe("integration mutation plan: what an undo would change", () => { + const owned = { ...RECORD, fragmentPaths: MANAGED_PATHS }; + + test("undoing an initial apply takes the managed place back out", () => { + // There was no prior record, because nothing was ours before that apply. Reading only the + // prior record therefore described this undo as touching no managed place at all, while the + // undo removes the block it added. + const plan = buildMutationPlan({ + ...PLAN_BASE, + operation: "restore", + classified: { state: "current" }, + restore: { ...RESTORE, entry: { ...ENTRY, priorRecord: null } }, + record: owned, + parsed: OCCUPIED, + }); + + expect(plan.changes).toContainEqual({ kind: "remove", path: "settings.providers.opencodex" }); + expect(plan.changes.some(change => change.kind === "replace")).toBe(false); + }); + + test("undoing a disable adds back a place the document does not have", () => { + // The disable removed it, so the document has nothing there now. Calling that a replacement + // described a mutation of something that is not in the file. + const plan = buildMutationPlan({ + ...PLAN_BASE, + operation: "restore", + classified: { state: "absent" }, + restore: { ...RESTORE, entry: { ...ENTRY, kind: "disable", priorRecord: owned } }, + record: null, + parsed: {}, + }); + + expect(plan.changes).toContainEqual({ kind: "add", path: "settings.providers.opencodex" }); + expect(plan.changes.some(change => change.kind === "replace")).toBe(false); + }); + + test("a place that is ours on both sides is replaced, and none of this publishes a value", () => { + const plan = buildMutationPlan({ + ...PLAN_BASE, + operation: "restore", + classified: { state: "current" }, + restore: { ...RESTORE, entry: { ...ENTRY, priorRecord: owned } }, + record: owned, + parsed: OCCUPIED, + }); + + expect(plan.changes).toContainEqual({ kind: "replace", path: "settings.providers.opencodex" }); + expect(plan.changes.some(change => change.kind === "remove" || change.kind === "add")).toBe(false); + // The plan names places, never what is in them or where the file lives. + expect(JSON.stringify(plan)).not.toContain(CANARY); + expect(JSON.stringify(plan)).not.toContain(CONFIG_PATH); + }); +}); + +describe("planning an undo of work that actually happened", () => { + function realApply(home: string, storeRoot: string) { + const store = createIntegrationStateStore(storeRoot); + const env = {} as NodeJS.ProcessEnv; + const { configPath, detectDir } = resolveIntegrationPaths("opencode", env, home); + mkdirSync(detectDir, { recursive: true }); + mkdirSync(dirname(configPath), { recursive: true }); + writeFileSync(configPath, "{}\n"); + const input = { clientId: "opencode" as const, models: FIXTURE_MODELS, config: FIXTURE_CONFIG, port: 10100, env, home, store }; + const applied = applyIntegration(input); + if (!applied.ok || !applied.opId) throw new Error("fixture apply failed"); + return { input, configPath, applyOpId: applied.opId }; + } + + test("undoing a real apply reports taking the managed places back out", () => { + const home = mkdtempSync(join(tmpdir(), "ocx-undo-apply-")); + const storeRoot = mkdtempSync(join(tmpdir(), "ocx-undo-apply-store-")); + try { + const { input, configPath, applyOpId } = realApply(home, storeRoot); + expect(readFileSync(configPath, "utf8")).toContain("opencodex"); + + // Every input this classification reads comes from the apply that just happened: the + // ownership record it wrote and the document it produced, not values a builder supplied. + const plan = previewIntegration(input, { operation: "restore", opId: applyOpId }); + + expect(plan.canApply).toBe(true); + expect(plan.changes.some(change => change.kind === "remove")).toBe(true); + expect(plan.changes.some(change => change.kind === "replace")).toBe(false); + expect(plan.changes.some(change => change.kind === "add")).toBe(false); + + // And the undo does what the plan said: the managed block is gone from the file. + const restored = restoreIntegration({ ...input, opId: applyOpId }); + expect(restored.ok).toBe(true); + expect(readFileSync(configPath, "utf8")).toBe("{}\n"); + } finally { + removeTreeWithRetry(home); + removeTreeWithRetry(storeRoot); + } + }); + + test("undoing a real disable reports adding the managed places back", () => { + const home = mkdtempSync(join(tmpdir(), "ocx-undo-disable-")); + const storeRoot = mkdtempSync(join(tmpdir(), "ocx-undo-disable-store-")); + try { + const { input, configPath } = realApply(home, storeRoot); + const disabled = disableIntegration(input); + if (!disabled.ok || !disabled.opId) throw new Error("fixture disable failed"); + // The disable took the block out, so the document does not hold those places now. + expect(readFileSync(configPath, "utf8")).not.toContain("opencodex"); + + const plan = previewIntegration(input, { operation: "restore", opId: disabled.opId }); + + expect(plan.canApply).toBe(true); + expect(plan.changes.some(change => change.kind === "add")).toBe(true); + expect(plan.changes.some(change => change.kind === "replace")).toBe(false); + + // And the undo does what the plan said: the managed block is back in the file. + const restored = restoreIntegration({ ...input, opId: disabled.opId }); + expect(restored.ok).toBe(true); + expect(readFileSync(configPath, "utf8")).toContain("opencodex"); + } finally { + removeTreeWithRetry(home); + removeTreeWithRetry(storeRoot); + } + }); + + test("a document that cannot be read leaves every place a replacement and refuses nothing", () => { + const home = mkdtempSync(join(tmpdir(), "ocx-undo-unreadable-")); + const storeRoot = mkdtempSync(join(tmpdir(), "ocx-undo-unreadable-store-")); + try { + const { input, configPath } = realApply(home, storeRoot); + const disabled = disableIntegration(input); + if (!disabled.ok || !disabled.opId) throw new Error("fixture disable failed"); + // Restore eligibility is a byte comparison and stays one: the document is descriptive here, + // so an unparseable file still plans, and the places it cannot speak for are replacements. + writeFileSync(configPath, "{ this is not valid json"); + + const plan = previewIntegration(input, { operation: "restore", opId: disabled.opId, confirmDrift: true }); + + expect(plan.refusalReason).toBeUndefined(); + expect(plan.canApply).toBe(true); + // The same undo reported additions when the document was readable and empty of them. With + // nothing readable to ask, it says replacement rather than inventing an answer. + expect(plan.changes.some(change => change.kind === "replace")).toBe(true); + expect(plan.changes.some(change => change.kind === "add")).toBe(false); + } finally { + removeTreeWithRetry(home); + removeTreeWithRetry(storeRoot); + } + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 47fa4634252..b87369c82c0 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -648,6 +648,7 @@ "integrations-merge.test.ts": "clients", "integrations-serialize.test.ts": "clients", "integrations-state.test.ts": "clients", + "integrations-writer-frozen-config.test.ts": "clients", "integrations-writer.test.ts": "clients", "interactive-confirm.test.ts": "cli", "issue-452-empty-503.test.ts": "codex-integration", @@ -756,6 +757,8 @@ "management-google-tool-schema-policy.test.ts": "server", "management-integration-journal-delete.test.ts": "server", "management-integration-routes.test.ts": "server", + "management-model-roster-gather-race.test.ts": "server", + "management-model-roster.test.ts": "server", "management-origin-tls.test.ts": "server", "management-provider-pinsless-validation.test.ts": "server", "management-provider-proto-override.test.ts": "server", @@ -796,6 +799,7 @@ "muse-spark-web-search-compat.test.ts": "providers", "muse-subscription-usage.test.ts": "providers", "muse-tool-name-alias.test.ts": "providers", + "mutation-plan.test.ts": "clients", "namespace-tool-compat.test.ts": "responses", "native-alias-maintainer-regressions.test.ts": "codex-integration", "native-claude-code-toggle.test.ts": "codex-integration", diff --git a/tests/providers/provider-outbound.test.ts b/tests/providers/provider-outbound.test.ts index edbf073ed7f..c0d5f59a2a9 100644 --- a/tests/providers/provider-outbound.test.ts +++ b/tests/providers/provider-outbound.test.ts @@ -54,6 +54,33 @@ function directDependencies( } describe("provider outbound GET transport", () => { + test("a written fetch value is configuration, not an executor to call", async () => { + for (const key of proxyKeys) delete process.env[key]; + const { providerOutboundGet } = await import("../../src/lib/provider-outbound"); + const { dependencies, captured } = directDependencies(new Response('{"data":[]}', { + status: 200, + headers: { "content-type": "application/json" }, + })); + + /* + * A provider entry keeps unknown configuration keys, so an operator can write `fetch` into + * config.json and it arrives here as a string. Treating a present value as callable threw + * inside discovery and failed that provider for a reason nothing in its configuration + * explains. A configured value means the built-in transport, which is what pins the peer. + */ + const written = { baseUrl: "https://provider.example/v1", fetch: "https://not-an-executor.example" }; + const response = await providerOutboundGet( + "written-fetch", + written as unknown as Parameters[1], + "https://provider.example/v1/models", + { headers: { authorization: "Bearer test-key" } }, + dependencies, + ); + + expect(await response.json()).toEqual({ data: [] }); + expect(captured.address).toBe("93.184.216.34"); + }); + test("direct HTTPS connects only to the validated address with TLS verification", async () => { for (const key of proxyKeys) delete process.env[key]; const { providerOutboundGet } = await import("../../src/lib/provider-outbound"); diff --git a/tests/server/aside-profiles-routes.test.ts b/tests/server/aside-profiles-routes.test.ts index a21f0168b64..2c202e13130 100644 --- a/tests/server/aside-profiles-routes.test.ts +++ b/tests/server/aside-profiles-routes.test.ts @@ -1,11 +1,12 @@ import { loadConfig } from "../../src/config"; import { afterEach, beforeEach, expect, test } from "bun:test"; -import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { handleManagementAPI } from "../../src/server/management-api"; import { MANAGEMENT_JSON_BODY_MAX_BYTES } from "../../src/server/management/body"; import { setIntegrationMutationFlightTestHooks, setIntegrationPathTestHooks } from "../../src/server/management/integration-routes"; +import { defaultIntegrationIO } from "../../src/integrations/config-io"; import { createIntegrationStateStore, type IntegrationStateStore } from "../../src/integrations/store"; import { applyIntegration } from "../../src/integrations/writer"; import { refreshOwnedCatalogIntegrations } from "../../src/integrations/catalog-refresh"; @@ -13,6 +14,10 @@ import type { OcxConfig } from "../../src/types"; import { catalogConvergenceFactory } from "../helpers/catalog-convergence"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { exportSnapshotIdentity, loadExportModels, previewExportSnapshot, resetExportSnapshotForTests } from "../../src/server/management/model-rows"; +import { asideGuardFor } from "../../src/server/management/aside-profile-routes"; +import { previewIntegration, type IntegrationMutationPlan } from "../../src/integrations/mutation-plan"; +import { setCached } from "../../src/codex/model-cache"; let root: string; let home: string; @@ -21,6 +26,12 @@ let config: OcxConfig; let isolation: IsolatedCodexHome; let priorOcxHome: string | undefined; let saved: OcxConfig | undefined; +/** + * Runs inside the preference write, which is where the window this fixture exercises lives: the + * confirmation has been checked, nothing has been written to the client yet, and an editor outside + * this process can still change the target. + */ +let onPersist: (() => void) | undefined; const env: NodeJS.ProcessEnv = {}; beforeEach(() => { @@ -43,6 +54,7 @@ beforeEach(() => { fixture: { adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1", liveModels: false, models: ["one","two"] }, } } as OcxConfig; saved = undefined; + onPersist = undefined; setIntegrationPathTestHooks({ home, env }); setIntegrationMutationFlightTestHooks({ store }); }); @@ -57,6 +69,26 @@ afterEach(() => { }); function path(id: number): string { return join(home, ".aside", "u", String(id), "models.json"); } + +/** Whole-tree content, so an appended journal row or replaced snapshot cannot hide. */ +function treeWitness(dir: string): string { + if (!existsSync(dir)) return ""; + return readdirSync(dir, { recursive: true }) + .map(entry => String(entry)) + .sort() + .map(entry => { + const full = join(dir, entry); + if (!existsSync(full) || statSync(full).isDirectory()) return `${entry}/`; + return `${entry}:${readFileSync(full, "utf8")}`; + }) + .join("\u0000"); +} + +/** A preview only answers from a roster an authoritative load already finished. */ +async function seedRoster(): Promise { + resetExportSnapshotForTests(); + await loadExportModels(config, []); +} function document(id: number) { return JSON.parse(readFileSync(path(id), "utf8")); } async function api(pathname: string, method = "GET", body?: unknown) { return rawApi(pathname, method, body === undefined ? undefined : JSON.stringify(body)); @@ -67,7 +99,7 @@ async function rawApi(pathname: string, method: string, body?: string) { method, headers: { Host: url.host, "content-type": "application/json" }, ...(body === undefined ? {} : { body }), }), url, config, { - saveConfigPreservingClaudeCode: value => { saved = structuredClone(value); }, + saveConfigPreservingClaudeCode: value => { saved = structuredClone(value); onPersist?.(); }, createManagementConvergeCodex: catalogConvergenceFactory(), refreshOwnedCatalogIntegrations: input => refreshOwnedCatalogIntegrations({ ...input, store, env, home }), }); @@ -196,6 +228,141 @@ test("profile history and Undo cannot recreate an undone enable on the next sync expect(document(0).providers.opencodex).toBeUndefined(); }); +test("profile zero can be planned; it is a real profile, not an absent one", async () => { + await seedRoster(); + const response = await api("/api/client-integrations/aside/profiles/0/preview", "POST", { operation: "apply" }); + expect(response.status).toBe(200); + const plan = await response.json() as { canApply: boolean; profileId?: number; fingerprint: string }; + expect(plan.canApply).toBe(true); + expect(plan.profileId).toBe(0); + // A plan names places, never the profile's location. + expect(JSON.stringify(plan)).not.toContain(home); +}); + +test("a previewed profile change commits once and then has no roster to replay against", async () => { + await seedRoster(); + const preview = await api("/api/client-integrations/aside/profiles/1/preview", "POST", { operation: "apply" }); + expect(preview.status).toBe(200); + const plan = await preview.json() as { canApply: boolean; fingerprint: string }; + expect(plan.canApply).toBe(true); + + const commit = await api("/api/client-integrations/aside/profiles/1", "PUT", { + enabled: true, operation: "apply", planFingerprint: plan.fingerprint, + }); + expect(commit.status).toBe(200); + expect(document(1).providers.opencodex).toBeDefined(); + const committed = readFileSync(path(1), "utf8"); + + // Replaying the same confirmation is refused before anything is written, and the reason is the + // earlier of the two: committing wrote the Aside preference into the configuration, so the + // roster this plan was bound to no longer describes the configuration in hand and there is + // nothing to replan against until the collection is read again. Production reaches the same + // point through the file, which the preference write also rewrites. + const replay = await api("/api/client-integrations/aside/profiles/1", "PUT", { + enabled: true, operation: "apply", planFingerprint: plan.fingerprint, + }); + expect(replay.status).toBe(409); + expect((await replay.json() as { code: string }).code).toBe("integration_preview_unavailable"); + expect(readFileSync(path(1), "utf8")).toBe(committed); + // The refusal must not touch a sibling profile either. + expect(document(2).providers.opencodex).toBeUndefined(); +}); + +test("a stale profile confirmation is refused before the preference is written", async () => { + await seedRoster(); + const preview = await api("/api/client-integrations/aside/profiles/2/preview", "POST", { operation: "apply" }); + const plan = await preview.json() as { fingerprint: string }; + const before = readFileSync(path(2), "utf8"); + const homeBefore = treeWitness(join(home, ".aside")); + const storeBefore = treeWitness(join(root, "store")); + + const response = await api("/api/client-integrations/aside/profiles/2", "PUT", { + enabled: true, operation: "apply", planFingerprint: `${plan.fingerprint}-not-current`, + }); + expect(response.status).toBe(409); + expect((await response.json() as { code: string }).code).toBe("integration_preview_stale"); + expect(readFileSync(path(2), "utf8")).toBe(before); + // Ownership records, snapshots and journal rows live in the store, and an Aside import writes + // history before any writer runs, so the target file alone would not see either of them. + expect(treeWitness(join(home, ".aside"))).toBe(homeBefore); + expect(treeWitness(join(root, "store"))).toBe(storeBefore); + // Aside persists its preference before any writer runs, so a check that fired later would have + // saved this already. + expect(saved).toBeUndefined(); +}); + +test("a bound undo follows the copy resolution actually chose", async () => { + await seedRoster(); + const original = readFileSync(path(1), "utf8"); + const applied = await api("/api/client-integrations/aside/profiles/1", "PUT", { enabled: true }); + expect(applied.status).toBe(200); + expect(document(1).providers.opencodex).toBeDefined(); + + const profileStore = join(root, "store", "aside-profiles", "1"); + const rows = readFileSync(join(profileStore, "journal.jsonl"), "utf8"); + const opId = JSON.parse(rows.trim().split("\n")[0] ?? "{}").opId as string; + expect(typeof opId).toBe("string"); + + /* + * The same operation can live in more than one store with different retention. Resolution + * prefers the copy whose snapshot still exists, so putting a stored copy in the root store and + * expiring the profile's own forces it to choose the alternate. If a preview and the mutation + * resolved independently they could pick different copies, and the confirmation would then + * describe an operation other than the one that runs. + */ + writeFileSync(join(root, "store", "journal.jsonl"), rows); + const snapshotName = join("snapshots", "aside", opId); + mkdirSync(join(root, "store", "snapshots", "aside"), { recursive: true }); + writeFileSync(join(root, "store", snapshotName), readFileSync(join(profileStore, snapshotName), "utf8")); + rmSync(join(profileStore, snapshotName)); + + const siblingBefore = treeWitness(join(root, "store", "aside-profiles", "2")); + const sibling2Before = readFileSync(path(2), "utf8"); + const preview = await api("/api/client-integrations/aside/profiles/1/preview", "POST", { operation: "restore", opId }); + expect(preview.status).toBe(200); + const plan = await preview.json() as { + canApply: boolean; fingerprint: string; profileId?: number; + changes: Array<{ kind: string; path: string }>; + }; + expect(plan.canApply).toBe(true); + expect(plan.profileId).toBe(1); + /* + * This undo takes the managed block back out, because the apply it reverses had nothing of ours + * before it. Saying so requires reading the ownership of the file being rewritten, which lives + * in the profile's own store, while the row and its snapshot were resolved out of the root one. + * Reading the selected row's store instead would describe another file's ownership. + */ + expect(plan.changes.some(change => change.kind === "remove")).toBe(true); + expect(plan.changes.some(change => change.kind === "add")).toBe(false); + + const undo = await api("/api/client-integrations/aside/profiles/1/restore", "POST", { + opId, operation: "restore", planFingerprint: plan.fingerprint, + }); + expect(undo.status).toBe(200); + const result = await undo.json() as { ok: boolean; changed: boolean; clientId: string; profileId: number; opId: string }; + expect(result).toMatchObject({ ok: true, changed: true, clientId: "aside", profileId: 1 }); + expect(typeof result.opId).toBe("string"); + + // Restored from the copy that was chosen, byte for byte. + expect(readFileSync(path(1), "utf8")).toBe(original); + + // The undo is journalled in the profile's own store as a restore row, rather than inferred from + // a substring of two files concatenated together. + const profileRows = readFileSync(join(profileStore, "journal.jsonl"), "utf8") + .trim().split("\n").map(line => JSON.parse(line) as { kind: string; opId: string }); + expect(profileRows.filter(row => row.kind === "restore")).toEqual([ + expect.objectContaining({ kind: "restore", opId: result.opId }), + ]); + expect(profileRows.some(row => row.opId === opId)).toBe(true); + + // The copy resolution chose is not rewritten, and the sibling profile is untouched in both its + // document and its store. + expect(readFileSync(join(root, "store", "journal.jsonl"), "utf8")).toBe(rows); + expect(readFileSync(join(root, "store", snapshotName), "utf8")).toBe(original); + expect(readFileSync(path(2), "utf8")).toBe(sibling2Before); + expect(treeWitness(join(root, "store", "aside-profiles", "2"))).toBe(siblingBefore); +}); + test.each(["../0", "01", "-1", "9007199254740992"])("rejects invalid profile %s before file mutation", async id => { const before = [0,1,2].map(i => readFileSync(path(i), "utf8")); const response = await api(`/api/client-integrations/aside?profile=${encodeURIComponent(id)}`, "PUT", { enabled: true }); @@ -250,3 +417,245 @@ test("dedicated nested paths retain profile scope for status, history and restor expect(document(2).providers.opencodex).toBeUndefined(); expect(document(0).providers.opencodex).toBeUndefined(); }); + +test("a bound confirmation is refused when the roster it was planned against has moved", async () => { + // The guard the route installs, exercised with the input a mutation would be written from. Only + // the rows are carried into the mutation, so an ordinary load completing while it prepares can + // replace or retire the snapshot without the carried rows noticing. + resetExportSnapshotForTests(); + // A roster with rows in it, so the plan below is a real apply rather than a noop. + await loadExportModels(config, [{ id: "one", provider: "fixture" }]); + const identity = exportSnapshotIdentity(config); + expect(identity).not.toBeNull(); + const roster = previewExportSnapshot(config); + expect(roster).not.toBeNull(); + + const profileStore = createIntegrationStateStore(join(root, "store", "aside-profiles", "1")); + const prepared = { + clientId: "aside" as const, + config, + models: roster!.models, + port: 10100, + env, + home, + store: profileStore, + resolvedPaths: { configPath: path(1), detectDir: join(home, ".aside", "u", "1") }, + }; + const plan = previewIntegration(prepared, { operation: "apply" as const, profileId: 1 }); + expect(plan.canApply).toBe(true); + + const capture: { plan: IntegrationMutationPlan | null } = { plan: null }; + const guard = asideGuardFor({ config }, identity!, 1, { operation: "apply", fingerprint: plan.fingerprint }, {}, capture); + + // Current roster, matching fingerprint: the confirmation stands. + expect(await guard(prepared)).toBeNull(); + + // A discovery publishes while this change is in flight. The rows the guard holds are unchanged, + // and they are no longer the roster the operator was shown. + expect(setCached("fixture", [{ id: "published-mid-flight", provider: "fixture" }])).toBe(true); + expect(exportSnapshotIdentity(config)).toBeNull(); + const refused = await guard(prepared); + expect(refused).toMatchObject({ ok: false, reason: "conflict", clientId: "aside", profileId: 1 }); + expect(refused?.message).toContain("roster changed"); + expect(capture.plan).not.toBeNull(); +}); + +test("a confirmed disable of a profile with nothing applied saves the preference and touches no file", async () => { + /* + * willChange: false is a statement about the managed client document, and the plan says so. The + * change still records the operator's desired sync preference for that profile, which happens + * before any client document is touched, so the document and its history stay exactly as they + * were while the preference is saved. + */ + await seedRoster(); + const before = readFileSync(path(1), "utf8"); + const profileStoreBefore = treeWitness(join(root, "store", "aside-profiles", "1")); + const rootStoreBefore = treeWitness(join(root, "store")); + + const preview = await api("/api/client-integrations/aside/profiles/1/preview", "POST", { operation: "disable" }); + expect(preview.status).toBe(200); + const plan = await preview.json() as { canApply: boolean; willChange: boolean; fingerprint: string }; + expect(plan.canApply).toBe(true); + expect(plan.willChange).toBe(false); + + const disabled = await api("/api/client-integrations/aside?profile=1", "PUT", { + enabled: false, operation: "disable", planFingerprint: plan.fingerprint, + }); + expect(disabled.status).toBe(200); + + // Nothing in the client's document or in either store moved. + expect(readFileSync(path(1), "utf8")).toBe(before); + expect(treeWitness(join(root, "store", "aside-profiles", "1"))).toBe(profileStoreBefore); + expect(treeWitness(join(root, "store"))).toBe(rootStoreBefore); + + // The preference is the one thing that was written, and it is what the operator asked for. + expect(saved?.asideProfileSync?.profiles?.["1"]).toBe(false); + expect(config.asideProfileSync?.profiles?.["1"]).toBe(false); +}); + +test("a target edited while the preference is being saved is not overwritten by the old confirmation", async () => { + /* + * Aside takes no writer lock, and its preference write sits between the confirmation check and + * the write that check authorizes. An overwrite does not ask the writer's own conflict question, + * so without a second look the confirmation about the earlier file would land on the later one. + */ + await seedRoster(); + const foreign = JSON.stringify({ theme: "keep", providers: { opencodex: { models: [{ id: "written-by-someone-else" }] } } }); + writeFileSync(path(1), foreign); + + const preview = await api("/api/client-integrations/aside/profiles/1/preview", "POST", { operation: "overwrite" }); + expect(preview.status).toBe(200); + const plan = await preview.json() as { canApply: boolean; fingerprint: string }; + expect(plan.canApply).toBe(true); + + const edited = JSON.stringify({ theme: "edited-after-the-check", providers: { opencodex: { models: [{ id: "still-not-ours" }] } } }); + onPersist = () => { writeFileSync(path(1), edited); }; + + const commit = await api("/api/client-integrations/aside?profile=1", "PUT", { + enabled: true, overwriteConflict: true, operation: "overwrite", planFingerprint: plan.fingerprint, + }); + + expect(commit.status).toBe(409); + expect((await commit.json() as { code: string }).code).toBe("integration_preview_stale"); + // The file the editor wrote is the file that is still there. + expect(readFileSync(path(1), "utf8")).toBe(edited); +}); + +test("an unchanged target still commits the same confirmed overwrite", async () => { + // The control for the case above: nothing moves in the window, and the confirmation stands. + await seedRoster(); + writeFileSync(path(1), JSON.stringify({ theme: "keep", providers: { opencodex: { models: [{ id: "written-by-someone-else" }] } } })); + + const preview = await api("/api/client-integrations/aside/profiles/1/preview", "POST", { operation: "overwrite" }); + const plan = await preview.json() as { canApply: boolean; fingerprint: string }; + expect(plan.canApply).toBe(true); + + const commit = await api("/api/client-integrations/aside?profile=1", "PUT", { + enabled: true, overwriteConflict: true, operation: "overwrite", planFingerprint: plan.fingerprint, + }); + + expect(commit.status).toBe(200); + // The block that was there is the one the overwrite was for, and it is gone. + expect(JSON.stringify(document(1))).not.toContain("written-by-someone-else"); + expect(document(1).providers.opencodex).toBeDefined(); +}); + +test("a confirmed drift restore does not rewrite a target edited while preferences were saved", async () => { + /* + * Confirming drift says the operator accepted the difference they were shown. It does not say + * they accepted one that appeared afterwards, and the snapshot checks say nothing about the + * target file. + * + * The row is left only in the root store, so a restore that proceeded would copy it and its + * snapshot into this profile's own store first. That copy is history, and the coordinated + * restore cannot take it back: a refusal from inside it returns before the restore transaction + * begins, so nothing compensates. The refusal therefore has to happen before the copy, and the + * store witnesses below are what say it did. + */ + const enabled = await (await api("/api/client-integrations/aside?profile=1", "PUT", { enabled: true })).json(); + expect(enabled.ok).toBe(true); + const opId = enabled.opId as string; + + const profileStore = join(root, "store", "aside-profiles", "1"); + const snapshotName = join("snapshots", "aside", opId); + writeFileSync(join(root, "store", "journal.jsonl"), readFileSync(join(profileStore, "journal.jsonl"), "utf8")); + mkdirSync(join(root, "store", "snapshots", "aside"), { recursive: true }); + writeFileSync(join(root, "store", snapshotName), readFileSync(join(profileStore, snapshotName), "utf8")); + rmSync(join(profileStore, snapshotName)); + + writeFileSync(path(1), JSON.stringify({ theme: "drifted-before-the-preview" })); + await seedRoster(); + const preview = await api("/api/client-integrations/aside/profiles/1/preview", "POST", { + operation: "restore", opId, confirmDrift: true, + }); + expect(preview.status).toBe(200); + const plan = await preview.json() as { canApply: boolean; fingerprint: string }; + expect(plan.canApply).toBe(true); + + const profileStoreBefore = treeWitness(profileStore); + const rootStoreBefore = treeWitness(join(root, "store")); + const edited = JSON.stringify({ theme: "edited-after-the-check" }); + onPersist = () => { writeFileSync(path(1), edited); }; + + const undo = await api("/api/client-integrations/aside/profiles/1/restore", "POST", { + opId, operation: "restore", confirmDrift: true, planFingerprint: plan.fingerprint, + }); + + expect(undo.status).toBe(409); + expect((await undo.json() as { code: string }).code).toBe("integration_preview_stale"); + // The editor's file is the one still there, and no history was written on the way to refusing. + expect(readFileSync(path(1), "utf8")).toBe(edited); + expect(treeWitness(profileStore)).toBe(profileStoreBefore); + expect(treeWitness(join(root, "store"))).toBe(rootStoreBefore); +}); + +test("a target edited after the history copy is still not rewritten by the old confirmation", async () => { + /* + * The copy and the restore are two moments, and an edit can land between them: the coordinated + * restore has no writer lock to hold one out, and it still awaits before beginning. The check + * before the copy accepts the file as it was; the one inside the coordinated restore is what has + * to see the file as it became. + */ + const enabled = await (await api("/api/client-integrations/aside?profile=1", "PUT", { enabled: true })).json(); + expect(enabled.ok).toBe(true); + const opId = enabled.opId as string; + + const profileStore = join(root, "store", "aside-profiles", "1"); + const snapshotName = join("snapshots", "aside", opId); + writeFileSync(join(root, "store", "journal.jsonl"), readFileSync(join(profileStore, "journal.jsonl"), "utf8")); + mkdirSync(join(root, "store", "snapshots", "aside"), { recursive: true }); + writeFileSync(join(root, "store", snapshotName), readFileSync(join(profileStore, snapshotName), "utf8")); + rmSync(join(profileStore, snapshotName)); + + writeFileSync(path(1), JSON.stringify({ theme: "drifted-before-the-preview" })); + await seedRoster(); + const preview = await api("/api/client-integrations/aside/profiles/1/preview", "POST", { + operation: "restore", opId, confirmDrift: true, + }); + const plan = await preview.json() as { canApply: boolean; fingerprint: string }; + expect(plan.canApply).toBe(true); + + // The absent destination snapshot is the observable boundary. Neither restore preflight nor + // either pre-import check can trigger the edit; only a read after the real copy can do so. + const copiedSnapshotPath = join(profileStore, snapshotName); + const sourceSnapshot = readFileSync(join(root, "store", snapshotName), "utf8"); + expect(existsSync(copiedSnapshotPath)).toBe(false); + const edited = JSON.stringify({ theme: "edited-after-the-copy" }); + let editedAfterCopy = false; + /* + * A file-level seam only. The Aside layer rebinds the journal and record writers to the + * profile's own store whatever io it is handed, so the history assertions below still describe + * the store they name. + */ + const base = defaultIntegrationIO(store); + setIntegrationMutationFlightTestHooks({ + store, + io: { + ...base, + readText: (target: string) => { + if (target === path(1) && !editedAfterCopy && existsSync(copiedSnapshotPath)) { + expect(readFileSync(copiedSnapshotPath, "utf8")).toBe(sourceSnapshot); + editedAfterCopy = true; + writeFileSync(path(1), edited); + } + return base.readText(target); + }, + }, + }); + + const undo = await api("/api/client-integrations/aside/profiles/1/restore", "POST", { + opId, operation: "restore", confirmDrift: true, planFingerprint: plan.fingerprint, + }); + + expect(editedAfterCopy).toBe(true); + expect(readFileSync(copiedSnapshotPath, "utf8")).toBe(sourceSnapshot); + expect(saved?.asideProfileSync?.profiles?.["1"]).toBe(false); + expect(undo.status).toBe(409); + expect((await undo.json() as { code: string }).code).toBe("integration_preview_stale"); + // The editor's file survives: no restore transaction ran. + expect(readFileSync(path(1), "utf8")).toBe(edited); + // The copied history and the saved preference may remain; what must not is a restore row. + const profileRows = readFileSync(join(profileStore, "journal.jsonl"), "utf8") + .trim().split("\n").map(line => JSON.parse(line) as { kind: string }); + expect(profileRows.some(row => row.kind === "restore")).toBe(false); +}); diff --git a/tests/server/management-integration-routes.test.ts b/tests/server/management-integration-routes.test.ts index e0d6563aeaa..23d3c4b2ab0 100644 --- a/tests/server/management-integration-routes.test.ts +++ b/tests/server/management-integration-routes.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; @@ -11,6 +11,7 @@ import { createIntegrationStateStore, type IntegrationStateStore } from "../../s import { applyIntegration } from "../../src/integrations/writer"; import type { IntegrationWriterLockSeams } from "../../src/integrations/writer-lock"; import { handleManagementAPI } from "../../src/server/management-api"; +import { loadExportModels, resetExportSnapshotForTests } from "../../src/server/management/model-rows"; import { setIntegrationMutationFlightTestHooks, setIntegrationPathTestHooks, @@ -141,6 +142,28 @@ function hermesConfigPath(): string { return INTEGRATION_CLIENTS.hermes.configPath(routeEnv, home); } +/** Whole-store content, so an append or an overwrite cannot hide behind an unchanged file list. */ +function storeContentWitness(root: string): string { + if (!existsSync(root)) return ""; + return readdirSync(root, { recursive: true }) + .map(entry => String(entry)) + .sort() + .map(entry => { + const full = join(root, entry); + if (!existsSync(full) || statSync(full).isDirectory()) return `${entry}/`; + return `${entry}:${readFileSync(full, "utf8")}`; + }) + .join("\u0000"); +} + +async function previewApi(path: string, body: unknown): Promise { + return api(path, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + async function rawApi(path: string, init: RequestInit = {}): Promise { const url = new URL(`http://127.0.0.1:10100${path}`); return handleManagementAPI( @@ -1212,3 +1235,307 @@ describe("admission", () => { } }); }); + +describe("integration previews are reads", () => { + test("planning without a cached roster refuses rather than gathering one", async () => { + const configPath = installHermes(); + const before = existsSync(configPath) ? readFileSync(configPath, "utf8") : null; + + const response = await previewApi("/api/client-integrations/preview", { clientId: "hermes", operation: "apply" }); + // Discovery refreshes credentials and writes the provider cache, so a preview may not perform + // one to manufacture a roster. With nothing cached there is no honest snapshot to plan + // against, and saying so is the correct answer. + expect(response.status).toBe(409); + const body = await response.json() as { code: string }; + expect(body.code).toBe("integration_preview_unavailable"); + expect(JSON.stringify(body)).not.toContain(home); + + const after = existsSync(configPath) ? readFileSync(configPath, "utf8") : null; + expect(after).toBe(before); + }); + + test("a previewed change commits exactly once and then reports itself stale", async () => { + const configPath = installHermes(); + resetExportSnapshotForTests(); + // A preview reads only a roster an authoritative load already finished, so give it one. + await loadExportModels(config, []); + + const preview = await previewApi("/api/client-integrations/preview", { clientId: "hermes", operation: "apply" }); + expect(preview.status).toBe(200); + const plan = await preview.json() as { canApply: boolean; fingerprint: string }; + expect(plan.canApply).toBe(true); + + const commit = await api("/api/client-integrations/hermes", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled: true, operation: "apply", planFingerprint: plan.fingerprint }), + }); + expect(commit.status).toBe(200); + const committed = readFileSync(configPath, "utf8"); + + // The same confirmation replayed now describes a file that no longer exists in that state. + // Without a real fingerprint comparison this would apply a second time. + const replay = await api("/api/client-integrations/hermes", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled: true, operation: "apply", planFingerprint: plan.fingerprint }), + }); + expect(replay.status).toBe(409); + expect((await replay.json() as { code: string }).code).toBe("integration_preview_stale"); + expect(readFileSync(configPath, "utf8")).toBe(committed); + }); + + test("a roster replaced while the lock is taken refuses instead of writing", async () => { + const configPath = installDsh(); + resetExportSnapshotForTests(); + await loadExportModels(config, []); + + const preview = await previewApi("/api/client-integrations/preview", { clientId: "dsh", operation: "apply" }); + expect(preview.status).toBe(200); + const plan = await preview.json() as { canApply: boolean; fingerprint: string }; + expect(plan.canApply).toBe(true); + const before = existsSync(configPath) ? readFileSync(configPath, "utf8") : null; + const storeBefore = storeContentWitness(storeRoot); + + /* + * Publishing a new roster as the lock is acquired puts the replacement exactly where it is + * hardest to notice: after the request captured its input and before the guard runs under the + * lock. A check that re-read the roster at that moment would validate the new one and write + * the old, which is the failure this ordering exists to make impossible. + * + * The replacement has to be a different roster. Reloading the same rows publishes nothing an + * operator could see, and a confirmation is not stale because an unrelated read rebuilt the + * list it was already holding. + */ + const lockSeams: IntegrationWriterLockSeams = { + writeFile: async () => { + await loadExportModels(config, [{ id: "published-under-the-lock", provider: "a" }]); + }, + removeFile: async () => {}, + now: () => 0, + delay: async () => {}, + pid: 12, + }; + setIntegrationMutationFlightTestHooks({ store, lockSeams }); + + const commit = await api("/api/client-integrations/dsh", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled: true, operation: "apply", planFingerprint: plan.fingerprint }), + }); + expect(commit.status).toBe(409); + expect((await commit.json() as { code: string }).code).toBe("integration_preview_stale"); + expect(existsSync(configPath) ? readFileSync(configPath, "utf8") : null).toBe(before); + // The race has to leave the store alone too: a snapshot or journal row written before the + // guard refused would be invisible to a target-bytes check. + expect(storeContentWitness(storeRoot)).toBe(storeBefore); + }); + + test("a roster rebuilt unchanged while the lock is taken is not a replacement", async () => { + const configPath = installDsh(); + resetExportSnapshotForTests(); + await loadExportModels(config, []); + + const preview = await previewApi("/api/client-integrations/preview", { clientId: "dsh", operation: "apply" }); + const plan = await preview.json() as { canApply: boolean; fingerprint: string }; + expect(plan.canApply).toBe(true); + + // The Integrations collection performs an ordinary load on every visit, and one can land in + // this window. It rebuilds the same rows, so the confirmation an operator is submitting still + // describes what they were shown, and refusing it would be a conflict they cannot act on. + const lockSeams: IntegrationWriterLockSeams = { + writeFile: async () => { await loadExportModels(config, []); }, + removeFile: async () => {}, + now: () => 0, + delay: async () => {}, + pid: 12, + }; + setIntegrationMutationFlightTestHooks({ store, lockSeams }); + + const commit = await api("/api/client-integrations/dsh", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled: true, operation: "apply", planFingerprint: plan.fingerprint }), + }); + expect(commit.status).toBe(200); + expect(existsSync(configPath)).toBe(true); + }); + + test("a previewed undo commits with its binding", async () => { + const configPath = installHermes(); + resetExportSnapshotForTests(); + await loadExportModels(config, []); + + const applied = await api("/api/client-integrations/hermes", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled: true }), + }); + expect(applied.status).toBe(200); + expect(existsSync(configPath)).toBe(true); + + const opId = store.listOperations("hermes")[0]?.opId; + expect(opId).toBeDefined(); + + const preview = await previewApi("/api/client-integrations/restore/preview", { opId }); + expect(preview.status).toBe(200); + const plan = await preview.json() as { canApply: boolean; fingerprint: string }; + expect(plan.canApply).toBe(true); + + // The binding has to carry a bound undo all the way through, not just refuse a bad one. + const undo = await api("/api/client-integrations/restore", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ opId, operation: "restore", planFingerprint: plan.fingerprint }), + }); + expect(undo.status).toBe(200); + /* + * Hermes has no config file before the apply, so the snapshot that apply took is "none" and a + * successful undo DELETES the file rather than rewriting it. Reading bytes back here was + * wrong: the file is gone, and the evidence that the undo happened lives in the journal. + */ + expect(existsSync(configPath)).toBe(false); + const rows = store.listOperations("hermes"); + expect(rows.some(row => row.kind === "restore")).toBe(true); + expect(rows.some(row => row.opId === opId)).toBe(true); + }); + + test("a plan is refused before any planning when the request does not name a real operation", async () => { + const badOperation = await previewApi("/api/client-integrations/preview", { clientId: "hermes", operation: "launch" }); + expect(badOperation.status).toBe(400); + expect((await badOperation.json() as { code: string }).code).toBe("invalid_preview_operation"); + + const badClient = await previewApi("/api/client-integrations/preview", { clientId: "not-a-client", operation: "apply" }); + expect(badClient.status).toBeGreaterThanOrEqual(400); + }); + + test("planning an undo for an operation that is not there declines to read the journal back", async () => { + const response = await previewApi("/api/client-integrations/restore/preview", { opId: "no-such-operation" }); + expect(response.status).toBe(404); + const body = await response.json() as { code: string }; + expect(body.code).toBe("integration_operation_not_found"); + }); + + test("a half-bound confirmation is rejected rather than quietly treated as unbound", async () => { + installHermes(); + // Dropping one half would answer 200 to a caller who believed their confirmation was being + // checked, which is worse than refusing the request. + const onlyOperation = await api("/api/client-integrations/hermes", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled: true, operation: "apply" }), + }); + expect(onlyOperation.status).toBe(400); + expect((await onlyOperation.json() as { code: string }).code).toBe("invalid_preview_binding"); + + const onlyFingerprint = await api("/api/client-integrations/hermes", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled: true, planFingerprint: "p1:whatever" }), + }); + expect(onlyFingerprint.status).toBe(400); + expect((await onlyFingerprint.json() as { code: string }).code).toBe("invalid_preview_binding"); + }); + + test("a confirmation naming a different operation than the request is not a confirmation of it", async () => { + installHermes(); + const response = await api("/api/client-integrations/hermes", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled: false, operation: "apply", planFingerprint: "p1:whatever" }), + }); + expect(response.status).toBe(400); + expect((await response.json() as { code: string }).code).toBe("invalid_preview_operation"); + }); + + test("a bound change cannot commit without a plan that still validates", async () => { + const configPath = installHermes(); + const before = existsSync(configPath) ? readFileSync(configPath, "utf8") : null; + // Content, not names. A journal append, an overwritten ownership record and a replaced + // snapshot all leave the file list identical, and every one of them is a write. + const storeContents = (): string => { + if (!existsSync(storeRoot)) return ""; + return readdirSync(storeRoot, { recursive: true }) + .map(entry => String(entry)) + .sort() + .map(entry => { + const full = join(storeRoot, entry); + if (!existsSync(full) || statSync(full).isDirectory()) return `${entry}/`; + return `${entry}:${readFileSync(full, "utf8")}`; + }) + .join("\u0000"); + }; + resetExportSnapshotForTests(); + await loadExportModels(config, []); + const storeBefore = storeContents(); + + const response = await api("/api/client-integrations/hermes", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled: true, operation: "apply", planFingerprint: "p1:not-the-current-plan" }), + }); + + // Whatever the reason the plan does not validate, the mutation does not happen. This is the + // case that would catch a refactor quietly dropping the binding on the way to the writer. + // With a roster present this can only be a fingerprint comparison, so the reason is exact. + expect(response.status).toBe(409); + const body = await response.json() as { code: string }; + expect(body.code).toBe("integration_preview_stale"); + + const after = existsSync(configPath) ? readFileSync(configPath, "utf8") : null; + expect(after).toBe(before); + expect(storeContents()).toBe(storeBefore); + }); +}); + +describe("the generic preview routes are not an unscoped door into Aside", () => { + /* + * Aside is a set of profiles, not one file. A plan built through the generic routes would + * describe the legacy single-account location, and no bound mutation accepts that: the mutation + * routes already refuse the unscoped spelling. Answering it here would hand an operator a + * confirmation nothing can carry out. + */ + test("planning apply for aside without a profile is refused, and writes nothing", async () => { + const homeBefore = storeContentWitness(home); + const storeBefore = storeContentWitness(storeRoot); + + const response = await previewApi("/api/client-integrations/preview", { clientId: "aside", operation: "apply" }); + + expect(response.status).toBe(400); + expect((await response.json() as { code: string }).code).toBe("invalid_aside_profile_path"); + expect(storeContentWitness(home)).toBe(homeBefore); + expect(storeContentWitness(storeRoot)).toBe(storeBefore); + }); + + test("planning an undo of an aside operation without a profile is refused for what it is", async () => { + // The row is found first, so the answer names the unscoped path rather than reporting an + // operation that is plainly there as missing. + store.appendJournal({ + opId: "aside-unscoped-op", + clientId: "aside", + kind: "apply", + at: new Date(0).toISOString(), + configPath: join(home, ".aside", "u", "1", "models.json"), + snapshot: { kind: "none" }, + resultFingerprint: "0123456789abcdef", + resultAbsent: false, + priorRecord: null, + }); + const homeBefore = storeContentWitness(home); + const storeBefore = storeContentWitness(storeRoot); + + const response = await previewApi("/api/client-integrations/restore/preview", { opId: "aside-unscoped-op" }); + + expect(response.status).toBe(400); + expect((await response.json() as { code: string }).code).toBe("invalid_aside_profile_path"); + expect(storeContentWitness(home)).toBe(homeBefore); + expect(storeContentWitness(storeRoot)).toBe(storeBefore); + + // Narrow: an operation belonging to any other client still plans here. + installHermes(); + resetExportSnapshotForTests(); + await loadExportModels(config, []); + const ok = await previewApi("/api/client-integrations/preview", { clientId: "hermes", operation: "apply" }); + expect(ok.status).toBe(200); + }); +}); diff --git a/tests/server/management-model-roster-gather-race.test.ts b/tests/server/management-model-roster-gather-race.test.ts new file mode 100644 index 00000000000..00d200597ac --- /dev/null +++ b/tests/server/management-model-roster-gather-race.test.ts @@ -0,0 +1,224 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { withStubbedProviderFetch } from "../helpers/catalog-provider-fetch"; +import { + loadExportModels, + previewExportModels, + resetExportSnapshotForTests, +} from "../../src/server/management/model-rows"; +import { clearGatherRoutedModelsInflight, resetCatalogRuntimeStateForTests } from "../../src/codex/catalog"; +import { clearModelCache, getStaleCached, setCached } from "../../src/codex/model-cache"; +import type { ExportModel } from "../../src/clients/config-export"; +import type { OcxConfig } from "../../src/types"; + +/** + * Things can change inside one export load, and what it retains has to be identified by the state + * its rows were chosen under rather than by whatever is current when it finishes. + * + * The interleaving here is real. The load runs a two-provider gather, alpha answers immediately and + * beta is held open, so the load is still inside its own await after alpha has chosen, published + * and stamped its rows. Whatever the case does in that window lands where it matters. + */ +const ALPHA_HOST = "https://alpha.gather-race.test/v1"; +const BETA_HOST = "https://beta.gather-race.test/v1"; +const GAMMA_HOST = "https://gamma.gather-race.test/v1"; + +const originalFetch = globalThis.fetch; +let configRoot = ""; +let priorHome: string | undefined; + +function raceConfig(): OcxConfig { + return withStubbedProviderFetch({ + port: 10100, + hostname: "127.0.0.1", + defaultProvider: "alpha", + providers: { + alpha: { adapter: "openai-chat", baseUrl: ALPHA_HOST, models: [] }, + beta: { adapter: "openai-chat", baseUrl: BETA_HOST, models: [] }, + }, + } as OcxConfig); +} + +/** Alpha answers at once; beta stays open until the returned release is called. */ +function holdBetaOpen(): () => void { + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + globalThis.fetch = (async (input: RequestInfo | URL) => { + const alpha = String(input).includes("alpha."); + if (!alpha) await gate; + return new Response(JSON.stringify({ data: [{ id: alpha ? "alpha-chosen" : "beta-only" }] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + return release; +} + +/** + * Resolves once alpha's rows are published, which is also once they are stamped: the provider + * publishes and builds its result in one synchronous block, so a cache entry another task can see + * is proof the revision that vouches for those rows has already been taken. + */ +async function afterAlphaChoseItsRows(): Promise { + for (let attempt = 0; attempt < 2_000; attempt += 1) { + if (getStaleCached("alpha") !== null) return; + await new Promise(resolve => setTimeout(resolve, 1)); + } + throw new Error("alpha never published its rows"); +} + +/** + * Runs the supplied step in the window where alpha has chosen and beta has not answered, then lets + * the load finish. + * + * Beta is released in a finally and the load is awaited there too, because a failure inside the + * window would otherwise leave a gather in flight while teardown clears the caches it is about to + * publish into, and the next case would inherit it. + */ +async function loadWhileBetaIsHeld( + during: (config: OcxConfig) => void | Promise, + prepare?: (config: OcxConfig) => void, +): Promise<{ config: OcxConfig; exported: readonly ExportModel[] }> { + const release = holdBetaOpen(); + const config = raceConfig(); + prepare?.(config); + const load = loadExportModels(config); + const settled = load.catch(() => undefined); + try { + await afterAlphaChoseItsRows(); + await during(config); + } finally { + release(); + await settled; + } + return { config, exported: await load }; +} + +const alphaRows = (models: readonly ExportModel[]): string[] => + models.filter(model => model.provider === "alpha").map(model => model.namespaced); + +beforeEach(() => { + configRoot = mkdtempSync(join(tmpdir(), "ocx-gather-race-")); + priorHome = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = configRoot; + resetExportSnapshotForTests(); + clearModelCache(); + clearGatherRoutedModelsInflight(); + resetCatalogRuntimeStateForTests(); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + resetExportSnapshotForTests(); + clearModelCache(); + clearGatherRoutedModelsInflight(); + resetCatalogRuntimeStateForTests(); + if (priorHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = priorHome; + removeTreeWithRetry(configRoot); +}); + +describe("an export load retains what its rows were chosen under", () => { + test("a load nothing competes with leaves a preview that serves exactly what it returned", async () => { + const { config, exported } = await loadWhileBetaIsHeld(() => {}); + + expect(alphaRows(exported)).toEqual(["alpha/alpha-chosen"]); + // The control for the two cases below. Without it, a preview refused for some unrelated reason + // would look like a race being closed. + expect(previewExportModels(config)).toEqual(exported); + }); + + test("rows chosen before a competing publication are never served as current", async () => { + const { config, exported } = await loadWhileBetaIsHeld(() => { + // The competing flight publishes here: alpha has chosen and stamped its rows, and this load + // has not reached its retention yet because beta is still open. + expect(setCached("alpha", [{ id: "alpha-superseded", provider: "alpha" }])).toBe(true); + }); + + // The load still returns the rows it fetched. Those rows are not wrong; they are simply no + // longer the current ones. + expect(alphaRows(exported)).toEqual(["alpha/alpha-chosen"]); + // What must not survive is the claim that they are current. Sampling the revision at retention + // would have read the competing publication's, matched it on the way out, and handed a preview + // these rows under an identity they never had. + expect(previewExportModels(config)).toBeNull(); + }); + + test("a configuration edited during the load changes neither the rows nor what is retained", async () => { + const { config, exported } = await loadWhileBetaIsHeld(edited => { + // Management routes edit the live configuration in place. This edit would remove the row + // below from the projection, which is what makes it visible: the load gathered under the + // configuration it admitted and has to project under that same one. + edited.disabledModels = ["alpha/alpha-chosen"]; + }); + + // Gathered under the admitted configuration and projected under it too. Reading the live + // object for the projection would have produced rows belonging to neither configuration. + expect(alphaRows(exported)).toEqual(["alpha/alpha-chosen"]); + // Retaining would record this roster under a configuration that does not produce it. Refusing + // costs one ordinary load and is the only honest answer. + expect(previewExportModels(config)).toBeNull(); + }); + + test("replacing a provider's transport executor retires the roster it gathered", async () => { + const { config, exported } = await loadWhileBetaIsHeld(() => {}); + expect(previewExportModels(config)).toEqual(exported); + const executor = (config.providers.alpha as { fetch?: typeof fetch }).fetch; + expect(typeof executor).toBe("function"); + + // The same executor is the same configuration: this is an identity, so putting back the very + // object that was there changes nothing. + (config.providers.alpha as { fetch?: typeof fetch }).fetch = executor; + expect(previewExportModels(config)).toEqual(exported); + + // The executor is the one part of a provider that is not data, so it is held by reference + // rather than compared as content. A different executor can answer differently, so a roster + // gathered through the previous one is no longer a roster for this configuration. + // A wrapper that defers to the previous one would serialize identically and is still a + // different answer waiting to happen, which is why nothing here compares what it looks like. + (config.providers.alpha as { fetch?: typeof fetch }).fetch = ((input: RequestInfo | URL, init?: RequestInit) => + executor!(input, init)) as typeof fetch; + expect(previewExportModels(config)).toBeNull(); + }); + + test("a provider fetch value that is not an executor is ordinary configuration", async () => { + const { config, exported } = await loadWhileBetaIsHeld(() => {}, prepared => { + // A provider schema passes unknown keys through, so a configuration file can carry this and + // the transport never calls it. Refusing the admission over it would take previews away for + // a field nothing reads. + prepared.providers.gamma = { + adapter: "openai-chat", baseUrl: GAMMA_HOST, liveModels: false, models: ["gamma-static"], + } as OcxConfig["providers"][string]; + (prepared.providers.gamma as { fetch?: unknown }).fetch = "https://not-an-executor.test/v1"; + }); + + expect(exported.some(model => model.namespaced === "gamma/gamma-static")).toBe(true); + expect(previewExportModels(config)).toEqual(exported); + + // Data, and compared as data: editing it is a configuration change like any other. + (config.providers.gamma as { fetch?: unknown }).fetch = "https://also-not-an-executor.test/v1"; + expect(previewExportModels(config)).toBeNull(); + }); + + test("a configuration carrying an accessor is refused without the accessor being read", async () => { + let reads = 0; + const { config, exported } = await loadWhileBetaIsHeld(edited => { + Object.defineProperty(edited, "disabledModels", { + configurable: true, + enumerable: true, + get: () => { reads += 1; return []; }, + }); + }); + + // The rows are still the ordinary answer; only the preview authority is withheld. + expect(alphaRows(exported)).toEqual(["alpha/alpha-chosen"]); + expect(previewExportModels(config)).toBeNull(); + // A copier that serialized the configuration would have called this and then compared whatever + // it chose to return. Refusing without invoking it is the difference between reading a + // configuration and asking it what it would like to be. + expect(reads).toBe(0); + }); +}); diff --git a/tests/server/management-model-roster.test.ts b/tests/server/management-model-roster.test.ts new file mode 100644 index 00000000000..493fb7f265c --- /dev/null +++ b/tests/server/management-model-roster.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, test } from "bun:test"; +import { afterEach, beforeEach } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { + exportSnapshotIdentity, + listManagementModelRows, + loadExportModels, + previewExportModels, + resetExportSnapshotForTests, +} from "../../src/server/management/model-rows"; +import type { CatalogModel } from "../../src/codex/catalog"; +import type { OcxConfig } from "../../src/types"; +import { clearModelCache, reconcileModelCacheProviders, setCached } from "../../src/codex/model-cache"; + +/** + * A read-only caller has to be able to see what a writer would write without performing the + * gather, because the gather reaches providers and can persist an initial model selection. What + * it must NOT get is a different projection, or a preview and the commit that follows it would + * disagree about the roster for a reason that has nothing to do with the roster changing. + */ +const CONFIG: OcxConfig = { + port: 10100, + hostname: "127.0.0.1", + defaultProvider: "supplied", + providers: { supplied: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } }, +} as OcxConfig; + +const SUPPLIED: CatalogModel[] = [{ id: "supplied-model", provider: "supplied" }]; + +/** + * A roster is identified by the configuration it was admitted under, so these cases need their own + * configuration directory rather than whatever the machine running them happens to have. + */ +let configRoot = ""; +let priorHome: string | undefined; + +beforeEach(() => { + configRoot = mkdtempSync(join(tmpdir(), "ocx-roster-config-")); + priorHome = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = configRoot; + mkdirSync(configRoot, { recursive: true }); +}); + +afterEach(() => { + if (priorHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = priorHome; + removeTreeWithRetry(configRoot); +}); + +describe("a supplied roster replaces the gather and keeps the projection", () => { + test("rows come from the roster the caller brought", async () => { + const rows = await listManagementModelRows(CONFIG, { models: SUPPLIED }); + // No provider here serves this id, so a gather could not have produced this row. Its + // presence is what proves the supplied roster was used instead. + expect(rows.some(row => row.id === "supplied-model")).toBe(true); + }); + + test("the disabled computation still applies to a supplied roster", async () => { + const rows = await listManagementModelRows( + { ...CONFIG, disabledModels: ["supplied/supplied-model"] } as OcxConfig, + { models: SUPPLIED }, + ); + const row = rows.find(candidate => candidate.id === "supplied-model"); + expect(row?.disabled).toBe(true); + }); + + test("the export projection accepts a supplied roster without gathering", async () => { + // Visibility and provider selection decide which rows survive here, so this asserts the call + // completes through the same path rather than pinning that policy from outside. + await expect(loadExportModels(CONFIG, SUPPLIED)).resolves.toBeInstanceOf(Array); + }); +}); + +describe("a preview reads only a roster an authoritative load already finished", () => { + test("a cold process has none, and an ordinary load leaves the final projection behind", async () => { + resetExportSnapshotForTests(); + // Recoverable rather than broken: the operator opens the models view and the snapshot appears. + expect(previewExportModels(CONFIG)).toBeNull(); + + const exported = await loadExportModels(CONFIG, SUPPLIED); + // The FINAL projection, not an input to it: rebuilding from raw provider caches would miss + // static and forward providers and skip the filtering this applies afterwards. + expect(previewExportModels(CONFIG)).toEqual(exported); + }); + + test("changing what the roster depends on retires the snapshot", async () => { + resetExportSnapshotForTests(); + await loadExportModels(CONFIG, SUPPLIED); + expect(previewExportModels(CONFIG)).not.toBeNull(); + + // A blocklist edit changes which models a client would be given, so a plan built against the + // old roster no longer describes what the user has. + const blocked = { ...CONFIG, disabledModels: ["supplied/supplied-model"] } as OcxConfig; + expect(previewExportModels(blocked)).toBeNull(); + + const added = { ...CONFIG, customModels: [{ id: "c1", provider: "supplied", modelId: "extra" }] } as OcxConfig; + expect(previewExportModels(added)).toBeNull(); + }); + + test("a preview is refused to a caller holding a different in-memory configuration", async () => { + resetExportSnapshotForTests(); + await loadExportModels(CONFIG, SUPPLIED); + expect(previewExportModels(CONFIG)).not.toBeNull(); + + // Same configuration file, different configuration object. The file digest alone cannot see + // this, and neither could a hand-written list of the fields that seemed to matter: fastRows + // changes what the export projection emits and no such list ever mentioned it. + expect(previewExportModels({ ...CONFIG, fastRows: false } as OcxConfig)).toBeNull(); + + // A difference that changes nothing about the roster is still a different configuration. The + // identity is structural precisely so it does not depend on anyone deciding which fields count. + expect(previewExportModels({ ...CONFIG, shutdownTimeoutMs: 7_000 } as OcxConfig)).toBeNull(); + + // A separate object describing the same configuration is the same configuration: this is an + // identity of the content, not of the object a caller happens to be holding. + const equivalent = previewExportModels({ ...CONFIG } as OcxConfig); + expect(equivalent).not.toBeNull(); + expect(equivalent).toEqual(previewExportModels(CONFIG)); + }); + + test("a completed discovery retires the snapshot even though the config never changed", async () => { + resetExportSnapshotForTests(); + await loadExportModels(CONFIG, SUPPLIED); + expect(previewExportModels(CONFIG)).not.toBeNull(); + + // The real trigger: a discovery that succeeded and published different rows. The config key + // cannot see it, and an authority generation does not move for it either. + expect(setCached("supplied", [{ id: "discovered-later", provider: "supplied" }])).toBe(true); + expect(previewExportModels(CONFIG)).toBeNull(); + }); + + test("an unchanged roster keeps the identity a caller is already holding", async () => { + resetExportSnapshotForTests(); + await loadExportModels(CONFIG, SUPPLIED); + const held = exportSnapshotIdentity(CONFIG); + expect(held).not.toBeNull(); + + // An ordinary read rebuilds the same rows, and the Integrations collection performs one on + // every visit. Moving the identity for that turned a confirmation an operator was in the + // middle of submitting into a stale one, for no change they could see. + await loadExportModels(CONFIG, SUPPLIED); + expect(exportSnapshotIdentity(CONFIG)).toBe(held); + + // A roster that genuinely differs still takes a new identity. + await loadExportModels(CONFIG, [...SUPPLIED, { id: "second-model", provider: "supplied" }]); + const moved = exportSnapshotIdentity(CONFIG); + expect(moved).not.toBeNull(); + expect(moved).not.toBe(held); + }); + + test("a wholesale clear retires the snapshot even though no provider counter survives it", async () => { + resetExportSnapshotForTests(); + await loadExportModels(CONFIG, SUPPLIED); + expect(previewExportModels(CONFIG)).not.toBeNull(); + + // The clear empties the map, so per-provider counters cannot record it. Only a global term + // can, and without one every derived roster would read as unchanged. + clearModelCache(); + expect(previewExportModels(CONFIG)).toBeNull(); + }); + + test("a provider that leaves and returns cannot match a roster built before it left", async () => { + resetExportSnapshotForTests(); + // The provider has to be CACHED for reconciliation to have anything to remove. Supplying a + // roster skips the gather, so without this nothing tracks "supplied" and the prune is a no-op + // that proves nothing. + expect(setCached("supplied", [{ id: "cached-before-removal", provider: "supplied" }])).toBe(true); + await loadExportModels(CONFIG, SUPPLIED); + expect(previewExportModels(CONFIG)).not.toBeNull(); + + // Reconciliation drops the provider's revision entry. If that were the whole story the entry + // would come back at zero and the old stamp would match again, which is the ABA this guards. + reconcileModelCacheProviders(new Set()); + expect(previewExportModels(CONFIG)).toBeNull(); + + expect(setCached("supplied", [{ id: "cached-after-return", provider: "supplied" }])).toBe(true); + await loadExportModels(CONFIG, SUPPLIED); + const reborn = previewExportModels(CONFIG); + expect(reborn).not.toBeNull(); + }); + + test("an absent config file is a configuration; an unreadable one is not", async () => { + // Absent means defaults, which is a fresh install and CI. Refusing there would make the + // feature look dead rather than safe. + resetExportSnapshotForTests(); + await loadExportModels(CONFIG, SUPPLIED); + expect(previewExportModels(CONFIG)).not.toBeNull(); + + // A file that exists and cannot be parsed says nothing about which configuration this roster + // belongs to, so it fails closed. + writeFileSync(join(configRoot, "config.json"), "{ not valid json"); + expect(previewExportModels(CONFIG)).toBeNull(); + }); + + test("the snapshot does not share objects with the caller that produced it", async () => { + resetExportSnapshotForTests(); + const exported = await loadExportModels(CONFIG, SUPPLIED); + const first = exported[0]; + expect(first).toBeDefined(); + + // Freezing only the array left the models themselves shared, so a caller editing one in place + // would have rewritten what a later preview plans against, and moved the fingerprint with it. + if (first) first.id = "mutated-by-the-caller"; + const snapshot = previewExportModels(CONFIG); + expect(snapshot?.some(model => model.id === "mutated-by-the-caller")).toBe(false); + + // The same has to hold for a reader: holding the retained objects would let a consumer edit + // the roster every later preview plans against. + const read = previewExportModels(CONFIG); + const readFirst = read?.[0]; + expect(readFirst).toBeDefined(); + if (readFirst) { + readFirst.id = "mutated-by-a-reader"; + readFirst.inputModalities = ["mutated"]; + } + const fresh = previewExportModels(CONFIG); + expect(fresh?.some(model => model.id === "mutated-by-a-reader")).toBe(false); + expect(fresh?.some(model => model.inputModalities?.includes("mutated"))).toBe(false); + }); +});