diff --git a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md index a123bf34797..f014dd0ecd6 100644 --- a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md @@ -32,6 +32,8 @@ ocx start --socks5-off ### `ocx stop` +Un Link Child non supervisé restaure Codex natif et Grok avant de libérer son listener lors d’un signal, en revérifiant la connexion à chaque écriture. Service/Desktop, sibling, transfert de redémarrage et reçu stop du parent préservent les intégrations. L’arrêt refuse le redémarrage avec `409 runtime_stopping`. Une terminaison forcée peut éviter le signal ; la restauration par `ocx stop` reste nécessaire. + Arrête le proxy actif à partir de son PID, supprime le fichier de PID et rétablit le fonctionnement natif de Codex. Si un service d’arrière-plan géré est installé, `ocx stop` l’arrête d’abord afin qu’il ne puisse pas relancer le proxy. Le bouton **Stop** du tableau de bord Web exécute la même opération (`POST /api/stop`) sur tous les backends, sauf le Planificateur de tâches Windows : le wrapper peut y relancer le proxy après la fin de la tâche, donc le tableau de bord refuse avec `respawnable_service`, ne modifie rien et vous demande d'exécuter `ocx stop`. Sur un client hub connecté, `ocx stop`, y compris via l’API, conserve le routage Codex, le catalogue et la configuration Grok appartenant à la connexion ; `ocx start` les dessert de nouveau. L’arrêt explicite d’un Remote Link Child rétablit au contraire, si la restauration partagée réussit, le routage natif de Codex, le catalogue et les métadonnées de reprise sauvegardées, et supprime la configuration Grok gérée. La connexion Home enregistrée reste conservée ; `ocx disconnect` la supprime. diff --git a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md index 73020dc2fc0..06103b47bc8 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -28,6 +28,8 @@ ocx start --socks5-off ### `ocx stop` +監督プロセスのない Link Child は終了シグナル時、各書き込みで接続を再確認し、リスナーを閉じる前に Codex と管理対象 Grok を復元します。サービス・Desktop、sibling、再起動の引き継ぎ、親 stop 記録がある場合は設定を保持します。終了中の再起動は `409 runtime_stopping` です。強制終了はシグナル処理を省略する場合があり、親 `ocx stop` の復元も必要です。 + 実行中のプロキシを (PID によって) 停止し、PID ファイルを削除します。スタンドアロンの所有者ではネイティブ Codex を復元します。マネージド バックグラウンド サービスがインストールされている場合、`ocx stop` はそれを最初に停止するため、プロキシを再起動できません。Web ダッシュボードの **停止** ボタンは同じ処理 (`POST /api/stop`) を実行しますが、Windows タスク スケジューラだけは例外です。タスク終了後もラッパーがプロキシを再起動しうるため、ダッシュボードは `respawnable_service` で拒否し、何も変更せずに `ocx stop` の実行を促します。 接続済みの hub クライアントでは、`ocx stop`(成功した API 停止を含む)は接続が所有する Codex のルーティング、モデルカタログ、Grok 設定を保持します。`ocx start` はルーティングを再注入せず、その設定で再び処理します。Codex を自身のアカウントへ戻すには `ocx disconnect`、既にネイティブルーティングへ戻した後の再適用には `ocx sync --restart-codex` を使います。 diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md index 90512b785e1..40ecde6889f 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -44,6 +44,8 @@ ocx start --socks5-off ### `ocx stop` +감독 프로세스 없이 실행되는 Link Child는 종료 신호 처리 시 각 쓰기 직전에 연결을 재확인하고 리스너를 닫기 전에 Codex 기본 설정과 관리 Grok 설정을 복원합니다. 서비스·Desktop 감독, sibling, 재시작·재활용 인계, 부모 stop 영수증이 있으면 설정을 보존합니다. 종료 중 재시작 요청은 `409 runtime_stopping`입니다. 강제 종료는 신호 처리를 건너뛸 수 있어 부모 `ocx stop`의 복원도 필요합니다. + 실행 중인 프록시를 PID 기준으로 중지하고 PID 파일을 삭제합니다. 독립 실행 소유자라면 기본 Codex를 복원합니다. 관리형 백그라운드 서비스가 설치되어 있으면 `ocx stop`이 먼저 그 서비스를 중지하므로 프록시가 다시 올라올 수 없습니다. 웹 대시보드의 **Stop** 버튼도 같은 동작(`POST /api/stop`)을 하지만, Windows 작업 스케줄러는 예외입니다. 작업이 끝나도 래퍼가 프록시를 다시 띄울 수 있어서, 대시보드는 `respawnable_service`로 거절하고 아무것도 바꾸지 않은 채 `ocx stop` 실행을 안내합니다. diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 8d6ee982c97..58e544a041b 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -274,7 +274,8 @@ For Meta Responses at the exact `api.meta.ai` host, OpenCodex omits the unsuppor `output.encrypted_content` include requested by Codex Desktop. Meta otherwise rejects the request before inference. `reasoning.encrypted_content` and other include entries remain intact, so stateless reasoning replay is preserved. The same exact-entry normalization -applies to xAI; unrelated Responses destinations keep their original include list. +applies to xAI and to Kimi Code at its exact HTTPS Responses endpoint +(`https://api.kimi.com/coding/v1/responses`); unrelated Responses destinations keep their original include list. These request-shape fixes do not change account entitlements. Meta limits Muse Code subscription credentials to the Muse Code client; other clients require a separately diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 9dc04b30b64..b557c2dfcb5 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -82,6 +82,8 @@ ocx start --socks5-off ### `ocx stop [--json]` +An unsupervised Link Child handling a shutdown signal restores native Codex and managed Grok before releasing its listener, rechecking the served connection before each write. Service/Desktop supervision, sibling ownership, restart/recycle handoff and pending parent stop receipts preserve integrations. Shutdown rejects bound restart with `409 runtime_stopping`. Forced termination can bypass signal handlers; parent `ocx stop` restoration remains necessary. To route Codex through Home again after such a stop, start the Child (`ocx start`, or `ocx service start` for an installed service) and then run `ocx sync --restart-codex`. Starting alone does not reapply routing. A sync run while the Child is stopped still reapplies routing, which points Codex at a listener that is not running, so start the Child first. + Stop the running proxy (by PID), remove the PID file, and restore native Codex for standalone owners. If a managed background service is installed, `ocx stop` also stops it first so it cannot respawn the proxy. On a connected hub client, `ocx stop` leaves Codex routing, its diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 98636bbedba..6309066f19e 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -806,7 +806,9 @@ native compaction model. OpenCodex changes only requests with explicit `request_kind: "compaction"` metadata whose `compaction.trigger` is one of the values you listed, sent to `/v1/responses/compact` or to -`/v1/responses` with a `compaction_trigger` input item. Later conversation turns keep their +`/v1/responses` with a `compaction_trigger` input item. With `codexClientCompaction` enabled, +the local checkpoint prompt also matches when metadata specifies `compaction.implementation` as +`"responses"`; it remains a normal text summary request. Later conversation turns keep their original routing and settings. Missing, malformed, or conflicting metadata does not activate the override, including on older clients without trigger metadata; when several copies of the metadata are supplied they must name the same trigger. WebSocket requests use each frame's diff --git a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md index 2ecf0d288cc..390aae87e90 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -50,6 +50,8 @@ ocx start --socks5-off ### `ocx stop` +Link Child без супервизора при сигнале восстанавливает нативный Codex и управляемый Grok до закрытия listener, проверяя подключение перед каждой записью. Сервис/Desktop, sibling, передача перезапуска и квитанция stop родителя сохраняют настройки. Во время завершения перезапуск возвращает `409 runtime_stopping`. Принудительное завершение может обойти сигнал; восстановление родителем `ocx stop` остаётся необходимым. + Остановить работающий прокси (по PID) и удалить PID-file; для автономного владельца также восстановить native Codex. Если установлена managed background service, `ocx stop` сначала останавливает и её, чтобы она не перезапустила прокси обратно. Кнопка **Stop** в веб-дашборде выполняет то же действие (`POST /api/stop`) на всех бэкендах, кроме планировщика заданий Windows: там обёртка может перезапустить прокси после завершения задачи, поэтому дашборд отказывает с `respawnable_service`, ничего не меняет и просит выполнить `ocx stop`. diff --git a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md index 4a95d62b8a2..8c7e61c2750 100644 --- a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md @@ -50,6 +50,8 @@ ocx start --socks5-off ### `ocx stop` +Gözetimsiz Link Child kapanma sinyalinde her yazmadan önce bağlantıyı doğrular ve dinleyiciyi bırakmadan önce yerel Codex ile yönetilen Grok ayarlarını geri yükler. Servis/Desktop, sibling, yeniden başlatma devri ve üst stop makbuzu ayarları korur. Kapanma sırasında yeniden başlatma `409 runtime_stopping` döndürür. Zorla sonlandırma sinyali atlayabilir; üst `ocx stop` geri yüklemesi yine gereklidir. + Çalışan proxy'yi (PID'ye göre) durdurun, PID dosyasını kaldırın ve yerel Codex'i geri yükleyin. Yönetilen bir arka plan servisi kuruluysa `ocx stop` proxy'yi yeniden oluşturamaması için önce onu da durdurur. Web kontrol panelinin **Durdur** düğmesi aynı eylemi (`POST /api/stop`) Windows Görev Zamanlayıcı dışındaki tüm arka uçlarda çalıştırır: orada görev bittikten sonra sarmalayıcı proxy'yi yeniden başlatabilir, bu yüzden panel `respawnable_service` ile reddeder, hiçbir şeyi değiştirmez ve `ocx stop` çalıştırmanızı ister. diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md index d547e59f933..5fafbccc46f 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md @@ -28,6 +28,8 @@ ocx start --socks5-off ### `ocx stop` +无人监督的 Link Child 处理退出信号时,会在每次写入前核对连接,并在关闭监听器前恢复 Codex 原生配置及受管理的 Grok。服务/Desktop、sibling、重启交接和父进程 stop 收据会保留配置。退出中的重启请求返回 `409 runtime_stopping`。强制终止可能跳过信号处理,仍需父进程 `ocx stop` 的恢复路径。 + 停止正在运行的代理(按 PID),移除 PID 文件;独立运行的所有者还会恢复原生 Codex。如果安装了受管后台服务,`ocx stop` 还会先停止该服务,这样它就无法重新拉起代理。Web 仪表盘的 **Stop** 按钮在多数后端执行同样的操作(`POST /api/stop`),但 Windows 任务计划程序除外:任务结束后包装器仍可能重新拉起代理,只有运行在代理之外的 stop 才能在恢复客户端配置前确认这个重启窗口,因此仪表盘会以 `respawnable_service` 拒绝、不做任何更改,并提示改用 `ocx stop`。 对于已连接的 hub 客户端,`ocx stop`(包括成功的 API 停止)会保留连接所拥有的 Codex 路由、模型目录和 Grok 配置。`ocx start` 使用这些保留的配置恢复服务,不会重新注入路由。要让 Codex 回到自己的账户,请运行 `ocx disconnect`;若已恢复为原生路由,要重新应用连接路由,请运行 `ocx sync --restart-codex`。 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md index c76ec26e196..66ba8a3ea3b 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md @@ -28,6 +28,8 @@ ocx start --socks5-off ### `ocx stop` +無人監督的 Link Child 處理結束訊號時,會在每次寫入前核對連線,並在關閉接聽器前還原 Codex 原生設定及受管理的 Grok。服務/Desktop、sibling、重新啟動交接和父程序 stop 收據會保留設定。結束中的重新啟動要求回傳 `409 runtime_stopping`。強制終止可能略過訊號處理,仍需父程序 `ocx stop` 的還原路徑。 + 停止執行中的代理(依 PID)、移除 PID 檔案,並還原原生 Codex。若已安裝受管背景服務,`ocx stop` 也會先停止它,使其無法重新生成代理。網頁儀表板的 **Stop** 按鈕在多數後端執行相同動作(`POST /api/stop`),但 Windows 工作排程器除外:工作結束後包裝程序仍可能重新啟動 Proxy,因此儀表板會以 `respawnable_service` 拒絕、不做任何變更,並請你改用 `ocx stop`。 對已連線的 hub 用戶端,`ocx stop`(包括成功的 API 停止)會保留連線擁有的 Codex 路由、目錄和 Grok 設定,`ocx start` 會再次提供服務。明確停止 Remote Link Child 時,若共用設定還原成功,則會還原原生 Codex 路由、目錄與已備份的續接歷程中繼資料,並移除受管理的 Grok 設定。已儲存的 Home 連線會保留,可用 `ocx disconnect` 移除。 diff --git a/scripts/test-layout/layout-clients.json b/scripts/test-layout/layout-clients.json index 01a3abb4020..c08082731bb 100644 --- a/scripts/test-layout/layout-clients.json +++ b/scripts/test-layout/layout-clients.json @@ -151,6 +151,7 @@ "client-link-connect.test.ts": "clients", "client-link-relay.test.ts": "clients", "client-link-runtime.test.ts": "clients", + "client-link-stop.test.ts": "clients", "link-routes.test.ts": "clients", "client-link-state.test.ts": "clients", "client-link-status.test.ts": "clients", diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 1621c385242..c492598af67 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -94,7 +94,7 @@ "codex-config-preservation.test.ts": "codex-integration", "codex-credits.test.ts": "codex-integration", "codex-credits-settings.test.ts": "codex-integration", - "codex-credits-probes.test.ts": "codex-integration", + "codex-credits-probes.test.ts": "codex-integration", "pool-monthly-window-retirement.test.ts": "codex-integration", "codex-credits-after-limit.test.ts": "codex-integration", "codex-pool-credit-policy.test.ts": "codex-integration", "codex-credit-retry-after.test.ts": "codex-integration", "codex-credit-scoped-retry.test.ts": "codex-integration", "codex-sidecar-credit-policy.test.ts": "codex-integration", @@ -1330,11 +1330,11 @@ "ocx-launcher-desktop-handoff.test.ts": "cli", "cli-path-diagnostics.test.ts": "cli", "ocx-launcher-source.test.ts": "cli", "ocx-run.test.ts": "cli", - "ollama-native-parser.test.ts": "providers/ollama", "ollama-native-reasoning-wire.test.ts": "providers/ollama", "ollama-native-structured-output.test.ts": "providers/ollama", "ollama-native-tool-continuations.test.ts": "providers/ollama", + "ollama-native-parser.test.ts": "providers/ollama", "ollama-native-cached-usage.test.ts": "providers/ollama", "ollama-native-reasoning-wire.test.ts": "providers/ollama", "ollama-native-structured-output.test.ts": "providers/ollama", "ollama-native-tool-continuations.test.ts": "providers/ollama", "ollama-native-v4.test.ts": "providers/ollama", "ollama-native-scaling.test.ts": "providers/ollama", "ollama-native.test.ts": "providers/ollama", "ollama-show-enrichment-v7.test.ts": "providers/ollama", "ollama-show-enrichment.test.ts": "providers/ollama", "ollama-show-ignore-abort.test.ts": "providers/ollama", "opaque-blob-wrapped-rejection.test.ts": "responses", "openai-api-virtual-models.test.ts": "adapters/openai", "openai-chat-bounded-tool-names.test.ts": "adapters/openai", "openai-chat-dangling-toolcalls.test.ts": "adapters/openai", - "openai-chat-developer-position.test.ts": "adapters/openai", "openai-chat-eof.test.ts": "adapters/openai", "openai-chat-hardening.test.ts": "adapters/openai", "openai-empty-assistant-history.test.ts": "adapters/openai", "openai-chat-image-normalization.test.ts": "adapters/openai", + "openai-chat-developer-position.test.ts": "adapters/openai", "openai-chat-eof.test.ts": "adapters/openai", "openai-chat-hardening.test.ts": "adapters/openai", "openai-empty-assistant-history.test.ts": "adapters/openai", "kimi-coding-output-include.test.ts": "providers", "openai-chat-image-normalization.test.ts": "adapters/openai", "openai-chat-inline-think-tags.test.ts": "adapters/openai", "openai-chat-invalid-tool-call-diagnostics.test.ts": "adapters/openai", "openai-chat-mistral-content.test.ts": "adapters/openai", "openai-chat-model-suffix.test.ts": "adapters/openai", "openai-chat-native-policy.test.ts": "adapters/openai", "openai-chat-parallel-stream.test.ts": "adapters/openai", "openai-chat-path-override.test.ts": "adapters/openai", "openai-chat-qwen38-leading-system.test.ts": "adapters/openai", "openai-chat-reasoning-wire-policy.test.ts": "adapters/openai", "openai-chat-sanitization-review-regressions.test.ts": "adapters/openai", "openai-chat-serialized-tool-call-content.test.ts": "adapters/openai", "openai-chat-serialized-tool-call-hold-bound.test.ts": "adapters/openai", "openai-chat-serialized-tool-call-think.test.ts": "adapters/openai", @@ -1537,7 +1537,7 @@ "responses-code-mode-patch-compile.test.ts": "responses", "responses-code-mode-shell-compile.test.ts": "responses", "responses-compact-handoff-admission.test.ts": "responses", - "responses-compaction-override.test.ts": "responses", + "responses-compaction-override.test.ts": "responses", "local-checkpoint-routing.test.ts": "responses", "responses-compaction-policy-identity.test.ts": "responses", "responses-compaction-http-isolation.test.ts": "responses", "responses-compaction-routing.test.ts": "responses", "native-compaction-gateway.test.ts": "responses", "native-compaction-gateway-policy.test.ts": "responses", "native-compaction-provenance.test.ts": "responses", "native-compaction-oauth.test.ts": "responses", "native-compaction-gateway-credentials.test.ts": "responses", "responses-compaction.test.ts": "responses", diff --git a/src/adapters/ollama-native.ts b/src/adapters/ollama-native.ts index e612011347d..64eea472fc6 100644 --- a/src/adapters/ollama-native.ts +++ b/src/adapters/ollama-native.ts @@ -641,7 +641,17 @@ function usageFromNative(value: JsonRecord | undefined): OcxUsage | undefined { const input = isFiniteNonNegativeInteger(value.prompt_eval_count) ? value.prompt_eval_count : undefined; const output = isFiniteNonNegativeInteger(value.eval_count) ? value.eval_count : undefined; if (input === undefined && output === undefined) return undefined; - return { inputTokens: input ?? 0, outputTokens: output ?? 0 }; + // Ollama reports prompt_eval_cached_count as the cached subset of prompt_eval_count (api/types.go; + // its CLI prints prompt_eval_count - cached as the uncached share), which is OCX's cache-read + // convention. Omitted means unknown, so no cache field is invented; a count that exceeds the + // prompt is not a subset and is ignored rather than clamped (#8101). + const cached = input !== undefined && isFiniteNonNegativeInteger(value.prompt_eval_cached_count) + && value.prompt_eval_cached_count <= input ? value.prompt_eval_cached_count : undefined; + return { + inputTokens: input ?? 0, + outputTokens: output ?? 0, + ...(cached !== undefined ? { cachedInputTokens: cached, cacheReadInputTokens: cached } : {}), + }; } function stopReasonFromNative(value: unknown): string | undefined { diff --git a/src/adapters/openai-responses/empty-assistant.ts b/src/adapters/openai-responses/empty-assistant.ts index 205292812e1..e03c9fded86 100644 --- a/src/adapters/openai-responses/empty-assistant.ts +++ b/src/adapters/openai-responses/empty-assistant.ts @@ -16,11 +16,23 @@ function isEmptyAssistantMessage(item: unknown): boolean { return item.content === "" || (Array.isArray(item.content) && item.content.every(isEmptyTextPart)); } +/** Exactly Kimi Code's native Responses endpoint; lookalike hosts and other Kimi paths do not match. */ +export function isKimiCodingResponsesUrl(responseUrl: string): boolean { + let url: URL; + try { url = new URL(responseUrl); } catch { return false; } + return url.hostname === "api.kimi.com" && /^\/coding\/v1\/responses\/?$/.test(url.pathname); +} + +/** The same endpoint over HTTPS on the default port only, for request rewrites scoped to Kimi Code itself. */ +export function isKimiCodingResponsesHttpsUrl(responseUrl: string): boolean { + if (!isKimiCodingResponsesUrl(responseUrl)) return false; + const url = new URL(responseUrl); + return url.protocol === "https:" && url.port === ""; +} + /** Kimi Code rejects content-empty assistant messages even on its native Responses endpoint. */ export function stripKimiEmptyAssistantMessages(body: unknown, responseUrl: string): unknown { - let url: URL; - try { url = new URL(responseUrl); } catch { return body; } - if (url.hostname !== "api.kimi.com" || !/^\/coding\/v1\/responses\/?$/.test(url.pathname)) return body; + if (!isKimiCodingResponsesUrl(responseUrl)) return body; if (!isPlainObject(body) || !Array.isArray(body.input)) return body; const referencedIds = new Set(); for (const item of body.input) { diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index f8dd88f3f46..c952d6c8c9c 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -57,7 +57,7 @@ import { observeOutbound } from "../../usage/cache-diagnostic"; import { normalizeMuseToolCatalog } from "./muse-tool-catalog"; import { normalizeMuseToolChoice } from "./muse-tool-choice"; import { declaresHostedWebSearch, projectHostedSearchHistory, renderCompactionSearchHistoryItems } from "./compaction-search-history"; -import { stripKimiEmptyAssistantMessages } from "./empty-assistant"; +import { isKimiCodingResponsesHttpsUrl, stripKimiEmptyAssistantMessages } from "./empty-assistant"; import { normalizeOpenGatewayResponsesInput } from "./opengateway-input"; /** @@ -358,12 +358,13 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): // OpenCode Go speaks the same dialect, which the final model id and URL identify. outBody = normalizeXaiResponsesWebSearch(outBody, provider, { modelId: parsed.modelId, responseUrl: url }); outBody = injectXaiResponsesXSearch(outBody, provider, parsed._replayPrefixLen); - // xAI, Meta and Azure answer 400 for `output.encrypted_content` instead of ignoring it; - // Codex Desktop asks for it on every turn (#7878). + // xAI, Meta, Azure and Kimi Code (#8075) answer 400 for `output.encrypted_content` + // instead of ignoring it; Codex Desktop asks for it on every turn (#7878). // Azure also rejects this exact include entry, including on plain Responses routes. const isAzureResponses = provider.adapter === "azure" || provider.adapter === "azure-openai" || new URL(url).hostname.toLowerCase().endsWith(".openai.azure.com"); - if (isXaiResponsesDestination(provider) || isMetaAiResponsesDestination(url) || isAzureResponses) { + if (isXaiResponsesDestination(provider) || isMetaAiResponsesDestination(url) || isAzureResponses + || isKimiCodingResponsesHttpsUrl(url)) { outBody = stripUnsupportedOutputInclude(outBody); } // xAI and explicitly classified compatible gateways reject these OpenAI web_search diff --git a/src/adapters/openai-responses/request-strips.ts b/src/adapters/openai-responses/request-strips.ts index 9d64f6ed49e..9ce2364be52 100644 --- a/src/adapters/openai-responses/request-strips.ts +++ b/src/adapters/openai-responses/request-strips.ts @@ -8,7 +8,7 @@ import { activateDeferredTool } from "./tool-schema"; import { stripOpenAiOnlyWebSearchFields } from "./web-search"; /** - * Codex requests output.encrypted_content, which xAI, Meta and Azure reject with HTTP 400. + * Codex requests output.encrypted_content, which xAI, Meta, Azure and Kimi Code reject with HTTP 400. * The caller gates this on the destination. Preserve reasoning.encrypted_content * for stateless replay and leave unknown or malformed values for upstream validation. */ diff --git a/src/cli/index.ts b/src/cli/index.ts index e2f98925666..bcddd09f7ff 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1139,7 +1139,9 @@ async function handleStopUnlocked(snapshot?: GuardedStopSnapshot) { .filter(read => isPendingTeardownAbandoned(read, teardownOwnerStillRunning)); let teardownNonce: string | undefined; // #7423: an update that restarts the proxy asks this stop to leave Codex routing for the replacement. - const handoverRequested = updateTeardownHandoverRequested(process.env, () => shouldSyncCodexOnStart(loadConfig())) && !stoppingSibling && !connectedClientSkip; + // Client start never discharges a standalone update-handover receipt or reapplies routing. + const handoverRequested = updateTeardownHandoverRequested(process.env, () => shouldSyncCodexOnStart(loadConfig())) + && !stoppingSibling && readClientConnectionState().kind !== "connected"; let deferralHonoredByProxy = false; const claimTeardown = (endpoint: { hostname: string; port: number }, endpointSource: "exact" | "guessed") => { if (teardownNonce || stoppingSibling) return; diff --git a/src/client/connect.ts b/src/client/connect.ts index 81f7f8ff516..00bb058891c 100644 --- a/src/client/connect.ts +++ b/src/client/connect.ts @@ -18,7 +18,8 @@ import { withClientLifecycle, withClientLifecycleSync, type ClientLifecycleHeld, type ClientLifecycleLockDeps, } from "./lifecycle-lock"; -import { invalidateCodexModelsCache } from "../codex/catalog/sync"; +import { invalidateCodexModelsCache, invalidateCodexModelsCacheWithPermit } from "../codex/catalog/sync"; +import { withCatalogWriteSerialization } from "../codex/catalog-write-serialization"; import { injectCodexConfig, currentExternalCodexModelProvider, @@ -30,7 +31,7 @@ import { journalOwner, restoreJournalState, } from "../codex/journal"; -import { DEFAULT_CATALOG_PATH } from "../codex/paths"; +import { DEFAULT_CATALOG_PATH, getCodexHome } from "../codex/paths"; import { readServiceApiTokenState, readTokenBackupState, @@ -95,6 +96,7 @@ import { import { hubStateCachePath } from "./hub-state"; import { ClientLinkStateError, clearClientLinkState, readClientLinkState } from "./link-state"; import { isLinkPort } from "../link/ports"; +import { clientSyncRuntimeGuard, ClientSyncRuntimeError } from "./sync-runtime"; class RotationRecoveryRequiredError extends Error { constructor(message: string, options?: ErrorOptions) { @@ -901,7 +903,15 @@ async function syncConnectedClientUnlocked( if (state.kind !== "connected" || state.value.pendingOperation) throw new Error("client_sync_unavailable"); const token = readServiceApiTokenState(); if (token.kind !== "present" || token.fingerprint !== state.value.tokenFingerprint) throw new Error("client_token_changed"); - return { connection: state.value, token }; + let assertRuntimeCurrent = () => {}; + try { if (isLinkConnection(state.value)) assertRuntimeCurrent = clientSyncRuntimeGuard(); } + catch (error) { + if (options.expect && error instanceof ClientSyncRuntimeError && error.code === "client_runtime_stopping") { + throw new ClientSyncExpectationError(options.expect, "pending_operation"); + } + throw error; + } + return { connection: state.value, token, assertRuntimeCurrent }; }), deps.lifecycleLockDeps); let downloaded: Awaited> | undefined; let stale = false; @@ -918,6 +928,7 @@ async function syncConnectedClientUnlocked( // they were, which is the known-good state. if (downloaded) assertClientCatalogCompatible(downloaded.body, deps.catalogCompatibility); const next = withClientLifecycleSync(() => withConfigMutationLockSync(() => { + initial.assertRuntimeCurrent(); assertClientConnectionUnchanged(initial.connection); const token = readServiceApiTokenState(); if (token.kind !== "present" || token.fingerprint !== initial.token.fingerprint) throw new Error("client_token_changed"); @@ -933,10 +944,12 @@ async function syncConnectedClientUnlocked( }), deps.lifecycleLockDeps); // Read-only: injection invokes this while N/C are held. Acquiring L here would invert C→L. const beforeClientWrite = () => { + initial.assertRuntimeCurrent(); assertClientConnectionUnchanged(next); const token = readServiceApiTokenState(); if (next.pendingOperation || token.kind !== "present" || token.fingerprint !== next.tokenFingerprint) throw new Error("client_token_changed"); }; + const withClientWrite = (write: () => T): T => withConfigMutationLockSync(() => { beforeClientWrite(); return write(); }); let injected = false; let homeSettings: HomeCodexSettingsSource | undefined; let appliedRecord: HomeCodexSettingsRecord | undefined; @@ -945,12 +958,15 @@ async function syncConnectedClientUnlocked( let config = loadConfig(); // #6957: a link Child applies the Home's OpenCodex-owned Codex settings with the catalog. if (isLinkConnection(next)) { - homeSettings = await resolveHomeCodexSettings(next, initial.token.token, deps.linkHome); + homeSettings = await resolveHomeCodexSettings(next, initial.token.token, { ...deps.linkHome, withWriteGuard: withClientWrite }); if (homeSettings.source !== "local") config = overlayHomeCodexSettings(config, homeSettings.document.settings); // About to write different settings: until their record commits, report no applied digest // rather than the previous one (OCX-6). A failed injection then reads "unknown", never a // digest of values Codex no longer has or never got. - if (homeSettings.source === "home" && next.link) withdrawChangingHomeCodexSettingsRecord(next.link.linkId, homeSettings.document); + if (homeSettings.source === "home" && next.link) { + const { linkId } = next.link, { document } = homeSettings; + withClientWrite(() => withdrawChangingHomeCodexSettingsRecord(linkId, document)); + } } const result = await injectCodexConfig(config.port, { ...config, syncResumeHistory: false }, { routingTarget: routingTarget(next.serverUrl, isLinkConnection(next) ? config.port : undefined, config), catalogPath: DEFAULT_CATALOG_PATH, @@ -962,23 +978,24 @@ async function syncConnectedClientUnlocked( const linkId = next.link.linkId; const settings = homeSettings; try { - appliedRecord = withClientLifecycleSync(() => { - beforeClientWrite(); - return recordHomeCodexSettings(linkId, settings, deps.now) ?? readHomeCodexSettingsRecord(linkId) ?? undefined; - }, deps.lifecycleLockDeps); + appliedRecord = withClientLifecycleSync(() => withClientWrite(() => + recordHomeCodexSettings(linkId, settings, deps.now) ?? readHomeCodexSettingsRecord(linkId) ?? undefined), deps.lifecycleLockDeps); } catch (error) { // An ownership change is not a disk problem: report it as itself. - if (error instanceof Error && (error.message === "client_token_changed" || error.message === "client_connection_changed")) throw error; + if (error instanceof ClientSyncRuntimeError || (error instanceof Error && (error.message === "client_token_changed" || error.message === "client_connection_changed"))) throw error; // The injection committed, so the cache refresh below still runs; the caller reports this. - try { clearHomeCodexSettingsRecord(); } catch { /* status already reads no recorded apply or reports the write */ } + try { withClientWrite(clearHomeCodexSettingsRecord); } catch { /* status already reads no recorded apply or reports the write */ } applyRecordFailed = true; } } } const cacheSynced = withClientLifecycleSync(() => { - beforeClientWrite(); - // Cache invalidation acquires K itself (N -> K -> C); never call it while C is held. - return invalidateCodexModelsCache({ allowWhenDesiredDisabled: true }); + // Acquire K before the guarded C commit; never acquire K from inside C. + const owningHome = getCodexHome(); + const result = withCatalogWriteSerialization(owningHome, permit => withClientWrite(() => + invalidateCodexModelsCacheWithPermit(permit, owningHome, { allowWhenDesiredDisabled: true })), + { intent: "cache", writer: "cache-invalidate" }); + return result.kind === "completed" && result.value; }, deps.lifecycleLockDeps); return { catalogWritten: downloaded !== undefined, cacheSynced, injected, stale, diff --git a/src/client/link-home.ts b/src/client/link-home.ts index a3cf4b49343..3452e602ee1 100644 --- a/src/client/link-home.ts +++ b/src/client/link-home.ts @@ -299,7 +299,7 @@ export type HomeCodexSettingsSource = export async function resolveHomeCodexSettings( connection: LinkHomeConnection, admissionKey: string, - deps: LinkHomeDeps & { now?: () => Date } = {}, + deps: LinkHomeDeps & { now?: () => Date; withWriteGuard?: (write: () => T) => T } = {}, ): Promise { const linkId = connection.link?.linkId; if (!linkId) return { source: "local", reason: "not a Remote Link Child" }; @@ -308,7 +308,8 @@ export async function resolveHomeCodexSettings( return { source: "home", document: fetched.document, fetchedAt: (deps.now ?? (() => new Date()))().toISOString() }; } if (fetched.kind === "unsupported") { - clearHomeCodexSettingsRecord(); + if (deps.withWriteGuard) deps.withWriteGuard(clearHomeCodexSettingsRecord); + else clearHomeCodexSettingsRecord(); return { source: "local", reason: "the Home does not publish Codex settings; upgrade the Home" }; } const cached = readHomeCodexSettingsRecord(linkId); diff --git a/src/client/machine-listener.ts b/src/client/machine-listener.ts index 0f6ca2681b4..a697089b975 100644 --- a/src/client/machine-listener.ts +++ b/src/client/machine-listener.ts @@ -324,6 +324,9 @@ async function handleBoundRestart( ); if (!authorized) return Response.json({ success: false, error: "restart capability required" }, { status: 401, headers: noStore }); const admission = await restart.request(); + if (admission.kind === "stopping") { + return Response.json({ success: false, error: { code: "runtime_stopping" } }, { status: 409, headers: noStore }); + } if (admission.kind === "supervised") { return Response.json({ success: false, diff --git a/src/client/restore-ownership.ts b/src/client/restore-ownership.ts index e1d190cb9f0..4ca5d24741d 100644 --- a/src/client/restore-ownership.ts +++ b/src/client/restore-ownership.ts @@ -1,5 +1,6 @@ import { withConfigMutationLockSync } from "../config"; import { assertNoClientDisconnectPending, pendingClientConnectMayOwnToken, readClientConnectionState } from "./state"; +import type { OcxClientConnectionConfig } from "../types"; export type RestoreBoundaryOwnership = "restore" | "hub-owned" | "unproven"; export type RestoreOwnershipRefusal = Exclude; @@ -21,8 +22,16 @@ export function inspectStopRestoreOwnership(read = readClientConnectionState): R } catch { return "unproven"; } } +function restoreConnectionSnapshot(connection: OcxClientConnectionConfig) { + // Normal sync changes these observations, not restoration ownership. All other + // fields, including priorCatalog and future connection fields, remain guarded. + const { catalogFingerprint: _fingerprint, catalogSyncedAt: _syncedAt, ...snapshot } = connection; + return snapshot; +} + /** C is reentrant inside N/C; this never acquires L or N. No lease crosses an await. */ -export function stopRestoreWriteGuard() { +export function stopRestoreWriteGuard(expectedConnection?: OcxClientConnectionConfig) { + const expected = expectedConnection && restoreConnectionSnapshot(structuredClone(expectedConnection)); let boundary: RestoreBoundaryOwnership = "restore"; return { boundary: () => boundary, @@ -32,6 +41,15 @@ export function stopRestoreWriteGuard() { return withConfigMutationLockSync(() => { entered = true; if (boundary === "restore") boundary = inspectStopRestoreOwnership(); + if (boundary === "restore" && expected) { + try { + if (expected.pendingOperation) throw new Error("client_rotation_pending"); + const current = readClientConnectionState(); + if (current.kind !== "connected" || !Bun.deepEquals(restoreConnectionSnapshot(current.value), expected, true)) { + throw new Error("client_connection_changed"); + } + } catch { boundary = "unproven"; } + } if (boundary !== "restore") throw new StopRestoreOwnershipError(boundary); return write(); }); diff --git a/src/client/runtime-restart.ts b/src/client/runtime-restart.ts index f5ad4417908..899fb165112 100644 --- a/src/client/runtime-restart.ts +++ b/src/client/runtime-restart.ts @@ -30,9 +30,12 @@ export type ClientRestartSupervisor = "service" | "desktop"; export type ClientRestartAdmission = | { kind: "accepted"; alreadyRestarting: boolean } + | { kind: "stopping" } | { kind: "supervised"; supervisor: ClientRestartSupervisor }; export interface ClientRestartIo { + /** A signal shutdown has claimed this runtime; it cannot admit a replacement. */ + stopping?: () => boolean; /** Which supervisor owns this process, or null when it starts its own replacement. */ supervisor(): Promise; /** Stop the tunnel supervisor and the listener, giving in-flight requests up to `drainMs`. */ @@ -41,7 +44,7 @@ export interface ClientRestartIo { spawnReplacement(deadlineAt: number): Promise; /** Serve again on this process: listener, fresh tunnel supervisor, records. */ resume(): Promise; - exitProcess(code: number): void; + exitProcess(code: number): void | Promise; now?: () => number; sleep?: (ms: number) => Promise; warn?: (message: string) => void; @@ -49,7 +52,9 @@ export interface ClientRestartIo { export interface ClientRestartController { request(): Promise; - /** Settles when the restart in flight (if any) has finished or rolled back. Tests only. */ + /** Only an accepted flight owns routing; a supervisor check does not. */ + hasHandoff(): boolean; + /** Join the accepted flight before signal teardown; may reject on a failed exit callback. */ settled(): Promise; } @@ -81,16 +86,19 @@ export function createClientRestartController(io: ClientRestartIo): ClientRestar await io.resume(); } catch (resumeError) { warn(`[client] restart: this runtime could not serve again (${failureCode(resumeError)}); run 'ocx start'`); - io.exitProcess(1); + await io.exitProcess(1); } return; } - io.exitProcess(0); + await io.exitProcess(0); }; return { + hasHandoff: () => flight !== null, async request() { + if (io.stopping?.()) return { kind: "stopping" }; const supervisor = await io.supervisor(); + if (io.stopping?.()) return { kind: "stopping" }; if (supervisor) return { kind: "supervised", supervisor }; if (flight) return { kind: "accepted", alreadyRestarting: true }; flight = run(now()).finally(() => { flight = null; }); diff --git a/src/client/runtime.ts b/src/client/runtime.ts index 7677170f3ae..75e3462f147 100644 --- a/src/client/runtime.ts +++ b/src/client/runtime.ts @@ -32,12 +32,17 @@ import { isLinkConnection, readClientConnectionState } from "./state"; import { withClientLifecycleSync } from "./lifecycle-lock"; import { clientRestartRollbackAction, clientRestartSupervisor, createClientRestartController, type ClientRestartController } from "./runtime-restart"; import { createLinkParityReporter, type LinkParityReporter } from "./link-parity-reporter"; +import { restoreLinkClientRouting } from "./stop-routing"; +import { markClientRuntimeStopping, markClientRuntimeStoppingWithRetry } from "./sync-runtime"; let activeServer: Server | null = null; let activePort: number | null = null; let activeSupervisor: ClientLinkSupervisor | null = null; let activeReporter: LinkParityReporter | null = null; let recycleScheduled = false; +let recycleTimer: ReturnType | undefined; +let recycleFlight: Promise | null = null; +let clientStopping = false; /** * When this runtime's process started (OCX-6, P5): what `/healthz` reports as `startedAt` and the @@ -97,7 +102,7 @@ export function clientRuntimeRecord( ): RuntimePortState { let command: string[] | undefined; try { command = currentServingCommand(); } catch { /* no reliable relaunch identity; update admission fails closed */ } - return { pid, port, hostname: "127.0.0.1", attestationSecret, ...(command ? { command } : {}), ...siblingRuntimeField() }; + return { pid, port, hostname: "127.0.0.1", attestationSecret, ...(command ? { command } : {}), ...siblingRuntimeField(), ...(clientStopping ? { clientStopping: true as const } : {}) }; } export function standaloneRecycleEnv( @@ -126,12 +131,15 @@ export function standaloneRecycleEnv( } export function scheduleStandaloneRecycle(disconnectedTokenFingerprint: string): void { - if (recycleScheduled) return; + if (recycleScheduled || clientStopping) return; recycleScheduled = true; - const timer = setTimeout(() => { - void recycleStandalone(disconnectedTokenFingerprint); + recycleTimer = setTimeout(() => { + recycleTimer = undefined; + if (clientStopping) return; + recycleFlight = recycleStandalone(disconnectedTokenFingerprint).finally(() => { recycleFlight = null; }); + void recycleFlight.catch(error => { if (!clientStopping) throw error; }); }, 50); - if (typeof timer === "object" && "unref" in timer) timer.unref(); + if (typeof recycleTimer === "object" && "unref" in recycleTimer) recycleTimer.unref(); } function configuredTcpPort(): number | undefined { @@ -421,7 +429,9 @@ export async function startClientRuntime( try { await server.stop(true); } catch { /* already closed */ } }; + let shuttingDown = false; const restartController: ClientRestartController = createClientRestartController({ + stopping: () => shuttingDown, supervisor: io.restartSupervisor ?? clientRestartSupervisor, stop: stopServing, spawnReplacement: async deadlineAt => { @@ -445,8 +455,8 @@ export async function startClientRuntime( } }, exitProcess: async code => { - await flushLinkDiagnostics(); - process.exit(code); + try { await flushLinkDiagnostics(); } + finally { process.exit(code); } }, }); @@ -455,11 +465,24 @@ export async function startClientRuntime( await serve(); options.afterPublish?.(); - let shuttingDown = false; const shutdown = (signal?: NodeJS.Signals) => { if (shuttingDown) return; shuttingDown = true; + clientStopping = true; + if (recycleTimer) { clearTimeout(recycleTimer); recycleTimer = undefined; } + const fenceSync = () => { + if (!linkMode) return true; + try { markClientRuntimeStopping({ pid: process.pid, port: servedPort, attestationSecret }); return true; } + catch { return false; } + }; + let syncFenced = fenceSync(); void (async () => { + // An accepted restart owns its replacement and rollback. A successful handoff exits + // inside that flight; a rollback finishes before this signal tears the runtime down. + if (restartController.hasHandoff()) await restartController.settled().catch(() => {}); + if (recycleFlight) await recycleFlight.catch(() => {}); + // A rollback may have republished its runtime record while shutdown joined it. + syncFenced = !linkMode || await markClientRuntimeStoppingWithRetry({ pid: process.pid, port: servedPort, attestationSecret }); credentialObservation?.stop(); credentialObservation = null; activeReporter?.stop(); @@ -470,15 +493,18 @@ export async function startClientRuntime( console.warn(`[client] link supervisor stop failed: ${error instanceof Error ? error.message : String(error)}`); } activeSupervisor = null; + const restored = linkMode ? restoreLinkClientRouting(state.value, { syncFenced }) : null; + if (restored && !restored.success) console.error(`[client] ${restored.message}`); try { activeServer?.stop(true); } catch (error) { console.warn(`[client] listener stop failed: ${error instanceof Error ? error.message : String(error)}`); } finally { - cleanup(); - await flushLinkDiagnostics(); + // Keep stopping admission visible through the last await. Cleanup and exit are adjacent. + try { await flushLinkDiagnostics(); } finally { cleanup(); } // launchd relaunches only unsuccessful exits; an external signal must still count. - process.exit(handledSignalExitCode(signal)); + if (restored?.success === false) process.exit(1); + else process.exit(handledSignalExitCode(signal)); } })(); }; diff --git a/src/client/stop-routing.ts b/src/client/stop-routing.ts new file mode 100644 index 00000000000..f3a5377e9ee --- /dev/null +++ b/src/client/stop-routing.ts @@ -0,0 +1,62 @@ +import { existsSync } from "node:fs"; +import { restoreNativeCodex } from "../codex/inject"; +import { hasInjectedCodexRouting } from "../codex/injected-marker"; +import { readBoundedCodexConfig } from "../codex/inject/bounded-config-reader"; +import { journalOwner, JOURNAL_PATH } from "../codex/journal"; +import { CODEX_CONFIG_PATH } from "../codex/paths"; +import { siblingOfLivePort, siblingSkipMessage } from "../codex/sibling-start"; +import type { OcxClientConnectionConfig } from "../types"; +import { pendingTeardownOutstanding } from "../config/pending-teardown"; +import { stripGrokConfig } from "../grok/inject"; +import { isDesktopSupervised } from "../lib/system-restart-contract"; +import { serviceEnvironmentOwnedHere } from "../service/guards"; +import { stopRestoreWriteGuard } from "./restore-ownership"; +import { isLinkConnection } from "./state"; + +export type LinkRoutingRestoreResult = { success: boolean; restored: boolean; message: string }; + +const resumeGuidance = "After starting the Child again, run `ocx sync --restart-codex` to restore its Home routing."; +const refusal = (reason: string): LinkRoutingRestoreResult => ({ + success: false, restored: false, + message: `Link Child Codex routing restore refused: ${reason}. No further writes; inspect remaining recovery evidence.`, +}); + +/** Unsupervised signal cleanup; explicit parent stop and restart retain their own restoration. */ +export function restoreLinkClientRouting(expected: OcxClientConnectionConfig, options: { syncFenced: boolean }): LinkRoutingRestoreResult { + if (siblingOfLivePort() !== null) return { success: true, restored: false, message: siblingSkipMessage() }; + if (!isLinkConnection(expected)) { + return { success: true, restored: false, message: "Hub connection-owned integrations preserved." }; + } + const preserve = () => process.env.OCX_SERVICE === "1" || isDesktopSupervised() + || pendingTeardownOutstanding(); + if (preserve()) return { success: true, restored: false, message: "Supervised restart or pending teardown owns the client integrations." }; + if (options?.syncFenced !== true) return refusal("the runtime sync fence could not be verified"); + const guard = stopRestoreWriteGuard(expected); + const withWriteGuard = (write: () => T): T => guard.run(() => { + if (preserve()) throw new Error("restart or teardown ownership changed"); + const owner = journalOwner({ readOnly: true }); + if (existsSync(JOURNAL_PATH) && (owner?.kind !== "client" || owner.apiKeyId !== expected.apiKeyId)) { + throw new Error("the Codex journal belongs to another owner or is unverified"); + } + return write(); + }); + try { + withWriteGuard(() => { + const owner = journalOwner({ readOnly: true }); + if (owner === null) { + const config = readBoundedCodexConfig(CODEX_CONFIG_PATH); + if (existsSync(JOURNAL_PATH) || (config !== null && hasInjectedCodexRouting(config))) throw new Error("the client journal is missing or unverified"); + } + }); + const result = restoreNativeCodex({ withWriteGuard }); + if (result.artifacts.config.state === "failed" || result.historyPreflightRefusal) { + return refusal("native Codex restoration was not authorized or could not complete"); + } + const grok = serviceEnvironmentOwnedHere() ? withWriteGuard(() => stripGrokConfig()) : { ok: true, changed: false }; + if (!result.success || !grok.ok) return refusal("native Codex or managed Grok restoration did not complete"); + return { success: true, restored: result.artifacts.config.changed || result.artifacts.catalog.changed || grok.changed, + message: `Link Child native Codex and managed Grok restored; connection credentials preserved. ${resumeGuidance}` }; + } catch (error) { + return refusal(error instanceof Error ? error.message : "restoration failed"); + } +} diff --git a/src/client/sync-runtime.ts b/src/client/sync-runtime.ts new file mode 100644 index 00000000000..883b013b6e9 --- /dev/null +++ b/src/client/sync-runtime.ts @@ -0,0 +1,65 @@ +/** Cross-process admission for Link sync while its local runtime shuts down. */ +import { lstatSync } from "node:fs"; +import { ConfigMutationLockError, withConfigMutationLockSync } from "../config"; +import { getRuntimePortPath, isValidRuntimePortState, writeRuntimePort, type RuntimePortState } from "../config/process-state"; +import { readBoundedRegularFileSync } from "../lib/bounded-file-read"; + +export class ClientSyncRuntimeError extends Error { + constructor(readonly code: "client_runtime_stopping" | "client_runtime_changed" | "client_runtime_unverified") { + super(`${code}: retry sync after the Child has started; ocx start/stop can reconcile a stale runtime record`); + } +} + +function readSyncRuntime(): RuntimePortState | null { + const path = getRuntimePortPath(); + try { lstatSync(path); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw new ClientSyncRuntimeError("client_runtime_unverified"); + } + try { + const value: unknown = JSON.parse(readBoundedRegularFileSync(path, 16 * 1024).toString("utf8")); + if (!isValidRuntimePortState(value)) throw new Error("invalid runtime record"); + return value; + } catch { throw new ClientSyncRuntimeError("client_runtime_unverified"); } +} + +/** Capture and recheck under C. Proven no-runtime sync keeps its existing offline behavior. */ +export function clientSyncRuntimeGuard(): () => void { + const expected = readSyncRuntime(); + if (expected?.clientStopping) throw new ClientSyncRuntimeError("client_runtime_stopping"); + return () => { + const current = readSyncRuntime(); + if (current?.clientStopping) throw new ClientSyncRuntimeError("client_runtime_stopping"); + if (!Bun.deepEquals(current, expected, true)) throw new ClientSyncRuntimeError("client_runtime_changed"); + }; +} + +/** The same C transaction excludes every sync commit; never mark a replacement runtime. */ +export function markClientRuntimeStopping(expected: Required>): void { + withConfigMutationLockSync(() => { + const current = readSyncRuntime(); + if (!current || current.pid !== expected.pid || current.port !== expected.port + || current.attestationSecret !== expected.attestationSecret) { + throw new ClientSyncRuntimeError("client_runtime_changed"); + } + if (!current.clientStopping) writeRuntimePort({ ...current, clientStopping: true }); + }); +} + +/** Yield only for proved SQLite contention; storage/identity failures remain immediate refusals. */ +export async function markClientRuntimeStoppingWithRetry( + expected: Required>, + deps: { mark?: () => void; sleep?: (ms: number) => Promise } = {}, +): Promise { + for (let attempt = 0; attempt < 20; attempt += 1) { + try { (deps.mark ?? (() => markClientRuntimeStopping(expected)))(); return true; } + catch (error) { + const cause = error instanceof ConfigMutationLockError ? error.cause : undefined; + const code = cause && typeof cause === "object" && "code" in cause ? cause.code : undefined; + if ((code !== "SQLITE_BUSY" && code !== "SQLITE_LOCKED") || attempt === 19) return false; + await (deps.sleep ?? Bun.sleep)(50); + } + } + return false; +} diff --git a/src/codex/quota.ts b/src/codex/quota.ts index b0933c46231..0f2722437b1 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -38,7 +38,7 @@ type QuotaDiskFile = { type MainPolicyQuota = { identityKey: string; quota: StoredAccountQuota }; /** Fresh WHAM topology proof is consumed by the merge, never retained in a cache or DTO. */ -type MainPolicyQuotaObservation = Omit & { shortWindowAbsent?: true }; +type MainPolicyQuotaObservation = Omit & { shortWindowAbsent?: true; monthlyWindowAbsent?: true }; let mainPolicyQuota: MainPolicyQuota | null = null; let diskHydrated = false; let persistTimer: ReturnType | null = null; @@ -343,9 +343,12 @@ export function setAccountQuotaFromParsed( // Retiring a short tuple releases a routing block, so it needs the same writer proof as // history: a live main writer for main, a live captured pool writer for pool requests. const mayRetireShort = isMain ? mainWriter !== undefined : livePoolEvidence; - const displayQuota: MainPolicyQuotaObservation = mayRetireShort && policyQuota?.shortWindowAbsent === true - ? { ...quota, shortWindowAbsent: true } - : quota; + // Monthly-window absence (#8067) comes from the same complete topology and needs the same proof. + const displayQuota: MainPolicyQuotaObservation = { + ...quota, + ...(mayRetireShort && policyQuota?.shortWindowAbsent === true ? { shortWindowAbsent: true as const } : {}), + ...(mayRetireShort && policyQuota?.monthlyWindowAbsent === true ? { monthlyWindowAbsent: true as const } : {}), + }; const next = mergeAccountQuota(displayQuota, legacyExisting, updatedAt); accountQuota.set(accountId, next); if (isMain) { @@ -448,8 +451,11 @@ function mergeAccountQuota( // while silently dropping `monthlyIsPrimaryWindow` would look like tertiary-only data to // any future reader, and that failure would be invisible. if (quota.monthlyIsPrimaryWindow === true) next.monthlyIsPrimaryWindow = true; - } else if ((snapshotHasWeekly(quota) || snapshotHasShort(quota) || snapshotHasCustom(quota)) + } else if (quota.monthlyWindowAbsent !== true + && (snapshotHasWeekly(quota) || snapshotHasShort(quota) || snapshotHasCustom(quota)) && existing?.monthlyPercent !== undefined) { + // A complete topology with no monthly-length window retires the carried tuple, so a plan + // change away from a monthly primary (Free to Pro Lite) cannot keep its old exhausted reading. next.monthlyPercent = existing.monthlyPercent; if (existing.monthlyResetAt !== undefined) next.monthlyResetAt = existing.monthlyResetAt; if (existing.monthlyIsPrimaryWindow === true) next.monthlyIsPrimaryWindow = true; @@ -991,7 +997,17 @@ export function poolWhamPolicyObservation( } const malformed = readings.some(window => window?.used_percent != null && normalizeUsagePercent(window.used_percent) === undefined); - return data.rate_limit?.allowed === false || malformed ? quota : withWhamShortWindowAbsence(data, quota); + if (data.rate_limit?.allowed === false || malformed) return quota; + const observation = withWhamShortWindowAbsence(data, quota); + // #8067: the same complete topology proves a pool account has no monthly window when no window + // declares a monthly duration and no tertiary window is present, so a plan change away from a + // monthly primary (Free to Pro Lite) retires the carried tuple. Pool-only: main observations + // keep their existing retention rules. + const [primary, secondary, tertiary] = [data.rate_limit?.primary_window, data.rate_limit?.secondary_window, data.rate_limit?.tertiary_window]; + return observation.shortWindowAbsent === true + && !isExplicitMonthlyWindow(primary) && !isExplicitMonthlyWindow(secondary) && tertiary == null + ? { ...observation, monthlyWindowAbsent: true } + : observation; } function isMeasuredLongWindow(window: WhamUsageWindow | null | undefined): boolean { diff --git a/src/config/process-state.ts b/src/config/process-state.ts index b7fa2349112..5b71fd4f430 100644 --- a/src/config/process-state.ts +++ b/src/config/process-state.ts @@ -39,6 +39,8 @@ export type RuntimePortState = { command?: string[]; /** Per-process proof key; protected by the config directory and never served. */ attestationSecret?: string; + /** A Link runtime is draining; new and in-flight sync commits must stand down. */ + clientStopping?: true; /** * The live proxy's port when this runtime is a sibling instance started beside it * (`src/codex/sibling-start.ts`). `ocx stop` reads it to leave shared client routing alone. @@ -61,6 +63,7 @@ export function isValidRuntimePortState(value: unknown): value is RuntimePortSta && Number(state.port) <= 65535 && hostnameOk && attestationOk + && (state.clientStopping === undefined || state.clientStopping === true) && siblingOk; } diff --git a/src/responses/compaction-history-framing.ts b/src/responses/compaction-history-framing.ts index 91f825ad895..ab13a9f109f 100644 --- a/src/responses/compaction-history-framing.ts +++ b/src/responses/compaction-history-framing.ts @@ -21,6 +21,10 @@ function plainUserText(item: unknown): string | null { return texts.join("\n"); } +export function isLocalCompactionCheckpoint(input: unknown): boolean { + return Array.isArray(input) && plainUserText(input.at(-1))?.trim() === COMPACT_PROMPT; +} + function isContext(text: string): boolean { const head = text.trimStart(); return head.startsWith("<") || head.startsWith("# AGENTS.md") || head.startsWith("# Context from my IDE"); @@ -44,7 +48,7 @@ export function frameCompactionHistory isRecord(item) && item.type === "compaction_trigger")) return body; // Codex local compaction appends this user prompt without a compaction_trigger. // Keep that summarizer turn free of generation-only continuation instructions. - if (plainUserText(input.at(-1))?.trim() === COMPACT_PROMPT) return body; + if (isLocalCompactionCheckpoint(input)) return body; let checkpointIndex = -1; for (let index = input.length - 1; index >= 0; index--) { if (isCheckpoint(input[index])) { diff --git a/src/server/json-serialized-bytes.ts b/src/server/json-serialized-bytes.ts new file mode 100644 index 00000000000..ea35dd57da4 --- /dev/null +++ b/src/server/json-serialized-bytes.ts @@ -0,0 +1,44 @@ +import { Buffer } from "node:buffer"; + +type Frame = + | { items: readonly unknown[]; index: number } + | { record: Record; keys: readonly string[]; index: number }; + +/** + * UTF-8 byte length of JSON.stringify(value) for a JSON.parse result, computed without + * recursion: a valid body nested deeper than the call stack must be measured, not crash. + * Each open container keeps one cursor frame, so arrays add no per-element queue; an object + * frame retains its key list, which stays bounded by the input size. + */ +export function serializedJsonByteLength(value: unknown): number { + let bytes = 0; + const stack: Frame[] = []; + const visit = (item: unknown): void => { + if (item === null || typeof item !== "object") { + bytes += Buffer.byteLength(JSON.stringify(item) ?? "null", "utf8"); + } else if (Array.isArray(item)) { + bytes += 2 + Math.max(0, item.length - 1); + stack.push({ items: item, index: 0 }); + } else { + const record = item as Record; + const keys = Object.keys(record); + bytes += 2 + Math.max(0, keys.length - 1); + stack.push({ record, keys, index: 0 }); + } + }; + visit(value); + while (stack.length > 0) { + const frame = stack[stack.length - 1]!; + if ("items" in frame) { + if (frame.index < frame.items.length) visit(frame.items[frame.index++]); + else stack.pop(); + } else if (frame.index < frame.keys.length) { + const key = frame.keys[frame.index++]!; + bytes += Buffer.byteLength(JSON.stringify(key), "utf8") + 1; + visit(frame.record[key]); + } else { + stack.pop(); + } + } + return bytes; +} diff --git a/src/server/request-decompress.ts b/src/server/request-decompress.ts index 1c9a56ec127..297ac0af90a 100644 --- a/src/server/request-decompress.ts +++ b/src/server/request-decompress.ts @@ -2,6 +2,7 @@ import { Buffer } from "node:buffer"; import { gunzipSync, inflateRawSync, inflateSync, zstdDecompressSync } from "node:zlib"; import type { TranslatorBudget } from "../lib/translator-budget"; import { updateInboundBodyReservation } from "./inbound-body-budget"; +import { serializedJsonByteLength } from "./json-serialized-bytes"; /** * Request-body decompression for the /v1/responses data plane. @@ -333,8 +334,9 @@ export async function readBoundedJsonRequestBody( return options.emptyBodyFallback; } const parsed = JSON.parse(text); - // Keep the serialized-size contract: normalization can expand numeric literals. - const parsedBytes = budget ? Buffer.byteLength(JSON.stringify(parsed), "utf8") : decoded.byteLength; + // Keep the serialized-size contract: normalization can expand numeric literals. The + // measurement walks iteratively, so a deeply nested valid body cannot overflow the stack. + const parsedBytes = serializedJsonByteLength(parsed); budget?.observeAcceptedRequestCopy(parsedBytes); updateInboundBodyReservation(req, Math.max(raw.byteLength, decoded.byteLength, parsedBytes)); return parsed; diff --git a/src/server/responses-undeclared-tool-guard.ts b/src/server/responses-undeclared-tool-guard.ts index eb88752c797..30904905f65 100644 --- a/src/server/responses-undeclared-tool-guard.ts +++ b/src/server/responses-undeclared-tool-guard.ts @@ -14,6 +14,7 @@ import { normalizeDeclaredToolName, } from "../types"; import { replaceSseDataPayload, sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite"; +import { TOOL_SEARCH_FUNCTION_NAME } from "../responses/tool-search-compat"; /** Item types the client executes through a request-declared wire name. */ export const CLIENT_EXECUTED_CALL_TYPES: ReadonlySet = new Set([ @@ -189,6 +190,28 @@ export function collectDeclaredWireToolNames(body: unknown, options?: { canonica return names; } +/** + * Function names that translated adapters give to nameless hosted declarations in this catalog. + * The parser exposes a declared `{type:"tool_search"}` to Chat-style providers as the function + * `tool_search`, so only a declaration present in this (current-turn) body authorizes that name. + */ +export function collectTranslatedHostedToolNames(body: unknown): Set { + const names = new Set(); + if (!isPlainObject(body)) return names; + const specGroups: unknown[] = [body.tools]; + if (Array.isArray(body.input)) { + for (const item of body.input) { + if (isPlainObject(item) && item.type === "additional_tools") specGroups.push(item.tools); + } + } + for (const specs of specGroups) { + if (Array.isArray(specs) && specs.some(spec => isPlainObject(spec) && spec.type === "tool_search")) { + names.add(TOOL_SEARCH_FUNCTION_NAME); + } + } + return names; +} + /** * Collects explicitly declared bare wire tool names from a Responses request body. * diff --git a/src/server/responses/compaction-routing.ts b/src/server/responses/compaction-routing.ts index aed7c2c577d..e09df815f03 100644 --- a/src/server/responses/compaction-routing.ts +++ b/src/server/responses/compaction-routing.ts @@ -7,6 +7,7 @@ import { resolvePolicyProfileId } from "../../routing/profile"; import { parseSyntheticRowId } from "../fast-row"; import { recallComboForLane } from "./combo-session-recall"; import { sessionLaneIdFromRequest } from "../request-log-conversation"; +import { isLocalCompactionCheckpoint } from "../../responses/compaction-history-framing"; /** `sourceModel` is the conversation's own selector before the rewrite. */ export interface CompactionRoutingOverride { @@ -38,7 +39,7 @@ function configuredTriggers(value: unknown): readonly string[] | null { } export interface CompactionRoutingOverrideOptions { - /** `responses` requires a `compaction_trigger` input item; the native compact endpoint carries none. */ + /** Responses accepts remote triggers or opted-in local checkpoints; compact carries neither. */ endpoint?: "responses" | "compact"; transport?: "websocket"; } @@ -66,8 +67,10 @@ export function applyCompactionRoutingOverride( ? source.startsWith(selector.slice(0, -1)) && source.length > selector.length - 1 : source === selector)) return null; } - if (options.endpoint !== "compact" - && !(Array.isArray(raw.input) && raw.input.some(item => record(item)?.type === "compaction_trigger"))) return null; + const remoteTrigger = Array.isArray(raw.input) && raw.input.some(item => record(item)?.type === "compaction_trigger"); + const localCheckpoint = options.endpoint !== "compact" && !remoteTrigger + && config.codexClientCompaction === true && isLocalCompactionCheckpoint(raw.input); + if (options.endpoint !== "compact" && !remoteTrigger && !localCheckpoint) return null; const metadata: unknown[] = []; const header = headers.get("x-codex-turn-metadata"); @@ -81,7 +84,9 @@ export function applyCompactionRoutingOverride( try { const parsed = record(JSON.parse(value)); if (parsed?.request_kind !== "compaction") return null; - const carried = record(parsed.compaction)?.trigger; + const compaction = record(parsed.compaction); + if (localCheckpoint && compaction?.implementation !== "responses") return null; + const carried = compaction?.trigger; if (typeof carried !== "string" || !triggers.includes(carried)) return null; // Copies that name different triggers are not agreement, and picking either one would // let a caller widen an override by disagreeing with itself. diff --git a/src/server/responses/request-prepare.ts b/src/server/responses/request-prepare.ts index 61750878783..49c7d12ace6 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -158,6 +158,7 @@ import { currentTurnWireToolCatalogBody, hasExplicitEmptyToolCatalog, collectDeclaredWireToolNames, + collectTranslatedHostedToolNames, collectDeclaredBareCustomWireToolNames, } from "../responses-undeclared-tool-guard"; import { streamingContextOverflowResponse } from "./context-overflow"; @@ -498,10 +499,12 @@ export async function prepareResponsesRequest( const catalogBody = options.directRequest && parsed.context.tools?.length ? { tools: parsed.context.tools.map(tool => ({ type: "function", name: tool.name })) } : parsed._rawBody; const currentCatalog = currentTurnWireToolCatalogBody(catalogBody, parsed._replayPrefixLen); + // A hosted tool declared this turn authorizes the function name its translation uses. + const translatedHostedNames = collectTranslatedHostedToolNames(currentCatalog); clientToolCatalogRestriction = { - declaredToolNames: collectDeclaredWireToolNames(currentCatalog), + declaredToolNames: new Set([...collectDeclaredWireToolNames(currentCatalog), ...translatedHostedNames]), bareCustomToolNames: collectDeclaredBareCustomWireToolNames(currentCatalog), - canonicalToolNames: collectDeclaredWireToolNames(currentCatalog, { canonicalOnly: true }), + canonicalToolNames: new Set([...collectDeclaredWireToolNames(currentCatalog, { canonicalOnly: true }), ...translatedHostedNames]), // Ordinary Responses validation stays with its delivery owner; replay cannot add authority. enforceDeclaredToolNames: (inboundWire === "responses" && (parsed._replayPrefixLen ?? 0) > 0) || options.clientDeclaredEmptyToolCatalog === true diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 724149eed58..7308da0bde6 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -435,7 +435,7 @@ cannot prove that the short window disappeared. All-null credits-only and tertia the resulting weekly/monthly window still blocks at its long threshold (98% by default). This prevents old short-window exhaustion from surviving indefinitely on a now weekly/monthly account. A live main writer applies that same retirement proof to the display/rotation cache, so account selection cannot keep refusing the -recovered main account on its old short tuple. Pool accounts get the same proof from a valid complete WHAM probe (#7961): `poolWhamPolicyObservation` marks the observation from its window topology only, so a Plus to Pro upgrade cannot keep a stored pool account on the old plan's 5-hour tuple, while partial WHAM, invalid or unparseable readings, an explicit `allowed: false` verdict and response headers keep carrying it. A pool probe retires the tuple only with a live captured pool writer for that account and credential generation, the same proof its history write needs; a missing, stale or other-account writer keeps the tuple. The login usage read marks its observation the same way, but reauthentication clears the row before writing and creation refuses existing ids, so it has nothing to retire. Main-only monthly filtering is not applied to pool observations, and a main observation without a live main writer still carries its tuple. Coverage lives in `tests/codex-integration/codex-credits-probes.test.ts` (pool), +recovered main account on its old short tuple. Pool accounts get the same proof from a valid complete WHAM probe (#7961): `poolWhamPolicyObservation` marks the observation from its window topology only, so a Plus to Pro upgrade cannot keep a stored pool account on the old plan's 5-hour tuple, while partial WHAM, invalid or unparseable readings, an explicit `allowed: false` verdict and response headers keep carrying it. A pool probe retires the tuple only with a live captured pool writer for that account and credential generation, the same proof its history write needs; a missing, stale or other-account writer keeps the tuple. The login usage read marks its observation the same way, but reauthentication clears the row before writing and creation refuses existing ids, so it has nothing to retire. The same complete topology, when no window declares a monthly duration and no tertiary window is present, also retires a carried monthly tuple under the same writer proof (#8067), so a Free to Pro Lite change cannot keep a pool account on the old plan's exhausted monthly reading. Main-only monthly filtering is not applied to pool observations, and a main observation without a live main writer still carries its tuple. Coverage lives in `tests/codex-integration/codex-credits-probes.test.ts` (pool), `tests/codex-integration/main-quota-evidence-validation.test.ts`, `tests/codex-integration/main-account-hard-lock-retirement.test.ts`, `tests/codex-integration/main-quota-provenance.test.ts`, and diff --git a/structure/remote-link.md b/structure/remote-link.md index c0a02ec2c21..75dbd235f5a 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -1,5 +1,11 @@ # Remote Link +Signal fencing uses the configuration mutation lock (`C`), yielding 50 ms between at most 20 attempts only when `ConfigMutationLockError` wraps `SQLITE_BUSY` or `SQLITE_LOCKED`; identity, storage and other failures refuse immediately. Restoration callers must explicitly supply their sync-fence verdict. `tests/clients/client-link-stop.test.ts` covers a real cross-process C transaction, retry exhaustion and non-contention errors. + +`src/client/sync-runtime.ts` fences Link sync against the local runtime record. Signal shutdown marks its own PID/port/attestation generation as `clientStopping` under C before restoration, and retains that mark through its last await. Sync captures the record at admission and rechecks it under C before catalog, injection, settings-record and cache commits. A stopping, replaced, removed or unreadable record refuses the write; a stale operation cannot reinject after the Child exits. Failure to mark the runtime refuses restoration without overriding service, Desktop, sibling or parent-receipt preservation. No-runtime sync remains an explicit offline operation: it reapplies routing even though no Child listener is serving it. A stale stopping record is reconciled by the existing start/stop recovery paths, not by guessing PID ownership. The separate-process catalog/settings-response holds in `tests/clients/client-link-stop.test.ts` cover late commits, fresh sync during shutdown and the completed-sync control. + +Orderly signals in an unsupervised Link Child call `src/client/stop-routing.ts` before releasing the listener. It reuses native Codex config/catalog/history restoration and managed Grok removal; the saved connection and credentials remain. `src/client/restore-ownership.ts` optionally binds each write under C to the immutable served connection, including selection and rotation state. Only `catalogFingerprint` and `catalogSyncedAt`, updated by normal catalog sync, are excluded from this restore-specific comparison; `priorCatalog` remains guarded and the shared full-snapshot CAS for asynchronous connection work is unchanged. A changed owner, pending transition or unverified journal refuses remaining writes. Service/desktop supervision, sibling ownership, accepted restart/recycle handoff and outstanding parent teardown receipts preserve integrations for their existing owners. Signal shutdown waits for an accepted restart to hand off or finish rolling back; a pending admission owns no routing. It refuses new or pending restart admission with `409 runtime_stopping`; normal bound restart remains unchanged. After an ordinary unsupervised stop, start alone does not reapply routing; the operator steps live in the public CLI lifecycle reference. Coverage: `tests/clients/client-link-stop.test.ts`, `tests/clients/client-runtime-restart.test.ts`. + `src/link/ssh-argv.ts` and `src/link/remote-windows.ts` build pinned Child sync commands that select and version-check the same executable before executing `sync --expect-link` without a restart flag. Fan-out platform discovery in `src/link/remote-platform.ts` accepts a Windows marker on any stdout line or one framed uname line, so login-shell output cannot decide the platform. The Home requires one valid selection marker on stderr and one stdout delimiter, and classifies only the text after that delimiter; login-shell output before them is ignored. Oversized version probes are skipped. POSIX version capture and watchdog readiness use a private temporary directory, removed before execution or on refusal. The Home's `src/cli/sync-command.ts` fans out only after its catalog is current; the Child may still restart its own OpenCodex client runtime after applying changed Home settings. `src/link/` owns the building blocks for linking OpenCodex machines over SSH. The pure modules start no process, open no socket and schedule no timer on import. diff --git a/structure/runtime.md b/structure/runtime.md index fb63adc54be..9e0106fd619 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -117,7 +117,7 @@ does not perform OAuth, and runtime credential resolution rereads the owned sour | `src/config.ts`, `src/config/persisted-mutation.ts` | Persisted `~/.opencodex/config.json` surface: the facade keeps load/save/initialize entry points and re-exports the schema-valid mutation callback, types, and one-shot test seam; the leaf owns its bounded rebase under the shared lock and a read-only authority reader for admitted writers that starts no optional ACL flight. Schema lives in `src/config/schema/` (`config-schema.ts`, `leaf-validators.ts`), defaults in `src/config/proxy-env.ts`, and replace-path persistence in `src/config/persist-unlocked.ts`. | | `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. No-follow exclusive publication reuses its private descriptor writer for raw recovery bytes, checks bigint identity and publishes by a no-replace hard link; cleanup never scrubs an already published inode. Synchronous caller validation runs immediately before each rename attempt, including Windows retries. 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. The existing-secret compliance probe in `src/lib/windows-secret-acl.ts` uses the same SID-based private-entry policy as recovery-copy reuse when the opt-in single-owner ACE fast check cannot establish compliance; recovery-copy reuse always calls the SID policy directly. Retained SYSTEM and Administrators ACEs do not trigger mutation or duplicate recovery publication. The asynchronous probe in `src/lib/windows-owner-acl.ts` yields during the bounded descriptor subprocess and shares the synchronous parser, without admitting a hardening success memo. | -| `src/config/process-state.ts` | Owns `ocx.pid`, `runtime-port.json`, cheap liveness, full command-line identity verification, and snapshot-guarded cleanup. A transient Windows WMIC/PowerShell command-line probe failure gets one bounded retry before a PID reads as unverified. `src/config/tracked-runtime-inspection.ts` re-runs the same record reads once for the guarded stop, shares `readPid()`'s cached start-identity observation rather than re-probing it, and names the failed check as a fixed cause. | +| `src/config/process-state.ts` | Owns `ocx.pid`, `runtime-port.json`, cheap liveness, full command-line identity verification, and snapshot-guarded cleanup. The optional `clientStopping: true` field fences Link sync through signal restoration and the final diagnostic flush; `src/client/sync-runtime.ts` compares the runtime generation before each sync commit. A transient Windows WMIC/PowerShell command-line probe failure gets one bounded retry before a PID reads as unverified. `src/config/tracked-runtime-inspection.ts` re-runs the same record reads once for the guarded stop, shares `readPid()`'s cached start-identity observation rather than re-probing it, and names the failed check as a fixed cause. | | `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. Canonical identity tags arrays and objects separately, so key sorting cannot equate an object with an array of entry pairs. 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` / `src/server/bind-host.ts` | Own bind availability, ephemeral-port selection and startup hostname normalization shared with liveness: blank and case-insensitive `localhost`/`localhost.` bind to IPv4 loopback; other spellings remain unchanged. 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. | @@ -177,7 +177,7 @@ described in [OpenAI quota ownership](providers/openai-tiers.md#public-provider- `runtime-port.json` through `src/config/process-state.ts`, syncs Codex config/catalog, then serves until shutdown. Standalone shutdown restores native Codex; connected hub clients preserve connection-owned Codex routing, catalog and Grok config across `ocx stop`/`ocx start`. An ordinary runtime rechecks persisted connection ownership in its exit cleanup: a connected Remote Link Child remains subject to the existing native Codex/Grok restoration decisions. Graceful signal shutdown runs that synchronous cleanup after draining and before releasing the listener. Sibling, recycling and service-managed exit exclusions remain intact; receipt-backed API stop may explicitly defer shared restoration to the stopping client. Only an accepted receipt deferral in `src/server/stop-teardown.ts` latches that process's exit deferral; CLI exit cleanup keeps native Codex and Grok restoration with the parent, while still stopping background owners and removing runtime records. Client start does not re-inject routing: use `ocx sync --restart-codex` to reapply it, or `ocx disconnect` to release connection ownership. A sibling instance beside a live proxy ([Codex home](codex-home.md#codex-home)) syncs and restores nothing, and `ocx stop` of a runtime whose record carries `siblingOfPort` skips the shared teardown. `src/cli/index.ts` resolves same-home ownership, then checks shared client hints through the cross-home owner helper, then reconciles the journal only when no sibling is marked. An indeterminate cross-home result refuses a new start before binding or publication, rather than persisting an unverified sibling topology; the next start performs fresh discovery. Service mode sets `OCX_SERVICE=1`, so managed restarts do not repeatedly restore/reinject; scheduler failure diagnostics follow the [service command contract](ops/service-and-sidecars.md#background-service-command-selection). Explicit service stop and -uninstall still restore. `src/service/cli.ts` removes the service token on uninstall only when persisted client state is disconnected and no pending connect marker owns the newly issued key. `src/client/connect.ts` publishes that fingerprint marker before the key, then clears it with the connection commit or rollback under the client lifecycle and config mutation locks. Connected, invalid, or mismatched client state retains an existing token. A valid pending marker retains only its matching fingerprint; an older marker does not own a replacement service key. An absent token is reported as absent; unsafe, malformed, or unreadable markers and lock, state-read, or deletion failures leave cleanup unverified. +uninstall still restore. Native client signal cleanup follows the [served-connection and supervisor boundaries](remote-link.md); client update stops use full parent restoration because client start cannot discharge standalone handover receipts. `src/service/cli.ts` removes the service token on uninstall only when persisted client state is disconnected and no pending connect marker owns the newly issued key. `src/client/connect.ts` publishes that fingerprint marker before the key, then clears it with the connection commit or rollback under the client lifecycle and config mutation locks. Connected, invalid, or mismatched client state retains an existing token. A valid pending marker retains only its matching fingerprint; an older marker does not own a replacement service key. An absent token is reported as absent; unsafe, malformed, or unreadable markers and lock, state-read, or deletion failures leave cleanup unverified. `src/cli/tray-proxy.ts` coordinates `ocx restart` and tray restart. A second start requires proof the prior attempt never launched or its child exited, plus confirmed absence under a fresh bounded discovery window; a missing health response alone is insufficient. After an accepted restart, polling continues through uncertain observations until its deadline and requires a new runtime PID on the same port. If the old proxy then disappears, explicit recovery starts it regardless of the Codex autostart preference; a skipped recovery fails rather than reporting success. The Windows tray startup-health probe in `src/tray/windows-tray.ps1` requests termination at its 30-second deadline without waiting on the UI thread. It retains the process through later ticks until actual exit and both redirected readers settle; replacements cannot start while that reference is held. Launch setup failures follow the same cleanup. Tray shutdown alone waits at most 500 ms before disposal. `ocx restart` also restarts a connected client runtime, which serves the same bound `POST /api/system/restart` from its client listener; the CLI sends that request only while the client's 90-second restart budget still fits before replacement observation ends (`CLIENT_RESTART_REQUEST_MIN_REMAINING_MS`), and reports a supervised client's `409 restart_supervised` as "not performed, runtime kept". See [Remote Link](remote-link.md) for the rollback. diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index 3eefc7360a8..4da2a12db7d 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -75,11 +75,14 @@ only. It copies no request content and adds no bytes to the upstream wire; see t `src/server/request-decompress.ts` observes the UTF-8 sizes of decoded text and reserialized JSON without allocating encoded byte arrays solely to count them. Parsed-body accounting still uses -`JSON.stringify(parsed)`: numeric normalization can make it larger than the input text. These +the byte length of `JSON.stringify(parsed)`: numeric normalization can make it larger than the input +text. `src/server/json-serialized-bytes.ts` computes that length with an iterative walk, so a valid +body nested deeper than the call stack is measured instead of throwing during accounting. These observations retain the existing ownership and release lifecycle and do not consume the translator's hard byte cap. Per-body limits, parsing, compression, and reader error envelopes are unchanged. `tests/usage/request-decompress.test.ts` covers exact accounting across codecs and Unicode/numeric -normalization, UTF-8 counting without encoded copies, and release after malformed or optional empty input. +normalization, UTF-8 counting without encoded copies, release after malformed or optional empty input, +and serialized-length parity with `JSON.stringify` including nesting it cannot walk. The final native ChatGPT Responses HTTP send in `src/server/responses/fetch-helpers.ts` encodes JSON strings of at least 1 MiB as a UTF-8 buffer for Bun upload compatibility. This is a transport copy, @@ -105,7 +108,7 @@ discarding active leases or FIFO waiters; lowering capacity cannot erase outstan checks, before any request body is read. Default and smaller allowances do not consume this budget. Missing or small Content-Length and compressed wire bodies do not reduce the upload reservation. After successful bounded JSON parsing, the reader reduces it to the largest observed wire/decoded -size, including normalized JSON bytes when the existing translator measurement is enabled. +size, including normalized JSON bytes whether or not the route uses a translator budget. This reduction never exceeds the original allowance. The measured charge remains live; parsing never frees the whole lease. An already-aborted request or explicitly oversized declaration keeps the existing abort/413 path. diff --git a/structure/transports/response-turn-membership.md b/structure/transports/response-turn-membership.md index f5f6675412d..c527824ed02 100644 --- a/structure/transports/response-turn-membership.md +++ b/structure/transports/response-turn-membership.md @@ -24,6 +24,9 @@ tool names for output normalization. Responses enforces membership in that catal Chat and Messages enforce it when the caller explicitly empties a catalog, while omitted and partial catalogs retain deferred delivery. Declarations prepended while expanding `previous_response_id` remain history and cannot grant current-turn membership. +A nameless hosted declaration that translated adapters expose as a function (`tool_search`) +adds that function name to the snapshot only when the current turn declares it; a replayed +declaration still grants nothing. Delivery intersects current names with the existing selection-filtered bridge names, so `tool_choice` cannot be widened by the snapshot. Ordinary Responses validation retains its delivery owner and diagnostics; the additional guard enforces explicit-empty catalogs and diff --git a/structure/transports/responses-failover.md b/structure/transports/responses-failover.md index 437eb990f5e..d2df5ad2a9e 100644 --- a/structure/transports/responses-failover.md +++ b/structure/transports/responses-failover.md @@ -278,8 +278,7 @@ Signed progress is not summary prose. `src/server/responses/compaction-routing.ts` applies `compactionRouting` before model routing in both `request-prepare.ts` and `compact.ts`. It requires explicit `request_kind: "compaction"` in `x-codex-turn-metadata`, supplied as a header or embedded in Responses `client_metadata`, and on -`/v1/responses` a `compaction_trigger` input item as well, so metadata alone cannot move an -ordinary turn. Every supplied metadata copy must agree, both that the request is a compaction and +`/v1/responses` a `compaction_trigger` input item. With `codexClientCompaction` enabled, the canonical local checkpoint prompt also matches when each metadata copy specifies `compaction.implementation: "responses"`; it keeps the normal text summary contract. Every supplied metadata copy must agree, both that the request is a compaction and on which trigger it carries; copies that name different triggers are rejected rather than reconciled. Malformed, absent, and ordinary-turn metadata leave the request unchanged. WebSocket requests use only per-frame metadata; handshake headers can describe an earlier request. diff --git a/structure/transports/responses-wire-shapes.md b/structure/transports/responses-wire-shapes.md index 27b25c3db6a..804850643f5 100644 --- a/structure/transports/responses-wire-shapes.md +++ b/structure/transports/responses-wire-shapes.md @@ -65,7 +65,7 @@ Google metadata follows the [destination-scoped replay projection](../providers/ xAI keeps `openai-chat` provider-wide; Grok 4.5/4.6/4.7 subscription Responses default to native `openai-responses`, retaining namespace, hosted-search and reasoning-replay normalization. The reserved `xai` OAuth transport is name-pinned to the Grok CLI gateway even if its saved base URL differs; custom provider IDs do not inherit this -default. Exact xAI Responses destinations, including the US regional API, omit only `output.encrypted_content` from `include`; other entries remain unchanged. Meta at the exact `api.meta.ai` host shares this normalization, as do Azure adapters and resource hosts under .openai.azure.com, including when configured with openai-responses; unrelated destinations retain their include list ([request compatibility](../providers/xai-grok.md#responses-request-compatibility)). API-key requests, translated Chat/Anthropic defaults and other Grok models retain their +default. Exact xAI Responses destinations, including the US regional API, omit only `output.encrypted_content` from `include`; other entries remain unchanged. Meta at the exact `api.meta.ai` host shares this normalization, as do Azure adapters and resource hosts under .openai.azure.com, including when configured with openai-responses. Kimi Code shares it only at its exact HTTPS Responses endpoint (`https://api.kimi.com/coding/v1/responses`, default port; #8075); unrelated destinations retain their include list ([request compatibility](../providers/xai-grok.md#responses-request-compatibility)). API-key requests, translated Chat/Anthropic defaults and other Grok models retain their existing wire and tier policy. The OAuth lane is service-tier classified per model (`modelSupportsServiceTier` on the registry entry, live-probed 2026-09-13 and 2026-09-23; `devlog/_fin/260923_grok47_parity/010_probe-evidence.md` records 4.7): grok-4.7, grok-4.6, grok-4.5, diff --git a/tests/clients/client-link-connect.test.ts b/tests/clients/client-link-connect.test.ts index 6b8130a9e33..25e59266a14 100644 --- a/tests/clients/client-link-connect.test.ts +++ b/tests/clients/client-link-connect.test.ts @@ -13,6 +13,8 @@ import { connectClient, routingTarget } from "../../src/client/connect"; import { readServiceApiTokenState } from "../../src/lib/service-secrets"; import { isLinkConnection, readClientConnectionState } from "../../src/client/state"; import { DEFAULT_CATALOG_PATH } from "../../src/codex/paths"; +import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; +import { setSyntheticWindowsPrincipalForTests } from "../../src/lib/windows-user-principal"; const linkId = "lnk_0123456789abcdef"; const key = `ocx_data_${"a".repeat(40)}`; @@ -89,12 +91,23 @@ describe("client link connection contracts", () => { const previousCodexHome = process.env.CODEX_HOME; process.env.OPENCODEX_HOME = home; process.env.CODEX_HOME = codexHome; + // These are enrollment/catalog contracts. ACL enforcement has its own native tests; + // real PowerShell/icacls work can consume the five-second semantic test budget. + let aclCalls = 0; + const aclSuccess = () => { aclCalls += 1; return { success: true, exitCode: 0, timedOut: false, stdout: "" }; }; + setSyntheticWindowsPrincipalForTests("*S-1-5-21-1-2-3-1001"); + setIcaclsRunnerForTests(aclSuccess); + setAsyncIcaclsRunnerForTests(async () => aclSuccess()); try { const config = getDefaultConfig(); config.port = 10100; saveConfig(config); await run(home, codexHome); + if (process.platform === "win32") expect(aclCalls).toBeGreaterThan(0); } finally { + setIcaclsRunnerForTests(null); + setAsyncIcaclsRunnerForTests(null); + setSyntheticWindowsPrincipalForTests(null); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; if (previousCodexHome === undefined) delete process.env.CODEX_HOME; diff --git a/tests/clients/client-link-stop.test.ts b/tests/clients/client-link-stop.test.ts new file mode 100644 index 00000000000..0a68e736791 --- /dev/null +++ b/tests/clients/client-link-stop.test.ts @@ -0,0 +1,332 @@ +import { expect, test } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fixturePath, repoPath } from "../helpers/repo-root"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +async function runStop(options: Record) { + const root = mkdtempSync(join(tmpdir(), "ocx-link-stop-")); + const home = join(root, "ocx"); + const child = Bun.spawn([process.execPath, fixturePath("client-link-stop-runner.ts")], { + cwd: repoPath(), stdin: new Blob([JSON.stringify(options)]), stdout: "pipe", stderr: "pipe", + env: { ...process.env, HOME: root, USERPROFILE: root, OPENCODEX_HOME: home, CODEX_HOME: join(root, "codex"), OCX_SERVICE: "", OCX_SERVICE_MANAGED: "0" }, + }); + const timeout = setTimeout(() => child.kill(), 15_000); + try { + if (options.osSignal) { + const deadline = Date.now() + 10_000; + while (!existsSync(join(home, "ready")) && child.exitCode === null && Date.now() < deadline) await Bun.sleep(10); + if (!existsSync(join(home, "ready"))) throw new Error("fixture did not publish its signal-ready listener"); + child.kill("SIGTERM"); + } + const [exit, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); + if (options.expectedFailure || options.expectedRefusal) { + expect(exit, stdout).toBe(options.expectedFailure ? 1 : 0); + expect(stderr).toContain("Link Child Codex routing restore refused"); + } else if (String(options.preserve).startsWith("restart-rollback")) { + expect(exit, stdout).toBe(0); + expect(stderr.trim()).toBe("[client] restart: the replacement did not start (not_ready); this runtime keeps serving"); + } else expect({ exit, stderr }, stdout).toEqual({ exit: 0, stderr: "" }); + const report = JSON.parse(readFileSync(join(home, "result.json"), "utf8")); + const atExit = JSON.parse(readFileSync(join(home, "exit.json"), "utf8")); + for (const field of ["after", "profileAfter", "journalRetained", "journalHomeBound", "catalogAfter", "grokAfter", "protectedUnchanged"]) { + expect(atExit[field], `no late restoration after listener release: ${field}`).toEqual(report[field]); + } + return report; + } finally { + clearTimeout(timeout); + if (child.exitCode === null) { child.kill(); await child.exited; } + removeTreeWithRetry(root); + } +} + +for (const signal of process.platform === "win32" ? ["SIGINT", "SIGTERM"] : ["SIGINT", "SIGTERM", "SIGHUP"]) { + test(`native Link ${signal} restores its owned Codex routing before listener release`, async () => { + const report = await runStop({ mode: "runtime", signal }); + expect(report.result).toEqual({ atListenerStop: true }); + expect(report.after).toBe('model = "native"\n'); + expect(report.profileAfter).toBeNull(); + expect(report.journalRetained).toBe(false); + expect(report.protectedUnchanged).toBe(true); + expect(report.catalogAfter.models).toEqual([{ slug: "fixture-native", visibility: "list" }]); + expect(report.grokAfter).toBe('theme = "fixture"\n'); + }, 20_000); +} + +test.skipIf(process.platform === "win32")("a real POSIX SIGTERM restores after catalog sync before listener release", async () => { + const report = await runStop({ mode: "runtime", osSignal: true, syncBeforeStop: true }); + expect(report.syncEvidence).toEqual({ changedFields: ["catalogFingerprint", "catalogSyncedAt"], fullCasRefused: true, catalogWritten: true, injected: true, journalHomeBound: true }); + expect(report.after).toBe('model = "native"\n'); + expect(report.profileAfter).toBeNull(); + expect(report.journalHomeBound).toBe(false); + expect(report.catalogAfter.models).toEqual([{ slug: "fixture-native", visibility: "list" }]); + expect(report.grokAfter).toBe('theme = "fixture"\n'); +}, 20_000); + +test("native Link signal restores after real catalog sync without weakening the full snapshot CAS", async () => { + const report = await runStop({ mode: "runtime", syncBeforeStop: true }); + expect(report.syncEvidence).toEqual({ changedFields: ["catalogFingerprint", "catalogSyncedAt"], fullCasRefused: true, catalogWritten: true, injected: true, journalHomeBound: true }); + expect(report.after).toBe('model = "native"\n'); + expect(report.profileAfter).toBeNull(); + // Reinjection preserves the first hash: field-level restore retains its recovery + // snapshot but releases the home binding, rather than deleting recovery evidence. + expect(report.journalRetained).toBe(true); + expect(report.journalHomeBound).toBe(false); + expect(report.protectedUnchanged).toBe(true); + expect(report.catalogAfter.models).toEqual([{ slug: "fixture-native", visibility: "list" }]); + expect(report.grokAfter).toBe('theme = "fixture"\n'); +}, 20_000); + +test("native hub signal exit preserves the connection-owned route", async () => { + const report = await runStop({ mode: "runtime", transport: "hub" }); + expect(report.after).toBe(report.before); + expect(report.profileAfter).toBe(report.profileBefore); + expect(report.journalRetained).toBe(true); + expect(report.protectedUnchanged).toBe(true); +}, 20_000); + +test("native Link signal refuses a connection swapped after runtime publication", async () => { + const report = await runStop({ mode: "runtime", mutation: "connection", expectedFailure: true }); + expect(report.result).toEqual({ atListenerStop: true }); + expect(report.after).toBe(report.before); + expect(report.profileAfter).toBe(report.profileBefore); + expect(report.journalRetained).toBe(true); + expect(report.protectedUnchanged).toBe(true); +}, 20_000); + +test("owned Link full restore is idempotent and retains connection credentials", async () => { + const report = await runStop({ mode: "helper", repeat: true }); + expect(report.result.first).toMatchObject({ success: true, restored: true }); + expect(report.result.repeated).toMatchObject({ success: true, restored: false }); + expect(report.after).toBe('model = "native"\n'); + expect(report.profileAfter).toBeNull(); + expect(report.journalRetained).toBe(false); + expect(report.protectedUnchanged).toBe(true); + expect(report.catalogAfter.models).toEqual([{ slug: "fixture-native", visibility: "list" }]); + expect(report.grokAfter).toBe('theme = "fixture"\n'); +}); + +for (const mutation of ["connection", "selection", "journal", "process-journal", "unverified", "rotation", "hub"]) { + test(`Link restore refuses ${mutation} evidence without touching its files`, async () => { + const report = await runStop({ mode: "helper", mutation }); + expect(report.result).toMatchObject({ success: false, restored: false }); + expect(report.after).toBe(report.before); + expect(report.profileAfter).toBe(report.profileBefore); + expect(report.journalRetained).toBe(true); + expect(report.protectedUnchanged).toBe(true); + expect(report.catalogAfter).toEqual(JSON.parse(report.catalogBefore)); + expect(report.grokAfter).toBe(report.grokBefore); + }); +} + +for (const mutation of ["config", "profile"]) { + test(`Link full restore preserves user edits in ${mutation} while removing owned routing`, async () => { + const report = await runStop({ mode: "helper", mutation }); + expect(report.result.success).toBe(true); + if (mutation === "config") expect(report.after).toContain('approval_policy = "on-request"'); + else expect(report.profileAfter).toBe(report.profileBefore); + expect(report.after).not.toContain("openai_base_url"); + expect(report.protectedUnchanged).toBe(true); + expect(report.catalogAfter.models).toEqual([{ slug: "fixture-native", visibility: "list" }]); + expect(report.grokAfter).toBe('theme = "fixture"\n'); + }); +} + +for (const mutation of ["oversized", "nonregular"]) { + test(`missing Link journal cannot make an ${mutation} config a successful no-op`, async () => { + const report = await runStop({ mode: "helper", mutation }); + expect(report.result).toMatchObject({ success: false, restored: false }); + expect(report.after).toBe(report.before); + expect(report.profileAfter).toBe(report.profileBefore); + expect(report.protectedUnchanged).toBe(true); + }); +} + +test("Link API teardown preserves the merged parent deferral contract", async () => { + const report = await runStop({ mode: "api" }); + expect(report.result).toMatchObject({ success: true, sharedTeardown: "deferred" }); + expect(report.after).toBe(report.before); + expect(report.profileAfter).toBe(report.profileBefore); + expect(report.journalRetained).toBe(true); + expect(report.protectedUnchanged).toBe(true); +}); + +test("parent Link teardown retains the merged full restoration contract", async () => { + const report = await runStop({ mode: "parent" }); + expect(report.result).toEqual({ historyOnly: false, historyDeferred: false, other: false }); + expect(report.after).toBe('model = "native"\n'); + expect(report.journalRetained).toBe(false); + expect(report.protectedUnchanged).toBe(true); + expect(report.catalogAfter.models).toEqual([{ slug: "fixture-native", visibility: "list" }]); + expect(report.grokAfter).toBe('theme = "fixture"\n'); +}); + +for (const preserve of ["service", "desktop", "receipt", "sibling"]) { + test(`native Link signal preserves ${preserve} restoration ownership`, async () => { + const report = await runStop({ mode: "runtime", preserve }); + expect(report.after).toBe(report.before); + expect(report.profileAfter).toBe(report.profileBefore); + expect(report.journalRetained).toBe(true); + expect(report.protectedUnchanged).toBe(true); + expect(report.catalogAfter).toEqual(JSON.parse(report.catalogBefore)); + expect(report.grokAfter).toBe(report.grokBefore); + }, 20_000); +} + +test("a pending restart admission cannot leave routing behind on signal shutdown", async () => { + const report = await runStop({ mode: "runtime", preserve: "restart" }); + expect(report.after).toBe('model = "native"\n'); + expect(report.journalRetained).toBe(false); + expect(report.catalogAfter.models).toEqual([{ slug: "fixture-native", visibility: "list" }]); + expect(report.grokAfter).toBe('theme = "fixture"\n'); +}, 20_000); + +test.each(["restart-rollback", "restart-rollback-recycle"])("signal shutdown joins %s before restoring", async preserve => { + const report = await runStop({ mode: "runtime", preserve }); + expect(report.after).toBe('model = "native"\n'); + expect(report.journalRetained).toBe(false); + expect(report.catalogAfter.models).toEqual([{ slug: "fixture-native", visibility: "list" }]); + expect(report.grokAfter).toBe('theme = "fixture"\n'); +}, 20_000); + +test("a scheduled recycle cannot skip restoration or spawn after signal shutdown", async () => { + const report = await runStop({ mode: "runtime", preserve: "recycle-pending" }); + expect(report.after).toBe('model = "native"\n'); + expect(report.journalRetained).toBe(false); + expect(report.catalogAfter.models).toEqual([{ slug: "fixture-native", visibility: "list" }]); + expect(report.grokAfter).toBe('theme = "fixture"\n'); +}, 20_000); + +for (const mutation of ["connection", "selection", "rotation", "hub", "prior-catalog", "api-key", "token", "management", "link-id"]) { + test(`served-connection guard rechecks ${mutation} at each write and freezes its snapshot`, async () => { + const report = await runStop({ mode: "guard", mutation }); + expect(report.result).toEqual({ writes: ["before-change"], refused: true, boundary: mutation === "hub" ? "hub-owned" : "unproven" }); + expect(report.after).toBe(report.before); + expect(report.journalRetained).toBe(true); + expect(report.catalogAfter).toEqual(JSON.parse(report.catalogBefore)); + expect(report.grokAfter).toBe(report.grokBefore); + }); +} + +test("served-connection guard permits catalog sync metadata at a later write", async () => { + const report = await runStop({ mode: "guard", mutation: "catalog" }); + expect(report.result).toEqual({ writes: ["before-change", "after-change"], refused: false, boundary: "restore" }); +}); + +for (const retryError of ["busy", "locked", "exhausted", "permission", "unwrapped", "identity"]) { + test(`signal sync fencing bounds and classifies ${retryError} retries`, async () => { + const report = await runStop({ mode: "retry", retryError }); + const succeeds = retryError === "busy" || retryError === "locked"; + expect(report.result).toEqual({ success: succeeds, attempts: succeeds ? 3 : retryError === "exhausted" ? 20 : 1, + waits: Array(succeeds ? 2 : retryError === "exhausted" ? 19 : 0).fill(50) }); + expect(report.after).toBe(report.before); + expect(report.catalogAfter).toEqual(JSON.parse(report.catalogBefore)); + }); +} + +for (const runtimeRecord of ["absent", "invalid", "foreign"]) { + test(`signal refuses restoration when its runtime record is ${runtimeRecord}`, async () => { + const report = await runStop({ mode: "runtime", runtimeRecord, expectedFailure: true }); + expect(report.after).toBe(report.before); + expect(report.profileAfter).toBe(report.profileBefore); + expect(report.catalogAfter).toEqual(JSON.parse(report.catalogBefore)); + expect(report.grokAfter).toBe(report.grokBefore); + expect(report.protectedUnchanged).toBe(true); + }, 20_000); +} + +for (const preserve of ["service", "desktop", "receipt", "sibling"]) { + test(`${preserve} signal preservation does not require a restoration sync fence`, async () => { + const report = await runStop({ mode: "runtime", preserve, runtimeRecord: "absent" }); + expect(report.after).toBe(report.before); + expect(report.catalogAfter).toEqual(JSON.parse(report.catalogBefore)); + expect(report.grokAfter).toBe(report.grokBefore); + }, 20_000); +} + +for (const { releaseFirst, hold } of [{ releaseFirst: false, hold: "catalog" }, { releaseFirst: false, hold: "home" }, { releaseFirst: true, hold: "catalog" }, { releaseFirst: false, hold: "lock" }] as const) { + test(`a separate sync ${releaseFirst ? "finishes before" : "cannot commit after"} signal restoration (${hold})`, async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-link-sync-stop-")); + // Sync records the canonical catalog path (/private/var on macOS) and backup lookup hashes the + // spelling, so the fixture must create its native backup under the same spelling. + const home = join(root, "ocx"), codex = join(realpathSync(root), "codex"); + const children: Array<{ child: ReturnType; drained: Promise }> = []; + const spawn = (file: string, options: unknown) => { + const child = Bun.spawn([process.execPath, fixturePath(file)], { + cwd: repoPath(), stdin: new Blob([JSON.stringify(options)]), stdout: "pipe", stderr: "pipe", + env: { ...process.env, HOME: root, USERPROFILE: root, OPENCODEX_HOME: home, CODEX_HOME: codex, OCX_SERVICE: "", OCX_SERVICE_MANAGED: "0" }, + }); + const drained = Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text()]); + children.push({ child, drained }); + return child; + }; + const waitFor = async (name: string) => { + const end = Date.now() + 15_000; + while (!existsSync(join(home, name)) && Date.now() < end) await Bun.sleep(10); + if (!existsSync(join(home, name))) throw new Error(`fixture did not publish ${name}`); + }; + const release = (name: string) => writeFileSync(join(home, name), "ready"); + const read = (name: string) => JSON.parse(readFileSync(join(home, name), "utf8")); + const durable = () => ({ + config: readFileSync(join(codex, "config.toml"), "utf8"), + catalog: JSON.parse(readFileSync(join(codex, "opencodex-catalog.json"), "utf8")), + profile: existsSync(join(codex, "opencodex.config.toml")), + connection: readFileSync(join(home, "config.json"), "utf8"), + token: readFileSync(join(home, "service-api-token"), "utf8"), + }); + const timer = setTimeout(() => { for (const { child } of children) if (child.exitCode === null) child.kill("SIGKILL"); }, 35_000); + try { + const runtime = spawn("client-link-stop-runner.ts", { mode: "runtime", externalSignal: true, holdShutdown: true }); + await waitFor("ready"); + const held = spawn("client-link-sync-runner.ts", { hold, name: "held" }); + await waitFor("held-requested"); + if (releaseFirst) { + release("catalog-release"); + expect(await held.exited).toBe(0); + expect(read("held-sync-result.json")).toMatchObject({ ok: true, catalogWritten: true, injected: true }); + expect(durable().config).toContain("openai_base_url"); + expect(durable().catalog.models).toEqual([{ slug: "fixture/late-sync" }]); + } + release("signal-now"); + if (hold === "lock") { + // Both immediate attempts see a real competing C transaction. Yielding lets it finish. + await Bun.sleep(150); + release("catalog-release"); + } + await waitFor("shutdown-drained"); + const restored = durable(); + expect(restored.config).toBe('model = "native"\n'); + expect(restored.profile).toBe(false); + expect(restored.catalog.models).toEqual([{ slug: "fixture-native", visibility: "list" }]); + if (releaseFirst) { + // No apply lock is held now: a fresh sync must be refused by shutdown admission. + const fresh = spawn("client-link-sync-runner.ts", { hold: false, name: "fresh" }); + expect(await fresh.exited).toBe(0); + expect(read("fresh-sync-result.json")).toMatchObject({ ok: false, fetched: false }); + expect(durable()).toEqual(restored); + } + release("shutdown-release"); + expect(await runtime.exited).toBe(0); + if (!releaseFirst) { + release("catalog-release"); + expect(await held.exited).toBe(0); + expect(read("held-sync-result.json")).toMatchObject(hold === "lock" ? { ok: true, lockHeld: true } : { ok: false, fetched: true }); + } + // Read after BOTH processes exit; the runtime's own exit snapshot cannot prove this. + expect(durable()).toEqual(restored); + if (releaseFirst) { + // A new explicit offline sync after complete shutdown remains supported. + const offline = spawn("client-link-sync-runner.ts", { hold: false, name: "fresh" }); + expect(await offline.exited).toBe(0); + expect(read("fresh-sync-result.json")).toMatchObject({ ok: true, fetched: true, injected: true }); + } + } finally { + clearTimeout(timer); + // A child parked on a held release ignores another SIGTERM; kill it so a failed assertion surfaces. + for (const { child } of children) { if (child.exitCode === null) child.kill("SIGKILL"); await child.exited; } + await Promise.all(children.map(value => value.drained)); + removeTreeWithRetry(root); + } + }, 40_000); +} diff --git a/tests/clients/client-runtime-restart.test.ts b/tests/clients/client-runtime-restart.test.ts index 4f256fe85fd..06cd7feb460 100644 --- a/tests/clients/client-runtime-restart.test.ts +++ b/tests/clients/client-runtime-restart.test.ts @@ -139,6 +139,13 @@ describe("client listener: bound restart", () => { expect(body.restartCapability).toBeUndefined(); expect((await fetch(new URL(SYSTEM_RESTART_PATH, server.url), { method: "POST" })).status).toBe(404); }); + + test("a signal-stopping runtime refuses bound restart without a success receipt", async () => { + const { server, secret, port } = listen(async () => ({ kind: "stopping" })); + const response = await fetch(new URL(SYSTEM_RESTART_PATH, server.url), { method: "POST", headers: restartHeaders(secret, port) }); + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ success: false, error: { code: "runtime_stopping" } }); + }); }); function fakeIo(overrides: Partial = {}) { @@ -158,6 +165,50 @@ function fakeIo(overrides: Partial = {}) { } describe("client restart controller", () => { + test("signal shutdown refuses new and pending restart admissions without spawning", async () => { + let stopping = false; + let admit!: (value: null) => void; + const { io, calls } = fakeIo({ stopping: () => stopping, supervisor: () => new Promise(resolve => { admit = resolve; }) }); + const controller = createClientRestartController(io); + const pending = controller.request(); + expect(controller.hasHandoff()).toBe(false); + stopping = true; + expect(await controller.request()).toEqual({ kind: "stopping" }); + admit(null); + expect(await pending).toEqual({ kind: "stopping" }); + expect(controller.hasHandoff()).toBe(false); + expect(calls).toEqual([]); + }); + + test("pending admission never owns routing and refusal or failure leaves no handoff", async () => { + let settle!: (value: "service" | null) => void; + const { io } = fakeIo({ supervisor: () => new Promise(resolve => { settle = resolve; }) }); + const controller = createClientRestartController(io); + expect(controller.hasHandoff()).toBe(false); + const pending = controller.request(); + expect(controller.hasHandoff()).toBe(false); + settle("service"); + expect(await pending).toEqual({ kind: "supervised", supervisor: "service" }); + expect(controller.hasHandoff()).toBe(false); + const failed = createClientRestartController(fakeIo({ supervisor: async () => { throw new Error("unavailable"); } }).io); + await expect(failed.request()).rejects.toThrow("unavailable"); + expect(failed.hasHandoff()).toBe(false); + }); + + test("accepted handoff remains owned until its asynchronous exit settles", async () => { + let entered!: () => void; + let release!: () => void; + const atExit = new Promise(resolve => { entered = resolve; }); + const exitGate = new Promise(resolve => { release = resolve; }); + const controller = createClientRestartController(fakeIo({ exitProcess: async () => { entered(); await exitGate; } }).io); + await controller.request(); + await atExit; + expect(controller.hasHandoff()).toBe(true); + release(); + await controller.settled(); + expect(controller.hasHandoff()).toBe(false); + }); + test("a replacement that answers ends this process", async () => { const { io, calls } = fakeIo(); const controller = createClientRestartController(io); diff --git a/tests/codex-integration/pool-monthly-window-retirement.test.ts b/tests/codex-integration/pool-monthly-window-retirement.test.ts new file mode 100644 index 00000000000..236c4cc3e0d --- /dev/null +++ b/tests/codex-integration/pool-monthly-window-retirement.test.ts @@ -0,0 +1,166 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { capturePoolQuotaWriter, readCodexAccountRecord, saveCodexAccountCredential } from "../../src/codex/account-store"; +import { MAIN_CODEX_ACCOUNT_ID as MAIN } from "../../src/codex/account-id"; +import { commitPoolQuotaResponse } from "../../src/codex/auth-api/pool-quota-probe"; +import { resetCodexCreditsForTests } from "../../src/codex/credits"; +import { captureMainQuotaWriter, clearMainAccountInfoCache, observeMainQuotaIdentity } from "../../src/codex/main-account-cache"; +import { + applyAccountQuotaFromUpstreamHeaders, clearAccountQuota, getAccountQuota, getMainPolicyQuota, isCodexQuotaExhausted, + parseMainPolicyUsageQuota, parseUsageQuota, poolWhamPolicyObservation, setAccountQuotaFromParsed, type WhamUsageResponse, +} from "../../src/codex/quota"; +import { resetQuotaQueryBackoffForTests } from "../../src/codex/quota-query-backoff"; +import { captureConfigGeneration } from "../../src/lib/state-store-sweeper"; +import { resetLifecycleDrainStateForTests } from "../../src/server/lifecycle"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +// #8067: a pool account that moved from a monthly-primary plan (Free) to a weekly plan +// (Pro Lite) kept the old exhausted monthly tuple forever and was never selected. +const accountId = "monthly-retire-pool"; +let testDir: string; +let previousHome: string | undefined; +let previousCodexHome: string | undefined; +let originalFetch: typeof fetch; + +function savePool() { + saveCodexAccountCredential(accountId, { + accessToken: "fixture-pool-bearer", refreshToken: "fixture-pool-refresh", + chatgptAccountId: "fixture-pool-identity", expiresAt: Date.now() + 3_600_000, + }); +} +function poolContext() { + const record = readCodexAccountRecord(accountId)!; + return { accountId, existing: null, configuredPlan: undefined, + generation: record.generation, writerGeneration: captureConfigGeneration(), + poolWriter: capturePoolQuotaWriter(accountId, { ...record.credential!, generation: record.generation }), + }; +} + +const now = () => Math.floor(Date.now() / 1000); +const monthly = (used_percent = 100) => ({ used_percent, limit_window_seconds: 2_592_000, reset_at: now() + 2_592_000 }); +const weekly = (used_percent = 0) => ({ used_percent, limit_window_seconds: 604_800, reset_at: now() + 604_800 }); +const observe = (plan_type: string, rate_limit: Record) => Response.json({ plan_type, rate_limit }); +const freeExhausted = () => observe("free", { allowed: false, primary_window: monthly(), secondary_window: null }); +const proLite = (extra: Record = {}) => + observe("prolite", { allowed: true, primary_window: weekly(), secondary_window: null, ...extra }); + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + previousCodexHome = process.env.CODEX_HOME; + originalFetch = globalThis.fetch; + testDir = mkdtempSync(join(tmpdir(), "ocx-monthly-retire-")); + process.env.OPENCODEX_HOME = testDir; + process.env.CODEX_HOME = join(testDir, "codex"); + mkdirSync(process.env.CODEX_HOME, { recursive: true }); + globalThis.fetch = (async () => { throw new Error("unexpected fixture network request"); }) as typeof fetch; + clearAccountQuota(); + clearMainAccountInfoCache(); + resetLifecycleDrainStateForTests(); + resetCodexCreditsForTests(); + resetQuotaQueryBackoffForTests(); +}); +afterEach(() => { + clearAccountQuota(); + clearMainAccountInfoCache(); + resetLifecycleDrainStateForTests(); + resetCodexCreditsForTests(); + resetQuotaQueryBackoffForTests(); + globalThis.fetch = originalFetch; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + removeTreeWithRetry(testDir); +}); + +describe("pool WHAM monthly-window retirement (#8067)", () => { + test("a complete weekly-only probe after a plan change retires the carried monthly tuple", async () => { + savePool(); + const ctx = poolContext(); + const before = await commitPoolQuotaResponse(freeExhausted(), ctx); + expect(before.quota?.monthlyPercent).toBe(100); + expect(before.quota?.monthlyIsPrimaryWindow).toBe(true); + + const after = await commitPoolQuotaResponse(proLite(), ctx); + expect(after.quota?.weeklyPercent).toBe(0); + expect(after.quota?.monthlyPercent).toBeUndefined(); + expect(after.quota?.monthlyResetAt).toBeUndefined(); + expect(after.quota?.monthlyIsPrimaryWindow).toBeUndefined(); + expect(isCodexQuotaExhausted(getAccountQuota(accountId), "prolite")).toBe(false); + expect(getAccountQuota(accountId)).not.toHaveProperty("monthlyWindowAbsent"); + }); + + test("a present tertiary window replaces the monthly reading instead of retiring it", async () => { + savePool(); + const ctx = poolContext(); + await commitPoolQuotaResponse(freeExhausted(), ctx); + const result = await commitPoolQuotaResponse(proLite({ tertiary_window: monthly(40) }), ctx); + expect(result.quota?.monthlyPercent).toBe(40); + }); + + test("partial, denied, header-only and writerless observations keep the carried tuple", async () => { + savePool(); + const ctx = poolContext(); + await commitPoolQuotaResponse(freeExhausted(), ctx); + const partial = await commitPoolQuotaResponse(Response.json({ plan_type: "prolite", rate_limit: { primary_window: weekly() } }), ctx); + expect(partial.quota?.monthlyPercent).toBe(100); + const denied = await commitPoolQuotaResponse(observe("prolite", { + allowed: false, primary_window: weekly(), secondary_window: null, tertiary_window: null, + }), ctx); + expect(denied.quota?.monthlyPercent).toBe(100); + applyAccountQuotaFromUpstreamHeaders(accountId, new Headers({ + "x-codex-primary-used-percent": "0", "x-codex-primary-window-minutes": "10080", + })); + expect(getAccountQuota(accountId)?.monthlyPercent).toBe(100); + const writerless = await commitPoolQuotaResponse(proLite(), { ...ctx, poolWriter: undefined }); + expect(writerless.quota?.monthlyPercent).toBe(100); + const otherAccount = await commitPoolQuotaResponse(proLite(), { ...ctx, poolWriter: { ...ctx.poolWriter!, accountId: "other-pool" } }); + expect(otherAccount.quota?.monthlyPercent).toBe(100); + }); + + test("policy evidence marks monthly absence only when no window is monthly", () => { + const evidence = (data: WhamUsageResponse) => poolWhamPolicyObservation(data, parseUsageQuota(data)); + const weeklyOnly = { plan_type: "prolite", rate_limit: { allowed: true, primary_window: weekly(), secondary_window: null } }; + expect(evidence(weeklyOnly)?.monthlyWindowAbsent).toBe(true); + expect(evidence({ ...weeklyOnly, rate_limit: { ...weeklyOnly.rate_limit, tertiary_window: monthly(5) } })?.monthlyWindowAbsent).toBeUndefined(); + expect(evidence({ plan_type: "team", rate_limit: { allowed: true, primary_window: monthly(5), secondary_window: weekly() } })?.monthlyWindowAbsent).toBeUndefined(); + expect(evidence({ plan_type: "prolite", rate_limit: { primary_window: weekly() } })?.monthlyWindowAbsent).toBeUndefined(); + }); +}); + +describe("main observations keep their monthly retention rules", () => { + const mainIdentity = "monthly-retire-main-fixture"; + const publish = (data: WhamUsageResponse) => setAccountQuotaFromParsed(MAIN, parseUsageQuota(data), undefined, + captureMainQuotaWriter(mainIdentity), parseMainPolicyUsageQuota(data)); + const caches = () => [getAccountQuota(MAIN), getMainPolicyQuota()]; + + test("an explicit denial with null secondary and tertiary keeps the monthly tuple in both caches", () => { + observeMainQuotaIdentity(mainIdentity); + publish({ plan_type: "free", rate_limit: { allowed: false, primary_window: monthly(), secondary_window: null } }); + for (const quota of caches()) expect(quota?.monthlyPercent).toBe(100); + const denied = { plan_type: "prolite", rate_limit: { + allowed: false, primary_window: weekly(), secondary_window: null, tertiary_window: null, + } }; + expect(parseMainPolicyUsageQuota(denied)?.monthlyWindowAbsent).toBeUndefined(); + publish(denied); + for (const quota of caches()) { + expect(quota?.monthlyPercent).toBe(100); + expect(quota?.monthlyIsPrimaryWindow).toBe(true); + } + expect(isCodexQuotaExhausted(getAccountQuota(MAIN), "prolite")).toBe(true); + }); + + test("an allowed snapshot with a malformed additional window keeps the monthly tuple", () => { + observeMainQuotaIdentity(mainIdentity); + publish({ plan_type: "free", rate_limit: { allowed: false, primary_window: monthly(), secondary_window: null } }); + const malformed = { plan_type: "prolite", rate_limit: { allowed: true, primary_window: weekly(), secondary_window: null }, + additional_rate_limits: [{ rate_limit: { primary_window: { used_percent: 101, limit_window_seconds: 604_800 } } }] }; + expect(parseMainPolicyUsageQuota(malformed)).not.toHaveProperty("monthlyWindowAbsent"); + publish(malformed); + for (const quota of caches()) expect(quota?.monthlyPercent).toBe(100); + expect(isCodexQuotaExhausted(getAccountQuota(MAIN), "prolite")).toBe(true); + }); +}); + diff --git a/tests/fixtures/client-link-stop-runner.ts b/tests/fixtures/client-link-stop-runner.ts new file mode 100644 index 00000000000..316a8c88c8b --- /dev/null +++ b/tests/fixtures/client-link-stop-runner.ts @@ -0,0 +1,228 @@ +/** Isolated disk and native-runtime coverage for a connected Link Child's stop. */ +import { mock } from "bun:test"; +import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { getDefaultConfig, saveConfig } from "../../src/config"; +import { CODEX_CONFIG_PATH, CODEX_HOME, CODEX_PROFILE_PATH, DEFAULT_CATALOG_PATH } from "../../src/codex/paths"; +import { JOURNAL_PATH, markJournalInjectedState, writeJournal } from "../../src/codex/journal"; +import { serviceApiTokenFingerprint } from "../../src/lib/service-secrets"; +import { ensureCatalogBackup } from "../../src/codex/catalog/parsing"; +import { takeDesktopSupervisedMarker } from "../../src/lib/system-restart-contract"; +import { claimPendingTeardown } from "../../src/config/pending-teardown"; + +const options = JSON.parse(readFileSync(0, "utf8")) as { + mode: "runtime" | "helper" | "api" | "parent" | "guard" | "retry"; + transport?: "link" | "hub"; + signal?: NodeJS.Signals; + osSignal?: boolean; + syncBeforeStop?: boolean; + externalSignal?: boolean; + holdShutdown?: boolean; + runtimeRecord?: "absent" | "invalid" | "foreign"; + retryError?: "busy" | "locked" | "exhausted" | "permission" | "unwrapped" | "identity"; + repeat?: boolean; + preserve?: "service" | "desktop" | "receipt" | "sibling" | "restart" | "restart-rollback" | "recycle-pending" | "restart-rollback-recycle"; + mutation?: "connection" | "journal" | "process-journal" | "config" | "profile" | "unverified" | "selection" | "oversized" | "nonregular" | "rotation" | "hub" | "catalog" | "prior-catalog" | "api-key" | "token" | "management" | "link-id"; +}; +const home = process.env.OPENCODEX_HOME!; +mkdirSync(CODEX_HOME, { recursive: true }); +mkdirSync(home, { recursive: true }); +const lifecycle = await import("../../src/client/lifecycle-lock"); +const lock = lifecycle.withClientLifecycleSync; +mock.module("../../src/client/lifecycle-lock", () => ({ ...lifecycle, + withClientLifecycleSync: (work: Parameters[0]) => lock(work, { lockPath: join(home, "lifecycle.sqlite") }), +})); +const token = `ocx_data_${"c".repeat(40)}`; +const config = getDefaultConfig(); +const probe = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response(null) }); +config.port = probe.port!; +await probe.stop(true); +config.runtimeRole = "client"; +config.client = { + serverUrl: options.transport === "hub" ? "https://hub.example.test" : "http://127.0.0.1:34567", + managementUrl: options.transport === "hub" ? "https://hub.example.test" : "http://127.0.0.1:34567", + managementTransport: "direct", transport: options.transport ?? "link", + ...(options.transport === "hub" ? {} : { link: { tunnelPort: 34567, linkId: "lnk_0123456789abcdef" } }), + selectedClients: ["codex"], tokenEnv: "OPENCODEX_API_AUTH_TOKEN", apiKeyId: "fixture-client", + tokenFingerprint: serviceApiTokenFingerprint(token), protocolVersion: 1, connectedAt: "2026-10-01T00:00:00.000Z", +}; +saveConfig(config); +const expected = structuredClone(config.client); +const original = 'model = "native"\n'; +const injected = `# Auto-injected by opencodex (undo: ocx restore)\nopenai_base_url = "http://127.0.0.1:${config.port}/v1"\n${original}`; +writeFileSync(CODEX_CONFIG_PATH, original); +writeJournal({ currentStateIsNative: true, owner: { kind: "client", apiKeyId: expected.apiKeyId } }); +writeFileSync(CODEX_CONFIG_PATH, injected); +writeFileSync(CODEX_PROFILE_PATH, 'model = "managed"\n'); +markJournalInjectedState(injected, 'model = "managed"\n', { + injectedOpenaiBaseUrl: `http://127.0.0.1:${config.port}/v1`, injectedRealtimeWsBaseUrl: null, injectedCatalogPath: DEFAULT_CATALOG_PATH, +}); +const nativeCatalog = { models: [{ slug: "fixture-native", visibility: "list" }] }; +writeFileSync(DEFAULT_CATALOG_PATH, JSON.stringify(nativeCatalog)); +ensureCatalogBackup(DEFAULT_CATALOG_PATH, nativeCatalog); +writeFileSync(DEFAULT_CATALOG_PATH, '{"models":[{"slug":"fixture/home-model"}]}'); +writeFileSync(join(home, "service-api-token"), token); +const grokHome = join(home, "grok"); +process.env.GROK_HOME = grokHome; +mkdirSync(grokHome, { recursive: true }); +const grokPath = join(grokHome, "config.toml"); +const grokNative = 'theme = "fixture"\n'; +writeFileSync(grokPath, grokNative + '\n# >>> opencodex managed block — do not edit (removed by `ocx stop`) >>>\n[model_providers.opencodex]\nbase_url = "http://127.0.0.1:10100/v1"\n# <<< opencodex managed block <<<\n'); +const mutate = () => { + if (options.mutation === "catalog") { config.client!.catalogFingerprint = "synced"; config.client!.catalogSyncedAt = "2026-10-31T00:00:00.000Z"; saveConfig(config); } + if (options.mutation === "prior-catalog") { config.client!.priorCatalog = ""; saveConfig(config); } + if (options.mutation === "api-key") { config.client!.apiKeyId = "other-client"; saveConfig(config); } + if (options.mutation === "token") { config.client!.tokenFingerprint = serviceApiTokenFingerprint("different-token"); saveConfig(config); } + if (options.mutation === "management") { config.client!.managementUrl = "https://other.example.test"; saveConfig(config); } + if (options.mutation === "link-id") { config.client!.link!.linkId = "lnk_fedcba9876543210"; saveConfig(config); } + if (options.mutation === "connection") { config.client!.connectedAt = "2026-10-02T00:00:00.000Z"; saveConfig(config); } + if (options.mutation === "selection") { config.client!.selectedClients = ["claude"]; saveConfig(config); } + if (options.mutation === "hub") { + delete config.client!.transport; delete config.client!.link; + config.client!.serverUrl = config.client!.managementUrl = "https://hub.example.test"; + saveConfig(config); + } + if (options.mutation === "rotation") { + config.client!.pendingOperation = { kind: "rotate", rotationId: "fixture-rotation", newKeyIssuedAt: "2026-10-02T00:00:00.000Z", oldKeyBackupPath: join(home, "token.prev") }; + saveConfig(config); + } + if (options.mutation === "journal" || options.mutation === "process-journal" || options.mutation === "unverified") { + const journal = JSON.parse(readFileSync(JOURNAL_PATH, "utf8")); + if (options.mutation === "unverified") delete journal.injectedConfigHash; + else journal.owner = options.mutation === "journal" ? { kind: "client", apiKeyId: "another-client" } : { kind: "process", pid: process.pid }; + writeFileSync(JOURNAL_PATH, JSON.stringify(journal)); + } + if (options.mutation === "config") writeFileSync(CODEX_CONFIG_PATH, injected + 'approval_policy = "on-request"\n'); + if (options.mutation === "profile") writeFileSync(CODEX_PROFILE_PATH, 'model = "operator-edit"\n'); + if (options.mutation === "oversized" || options.mutation === "nonregular") { + unlinkSync(JOURNAL_PATH); + if (options.mutation === "oversized") writeFileSync(CODEX_CONFIG_PATH, " ".repeat(1024 * 1024 + 1)); + else { unlinkSync(CODEX_CONFIG_PATH); mkdirSync(CODEX_CONFIG_PATH); } + } +}; +if (options.mode !== "runtime" && options.mode !== "guard") mutate(); +const protectedPaths = [join(home, "config.json"), join(home, "service-api-token")]; +let protectedBefore = protectedPaths.map(path => readFileSync(path, "utf8")); +const catalogBefore = readFileSync(DEFAULT_CATALOG_PATH, "utf8"); +const grokBefore = readFileSync(grokPath, "utf8"); +const configContents = () => statSync(CODEX_CONFIG_PATH).isFile() ? readFileSync(CODEX_CONFIG_PATH, "utf8") : ""; +const before = configContents(); +const profileBefore = readFileSync(CODEX_PROFILE_PATH, "utf8"); +let syncEvidence: { changedFields: string[]; fullCasRefused: boolean; catalogWritten: boolean; injected: boolean; journalHomeBound: boolean } | undefined; +const snapshot = (result: unknown) => ({ + result, before, profileBefore, syncEvidence, after: configContents(), + profileAfter: existsSync(CODEX_PROFILE_PATH) ? readFileSync(CODEX_PROFILE_PATH, "utf8") : null, + journalRetained: existsSync(JOURNAL_PATH), + journalHomeBound: existsSync(JOURNAL_PATH) && typeof JSON.parse(readFileSync(JOURNAL_PATH, "utf8")).opencodexHome === "string", + protectedUnchanged: protectedPaths.every((path, index) => readFileSync(path, "utf8") === protectedBefore[index]), + catalogBefore, catalogAfter: JSON.parse(readFileSync(DEFAULT_CATALOG_PATH, "utf8")), + grokBefore, grokAfter: readFileSync(grokPath, "utf8"), +}); +const report = (result: unknown) => writeFileSync(join(home, "result.json"), JSON.stringify(snapshot(result))); +process.on("exit", () => writeFileSync(join(home, "exit.json"), JSON.stringify(snapshot(null)))); +if (options.preserve === "service") process.env.OCX_SERVICE = "1"; +if (options.preserve === "desktop") takeDesktopSupervisedMarker({ OCX_DESKTOP_SUPERVISED: "1" }); +if (options.preserve === "receipt") claimPendingTeardown({ hostname: "127.0.0.1", port: config.port }, "exact"); +if (options.preserve === "sibling") (await import("../../src/codex/sibling-start")).markSiblingStart(10100); +if (options.mode === "retry") { + const { ConfigMutationLockError } = await import("../../src/config"); + const { ClientSyncRuntimeError, markClientRuntimeStoppingWithRetry } = await import("../../src/client/sync-runtime"); + let attempts = 0; + const waits: number[] = []; + const success = await markClientRuntimeStoppingWithRetry({ pid: process.pid, port: config.port, attestationSecret: "unused-fixture-identity" }, { + mark: () => { + attempts += 1; + if ((options.retryError === "busy" || options.retryError === "locked") && attempts === 3) return; + if (options.retryError === "identity") throw new ClientSyncRuntimeError("client_runtime_changed"); + const cause = Object.assign(new Error("fixture acquisition failure"), { code: options.retryError === "permission" ? "EACCES" : options.retryError === "locked" ? "SQLITE_LOCKED" : "SQLITE_BUSY" }); + if (options.retryError === "unwrapped") throw cause; + throw new ConfigMutationLockError("fixture acquisition failure", { cause }); + }, + sleep: async ms => { waits.push(ms); }, + }); + report({ success, attempts, waits }); +} else if (options.mode === "guard") { + const { stopRestoreWriteGuard } = await import("../../src/client/restore-ownership"); + const guard = stopRestoreWriteGuard(expected); + const writes: string[] = []; + guard.run(() => writes.push("before-change")); + mutate(); + // A caller mutating its own snapshot cannot move an already-created guard's authority. + Object.assign(expected, config.client); + let refused = false; + try { guard.run(() => writes.push("after-change")); } catch { refused = true; } + protectedBefore = protectedPaths.map(path => readFileSync(path, "utf8")); + report({ writes, refused, boundary: guard.boundary() }); +} else if (options.mode === "runtime") { + if (options.holdShutdown) { + const diagnostics = await import("../../src/link/diagnostics"); + mock.module("../../src/link/diagnostics", () => ({ ...diagnostics, flushLinkDiagnostics: async () => { + writeFileSync(join(home, "shutdown-drained"), "ready"); + while (!existsSync(join(home, "shutdown-release"))) await Bun.sleep(10); + } })); + } + if (options.preserve?.startsWith("restart-rollback")) { + const replacement = await import("../../src/server/restart-replacement"); + mock.module("../../src/server/restart-replacement", () => ({ ...replacement, + spawnReplacementStart: async () => { throw Object.assign(new Error("fixture replacement refused"), { code: "not_ready" }); }, + })); + } + const { scheduleStandaloneRecycle, startClientRuntime } = await import("../../src/client/runtime"); + const { startMachineListener } = await import("../../src/client/machine-listener"); + let requestRestart: (() => Promise) | undefined; + await startClientRuntime({ block: false }, { restartSupervisor: () => options.preserve?.startsWith("restart-rollback") ? Promise.resolve(null) : new Promise(() => {}), startListener: (port, deps) => { + requestRestart = deps?.restart?.request; + const server = startMachineListener(port, deps); + const stop = server.stop.bind(server); + server.stop = ((close: boolean) => { report({ atListenerStop: true }); return stop(close); }) as typeof server.stop; + return server; + } }); + if (options.syncBeforeStop) { + const { syncConnectedClient } = await import("../../src/client/connect"); + const { assertClientConnectionUnchanged } = await import("../../src/client/state"); + const synced = await syncConnectedClient({}, { + fetchImpl: (async () => Response.json({ models: [{ slug: "fixture/synced" }] })) as typeof fetch, + now: () => new Date("2026-10-31T00:00:00.000Z"), + catalogCompatibility: { supportedEfforts: () => new Set(["low", "medium", "high"]) }, + linkHome: { fetchImpl: (async () => Response.json({}, { status: 404 })) as typeof fetch }, + }); + const keys = new Set([...Object.keys(expected), ...Object.keys(synced.connection)]); + const changedFields = [...keys].filter(key => !Bun.deepEquals(Reflect.get(expected, key), Reflect.get(synced.connection, key))).sort(); + let fullCasRefused = false; + try { assertClientConnectionUnchanged(expected); } catch { fullCasRefused = true; } + syncEvidence = { changedFields, fullCasRefused, catalogWritten: synced.catalogWritten, injected: synced.injected, + journalHomeBound: typeof JSON.parse(readFileSync(JOURNAL_PATH, "utf8")).opencodexHome === "string" }; + } + if (options.runtimeRecord) { + const path = join(home, "runtime-port.json"); + if (options.runtimeRecord === "absent") unlinkSync(path); + else if (options.runtimeRecord === "invalid") writeFileSync(path, "{"); + else { const record = JSON.parse(readFileSync(path, "utf8")); record.pid += 100_000; writeFileSync(path, JSON.stringify(record)); } + } + mutate(); + protectedBefore = protectedPaths.map(path => readFileSync(path, "utf8")); + if (options.preserve === "restart") { + if (!requestRestart) throw new Error("fixture missing real restart admission"); + void requestRestart(); + } + if (options.preserve?.startsWith("restart-rollback")) { + if (!requestRestart) throw new Error("fixture missing real restart admission"); + await requestRestart(); + } + if (options.preserve === "recycle-pending" || options.preserve === "restart-rollback-recycle") scheduleStandaloneRecycle(expected.tokenFingerprint); + // Exercises the registered production handler; it is not evidence of native OS signal delivery. + if (options.externalSignal) { + writeFileSync(join(home, "ready"), "ready"); + while (!existsSync(join(home, "signal-now"))) await Bun.sleep(10); + process.emit(options.signal ?? "SIGTERM", options.signal ?? "SIGTERM"); + } else if (options.osSignal) writeFileSync(join(home, "ready"), "ready"); + else process.emit(options.signal ?? "SIGTERM", options.signal ?? "SIGTERM"); +} else { + const result = options.mode === "api" + ? await (await import("../../src/server/stop-teardown")).performStopTeardown(new URL("http://127.0.0.1:10100/api/stop?deferSharedTeardown=1&teardownNonce=fixture"), { ownsReceipt: () => true }) + : options.mode === "parent" ? await (await import("../../src/cli/stop-restore")).restoreSharedClientStateAfterStop(() => {}) + // The direct helper fixture has no runtime, async sync or other writer. + : (await import("../../src/client/stop-routing")).restoreLinkClientRouting(expected, { syncFenced: true }); + const repeated = options.repeat ? (await import("../../src/client/stop-routing")).restoreLinkClientRouting(expected, { syncFenced: true }) : undefined; + report(repeated ? { first: result, repeated } : result); +} diff --git a/tests/fixtures/client-link-sync-runner.ts b/tests/fixtures/client-link-sync-runner.ts new file mode 100644 index 00000000000..b2a94d21830 --- /dev/null +++ b/tests/fixtures/client-link-sync-runner.ts @@ -0,0 +1,49 @@ +/** A separate CLI-like sync process; never initializes or overwrites the running fixture. */ +import { mock } from "bun:test"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +const options = JSON.parse(readFileSync(0, "utf8")) as { hold: "catalog" | "home" | "lock" | false; name: "held" | "fresh" }; +const home = process.env.OPENCODEX_HOME!; +if (options.hold === "lock") { + const { Database } = await import("bun:sqlite"); + const database = new Database(join(home, "config-mutation.sqlite")); + try { + database.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); + writeFileSync(join(home, "held-requested"), "ready"); + while (!existsSync(join(home, "catalog-release"))) await Bun.sleep(10); + database.exec("ROLLBACK"); + } finally { database.close(); } + writeFileSync(join(home, "held-sync-result.json"), JSON.stringify({ ok: true, lockHeld: true })); + process.exit(0); +} +const lifecycle = await import("../../src/client/lifecycle-lock"); +const lock = lifecycle.withClientLifecycleSync; +mock.module("../../src/client/lifecycle-lock", () => ({ ...lifecycle, + withClientLifecycleSync: (work: Parameters[0]) => lock(work, { lockPath: join(home, "lifecycle.sqlite") }), +})); +const { syncConnectedClient } = await import("../../src/client/connect"); +let fetched = false; +let result: unknown; +const pause = async (phase: "catalog" | "home") => { + if (options.hold !== phase) return; + writeFileSync(join(home, `${options.name}-requested`), "ready"); + while (!existsSync(join(home, "catalog-release"))) await Bun.sleep(10); +}; +try { + const synced = await syncConnectedClient({}, { + fetchImpl: (async () => { + fetched = true; + // A deterministic transport seam: test ordering, not a production network deadline. + await pause("catalog"); + return Response.json({ models: [{ slug: "fixture/late-sync" }] }); + }) as typeof fetch, + now: () => new Date("2026-10-31T01:00:00.000Z"), + catalogCompatibility: { supportedEfforts: () => new Set(["low", "medium", "high"]) }, + linkHome: { fetchImpl: (async () => { await pause("home"); return Response.json({}, { status: 404 }); }) as typeof fetch }, + }); + result = { ok: true, fetched, catalogWritten: synced.catalogWritten, injected: synced.injected }; +} catch (error) { + result = { ok: false, fetched, error: error instanceof Error ? error.message : "unknown" }; +} +writeFileSync(join(home, `${options.name}-sync-result.json`), JSON.stringify(result)); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index c2f191980c6..d3bc2e20c74 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -269,6 +269,7 @@ "claude-devin-output-order.test.ts": "claude-integration", "codex-config-preservation.test.ts": "codex-integration", "codex-credits.test.ts": "codex-integration", "codex-credits-settings.test.ts": "codex-integration", "codex-credits-probes.test.ts": "codex-integration", + "pool-monthly-window-retirement.test.ts": "codex-integration", "codex-credits-after-limit.test.ts": "codex-integration", "codex-pool-credit-policy.test.ts": "codex-integration", "codex-credit-retry-after.test.ts": "codex-integration", "codex-credit-scoped-retry.test.ts": "codex-integration", "codex-direct-credit-renewal.test.ts": "codex-integration", "codex-direct-renewal-revocation.test.ts": "codex-integration", @@ -1092,7 +1093,7 @@ "oauth-tos-warning.test.ts": "gui", "oauth-upsert-preserves-api-key.test.ts": "oauth", "ocx-launcher-runtime.test.ts": "cli", "ocx-launcher-fixture-trust.test.ts": "cli", "ocx-launcher-source.test.ts": "cli", "ocx-launcher-desktop-handoff.test.ts": "cli", "cli-path-diagnostics.test.ts": "cli", - "ocx-run.test.ts": "cli", "ollama-native-parser.test.ts": "providers/ollama", + "ocx-run.test.ts": "cli", "ollama-native-parser.test.ts": "providers/ollama", "ollama-native-cached-usage.test.ts": "providers/ollama", "ollama-native-reasoning-wire.test.ts": "providers/ollama", "ollama-native-scaling.test.ts": "providers/ollama", "ollama-native-structured-output.test.ts": "providers/ollama", "ollama-native-v4.test.ts": "providers/ollama", @@ -1104,7 +1105,7 @@ "openai-chat-bounded-tool-names.test.ts": "adapters/openai", "openai-chat-dangling-toolcalls.test.ts": "adapters/openai", "openai-chat-developer-position.test.ts": "adapters/openai", "openai-chat-eof.test.ts": "adapters/openai", - "openai-chat-hardening.test.ts": "adapters/openai", "openai-empty-assistant-history.test.ts": "adapters/openai", "openai-chat-image-normalization.test.ts": "adapters/openai", + "openai-chat-hardening.test.ts": "adapters/openai", "openai-empty-assistant-history.test.ts": "adapters/openai", "kimi-coding-output-include.test.ts": "providers", "openai-chat-image-normalization.test.ts": "adapters/openai", "openai-chat-inline-think-tags.test.ts": "adapters/openai", "openai-chat-invalid-tool-call-diagnostics.test.ts": "adapters/openai", "openai-chat-mistral-content.test.ts": "adapters/openai", @@ -1257,7 +1258,7 @@ "responses-chatgpt-web-replay-metadata.test.ts": "responses", "responses-code-mode-goal-helpers.test.ts": "responses", "responses-code-mode-mcp-direct.test.ts": "responses", "local-read-response-proof.test.ts": "server", "responses-code-mode-patch-compile.test.ts": "responses", "responses-code-mode-shell-compile.test.ts": "responses", - "responses-compact-handoff-admission.test.ts": "responses", "responses-compaction-override.test.ts": "responses", + "responses-compact-handoff-admission.test.ts": "responses", "responses-compaction-override.test.ts": "responses", "local-checkpoint-routing.test.ts": "responses", "responses-compaction-policy-identity.test.ts": "responses", "responses-compaction-http-isolation.test.ts": "responses", "responses-compaction-routing.test.ts": "responses", "native-compaction-gateway.test.ts": "responses", "native-compaction-gateway-policy.test.ts": "responses", "native-compaction-provenance.test.ts": "responses", "native-compaction-oauth.test.ts": "responses", "native-compaction-gateway-credentials.test.ts": "responses", "responses-compaction.test.ts": "responses", "responses-console-go-upload-retry.test.ts": "responses", @@ -1552,7 +1553,7 @@ "command-code-tool-text-prose-split.test.ts": "providers", "cli-effort-slug.test.ts": "cli", "grok-47-build-fast-metadata.test.ts": "providers/xai", "grok-47-fast-model.test.ts": "providers/xai", "grok-47-fast-model-wire.test.ts": "providers/xai", "client-link-connect.test.ts": "clients", - "client-link-relay.test.ts": "clients", "client-link-runtime.test.ts": "clients", + "client-link-relay.test.ts": "clients", "client-link-runtime.test.ts": "clients", "client-link-stop.test.ts": "clients", "link-routes.test.ts": "clients", "client-link-state.test.ts": "clients", "client-link-status.test.ts": "clients", "client-link-teardown.test.ts": "clients", "client-link-tunnel.test.ts": "clients", "link-ports.test.ts": "clients", diff --git a/tests/providers/kimi-coding-output-include.test.ts b/tests/providers/kimi-coding-output-include.test.ts new file mode 100644 index 00000000000..01bd69354d8 --- /dev/null +++ b/tests/providers/kimi-coding-output-include.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test"; +import { createResponsesPassthroughAdapter } from "../../src/adapters/openai-responses"; +import { parseRequest } from "../../src/responses/parser"; +import { createTranslatorBudget } from "../../src/lib/translator-budget"; +import type { OcxProviderConfig } from "../../src/types"; + +// #8075: Kimi Code's native Responses endpoint answers 400 for the output.encrypted_content +// include entry that Codex Desktop sends on every turn. +const provider: OcxProviderConfig = { + adapter: "openai-responses", baseUrl: "https://api.kimi.com/coding/v1", + authMode: "key", apiKey: "test-credential", modelSuffixBracketStrip: true, +}; +const input = [ + { type: "message", role: "user", content: "read the file" }, + { type: "function_call", call_id: "call_1", name: "read_file", arguments: "{}" }, + { type: "function_call_output", call_id: "call_1", output: "first line" }, +]; + +function wire(include: unknown, target: OcxProviderConfig = provider): Record { + const raw = { model: "k3.1-pro[256k]", input, stream: true, ...(include === undefined ? {} : { include }) }; + const snapshot = JSON.stringify(raw); + const budget = createTranslatorBudget(); + const request = createResponsesPassthroughAdapter(target) + .buildRequest(parseRequest(raw), { headers: new Headers(), translatorBudget: budget }); + request.releaseBodyObservation?.(); + budget.dispose(); + expect(JSON.stringify(raw)).toBe(snapshot); + return JSON.parse(request.body) as Record; +} + +describe("Kimi Code Responses include normalization (#8075)", () => { + test("removes only output.encrypted_content and keeps the other entries in order", () => { + const body = wire(["reasoning.encrypted_content", "output.encrypted_content", "message.output_text.logprobs"]); + expect(body.include).toEqual(["reasoning.encrypted_content", "message.output_text.logprobs"]); + expect(body.input).toEqual(input); + }); + + test("omits include when the rejected entry was the only one", () => { + expect(wire(["output.encrypted_content"])).not.toHaveProperty("include"); + }); + + test("removes every duplicate output.encrypted_content", () => { + expect(wire(["output.encrypted_content", "reasoning.encrypted_content", "output.encrypted_content"]).include) + .toEqual(["reasoning.encrypted_content"]); + }); + + test("an absent or already-supported include list is unchanged", () => { + expect(wire(undefined)).not.toHaveProperty("include"); + expect(wire(["reasoning.encrypted_content"]).include).toEqual(["reasoning.encrypted_content"]); + }); + + test.each([ + "https://api.kimi.com/v1", "https://api.kimi.com/coding/v2", + "https://api.kimi.com.evil.test/coding/v1", "https://kimi.example.test/coding/v1", + "https://api.moonshot.ai/v1", "http://api.kimi.com/coding/v1", "https://api.kimi.com:444/coding/v1", + ])("leaves other destinations untouched: %s", baseUrl => { + const include = ["reasoning.encrypted_content", "output.encrypted_content"]; + expect(wire(include, { ...provider, baseUrl }).include).toEqual(include); + }); +}); + diff --git a/tests/providers/ollama/ollama-native-cached-usage.test.ts b/tests/providers/ollama/ollama-native-cached-usage.test.ts new file mode 100644 index 00000000000..97053ce9b0b --- /dev/null +++ b/tests/providers/ollama/ollama-native-cached-usage.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test"; +import { createOllamaNativeAdapter } from "../../../src/adapters/ollama-native"; +import type { AdapterEvent } from "../../../src/types"; +import { createTestTranslatorBudget } from "../../helpers/translator-budget"; + +// #8101: the native usage parser dropped prompt_eval_cached_count, so cached input always read 0. +const adapter = () => createOllamaNativeAdapter({ adapter: "ollama-native", baseUrl: "http://127.0.0.1:11434", models: ["m"] }); +const ndjson = (frames: unknown[]) => new Response(frames.map(frame => JSON.stringify(frame)).join("\n") + "\n", + { headers: { "content-type": "application/x-ndjson" } }); +const final = (usage: Record) => ({ + model: "m", message: { role: "assistant", content: "hi" }, done: true, done_reason: "stop", ...usage, +}); + +async function streamedUsage(usage: Record) { + const events: AdapterEvent[] = []; + for await (const event of adapter().parseStream(ndjson([final(usage)]), createTestTranslatorBudget())) events.push(event); + const done = events.at(-1); + expect(done?.type).toBe("done"); + return (done as Extract).usage; +} + +describe("ollama-native cached prompt usage (#8101)", () => { + test("streamed final record maps the cached subset without changing the input total", async () => { + expect(await streamedUsage({ prompt_eval_count: 2000, prompt_eval_cached_count: 1500, eval_count: 100 })) + .toEqual({ inputTokens: 2000, outputTokens: 100, cachedInputTokens: 1500, cacheReadInputTokens: 1500 }); + }); + + test("buffered responses carry the same cached count", async () => { + const events = await adapter().parseResponse!(ndjson([final({ prompt_eval_count: 11, prompt_eval_cached_count: 8, eval_count: 18 })]), + createTestTranslatorBudget()); + expect(events.at(-1)).toMatchObject({ type: "done", usage: { inputTokens: 11, cachedInputTokens: 8, cacheReadInputTokens: 8 } }); + }); + + test("an explicit zero stays a known zero", async () => { + expect(await streamedUsage({ prompt_eval_count: 40, prompt_eval_cached_count: 0, eval_count: 2 })) + .toEqual({ inputTokens: 40, outputTokens: 2, cachedInputTokens: 0, cacheReadInputTokens: 0 }); + }); + + test.each([ + ["omitted", {}], + ["negative", { prompt_eval_cached_count: -1 }], + ["fractional", { prompt_eval_cached_count: 1.5 }], + ["non-numeric", { prompt_eval_cached_count: "8" }], + ["larger than the prompt", { prompt_eval_cached_count: 41 }], + ])("%s cached count reports no cache field", async (_label, extra) => { + expect(await streamedUsage({ prompt_eval_count: 40, eval_count: 2, ...extra })).toEqual({ inputTokens: 40, outputTokens: 2 }); + }); + + test("a cached count without a prompt count is ignored", async () => { + expect(await streamedUsage({ prompt_eval_cached_count: 5, eval_count: 2 })).toEqual({ inputTokens: 0, outputTokens: 2 }); + }); +}); + diff --git a/tests/responses/explicit-empty-tool-catalog-history.test.ts b/tests/responses/explicit-empty-tool-catalog-history.test.ts index efc8e17444d..d724d63f62f 100644 --- a/tests/responses/explicit-empty-tool-catalog-history.test.ts +++ b/tests/responses/explicit-empty-tool-catalog-history.test.ts @@ -21,6 +21,7 @@ const REFUSAL_CODE = "undeclared_tool_call"; const userInput = { role: "user", content: [{ type: "input_text", text: "fixture request" }] }; const tool = (name: string) => ({ type: "function", name, parameters: { type: "object", properties: {} } }); const additional = (names: string[]) => ({ type: "additional_tools", role: "developer", tools: names.map(tool) }); +const hostedToolSearch = { type: "additional_tools", role: "developer", tools: [{ type: "tool_search" }] }; let home: ReturnType; let releaseHome: (() => void) | undefined; @@ -162,6 +163,29 @@ describe("explicit empty catalog current-turn and continuation boundaries", () = } } + for (const lane of ["adapter", "runTurn"] as const) for (const wire of [undefined, "chat"] as const) { + for (const current of [false, true]) { + test(`${lane}, ${wire ?? "responses"}: ${current ? "current" : "replayed"} hosted tool_search ${current ? "authorizes" : "cannot authorize"} its translated call`, async () => { + const config = setup(lane); + const prior = await post(config, wire, false, { input: [hostedToolSearch, userInput] }); + expect(prior.status).toBe("completed"); + expect(dispatched[0]!.tools).toContain("tool_search"); + nextCall = "tool_search"; + const response = await post(config, wire, false, { + previous_response_id: prior.id, tools: [], input: [...(current ? [hostedToolSearch] : []), userInput], + }); + expect(dispatched[1]!.replayPrefix).toBeGreaterThan(0); + expect(dispatched[1]!.tools).toContain("tool_search"); + if (!current) assertRefusedAndUncached(response); + else { + expect(response.status).toBe("completed"); + expect(response.error).toBeUndefined(); + expect(JSON.stringify(response.output)).toContain("tool_search"); + } + }); + } + } + for (const lane of ["adapter", "runTurn"] as const) for (const wire of ["chat", "anthropic"] as const) { for (const stream of [false, true]) { const label = `${lane}, ${wire}, ${stream ? "SSE" : "JSON"}`; diff --git a/tests/responses/local-checkpoint-routing.test.ts b/tests/responses/local-checkpoint-routing.test.ts new file mode 100644 index 00000000000..0925e715d22 --- /dev/null +++ b/tests/responses/local-checkpoint-routing.test.ts @@ -0,0 +1,78 @@ +import { afterEach, expect, test } from "bun:test"; +import { getDefaultConfig } from "../../src/config"; +import { COMPACT_PROMPT } from "../../src/responses/compaction"; +import { applyCompactionRoutingOverride } from "../../src/server/responses/compaction-routing"; +import { handleResponses } from "../../src/server/responses"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; + +const meta = (trigger = "auto", implementation = "responses", request_kind = "compaction") => + JSON.stringify({ request_kind, compaction: { trigger, implementation } }); +const settings = () => ({ ...getDefaultConfig(), codexClientCompaction: true, + defaultProvider: "gateway", providers: { gateway: { adapter: "openai-responses" as const, + authMode: "key" as const, baseUrl: "https://gateway.example/v1", apiKey: "fixture-key" } }, + compactionRouting: { model: "gateway/cheap", triggers: ["auto", "manual"] as ("auto" | "manual")[] } }); +const body = () => ({ model: "gateway/normal", stream: false, + input: [{ type: "message", role: "user", content: [{ type: "input_text", text: COMPACT_PROMPT }] }] }); +const originalFetch = globalThis.fetch; +afterEach(() => { globalThis.fetch = originalFetch; }); + +test.each(["header", "body", "both"])("routes a local checkpoint using %s metadata", location => { + for (const trigger of ["auto", "manual"]) { + const input: Record = body(); + const headers = new Headers(); + if (location !== "body") headers.set("x-codex-turn-metadata", meta(trigger)); + if (location !== "header") input.client_metadata = { "x-codex-turn-metadata": meta(trigger) }; + expect(applyCompactionRoutingOverride(input, headers, settings())).toEqual({ sourceModel: "gateway/normal" }); + expect(input.model).toBe("gateway/cheap"); + } +}); + +test("rejects ordinary, missing, malformed, inconsistent and disabled cases", () => { + const cases: Array<[unknown, unknown, boolean, ("auto" | "manual")[]]> = [ + [meta("auto", "responses", "turn"), undefined, true, ["auto"]], + [undefined, undefined, true, ["auto"]], [meta("auto", "compact"), undefined, true, ["auto"]], + ["{", undefined, true, ["auto"]], [meta(), meta("manual"), true, ["auto", "manual"]], + [meta(), meta("auto", "compact"), true, ["auto"]], [meta(), undefined, true, ["manual"]], + [meta(), "{", true, ["auto"]], [meta(), 42, true, ["auto"]], + [meta(), undefined, false, ["auto"]], + ]; + for (const [header, client, enabled, triggers] of cases) { + const input: Record = body(); + if (client !== undefined) input.client_metadata = { "x-codex-turn-metadata": client }; + const config = settings(); config.codexClientCompaction = enabled; config.compactionRouting.triggers = triggers; + const headers = new Headers(); if (typeof header === "string") headers.set("x-codex-turn-metadata", header); + expect(applyCompactionRoutingOverride(input, headers, config)).toBeNull(); + expect(input.model).toBe("gateway/normal"); + } + const ordinary = body(); ordinary.input[0]!.content[0]!.text = "Ordinary request"; + expect(applyCompactionRoutingOverride(ordinary, new Headers({ "x-codex-turn-metadata": meta() }), settings())).toBeNull(); + const absent = settings(); delete (absent as { codexClientCompaction?: boolean }).codexClientCompaction; + expect(applyCompactionRoutingOverride(body(), new Headers({ "x-codex-turn-metadata": meta() }), absent)).toBeNull(); + expect(applyCompactionRoutingOverride(body(), new Headers({ "x-codex-turn-metadata": meta() }), settings(), { transport: "websocket" })).toBeNull(); + const frame = { ...body(), client_metadata: { "x-codex-turn-metadata": meta() } }; + expect(applyCompactionRoutingOverride(frame, new Headers(), settings(), { transport: "websocket" })).not.toBeNull(); +}); + +test("remote trigger remains accepted without local implementation metadata", () => { + const input = { ...body(), input: [{ type: "compaction_trigger" }] }; + expect(applyCompactionRoutingOverride(input, new Headers({ "x-codex-turn-metadata": JSON.stringify({ request_kind: "compaction", compaction: { trigger: "auto" } }) }), settings())).not.toBeNull(); +}); + +test("handler sends the checkpoint as a normal summary request to the target", async () => { + const release = acquireOwnedSpendHome(); + const calls: Record[] = []; + globalThis.fetch = (async (_url: unknown, init?: RequestInit) => { + calls.push(JSON.parse(String(init?.body))); + return Response.json({ id: "resp_fixture", status: "completed", model: "cheap", output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: "Summary" }] }], usage: { input_tokens: 10, output_tokens: 2, total_tokens: 12 } }); + }) as typeof fetch; + try { + const req = new Request("http://localhost/v1/responses", { method: "POST", headers: { "content-type": "application/json", "x-codex-turn-metadata": meta() }, body: JSON.stringify(body()) }); + const response = await handleResponses(req, settings(), { model: "", provider: "" }); + expect(response.status).toBe(200); + const result = await response.json() as { output: Array<{ type: string; content: Array<{ text: string }> }> }; + expect(calls[0]!.model).toBe("cheap"); + expect(JSON.stringify(calls[0]!.input)).not.toContain("compaction_trigger"); + expect(result.output[0]!.type).toBe("message"); + expect(result.output[0]!.content[0]!.text).toBe("Summary"); + } finally { release(); } +}); diff --git a/tests/server/inbound-body-admission.test.ts b/tests/server/inbound-body-admission.test.ts index ffe4096ec88..2d3739a59b1 100644 --- a/tests/server/inbound-body-admission.test.ts +++ b/tests/server/inbound-body-admission.test.ts @@ -591,7 +591,7 @@ describe("raised inbound body admission lifetime", () => { } for (const normalized of [false, true]) { - test(`retains measured JSON bytes with translator normalization ${normalized}`, async () => { + test(`retains normalized JSON bytes with translator budget ${normalized}`, async () => { const text = "1e20"; const req = admissionRequest("/v1/responses", text); const translator = normalized ? createTranslatorBudget() : undefined; @@ -602,7 +602,7 @@ describe("raised inbound body admission lifetime", () => { assert.equal(await readJsonRequestBody(req, translator, RAISED_BODY_LIMIT), 1e20); return new Response("held"); }, req); - const expected = normalized ? Buffer.byteLength(JSON.stringify(1e20)) : Buffer.byteLength(text); + const expected = Buffer.byteLength(JSON.stringify(1e20)); assert.deepEqual(testBudget.snapshot(), { reservedBytes: expected, activeLeases: 1, queuedRequests: 0 }); await response.text(); } finally { @@ -612,6 +612,29 @@ describe("raised inbound body admission lifetime", () => { }); } + for (const normalized of [false, true]) { + test(`measures a deeply nested valid JSON body with translator budget ${normalized}`, async () => { + // Deeper than the call stack allows a recursive serializer to walk, and well under the limit. + const depth = 500_000; + const text = "[".repeat(depth) + "]".repeat(depth); + const req = admissionRequest("/v1/responses", text); + const translator = normalized ? createTranslatorBudget() : undefined; + let response: Response | undefined; + try { + response = await admittedBodyWork(async () => { + assert.equal(Array.isArray(await readJsonRequestBody(req, translator, RAISED_BODY_LIMIT)), true); + return new Response("held"); + }, req); + assert.equal(response.status, 200); + assert.deepEqual(testBudget.snapshot(), { reservedBytes: text.length, activeLeases: 1, queuedRequests: 0 }); + await response.text(); + } finally { + await response?.body?.cancel().catch(() => undefined); + translator?.dispose(); + } + }); + } + for (const end of ["eof", "error", "cancel"] as const) { test(`retains decoded compressed bytes until response ${end} settles exactly once`, async () => { const text = JSON.stringify({ input: "x".repeat(10_000) }); diff --git a/tests/update/update-teardown-handover.test.ts b/tests/update/update-teardown-handover.test.ts index 688fc588724..a0216e46ac1 100644 --- a/tests/update/update-teardown-handover.test.ts +++ b/tests/update/update-teardown-handover.test.ts @@ -229,6 +229,22 @@ describe("ocx stop under an update handover", () => { expect(receiptNames()).toEqual([]); }); + test("a manager-stopped Link Child cannot leave a standalone update-handover receipt", () => { + const serverUrl = "http://127.0.0.1:10200"; + writeFileSync(join(home, "config.json"), JSON.stringify({ runtimeRole: "client", providers: {}, defaultProvider: "openai", client: { + serverUrl, managementUrl: serverUrl, managementTransport: "direct", transport: "link", + link: { tunnelPort: 10200, linkId: "lnk_0123456789abcdef" }, selectedClients: ["codex"], + tokenEnv: "OPENCODEX_API_AUTH_TOKEN", apiKeyId: "fixture-client", tokenFingerprint: "a".repeat(64), + protocolVersion: 1, connectedAt: "2026-10-01T00:00:00.000Z", + } })); + const run = runStop(true, { managerCascades: true, deadPidReadsNull: true }); + expect(run.exitCode, run.stderr).toBe(0); + expect(run.calls).toMatchObject({ native: 1, grok: 1, service: 1 }); + expect(run.receiptExists).toBe(false); + expect(receiptNames()).toEqual([]); + expect(JSON.parse(run.stdout)).toMatchObject({ sharedTeardown: "restored", exitCode: 0 }); + }); + test("without a runtime record the stop restores instead of handing over a guessed receipt", () => { const run = runStop(true, { noRuntimeRecord: true, nativeSignal: true }); expect(run.exitCode, run.stderr).toBe(0); @@ -376,6 +392,11 @@ describe("dashboard update job", () => { } }), resolvePnpmActiveLauncherFn: () => "/pnpm/owner/global/v11/node_modules/@bitkyc08/opencodex/bin/ocx.mjs", integrityFn: () => ({ ok: true as const, integrity: "sha512-testfixturevalue000000000" }), + serviceHomeAuthorityFn: () => ({ kind: "absent" }), + launcherSupportsUpdateHomeFn: () => true, + // This fixture checks the update child's environment, not a real Windows tray process. + trayHandoffFn: () => ({ installed: false, running: false, stopBeforeReplacement: false, + refreshAfterReplacement: false, restoreOnFailure: false, installArgs: ["tray", "install", "--no-start"] }), // Only the environment handed to the update child matters here; failing the command // ends the job before any restart work, which other suites already cover. runCommandFn: (_job, _bin, _args, _timeout, env) => { updateEnv = env; return { status: 1, signal: null }; }, diff --git a/tests/usage/request-decompress.test.ts b/tests/usage/request-decompress.test.ts index e6c0fcfc565..676dd05b718 100644 --- a/tests/usage/request-decompress.test.ts +++ b/tests/usage/request-decompress.test.ts @@ -16,6 +16,7 @@ import { import { MANAGEMENT_JSON_BODY_MAX_BYTES } from "../../src/server/management/body"; import { handleManagementAPI } from "../../src/server/management-api"; import { decodeRequestErrorResponse } from "../../src/server/responses/core"; +import { serializedJsonByteLength } from "../../src/server/json-serialized-bytes"; import type { OcxConfig } from "../../src/types"; const PAYLOAD = { model: "gpt-5.5", input: "hello", stream: true }; @@ -624,3 +625,33 @@ describe("readJsonRequestBody", () => { await expect(readJsonRequestBody(req)).rejects.toBeInstanceOf(SyntaxError); }); }); + +describe("serializedJsonByteLength", () => { + test("matches the UTF-8 length of JSON.stringify for parsed JSON", () => { + const sources = [ + "null", "true", "false", "0", "-0", "1e20", "1e400", "-1e400", "0.1", "1.5e-7", "123456789012345678901234567890", + "\"\"", "\"plain\"", "\"\\ud800\"", "\"\\udc00x\\ud83d\\ude00\"", "\"\\u0000\\u001f\\u007f\"", "\"é漢字\\\"\\\\\\n\"", + "[]", "{}", "[[]]", "[{}]", "{\"a\":{}}", "[[],[[]],{}]", "[1,\"x\",null,true,[2,3],{\"k\":[]}]", + "{\"__proto__\":{\"x\":1},\"constructor\":[1]}", "{\"a\":1,\"a\":2}", "{\"2\":1,\"1\":2,\"b\":3,\"a\":4}", + "{\"\\ud800\":\"\\udfff\",\"é\":\"漢\"}", JSON.stringify({ input: [{ role: "user", content: "x".repeat(1000) }] }), + ]; + for (const source of sources) { + const parsed: unknown = JSON.parse(source); + expect([source, serializedJsonByteLength(parsed)]).toEqual([source, Buffer.byteLength(JSON.stringify(parsed), "utf8")]); + } + }); + + test("measures nesting deeper than a recursive serializer can walk", () => { + const depth = 500_000; + const parsed: unknown = JSON.parse("[".repeat(depth) + "]".repeat(depth)); + expect(() => JSON.stringify(parsed)).toThrow(RangeError); + expect(serializedJsonByteLength(parsed)).toBe(2 * depth); + }); + + test("measures wide and mixed containers exactly", () => { + const wide = JSON.parse("[" + Array(1_000_000).fill("0").join(",") + "]"); + expect(serializedJsonByteLength(wide)).toBe(Buffer.byteLength(JSON.stringify(wide), "utf8")); + const mixed = JSON.parse(JSON.stringify({ a: Array.from({ length: 50_000 }, (_, i) => ({ [`k${i}`]: [i, "é", null] })) })); + expect(serializedJsonByteLength(mixed)).toBe(Buffer.byteLength(JSON.stringify(mixed), "utf8")); + }); +});