From 300a73ab0e1e7c5a0fcef79f32378eb0fecab8be Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:52:15 +0900 Subject: [PATCH 01/84] chore(release): open dev at 2.95.0 before releasing 2.94.0 (#7934) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/tauri.conf.json | 2 +- package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index ed14eef8e8a..ff520a88d25 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2645,7 +2645,7 @@ dependencies = [ [[package]] name = "opencodex-desktop" -version = "2.94.0" +version = "2.95.0" dependencies = [ "base64 0.22.1", "dbus", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 322c1d8f7a4..9407ec769d7 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "opencodex-desktop" -version = "2.94.0" +version = "2.95.0" description = "OpenCodex desktop shell" authors = ["OpenCodex contributors"] license = "MIT" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index a2dad4dcdde..d5c8dfbe104 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenCodex", - "version": "2.94.0", + "version": "2.95.0", "identifier": "com.opencodex.desktop", "build": { "frontendDist": "../ui", diff --git a/package.json b/package.json index 6fe62b4200b..587d24507bc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.94.0", + "version": "2.95.0", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", From 24d176efdfe0975a0eb55fc01fd515e2c3b68b7e Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 29 Oct 2026 12:41:37 +0900 Subject: [PATCH 02/84] feat(gui): show distinct first-response states on the provider overview (#7939) * feat(gui): show distinct first-response states on the provider overview Distinguish provider configuration saved, model discovery and selection readiness, client routing application in Connect, and completion probe verification on the provider overview instead of labeling every configured provider Connected. Select newly added providers in the workspace when none is open, show the raw provider config ID next to the display name when they differ, and join per-provider discovery status into the revision-bound model snapshot. * fix(gui): align overview Status row with effective OAuth state and expand guidance tests * fix(gui): prefer settled model snapshot over stale pending config and preserve probe state on cancel --- gui/src/components/ProviderModelsNotice.tsx | 39 +- .../ProviderCompletionTest.tsx | 67 +- .../provider-workspace/ProviderDetails.tsx | 15 +- .../provider-workspace/ProviderOverview.tsx | 204 +++++- .../ProviderWorkspaceShell.tsx | 27 +- .../provider-completion-test.css | 25 + gui/src/i18n/de.ts | 26 +- gui/src/i18n/en.ts | 26 +- gui/src/i18n/fr.ts | 26 +- gui/src/i18n/ja.ts | 26 +- gui/src/i18n/ko.ts | 26 +- gui/src/i18n/pt.ts | 26 +- gui/src/i18n/ru.ts | 26 +- gui/src/i18n/tr.ts | 26 +- gui/src/i18n/vi.ts | 26 +- gui/src/i18n/zh-TW.ts | 26 +- gui/src/i18n/zh.ts | 26 +- gui/src/pages/Providers.tsx | 13 +- gui/src/pages/providers-shared.ts | 1 + gui/src/provider-workspace/catalog.ts | 3 + gui/src/provider-workspace/model-inventory.ts | 36 + .../provider-first-response-guidance.test.tsx | 670 ++++++++++++++++++ 22 files changed, 1343 insertions(+), 43 deletions(-) create mode 100644 gui/tests/provider-first-response-guidance.test.tsx diff --git a/gui/src/components/ProviderModelsNotice.tsx b/gui/src/components/ProviderModelsNotice.tsx index c19844e84cb..562f227443f 100644 --- a/gui/src/components/ProviderModelsNotice.tsx +++ b/gui/src/components/ProviderModelsNotice.tsx @@ -14,6 +14,43 @@ export interface ProviderModelsNoticeProps { onRetry?: () => void; } +const FOCUS_FALLBACK_REGIONS = [".pws-detail-panel", ".pws-detail", ".pws-main", ".page-head"] as const; +const FOCUSABLE_SELECTOR = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +function isUsableFocusTarget(element: Element | null): element is HTMLElement { + if (!element || !element.isConnected || typeof (element as HTMLElement).focus !== "function") return false; + if (element === document.body || element === document.documentElement) return false; + if ((element as HTMLButtonElement).disabled === true) return false; + for (let cur: Element | null = element; cur && cur !== document.documentElement; cur = cur.parentElement) { + const htmlCur = cur as HTMLElement; + if (htmlCur.hidden || cur.hasAttribute("inert") || cur.getAttribute("aria-hidden") === "true") return false; + if (htmlCur.style?.display === "none" || htmlCur.style?.visibility === "hidden" || htmlCur.style?.visibility === "collapse") { + return false; + } + if (typeof window !== "undefined" && typeof window.getComputedStyle === "function") { + const cs = window.getComputedStyle(cur); + if (cs.display === "none" || cs.visibility === "hidden" || cs.visibility === "collapse") return false; + } + } + return true; +} + +function restoreOrFallbackFocus(previous: HTMLElement | null): void { + if (isUsableFocusTarget(previous)) { + previous.focus(); + if (document.activeElement === previous) return; + } + for (const regionSelector of FOCUS_FALLBACK_REGIONS) { + for (const region of document.querySelectorAll(regionSelector)) { + for (const candidate of region.querySelectorAll(FOCUSABLE_SELECTOR)) { + if (!isUsableFocusTarget(candidate)) continue; + candidate.focus(); + if (document.activeElement === candidate) return; + } + } + } +} + export default function ProviderModelsNotice(props: ProviderModelsNoticeProps) { const t = useT(); const titleId = useId(); @@ -22,7 +59,7 @@ export default function ProviderModelsNotice(props: ProviderModelsNoticeProps) { useEffect(() => { const previous = document.activeElement as HTMLElement | null; primary.current?.focus(); - return () => { if (previous?.isConnected && typeof previous.focus === "function") previous.focus(); }; + return () => { restoreOrFallbackFocus(previous); }; }, []); const pending = props.selection?.status === "pending"; const unavailable = props.failed || !props.providerKnown; diff --git a/gui/src/components/provider-workspace/ProviderCompletionTest.tsx b/gui/src/components/provider-workspace/ProviderCompletionTest.tsx index 63a19421bdb..895918639cc 100644 --- a/gui/src/components/provider-workspace/ProviderCompletionTest.tsx +++ b/gui/src/components/provider-workspace/ProviderCompletionTest.tsx @@ -18,43 +18,75 @@ export type CompletionTestResult = termination: "completed" | "failed" | "timeout" | "aborted" | "too_large"; error: { code: string; status?: number; message: string } | null }; -type Props = { apiBase: string; provider: string; models: string[] }; +export type CompletionProbeStatus = "untested" | "testing" | "ok" | "failed" | "not-applicable"; -export default function ProviderCompletionTest({ apiBase, provider, models }: Props) { +type Props = { + apiBase: string; + provider: string; + models: string[]; + onStatusChange?: (state: { status: CompletionProbeStatus; model: string }) => void; +}; + +export default function ProviderCompletionTest({ apiBase, provider, models, onStatusChange }: Props) { const t = useT(); const [model, setModel] = useState(""); + // Permanently clear a selected model that was removed from the list so re-adding it cannot + // resurrect an older selection. + if (model !== "" && !models.includes(model)) { + setModel(""); + } const selectedModel = models.includes(model) ? model : ""; return (

{t("pws.completion.title")}

onChange(setNewModelPolicy(visibility, models.map(model => model.route), mode))} + /> + {mode === "exclude" ? t("claudeDesktop.visibility.newShow") : t("claudeDesktop.visibility.newHide")} + + ))} + + +
+ { setQuery(event.target.value); setLimit(LANE_PAGE); }} + /> + + + +
+ + {counts.selected === 0 && counts.available > 0 && {t("claudeDesktop.visibility.noneSelected")}} + + {shown.length === 0 ? ( +

{t("claudeDesktop.visibility.noMatch")}

+ ) : ( +
    + {shown.slice(0, limit).map(model => { + const visible = routeVisible(visibility, model.route); + return ( +
  • + onChange(setRoutesVisible(visibility, [model.route], event.target.checked))} + /> + + {model.label} + {model.label !== model.route && {model.route}} + + {providerOf(model.route)} + {badges(model)} +
  • + ); + })} +
+ )} + {shown.length > limit && ( + + )} + + {preview && ( +
+ {t("claudeDesktop.visibility.previewTitle")} + {preview.added.length === 0 && preview.removed.length === 0 + ? {t("claudeDesktop.visibility.previewSame")} + : ( + <> + {preview.added.length > 0 && {t("claudeDesktop.visibility.previewAdds", { models: previewText(preview.added) })}} + {preview.removed.length > 0 && {t("claudeDesktop.visibility.previewRemoves", { models: previewText(preview.removed) })}} + + )} +
+ )} +

{t("claudeDesktop.visibility.replaceNote")}

+ + ); +} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index fcb530ef493..55be0a6b565 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -3462,7 +3462,7 @@ export const de: Record = { "claudeDesktop.importFailed": "Import fehlgeschlagen. {error}", "claudeDesktop.moved": "{route} wurde nach {family} verschoben.", "claudeDesktop.unsaved": "Ungespeicherte Änderungen", - "claudeDesktop.upToDate": "Profil ist aktuell", + "claudeDesktop.upToDate": "Alle Änderungen gespeichert", "claudeDesktop.saving": "Speichert…", "claudeDesktop.applying": "Wird angewendet…", "claudeDesktop.saveApply": "Speichern & anwenden", @@ -3516,7 +3516,29 @@ export const de: Record = { "claudeDesktop.roles.unavailableOption": "{route} (nicht verfügbar)", "claudeDesktop.roles.gatewayScope": "Diese Auswahl gilt für den Gateway-Modus, der die Modellliste von Claude Desktop schreibt.", "claudeDesktop.roles.listTitle": "Desktop-Modellliste", - "claudeDesktop.roles.listHint": "Wird automatisch mit Modellen deiner verbundenen Anbieter gefüllt.", + "claudeDesktop.roles.listHint": "Wähle, welche Modelle Claude Desktop auflistet. Nur Claude Desktop ändert sich: andere Clients und Anbietereinstellungen behalten ihre Modelle.", + "claudeDesktop.status.pendingApply": "Gespeichert — anwenden, um Desktop zu aktualisieren", + "claudeDesktop.visibility.search": "Modelle suchen", + "claudeDesktop.visibility.provider": "Anbieter", + "claudeDesktop.visibility.allProviders": "Alle Anbieter", + "claudeDesktop.visibility.selectShown": "Alle Treffer auswählen", + "claudeDesktop.visibility.clearShown": "Alle passenden Modelle abwählen", + "claudeDesktop.visibility.toggle": "{model} in Claude Desktop anzeigen", + "claudeDesktop.visibility.selected": "{n} ausgewählt", + "claudeDesktop.visibility.available": "{n} verfügbar", + "claudeDesktop.visibility.applied": "{n} angewendet", + "claudeDesktop.visibility.appliedUnknown": "Angewendete Liste unbekannt", + "claudeDesktop.visibility.selectedUnavailable": "{n} ausgewählt, aber nicht verfügbar", + "claudeDesktop.visibility.newModels": "Neu erkannte Modelle", + "claudeDesktop.visibility.newShow": "Automatisch auflisten", + "claudeDesktop.visibility.newHide": "Ausgeblendet lassen, bis ausgewählt", + "claudeDesktop.visibility.noneSelected": "Wähle mindestens ein verfügbares Modell.", + "claudeDesktop.visibility.noMatch": "Keine Modelle passen zu diesem Filter.", + "claudeDesktop.visibility.previewTitle": "Nächstes Anwenden", + "claudeDesktop.visibility.previewAdds": "Fügt hinzu: {models}", + "claudeDesktop.visibility.previewRemoves": "Entfernt: {models}", + "claudeDesktop.visibility.previewSame": "Das Profil enthält dieselben Modell-IDs. Wenden Sie Profiländerungen an, bevor Sie Desktop neu starten.", + "claudeDesktop.visibility.replaceNote": "Anwenden ersetzt die Modellliste im Claude-Desktop-Profil von OpenCodex, auch eine dort von Hand bearbeitete Liste. Die Stufenzuordnung unter Erweitert bleibt optional. Der reine Discovery-Modus nutzt diese Auswahl nicht.", "claudeDesktop.roles.quickBadge": "Schnelle Aufgaben", "claudeDesktop.advanced.title": "Erweitert: Claude-Stufen zuweisen", "claudeDesktop.advanced.desc": "Wähle, welcher Claude-Stufe (Opus, Fable, Sonnet, Haiku) Claude Desktop jedes Modell zuordnet.", @@ -3531,7 +3553,7 @@ export const de: Record = { "claudeDesktop.moveTo": "Verschieben nach", "claudeDesktop.move": "Verschieben", "claudeDesktop.status.applied": "Auf Desktop angewendet", - "claudeDesktop.status.stale": "Konfiguration veraltet — erneut anwenden", + "claudeDesktop.status.stale": "Desktop-Konfiguration ist nicht aktuell — erneut anwenden", "claudeDesktop.status.notApplied": "Nicht angewendet", "claudeDesktop.status.notActiveProfile": "Desktop nutzt ein anderes Profil — erneut anwenden", "claudeDesktop.status.disabled": "Die Claude-Desktop-Integration ist deaktiviert. Beende Desktop nach dem Aktivieren vollständig und öffne es erneut.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 5893b8b5453..354c88f0db8 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -3576,7 +3576,7 @@ export const en = { "claudeDesktop.importFailed": "Import failed. {error}", "claudeDesktop.moved": "{route} moved to {family}.", "claudeDesktop.unsaved": "Unsaved changes", - "claudeDesktop.upToDate": "Profile is up to date", + "claudeDesktop.upToDate": "All changes saved", "claudeDesktop.saving": "Saving…", "claudeDesktop.applying": "Applying…", "claudeDesktop.saveApply": "Save & apply", @@ -3630,7 +3630,29 @@ export const en = { "claudeDesktop.roles.unavailableOption": "{route} (unavailable)", "claudeDesktop.roles.gatewayScope": "These choices apply to gateway mode, which writes Claude Desktop's model list.", "claudeDesktop.roles.listTitle": "Desktop model list", - "claudeDesktop.roles.listHint": "Filled automatically from your connected providers.", + "claudeDesktop.roles.listHint": "Choose which models Claude Desktop lists. Only Claude Desktop changes: other clients and provider settings keep their models.", + "claudeDesktop.status.pendingApply": "Saved — apply to update Desktop", + "claudeDesktop.visibility.search": "Search models", + "claudeDesktop.visibility.provider": "Provider", + "claudeDesktop.visibility.allProviders": "All providers", + "claudeDesktop.visibility.selectShown": "Select all matching", + "claudeDesktop.visibility.clearShown": "Deselect all matching models", + "claudeDesktop.visibility.toggle": "Show {model} in Claude Desktop", + "claudeDesktop.visibility.selected": "{n} selected", + "claudeDesktop.visibility.available": "{n} available", + "claudeDesktop.visibility.applied": "{n} applied", + "claudeDesktop.visibility.appliedUnknown": "Applied list unknown", + "claudeDesktop.visibility.selectedUnavailable": "{n} selected but unavailable", + "claudeDesktop.visibility.newModels": "Newly discovered models", + "claudeDesktop.visibility.newShow": "List automatically", + "claudeDesktop.visibility.newHide": "Keep hidden until selected", + "claudeDesktop.visibility.noneSelected": "Select at least one available model.", + "claudeDesktop.visibility.noMatch": "No models match this filter.", + "claudeDesktop.visibility.previewTitle": "Next apply", + "claudeDesktop.visibility.previewAdds": "Adds: {models}", + "claudeDesktop.visibility.previewRemoves": "Removes: {models}", + "claudeDesktop.visibility.previewSame": "The profile contains the same model IDs. Apply profile changes before restarting Desktop.", + "claudeDesktop.visibility.replaceNote": "Applying replaces the model list in OpenCodex's Claude Desktop profile, including any list edited there by hand. Tier mapping under Advanced stays optional. Discovery-only mode does not use this selection.", "claudeDesktop.roles.quickBadge": "Quick tasks", "claudeDesktop.advanced.title": "Advanced: Claude tier assignment", "claudeDesktop.advanced.desc": "Choose which Claude tier (Opus, Fable, Sonnet, Haiku) Claude Desktop treats each model as.", @@ -3645,7 +3667,7 @@ export const en = { "claudeDesktop.moveTo": "Move to", "claudeDesktop.move": "Move", "claudeDesktop.status.applied": "Applied to Desktop", - "claudeDesktop.status.stale": "Config stale — re-apply", + "claudeDesktop.status.stale": "Desktop configuration is out of date — re-apply", "claudeDesktop.status.notApplied": "Not applied", "claudeDesktop.status.notActiveProfile": "Desktop is serving another profile — re-apply", "claudeDesktop.status.disabled": "Claude Desktop integration is off. Fully quit and reopen Desktop after enabling it.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index c1f5f443fb6..39f52282f94 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -3489,7 +3489,7 @@ export const fr: Record = { "claudeDesktop.importFailed": "Échec de l’importation. {error}", "claudeDesktop.moved": "{route} déplacé vers {family}.", "claudeDesktop.unsaved": "Modifications non enregistrées", - "claudeDesktop.upToDate": "Le profil est à jour", + "claudeDesktop.upToDate": "Toutes les modifications sont enregistrées", "claudeDesktop.saving": "Enregistrement…", "claudeDesktop.applying": "Application…", "claudeDesktop.saveApply": "Enregistrer et appliquer", @@ -3543,7 +3543,29 @@ export const fr: Record = { "claudeDesktop.roles.unavailableOption": "{route} (indisponible)", "claudeDesktop.roles.gatewayScope": "Ces choix s’appliquent au mode passerelle, qui écrit la liste des modèles de Claude Desktop.", "claudeDesktop.roles.listTitle": "Liste des modèles Desktop", - "claudeDesktop.roles.listHint": "Remplie automatiquement à partir de vos fournisseurs connectés.", + "claudeDesktop.roles.listHint": "Choisissez les modèles listés par Claude Desktop. Seul Claude Desktop change : les autres clients et les réglages des fournisseurs gardent leurs modèles.", + "claudeDesktop.status.pendingApply": "Enregistré — appliquer pour mettre Desktop à jour", + "claudeDesktop.visibility.search": "Rechercher des modèles", + "claudeDesktop.visibility.provider": "Fournisseur", + "claudeDesktop.visibility.allProviders": "Tous les fournisseurs", + "claudeDesktop.visibility.selectShown": "Sélectionner tous les résultats", + "claudeDesktop.visibility.clearShown": "Désélectionner tous les modèles correspondants", + "claudeDesktop.visibility.toggle": "Afficher {model} dans Claude Desktop", + "claudeDesktop.visibility.selected": "{n} sélectionnés", + "claudeDesktop.visibility.available": "{n} disponibles", + "claudeDesktop.visibility.applied": "{n} appliqués", + "claudeDesktop.visibility.appliedUnknown": "Liste appliquée inconnue", + "claudeDesktop.visibility.selectedUnavailable": "{n} sélectionnés mais indisponibles", + "claudeDesktop.visibility.newModels": "Modèles découverts ensuite", + "claudeDesktop.visibility.newShow": "Lister automatiquement", + "claudeDesktop.visibility.newHide": "Masquer jusqu'à sélection", + "claudeDesktop.visibility.noneSelected": "Sélectionnez au moins un modèle disponible.", + "claudeDesktop.visibility.noMatch": "Aucun modèle ne correspond à ce filtre.", + "claudeDesktop.visibility.previewTitle": "Prochaine application", + "claudeDesktop.visibility.previewAdds": "Ajoute : {models}", + "claudeDesktop.visibility.previewRemoves": "Retire : {models}", + "claudeDesktop.visibility.previewSame": "Le profil contient les mêmes identifiants de modèles. Appliquez les modifications du profil avant de redémarrer Desktop.", + "claudeDesktop.visibility.replaceNote": "Appliquer remplace la liste de modèles du profil Claude Desktop d'OpenCodex, y compris une liste modifiée à la main. Le mappage des niveaux dans Avancé reste facultatif. Le mode découverte seule n'utilise pas cette sélection.", "claudeDesktop.roles.quickBadge": "Tâches rapides", "claudeDesktop.advanced.title": "Avancé : attribution des niveaux Claude", "claudeDesktop.advanced.desc": "Choisissez à quel niveau Claude (Opus, Fable, Sonnet, Haiku) Claude Desktop associe chaque modèle.", @@ -3558,7 +3580,7 @@ export const fr: Record = { "claudeDesktop.moveTo": "Déplacer vers", "claudeDesktop.move": "Déplacer", "claudeDesktop.status.applied": "Appliqué à Desktop", - "claudeDesktop.status.stale": "Configuration obsolète — appliquer de nouveau", + "claudeDesktop.status.stale": "La configuration de Desktop n’est pas à jour — appliquer de nouveau", "claudeDesktop.status.notApplied": "Non appliqué", "claudeDesktop.status.notActiveProfile": "Desktop utilise un autre profil — appliquer de nouveau", "claudeDesktop.status.disabled": "L’intégration Claude Desktop est désactivée. Quittez complètement Desktop et rouvrez-le après l’avoir activée.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 56d503b0656..5a97b67371f 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -3171,7 +3171,7 @@ export const ja: Record = { "claudeDesktop.importFailed": "インポートに失敗しました。{error}", "claudeDesktop.moved": "{route} を {family} に移動しました。", "claudeDesktop.unsaved": "未保存の変更", - "claudeDesktop.upToDate": "プロファイルは最新です", + "claudeDesktop.upToDate": "すべての変更を保存済み", "claudeDesktop.saving": "保存中…", "claudeDesktop.applying": "適用中…", "claudeDesktop.saveApply": "保存して適用", @@ -3225,7 +3225,29 @@ export const ja: Record = { "claudeDesktop.roles.unavailableOption": "{route}(利用不可)", "claudeDesktop.roles.gatewayScope": "これらの選択は、Claude Desktop のモデル一覧を書き込むゲートウェイモードに適用されます。", "claudeDesktop.roles.listTitle": "Desktop モデル一覧", - "claudeDesktop.roles.listHint": "接続済みのプロバイダーから自動的に追加されます。", + "claudeDesktop.roles.listHint": "Claude Desktop に表示するモデルを選びます。変わるのは Claude Desktop だけで、他のクライアントやプロバイダー設定のモデルはそのままです。", + "claudeDesktop.status.pendingApply": "保存済み — 適用すると Desktop に反映されます", + "claudeDesktop.visibility.search": "モデルを検索", + "claudeDesktop.visibility.provider": "プロバイダー", + "claudeDesktop.visibility.allProviders": "すべてのプロバイダー", + "claudeDesktop.visibility.selectShown": "一致するすべてを選択", + "claudeDesktop.visibility.clearShown": "一致するすべてのモデルの選択を解除", + "claudeDesktop.visibility.toggle": "{model} を Claude Desktop に表示", + "claudeDesktop.visibility.selected": "{n} 件選択", + "claudeDesktop.visibility.available": "{n} 件利用可能", + "claudeDesktop.visibility.applied": "{n} 件適用済み", + "claudeDesktop.visibility.appliedUnknown": "適用済みリスト不明", + "claudeDesktop.visibility.selectedUnavailable": "{n} 件選択済み・利用不可", + "claudeDesktop.visibility.newModels": "新しく見つかったモデル", + "claudeDesktop.visibility.newShow": "自動で表示", + "claudeDesktop.visibility.newHide": "選択するまで非表示", + "claudeDesktop.visibility.noneSelected": "利用可能なモデルを 1 つ以上選んでください。", + "claudeDesktop.visibility.noMatch": "このフィルターに一致するモデルはありません。", + "claudeDesktop.visibility.previewTitle": "次回の適用", + "claudeDesktop.visibility.previewAdds": "追加: {models}", + "claudeDesktop.visibility.previewRemoves": "削除: {models}", + "claudeDesktop.visibility.previewSame": "プロファイルには同じモデル ID が含まれています。Desktop を再起動する前にプロファイルの変更を適用してください。", + "claudeDesktop.visibility.replaceNote": "適用すると OpenCodex の Claude Desktop プロファイルのモデルリストが置き換わります(手動で編集したリストも含む)。詳細設定のティア割り当ては任意です。ディスカバリー専用モードではこの選択は使われません。", "claudeDesktop.roles.quickBadge": "クイックタスク", "claudeDesktop.advanced.title": "詳細設定:Claude ティアの割り当て", "claudeDesktop.advanced.desc": "Claude Desktop が各モデルをどの Claude ティア(Opus、Fable、Sonnet、Haiku)として扱うかを選択します。", @@ -3240,7 +3262,7 @@ export const ja: Record = { "claudeDesktop.moveTo": "移動先", "claudeDesktop.move": "移動", "claudeDesktop.status.applied": "Desktop に適用済み", - "claudeDesktop.status.stale": "設定が古くなっています — 再適用してください", + "claudeDesktop.status.stale": "Desktop の設定が最新ではありません — 再適用してください", "claudeDesktop.status.notApplied": "未適用", "claudeDesktop.status.notActiveProfile": "Desktop は別のプロファイルを使用中 — 再適用してください", "claudeDesktop.status.disabled": "Claude Desktop 連携はオフです。有効にした後、Desktop を完全に終了して再起動してください。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index d3132b2bf6f..09344d12b99 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -3509,7 +3509,7 @@ export const ko: Record = { "claudeDesktop.importFailed": "가져오기에 실패했습니다. {error}", "claudeDesktop.moved": "{route} 모델을 {family}(으)로 옮겼습니다.", "claudeDesktop.unsaved": "저장하지 않은 변경 사항", - "claudeDesktop.upToDate": "프로필이 최신 상태입니다", + "claudeDesktop.upToDate": "모든 변경 사항 저장됨", "claudeDesktop.saving": "저장 중…", "claudeDesktop.applying": "적용 중…", "claudeDesktop.saveApply": "저장 및 적용", @@ -3563,7 +3563,29 @@ export const ko: Record = { "claudeDesktop.roles.unavailableOption": "{route} (사용 불가)", "claudeDesktop.roles.gatewayScope": "이 선택은 Claude Desktop 모델 목록을 쓰는 게이트웨이 모드에 적용됩니다.", "claudeDesktop.roles.listTitle": "Desktop 모델 목록", - "claudeDesktop.roles.listHint": "연결한 프로바이더에서 자동으로 채워집니다.", + "claudeDesktop.roles.listHint": "Claude Desktop에 표시할 모델을 고릅니다. Claude Desktop만 바뀌며, 다른 클라이언트와 프로바이더 설정의 모델은 그대로입니다.", + "claudeDesktop.status.pendingApply": "저장됨 — 적용하면 Desktop에 반영됩니다", + "claudeDesktop.visibility.search": "모델 검색", + "claudeDesktop.visibility.provider": "프로바이더", + "claudeDesktop.visibility.allProviders": "모든 프로바이더", + "claudeDesktop.visibility.selectShown": "일치하는 모든 항목 선택", + "claudeDesktop.visibility.clearShown": "일치하는 모든 모델 선택 해제", + "claudeDesktop.visibility.toggle": "Claude Desktop에 {model} 표시", + "claudeDesktop.visibility.selected": "{n}개 선택", + "claudeDesktop.visibility.available": "{n}개 사용 가능", + "claudeDesktop.visibility.applied": "{n}개 적용됨", + "claudeDesktop.visibility.appliedUnknown": "적용된 목록 알 수 없음", + "claudeDesktop.visibility.selectedUnavailable": "{n}개 선택했지만 사용 불가", + "claudeDesktop.visibility.newModels": "새로 발견된 모델", + "claudeDesktop.visibility.newShow": "자동으로 표시", + "claudeDesktop.visibility.newHide": "선택할 때까지 숨김", + "claudeDesktop.visibility.noneSelected": "사용 가능한 모델을 하나 이상 선택하세요.", + "claudeDesktop.visibility.noMatch": "이 필터와 일치하는 모델이 없습니다.", + "claudeDesktop.visibility.previewTitle": "다음 적용", + "claudeDesktop.visibility.previewAdds": "추가: {models}", + "claudeDesktop.visibility.previewRemoves": "제거: {models}", + "claudeDesktop.visibility.previewSame": "프로필에 같은 모델 ID가 있습니다. Desktop을 다시 시작하기 전에 프로필 변경 사항을 적용하세요.", + "claudeDesktop.visibility.replaceNote": "적용하면 OpenCodex의 Claude Desktop 프로필 모델 목록이 교체되며, 직접 편집한 목록도 덮어씁니다. 고급의 티어 매핑은 선택 사항입니다. 디스커버리 전용 모드는 이 선택을 사용하지 않습니다.", "claudeDesktop.roles.quickBadge": "빠른 작업", "claudeDesktop.advanced.title": "고급: Claude 등급 배치", "claudeDesktop.advanced.desc": "Claude Desktop이 각 모델을 어느 Claude 등급(Opus, Fable, Sonnet, Haiku)으로 볼지 직접 정합니다.", @@ -3578,7 +3600,7 @@ export const ko: Record = { "claudeDesktop.moveTo": "이동 위치", "claudeDesktop.move": "이동", "claudeDesktop.status.applied": "Desktop에 적용됨", - "claudeDesktop.status.stale": "설정 변경됨 — 재적용 필요", + "claudeDesktop.status.stale": "Desktop 설정이 최신 상태가 아님 — 재적용 필요", "claudeDesktop.status.notApplied": "미적용", "claudeDesktop.status.notActiveProfile": "Desktop이 다른 프로필을 사용 중 — 재적용 필요", "claudeDesktop.status.disabled": "Claude Desktop 통합이 꺼져 있습니다. 켠 뒤 Desktop을 완전히 종료하고 다시 여세요.", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index 4c70a965a8e..9c49dddc902 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -3532,7 +3532,7 @@ export const pt: Record = { "claudeDesktop.importFailed": "Falha na importação. {error}", "claudeDesktop.moved": "{route} movido para {family}.", "claudeDesktop.unsaved": "Alterações não salvas", - "claudeDesktop.upToDate": "O perfil está atualizado", + "claudeDesktop.upToDate": "Todas as alterações salvas", "claudeDesktop.saving": "Salvando…", "claudeDesktop.applying": "Aplicando…", "claudeDesktop.saveApply": "Salvar e aplicar", @@ -3586,7 +3586,29 @@ export const pt: Record = { "claudeDesktop.roles.unavailableOption": "{route} (indisponível)", "claudeDesktop.roles.gatewayScope": "Essas escolhas se aplicam ao modo gateway, que grava a lista de modelos do Claude Desktop.", "claudeDesktop.roles.listTitle": "Lista de modelos Desktop", - "claudeDesktop.roles.listHint": "Preenchida automaticamente a partir dos seus provedores conectados.", + "claudeDesktop.roles.listHint": "Escolha quais modelos o Claude Desktop lista. Só o Claude Desktop muda: outros clientes e as configurações dos provedores mantêm seus modelos.", + "claudeDesktop.status.pendingApply": "Salvo — aplique para atualizar o Desktop", + "claudeDesktop.visibility.search": "Buscar modelos", + "claudeDesktop.visibility.provider": "Provedor", + "claudeDesktop.visibility.allProviders": "Todos os provedores", + "claudeDesktop.visibility.selectShown": "Selecionar todos os resultados", + "claudeDesktop.visibility.clearShown": "Desmarcar todos os modelos correspondentes", + "claudeDesktop.visibility.toggle": "Mostrar {model} no Claude Desktop", + "claudeDesktop.visibility.selected": "{n} selecionados", + "claudeDesktop.visibility.available": "{n} disponíveis", + "claudeDesktop.visibility.applied": "{n} aplicados", + "claudeDesktop.visibility.appliedUnknown": "Lista aplicada desconhecida", + "claudeDesktop.visibility.selectedUnavailable": "{n} selecionados, mas indisponíveis", + "claudeDesktop.visibility.newModels": "Modelos descobertos depois", + "claudeDesktop.visibility.newShow": "Listar automaticamente", + "claudeDesktop.visibility.newHide": "Manter ocultos até selecionar", + "claudeDesktop.visibility.noneSelected": "Selecione pelo menos um modelo disponível.", + "claudeDesktop.visibility.noMatch": "Nenhum modelo corresponde a este filtro.", + "claudeDesktop.visibility.previewTitle": "Próxima aplicação", + "claudeDesktop.visibility.previewAdds": "Adiciona: {models}", + "claudeDesktop.visibility.previewRemoves": "Remove: {models}", + "claudeDesktop.visibility.previewSame": "O perfil contém os mesmos IDs de modelos. Aplique as alterações do perfil antes de reiniciar o Desktop.", + "claudeDesktop.visibility.replaceNote": "Aplicar substitui a lista de modelos do perfil Claude Desktop do OpenCodex, inclusive uma lista editada à mão. O mapeamento de níveis em Avançado continua opcional. O modo somente descoberta não usa esta seleção.", "claudeDesktop.roles.quickBadge": "Tarefas rápidas", "claudeDesktop.advanced.title": "Avançado: atribuição de níveis Claude", "claudeDesktop.advanced.desc": "Escolha em qual nível Claude (Opus, Fable, Sonnet, Haiku) o Claude Desktop classifica cada modelo.", @@ -3601,7 +3623,7 @@ export const pt: Record = { "claudeDesktop.moveTo": "Mover para", "claudeDesktop.move": "Mover", "claudeDesktop.status.applied": "Aplicado ao Desktop", - "claudeDesktop.status.stale": "Configuração desatualizada — aplique novamente", + "claudeDesktop.status.stale": "A configuração do Desktop está desatualizada — aplique novamente", "claudeDesktop.status.notApplied": "Não aplicado", "claudeDesktop.status.notActiveProfile": "O Desktop está usando outro perfil — aplique novamente", "claudeDesktop.status.disabled": "A integração com o Claude Desktop está desativada. Feche completamente e reabra o Desktop após ativá-la.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 0994ff7d2ea..81b386cc191 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -3261,7 +3261,7 @@ export const ru: Record = { "claudeDesktop.importFailed": "Импорт не удался. {error}", "claudeDesktop.moved": "{route} перемещён в {family}.", "claudeDesktop.unsaved": "Несохранённые изменения", - "claudeDesktop.upToDate": "Профиль актуален", + "claudeDesktop.upToDate": "Все изменения сохранены", "claudeDesktop.saving": "Сохранение…", "claudeDesktop.applying": "Применение…", "claudeDesktop.saveApply": "Сохранить и применить", @@ -3315,7 +3315,29 @@ export const ru: Record = { "claudeDesktop.roles.unavailableOption": "{route} (недоступно)", "claudeDesktop.roles.gatewayScope": "Эти настройки применяются в режиме шлюза, который записывает список моделей Claude Desktop.", "claudeDesktop.roles.listTitle": "Список моделей Desktop", - "claudeDesktop.roles.listHint": "Автоматически заполняется моделями подключённых провайдеров.", + "claudeDesktop.roles.listHint": "Выберите, какие модели показывает Claude Desktop. Меняется только Claude Desktop: другие клиенты и настройки провайдеров сохраняют свои модели.", + "claudeDesktop.status.pendingApply": "Сохранено — примените, чтобы обновить Desktop", + "claudeDesktop.visibility.search": "Поиск моделей", + "claudeDesktop.visibility.provider": "Провайдер", + "claudeDesktop.visibility.allProviders": "Все провайдеры", + "claudeDesktop.visibility.selectShown": "Выбрать все совпадения", + "claudeDesktop.visibility.clearShown": "Снять выбор со всех подходящих моделей", + "claudeDesktop.visibility.toggle": "Показывать {model} в Claude Desktop", + "claudeDesktop.visibility.selected": "Выбрано: {n}", + "claudeDesktop.visibility.available": "Доступно: {n}", + "claudeDesktop.visibility.applied": "Применено: {n}", + "claudeDesktop.visibility.appliedUnknown": "Применённый список неизвестен", + "claudeDesktop.visibility.selectedUnavailable": "Выбрано, но недоступно: {n}", + "claudeDesktop.visibility.newModels": "Новые найденные модели", + "claudeDesktop.visibility.newShow": "Показывать автоматически", + "claudeDesktop.visibility.newHide": "Скрывать, пока не выбраны", + "claudeDesktop.visibility.noneSelected": "Выберите хотя бы одну доступную модель.", + "claudeDesktop.visibility.noMatch": "Нет моделей, подходящих под фильтр.", + "claudeDesktop.visibility.previewTitle": "Следующее применение", + "claudeDesktop.visibility.previewAdds": "Добавит: {models}", + "claudeDesktop.visibility.previewRemoves": "Уберёт: {models}", + "claudeDesktop.visibility.previewSame": "Профиль содержит те же ID моделей. Примените изменения профиля перед перезапуском Desktop.", + "claudeDesktop.visibility.replaceNote": "Применение заменяет список моделей в профиле Claude Desktop от OpenCodex, включая список, отредактированный вручную. Привязка уровней в разделе «Дополнительно» необязательна. Режим только обнаружения не использует этот выбор.", "claudeDesktop.roles.quickBadge": "Быстрые задачи", "claudeDesktop.advanced.title": "Дополнительно: назначение уровней Claude", "claudeDesktop.advanced.desc": "Выберите, к какому уровню Claude (Opus, Fable, Sonnet, Haiku) Claude Desktop относит каждую модель.", @@ -3330,7 +3352,7 @@ export const ru: Record = { "claudeDesktop.moveTo": "Переместить в", "claudeDesktop.move": "Переместить", "claudeDesktop.status.applied": "Применено к Desktop", - "claudeDesktop.status.stale": "Конфигурация устарела — примените заново", + "claudeDesktop.status.stale": "Конфигурация Desktop устарела — примените заново", "claudeDesktop.status.notApplied": "Не применено", "claudeDesktop.status.notActiveProfile": "Desktop использует другой профиль — примените заново", "claudeDesktop.status.disabled": "Интеграция Claude Desktop отключена. После включения полностью закройте и снова откройте Desktop.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 4a8bf247784..f54d723e48e 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -3512,7 +3512,7 @@ export const tr: Record = { "claudeDesktop.importFailed": "İçe aktarma başarısız oldu. {error}", "claudeDesktop.moved": "{route}, {family} ailesine taşındı.", "claudeDesktop.unsaved": "Kaydedilmemiş değişiklikler", - "claudeDesktop.upToDate": "Profil güncel", + "claudeDesktop.upToDate": "Tüm değişiklikler kaydedildi", "claudeDesktop.saving": "Kaydediliyor…", "claudeDesktop.applying": "Uygulanıyor…", "claudeDesktop.saveApply": "Kaydet & uygula", @@ -3566,7 +3566,29 @@ export const tr: Record = { "claudeDesktop.roles.unavailableOption": "{route} (kullanılamıyor)", "claudeDesktop.roles.gatewayScope": "Bu seçimler, Claude Desktop’ın model listesini yazan ağ geçidi modunda geçerlidir.", "claudeDesktop.roles.listTitle": "Desktop model listesi", - "claudeDesktop.roles.listHint": "Bağlı sağlayıcılarınızdaki modellerle otomatik olarak doldurulur.", + "claudeDesktop.roles.listHint": "Claude Desktop'un listeleyeceği modelleri seçin. Yalnızca Claude Desktop değişir: diğer istemciler ve sağlayıcı ayarları modellerini korur.", + "claudeDesktop.status.pendingApply": "Kaydedildi — Desktop'u güncellemek için uygulayın", + "claudeDesktop.visibility.search": "Model ara", + "claudeDesktop.visibility.provider": "Sağlayıcı", + "claudeDesktop.visibility.allProviders": "Tüm sağlayıcılar", + "claudeDesktop.visibility.selectShown": "Eşleşenlerin tümünü seç", + "claudeDesktop.visibility.clearShown": "Eşleşen tüm modellerin seçimini kaldır", + "claudeDesktop.visibility.toggle": "{model} modelini Claude Desktop'ta göster", + "claudeDesktop.visibility.selected": "{n} seçili", + "claudeDesktop.visibility.available": "{n} kullanılabilir", + "claudeDesktop.visibility.applied": "{n} uygulandı", + "claudeDesktop.visibility.appliedUnknown": "Uygulanan liste bilinmiyor", + "claudeDesktop.visibility.selectedUnavailable": "{n} seçili ama kullanılamıyor", + "claudeDesktop.visibility.newModels": "Yeni keşfedilen modeller", + "claudeDesktop.visibility.newShow": "Otomatik listele", + "claudeDesktop.visibility.newHide": "Seçilene kadar gizle", + "claudeDesktop.visibility.noneSelected": "En az bir kullanılabilir model seçin.", + "claudeDesktop.visibility.noMatch": "Bu filtreyle eşleşen model yok.", + "claudeDesktop.visibility.previewTitle": "Sonraki uygulama", + "claudeDesktop.visibility.previewAdds": "Ekler: {models}", + "claudeDesktop.visibility.previewRemoves": "Kaldırır: {models}", + "claudeDesktop.visibility.previewSame": "Profil aynı model kimliklerini içeriyor. Desktop'ı yeniden başlatmadan önce profil değişikliklerini uygulayın.", + "claudeDesktop.visibility.replaceNote": "Uygulamak, OpenCodex'in Claude Desktop profilindeki model listesini (elle düzenlenmiş liste dahil) değiştirir. Gelişmiş altındaki katman eşlemesi isteğe bağlıdır. Yalnızca keşif modu bu seçimi kullanmaz.", "claudeDesktop.roles.quickBadge": "Hızlı görevler", "claudeDesktop.advanced.title": "Gelişmiş: Claude düzeyi ataması", "claudeDesktop.advanced.desc": "Claude Desktop’ın her modeli hangi Claude düzeyi (Opus, Fable, Sonnet, Haiku) olarak değerlendireceğini seçin.", @@ -3581,7 +3603,7 @@ export const tr: Record = { "claudeDesktop.moveTo": "Şuraya taşı", "claudeDesktop.move": "Taşı", "claudeDesktop.status.applied": "Desktop'a Uygulandı", - "claudeDesktop.status.stale": "Konfigürasyon eski — tekrar uygulayın", + "claudeDesktop.status.stale": "Desktop yapılandırması güncel değil — tekrar uygulayın", "claudeDesktop.status.notApplied": "Uygulanmadı", "claudeDesktop.status.notActiveProfile": "Desktop başka bir profil kullanıyor", "claudeDesktop.status.disabled": "Claude Desktop entegrasyonu kapalı.", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index c4c5757ee1b..2f1a43953e9 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -3500,7 +3500,7 @@ export const vi: Record = { "claudeDesktop.importFailed": "Nhập thất bại. {error}", "claudeDesktop.moved": "{route} đã được chuyển đến {family}.", "claudeDesktop.unsaved": "Các thay đổi chưa được lưu", - "claudeDesktop.upToDate": "Hồ sơ đã được cập nhật", + "claudeDesktop.upToDate": "Đã lưu mọi thay đổi", "claudeDesktop.saving": "Đang lưu…", "claudeDesktop.applying": "Đang áp dụng…", "claudeDesktop.saveApply": "Lưu & áp dụng", @@ -3554,7 +3554,29 @@ export const vi: Record = { "claudeDesktop.roles.unavailableOption": "{route} (không khả dụng)", "claudeDesktop.roles.gatewayScope": "Các lựa chọn này áp dụng cho chế độ gateway, chế độ ghi danh sách model của Claude Desktop.", "claudeDesktop.roles.listTitle": "Danh sách model Desktop", - "claudeDesktop.roles.listHint": "Tự động điền từ các provider đã kết nối.", + "claudeDesktop.roles.listHint": "Chọn những model Claude Desktop sẽ liệt kê. Chỉ Claude Desktop thay đổi: các client khác và cài đặt provider giữ nguyên model.", + "claudeDesktop.status.pendingApply": "Đã lưu — hãy áp dụng để cập nhật Desktop", + "claudeDesktop.visibility.search": "Tìm model", + "claudeDesktop.visibility.provider": "Provider", + "claudeDesktop.visibility.allProviders": "Tất cả provider", + "claudeDesktop.visibility.selectShown": "Chọn tất cả mục phù hợp", + "claudeDesktop.visibility.clearShown": "Bỏ chọn tất cả model phù hợp", + "claudeDesktop.visibility.toggle": "Hiện {model} trong Claude Desktop", + "claudeDesktop.visibility.selected": "Đã chọn {n}", + "claudeDesktop.visibility.available": "{n} khả dụng", + "claudeDesktop.visibility.applied": "Đã áp dụng {n}", + "claudeDesktop.visibility.appliedUnknown": "Không rõ danh sách đã áp dụng", + "claudeDesktop.visibility.selectedUnavailable": "{n} đã chọn nhưng không khả dụng", + "claudeDesktop.visibility.newModels": "Model mới phát hiện", + "claudeDesktop.visibility.newShow": "Tự động liệt kê", + "claudeDesktop.visibility.newHide": "Ẩn cho đến khi được chọn", + "claudeDesktop.visibility.noneSelected": "Hãy chọn ít nhất một model khả dụng.", + "claudeDesktop.visibility.noMatch": "Không có model nào khớp bộ lọc.", + "claudeDesktop.visibility.previewTitle": "Lần áp dụng tới", + "claudeDesktop.visibility.previewAdds": "Thêm: {models}", + "claudeDesktop.visibility.previewRemoves": "Bỏ: {models}", + "claudeDesktop.visibility.previewSame": "Hồ sơ chứa cùng các ID model. Áp dụng thay đổi hồ sơ trước khi khởi động lại Desktop.", + "claudeDesktop.visibility.replaceNote": "Áp dụng sẽ thay danh sách model trong hồ sơ Claude Desktop của OpenCodex, kể cả danh sách đã sửa tay. Ánh xạ tier trong Nâng cao vẫn là tùy chọn. Chế độ chỉ discovery không dùng lựa chọn này.", "claudeDesktop.roles.quickBadge": "Tác vụ nhanh", "claudeDesktop.advanced.title": "Nâng cao: chỉ định cấp Claude", "claudeDesktop.advanced.desc": "Chọn cấp Claude (Opus, Fable, Sonnet, Haiku) mà Claude Desktop dùng để phân loại từng model.", @@ -3569,7 +3591,7 @@ export const vi: Record = { "claudeDesktop.moveTo": "Chuyển đến", "claudeDesktop.move": "Di chuyển", "claudeDesktop.status.applied": "Đã áp dụng cho Desktop", - "claudeDesktop.status.stale": "Cấu hình đã cũ — hãy áp dụng lại", + "claudeDesktop.status.stale": "Cấu hình Desktop chưa cập nhật — hãy áp dụng lại", "claudeDesktop.status.notApplied": "Chưa được áp dụng", "claudeDesktop.status.notActiveProfile": "Desktop đang phục vụ một hồ sơ khác — hãy áp dụng lại", "claudeDesktop.status.disabled": "Tiện ích tích hợp Claude Desktop đang tắt. Hãy thoát hoàn toàn và mở lại Desktop sau khi bật nó.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 3d56f861226..714c609e01d 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2661,7 +2661,7 @@ export const zhTW: Record = { "claudeDesktop.importFailed": "匯入失敗。{error}", "claudeDesktop.moved": "已將 {route} 移動到 {family}。", "claudeDesktop.unsaved": "有未儲存的更改", - "claudeDesktop.upToDate": "配置已是最新", + "claudeDesktop.upToDate": "所有變更已儲存", "claudeDesktop.saving": "正在儲存…", "claudeDesktop.applying": "正在套用…", "claudeDesktop.saveApply": "儲存並套用", @@ -2715,7 +2715,29 @@ export const zhTW: Record = { "claudeDesktop.roles.unavailableOption": "{route}(無法使用)", "claudeDesktop.roles.gatewayScope": "這些選擇適用於閘道模式,此模式會寫入 Claude Desktop 的模型清單。", "claudeDesktop.roles.listTitle": "Desktop 模型清單", - "claudeDesktop.roles.listHint": "自動從已連線的供應商填入。", + "claudeDesktop.roles.listHint": "選擇 Claude Desktop 列出的模型。只影響 Claude Desktop:其他用戶端與供應商設定的模型保持不變。", + "claudeDesktop.status.pendingApply": "已儲存 — 套用後更新 Desktop", + "claudeDesktop.visibility.search": "搜尋模型", + "claudeDesktop.visibility.provider": "供應商", + "claudeDesktop.visibility.allProviders": "所有供應商", + "claudeDesktop.visibility.selectShown": "選取所有符合項目", + "claudeDesktop.visibility.clearShown": "取消選取所有符合的模型", + "claudeDesktop.visibility.toggle": "在 Claude Desktop 中顯示 {model}", + "claudeDesktop.visibility.selected": "已選 {n}", + "claudeDesktop.visibility.available": "可用 {n}", + "claudeDesktop.visibility.applied": "已套用 {n}", + "claudeDesktop.visibility.appliedUnknown": "已套用清單未知", + "claudeDesktop.visibility.selectedUnavailable": "已選但不可用 {n}", + "claudeDesktop.visibility.newModels": "新發現的模型", + "claudeDesktop.visibility.newShow": "自動列出", + "claudeDesktop.visibility.newHide": "選取前保持隱藏", + "claudeDesktop.visibility.noneSelected": "請至少選擇一個可用模型。", + "claudeDesktop.visibility.noMatch": "沒有符合此篩選的模型。", + "claudeDesktop.visibility.previewTitle": "下次套用", + "claudeDesktop.visibility.previewAdds": "新增:{models}", + "claudeDesktop.visibility.previewRemoves": "移除:{models}", + "claudeDesktop.visibility.previewSame": "設定檔包含相同的模型 ID。請先套用設定檔變更,再重新啟動 Desktop。", + "claudeDesktop.visibility.replaceNote": "套用會取代 OpenCodex 的 Claude Desktop 設定中的模型清單,包括手動編輯過的清單。進階中的層級對應仍為選用。僅探索模式不使用此選擇。", "claudeDesktop.roles.quickBadge": "快速任務", "claudeDesktop.advanced.title": "進階:Claude 等級分配", "claudeDesktop.advanced.desc": "選擇 Claude Desktop 將各模型視為哪個 Claude 等級(Opus、Fable、Sonnet、Haiku)。", @@ -2730,7 +2752,7 @@ export const zhTW: Record = { "claudeDesktop.moveTo": "移動到", "claudeDesktop.move": "移動", "claudeDesktop.status.applied": "已套用到 Desktop", - "claudeDesktop.status.stale": "配置已更改 — 需重新套用", + "claudeDesktop.status.stale": "Desktop 設定尚未更新 — 需重新套用", "claudeDesktop.status.notApplied": "未套用", "claudeDesktop.status.notActiveProfile": "Desktop 正在使用其他配置 — 請重新套用", "claudeDesktop.health.lastRequest": "最後請求", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 34b370b68db..9342927ac1b 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -3482,7 +3482,7 @@ export const zh: Record = { "claudeDesktop.importFailed": "导入失败。{error}", "claudeDesktop.moved": "已将 {route} 移动到 {family}。", "claudeDesktop.unsaved": "有未保存的更改", - "claudeDesktop.upToDate": "配置已是最新", + "claudeDesktop.upToDate": "所有更改已保存", "claudeDesktop.saving": "正在保存…", "claudeDesktop.applying": "正在应用…", "claudeDesktop.saveApply": "保存并应用", @@ -3536,7 +3536,29 @@ export const zh: Record = { "claudeDesktop.roles.unavailableOption": "{route}(不可用)", "claudeDesktop.roles.gatewayScope": "这些选择适用于网关模式,该模式会写入 Claude Desktop 的模型列表。", "claudeDesktop.roles.listTitle": "Desktop 模型列表", - "claudeDesktop.roles.listHint": "自动从已连接的提供商填充。", + "claudeDesktop.roles.listHint": "选择 Claude Desktop 列出的模型。只影响 Claude Desktop:其他客户端和提供商设置的模型保持不变。", + "claudeDesktop.status.pendingApply": "已保存 — 应用后更新 Desktop", + "claudeDesktop.visibility.search": "搜索模型", + "claudeDesktop.visibility.provider": "提供商", + "claudeDesktop.visibility.allProviders": "所有提供商", + "claudeDesktop.visibility.selectShown": "选中所有匹配项", + "claudeDesktop.visibility.clearShown": "取消选择所有匹配的模型", + "claudeDesktop.visibility.toggle": "在 Claude Desktop 中显示 {model}", + "claudeDesktop.visibility.selected": "已选 {n}", + "claudeDesktop.visibility.available": "可用 {n}", + "claudeDesktop.visibility.applied": "已应用 {n}", + "claudeDesktop.visibility.appliedUnknown": "已应用列表未知", + "claudeDesktop.visibility.selectedUnavailable": "已选但不可用 {n}", + "claudeDesktop.visibility.newModels": "新发现的模型", + "claudeDesktop.visibility.newShow": "自动列出", + "claudeDesktop.visibility.newHide": "选中前保持隐藏", + "claudeDesktop.visibility.noneSelected": "请至少选择一个可用模型。", + "claudeDesktop.visibility.noMatch": "没有符合此筛选的模型。", + "claudeDesktop.visibility.previewTitle": "下次应用", + "claudeDesktop.visibility.previewAdds": "新增:{models}", + "claudeDesktop.visibility.previewRemoves": "移除:{models}", + "claudeDesktop.visibility.previewSame": "配置中包含相同的模型 ID。请先应用配置更改,再重启 Desktop。", + "claudeDesktop.visibility.replaceNote": "应用会替换 OpenCodex 的 Claude Desktop 配置中的模型列表,包括手动编辑过的列表。高级中的层级映射仍为可选。仅发现模式不使用此选择。", "claudeDesktop.roles.quickBadge": "快速任务", "claudeDesktop.advanced.title": "高级:Claude 等级分配", "claudeDesktop.advanced.desc": "选择 Claude Desktop 将各模型视为哪个 Claude 等级(Opus、Fable、Sonnet、Haiku)。", @@ -3551,7 +3573,7 @@ export const zh: Record = { "claudeDesktop.moveTo": "移动到", "claudeDesktop.move": "移动", "claudeDesktop.status.applied": "已应用到 Desktop", - "claudeDesktop.status.stale": "配置已更改 — 需重新应用", + "claudeDesktop.status.stale": "Desktop 配置尚未更新 — 需重新应用", "claudeDesktop.status.notApplied": "未应用", "claudeDesktop.status.notActiveProfile": "Desktop 正在使用其他配置 — 请重新应用", "claudeDesktop.status.disabled": "Claude Desktop 集成已关闭。开启后请完全退出并重新打开 Desktop。", diff --git a/gui/src/pages/ClaudeDesktop.tsx b/gui/src/pages/ClaudeDesktop.tsx index c71e9b0deb1..8971d0566d9 100644 --- a/gui/src/pages/ClaudeDesktop.tsx +++ b/gui/src/pages/ClaudeDesktop.tsx @@ -11,6 +11,11 @@ import { useDataSurface } from "../data-surface"; import { DataSurfaceSkeleton } from "../components/data-surface"; import ClaudeFirstPartyBindings from "../components/ClaudeFirstPartyBindings"; import ClaudeDesktopPicker, { type DesktopPickerStatus } from "../components/ClaudeDesktopPicker"; +import ClaudeDesktopVisibility from "../components/ClaudeDesktopVisibility"; +import { + applyPreview, desiredDesktopList, isDesktopVisibility, normalizeVisibility, sameVisibility, visibilityCounts, + type AppliedDesktopModel, type DesktopVisibility, type ListedDesktopModel, +} from "./claude-desktop-visibility"; import { FAMILIES, assignFamily, @@ -91,6 +96,8 @@ interface DesktopStatus { * `applied` (our saved fingerprint) says otherwise. null = undeterminable. */ activeProfile?: boolean | null; + /** The static list Desktop runs now; null/absent when there is none to read. */ + appliedModels?: AppliedDesktopModel[] | null; health: { lastRequestAt: string | null; requestCount: number; errorCount: number }; } @@ -123,6 +130,8 @@ function isDesktopStatus(value: unknown): value is DesktopStatus { if (fp.pickerSuggestions !== undefined && (!Array.isArray(fp.pickerSuggestions) || fp.pickerSuggestions.some(id => typeof id !== "string"))) return false; } + if (v.appliedModels !== undefined && v.appliedModels !== null && (!Array.isArray(v.appliedModels) + || v.appliedModels.some(row => typeof row?.name !== "string" || typeof row?.label !== "string"))) return false; return typeof v.desiredEnabled === "boolean" && typeof v.applied === "boolean" && (v.appliedAt === null || typeof v.appliedAt === "string") @@ -136,7 +145,9 @@ function isDesktopStatus(value: unknown): value is DesktopStatus { interface DesktopResponse { profile: DesktopProfile; models: DesktopModel[]; - rendered: unknown[]; + rendered: ListedDesktopModel[]; + /** Absent on servers that predate #7702; null lists every model. */ + visibility?: DesktopVisibility | null; port: number; } @@ -220,6 +231,10 @@ function formatContextWindow(value: number | undefined, t: TFn): string | null { type CachedDesktop = { data: DesktopResponse; profile: DesktopProfile }; +function responseVisibility(data: DesktopResponse | null | undefined): DesktopVisibility | null { + return isDesktopVisibility(data?.visibility) ? normalizeVisibility(data.visibility) : null; +} + function readDesktopCache(cacheKey: string): CachedDesktop | null { return readSessionListCacheEntry(cacheKey)?.data ?? null; } @@ -258,6 +273,9 @@ export default function ClaudeDesktop({ const [draftProfile, setProfile] = useState(() => cached.profile); const [savedDraftProfile, setSavedProfile] = useState(() => cached.savedProfile); const [draftDestinations, setDestinations] = useState>(() => cached.destinations); + // Desktop list membership (#7702). undefined = no draft yet, follow the loaded response. + const [draftVisibility, setVisibility] = useState(undefined); + const [savedDraftVisibility, setSavedVisibility] = useState(undefined); const [message, setMessage] = useState<{ tone: "ok" | "err" | "warn"; text: string } | null>(null); const [announcement, setAnnouncement] = useState(""); const [pending, setPending] = useState(null); @@ -274,9 +292,8 @@ export default function ClaudeDesktop({ // a family's fold is a durable preference, but which single model you were inspecting // is not, and restoring five open rows on reload would rebuild the wall this removes. const [openRows, setOpenRows] = useState>({}); - // The Advanced disclosure and the compact list's page are view state only. + // The Advanced disclosure is view state only. const [advancedOpen, setAdvancedOpen] = useState(false); - const [listLimit, setListLimit] = useState(LANE_PAGE); // The mode the user wants the next apply to use. null = follow whatever /status reports // (gateway by default), so a page load never silently changes an existing install. const [chosenMode, setChosenMode] = useState(null); @@ -297,6 +314,8 @@ export default function ClaudeDesktop({ // the established save→reload contract without synchronizing resource data in an effect. setProfile(normalized); setSavedProfile(cloneProfile(normalized)); + setVisibility(responseVisibility(payload)); + setSavedVisibility(responseVisibility(payload)); setDestinations(Object.fromEntries(payload.models.map(model => [model.route, normalized.assignments[model.route]?.family ?? "opus"]))); // Fold empty families on load, but only while the user has no stored preference. // Doing it here rather than per render means a later move or import can never @@ -308,7 +327,7 @@ export default function ClaudeDesktop({ } writeSessionListCacheEntry(cacheKey, next); return next; - }, [apiBase, cacheKey, t, setDestinations, setProfile, setSavedProfile]); + }, [apiBase, cacheKey, t, setDestinations, setProfile, setSavedProfile, setVisibility, setSavedVisibility]); const desktopResource = useDataSurface( resourceKey, @@ -332,9 +351,12 @@ export default function ClaudeDesktop({ : {} as Record; const destinations = Object.keys(draftDestinations).length > 0 ? draftDestinations : resourceDestinations; + const visibility = draftVisibility === undefined ? responseVisibility(data) : draftVisibility; + const savedVisibility = savedDraftVisibility === undefined ? responseVisibility(data) : savedDraftVisibility; const dirty = useMemo( - () => profile !== null && savedProfile !== null && JSON.stringify(profile) !== JSON.stringify(savedProfile), - [profile, savedProfile], + () => (profile !== null && savedProfile !== null && JSON.stringify(profile) !== JSON.stringify(savedProfile)) + || !sameVisibility(visibility, savedVisibility), + [profile, savedProfile, visibility, savedVisibility], ); const modelsByFamily = useMemo(() => { @@ -428,10 +450,11 @@ export default function ClaudeDesktop({ const response = await fetch(`${apiBase}/api/claude-desktop`, { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ profile }), + body: JSON.stringify({ profile, visibility: normalizeVisibility(visibility) }), }); await readJsonOrThrow<{ error?: string }>(response, t("claudeDesktop.saveFailed")); setSavedProfile(cloneProfile(profile)); + setSavedVisibility(normalizeVisibility(visibility)); if (applyAfter) { setPending("apply"); @@ -547,6 +570,10 @@ export default function ClaudeDesktop({ }; const listed = roleListOrder(data.models, defaultRoute || null, quickRoute || null); const haikuEmpty = familySize(data.models, profile, "haiku") === 0; + const noneVisible = visibilityCounts(data.models, visibility).selected === 0 && data.models.some(model => model.available); + // Saved but not yet written: Desktop's list differs from what the saved selection produces. + const savedPreview = Array.isArray(data.rendered) ? applyPreview(desiredDesktopList(data.rendered, savedVisibility), status?.appliedModels ?? null) : null; + const pendingApply = effectiveMode === "gateway" && !!savedPreview && (savedPreview.added.length > 0 || savedPreview.removed.length > 0); return ( <> @@ -591,7 +618,7 @@ export default function ClaudeDesktop({ ? "not-applied" : effectiveMode === "gateway" && status.activeProfile === false ? "not-applied" - : status.stale + : status.stale || (status.applied && pendingApply) ? "stale" : status.applied ? "applied" @@ -614,6 +641,8 @@ export default function ClaudeDesktop({ ? t("claudeDesktop.status.notActiveProfile") : status.stale ? t("claudeDesktop.status.stale") + : status.applied && pendingApply + ? t("claudeDesktop.status.pendingApply") : status.applied ? effectiveMode === "first-party" ? t("claudeDesktop.status.appliedFirstParty") : t("claudeDesktop.status.applied") : t("claudeDesktop.status.notApplied")} @@ -641,10 +670,10 @@ export default function ClaudeDesktop({ {dirty ? t("claudeDesktop.unsaved") : t("claudeDesktop.upToDate")} - - @@ -747,29 +776,26 @@ export default function ClaudeDesktop({ {data.models.length > 0 &&

{t("claudeDesktop.roles.listHint")}

} {data.models.length > 0 && ( -
    - {listed.slice(0, listLimit).map(model => { - const context = formatContextWindow(model.contextWindow, t); - return ( -
  • - - {model.label} - {model.label !== model.route && {model.route}} - - {model.route === defaultRoute && {t("claudeDesktop.defaultBadge")}} - {model.route === quickRoute && {t("claudeDesktop.roles.quickBadge")}} - {context && {context}} - {model.supports1m === true && {t("claudeDesktop.supports1m")}} - {!model.available && {t("claudeDesktop.unavailable")}} -
  • - ); - })} -
- )} - {listed.length > listLimit && ( - + { + const context = formatContextWindow(model.contextWindow, t); + return ( + <> + {model.route === defaultRoute && {t("claudeDesktop.defaultBadge")}} + {model.route === quickRoute && {t("claudeDesktop.roles.quickBadge")}} + {context && {context}} + {model.supports1m === true && {t("claudeDesktop.supports1m")}} + {!model.available && {t("claudeDesktop.unavailable")}} + + ); + }} + /> )}
diff --git a/gui/src/pages/claude-desktop-visibility.ts b/gui/src/pages/claude-desktop-visibility.ts new file mode 100644 index 00000000000..62f8e2c7d89 --- /dev/null +++ b/gui/src/pages/claude-desktop-visibility.ts @@ -0,0 +1,157 @@ +/** + * Claude Desktop model visibility (#7702), dashboard side. Mirrors + * src/claude/desktop-model-visibility.ts: `null` lists every model, `exclude` lists all but + * `routes` (new models appear), `include` lists only `routes` (new models stay hidden). + * Pure so the selection, the new-model policy switch and the apply preview are testable + * without mounting the page. + */ + +export interface DesktopVisibility { + mode: "include" | "exclude"; + routes: string[]; +} + +export interface VisibilityModel { + route: string; + label: string; + available: boolean; +} + +/** A row the next apply writes (`listed`/`rendered` on GET /api/claude-desktop). */ +export interface ListedDesktopModel { + route: string; + name: string; + label: string; +} + +/** An on-disk selected-profile row (`appliedModels` on GET /api/claude-desktop/status). */ +export interface AppliedDesktopModel { + name: string; + label: string; +} + +export function isDesktopVisibility(value: unknown): value is DesktopVisibility { + if (typeof value !== "object" || value === null) return false; + const v = value as Record; + return (v.mode === "include" || v.mode === "exclude") + && Array.isArray(v.routes) && v.routes.every(route => typeof route === "string"); +} + +/** Canonical form: sorted unique routes, and "exclude nothing" is the same as unset. */ +export function normalizeVisibility(visibility: DesktopVisibility | null): DesktopVisibility | null { + if (!visibility) return null; + const routes = [...new Set(visibility.routes)].sort(); + if (visibility.mode === "exclude" && routes.length === 0) return null; + return { mode: visibility.mode, routes }; +} + +export function sameVisibility(left: DesktopVisibility | null, right: DesktopVisibility | null): boolean { + return JSON.stringify(normalizeVisibility(left)) === JSON.stringify(normalizeVisibility(right)); +} + +export function routeVisible(visibility: DesktopVisibility | null, route: string): boolean { + if (!visibility) return true; + const listed = visibility.routes.includes(route); + return visibility.mode === "include" ? listed : !listed; +} + +/** Show or hide `routes`, keeping the new-model policy the selection already has. */ +export function setRoutesVisible( + visibility: DesktopVisibility | null, + routes: readonly string[], + visible: boolean, +): DesktopVisibility | null { + const current = visibility ?? { mode: "exclude" as const, routes: [] }; + const members = new Set(current.routes); + // include lists the shown routes; exclude lists the hidden ones. + const addToList = current.mode === "include" ? visible : !visible; + for (const route of routes) { + if (addToList) members.add(route); + else members.delete(route); + } + return normalizeVisibility({ mode: current.mode, routes: [...members] }); +} + +/** + * Switch what happens to models discovered later, without changing which known models are + * shown today: the list is rewritten from the include form to the exclude form or back. + */ +export function setNewModelPolicy( + visibility: DesktopVisibility | null, + knownRoutes: readonly string[], + mode: DesktopVisibility["mode"], +): DesktopVisibility | null { + if ((visibility?.mode ?? "exclude") === mode) return visibility; + const routes = knownRoutes.filter(route => routeVisible(visibility, route) === (mode === "include")); + // An include list must keep its entries even when it names nothing yet; only an empty + // exclude list collapses to unset. + return mode === "include" ? { mode, routes: [...new Set(routes)].sort() } : normalizeVisibility({ mode, routes }); +} + +export function providerOf(route: string): string { + const slash = route.indexOf("/"); + return slash > 0 ? route.slice(0, slash) : route; +} + +export function providersOf(models: readonly VisibilityModel[]): string[] { + return [...new Set(models.map(model => providerOf(model.route)))].sort(); +} + +/** Search matches route or label, case-insensitively; an empty provider means every provider. */ +export function filterVisibilityModels(models: readonly T[], query: string, provider: string): T[] { + const needle = query.trim().toLowerCase(); + return models.filter(model => (!provider || providerOf(model.route) === provider) + && (!needle || model.route.toLowerCase().includes(needle) || model.label.toLowerCase().includes(needle))); +} + +export interface VisibilityCounts { + /** Available models the selection lists. */ + selected: number; + /** Models currently available from providers. */ + available: number; + /** Selected models that are not available now; never written, so counted apart. */ + selectedUnavailable: number; +} + +export function visibilityCounts(models: readonly VisibilityModel[], visibility: DesktopVisibility | null): VisibilityCounts { + let selected = 0; + let available = 0; + let selectedUnavailable = 0; + for (const model of models) { + const visible = routeVisible(visibility, model.route); + if (model.available) { + available += 1; + if (visible) selected += 1; + } else if (visible && visibility?.mode === "include") { + // Only an explicit include names an unavailable model; "everything" does not. + selectedUnavailable += 1; + } + } + return { selected, available, selectedUnavailable }; +} + +/** The rows the next apply writes under a draft selection, in written order. */ +export function desiredDesktopList(rendered: readonly ListedDesktopModel[], visibility: DesktopVisibility | null): ListedDesktopModel[] { + const kept = rendered.filter(model => routeVisible(visibility, model.route)); + // Mirrors the writer's guard: a selection hiding everything falls back to the full list. + return kept.length > 0 ? kept : [...rendered]; +} + +export interface ApplyPreview { + added: string[]; + removed: string[]; +} + +/** Labels Desktop gains and loses on the next apply; null when the applied list is unknown. */ +export function applyPreview( + desired: readonly ListedDesktopModel[], + applied: readonly AppliedDesktopModel[] | null, +): ApplyPreview | null { + if (!applied) return null; + const appliedNames = new Set(applied.map(model => model.name)); + const desiredNames = new Set(desired.map(model => model.name)); + return { + added: desired.filter(model => !appliedNames.has(model.name)).map(model => model.label), + removed: applied.filter(model => !desiredNames.has(model.name)).map(model => model.label), + }; +} diff --git a/gui/src/styles/claude-page.css b/gui/src/styles/claude-page.css index 30987498847..940de1e6440 100644 --- a/gui/src/styles/claude-page.css +++ b/gui/src/styles/claude-page.css @@ -258,3 +258,57 @@ .claude-desktop-advanced > .ocx-group-stack { padding: 0 10px 10px; } +/* Claude Desktop model visibility (#7702). */ +.claude-visibility { + display: flex; + flex-direction: column; + gap: var(--space-2); +} +.claude-visibility-counts, +.claude-visibility-filters, +.claude-visibility-preview { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +.claude-visibility-count { + font-size: var(--text-label); + font-weight: var(--weight-medium); +} +.claude-visibility-count.muted, +.claude-visibility-preview { + color: var(--muted); + font-size: var(--text-label); +} +.claude-visibility-filters .input[type="search"] { + flex: 1; + min-width: 160px; +} +.claude-visibility-policy { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + margin: 0; + padding: 0; + border: none; + font-size: var(--text-label); +} +.claude-visibility-policy legend { + float: left; + margin-right: 4px; + font-weight: var(--weight-medium); +} +.claude-visibility-policy label { + display: inline-flex; + align-items: center; + gap: 4px; +} +.claude-desktop-list-row.hidden-row .claude-desktop-list-names { + opacity: 0.55; +} +.claude-visibility-preview-title { + font-weight: var(--weight-medium); + color: var(--text); +} diff --git a/gui/tests/claude-desktop-visibility-card.test.tsx b/gui/tests/claude-desktop-visibility-card.test.tsx new file mode 100644 index 00000000000..57533d17741 --- /dev/null +++ b/gui/tests/claude-desktop-visibility-card.test.tsx @@ -0,0 +1,207 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import ClaudeDesktop from "../src/pages/ClaudeDesktop"; +import { LanguageProvider } from "../src/i18n/provider"; +import { clearClientResourceStoresForTests } from "../src/client-resource"; +import { preserveDesktopAppliedState, type DesktopProfile } from "../../src/claude/desktop-profile"; + +/** + * #7702: the Desktop model list is a per-model selection. Mounted, because the contract is + * the save body the page sends and the saved/applied state it shows. + */ + +const globals = ["document", "window", "navigator", "localStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; +let container: HTMLElement; +let root: Root | null = null; + +function model(route: string, label: string, family: string, available = true) { + return { route, label, available, contextWindow: 200_000, effortSupported: true, assignment: { family, alias: `alias-${label}` } }; +} + +// Eight models so the lane pager and the search input are both in play (LANE_PAGE = 6, +// LANE_SEARCH_MIN = 4). +const MODELS = [ + ...Array.from({ length: 7 }, (_, i) => model(`prov/opus-${i}`, `Opus Model ${i}`, "opus")), + model("prov/only-sonnet", "Sonnet Model", "sonnet"), +]; + +// A stored default whose long route went unavailable (R4 audit: it overflowed the Models card). +const STALE_ROUTE = "openrouter/anthropic/claude-sonnet-4.5-thinking-extended-context-preview-20260930"; +let withStaleDefault = false; +let puts: Array> = []; +// Desktop currently runs every model, so a saved narrower selection is pending apply. +let appliedModels: Array<{ name: string; label: string }> = []; +let savedProfile: DesktopProfile | null = null; + +function payload() { + const models = withStaleDefault ? [...MODELS, model(STALE_ROUTE, STALE_ROUTE, "opus", false)] : MODELS; + return { + profile: savedProfile ?? { + version: 1 as const, + assignments: Object.fromEntries(models.map(m => [m.route, m.assignment])), + defaults: { opus: withStaleDefault ? STALE_ROUTE : "prov/opus-0", fable: null, sonnet: "prov/only-sonnet", haiku: null }, + }, + models, + rendered: models.map(m => ({ route: m.route, name: `claude-${m.route.replace(/\W/g, "-")}`, label: m.label })), + visibility: savedVisibility, + port: 10100, + }; +} + +let savedVisibility: unknown = null; + +beforeEach(() => { + withStaleDefault = false; + puts = []; + savedVisibility = null; + savedProfile = null; + appliedModels = MODELS.map(m => ({ name: `claude-${m.route.replace(/\W/g, "-")}`, label: m.label })); + clearClientResourceStoresForTests(); + previousGlobals = Object.fromEntries(globals.map(k => [k, Reflect.get(globalThis, k)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperty(testWindow.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async (url: string, init?: RequestInit) => { + if (init?.method === "PUT") { + const request = JSON.parse(String(init.body)); + puts.push(request); + if (savedProfile) savedProfile = preserveDesktopAppliedState(savedProfile, request.profile); + } + const body = String(url).includes("/status") + ? { desiredEnabled: true, applied: true, appliedAt: null, stale: savedProfile !== null && !savedProfile.appliedFingerprint, appliedModels, health: { lastRequestAt: null, requestCount: 0, errorCount: 0 } } + : payload(); + return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) } as unknown as Response; + }, + }); + + container = testWindow.document.createElement("div") as unknown as HTMLElement; + testWindow.document.body.appendChild(container as never); +}); + +afterEach(async () => { + if (root) { + const current = root; + await act(async () => { current.unmount(); }); + root = null; + } + clearClientResourceStoresForTests(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +async function mount() { + await act(async () => { + root = createRoot(container); + root.render(); + }); + await act(async () => { await new Promise(r => setTimeout(r, 50)); }); +} + +function checkbox(label: string): HTMLInputElement { + const found = container.querySelector(`input[type="checkbox"][aria-label="Show ${label} in Claude Desktop"]`); + if (!found) throw new Error(`checkbox not found: ${label}`); + return found as unknown as HTMLInputElement; +} + +function button(text: string): HTMLButtonElement { + const found = Array.from(container.querySelectorAll("button")).find(b => (b.textContent ?? "").trim() === text); + if (!found) throw new Error(`button not found: ${text}`); + return found as unknown as HTMLButtonElement; +} + +test("unchecking a model marks the page unsaved and saves an exclude selection with the profile", async () => { + await mount(); + expect(container.querySelector(".claude-visibility-counts")?.textContent).toContain("8 selected"); + expect(checkbox("Opus Model 1").checked).toBe(true); + await act(async () => { checkbox("Opus Model 1").click(); }); + expect(checkbox("Opus Model 1").checked).toBe(false); + expect(container.querySelector(".claude-dirty")?.textContent).toBe("Unsaved changes"); + expect(container.querySelector(".claude-visibility-preview")?.textContent).toContain("Removes: Opus Model 1"); + await act(async () => { button("Save").click(); }); + await act(async () => { await new Promise(r => setTimeout(r, 20)); }); + expect(puts).toHaveLength(1); + expect(puts[0]!.visibility).toEqual({ mode: "exclude", routes: ["prov/opus-1"] }); + expect(puts[0]!.profile).toBeDefined(); +}); + +test("a saved selection Desktop has not received yet reads as pending apply, not up to date", async () => { + savedVisibility = { mode: "include", routes: ["prov/opus-0"] }; + await mount(); + expect(checkbox("Opus Model 1").checked).toBe(false); + expect(container.querySelector(".claude-status-bar")?.className).toContain("stale"); + expect(container.querySelector(".claude-status-bar")?.textContent).toContain("Saved — apply to update Desktop"); + expect(container.querySelector(".claude-dirty")?.textContent).toBe("All changes saved"); +}); + +test("clearing every shown model blocks saving", async () => { + await mount(); + await act(async () => { button("Deselect all matching models").click(); }); + expect(container.textContent).toContain("Select at least one available model."); + expect(button("Save").disabled).toBe(true); +}); + +test("editing a draft keeps a newer saved selection pending apply", async () => { + savedVisibility = { mode: "include", routes: ["prov/opus-0"] }; + await mount(); + await act(async () => { checkbox("Opus Model 1").click(); }); + expect(container.querySelector(".claude-dirty")?.textContent).toBe("Unsaved changes"); + expect(container.querySelector(".claude-status-bar")?.textContent).toContain("Saved — apply to update Desktop"); +}); + +test("saving a new family default without applying does not claim external file edits", async () => { + savedProfile = { ...payload().profile, appliedFingerprint: "unchanged-desktop-bytes" } as DesktopProfile; + const desktopModels = structuredClone(appliedModels); + await mount(); + const family = container.querySelector("#claude-lane-opus button") as unknown as HTMLButtonElement; + if (family.getAttribute("aria-expanded") === "false") await act(async () => { family.click(); }); + const radio = container.querySelector('input[type="radio"][name="default-opus"]') as unknown as HTMLInputElement; + expect(radio).not.toBeNull(); + // The first model is already the default; open a second model and choose it instead. + const summary = Array.from(container.querySelectorAll("button.claude-model-summary")) + .find(node => node.textContent?.includes("Opus Model 1")) as unknown as HTMLButtonElement; + await act(async () => { summary.click(); }); + const secondDefault = container.querySelectorAll('input[type="radio"][name="default-opus"]')[1] as unknown as HTMLInputElement; + await act(async () => { secondDefault.click(); }); + expect(button("Save").disabled).toBe(false); + await act(async () => { button("Save").click(); }); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); }); + expect(puts).toHaveLength(1); + expect(savedProfile!.defaults.opus).toBe("prov/opus-1"); + expect(savedProfile!.appliedFingerprint).toBeUndefined(); + expect(appliedModels).toEqual(desktopModels); + expect(container.querySelector(".claude-dirty")?.textContent).toBe("All changes saved"); + expect(container.querySelector(".claude-status-bar")?.textContent) + .toContain("Desktop configuration is out of date — re-apply"); +}); + +test("bulk labels disclose that matching rows beyond Show more are included", async () => { + await mount(); + const actions = container.querySelectorAll(".claude-visibility-filters button"); + expect(container.querySelectorAll(".claude-desktop-list-row")).toHaveLength(6); + await act(async () => { actions[1]!.click(); }); + expect(container.querySelector(".claude-visibility-counts")?.textContent).toContain("0 selected"); + await act(async () => { actions[0]!.click(); }); + expect(container.querySelector(".claude-visibility-counts")?.textContent).toContain("8 selected"); + expect(actions[0]!.textContent).toBe("Select all matching"); + expect(actions[1]!.textContent).toBe("Deselect all matching models"); +}); + +test("an equal preview asks to apply profile changes before restarting Desktop", async () => { + await mount(); + expect(container.querySelector(".claude-visibility-preview")?.textContent) + .toContain("The profile contains the same model IDs. Apply profile changes before restarting Desktop."); +}); diff --git a/gui/tests/claude-desktop-visibility.test.ts b/gui/tests/claude-desktop-visibility.test.ts new file mode 100644 index 00000000000..5af95c69f54 --- /dev/null +++ b/gui/tests/claude-desktop-visibility.test.ts @@ -0,0 +1,66 @@ +import { expect, test } from "bun:test"; +import { + applyPreview, + desiredDesktopList, + filterVisibilityModels, + normalizeVisibility, + routeVisible, + sameVisibility, + setNewModelPolicy, + setRoutesVisible, + visibilityCounts, +} from "../src/pages/claude-desktop-visibility"; + +/** #7702: the dashboard's Desktop selection math, kept pure so it is testable unmounted. */ + +const MODELS = [ + { route: "anthropic/claude-sonnet-5", label: "Sonnet 5", available: true }, + { route: "google/gemini-3-pro", label: "Gemini 3 Pro", available: true }, + { route: "xai/grok-5", label: "Grok 5", available: true }, + { route: "deepseek/deepseek-v4", label: "DeepSeek V4", available: false }, +]; +const ROUTES = MODELS.map(model => model.route); + +test("unset lists everything; toggling builds an exclude list that keeps new models listed", () => { + expect(ROUTES.every(route => routeVisible(null, route))).toBe(true); + const hidden = setRoutesVisible(null, ["xai/grok-5"], false); + expect(hidden).toEqual({ mode: "exclude", routes: ["xai/grok-5"] }); + expect(routeVisible(hidden, "new/model")).toBe(true); + // Showing it again is the same as never having hidden it. + expect(setRoutesVisible(hidden, ["xai/grok-5"], true)).toBeNull(); +}); + +test("switching the new-model policy rewrites the list without changing today's selection", () => { + const hidden = setRoutesVisible(null, ["xai/grok-5"], false); + const include = setNewModelPolicy(hidden, ROUTES, "include"); + expect(include).toEqual({ mode: "include", routes: ["anthropic/claude-sonnet-5", "deepseek/deepseek-v4", "google/gemini-3-pro"] }); + for (const route of ROUTES) expect(routeVisible(include, route)).toBe(routeVisible(hidden, route)); + expect(routeVisible(include, "new/model")).toBe(false); + expect(sameVisibility(setNewModelPolicy(include, ROUTES, "exclude"), hidden)).toBe(true); +}); + +test("bulk select acts on the filtered rows only", () => { + const shown = filterVisibilityModels(MODELS, "", "google").map(model => model.route); + const include = setRoutesVisible({ mode: "include", routes: [] }, shown, true); + expect(include).toEqual({ mode: "include", routes: ["google/gemini-3-pro"] }); + expect(filterVisibilityModels(MODELS, "GROK", "").map(model => model.route)).toEqual(["xai/grok-5"]); +}); + +test("counts keep unavailable selections apart from the selected count", () => { + expect(visibilityCounts(MODELS, null)).toEqual({ selected: 3, available: 3, selectedUnavailable: 0 }); + expect(visibilityCounts(MODELS, { mode: "include", routes: ["google/gemini-3-pro", "deepseek/deepseek-v4"] })) + .toEqual({ selected: 1, available: 3, selectedUnavailable: 1 }); +}); + +test("the apply preview diffs the desired list against what Desktop runs", () => { + const rendered = [ + { route: "google/gemini-3-pro", name: "claude-a", label: "Gemini 3 Pro" }, + { route: "xai/grok-5", name: "claude-b", label: "Grok 5" }, + ]; + const desired = desiredDesktopList(rendered, { mode: "exclude", routes: ["xai/grok-5"] }); + expect(applyPreview(desired, [{ name: "claude-b", label: "Grok 5" }])).toEqual({ added: ["Gemini 3 Pro"], removed: ["Grok 5"] }); + expect(applyPreview(desired, null)).toBeNull(); + // Hiding everything previews the writer's fallback, not an empty list. + expect(desiredDesktopList(rendered, { mode: "include", routes: [] })).toHaveLength(2); + expect(normalizeVisibility({ mode: "exclude", routes: [] })).toBeNull(); +}); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index d600d8a8a08..72a9048d137 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -410,7 +410,7 @@ "claude-compatibility.test.ts": "claude-integration", "claude-context-windows.test.ts": "claude-integration", "claude-desktop-1m.test.ts": "claude-integration", - "claude-desktop-cli.test.ts": "claude-integration", + "claude-desktop-cli.test.ts": "claude-integration", "claude-desktop-model-visibility.test.ts": "claude-integration", "claude-desktop-config-path.test.ts": "claude-integration", "claude-desktop-discovery.test.ts": "claude-integration", "claude-desktop-first-party.test.ts": "claude-integration", diff --git a/src/claude/desktop-3p.ts b/src/claude/desktop-3p.ts index 46ff2e77843..25d0f85e509 100644 --- a/src/claude/desktop-3p.ts +++ b/src/claude/desktop-3p.ts @@ -27,6 +27,7 @@ import { import { nativeOpenAiContextWindow, type NativeContextLimitsInput } from "../codex/catalog"; import { localAdmissionToken, localInferenceDestination } from "../lib/local-destinations"; import { assertDesktop3pModelsValid } from "./desktop-3p-guard"; +import { filterDesktopModelsByVisibility, readDesktopModelVisibility, type DesktopModelVisibility } from "./desktop-model-visibility"; import { claudeSurfaceSupportsOneMillion, ONE_MILLION } from "./long-context"; export interface Desktop3pModelEntry { @@ -170,9 +171,11 @@ function collectDesktop3pModels( routedModels: Array, profile?: OcxClaudeDesktopProfile, nativeContextCap?: NativeContextLimitsInput, -): { models: Desktop3pModelEntry[]; registry: Map; realAnthropicIds: Set } { +): { models: Desktop3pModelEntry[]; routes: string[]; registry: Map; realAnthropicIds: Set } { const registry = new Map(); const models: Desktop3pModelEntry[] = []; + // routes[i] is the route models[i] serves; visibility filters by route, never by alias. + const routes: string[] = []; const candidates: Desktop3pRoutedModel[] = [ // Native candidates carry their real context window from the same accessor the // Desktop DTO uses, so a native 1M/372k model resolves identically in the written @@ -224,6 +227,7 @@ function collectDesktop3pModels( ...(model.isFamilyDefault ? { isFamilyDefault: true } : {}), ...(model.supports1m ? { supports1m: true, prefer1m: true } : {}), }); + routes.push(model.route); } // Previous profile IDs and both hash generations are compatibility-only and can // collide. Bind them in stable route order so changing a family default or @@ -248,7 +252,7 @@ function collectDesktop3pModels( } } desktop3pAliasesByRoute = aliasesByRoute; - return { models, registry, realAnthropicIds }; + return { models, routes, registry, realAnthropicIds }; } const aliasesByRoute = new Map(); @@ -272,6 +276,7 @@ function collectDesktop3pModels( ...(supports1m.supports1m ? { prefer1m: true as const } : {}), }); emittedNames.add(alias); + routes.push(route); aliasesByRoute.set(route, alias); continue; } @@ -300,12 +305,13 @@ function collectDesktop3pModels( ...supports1m, ...(supports1m.supports1m ? { prefer1m: true as const } : {}), }); + routes.push(route); aliasesByRoute.set(route, alias); } if (models[0]) models[0].isFamilyDefault = true; desktop3pAliasesByRoute = aliasesByRoute; - return { models, registry, realAnthropicIds }; + return { models, routes, registry, realAnthropicIds }; } /** Build and install the registry used to decode Desktop aliases. */ @@ -328,6 +334,11 @@ export interface Desktop3pModelOptions { * (`supports1m`) but no longer defaults to it (`prefer1m` dropped). */ preferOneMillion?: boolean; + /** + * Rows Desktop lists (#7702). Filters only the emitted list: the decode registry keeps every + * route so a running session holding a just-hidden alias still routes. + */ + visibility?: DesktopModelVisibility; } export function generateDesktop3pModels( @@ -337,15 +348,25 @@ export function generateDesktop3pModels( nativeContextCap?: NativeContextLimitsInput, options: Desktop3pModelOptions = {}, ): Desktop3pModelEntry[] { - const { models, registry, realAnthropicIds } = collectDesktop3pModels(nativeSlugs, routedModels, profile, nativeContextCap); + const { models: all, routes, registry, realAnthropicIds } = collectDesktop3pModels(nativeSlugs, routedModels, profile, nativeContextCap); desktop3pRegistry = registry; desktop3pRealAnthropicIds = realAnthropicIds; + let models = filterDesktopModelsByVisibility(all, routes, options.visibility); + if (models.length === 0 && all.length > 0) { + // An empty static list leaves Desktop with no usable picker. The dashboard refuses to save + // such a selection; this covers selections whose every model has since disappeared. + console.warn("[opencodex] Claude Desktop model visibility hides every available model; listing all models instead"); + models = all; + } return options.preferOneMillion === false ? models.map(({ prefer1m: _prefer1m, ...model }) => model) : models; } /** The Desktop model options a config asks for (one place, so writer and export agree). */ -export function desktop3pModelOptions(claudeCode: { contextAccounting?: string } | undefined): Desktop3pModelOptions { - return { preferOneMillion: claudeCode?.contextAccounting !== "200k" }; +export function desktop3pModelOptions( + claudeCode: { contextAccounting?: string; desktopModelVisibility?: unknown } | undefined, +): Desktop3pModelOptions { + const visibility = readDesktopModelVisibility(claudeCode); + return { preferOneMillion: claudeCode?.contextAccounting !== "200k", ...(visibility ? { visibility } : {}) }; } /** Resolve an alias using the most recently generated Desktop model registry. */ diff --git a/src/claude/desktop-model-visibility.ts b/src/claude/desktop-model-visibility.ts new file mode 100644 index 00000000000..42edb3b7e2e --- /dev/null +++ b/src/claude/desktop-model-visibility.ts @@ -0,0 +1,141 @@ +/** + * Claude Desktop model visibility (#7702): which rows the Desktop 3P `inferenceModels` + * list carries, persisted as `claudeCode.desktopModelVisibility`. + * + * Deliberately separate from the Desktop profile (tier/family mapping and defaults) and + * from provider-wide `disabledModels`: hiding a row here changes only what Claude Desktop + * lists. The alias decode registry keeps every route, so a running Desktop session that + * still sends a just-hidden alias keeps routing instead of failing mid-conversation. + * + * - unset: every available model is listed (the pre-#7702 behaviour). + * - `exclude`: every model except `routes`; newly discovered models are listed. + * - `include`: only `routes`; newly discovered models stay hidden until selected. + * + * Discovery-only mode emits no static list, so visibility does not apply there. + */ +import type { OcxClaudeDesktopModelVisibility } from "../types"; +import { readDesktopLibraryText } from "./desktop-3p-library"; + +export type DesktopModelVisibility = OcxClaudeDesktopModelVisibility; + +export const DESKTOP_VISIBILITY_MODES = ["include", "exclude"] as const; +/** Bounded so a hand-edited config cannot grow the per-write set without limit. */ +export const MAX_DESKTOP_VISIBILITY_ROUTES = 5000; + +export class DesktopModelVisibilityError extends Error { + constructor(message: string, readonly path = "desktopModelVisibility") { + super(`${path}: ${message}`); + this.name = "DesktopModelVisibilityError"; + } +} + +function isPlainObject(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +export function parseDesktopModelVisibility(value: unknown): DesktopModelVisibility { + if (!isPlainObject(value)) throw new DesktopModelVisibilityError("must be an object"); + for (const key of Object.keys(value)) { + if (key !== "mode" && key !== "routes") throw new DesktopModelVisibilityError(`unknown field "${key}"`); + } + if (value.mode !== "include" && value.mode !== "exclude") { + throw new DesktopModelVisibilityError("must be \"include\" or \"exclude\"", "desktopModelVisibility.mode"); + } + if (!Array.isArray(value.routes)) throw new DesktopModelVisibilityError("must be an array", "desktopModelVisibility.routes"); + if (value.routes.length > MAX_DESKTOP_VISIBILITY_ROUTES) { + throw new DesktopModelVisibilityError(`must list at most ${MAX_DESKTOP_VISIBILITY_ROUTES} routes`, "desktopModelVisibility.routes"); + } + const routes = new Set(); + for (const route of value.routes) { + if (typeof route !== "string" || !route.includes("/") || /\s/.test(route) || route.startsWith("/") || route.endsWith("/")) { + throw new DesktopModelVisibilityError("routes must be provider/model strings", "desktopModelVisibility.routes"); + } + routes.add(route); + } + return { mode: value.mode, routes: [...routes].sort() }; +} + +/** Whether Desktop lists this route under the given visibility (unset lists everything). */ +export function desktopRouteVisible(visibility: DesktopModelVisibility | undefined, route: string): boolean { + if (!visibility) return true; + const listed = visibility.routes.includes(route); + return visibility.mode === "include" ? listed : !listed; +} + +/** Same desired list, compared order-insensitively (routes are stored sorted). */ +export function sameDesktopModelVisibility( + left: DesktopModelVisibility | undefined, + right: DesktopModelVisibility | undefined, +): boolean { + if (!left || !right) return !left && !right; + return left.mode === right.mode && left.routes.join("\n") === right.routes.join("\n"); +} + +interface FamilyRow { + anthropicFamilyTier: string; + isFamilyDefault?: boolean; +} + +/** + * Drop hidden rows from a generated Desktop list (`routes[i]` is the route of `models[i]`). + * + * A hidden family default hands its role to the first remaining member of that family, and + * the defaults are moved to the front so the written list keeps the shape + * `renderDesktopProfile` produces: defaults first, in family order, then everything else. + */ +export function filterDesktopModelsByVisibility( + models: readonly T[], + routes: readonly string[], + visibility: DesktopModelVisibility | undefined, +): T[] { + if (!visibility) return [...models]; + const kept = models.filter((_, index) => desktopRouteVisible(visibility, routes[index] ?? "")); + if (kept.length === models.length) return kept; + const families: string[] = []; + for (const model of models) if (!families.includes(model.anthropicFamilyTier)) families.push(model.anthropicFamilyTier); + const defaults: T[] = []; + for (const family of families) { + const members = kept.filter(model => model.anthropicFamilyTier === family); + const chosen = members.find(model => model.isFamilyDefault) ?? members[0]; + if (chosen) defaults.push(chosen); + } + const defaultSet = new Set(defaults); + return [ + ...defaults.map(model => ({ ...model, isFamilyDefault: true })), + ...kept.filter(model => !defaultSet.has(model)).map(({ isFamilyDefault: _drop, ...model }) => model as T), + ]; +} + +/** The persisted selection, or undefined when unset or unreadable (unreadable lists everything). */ +export function readDesktopModelVisibility(claudeCode: { desktopModelVisibility?: unknown } | undefined): DesktopModelVisibility | undefined { + if (claudeCode?.desktopModelVisibility === undefined) return undefined; + try { + return parseDesktopModelVisibility(claudeCode.desktopModelVisibility); + } catch { + return undefined; + } +} + +export interface AppliedDesktopModel { + name: string; + label: string; +} + +/** + * The `inferenceModels` rows of the profile Desktop has selected, for the dashboard's + * applied count and added/removed preview. `null` when there is no static list to read + * (no profile, discovery-only, or unreadable): unknown, not empty. Names and labels only. + */ +export function readAppliedDesktopModels(selectedProfilePath: string | null): AppliedDesktopModel[] | null { + if (!selectedProfilePath) return null; + try { + const parsed = JSON.parse(readDesktopLibraryText(selectedProfilePath)) as { inferenceModels?: unknown }; + if (!Array.isArray(parsed.inferenceModels)) return null; + return parsed.inferenceModels.flatMap(row => { + if (!isPlainObject(row) || typeof row.name !== "string") return []; + return [{ name: row.name, label: typeof row.labelOverride === "string" ? row.labelOverride : row.name }]; + }); + } catch { + return null; + } +} diff --git a/src/config/salvage.ts b/src/config/salvage.ts index 154283dc94b..e9a19ca5352 100644 --- a/src/config/salvage.ts +++ b/src/config/salvage.ts @@ -31,6 +31,7 @@ const SALVAGEABLE_CONFIG_SECTIONS = ["routingProfiles", "combos"] as const; /** Optional nested fields that can be dropped whole without changing the rest of the document. */ const SALVAGEABLE_OPTIONAL_FIELDS: ReadonlyArray = [ ["claudeCode", "desktopProfile"], + ["claudeCode", "desktopModelVisibility"], ["claudeCode", "appendOnlyToolLoadingModels"], ]; diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index 0565f9efc35..959679ed2be 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -83,6 +83,7 @@ import { OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; import { modelAutoCompactTokenLimitsConfigError } from "../../providers/auto-compact-budget"; import { hasFastWireCapabilityConflict } from "../../providers/fastwire"; import { parseDesktopProfile } from "../../claude/desktop-profile"; +import { parseDesktopModelVisibility } from "../../claude/desktop-model-visibility"; import { isInterceptBindingId, isInterceptBindingRoute } from "../../claude/intercept/model-bindings"; import { DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES, MAX_APP_OWNED_MEMORY_BUDGET_MB, MIN_APP_OWNED_MEMORY_BUDGET_MB } from "../../lib/app-owned-memory"; @@ -420,7 +421,7 @@ export const configSchema = z.object({ if (claudeCode !== undefined && (!claudeCode || typeof claudeCode !== "object" || Array.isArray(claudeCode))) { ctx.addIssue({ code: "custom", path: ["claudeCode"], message: "claudeCode must be an object" }); } else if (claudeCode) { - const claude = claudeCode as { desktopProfile?: unknown; desktopMode?: unknown; intercept?: unknown; appendOnlyToolLoadingModels?: unknown }; + const claude = claudeCode as { desktopProfile?: unknown; desktopModelVisibility?: unknown; desktopMode?: unknown; intercept?: unknown; appendOnlyToolLoadingModels?: unknown }; if (claude.appendOnlyToolLoadingModels !== undefined && !isAppendOnlyToolLoadingModels(claude.appendOnlyToolLoadingModels)) { ctx.addIssue({ code: "custom", path: ["claudeCode", "appendOnlyToolLoadingModels"], message: "appendOnlyToolLoadingModels must be up to 128 provider/model selectors (max 512 characters each), optionally ending in a single wildcard" }); } @@ -468,6 +469,17 @@ export const configSchema = z.object({ }); } } + if (claude.desktopModelVisibility !== undefined) { + try { + parseDesktopModelVisibility(claude.desktopModelVisibility); + } catch (error) { + ctx.addIssue({ + code: "custom", + path: ["claudeCode", "desktopModelVisibility"], + message: error instanceof Error ? error.message : String(error), + }); + } + } } const accountNamespaces = config.codexAccountNamespaces; diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index 901590eb642..db957a73e72 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -972,13 +972,19 @@ async function handleAgentSettingsRoutesImpl(ctx: ManagementContext): Promise model.available && desktopRouteVisible(visibility, model.route))) { + throw new Error("Claude Desktop needs at least one visible available model"); + } const availableRoutes = new Set(current.models.filter(item => item.available).map(item => item.route)); for (const route of Object.keys(parsed.assignments)) { if (!current.profile.assignments[route] && !availableRoutes.has(route)) { @@ -1012,10 +1018,15 @@ async function handleAgentSettingsRoutesImpl(ctx: ManagementContext): Promise ({ route, label: modelByRoute.get(route)?.label ?? route, available: available.has(route), + // Desktop list membership (#7702), separate from the tier assignment below. + visible: desktopRouteVisible(visibility, route), ...(modelByRoute.get(route)?.contextWindow ? { contextWindow: modelByRoute.get(route)!.contextWindow } : {}), effortSupported: effortByRoute.get(route) ?? false, // Read-only view of the 1M capability the written Desktop config already emits, @@ -420,10 +424,21 @@ export async function buildClaudeDesktopState(config: OcxConfig, stored?: OcxCla supports1m: routeSupportsOneMillion(route, modelByRoute.get(route)?.contextWindow), assignment: profile.assignments[route]!, })); + const rendered = renderDesktopProfile(profile, profileModels); + const selected = filterDesktopModelsByVisibility( + rendered.map(model => ({ ...model, anthropicFamilyTier: model.family })), + rendered.map(model => model.route), + visibility, + ); + // Match the writer's empty-selection fallback without changing the saved intent. + const listed = selected.length > 0 ? selected : rendered; return { profile, models, - rendered: renderDesktopProfile(profile, profileModels), + rendered, + visibility: visibility ?? null, + // What the next apply writes, including generateDesktop3pModels' empty-selection fallback. + listed: listed.map(({ route, name, label, family, isFamilyDefault }) => ({ route, name, label, family, isFamilyDefault: !!isFamilyDefault })), port: config.port, }; } diff --git a/src/types.ts b/src/types.ts index 50ffa1f8b47..8da67a3db8b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -74,6 +74,7 @@ export type { OcxClaudeCodeConfig, OcxClaudeDesktopFamily, OcxClaudeDesktopAssignment, + OcxClaudeDesktopModelVisibility, OcxClaudeDesktopProfile, StorageCleanupPolicy, OcxCustomModel, diff --git a/src/types/config.ts b/src/types/config.ts index 30da5d1a18a..20ccaa54921 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -264,6 +264,18 @@ export interface OcxClaudeCodeConfig { * Routing-sidecar alias decoding is unchanged — only the Desktop model list writer. */ desktopNativeModels?: boolean; + /** + * Which models the Claude Desktop 3P list carries (src/claude/desktop-model-visibility.ts). + * Unset lists every available model. Independent of desktopProfile tiers and of + * provider-wide disabledModels; other clients are unaffected. + */ + desktopModelVisibility?: OcxClaudeDesktopModelVisibility; +} + +/** `include`: only `routes` (new models hidden). `exclude`: all but `routes` (new models listed). */ +export interface OcxClaudeDesktopModelVisibility { + mode: "include" | "exclude"; + routes: string[]; } export type OcxClaudeDesktopFamily = "opus" | "fable" | "sonnet" | "haiku"; diff --git a/structure/INDEX.md b/structure/INDEX.md index 47339cbdf98..766c596c25a 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -128,6 +128,7 @@ The dashboard, the management API, and third-party client config ownership. | [`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md) | Experimental macOS app-server stdout shim, opt-in launch, restore and failure boundaries. | | [`clients/claude-desktop.md`](clients/claude-desktop.md) | Claude Desktop profile ownership and config-library resolution. | | [`clients/claude-desktop-config-reads.md`](clients/claude-desktop-config-reads.md) | Verified regular-file reads of Claude Desktop config-library metadata, profiles and snapshots. | +| [`clients/claude-desktop-model-visibility.md`](clients/claude-desktop-model-visibility.md) | Per-client selection of the rows the Claude Desktop 3P model list carries. | | [`clients/hermes.md`](clients/hermes.md) | Hermes profile discovery, per-profile selection, ownership stores, and boundaries. | | [`clients/qoder.md`](clients/qoder.md) | Qoder settings.json provider and custom-model ownership, export and loopback placeholder rules. | | [`clients/client-support-matrix.md`](clients/client-support-matrix.md) | Generated client support page: registry sources, curated protocol and test claims, drift checks. | @@ -222,7 +223,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/chat/` | [`runtime.md`](runtime.md)
[`transports/byte-accounting.md`](transports/byte-accounting.md)
[`transports/inventory.md`](transports/inventory.md)
[`data-planes/inbound-compat.md`](data-planes/inbound-compat.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/chat-compat.md`](providers/chat-compat.md) | | `src/chat/reasoning-details.ts` | [`data-planes/chat-reasoning-details.md`](data-planes/chat-reasoning-details.md) | | `src/chatgpt/` | [`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md) | -| `src/claude/` | [`runtime.md`](runtime.md)
[`data-planes/claude-tool-loading.md`](data-planes/claude-tool-loading.md)
[`providers/anthropic-progress.md`](providers/anthropic-progress.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`clients/claude-desktop-config-reads.md`](clients/claude-desktop-config-reads.md) | +| `src/claude/` | [`runtime.md`](runtime.md)
[`data-planes/claude-tool-loading.md`](data-planes/claude-tool-loading.md)
[`providers/anthropic-progress.md`](providers/anthropic-progress.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`clients/claude-desktop-config-reads.md`](clients/claude-desktop-config-reads.md)
[`clients/claude-desktop-model-visibility.md`](clients/claude-desktop-model-visibility.md) | | `src/cli.ts` | [`runtime.md`](runtime.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `src/cli/` | [`runtime.md`](runtime.md)
[`config.md`](config.md)
[`remote-messaging.md`](remote-messaging.md)
[`local-messaging.md`](local-messaging.md)
[`cli-management.md`](cli-management.md)
[`provider-completion-diagnostic.md`](provider-completion-diagnostic.md)
[`fallback-policy.md`](fallback-policy.md)
[`clients/export-model-names.md`](clients/export-model-names.md)
[`clients/integrations.md`](clients/integrations.md)
[`clients/codex-desktop.md`](clients/codex-desktop.md)
[`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`clients/hermes.md`](clients/hermes.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `src/cli/account-preservation.ts` | [`providers/openai-signed-in-preservation.md`](providers/openai-signed-in-preservation.md) | @@ -358,7 +359,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/router.ts` | [`runtime.md`](runtime.md) | | `src/routing/` | [`catalog.md`](catalog.md)
[`fallback-policy.md`](fallback-policy.md) | | `src/routing/history/` | [`dashboard-and-usage.md`](dashboard-and-usage.md) | -| `src/server/` | [`transports/timeouts.md`](transports/timeouts.md)
[`providers/generic-account-pool.md`](providers/generic-account-pool.md)
[`runtime.md`](runtime.md)
[`codex-prompts.md`](codex-prompts.md)
[`catalog.md`](catalog.md)
[`subagents.md`](subagents.md)
[`transports/byte-accounting.md`](transports/byte-accounting.md)
[`transports/responses.md`](transports/responses.md)
[`transports/native-reasoning-retention.md`](transports/native-reasoning-retention.md)
[`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md)
[`transports/responses-failover.md`](transports/responses-failover.md)
[`transports/responses-combo-boundaries.md`](transports/responses-combo-boundaries.md)
[`transports/policy-fallback.md`](transports/policy-fallback.md)
[`transports/streaming-health.md`](transports/streaming-health.md)
[`transports/inventory.md`](transports/inventory.md)
[`data-planes/images.md`](data-planes/images.md)
[`data-planes/inbound-compat.md`](data-planes/inbound-compat.md)
[`providers/anthropic-progress.md`](providers/anthropic-progress.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/jev-decision.md`](providers/jev-decision.md)
[`providers/xai-grok.md`](providers/xai-grok.md)
[`adapters/registry.md`](adapters/registry.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`provider-completion-diagnostic.md`](provider-completion-diagnostic.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`fallback-policy.md`](fallback-policy.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`clients/hermes.md`](clients/hermes.md)
[`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | +| `src/server/` | [`transports/timeouts.md`](transports/timeouts.md)
[`providers/generic-account-pool.md`](providers/generic-account-pool.md)
[`runtime.md`](runtime.md)
[`codex-prompts.md`](codex-prompts.md)
[`catalog.md`](catalog.md)
[`subagents.md`](subagents.md)
[`transports/byte-accounting.md`](transports/byte-accounting.md)
[`transports/responses.md`](transports/responses.md)
[`transports/native-reasoning-retention.md`](transports/native-reasoning-retention.md)
[`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md)
[`transports/responses-failover.md`](transports/responses-failover.md)
[`transports/responses-combo-boundaries.md`](transports/responses-combo-boundaries.md)
[`transports/policy-fallback.md`](transports/policy-fallback.md)
[`transports/streaming-health.md`](transports/streaming-health.md)
[`transports/inventory.md`](transports/inventory.md)
[`data-planes/images.md`](data-planes/images.md)
[`data-planes/inbound-compat.md`](data-planes/inbound-compat.md)
[`providers/anthropic-progress.md`](providers/anthropic-progress.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/jev-decision.md`](providers/jev-decision.md)
[`providers/xai-grok.md`](providers/xai-grok.md)
[`adapters/registry.md`](adapters/registry.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`provider-completion-diagnostic.md`](provider-completion-diagnostic.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`fallback-policy.md`](fallback-policy.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`clients/claude-desktop-model-visibility.md`](clients/claude-desktop-model-visibility.md)
[`clients/hermes.md`](clients/hermes.md)
[`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | | `src/server/advisor-admission-scope.ts` | [`advisor.md`](advisor.md) | | `src/server/auth-cors.ts` | [`transports/response-turn-membership.md`](transports/response-turn-membership.md) | | `src/server/gui-pair-delivery.ts` | [`remote-link.md`](remote-link.md) | diff --git a/structure/clients/claude-desktop-config-reads.md b/structure/clients/claude-desktop-config-reads.md index db1cd7ab2aa..45ca5634ac0 100644 --- a/structure/clients/claude-desktop-config-reads.md +++ b/structure/clients/claude-desktop-config-reads.md @@ -2,7 +2,8 @@ Every read of Desktop library configuration — selected-profile inspection, `_meta.json` parsing, foreign-key preservation during writes, backup creation and rollback, refresh mode re-resolution, the refresh fingerprint hash, -the picker profile's inspection and apply snapshots, and the connection-owned store's metadata and +the picker profile's inspection and apply snapshots, the status route's applied model list +(`readAppliedDesktopModels`, names and labels only), and the connection-owned store's metadata and profile reads — goes through the shared verified regular-file reader in `src/claude/desktop-3p-library.ts` (`readDesktopLibraryBytes`/`readDesktopLibraryText`). The path `lstat` must already name a regular file; on POSIX the open is nonblocking and no-follow, so a path diff --git a/structure/clients/claude-desktop-model-visibility.md b/structure/clients/claude-desktop-model-visibility.md new file mode 100644 index 00000000000..84f08c9f21a --- /dev/null +++ b/structure/clients/claude-desktop-model-visibility.md @@ -0,0 +1,59 @@ +# Claude Desktop Model Visibility + +`claudeCode.desktopModelVisibility` chooses which rows the Claude Desktop 3P `inferenceModels` +list carries (#7702). It is parsed and applied in `src/claude/desktop-model-visibility.ts`. + +The selection has two shapes. `include` lists only its `routes`, so a model discovered later stays +hidden until someone selects it. `exclude` lists everything except its `routes`, so a new model is +listed. Unset means every available model is listed, which is what installs did before the +setting existed. Routes are stored sorted and unique, capped at `MAX_DESKTOP_VISIBILITY_ROUTES`. + +The selection is kept apart from three other settings: + +- **The Desktop profile.** `claudeCode.desktopProfile` holds tier families, aliases and family + defaults. A hidden model keeps its assignment and alias, so showing it again restores the same + Desktop id. Profile saves never rewrite the selection, and the selection never rewrites the + profile. +- **Provider-wide visibility.** `disabledModels` and provider model lists still decide what is + available at all. The selection can only narrow what Desktop lists from that set. +- **Other clients.** Only the static and hybrid Desktop writers read the selection. Claude Code, + Codex and `/v1/models` discovery are unchanged. Discovery-only mode emits no static list, so the + selection does not apply to it. + +## Where it applies + +`generateDesktop3pModels` builds the full list and the alias registry first, then filters only +the emitted rows by route. Every writer gets the selection through `desktop3pModelOptions(claudeCode)`: +the local gateway writer (`writeDesktop3pConfig`, which reads the latest config under its locks), +catalog-driven refreshes, and the hub's `wantsDesktopConfig` export, which connected clients +write verbatim. The decode registry keeps hidden routes, so a running Desktop session that still +sends a just-hidden alias keeps routing. + +When a hidden row was a family default, the first remaining member of that family takes the role. +Defaults are moved back to the front, which keeps the order `renderDesktopProfile` writes. A +selection that would leave the list empty writes the full list and logs a warning, because Desktop +with an empty static list has no usable picker. The management API refuses to save such a selection. + +## Persistence and the dashboard + +`PUT /api/claude-desktop` (`src/server/management/agent-settings-routes.ts`) accepts `visibility` +alongside `profile`. An absent field keeps the +stored selection, `null` clears it, and an object replaces it. The selection is written in the +same field-scoped config mutation as the profile. A supplied replacement or clear compares the +selection again inside that mutation; a concurrent visibility edit during discovery returns 409. +An omitted selection preserves the latest persisted value. `GET /api/claude-desktop` (`buildClaudeDesktopState` in +`src/server/management/shared.ts`) returns the selection, +a per-model `visible` flag, and `listed`, the rows the next apply writes. `GET +/api/claude-desktop/status` returns `appliedModels`, the selected profile's on-disk names and labels, +read through the verified library reader. This nullable list does not prove which models the +Desktop process has loaded; a restart or managed policy can affect that. The dashboard derives its selected, available and applied +counts and its added/removed preview from these fields. `listed` includes the writer's full-list +fallback when every selected model is unavailable, without changing the saved selection or default. +When the saved selection differs from the applied list, the status bar reports a pending apply even +while a newer draft has unsaved edits. + +A malformed hand-edited selection fails config validation at +`claudeCode.desktopModelVisibility`. Salvage drops that one field, so the rest of the config loads. + +Tests: `tests/claude-integration/claude-desktop-model-visibility.test.ts`, +`gui/tests/claude-desktop-visibility.test.ts`, and `gui/tests/claude-desktop-visibility-card.test.tsx`. diff --git a/structure/manifest.json b/structure/manifest.json index 4833242bf0b..693fa4cdd2d 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -957,6 +957,16 @@ "src/claude/" ] }, + { + "path": "clients/claude-desktop-model-visibility.md", + "tier": 5, + "title": "Claude Desktop Model Visibility", + "scope": "Per-client selection of the rows the Claude Desktop 3P model list carries.", + "documents": [ + "src/claude/", + "src/server/" + ] + }, { "path": "clients/hermes.md", "tier": 5, diff --git a/tests/claude-integration/claude-desktop-model-visibility.test.ts b/tests/claude-integration/claude-desktop-model-visibility.test.ts new file mode 100644 index 00000000000..06c5d14b2fa --- /dev/null +++ b/tests/claude-integration/claude-desktop-model-visibility.test.ts @@ -0,0 +1,258 @@ +import { afterEach, beforeEach, expect, setDefaultTimeout, spyOn, test } from "bun:test"; +import { managementFetch as fetch } from "../helpers/management-auth"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadConfig, saveConfig } from "../../src/config"; +import { startServer as startServerImpl } from "../../src/server"; +import { + desktop3pAlias, + desktop3pModelOptions, + generateDesktop3pConfig, + generateDesktop3pModels, + removeDesktop3pStandardPivot, + resolveDesktop3pAlias, + writeDesktop3pConfig, +} from "../../src/claude/desktop-3p"; +import { reconcileDesktopProfile, moveDesktopRoute } from "../../src/claude/desktop-profile"; +import { + desktopRouteVisible, + filterDesktopModelsByVisibility, + parseDesktopModelVisibility, +} from "../../src/claude/desktop-model-visibility"; +import type { OcxConfig } from "../../src/types"; +import { buildClaudeDesktopState } from "../../src/server/management/shared"; +import * as desktopState from "../../src/server/management/shared"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeAfterBackgroundWork } from "../helpers/remove-tree"; +import { isolateClientLifecycleLock } from "../helpers/client-lifecycle-lock"; + +/* + * #7702: a per-client Claude Desktop model selection. It narrows only the written Desktop + * list; the alias registry, the tier profile and every other client stay as they were. + */ + +setDefaultTimeout(30_000); + +const ROUTED = [ + { provider: "google", id: "gemini-3-pro" }, + { provider: "deepseek", id: "deepseek-v4" }, + { provider: "xai", id: "grok-5" }, +]; + +test("parse accepts include/exclude, sorts and dedupes routes, and rejects malformed input", () => { + expect(parseDesktopModelVisibility({ mode: "include", routes: ["xai/grok-5", "google/gemini-3-pro", "xai/grok-5"] })) + .toEqual({ mode: "include", routes: ["google/gemini-3-pro", "xai/grok-5"] }); + expect(() => parseDesktopModelVisibility({ mode: "all", routes: [] })).toThrow("mode"); + expect(() => parseDesktopModelVisibility({ mode: "include", routes: ["no-slash"] })).toThrow("provider/model"); + expect(() => parseDesktopModelVisibility({ mode: "include", routes: [], extra: 1 })).toThrow("unknown field"); + expect(() => parseDesktopModelVisibility(["google/gemini-3-pro"])).toThrow("object"); +}); + +test("include hides newly discovered routes, exclude lists them", () => { + expect(desktopRouteVisible(undefined, "new/model")).toBe(true); + expect(desktopRouteVisible({ mode: "include", routes: ["google/gemini-3-pro"] }, "new/model")).toBe(false); + expect(desktopRouteVisible({ mode: "exclude", routes: ["xai/grok-5"] }, "new/model")).toBe(true); + expect(desktopRouteVisible({ mode: "exclude", routes: ["xai/grok-5"] }, "xai/grok-5")).toBe(false); +}); + +test("the written list drops hidden rows while the registry still decodes them", () => { + const visibility = { mode: "exclude" as const, routes: ["google/gemini-3-pro"] }; + const models = generateDesktop3pModels([], ROUTED, undefined, undefined, { visibility }); + const hiddenAlias = desktop3pAlias("google", "gemini-3-pro"); + expect(models.map(model => model.name)).not.toContain(hiddenAlias); + expect(models).toHaveLength(2); + // A running Desktop session that still sends the hidden alias keeps routing. + expect(resolveDesktop3pAlias(hiddenAlias)).toBe("google/gemini-3-pro"); + // The hidden row was the family default; exactly one remaining row takes the role. + expect(models.filter(model => model.isFamilyDefault)).toHaveLength(1); + expect(models[0]!.isFamilyDefault).toBe(true); +}); + +test("a hidden profile default hands the family role to a remaining member", () => { + const profileModels = ROUTED.map(model => ({ route: `${model.provider}/${model.id}`, label: model.id })); + let profile = reconcileDesktopProfile(undefined, profileModels); + profile = moveDesktopRoute(profile, "deepseek/deepseek-v4", "sonnet", true); + const all = generateDesktop3pModels([], ROUTED, profile); + const hidden = generateDesktop3pModels([], ROUTED, profile, undefined, { + visibility: { mode: "exclude", routes: [profile.defaults.opus!] }, + }); + expect(all).toHaveLength(3); + expect(hidden).toHaveLength(2); + for (const family of ["opus", "sonnet"] as const) { + expect(hidden.filter(model => model.anthropicFamilyTier === family && model.isFamilyDefault)).toHaveLength(1); + } + // Defaults still lead the list, as renderDesktopProfile writes it. + expect(hidden.slice(0, 2).every(model => model.isFamilyDefault)).toBe(true); +}); + +test("a selection that hides every model falls back to the full list instead of an empty picker", () => { + const models = generateDesktop3pModels([], ROUTED, undefined, undefined, { visibility: { mode: "include", routes: ["gone/model"] } }); + expect(models).toHaveLength(3); +}); + +test("filter keeps the original list when nothing is hidden", () => { + const rows = [{ anthropicFamilyTier: "opus", isFamilyDefault: true }, { anthropicFamilyTier: "opus" }]; + expect(filterDesktopModelsByVisibility(rows, ["a/x", "b/y"], { mode: "exclude", routes: [] })).toEqual(rows); +}); + +test("the generated config and model options carry the persisted selection; an unreadable one lists everything", () => { + const options = desktop3pModelOptions({ desktopModelVisibility: { mode: "include", routes: ["xai/grok-5"] } }); + const config = generateDesktop3pConfig(10100, [], ROUTED, "ocx", "static", undefined, undefined, options) as { inferenceModels: Array<{ name: string }> }; + expect(config.inferenceModels.map(model => model.name)).toEqual([desktop3pAlias("xai", "grok-5")]); + expect(desktop3pModelOptions({ desktopModelVisibility: { mode: "bogus" } }).visibility).toBeUndefined(); +}); + +let testDir = ""; +let previousHome: string | undefined; +let previousDesktopConfigDir: string | undefined; +let isolatedCodexHome: IsolatedCodexHome | null = null; +let lifecycleLock: ReturnType | null = null; + +function startServer() { + return startServerImpl(0, { + managementApi: { + writeDesktop3pConfig: (port, slugs, models, key, mode, profile, cap) => + writeDesktop3pConfig(port, slugs, models, key, mode, profile, cap, { lockPath: join(testDir, "lifecycle.sqlite") }), + removeDesktop3pStandardPivot: options => removeDesktop3pStandardPivot({ ...options, lifecycleLockDeps: { lockPath: join(testDir, "lifecycle.sqlite") } }), + }, + }); +} + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + previousDesktopConfigDir = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; + isolatedCodexHome = installIsolatedCodexHome("ocx-desktop-visibility-"); + testDir = mkdtempSync(join(tmpdir(), "ocx-desktop-visibility-")); + lifecycleLock = isolateClientLifecycleLock(testDir); + process.env.OPENCODEX_HOME = testDir; + process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = join(testDir, "claude-desktop"); + saveConfig({ + port: 0, + defaultProvider: "mock", + providers: { + mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1:1/v1", apiKey: "k", allowPrivateNetwork: true, liveModels: false, models: ["keep-model", "hide-model"] }, + }, + claudeCode: { desktopNativeModels: false }, + } as OcxConfig); +}); + +afterEach(async () => { + if (testDir) await removeTreeAfterBackgroundWork(testDir); + lifecycleLock?.restore(); + lifecycleLock = null; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (previousDesktopConfigDir === undefined) delete process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; + else process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = previousDesktopConfigDir; + isolatedCodexHome?.restore(); + isolatedCodexHome = null; +}); + +test("an invalid hand-edited selection is dropped alone; providers survive", () => { + const path = join(testDir, "config.json"); + const raw = JSON.parse(readFileSync(path, "utf8")) as Record; + raw.claudeCode = { ...raw.claudeCode, desktopModelVisibility: { mode: "include", routes: "mock/keep-model" } }; + writeFileSync(path, JSON.stringify(raw)); + const loaded = loadConfig(); + expect(Object.keys(loaded.providers)).toContain("mock"); + expect(loaded.claudeCode?.desktopNativeModels).toBe(false); + expect(loaded.claudeCode?.desktopModelVisibility).toBeUndefined(); +}); + +test("dashboard save persists the selection, apply writes only selected rows, and status reports them", async () => { + const server = startServer(); + const api = (path: string, init?: RequestInit) => fetch(new URL(path, server.url), init); + const put = (body: unknown) => api("/api/claude-desktop", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); + try { + const initial = await api("/api/claude-desktop").then(r => r.json()) as Record; + expect(initial.visibility).toBeNull(); + expect(initial.listed.map((row: { route: string }) => row.route).sort()).toEqual(["mock/hide-model", "mock/keep-model"]); + + // Hiding everything is refused: Desktop would have nothing to pick. + const refused = await put({ profile: initial.profile, visibility: { mode: "include", routes: [] } }); + expect(refused.status).toBe(400); + expect(loadConfig().claudeCode?.desktopModelVisibility).toBeUndefined(); + + const saved = await put({ profile: initial.profile, visibility: { mode: "include", routes: ["mock/keep-model"] } }); + expect(saved.status).toBe(200); + const savedBody = await saved.json() as Record; + expect(savedBody.visibility).toEqual({ mode: "include", routes: ["mock/keep-model"] }); + expect(savedBody.models.find((model: { route: string }) => model.route === "mock/hide-model").visible).toBe(false); + expect(savedBody.listed.map((row: { route: string }) => row.route)).toEqual(["mock/keep-model"]); + // Tier mapping is untouched: the hidden model keeps its assignment. + expect(loadConfig().claudeCode?.desktopProfile?.assignments["mock/hide-model"]).toBeDefined(); + expect(loadConfig().claudeCode?.desktopModelVisibility).toEqual({ mode: "include", routes: ["mock/keep-model"] }); + + // A later profile-only save keeps the selection. + expect((await put({ profile: savedBody.profile })).status).toBe(200); + expect(loadConfig().claudeCode?.desktopModelVisibility?.mode).toBe("include"); + + const apply = await api("/api/claude-desktop/apply", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode: "gateway" }) }); + expect(apply.status).toBe(200); + const { path } = await apply.json() as { path: string }; + const written = JSON.parse(readFileSync(path, "utf8")) as { inferenceModels: Array<{ name: string }> }; + const keepName = savedBody.listed[0].name as string; + expect(written.inferenceModels.map(model => model.name)).toEqual([keepName]); + + const status = await api("/api/claude-desktop/status").then(r => r.json()) as { appliedModels: Array<{ name: string; label: string }> | null }; + expect(status.appliedModels?.map(model => model.name)).toEqual([keepName]); + + // null clears the selection: every model is listed again. + expect((await put({ profile: savedBody.profile, visibility: null })).status).toBe(200); + expect(loadConfig().claudeCode?.desktopModelVisibility).toBeUndefined(); + } finally { + await server.stop(true); + } +}); + +test("the next-apply projection matches the writer when every selected model disappears", async () => { + const config = loadConfig(); + const initial = await buildClaudeDesktopState(config); + const profile = reconcileDesktopProfile(initial.profile, [{ route: "mock/gone", label: "Gone" }]); + profile.defaults.opus = "mock/gone"; + config.claudeCode = { + ...config.claudeCode, + desktopProfile: profile, + desktopModelVisibility: { mode: "include", routes: ["mock/gone"] }, + }; + const state = await buildClaudeDesktopState(config); + const written = generateDesktop3pModels([], [ + { provider: "mock", id: "keep-model" }, { provider: "mock", id: "hide-model" }, + ], state.profile, undefined, desktop3pModelOptions(config.claudeCode)); + expect(state.models.find(model => model.route === "mock/gone")?.available).toBe(false); + expect(state.visibility).toEqual({ mode: "include", routes: ["mock/gone"] }); + expect(state.profile.defaults.opus).toBe("mock/gone"); + expect(state.listed.map(model => model.name)).toEqual(written.map(model => model.name)); + expect(state.listed).toHaveLength(2); +}); + +test("a visibility change during discovery refuses a stale replacement", async () => { + const server = startServer(); + const endpoint = new URL("/api/claude-desktop", server.url); + const put = (body: unknown) => fetch(endpoint, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); + let discovery: ReturnType | undefined; + try { + const initial = await fetch(endpoint).then(response => response.json()) as { profile: unknown }; + expect((await put({ profile: initial.profile })).status).toBe(200); + const buildState = desktopState.buildClaudeDesktopState; + let changed = false; + discovery = spyOn(desktopState, "buildClaudeDesktopState").mockImplementation(async (...args) => { + const result = await buildState(...args); + if (!changed) { + changed = true; + const concurrent = loadConfig(); + concurrent.claudeCode = { ...concurrent.claudeCode, desktopModelVisibility: { mode: "include", routes: ["mock/keep-model"] } }; + saveConfig(concurrent); + } + return result; + }); + const response = await put({ profile: initial.profile, visibility: { mode: "include", routes: ["mock/hide-model"] } }); + expect(changed).toBe(true); + expect(response.status).toBe(409); + expect(loadConfig().claudeCode?.desktopModelVisibility).toEqual({ mode: "include", routes: ["mock/keep-model"] }); + } finally { + discovery?.mockRestore(); + await server.stop(true); + } +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index fd8a2182135..0ff13d3cba4 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -439,7 +439,7 @@ "claude-cli-tool-capture.test.ts": "providers", "claude-cli-usage-admission.test.ts": "providers", "claude-config-first-party.test.ts": "cli", "claude-code-thought-signature-scope.test.ts": "claude-integration", "claude-compatibility.test.ts": "claude-integration", "claude-context-windows.test.ts": "claude-integration", - "claude-desktop-1m.test.ts": "claude-integration", "claude-desktop-cli.test.ts": "claude-integration", + "claude-desktop-1m.test.ts": "claude-integration", "claude-desktop-cli.test.ts": "claude-integration", "claude-desktop-model-visibility.test.ts": "claude-integration", "claude-desktop-config-path.test.ts": "claude-integration", "claude-desktop-discovery.test.ts": "claude-integration", "claude-desktop-first-party.test.ts": "claude-integration", "claude-desktop-first-party-guards.test.ts": "claude-integration", From 6c49278cb7764939f57735ff42935243a0ee32ae Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 29 Oct 2026 13:16:36 +0900 Subject: [PATCH 16/84] fix(oauth): explain why Antigravity project discovery failed (#7888) * fix(oauth): sanitize Antigravity discovery failure diagnostics * fix(oauth): surface sanitized Antigravity discovery failures * fix(oauth): end Antigravity onboarding explicitly on a null body A JSON null onboardUser body threw a TypeError on data.done. Without a loadCodeAssist account that onboarding call has no catch, so the TypeError replaced the recorded discovery error with generic OAuth text. Return no project instead, keeping the existing one-poll budget. --------- Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> --- .../src/content/docs/guides/providers.md | 7 + .../antigravity-discovery-diagnostics.ts | 65 ++++++ src/oauth/google-antigravity.ts | 33 +++- src/oauth/index.ts | 2 + structure/providers/anthropic-account-pool.md | 8 + .../google/google-antigravity-oauth.test.ts | 186 ++++++++++++++++++ tests/oauth/oauth-login-cli-errors.test.ts | 77 ++++++++ 7 files changed, 368 insertions(+), 10 deletions(-) create mode 100644 src/oauth/antigravity-discovery-diagnostics.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 42e619b6fe4..83b442b2d22 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -218,6 +218,13 @@ Cursor, Kiro, Zed Hosted AI and Nous Portal are login-only and have no API-key e login-only too: `ocx login google-antigravity` signs in with your Google account over the Cloud Code Assist wire, and the `google` preset beside it is the AI Studio Gemini API — a different product reached with its own key, not a key mode for the same login. +If Antigravity login cannot discover a Cloud Code Assist project, the failure includes a sanitized +summary for `loadCodeAssist` and the last `onboardUser` response: HTTP status, response category, +completion/error presence and known project-field types. It distinguishes HTTP refusal, unusable +JSON, invalid roots, unfinished onboarding and completion with an error or without a project. +The summary omits upstream values, arbitrary key names and account/project identifiers. Login +still requires a discovered project and saves no account when discovery fails. + The Antigravity Accounts tab shows an observed membership tier as a badge beside the account label, using the upstream tier ID (for example `free-tier` or `gemini-pro`). No badge appears when the plan is unknown; it does not affect routing or quota. diff --git a/src/oauth/antigravity-discovery-diagnostics.ts b/src/oauth/antigravity-discovery-diagnostics.ts new file mode 100644 index 00000000000..78b5c373e62 --- /dev/null +++ b/src/oauth/antigravity-discovery-diagnostics.ts @@ -0,0 +1,65 @@ +/** Content-free discovery observations. Never enumerate upstream keys or serialize its objects. */ +function projectValueType(value: unknown): string { + if (value === undefined) return "absent"; + if (typeof value === "string") return "string"; + if (value && typeof value === "object" && !Array.isArray(value)) { + return typeof (value as { id?: unknown }).id === "string" ? "object-with-id" : "object-without-id"; + } + return "other"; +} + +function projectTypes(data: unknown): Record { + const object = data && typeof data === "object" && !Array.isArray(data) + ? data as Record : undefined; + return { + cloudaicompanionProject: projectValueType(object?.cloudaicompanionProject), + projectId: projectValueType(object?.projectId), + project: projectValueType(object?.project), + }; +} + +/** Published only when the existing parser did not discover a project; never used for admission. + * `undefined` means JSON was unparseable or, for HTTP refusals, deliberately not inspected. + */ +export function summarizeAntigravityDiscovery( + stage: "loadCodeAssist" | "onboardUser", + status: number, + data: unknown, +): string { + const root = data === undefined ? "unparseable" : Array.isArray(data) ? "array" + : data && typeof data === "object" ? "object" : "other"; + const object = root === "object" ? data as Record : undefined; + const done = object?.done === undefined ? "absent" : object.done === true ? "true" + : object.done === false ? "false" : "other"; + const error = object && Object.hasOwn(object, "error") ? "present" : "absent"; + const outcome = status < 200 || status >= 300 ? "http-refusal" + : root === "unparseable" ? "unparseable" : root !== "object" ? "invalid-root" + : stage === "loadCodeAssist" ? "no-project" + : done !== "true" ? "pending" : error === "present" ? "done-with-error" : "done-without-project"; + return JSON.stringify({ + stage, status, root, outcome, done, error, + projects: projectTypes(object), + response: projectTypes(object?.response), + }); +} + +const DISCOVERY_FAILURE_MESSAGE = "Antigravity login could not discover a Cloud Code Assist project for this account. Ensure the account has Antigravity/Cloud Code Assist access and try again."; + +/** Only sanitizer outputs can enter this error's public message; raw responses are never retained. */ +export class AntigravityProjectDiscoveryError extends Error { + #summaries: string[] = []; + + constructor() { + super(DISCOVERY_FAILURE_MESSAGE); + this.name = "AntigravityProjectDiscoveryError"; + } + + observe(stage: "loadCodeAssist" | "onboardUser", status: number, data: unknown): void { + this.#summaries[stage === "loadCodeAssist" ? 0 : 1] = summarizeAntigravityDiscovery(stage, status, data); + this.message = this.publicMessage; + } + + get publicMessage(): string { + return `${DISCOVERY_FAILURE_MESSAGE} Discovery: ${this.#summaries.join("; ")}`; + } +} diff --git a/src/oauth/google-antigravity.ts b/src/oauth/google-antigravity.ts index f8e8a12b19c..3dcc84200fa 100644 --- a/src/oauth/google-antigravity.ts +++ b/src/oauth/google-antigravity.ts @@ -14,6 +14,7 @@ import { generatePKCE } from "./pkce"; import { normalizeOAuthPlan } from "./plan"; import type { OAuthController, OAuthCredentials } from "./types"; import { antigravityUserAgent, ANTIGRAVITY_IDE_VERSION } from "../adapters/client-fingerprint"; +import { AntigravityProjectDiscoveryError } from "./antigravity-discovery-diagnostics"; const CLIENT_ID = process.env.GOOGLE_ANTIGRAVITY_CLIENT_ID || "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"; @@ -101,15 +102,19 @@ function extractProjectId(data: Record | undefined): string | u return undefined; } -async function loadCodeAssistProject(accessToken: string, signal?: AbortSignal): Promise<{ projectId?: string; plan: string | null; defaultTierId?: string } | undefined> { +async function loadCodeAssistProject(accessToken: string, signal?: AbortSignal, diagnostics?: AntigravityProjectDiscoveryError): Promise<{ projectId?: string; plan: string | null; defaultTierId?: string } | undefined> { const response = await fetch(`${PROD_API}/${API_VERSION}:loadCodeAssist`, { method: "POST", headers: { Authorization: `Bearer ${accessToken}`, Accept: "*/*", "Content-Type": "application/json", "User-Agent": antigravityUserAgent() }, body: JSON.stringify({ metadata: { ideType: "ANTIGRAVITY" } }), signal: requestSignal(signal), }); - if (!response.ok) return undefined; + if (!response.ok) { + diagnostics?.observe("loadCodeAssist", response.status, undefined); + return undefined; + } const data: unknown = await response.json().catch(() => undefined); + diagnostics?.observe("loadCodeAssist", response.status, data); if (!data || typeof data !== "object" || Array.isArray(data)) return undefined; const payload = data as Record; const tier = (value: unknown) => value && typeof value === "object" && "id" in value @@ -133,7 +138,7 @@ async function loadCodeAssistProject(accessToken: string, signal?: AbortSignal): }; } -async function onboardProject(accessToken: string, signal?: AbortSignal, tierId = "free-tier"): Promise { +async function onboardProject(accessToken: string, signal?: AbortSignal, tierId = "free-tier", diagnostics?: AntigravityProjectDiscoveryError): Promise { for (let attempt = 0; attempt < ONBOARD_ATTEMPTS; attempt++) { if (signal?.aborted) throw signal.reason ?? new Error("Antigravity onboarding aborted"); const response = await fetch(`${DAILY_API}/${API_VERSION}:onboardUser`, { @@ -148,6 +153,7 @@ async function onboardProject(accessToken: string, signal?: AbortSignal, tierId signal: requestSignal(signal), }); if (!response.ok) { + diagnostics?.observe("onboardUser", response.status, undefined); // Transient (429/5xx): keep polling within the attempt budget. Hard 4xx: give up now. if (response.status === 429 || response.status >= 500) { await new Promise(resolve => setTimeout(resolve, ONBOARD_POLL_MS)); @@ -155,7 +161,13 @@ async function onboardProject(accessToken: string, signal?: AbortSignal, tierId } return undefined; } - const data = (await response.json().catch(() => ({}))) as Record; + let unparseable = false; + const data = (await response.json().catch(() => { unparseable = true; return {}; })) as Record; + // Keep only the last poll, and only sanitized text. Parsing/admission below is unchanged. + diagnostics?.observe("onboardUser", response.status, unparseable ? undefined : data); + // A JSON `null` used to end polling with a TypeError on `data.done`; end it explicitly instead, + // keeping the one-poll budget, so the caller still raises the recorded discovery error. + if (data === null) return undefined; if (data.done === true) { return extractProjectId(data.response as Record | undefined); } @@ -169,13 +181,13 @@ export async function discoverAntigravityProject(accessToken: string, signal?: A return (await discoverAntigravityAccount(accessToken, signal)).projectId; } -async function discoverAntigravityAccount(accessToken: string, signal?: AbortSignal): Promise<{ projectId?: string; plan?: string | null }> { - const account = await loadCodeAssistProject(accessToken, signal); +async function discoverAntigravityAccount(accessToken: string, signal?: AbortSignal, diagnostics?: AntigravityProjectDiscoveryError): Promise<{ projectId?: string; plan?: string | null }> { + const account = await loadCodeAssistProject(accessToken, signal, diagnostics); // Once loadCodeAssist answered, an onboarding failure must not discard its tier observation; // a missing project still fails login, and cancellation still propagates. const projectId = account?.projectId ?? await (account - ? onboardProject(accessToken, signal, account.defaultTierId).catch(error => { if (signal?.aborted) throw error; return undefined; }) - : onboardProject(accessToken, signal)); + ? onboardProject(accessToken, signal, account.defaultTierId, diagnostics).catch(error => { if (signal?.aborted) throw error; return undefined; }) + : onboardProject(accessToken, signal, undefined, diagnostics)); // An unanswered loadCodeAssist observed nothing; only a parsed body may clear a stored label. return { projectId, ...(account ? { plan: account.plan } : {}) }; } @@ -248,11 +260,12 @@ class AntigravityOAuthFlow extends OAuthCallbackFlow { }, this.ctrl.signal); const creds = credentialsFromPayload(payload); this.ctrl.onProgress?.("Discovering Cloud Code Assist project"); - const { projectId, plan } = await discoverAntigravityAccount(creds.access, this.ctrl.signal); + const diagnostics = new AntigravityProjectDiscoveryError(); + const { projectId, plan } = await discoverAntigravityAccount(creds.access, this.ctrl.signal, diagnostics); if (!projectId) { // Fail the login rather than persisting a credential that every request would reject for a // missing CCA project — otherwise status shows "logged in" while all calls fail closed. - throw new Error("Antigravity login could not discover a Cloud Code Assist project for this account. Ensure the account has Antigravity/Cloud Code Assist access and try again."); + throw diagnostics; } return { ...creds, projectId, plan }; } diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 2c04cbbe4bb..fab4ef78aa8 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -43,6 +43,7 @@ import { loginKimi, refreshKimiToken } from "./kimi"; import { loginNous, NousTokenError, refreshNousToken, clearNousRefreshIntent, RefreshIntentIOError } from "./nous"; import { ChatGptTokenRequestError, ChatGptTokenError, loginChatGPT, refreshChatGPTToken, type ChatGPTLoginFlow } from "./chatgpt"; import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity"; +import { AntigravityProjectDiscoveryError } from "./antigravity-discovery-diagnostics"; import { loginCursor, refreshCursorToken } from "./cursor"; import { loginZed, refreshZedToken } from "./zed"; import { assertDevinCliAdoptionOwnership, loginDevin, refreshDevinToken } from "./devin"; @@ -512,6 +513,7 @@ class OAuthLoginSupersededError extends Error { /** Project arbitrary OAuth failures onto the small, stable public error vocabulary. */ export function publicOAuthAuthenticationErrorMessage(error: unknown): string { + if (error instanceof AntigravityProjectDiscoveryError) return error.publicMessage; if (error instanceof MuseLoginError) return museLoginErrorMessage(error.kind, error.status); if (error instanceof OAuthMutationBusyError) { return error.message === "OAuth mutation queue wait timed out" diff --git a/structure/providers/anthropic-account-pool.md b/structure/providers/anthropic-account-pool.md index 76bcf5cca20..0770a80b59d 100644 --- a/structure/providers/anthropic-account-pool.md +++ b/structure/providers/anthropic-account-pool.md @@ -54,6 +54,14 @@ most 64 characters in `[a-z0-9_.-]`. `src/oauth/store.ts` preserves this allowli `src/oauth/index.ts` projects validated stored labels for every provider, with explicit null for an unknown subscription. Subscription labels never establish identity, entitlement or quota capacity. +Antigravity failed-login text includes a content-free discovery summary from +`src/oauth/antigravity-discovery-diagnostics.ts`: stage, HTTP status, root category, done/error +presence and types of only the three known project fields at the root and fixed `response` container. +Only the last response per stage is retained; values and arbitrary keys never enter the summary. +`AntigravityProjectDiscoveryError` constructs this public text only from sanitizer outputs; +the public OAuth mapper preserves it for CLI reporting and background login/status settlement. +Project-required admission, import codes, polling and request budgets remain unchanged. + ## Discovery credential ownership `src/oauth/model-discovery-auth.ts` checks the configured Pool 2 row before a diff --git a/tests/adapters/google/google-antigravity-oauth.test.ts b/tests/adapters/google/google-antigravity-oauth.test.ts index 6b03cfc0b5d..cb094cde7b0 100644 --- a/tests/adapters/google/google-antigravity-oauth.test.ts +++ b/tests/adapters/google/google-antigravity-oauth.test.ts @@ -6,8 +6,13 @@ import { join } from "node:path"; import { getCredential, saveCredential } from "../../../src/oauth/store"; import { ANTIGRAVITY_IDE_VERSION } from "../../../src/adapters/client-fingerprint"; import { removeTreeWithRetry } from "../../helpers/remove-tree"; +import { OAuthCallbackFlow } from "../../../src/oauth/callback-server"; +import { publicOAuthAuthenticationErrorMessage, runLogin } from "../../../src/oauth"; +import { getDefaultConfig } from "../../../src/config"; +import { AntigravityProjectDiscoveryError, summarizeAntigravityDiscovery } from "../../../src/oauth/antigravity-discovery-diagnostics"; const realFetch = globalThis.fetch; +const secret = "PRIVATE_DISCOVERY_SENTINEL"; const prevGoogleUa = process.env.GOOGLE_ANTIGRAVITY_USER_AGENT; beforeEach(() => { delete process.env.GOOGLE_ANTIGRAVITY_USER_AGENT; }); afterEach(() => { @@ -26,6 +31,187 @@ function routeFetch(handler: (url: string, init?: RequestInit) => Response | Pro return { calls }; } +// Exercise the real exchange and runLogin admission, without a browser/listening socket or writes. +async function failedDiscoveryLogin(load: () => Response, onboard: () => Response): Promise { + routeFetch(url => url.includes("oauth2.googleapis.com/token") + ? Response.json({ access_token: secret, refresh_token: secret }) + : url.includes(":loadCodeAssist") ? load() : onboard()); + const flow = spyOn(OAuthCallbackFlow.prototype, "login").mockImplementation(async function () { + await this.generateAuthUrl("state", "http://127.0.0.1/callback"); + return this.exchangeToken("code", "state", "http://127.0.0.1/callback"); + }); + let writes = 0; + const rejectWrite = async (): Promise => { writes++; throw new Error("unexpected persistence"); }; + try { + let caught: unknown; + try { + await runLogin("google-antigravity", { signal: new AbortController().signal, onAuth() {} }, undefined, { + loadConfig: getDefaultConfig, + saveCredential: rejectWrite, + saveAccountCredential: rejectWrite, + saveConfig: rejectWrite, + mutatePersistedConfig: rejectWrite, + }); + } catch (error) { caught = error; } + expect(caught).toBeInstanceOf(Error); + expect(writes).toBe(0); + const message = (caught as Error).message; + expect(message).toContain("could not discover a Cloud Code Assist project"); + expect(message).not.toContain(secret); + return message; + } finally { flow.mockRestore(); } +} + +describe("antigravity sanitized failed-login discovery diagnostics", () => { + test("typed error projects only sanitized observations even when message and cause are overwritten", () => { + const error = new AntigravityProjectDiscoveryError(); + error.observe("loadCodeAssist", 403, undefined); + error.observe("onboardUser", 200, { done: false, [secret]: secret }); + error.observe("onboardUser", 200, { done: true, error: { message: secret } }); + const safeMessage = error.message; + error.message = secret; + error.cause = new Error(secret); + const projected = publicOAuthAuthenticationErrorMessage(error); + expect(projected).toBe(safeMessage); + expect(projected).toContain('"stage":"loadCodeAssist","status":403'); + expect(projected).toContain('"outcome":"done-with-error"'); + expect(projected).not.toContain('"outcome":"pending"'); + expect(projected.match(/"stage":"onboardUser"/g)).toHaveLength(1); + expect(projected).not.toContain(secret); + }); + + test("pure summary contains only fixed fields, enums and status with private keys/values", () => { + const summary = summarizeAntigravityDiscovery("onboardUser", 200, { + [secret]: secret, done: true, error: { [secret]: secret }, name: secret, + cloudaicompanionProject: secret, projectId: { id: secret, [secret]: secret }, project: { [secret]: secret }, + response: { [secret]: secret, cloudaicompanionProject: [secret], projectId: null }, + }); + expect(summary).not.toContain(secret); + expect(JSON.parse(summary)).toEqual({ + stage: "onboardUser", status: 200, root: "object", outcome: "done-with-error", done: "true", error: "present", + projects: { cloudaicompanionProject: "string", projectId: "object-with-id", project: "object-without-id" }, + response: { cloudaicompanionProject: "other", projectId: "other", project: "absent" }, + }); + }); + + for (const [label, load, outcome, root] of [ + ["HTTP refusal", () => new Response(secret, { status: 403 }), "http-refusal", "unparseable"], + ["malformed JSON", () => new Response(secret), "unparseable", "unparseable"], + ["array root", () => Response.json([{ [secret]: secret }]), "invalid-root", "array"], + ["scalar root", () => Response.json(secret), "invalid-root", "other"], + ["null root", () => Response.json(null), "invalid-root", "other"], + ["object without project", () => Response.json({ [secret]: secret }), "no-project", "object"], + ] as const) { + test(`${label} has its own loadCodeAssist summary and cannot persist`, async () => { + const message = await failedDiscoveryLogin(load, () => new Response(secret, { status: 403 })); + expect(message).toContain(`"stage":"loadCodeAssist","status":${label === "HTTP refusal" ? 403 : 200}`); + expect(message).toContain(`"root":"${root}","outcome":"${outcome}"`); + expect(message).toContain('"stage":"onboardUser","status":403'); + }); + } + + for (const [label, body, done, error] of [ + ["done-with-error", { done: true, error: { [secret]: secret }, name: secret }, "true", "present"], + ["done-without-project", { done: true, response: { [secret]: secret } }, "true", "absent"], + ["pending", { done: false, name: secret }, "false", "absent"], + ["pending", { [secret]: secret }, "absent", "absent"], + ] as const) { + test(`onboardUser ${label} (done=${done}) stays sanitized and within budget`, async () => { + const realSetTimeout = globalThis.setTimeout; + let waits = 0; + const timer = spyOn(globalThis, "setTimeout").mockImplementation(((handler, delay, ...args) => { + if (delay === 2000) { waits++; return realSetTimeout(handler, 0, ...args); } + return realSetTimeout(handler, delay, ...args); + }) as typeof setTimeout); + let polls = 0; + try { + const message = await failedDiscoveryLogin(() => Response.json({}), () => { polls++; return Response.json(body); }); + expect(message).toContain(`"root":"object","outcome":"${label}"`); + expect(message).toContain(`"done":"${done}","error":"${error}"`); + expect(polls).toBe(label === "pending" ? 5 : 1); + expect(waits).toBe(label === "pending" ? 5 : 0); + expect(message.match(/"stage":"onboardUser"/g)?.length).toBe(1); + } finally { timer.mockRestore(); } + }); + } + + test("only known project keys and value types appear, never arbitrary keys or values", async () => { + const message = await failedDiscoveryLogin(() => Response.json({ + [secret]: secret, name: secret, done: secret, error: { message: secret }, + cloudaicompanionProject: { [secret]: secret }, projectId: 42, project: [], + }), () => Response.json({ done: true, error: { [secret]: secret }, response: { + [secret]: secret, cloudaicompanionProject: "", projectId: { id: "", [secret]: secret }, project: null, + } })); + expect(message).toContain('"cloudaicompanionProject":"object-without-id","projectId":"other","project":"other"'); + expect(message).toContain('"cloudaicompanionProject":"string","projectId":"object-with-id","project":"other"'); + expect(message).not.toContain('"name"'); + expect(message).not.toContain('"message"'); + }); + + for (const [label, response, status, root, polls, waits] of [ + ["malformed JSON", () => new Response(secret), 200, "unparseable", 5, 5], + ["array root", () => Response.json([{ [secret]: secret }]), 200, "array", 5, 5], + ["scalar root", () => Response.json(secret), 200, "other", 5, 5], + ["null root", () => Response.json(null), 200, "other", 1, 0], + ["hard refusal", () => new Response(secret, { status: 403 }), 403, "unparseable", 1, 0], + ["transient refusal", () => new Response(secret, { status: 503 }), 503, "unparseable", 5, 5], + ["rate limit", () => new Response(secret, { status: 429 }), 429, "unparseable", 5, 5], + ] as const) { + test(`onboardUser ${label} keeps its original poll budget`, async () => { + const realSetTimeout = globalThis.setTimeout; + let waitCount = 0; + const timer = spyOn(globalThis, "setTimeout").mockImplementation(((handler, delay, ...args) => { + if (delay === 2000) { waitCount++; return realSetTimeout(handler, 0, ...args); } + return realSetTimeout(handler, delay, ...args); + }) as typeof setTimeout); + let pollCount = 0; + try { + const message = await failedDiscoveryLogin(() => Response.json({}), () => { pollCount++; return response(); }); + expect(message).toContain(`"stage":"onboardUser","status":${status},"root":"${root}"`); + expect(pollCount).toBe(polls); + expect(waitCount).toBe(waits); + } finally { timer.mockRestore(); } + }); + } + + test("a null onboarding body after a refused loadCodeAssist still reports both sanitized stages", async () => { + // Without a loadCodeAssist account the onboarding call has no catch, so a TypeError on `null.done` + // used to replace the discovery error with generic OAuth text. + const message = await failedDiscoveryLogin(() => new Response(secret, { status: 403 }), () => Response.json(null)); + expect(message).toContain('"stage":"loadCodeAssist","status":403,"root":"unparseable","outcome":"http-refusal"'); + expect(message).toContain('"stage":"onboardUser","status":200,"root":"other","outcome":"invalid-root"'); + }); + + for (const key of ["cloudaicompanionProject", "projectId", "project"]) { + for (const value of ["accepted-project", { id: "accepted-project" }]) { + test(`accepted ${key} ${typeof value} still discovers without onboarding`, async () => { + const { calls } = routeFetch(() => Response.json({ [key]: value, [secret]: secret })); + expect(await discoverAntigravityProject("tok")).toBe("accepted-project"); + expect(calls).toHaveLength(1); + }); + test(`accepted onboardUser ${key} ${typeof value} still discovers`, async () => { + const { calls } = routeFetch(url => Response.json(url.includes(":loadCodeAssist") ? {} : { + done: true, response: { [key]: value, [secret]: secret }, + })); + expect(await discoverAntigravityProject("tok")).toBe("accepted-project"); + expect(calls).toHaveLength(2); + }); + } + } + + test("caller abort propagates unchanged during onboarding", async () => { + const ctrl = new AbortController(); + const reason = new Error("caller cancelled"); + const { calls } = routeFetch(url => { + if (url.includes(":loadCodeAssist")) return Response.json({}); + ctrl.abort(reason); + throw reason; + }); + await expect(discoverAntigravityProject("tok", ctrl.signal)).rejects.toBe(reason); + expect(calls).toHaveLength(2); + }); +}); + describe("antigravity project discovery", () => { test("loadCodeAssist returns the project (cloudaicompanionProject)", async () => { routeFetch((url) => { diff --git a/tests/oauth/oauth-login-cli-errors.test.ts b/tests/oauth/oauth-login-cli-errors.test.ts index 73eccf8e763..ed351e7a903 100644 --- a/tests/oauth/oauth-login-cli-errors.test.ts +++ b/tests/oauth/oauth-login-cli-errors.test.ts @@ -10,6 +10,23 @@ const marker = "oauth-cli-provider-response-marker"; const moduleUrl = (...name: string[]) => JSON.stringify(pathToFileURL(repoPath("src", ...name)).href); const publicMessage = "OAuth authentication failed. Check the OpenCodex account status and retry."; +// Keep the real token exchange, discovery, runLogin and public reporting/settlement paths. +const antigravityFailureSetup = ` + import { OAuthCallbackFlow } from ${moduleUrl("oauth", "callback-server.ts")}; + globalThis.fetch = async input => { + const url = String(input); + if (url.includes("oauth2.googleapis.com/token")) return Response.json({ access_token: ${JSON.stringify(marker)}, refresh_token: ${JSON.stringify(marker)} }); + if (url.includes(":loadCodeAssist")) return Response.json({ [${JSON.stringify(marker)}]: ${JSON.stringify(marker)} }); + if (url.includes(":onboardUser")) return Response.json({ done: true, error: { message: ${JSON.stringify(marker)} } }); + throw new Error("unexpected request"); + }; + OAuthCallbackFlow.prototype.login = async function () { + const hint = await this.generateAuthUrl("state", "http://127.0.0.1/callback"); + this.ctrl.onAuth?.(hint); + return this.exchangeToken("code", "state", "http://127.0.0.1/callback"); + }; +`; + /** Run one `ocx login` in a child process: the failure path ends in process.exit. */ async function runLoginChild(script: string): Promise<{ stdout: string; stderr: string; code: number }> { const home = mkdtempSync(join(tmpdir(), "ocx-login-errors-")); @@ -80,6 +97,66 @@ for (const scenario of [ }); } +test("Antigravity discovery diagnostics survive CLI reporting without provider text", async () => { + const { stdout, stderr, code } = await runLoginChild(` + import { handleLogin } from ${moduleUrl("oauth", "login-cli.ts")}; + ${antigravityFailureSetup} + await handleLogin("google-antigravity", { ask: async () => "", openUrl: async () => ({ status: "started" }) }); + `); + expect(code).toBe(1); + expect(stderr).toContain("Antigravity login could not discover a Cloud Code Assist project"); + expect(stderr).toContain('"stage":"loadCodeAssist","status":200,"root":"object","outcome":"no-project"'); + expect(stderr).toContain('"stage":"onboardUser","status":200,"root":"object","outcome":"done-with-error"'); + expect(stderr).not.toContain(marker); + expect(stdout).not.toContain("Logged in"); +}); + +test("Antigravity discovery diagnostics survive background login settlement and status", async () => { + const { stdout, stderr, code } = await runLoginChild(` + import { startLoginFlow, waitForLoginSettled, getLoginStatus } from ${moduleUrl("oauth", "index.ts")}; + import { getDefaultConfig } from ${moduleUrl("config.ts")}; + import { handleManagementAPI } from ${moduleUrl("server", "management-api.ts")}; + import { ManagementRequest } from ${JSON.stringify(pathToFileURL(repoPath("tests", "helpers", "management-auth.ts")).href)}; + ${antigravityFailureSetup} + await startLoginFlow("google-antigravity", { forceLogin: true }); + if (!await waitForLoginSettled("google-antigravity", 5000)) throw new Error("login did not settle"); + const req = new ManagementRequest("http://localhost/api/oauth/status?provider=google-antigravity"); + const response = await handleManagementAPI(req, new URL(req.url), getDefaultConfig(), {}, "gui-session"); + console.log(JSON.stringify({ status: getLoginStatus("google-antigravity"), httpStatus: response.status, management: await response.json() })); + `); + expect(code).toBe(0); + expect(stderr).toBe(""); + const { status, httpStatus, management } = JSON.parse(stdout.trim()); + expect(httpStatus).toBe(200); + expect(management).toMatchObject({ error: status.error, loggedIn: false, done: true, status: "error" }); + expect(status).toMatchObject({ loggedIn: false, done: true, status: "error" }); + expect(status.error).toContain("Antigravity login could not discover a Cloud Code Assist project"); + expect(status.error).toContain('"stage":"loadCodeAssist","status":200,"root":"object","outcome":"no-project"'); + expect(status.error).toContain('"stage":"onboardUser","status":200,"root":"object","outcome":"done-with-error"'); + expect(stdout).not.toContain(marker); +}); + +test("a plain Error cannot impersonate Antigravity diagnostics in CLI or settled status", async () => { + const { stdout, stderr, code } = await runLoginChild(` + import { handleLogin } from ${moduleUrl("oauth", "login-cli.ts")}; + import { OAUTH_PROVIDERS, startLoginFlow, waitForLoginSettled, getLoginStatus } from ${moduleUrl("oauth", "index.ts")}; + const error = new Error(${JSON.stringify(marker)}); + error.name = "AntigravityProjectDiscoveryError"; + OAUTH_PROVIDERS["google-antigravity"].login = async ctrl => { + ctrl.onAuth?.({ url: "https://example.invalid/authorize" }); + throw error; + }; + await startLoginFlow("google-antigravity", { forceLogin: true }); + if (!await waitForLoginSettled("google-antigravity", 5000)) throw new Error("login did not settle"); + console.log(JSON.stringify(getLoginStatus("google-antigravity"))); + await handleLogin("google-antigravity", { ask: async () => "", runLogin: async () => { throw error; } }); + `); + expect(code).toBe(1); + expect(JSON.parse(stdout.trim())).toMatchObject({ error: publicMessage, loggedIn: false, done: true, status: "error" }); + expect(stderr.trim()).toBe(`❌ OAuth login failed (oauth_authentication_failed): ${publicMessage}`); + expect(stdout + stderr).not.toContain(marker); +}); + test("a non-Error login rejection prints fixed text and exits unsuccessfully", async () => { const { stdout, stderr, code } = await runLoginChild(` import { handleLogin } from ${moduleUrl("oauth", "login-cli.ts")}; From e1a60c218ce290ca4c828412b9154fd8bf209291 Mon Sep 17 00:00:00 2001 From: Martin Becerra Date: Thu, 29 Oct 2026 05:17:04 +0100 Subject: [PATCH 17/84] feat(kiro): add IAM Identity Center login to dashboard (#7690) * feat(kiro): add IAM Identity Center login to dashboard * test(kiro): preserve IAM product name in locale guards * fix(kiro): address IAM login review findings * fix(kiro): preserve accepted device commits across expiry * test(kiro): budget repeated device credential writes Use the existing STORE_BUDGET_MS for the 40-flow retention test. The case performs real protected credential/config writes; retain every iteration and assertion. The Windows default 5s budget expired after 5.05s. Retaining both the completed flow and config snapshot still fails at the cleanup assertion under the store budget (0 pass, 1 fail in 0.71s). Co-authored-by: Martin Becerra <197874+martinbcg@users.noreply.github.com> * test(kiro): budget the IAM credential-write pressure scenario Apply STORE_BUDGET_MS to the other repeated protected-write case after its 5s default timeout under Windows contention. Keep all 14 completions, the active-flow cap and owner-scoped profile-selection assertions. Ablating terminal-only eviction fails at the preserved live-flow assertion (0 pass, 1 fail in 2.31s) within the store budget. Co-authored-by: Martin Becerra <197874+martinbcg@users.noreply.github.com> --------- Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> Co-authored-by: Martin Becerra <197874+martinbcg@users.noreply.github.com> --- .../src/content/docs/fr/guides/providers.md | 5 +- .../src/content/docs/guides/providers.md | 20 +- .../src/content/docs/ja/guides/providers.md | 4 +- .../src/content/docs/ko/guides/providers.md | 4 +- .../src/content/docs/ru/guides/providers.md | 4 +- .../src/content/docs/tr/guides/providers.md | 5 +- .../content/docs/zh-cn/guides/providers.md | 4 +- .../content/docs/zh-tw/guides/providers.md | 5 +- gui/src/components/KiroDeviceLoginDialog.tsx | 16 +- gui/src/components/KiroIamLoginForm.tsx | 56 +++++ gui/src/components/KiroLoginChooser.tsx | 1 + gui/src/components/use-kiro-device-login.ts | 53 ++++- gui/src/i18n/de.ts | 12 + gui/src/i18n/en.ts | 12 + gui/src/i18n/fr.ts | 12 + gui/src/i18n/ja.ts | 12 + gui/src/i18n/ko.ts | 12 + gui/src/i18n/pt.ts | 12 + gui/src/i18n/ru.ts | 12 + gui/src/i18n/tr.ts | 12 + gui/src/i18n/vi.ts | 12 + gui/src/i18n/zh-TW.ts | 12 + gui/src/i18n/zh.ts | 12 + gui/src/kiro-device-login-helpers.ts | 34 ++- gui/src/styles/kiro-device-login.css | 3 + gui/tests/fr-localization.test.ts | 1 + gui/tests/kiro-device-login.test.tsx | 81 +++++++ gui/tests/locale-parity.test.ts | 1 + scripts/test-layout/layout.json | 2 +- src/oauth/kiro-device-login.ts | 117 +++++++--- src/oauth/kiro-iam-identity-center.ts | 107 +++++++++ src/oauth/store.ts | 3 +- src/server/management/oauth-account-routes.ts | 24 +- structure/gui-and-management-api.md | 4 +- structure/providers/kiro.md | 26 ++- tests/fixtures/test-layout-expected.json | 2 +- tests/helpers/kiro-device-fixture.ts | 2 +- tests/providers/kiro/kiro-device-iam.test.ts | 206 ++++++++++++++++++ .../providers/kiro/kiro-device-social.test.ts | 3 +- tests/server/server-kiro-device-login.test.ts | 30 +++ 40 files changed, 878 insertions(+), 77 deletions(-) create mode 100644 gui/src/components/KiroIamLoginForm.tsx create mode 100644 src/oauth/kiro-iam-identity-center.ts create mode 100644 tests/providers/kiro/kiro-device-iam.test.ts diff --git a/docs-site/src/content/docs/fr/guides/providers.md b/docs-site/src/content/docs/fr/guides/providers.md index 726ae425a87..e75336c090f 100644 --- a/docs-site/src/content/docs/fr/guides/providers.md +++ b/docs-site/src/content/docs/fr/guides/providers.md @@ -180,8 +180,7 @@ Les fournisseurs OAuth dont les identifiants comportent un identifiant de compte peuvent conserver plusieurs connexions. La page **Fournisseurs** affiche ces comptes dans une liste déroulante, permet d'en ajouter un autre et de changer de compte actif sans déconnecter les autres. Une connexion ordinaire avec un identifiant Kimi qui ne contient aucune information d'identité remplace l'emplacement actif, tandis que l'action explicite **Ajouter un -compte** préserve cet emplacement et en active un nouveau, distinct. Les comptes Kiro sont indexés par ARN de -profil. `chatgpt` n'utilise toujours qu'un seul emplacement, car les comptes du pool Codex sont gérés dans un +compte** préserve cet emplacement et en active un nouveau, distinct. Les comptes Kiro utilisent l’ARN du profil avec l’adresse e-mail de l’utilisateur connecté lorsqu’elle est disponible ; plusieurs utilisateurs peuvent conserver des identifiants distincts pour un même profil d’organisation. `chatgpt` n'utilise toujours qu'un seul emplacement, car les comptes du pool Codex sont gérés dans un registre distinct. Les jetons restent dans `~/.opencodex/auth.json` ; `/api/oauth/accounts` ne renvoie que des métadonnées masquées. @@ -255,6 +254,8 @@ dans le pool de comptes, ce que fait aussi le pool de comptes Codex du tableau d ### Importation des identifiants Kiro +Dans le tableau de bord, choisissez **AWS IAM Identity Center**, puis indiquez l’URL du portail d’accès AWS et la région SSO de votre organisation. Autorisez le code d’appareil dans votre navigateur et choisissez le profil Kiro à enregistrer si plusieurs sont proposés. Aucun CLI ni accès SSH n’est nécessaire. Les instructions CLI ci-dessous concernent le choix **Kiro CLI**. + La connexion Kiro nécessite la CLI Kiro : sous Unix, installez-la avec `curl -fsSL https://cli.kiro.dev/install | bash` ; sous Windows PowerShell, utilisez `irm 'https://cli.kiro.dev/install.ps1' | iex`, puis connectez-vous avec `kiro-cli login`. En l'absence de session `kiro-cli`, `ocx login kiro` se rabat sur un jeton d'accès collé ou sur la variable diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 83b442b2d22..9b010a5584b 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -347,7 +347,7 @@ ocx logout | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi Code Plan coding models. Defaults to the stable `kimi-for-coding` alias (currently K2.8 Preview): 1M-token context window, adjustable `low`/`high`/`max` thinking (default `max`), text + image input. `k3.1-pro` and `k3.1-pro-highspeed` (K3.1 Pro) are also listed: 1M-token context, thinking-only on the same ladder (default `max`). Retired `kimi-k2.x` selections are migrated to the alias on upgrade. | | `kimi-responses` | `openai-responses` | `https://api.kimi.com/coding/v1` | Same Kimi account login (reuses the `kimi` OAuth credential) over the OpenAI Responses wire. Same model roster and capabilities as `kimi`; thinking content stays encrypted server-side, tool calls and results stay visible. | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research subscription gateway (same backend Hermes Agent uses). Device-grant login against `portal.nousresearch.com`; the access token is the per-request inference JWT. Mixed paid + `:free` model catalog (`tencent/hy3:free`, `stepfun/step-3.7-flash:free`, ...) discovered live from the signed-in account. Refresh tokens are single-use and rotated on every refresh. | -| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | The dashboard Login and Add account buttons offer Builder ID, Google, GitHub device login, or Kiro CLI. Native device login adds an account without signing `kiro-cli` out. The Kiro CLI choice imports or starts a CLI session; its Add account workflow temporarily switches the CLI account and restores it on cancellation or failure. | +| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | The dashboard Login and Add account buttons offer AWS IAM Identity Center, Builder ID, Google, GitHub device login, or Kiro CLI. Native device login adds an account without signing `kiro-cli` out. The Kiro CLI choice imports or starts a CLI session; its Add account workflow temporarily switches the CLI account and restores it on cancellation or failure. | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth over the Cloud Code Assist wire. Live discovery uses CCA's authenticated `v1internal:fetchAvailableModels` endpoint and publishes the agent models available to the signed-in account; the maintained catalog remains the fallback. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Experimental PKCE login, live HTTP/2 transport with an opt-in HTTP/1.1 compatibility path, and account-filtered model discovery. | | `zed` | `zed` | `https://cloud.zed.dev` | Experimental Zed Hosted AI bridge. Uses Zed's native-app RSA callback, exchanges the account token for a short-lived hosted-inference token, and discovers the account's live roster. | @@ -544,7 +544,7 @@ OAuth providers whose credentials include a stable account id or email can keep login. The Providers page shows those accounts in a dropdown, lets you add another, and switches the active account without logging the others out. A normal login with an identity-less Kimi credential replaces the active slot, while an explicit **Add account** preserves that slot and activates a new, -distinct one. Kiro accounts are keyed by profile ARN. `chatgpt` is always single-slot because Codex +distinct one. Kiro accounts use the profile ARN together with the signed-in user's email when available; different users can keep separate credentials for one organization profile. `chatgpt` is always single-slot because Codex pool accounts have a separate ledger. Tokens stay in `~/.opencodex/auth.json`; `/api/oauth/accounts` returns masked metadata only. @@ -668,7 +668,19 @@ Other messages, statuses, and AWS SSO responses do not trigger this desktop-spec ### Kiro credential import -The dashboard offers native Builder ID, Google, and GitHub device login without Kiro CLI. +The dashboard offers native AWS IAM Identity Center, Builder ID, Google, and GitHub device login without Kiro CLI. + +For an organization account, choose **AWS IAM Identity Center**, enter your AWS access portal URL +(for example `https://example.awsapps.com/start`) and the region where your organization configured +IAM Identity Center. Open the verification link in your own browser, enter the displayed code, +and authorize the account. The hub completes the grant; no SSH session or browser callback to +the hub is required. If AWS lists several Kiro profiles, choose the profile to save. With one +profile the account is saved automatically. The SSO region and the selected profile's API region +are kept separately. Profile discovery checks AWS's US East and Frankfurt service regions. +The signed-in user's email is resolved from AWS for the selected profile before saving; users +sharing one organization profile keep separate account credentials. Cancellation or expiry before +the credential write saves no account. Native login adds a slot and keeps the existing selection. + The Kiro CLI choice and `ocx login kiro` import path use the CLI: on Unix, install it with `curl -fsSL https://cli.kiro.dev/install | bash`; on Windows PowerShell, use `irm 'https://cli.kiro.dev/install.ps1' | iex`; then sign in with `kiro-cli login`. Without a `kiro-cli` session, `ocx login kiro` falls @@ -705,7 +717,7 @@ an ambiguous token selection), when `KIROCLI_DB_PATH` / `KIRO_CLI_DB_FILE` redir from the live CLI store, or when an existing primary CLI database has no recognized token row. Repair or remove the unreadable database under the normal `kiro-cli` data path, unset those import selectors, then retry. Signing in from a machine with no existing `kiro-cli` session is unaffected. -The native dashboard choices are add-only and do not sign out `kiro-cli`. A device dialog shows the code and verification destination. Only recognized Kiro or Builder ID hosts are opened as links; an unexpected destination is shown as copyable text for review. Closing the dialog sends cancellation and leaves a bounded background status check to reconcile a commit already in progress. A stalled status response is retried; exhausting the flow deadline produces the neutral ended outcome rather than claiming success. Google and GitHub approval completes when Kiro reports `authorized` (or the legacy `approved` status) with valid tokens and a profile ARN. +The native dashboard choices are add-only and do not sign out `kiro-cli`. A device dialog shows the code and verification destination. Only recognized Kiro or regional AWS device-SSO hosts are opened as links; an unexpected destination is shown as copyable text for review. Closing the dialog sends cancellation and leaves a bounded background status check to reconcile a commit already in progress. A stalled status response is retried; exhausting the flow deadline produces the neutral ended outcome rather than claiming success. Google and GitHub approval completes when Kiro reports `authorized` (or the legacy `approved` status) with valid tokens and a profile ARN. The account list marks Kiro accounts excluded from automatic selection with a reason, when available. ### DeepSeek API diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index 8f6283a5c10..0d4f2d41c39 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -165,7 +165,7 @@ opt-in した上流がこのフィールドを拒否しても、opencodex はフ 認証情報に固定アカウント ID やメールがある OAuth プロバイダーはログインを複数保持できます。 Providers ページでアカウントを追加し、別アカウントをログアウトせずにアクティブアカウントだけを切り替えられます。 -アカウント識別情報がない Kimi 認証情報は通常のログインではアクティブスロットを差し替えますが、明示的な **アカウントを追加** では既存スロットを保持し、別の新しいスロットをアクティブにします。Kiro アカウントはプロファイル ARN をキーに保存されます。 +アカウント識別情報がない Kimi 認証情報は通常のログインではアクティブスロットを差し替えますが、明示的な **アカウントを追加** では既存スロットを保持し、別の新しいスロットをアクティブにします。Kiro アカウントはプロファイル ARN と、取得できる場合はサインインしたユーザーのメールアドレスを組み合わせて識別されます。同じ組織プロファイルを共有するユーザーも、それぞれ別の認証情報を保持できます。 `chatgpt` は Codex アカウントプールに別の保存場所があり、常に単一スロットのみ書き込みます。トークンは `~/.opencodex/auth.json` に保存され、 `/api/oauth/accounts` はマスク済みメタデータのみを返します。 @@ -184,6 +184,8 @@ cat accounts.json | ocx account import google-antigravity --format cockpit-tools ### Kiro 認証情報の取り込み +ダッシュボードでは **AWS IAM Identity Center** を選び、組織の AWS アクセスポータル URL と SSO リージョンを入力すると、CLI や SSH を使わずにアカウントを追加できます。ブラウザーでデバイスコードを承認し、複数の Kiro プロファイルがある場合は保存するものを選択します。以下の CLI 手順は **Kiro CLI** の選択肢に適用されます。 + Kiro のログインには Kiro CLI が必要です。Unix では `curl -fsSL https://cli.kiro.dev/install | bash`、Windows PowerShell では `irm 'https://cli.kiro.dev/install.ps1' | iex` でインストールしてから、先に `kiro-cli login` でサインインしてください。`kiro-cli` セッションがない場合、`ocx login kiro` は貼り付けたアクセストークンまたは `KIRO_ACCESS_TOKEN` 環境変数にフォールバックします。 通常の `ocx login kiro` 取り込みは CLI の SQLite データベースを読み取り専用で開き、データベース、WAL、SHM を変更しません。 diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 453aad8d639..3562976e849 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -168,7 +168,7 @@ deny-by-default 상태로 유지됩니다. 자격 증명에 고정된 계정 id나 이메일이 있는 OAuth 프로바이더는 로그인을 여러 개 보관할 수 있습니다. Providers 페이지에서 계정을 추가하고, 다른 계정을 로그아웃하지 않은 채 활성 계정만 바꿀 수 있습니다. -계정 식별 정보가 없는 Kimi 자격 증명은 일반 로그인에서는 활성 슬롯을 교체하지만, 명시적인 **계정 추가**에서는 기존 슬롯을 보존하고 별도의 새 슬롯을 활성화합니다. Kiro 계정은 프로필 ARN을 키로 저장됩니다. +계정 식별 정보가 없는 Kimi 자격 증명은 일반 로그인에서는 활성 슬롯을 교체하지만, 명시적인 **계정 추가**에서는 기존 슬롯을 보존하고 별도의 새 슬롯을 활성화합니다. Kiro 계정은 프로필 ARN과 로그인한 사용자의 이메일(제공되는 경우)을 함께 사용해 구분합니다. 같은 조직 프로필을 공유하는 사용자도 각자의 자격 증명을 별도로 유지할 수 있습니다. `chatgpt`는 Codex 계정 풀에 별도 저장소가 있어 항상 단일 슬롯만 씁니다. 토큰은 `~/.opencodex/auth.json`에 저장되고, `/api/oauth/accounts`는 마스킹된 메타데이터만 반환합니다. @@ -187,6 +187,8 @@ cat accounts.json | ocx account import google-antigravity --format cockpit-tools ### Kiro 자격 증명 가져오기 +대시보드에서 **AWS IAM Identity Center**를 선택하고 조직의 AWS 액세스 포털 URL과 SSO 리전을 입력하면 CLI나 SSH 없이 계정을 추가할 수 있습니다. 브라우저에서 기기 코드를 승인하고 Kiro 프로필이 여러 개면 저장할 프로필을 선택하세요. 아래 CLI 설명은 **Kiro CLI** 선택 항목에 해당합니다. + Kiro 로그인에는 Kiro CLI가 필요합니다. Unix에서는 `curl -fsSL https://cli.kiro.dev/install | bash`, Windows PowerShell에서는 `irm 'https://cli.kiro.dev/install.ps1' | iex`로 설치한 뒤 먼저 `kiro-cli login`으로 로그인하세요. `kiro-cli` 세션이 없으면 `ocx login kiro`는 붙여 넣은 액세스 토큰이나 `KIRO_ACCESS_TOKEN` 환경 변수로 폴백합니다. 일반 `ocx login kiro` 가져오기는 CLI SQLite 데이터베이스를 읽기 전용으로 열며 데이터베이스, WAL, SHM을 수정하지 않습니다. diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index 05df36d384b..03e1cc7b62b 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -177,7 +177,7 @@ OAuth-провайдеры, чьи учётные данные содержат несколько входов. Страница Providers показывает эти аккаунты в выпадающем списке, позволяет добавить ещё один и переключает активный аккаунт, не выполняя выход из остальных. При обычном входе учётные данные Kimi без идентификатора заменяют активный слот; явное действие **Добавить аккаунт** -сохраняет прежний слот и активирует отдельный новый. Аккаунты Kiro сохраняются по ARN профиля. +сохраняет прежний слот и активирует отдельный новый. Аккаунты Kiro различаются по ARN профиля и адресу электронной почты вошедшего пользователя, если он доступен; пользователи одного профиля организации могут хранить отдельные учётные данные. `chatgpt` всегда занимает один слот, поскольку у пула аккаунтов Codex отдельный реестр. Токены остаются в `~/.opencodex/auth.json`; `/api/oauth/accounts` возвращает только маскированные метаданные. @@ -196,6 +196,8 @@ Inline JSON и лишние позиционные аргументы откло ### Импорт учётных данных Kiro +В панели выберите **AWS IAM Identity Center**, укажите URL портала доступа AWS и регион SSO организации. Подтвердите код устройства в браузере и выберите профиль Kiro для сохранения, если их несколько. CLI и SSH не требуются. Инструкции CLI ниже относятся к варианту **Kiro CLI**. + Для входа Kiro требуется Kiro CLI: в Unix установите его командой `curl -fsSL https://cli.kiro.dev/install | bash`, в Windows PowerShell — `irm 'https://cli.kiro.dev/install.ps1' | iex`, затем сначала выполните `kiro-cli login`. Если сессии `kiro-cli` нет, `ocx login kiro` использует вставленный токен доступа или переменную окружения `KIRO_ACCESS_TOKEN`. Обычный импорт `ocx login kiro` открывает базу SQLite CLI только для чтения и не изменяет базу, WAL или SHM. diff --git a/docs-site/src/content/docs/tr/guides/providers.md b/docs-site/src/content/docs/tr/guides/providers.md index eb6d9d9bc46..09ea15cc2b1 100644 --- a/docs-site/src/content/docs/tr/guides/providers.md +++ b/docs-site/src/content/docs/tr/guides/providers.md @@ -196,8 +196,7 @@ sağlayıcıları birden fazla oturum tutabilir. Sağlayıcılar sayfası bu hes bir açılır menüde gösterir, başka bir tane eklemenize izin verir ve diğerlerinin oturumunu kapatmadan etkin hesabı değiştirir. Normal oturum açmada kimliği olmayan Kimi kimlik bilgileri etkin yuvanın yerini alır; açık **Hesap ekle** akışı mevcut -yuvayı korur ve ayrı yeni yuvayı etkinleştirir. Kiro hesapları profil ARN'sine göre -anahtarlanır. `chatgpt` her zaman tek yuvalıdır çünkü Codex havuz hesaplarının +yuvayı korur ve ayrı yeni yuvayı etkinleştirir. Kiro hesapları, varsa oturum açan kullanıcının e-posta adresi ile profil ARN'si birlikte kullanılarak ayırt edilir; aynı kuruluş profilini paylaşan kullanıcılar ayrı kimlik bilgilerini koruyabilir. `chatgpt` her zaman tek yuvalıdır çünkü Codex havuz hesaplarının ayrı bir defteri vardır. Belirteçler `~/.opencodex/auth.json` içinde kalır; `/api/oauth/accounts` yalnızca maskelenmiş meta verileri döndürür. @@ -280,6 +279,8 @@ referansında [`ocx status` / `ocx doctor`](/tr/reference/cli/) bölümüne bak ### Kiro kimlik bilgisi içe aktarma +Panelde **AWS IAM Identity Center** seçeneğini seçip kuruluşunuzun AWS erişim portalı URL’sini ve SSO bölgesini girin. Tarayıcıda cihaz kodunu onaylayın ve birden fazla Kiro profili varsa kaydedilecek profili seçin. CLI veya SSH gerekmez. Aşağıdaki CLI adımları **Kiro CLI** seçeneği içindir. + Kiro oturum açma Kiro CLI'yı bekler: Unix'te `curl -fsSL https://cli.kiro.dev/install | bash` ile kurun; Windows PowerShell'de `irm 'https://cli.kiro.dev/install.ps1' | iex` kullanın; ardından `kiro-cli login` diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index de310926515..6396d2bff10 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -158,7 +158,7 @@ Kimi Code 原生 Responses 路由会省略明确正文为空的 assistant 历史 OAuth 凭据中带有稳定账号 id 或邮箱的提供商可以保存多个登录。Providers 页面会在下拉列表中显示这些 账号,允许继续添加,并在不登出其他账号的情况下切换当前账号。普通登录时,没有身份信息的 Kimi 凭据会替换 -当前 active slot;显式 **添加账号** 会保留原有 slot 并激活一个独立的新 slot。Kiro 账户以配置文件 ARN 为键。 +当前 active slot;显式 **添加账号** 会保留原有 slot 并激活一个独立的新 slot。Kiro 账户结合配置文件 ARN 与已登录用户的电子邮件地址(若可用)来区分身份;共享同一组织配置文件的用户可以各自保留独立的凭据。 `chatgpt` 始终只有一个 slot,因为 Codex 账号池使用独立存储。令牌仍保存在 `~/.opencodex/auth.json` 中;`/api/oauth/accounts` 只返回脱敏后的 metadata。 @@ -177,6 +177,8 @@ cat accounts.json | ocx account import google-antigravity --format cockpit-tools ### Kiro 凭据导入 +在仪表盘选择 **AWS IAM Identity Center**,输入组织的 AWS 访问门户 URL 和 SSO 区域,即可添加账户,无需 CLI 或 SSH。在浏览器批准设备代码后,如有多个 Kiro 配置文件,请选择要保存的配置文件。下面的 CLI 步骤适用于 **Kiro CLI** 选项。 + Kiro 登录需要 Kiro CLI:Unix 使用 `curl -fsSL https://cli.kiro.dev/install | bash` 安装;Windows PowerShell 使用 `irm 'https://cli.kiro.dev/install.ps1' | iex`;然后先运行 `kiro-cli login`。如果没有 `kiro-cli` 会话,`ocx login kiro` 会回退到粘贴的访问令牌或 `KIRO_ACCESS_TOKEN` 环境变量。 普通的 `ocx login kiro` 导入会以只读方式打开 CLI SQLite 数据库,不修改数据库、WAL 或 SHM。 diff --git a/docs-site/src/content/docs/zh-tw/guides/providers.md b/docs-site/src/content/docs/zh-tw/guides/providers.md index 35cd260dd7c..f447248d9d0 100644 --- a/docs-site/src/content/docs/zh-tw/guides/providers.md +++ b/docs-site/src/content/docs/zh-tw/guides/providers.md @@ -155,8 +155,7 @@ session/task key 有助提升 Code Plan cache hit rate;沒有 key 的請求 credential 內含穩定 account id 或 email 的 OAuth provider 可以保存多個登入。Providers 頁面會在下拉 選單顯示這些帳號、允許新增帳號,並在不登出其他帳號的情況下切換目前帳號。一般登入時,沒有 identity 的 -Kimi credential 會取代 active slot;明確的 **新增帳號** 會保留原有 slot 並啟用另一個新 slot。Kiro 帳號以 -profile ARN 作為 key。`chatgpt` 始終是 single-slot,因為 +Kimi credential 會取代 active slot;明確的 **新增帳號** 會保留原有 slot 並啟用另一個新 slot。Kiro 帳號結合 profile ARN 與登入使用者的電子郵件地址(若可取得)來識別身分;共用同一組織 profile 的使用者可以各自保留獨立的憑證。`chatgpt` 始終是 single-slot,因為 Codex pool 帳號使用獨立 ledger。Token 仍存放在 `~/.opencodex/auth.json`;`/api/oauth/accounts` 只回傳 遮蔽後的 metadata。 @@ -217,6 +216,8 @@ Reauthenticate。Codex pool 帳號不是那些 provider 之一,但 `ocx login ### Kiro credential 匯入 +在儀表板選擇 **AWS IAM Identity Center**,輸入組織的 AWS 存取入口網站 URL 和 SSO 區域,即可新增帳號,無需 CLI 或 SSH。在瀏覽器核准裝置代碼後,如有多個 Kiro 設定檔,請選擇要儲存的設定檔。以下 CLI 步驟適用於 **Kiro CLI** 選項。 + Kiro 登入預期存在 Kiro CLI。Unix 可用 `curl -fsSL https://cli.kiro.dev/install | bash` 安裝;Windows PowerShell 使用 `irm 'https://cli.kiro.dev/install.ps1' | iex`;接著以 `kiro-cli login` 登入。若沒有 `kiro-cli` session,`ocx login kiro` 會 fallback 到貼上的 access token 或 `KIRO_ACCESS_TOKEN` 環境變數。 diff --git a/gui/src/components/KiroDeviceLoginDialog.tsx b/gui/src/components/KiroDeviceLoginDialog.tsx index aa0c4729be1..65ba58b4a7e 100644 --- a/gui/src/components/KiroDeviceLoginDialog.tsx +++ b/gui/src/components/KiroDeviceLoginDialog.tsx @@ -1,10 +1,11 @@ -import { useId, type RefObject } from "react"; +import { useId, useState, type RefObject } from "react"; import { useT } from "../i18n/shared"; import { useModalDialog } from "../pages/dashboard-shared"; import { kiroVerificationLink } from "../kiro-device-login-helpers"; import { useCopyFeedback } from "./use-copy-feedback"; import { useKiroDeviceLogin } from "./use-kiro-device-login"; import KiroLoginChooser from "./KiroLoginChooser"; +import { KiroIamLoginForm, KiroIamProfilePicker } from "./KiroIamLoginForm"; import type { KiroFinalOutcome } from "../kiro-device-login-finalizer"; import "../styles/kiro-device-login.css"; @@ -23,6 +24,7 @@ export default function KiroDeviceLoginDialog({ apiBase, triggerRef, addAccount, const titleId = useId(); const dialogRef = useModalDialog(true, triggerRef); const login = useKiroDeviceLogin(apiBase, onSettled, pollDelay); + const [iamSetup, setIamSetup] = useState(false); const copy = useCopyFeedback(); const { state } = login; const view = state.view; @@ -36,10 +38,19 @@ export default function KiroDeviceLoginDialog({ apiBase, triggerRef, addAccount, {state.phase === "idle" && ( <>

{t(addAccount ? "kiroLogin.addDescription" : "kiroLogin.loginDescription")}

- { onClose(); onCli(addAccount); }} onMethod={method => { void login.start(method); }} /> + {iamSetup + ? setIamSetup(false)} onStart={options => { void login.start("iam-identity-center", options); }} /> + : { onClose(); onCli(addAccount); }} onMethod={method => { + if (method === "iam-identity-center") setIamSetup(true); + else void login.start(method); + }} />} )} {state.phase === "starting" &&

{t("kiroLogin.starting")}

} + {view?.profiles && (state.phase === "select-profile" || state.phase === "selecting") && <> + { void login.selectProfile(arn); }} /> + {state.error === "profile" &&

{t("kiroLogin.profileFailed")}

} + } {view && (state.phase === "pending" || state.phase === "done" || state.phase === "failed" || state.phase === "expired" || state.phase === "ended") && ( <> {view.userCode && ( @@ -59,6 +70,7 @@ export default function KiroDeviceLoginDialog({ apiBase, triggerRef, addAccount, )}

{state.phase === "pending" && t("kiroLogin.pending")} + {state.phase === "selecting" && t("kiroLogin.selecting")} {state.phase === "done" && t("kiroLogin.done")} {state.phase === "expired" && t("kiroLogin.expired")} {state.phase === "ended" && t("kiroLogin.ended")} diff --git a/gui/src/components/KiroIamLoginForm.tsx b/gui/src/components/KiroIamLoginForm.tsx new file mode 100644 index 00000000000..84c1055d605 --- /dev/null +++ b/gui/src/components/KiroIamLoginForm.tsx @@ -0,0 +1,56 @@ +import { useId, useState } from "react"; +import { useT } from "../i18n/shared"; +import { validKiroIamOptions, type KiroIamOptions, type KiroIamProfile } from "../kiro-device-login-helpers"; + +export function KiroIamLoginForm({ disabled, onStart, onBack }: { + disabled: boolean; onStart: (options: KiroIamOptions) => void; onBack: () => void; +}) { + const t = useT(); + const id = useId(); + const [startUrl, setStartUrl] = useState(""); + const [region, setRegion] = useState("us-east-1"); + const [invalid, setInvalid] = useState(false); + return

{ + event.preventDefault(); + const options = { startUrl: startUrl.trim(), region: region.trim() }; + if (!validKiroIamOptions(options)) { setInvalid(true); return; } + setInvalid(false); onStart(options); + }}> +

{t("kiroLogin.iamDescription")}

+ + { setStartUrl(event.target.value); setInvalid(false); }} /> + + { setRegion(event.target.value); setInvalid(false); }} /> + {invalid && } +
+ + +
+
; +} + +export function KiroIamProfilePicker({ profiles, disabled, onSelect }: { + profiles: KiroIamProfile[]; disabled: boolean; onSelect: (arn: string) => void; +}) { + const t = useT(); + const id = useId(); + const [arn, setArn] = useState(""); + return
{ + event.preventDefault(); + if (profiles.some(profile => profile.arn === arn)) onSelect(arn); + }}> +

{t("kiroLogin.selectProfile")}

+ + + {arn && {arn}} +
+
; +} diff --git a/gui/src/components/KiroLoginChooser.tsx b/gui/src/components/KiroLoginChooser.tsx index 586fe110583..cd9cf6bcada 100644 --- a/gui/src/components/KiroLoginChooser.tsx +++ b/gui/src/components/KiroLoginChooser.tsx @@ -10,6 +10,7 @@ export default function KiroLoginChooser({ disabled, onCli, onMethod }: { return (
+ diff --git a/gui/src/components/use-kiro-device-login.ts b/gui/src/components/use-kiro-device-login.ts index e4c7e978d39..0dbf1ef6117 100644 --- a/gui/src/components/use-kiro-device-login.ts +++ b/gui/src/components/use-kiro-device-login.ts @@ -4,12 +4,12 @@ import { finalizeKiroDeviceFlow, observeKiroDeviceFinal, readKiroDeviceStatus, type KiroFinalOutcome, type KiroStatusRead, } from "../kiro-device-login-finalizer"; -import { parseKiroDeviceView, type KiroDeviceMethod, type KiroDeviceView } from "../kiro-device-login-helpers"; +import { parseKiroDeviceView, type KiroDeviceMethod, type KiroDeviceView, type KiroIamOptions } from "../kiro-device-login-helpers"; -type Phase = "idle" | "starting" | "pending" | "done" | "expired" | "failed" | "cancelled" | "ended"; -export type KiroLoginState = { phase: Phase; view?: KiroDeviceView; error?: "start" | "network" | "invalid" }; +type Phase = "idle" | "starting" | "pending" | "select-profile" | "selecting" | "done" | "expired" | "failed" | "cancelled" | "ended"; +export type KiroLoginState = { phase: Phase; view?: KiroDeviceView; error?: "start" | "network" | "invalid" | "profile" }; type Session = { closed: boolean; closedController: AbortController; view?: KiroDeviceView; - inFlight?: KiroStatusRead; waitController?: AbortController; terminal?: KiroFinalOutcome }; + inFlight?: KiroStatusRead; waitController?: AbortController; terminal?: KiroFinalOutcome; selecting?: boolean }; const wait = (ms: number, signal: AbortSignal) => new Promise(resolve => { if (signal.aborted) { resolve(); return; } const timer = setTimeout(() => { signal.removeEventListener("abort", stop); resolve(); }, ms); @@ -78,7 +78,7 @@ export function useKiroDeviceLogin(apiBase: string, onSettled?: (provider: strin return () => { mountedRef.current = false; void close(); }; }, [close]); - const start = useCallback(async (method: KiroDeviceMethod) => { + const start = useCallback(async (method: KiroDeviceMethod, iam?: KiroIamOptions) => { if (sessionRef.current) return; const session: Session = { closed: false, closedController: new AbortController() }; sessionRef.current = session; @@ -87,7 +87,7 @@ export function useKiroDeviceLogin(apiBase: string, onSettled?: (provider: strin try { response = await afterOAuthCancellation(apiBase, "kiro", () => fetch(`${apiBase}/api/oauth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ provider: "kiro", method }), + body: JSON.stringify({ provider: "kiro", method, ...(method === "iam-identity-center" ? iam : {}) }), })); } catch { if (!session.closed && mountedRef.current) setState({ phase: "failed", error: "network" }); @@ -109,7 +109,7 @@ export function useKiroDeviceLogin(apiBase: string, onSettled?: (provider: strin if (reply) observeKiroDeviceFinal(apiBase, view.flowId, reply, "cancel"); return; } - if (view.state !== "pending") { + if (view.state !== "pending" && view.state !== "select-profile") { if (view.state === "done") settledRef.current?.("kiro", "added"); else if (view.state === "failed") settledRef.current?.("kiro", "failed"); if (mountedRef.current) setState({ phase: view.state, view }); @@ -117,7 +117,7 @@ export function useKiroDeviceLogin(apiBase: string, onSettled?: (provider: strin sessionRef.current = null; return; } - setState({ phase: "pending", view }); + setState({ phase: view.state, view }); while (!session.closed && !session.terminal) { const waitController = new AbortController(); session.waitController = waitController; @@ -133,7 +133,7 @@ export function useKiroDeviceLogin(apiBase: string, onSettled?: (provider: strin const request = readKiroDeviceStatus(apiBase, flowId); session.inFlight = request; const status = await unlessClosed(session, request.result); - if (status === CLOSED) break; + if (status === CLOSED || session.terminal) break; session.inFlight = undefined; if (status.kind === "missing") { session.terminal = "ended"; @@ -144,7 +144,10 @@ export function useKiroDeviceLogin(apiBase: string, onSettled?: (provider: strin const next = status.kind === "view" ? status.view : null; if (!next || next.flowId !== flowId) continue; session.view = next; - if (next.state === "pending") { setState({ phase: "pending", view: next }); continue; } + if (next.state === "pending" || next.state === "select-profile") { + if (!session.selecting) setState({ phase: next.state, view: next }); + continue; + } session.terminal = next.state === "done" ? "added" : next.state === "failed" ? "failed" : "ended"; if (next.state === "done" || next.state === "failed") settledRef.current?.("kiro", session.terminal); setState({ phase: next.state, view: next }); @@ -153,6 +156,34 @@ export function useKiroDeviceLogin(apiBase: string, onSettled?: (provider: strin if (sessionRef.current === session) sessionRef.current = null; }, [apiBase, cancelServer, handoff, pollDelay]); + const selectProfile = useCallback(async (profileArn: string) => { + const session = sessionRef.current; + if (!session || session.closed || session.terminal || session.selecting || session.view?.state !== "select-profile") return; + const view = session.view; + session.selecting = true; + setState({ phase: "selecting", view }); + let next: KiroDeviceView | null = null; + try { + const response = await fetch(`${apiBase}/api/oauth/login`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: "kiro", flowId: view.flowId, profileArn }), + }); + if (response.ok) next = parseKiroDeviceView(await response.json()); + } catch { /* A retry uses the same flow and server-owned profile list. */ } + session.selecting = false; + if (session.closed || session.terminal || !mountedRef.current) return; + if (!next || next.flowId !== view.flowId || next.method !== "iam-identity-center") { + setState({ phase: "select-profile", view, error: "profile" }); return; + } + session.view = next; + if (next.state === "done" || next.state === "failed") { + session.terminal = next.state === "done" ? "added" : "failed"; + settledRef.current?.("kiro", session.terminal); + session.waitController?.abort(); + } + setState({ phase: next.state, view: next }); + }, [apiBase]); + const reset = useCallback(() => { if (!sessionRef.current) setState({ phase: "idle" }); }, []); - return { state, start, close, reset }; + return { state, start, selectProfile, close, reset }; } diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 55be0a6b565..dc4e5c9d12f 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -100,6 +100,18 @@ export const de: Record = { "kiroLogin.title": "Bei Kiro anmelden", "kiroLogin.chooseMethod": "Anmeldemethode wählen", "kiroLogin.cli": "Mit Kiro CLI anmelden", + "kiroLogin.iam": "AWS IAM Identity Center", + "kiroLogin.iamDescription": "Verwende das AWS-Zugangsportal und die IAM Identity Center-Region deiner Organisation.", + "kiroLogin.startUrl": "URL des AWS-Zugangsportals", + "kiroLogin.region": "IAM Identity Center-Region", + "kiroLogin.invalidIam": "Gib eine HTTPS-URL des AWS-Zugangsportals mit /start am Ende und eine gültige AWS-Region ein.", + "kiroLogin.back": "Zurück", + "kiroLogin.continue": "Weiter zu AWS", + "kiroLogin.selectProfile": "Kiro-Profil auswählen", + "kiroLogin.profile": "Kiro-Profil", + "kiroLogin.saveAccount": "Konto speichern", + "kiroLogin.selecting": "Kiro-Konto wird gespeichert…", + "kiroLogin.profileFailed": "Dieses Profil konnte nicht gespeichert werden. Versuche es erneut.", "kiroLogin.builderId": "Mit Builder ID anmelden", "kiroLogin.google": "Mit Google anmelden", "kiroLogin.github": "Mit GitHub anmelden", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 354c88f0db8..6e8c2486619 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -101,6 +101,18 @@ export const en = { "kiroLogin.title": "Sign in to Kiro", "kiroLogin.chooseMethod": "Choose a sign-in method", "kiroLogin.cli": "Kiro CLI", + "kiroLogin.iam": "AWS IAM Identity Center", + "kiroLogin.iamDescription": "Use your organization’s AWS access portal and IAM Identity Center region.", + "kiroLogin.startUrl": "AWS access portal URL", + "kiroLogin.region": "IAM Identity Center region", + "kiroLogin.invalidIam": "Enter an HTTPS AWS access portal URL ending in /start and a valid AWS region.", + "kiroLogin.back": "Back", + "kiroLogin.continue": "Continue to AWS", + "kiroLogin.selectProfile": "Choose a Kiro profile", + "kiroLogin.profile": "Kiro profile", + "kiroLogin.saveAccount": "Save account", + "kiroLogin.selecting": "Saving Kiro account…", + "kiroLogin.profileFailed": "Could not save this profile. Try again.", "kiroLogin.builderId": "Builder ID", "kiroLogin.google": "Google", "kiroLogin.github": "GitHub", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 39f52282f94..d9fea5eeea4 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -99,6 +99,18 @@ export const fr: Record = { "kiroLogin.title": "Se connecter à Kiro", "kiroLogin.chooseMethod": "Choisir une méthode de connexion", "kiroLogin.cli": "Importer avec Kiro CLI", + "kiroLogin.iam": "AWS IAM Identity Center", + "kiroLogin.iamDescription": "Utilisez le portail d’accès AWS et la région IAM Identity Center de votre organisation.", + "kiroLogin.startUrl": "URL du portail d’accès AWS", + "kiroLogin.region": "Région IAM Identity Center", + "kiroLogin.invalidIam": "Saisissez une URL HTTPS du portail AWS se terminant par /start et une région AWS valide.", + "kiroLogin.back": "Retour", + "kiroLogin.continue": "Continuer vers AWS", + "kiroLogin.selectProfile": "Choisir un profil Kiro", + "kiroLogin.profile": "Profil Kiro", + "kiroLogin.saveAccount": "Enregistrer le compte", + "kiroLogin.selecting": "Enregistrement du compte Kiro…", + "kiroLogin.profileFailed": "Impossible d’enregistrer ce profil. Réessayez.", "kiroLogin.builderId": "Se connecter avec Builder ID", "kiroLogin.google": "Se connecter avec Google", "kiroLogin.github": "Se connecter avec GitHub", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 5a97b67371f..a9a4c68af60 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -99,6 +99,18 @@ export const ja: Record = { "kiroLogin.title": "Kiro にログイン", "kiroLogin.chooseMethod": "ログイン方法を選択", "kiroLogin.cli": "Kiro CLI から取り込む", + "kiroLogin.iam": "AWS IAM Identity Center", + "kiroLogin.iamDescription": "組織の AWS アクセスポータルと IAM Identity Center のリージョンを入力してください。", + "kiroLogin.startUrl": "AWS アクセスポータルの URL", + "kiroLogin.region": "IAM Identity Center のリージョン", + "kiroLogin.invalidIam": "/start で終わる AWS アクセスポータルの HTTPS URL と有効な AWS リージョンを入力してください。", + "kiroLogin.back": "戻る", + "kiroLogin.continue": "AWS に進む", + "kiroLogin.selectProfile": "Kiro プロファイルを選択", + "kiroLogin.profile": "Kiro プロファイル", + "kiroLogin.saveAccount": "アカウントを保存", + "kiroLogin.selecting": "Kiro アカウントを保存中…", + "kiroLogin.profileFailed": "このプロファイルを保存できませんでした。もう一度お試しください。", "kiroLogin.builderId": "Builder ID でログイン", "kiroLogin.google": "Google でログイン", "kiroLogin.github": "GitHub でログイン", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 09344d12b99..2dac151790a 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -99,6 +99,18 @@ export const ko: Record = { "kiroLogin.title": "Kiro에 로그인", "kiroLogin.chooseMethod": "로그인 방법 선택", "kiroLogin.cli": "Kiro CLI에서 가져오기", + "kiroLogin.iam": "AWS IAM Identity Center", + "kiroLogin.iamDescription": "조직의 AWS 액세스 포털과 IAM Identity Center 리전을 입력하세요.", + "kiroLogin.startUrl": "AWS 액세스 포털 URL", + "kiroLogin.region": "IAM Identity Center 리전", + "kiroLogin.invalidIam": "/start로 끝나는 AWS 액세스 포털의 HTTPS URL과 유효한 AWS 리전을 입력하세요.", + "kiroLogin.back": "뒤로", + "kiroLogin.continue": "AWS로 계속", + "kiroLogin.selectProfile": "Kiro 프로필 선택", + "kiroLogin.profile": "Kiro 프로필", + "kiroLogin.saveAccount": "계정 저장", + "kiroLogin.selecting": "Kiro 계정 저장 중…", + "kiroLogin.profileFailed": "이 프로필을 저장하지 못했습니다. 다시 시도하세요.", "kiroLogin.builderId": "Builder ID로 로그인", "kiroLogin.google": "Google로 로그인", "kiroLogin.github": "GitHub로 로그인", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index 9c49dddc902..c3e2d8f8b1f 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -168,6 +168,18 @@ export const pt: Record = { "kiroLogin.title": "Entrar no Kiro", "kiroLogin.chooseMethod": "Escolha um método de login", "kiroLogin.cli": "Kiro CLI", + "kiroLogin.iam": "AWS IAM Identity Center", + "kiroLogin.iamDescription": "Use o portal de acesso AWS e a região do IAM Identity Center da sua organização.", + "kiroLogin.startUrl": "URL do portal de acesso AWS", + "kiroLogin.region": "Região do IAM Identity Center", + "kiroLogin.invalidIam": "Insira uma URL HTTPS do portal AWS terminada em /start e uma região AWS válida.", + "kiroLogin.back": "Voltar", + "kiroLogin.continue": "Continuar para a AWS", + "kiroLogin.selectProfile": "Escolher um perfil Kiro", + "kiroLogin.profile": "Perfil Kiro", + "kiroLogin.saveAccount": "Salvar conta", + "kiroLogin.selecting": "Salvando conta Kiro…", + "kiroLogin.profileFailed": "Não foi possível salvar este perfil. Tente novamente.", "kiroLogin.builderId": "Builder ID", "kiroLogin.google": "Google", "kiroLogin.github": "GitHub", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 81b386cc191..24476c7e51d 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -99,6 +99,18 @@ export const ru: Record = { "kiroLogin.title": "Войти в Kiro", "kiroLogin.chooseMethod": "Выберите способ входа", "kiroLogin.cli": "Импортировать через Kiro CLI", + "kiroLogin.iam": "AWS IAM Identity Center", + "kiroLogin.iamDescription": "Укажите портал доступа AWS и регион IAM Identity Center вашей организации.", + "kiroLogin.startUrl": "URL портала доступа AWS", + "kiroLogin.region": "Регион IAM Identity Center", + "kiroLogin.invalidIam": "Введите HTTPS-адрес портала AWS, заканчивающийся на /start, и допустимый регион AWS.", + "kiroLogin.back": "Назад", + "kiroLogin.continue": "Продолжить в AWS", + "kiroLogin.selectProfile": "Выберите профиль Kiro", + "kiroLogin.profile": "Профиль Kiro", + "kiroLogin.saveAccount": "Сохранить аккаунт", + "kiroLogin.selecting": "Сохранение аккаунта Kiro…", + "kiroLogin.profileFailed": "Не удалось сохранить этот профиль. Повторите попытку.", "kiroLogin.builderId": "Войти через Builder ID", "kiroLogin.google": "Войти через Google", "kiroLogin.github": "Войти через GitHub", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index f54d723e48e..698905df511 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -100,6 +100,18 @@ export const tr: Record = { "kiroLogin.title": "Kiro oturumu aç", "kiroLogin.chooseMethod": "Oturum açma yöntemi seç", "kiroLogin.cli": "Kiro CLI ile içe aktar", + "kiroLogin.iam": "AWS IAM Identity Center", + "kiroLogin.iamDescription": "Kuruluşunuzun AWS erişim portalını ve IAM Identity Center bölgesini kullanın.", + "kiroLogin.startUrl": "AWS erişim portalı URL’si", + "kiroLogin.region": "IAM Identity Center bölgesi", + "kiroLogin.invalidIam": "/start ile biten bir HTTPS AWS erişim portalı URL’si ve geçerli bir AWS bölgesi girin.", + "kiroLogin.back": "Geri", + "kiroLogin.continue": "AWS ile devam et", + "kiroLogin.selectProfile": "Bir Kiro profili seçin", + "kiroLogin.profile": "Kiro profili", + "kiroLogin.saveAccount": "Hesabı kaydet", + "kiroLogin.selecting": "Kiro hesabı kaydediliyor…", + "kiroLogin.profileFailed": "Bu profil kaydedilemedi. Tekrar deneyin.", "kiroLogin.builderId": "Builder ID ile oturum aç", "kiroLogin.google": "Google ile oturum aç", "kiroLogin.github": "GitHub ile oturum aç", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 2f1a43953e9..b23c18fc54b 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -101,6 +101,18 @@ export const vi: Record = { "kiroLogin.title": "Đăng nhập Kiro", "kiroLogin.chooseMethod": "Chọn cách đăng nhập", "kiroLogin.cli": "Nhập từ Kiro CLI", + "kiroLogin.iam": "AWS IAM Identity Center", + "kiroLogin.iamDescription": "Dùng cổng truy cập AWS và vùng IAM Identity Center của tổ chức bạn.", + "kiroLogin.startUrl": "URL cổng truy cập AWS", + "kiroLogin.region": "Vùng IAM Identity Center", + "kiroLogin.invalidIam": "Nhập URL HTTPS của cổng AWS kết thúc bằng /start và một vùng AWS hợp lệ.", + "kiroLogin.back": "Quay lại", + "kiroLogin.continue": "Tiếp tục đến AWS", + "kiroLogin.selectProfile": "Chọn hồ sơ Kiro", + "kiroLogin.profile": "Hồ sơ Kiro", + "kiroLogin.saveAccount": "Lưu tài khoản", + "kiroLogin.selecting": "Đang lưu tài khoản Kiro…", + "kiroLogin.profileFailed": "Không thể lưu hồ sơ này. Hãy thử lại.", "kiroLogin.builderId": "Đăng nhập bằng Builder ID", "kiroLogin.google": "Đăng nhập bằng Google", "kiroLogin.github": "Đăng nhập bằng GitHub", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 714c609e01d..98d3aa6ca17 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -97,6 +97,18 @@ export const zhTW: Record = { "kiroLogin.title": "登入 Kiro", "kiroLogin.chooseMethod": "選擇登入方式", "kiroLogin.cli": "從 Kiro CLI 匯入", + "kiroLogin.iam": "AWS IAM Identity Center", + "kiroLogin.iamDescription": "請輸入組織的 AWS 存取入口網站和 IAM Identity Center 區域。", + "kiroLogin.startUrl": "AWS 存取入口網站 URL", + "kiroLogin.region": "IAM Identity Center 區域", + "kiroLogin.invalidIam": "請輸入以 /start 結尾的 AWS 存取入口網站 HTTPS URL 和有效的 AWS 區域。", + "kiroLogin.back": "返回", + "kiroLogin.continue": "繼續前往 AWS", + "kiroLogin.selectProfile": "選擇 Kiro 設定檔", + "kiroLogin.profile": "Kiro 設定檔", + "kiroLogin.saveAccount": "儲存帳號", + "kiroLogin.selecting": "正在儲存 Kiro 帳號…", + "kiroLogin.profileFailed": "無法儲存此設定檔,請重試。", "kiroLogin.builderId": "使用 Builder ID 登入", "kiroLogin.google": "使用 Google 登入", "kiroLogin.github": "使用 GitHub 登入", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 9342927ac1b..22d70a3d63b 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -99,6 +99,18 @@ export const zh: Record = { "kiroLogin.title": "登录 Kiro", "kiroLogin.chooseMethod": "选择登录方式", "kiroLogin.cli": "从 Kiro CLI 导入", + "kiroLogin.iam": "AWS IAM Identity Center", + "kiroLogin.iamDescription": "请输入组织的 AWS 访问门户和 IAM Identity Center 区域。", + "kiroLogin.startUrl": "AWS 访问门户 URL", + "kiroLogin.region": "IAM Identity Center 区域", + "kiroLogin.invalidIam": "请输入以 /start 结尾的 AWS 访问门户 HTTPS URL 和有效的 AWS 区域。", + "kiroLogin.back": "返回", + "kiroLogin.continue": "继续前往 AWS", + "kiroLogin.selectProfile": "选择 Kiro 配置文件", + "kiroLogin.profile": "Kiro 配置文件", + "kiroLogin.saveAccount": "保存账户", + "kiroLogin.selecting": "正在保存 Kiro 账户…", + "kiroLogin.profileFailed": "无法保存此配置文件,请重试。", "kiroLogin.builderId": "使用 Builder ID 登录", "kiroLogin.google": "使用 Google 登录", "kiroLogin.github": "使用 GitHub 登录", diff --git a/gui/src/kiro-device-login-helpers.ts b/gui/src/kiro-device-login-helpers.ts index 47b4cab06ae..4a5a2a64e9b 100644 --- a/gui/src/kiro-device-login-helpers.ts +++ b/gui/src/kiro-device-login-helpers.ts @@ -1,7 +1,9 @@ import type { TKey } from "./i18n/shared"; -export type KiroDeviceMethod = "builder-id" | "google" | "github"; -export type KiroDeviceState = "pending" | "done" | "failed" | "expired" | "cancelled"; +export type KiroDeviceMethod = "builder-id" | "google" | "github" | "iam-identity-center"; +export type KiroDeviceState = "pending" | "select-profile" | "done" | "failed" | "expired" | "cancelled"; +export interface KiroIamOptions { startUrl: string; region: string } +export interface KiroIamProfile { arn: string; name: string; region: string } export interface KiroDeviceView { flowId: string; method: KiroDeviceMethod; @@ -11,16 +13,36 @@ export interface KiroDeviceView { verificationUriComplete?: string; expiresAt?: number; warning?: "duplicate_profile_arn" | "manual_review_required"; + profiles?: KiroIamProfile[]; } -const methods = new Set(["builder-id", "google", "github"]); -const states = new Set(["pending", "done", "failed", "expired", "cancelled"]); +const methods = new Set(["builder-id", "google", "github", "iam-identity-center"]); +const states = new Set(["pending", "select-profile", "done", "failed", "expired", "cancelled"]); +const AWS_REGION = /^(?:af|ap|ca|eu|il|me|mx|sa|us)-(?:central|east|north|northeast|northwest|south|southeast|southwest|west)-[1-9][0-9]?$/; + +export function validKiroIamOptions({ startUrl, region }: KiroIamOptions): boolean { + return /^https:\/\/[a-z0-9][a-z0-9-]{0,62}\.awsapps\.com\/start\/?(?:#\/)?$/.test(startUrl) && AWS_REGION.test(region); +} /** Admit only the public device-view fields; an unexpected server field is never retained. */ export function parseKiroDeviceView(input: unknown): KiroDeviceView | null { if (!input || typeof input !== "object") return null; const value = input as Record; if (typeof value.flowId !== "string" || !value.flowId || !methods.has(value.method as KiroDeviceMethod) || !states.has(value.state as KiroDeviceState)) return null; + let profiles: KiroIamProfile[] | undefined; + if (value.state === "select-profile") { + if (value.method !== "iam-identity-center" || !Array.isArray(value.profiles) || !value.profiles.length || value.profiles.length > 64) return null; + profiles = []; + for (const item of value.profiles) { + if (!item || typeof item !== "object") return null; + const profile = item as Record; + if (typeof profile.arn !== "string" || profile.arn.length > 256 + || !/^arn:aws:codewhisperer:[a-z0-9-]+:\d{12}:profile\/[A-Za-z0-9-]+$/.test(profile.arn) + || typeof profile.name !== "string" || !profile.name || profile.name.length > 256 + || typeof profile.region !== "string" || !AWS_REGION.test(profile.region)) return null; + profiles.push({ arn: profile.arn, name: profile.name, region: profile.region }); + } + } return { flowId: value.flowId, method: value.method as KiroDeviceMethod, @@ -30,6 +52,7 @@ export function parseKiroDeviceView(input: unknown): KiroDeviceView | null { ...(typeof value.verificationUriComplete === "string" ? { verificationUriComplete: value.verificationUriComplete } : {}), ...(typeof value.expiresAt === "number" && Number.isFinite(value.expiresAt) ? { expiresAt: value.expiresAt } : {}), ...(value.warning === "duplicate_profile_arn" || value.warning === "manual_review_required" ? { warning: value.warning } : {}), + ...(profiles ? { profiles } : {}), }; } @@ -42,7 +65,8 @@ export function kiroVerificationLink(raw: string | undefined): string | null { const url = new URL(raw); if (url.protocol !== "https:" || url.username || url.password || url.port) return null; const host = url.hostname.toLowerCase(); - if (host !== "device.sso.us-east-1.amazonaws.com" && host !== "kiro.dev" && !host.endsWith(".kiro.dev")) return null; + const awsRegion = /^device\.sso\.([a-z0-9-]+)\.amazonaws\.com$/.exec(host)?.[1]; + if (!(awsRegion && AWS_REGION.test(awsRegion)) && host !== "kiro.dev" && !host.endsWith(".kiro.dev")) return null; return url.href; } catch { return null; } } diff --git a/gui/src/styles/kiro-device-login.css b/gui/src/styles/kiro-device-login.css index 63b3d2a1d20..78ea59f4618 100644 --- a/gui/src/styles/kiro-device-login.css +++ b/gui/src/styles/kiro-device-login.css @@ -6,3 +6,6 @@ .kiro-device-code-value { font-family: var(--font-code); font-size: var(--text-title); font-weight: var(--weight-semibold); letter-spacing: var(--tracking-wide); } .kiro-device-destination { overflow-wrap: anywhere; user-select: all; } .kiro-device-actions { display: flex; justify-content: flex-end; gap: var(--space-2); margin-top: var(--space-4); } +.kiro-iam-form { display: grid; gap: var(--space-2); margin-block: var(--space-4); } +.kiro-iam-form label { margin-top: var(--space-2); font-weight: var(--weight-medium); } +.kiro-iam-form input, .kiro-iam-form select { width: 100%; min-height: var(--control-touch); } diff --git a/gui/tests/fr-localization.test.ts b/gui/tests/fr-localization.test.ts index 7fb61407013..303ae818021 100644 --- a/gui/tests/fr-localization.test.ts +++ b/gui/tests/fr-localization.test.ts @@ -27,6 +27,7 @@ function carriesTranslatableWords(value: string): boolean { const INTENTIONAL_ENGLISH = new Set([ "nav.claude", // Product name. "nav.codexSet", // Product name: the sidebar row reads "Codex" in every locale. + "kiroLogin.iam", // AWS product name, retained in the original spelling. // The vendor name, and the Pool 2 label that de and pt also keep: "pool" is the French UI term here. "sidecar.poolA", "sidecar.poolB", diff --git a/gui/tests/kiro-device-login.test.tsx b/gui/tests/kiro-device-login.test.tsx index 203b1d5ee6c..de555f33e1d 100644 --- a/gui/tests/kiro-device-login.test.tsx +++ b/gui/tests/kiro-device-login.test.tsx @@ -81,6 +81,19 @@ async function click(label: string) { } async function start() { await click("Builder ID"); expect(requests.find(r => r.url.endsWith("/api/oauth/login"))?.init?.body).toBe(JSON.stringify({ provider: "kiro", method: "builder-id" })); } async function tick() { expect(ticks.length).toBeGreaterThan(0); await act(async () => { ticks.shift()!(); }); await flush(); } +async function iamInput(portal = "https://example.awsapps.com/start", region = "eu-west-1") { + const inputs = host.querySelectorAll(".kiro-iam-form input"); + await act(async () => { + for (const [index, value] of [portal, region].entries()) { + Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, "value")!.set!.call(inputs[index], value); + inputs[index]!.dispatchEvent(new win.Event("input", { bubbles: true })); + } + }); +} +async function submitIamForm() { + await act(async () => { host.querySelector(".kiro-iam-form")!.dispatchEvent(new win.Event("submit", { bubbles: true, cancelable: true })); }); + await flush(); +} function expectCancelBeforeStatus() { const cancelIndex = requests.findIndex(r => r.url.endsWith("/api/oauth/login/cancel") && JSON.parse(String(r.init?.body)).flowId === flowId); @@ -98,6 +111,74 @@ test("chooser starts native login, shows code and safe link, then confirms done" expect(host.textContent).toContain("Kiro account added"); }); +test("IAM collects portal and region before starting and accepts regional AWS verification links", async () => { + responder = async url => url.endsWith("/api/oauth/login") ? json(view("pending", { + method: "iam-identity-center", verificationUri: "https://device.sso.eu-west-1.amazonaws.com/", + })) : json(view("done", { method: "iam-identity-center" })); + await mountDialog(); await click("AWS IAM Identity Center"); + expect(requests).toHaveLength(0); + expect(host.querySelector('label[for$="-portal"]')?.textContent).toBe("AWS access portal URL"); + await iamInput(); await submitIamForm(); + expect(JSON.parse(String(requests[0]!.init?.body))).toEqual({ + provider: "kiro", method: "iam-identity-center", startUrl: "https://example.awsapps.com/start", region: "eu-west-1", + }); + expect(host.querySelector('a[href="https://device.sso.eu-west-1.amazonaws.com/"]')).not.toBeNull(); + await tick(); + expect(settled).toEqual(["added"]); +}); + +test("invalid IAM portal or region is explained without sending a login request", async () => { + await mountDialog(); await click("AWS IAM Identity Center"); + await iamInput("https://evil.test/start", "eu-west-1.evil.test"); await submitIamForm(); + expect(requests).toHaveLength(0); + expect(host.querySelector('[role="alert"]')?.textContent).toContain("valid AWS region"); + await click("Back"); + expect(host.textContent).toContain("Builder ID"); +}); + +test.each(["portal", "region"])("editing the IAM %s clears stale validation feedback", async field => { + await mountDialog(); await click("AWS IAM Identity Center"); + await iamInput("https://evil.test/start", "invalid-region"); await submitIamForm(); + expect(host.querySelector('[role="alert"]')).not.toBeNull(); + const input = host.querySelector(`input[id$="-${field}"]`)!; + await act(async () => { + Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, "value")!.set!.call(input, + field === "portal" ? "https://example.awsapps.com/start" : "eu-west-1"); + input.dispatchEvent(new win.Event("input", { bubbles: true })); + }); + expect(host.querySelector('[role="alert"]')).toBeNull(); + expect(host.querySelector('[aria-invalid="true"]')).toBeNull(); + expect(host.querySelector("input[aria-describedby]")).toBeNull(); + await submitIamForm(); + expect(host.querySelector('[role="alert"]')).not.toBeNull(); + expect(requests).toHaveLength(0); +}); + +test("IAM profile selection sends the server-offered ARN and completes only once", async () => { + const profiles = [ + { arn: "arn:aws:codewhisperer:us-east-1:123456789012:profile/team", name: "Team profile", region: "us-east-1" }, + { arn: "arn:aws:codewhisperer:eu-central-1:123456789012:profile/europe", name: "European profile", region: "eu-central-1" }, + ]; + const choosing = view("select-profile", { method: "iam-identity-center", profiles }); + responder = async (url, init) => { + if (url.endsWith("/api/oauth/login")) { + const body = JSON.parse(String(init?.body)); + return json(body.profileArn ? view("done", { method: "iam-identity-center" }) : view("pending", { method: "iam-identity-center" })); + } + return json(choosing); + }; + await mountDialog(); await click("AWS IAM Identity Center"); await iamInput(); await submitIamForm(); await tick(); + expect(host.textContent).toContain("Choose a Kiro profile"); + expect(settled).toEqual([]); + const select = host.querySelector("select")!; + await act(async () => { select.value = profiles[1]!.arn; select.dispatchEvent(new win.Event("change", { bubbles: true })); }); + await submitIamForm(); + const posts = requests.filter(request => request.url.endsWith("/api/oauth/login")); + expect(JSON.parse(String(posts[1]!.init?.body))).toEqual({ provider: "kiro", flowId, profileArn: profiles[1]!.arn }); + expect(host.textContent).toContain("Kiro account added"); + expect(settled).toEqual(["added"]); +}); + test("unknown verification host is copyable text, not a link; warning is visible", async () => { responder = async url => url.endsWith("/api/oauth/login") ? json(view("pending", { verificationUri: "https://x.awsapps.com/", warning: "duplicate_profile_arn" })) : json(view("cancelled")); await mountDialog(); await start(); diff --git a/gui/tests/locale-parity.test.ts b/gui/tests/locale-parity.test.ts index d51d5009810..9e155e33aa6 100644 --- a/gui/tests/locale-parity.test.ts +++ b/gui/tests/locale-parity.test.ts @@ -43,6 +43,7 @@ function carriesTranslatableWords(value: string): boolean { const ZH_TW_KEEP_ENGLISH: ReadonlySet = new Set([ // The Codex sidebar row is the product name, kept in every locale like Claude. "nav.codexSet", + "kiroLogin.iam", // AWS product name, retained in the original spelling. // The primary Anthropic pool is labelled by the vendor name alone, kept in every locale. "sidecar.poolA", // A bare em dash: the "no Reasoning control" marker is a symbol, not copy. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 72a9048d137..f7319a77ebe 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1083,7 +1083,7 @@ "kiro-auth-context-continuation.test.ts": "providers/kiro", "kiro-builder-id-profile.test.ts": "providers/kiro", "kiro-calibration.test.ts": "providers/kiro", - "kiro-device-builder.test.ts": "providers/kiro", "kiro-device-social.test.ts": "providers/kiro", + "kiro-device-builder.test.ts": "providers/kiro", "kiro-device-iam.test.ts": "providers/kiro", "kiro-device-social.test.ts": "providers/kiro", "kiro-fallback-error-body.test.ts": "providers/kiro", "kiro-images.test.ts": "providers/kiro", "kiro-leased-responses.test.ts": "providers/kiro", diff --git a/src/oauth/kiro-device-login.ts b/src/oauth/kiro-device-login.ts index 9db5f28c296..423bec62614 100644 --- a/src/oauth/kiro-device-login.ts +++ b/src/oauth/kiro-device-login.ts @@ -8,10 +8,11 @@ import { appendKiroAccountFromDeviceLogin, isStorableKiroClientPart, rollbackCre import type { OAuthCredentials } from "./types"; import type { ManagementPrincipal } from "../server/management-auth"; import type { OcxConfig } from "../types"; +import { listKiroIamProfiles, parseKiroIamOptions, resolveKiroIamCredential, type KiroIamOptions, type KiroIamProfile } from "./kiro-iam-identity-center"; -export type KiroDeviceMethod = "builder-id" | "google" | "github"; +export type KiroDeviceMethod = "builder-id" | "google" | "github" | "iam-identity-center"; export type KiroDevicePrincipal = ManagementPrincipal; -type State = "pending" | "done" | "failed" | "expired" | "cancelled"; +type State = "pending" | "select-profile" | "done" | "failed" | "expired" | "cancelled"; export interface KiroDeviceView { flowId: string; method: KiroDeviceMethod; @@ -21,6 +22,7 @@ export interface KiroDeviceView { verificationUriComplete?: string; expiresAt?: number; warning?: "duplicate_profile_arn" | "manual_review_required"; + profiles?: KiroIamProfile[]; } interface Flow { view: KiroDeviceView; @@ -36,8 +38,11 @@ interface Flow { commitAccepted?: boolean; terminalAt?: number; retentionTimer?: ReturnType; + expiryTimer?: ReturnType; + iam?: KiroIamOptions; + credential?: OAuthCredentials; } -export type KiroDevicePost = (url: string, body: Record) => Promise; +export type KiroDevicePost = (url: string, body: Record, headers?: Record) => Promise; const SOCIAL = "https://prod.us-east-1.auth.desktop.kiro.dev"; const OIDC = "https://oidc.us-east-1.amazonaws.com"; const MAX_KIRO_DEVICE_FLOWS = 4; @@ -50,7 +55,7 @@ const USER_CODE = /^[A-Za-z0-9-]{4,32}$/; const CONTROL = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff]/; const flows = new Map(); -async function defaultPost(url: string, body: Record): Promise { +async function defaultPost(url: string, body: Record, headers?: Record): Promise { const configured = loadConfig().providers.kiro; return providerOutboundPost("kiro", { baseUrl: new URL(url).origin, @@ -58,7 +63,7 @@ async function defaultPost(url: string, body: Record): Promise< ...(configured?.noProxy ? { noProxy: configured.noProxy } : {}), ...(configured?.allowPrivateNetwork ? { allowPrivateNetwork: true } : {}), }, url, { - headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(20_000), + headers: { "content-type": "application/json", ...headers }, body: JSON.stringify(body), signal: AbortSignal.timeout(20_000), }); } @@ -77,7 +82,7 @@ export function setKiroDevicePublishForTests(publish?: () => void | Promise { return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; } -async function reply(url: string, body: Record): Promise<{ status: number; data: Record; errorType: string }> { - const response = await transport(url, body); +async function reply(url: string, body: Record, headers?: Record): Promise<{ status: number; data: Record; errorType: string }> { + const response = await transport(url, body, headers); // Unknown upstream bodies are bounded and never echoed into a response or error. const { bytes, oversized } = await readBoundedResponseBytes(response, { maxBytes: BOUNDED_BODY_MAX_BYTES, signal: AbortSignal.timeout(20_000), @@ -98,13 +103,18 @@ async function reply(url: string, body: Record): Promise<{ stat return { status: response.status, data, errorType: response.headers.get("x-amzn-errortype")?.split(":")[0] ?? "" }; } function positive(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value) && value > 0; } -function publicView(flow: Flow): KiroDeviceView { return { ...flow.view }; } +function publicView(flow: Flow): KiroDeviceView { + return { ...flow.view, ...(flow.view.profiles ? { profiles: flow.view.profiles.map(profile => ({ ...profile })) } : {}) }; +} +function live(flow: Flow): boolean { return flow.view.state === "pending" || flow.view.state === "select-profile"; } function forgetFlow(id: string): void { const flow = flows.get(id); if (flow?.retentionTimer) clearTimeout(flow.retentionTimer); + if (flow?.expiryTimer) clearTimeout(flow.expiryTimer); flows.delete(id); } function terminal(flow: Flow, state: State, warning?: KiroDeviceView["warning"]): KiroDeviceView { + clearTimeout(flow.expiryTimer); flow.view.state = state; if (flow.terminalAt === undefined) { flow.terminalAt = clock(); @@ -118,16 +128,19 @@ function terminal(flow: Flow, state: State, warning?: KiroDeviceView["warning"]) delete flow.clientId; delete flow.clientSecret; delete flow.configBaseline; + delete flow.credential; + delete flow.iam; + delete flow.view.profiles; return publicView(flow); } function pruneFlows(): void { const now = clock(); for (const [id, flow] of flows) { - if (flow.view.state === "pending" && now >= flow.deadline) terminal(flow, "expired"); + if (live(flow) && now >= flow.deadline && !flow.commitAccepted) terminal(flow, "expired"); if (flow.terminalAt !== undefined && now - flow.terminalAt >= TERMINAL_RETENTION_MS) forgetFlow(id); } while (flows.size >= MAX_KIRO_DEVICE_ENTRIES) { - const oldest = [...flows].find(([, flow]) => flow.view.state !== "pending"); + const oldest = [...flows].find(([, flow]) => !live(flow)); if (!oldest) break; forgetFlow(oldest[0]); } @@ -135,7 +148,7 @@ function pruneFlows(): void { function owned(flowId: string, principal: KiroDevicePrincipal): Flow | undefined { const flow = flows.get(flowId); if (!flow || flow.ownerPrincipal !== principal) return undefined; - if (clock() >= flow.deadline) { + if (clock() >= flow.deadline && !flow.commitAccepted) { terminal(flow, "expired"); forgetFlow(flowId); return undefined; @@ -171,12 +184,21 @@ function viewFromAuthorization(flow: Flow, data: Record, scale: ...(data.verificationUriComplete ? { verificationUriComplete: data.verificationUriComplete } : {}), expiresAt: flow.deadline, }; + // An approved IAM grant may wait for a profile choice. Erase that staged credential at + // expiry even if its browser disappears and no further management request prunes flows. + flow.expiryTimer = setTimeout(() => { + if (flows.get(flow.view.flowId) === flow && live(flow) && !flow.commitAccepted) terminal(flow, "expired"); + }, Math.max(0, flow.deadline - clock())); + flow.expiryTimer.unref(); } -export async function startKiroDeviceLogin(method: KiroDeviceMethod, principal: KiroDevicePrincipal, configBaseline?: OcxConfig): Promise { +export async function startKiroDeviceLogin(method: KiroDeviceMethod, principal: KiroDevicePrincipal, configBaseline?: OcxConfig, + iamInput?: unknown): Promise { + const iam = method === "iam-identity-center" ? parseKiroIamOptions(iamInput) : null; + if (method === "iam-identity-center" && !iam) throw new Error("Invalid Kiro IAM settings"); // Reserve before the first await, so concurrent starts cannot evade the cap. pruneFlows(); - if ([...flows.values()].filter(flow => flow.view.state === "pending").length >= MAX_KIRO_DEVICE_FLOWS) { + if ([...flows.values()].filter(live).length >= MAX_KIRO_DEVICE_FLOWS) { throw new Error("Too many Kiro device logins in progress"); } if (flows.size >= MAX_KIRO_DEVICE_ENTRIES) throw new Error("Too many Kiro device logins in progress"); @@ -185,12 +207,14 @@ export async function startKiroDeviceLogin(method: KiroDeviceMethod, principal: view: { flowId, method, state: "pending" }, ownerPrincipal: principal, nextPollAt: 0, intervalMs: 5_000, deadline: clock() + MAX_LIFETIME_MS, ...(configBaseline ? { configBaseline: structuredClone(configBaseline) } : {}), + ...(iam ? { iam } : {}), }; flows.set(flowId, flow); let started = false; try { - if (method === "builder-id") { - const registration = await reply(`${OIDC}/client/register`, { + if (method === "builder-id" || iam) { + const oidc = iam ? `https://oidc.${iam.region}.amazonaws.com` : OIDC; + const registration = await reply(`${oidc}/client/register`, { clientName: "kiro-cli", clientType: "public", scopes: ["codewhisperer:completions", "codewhisperer:analysis", "codewhisperer:conversations"], }); @@ -198,8 +222,8 @@ export async function startKiroDeviceLogin(method: KiroDeviceMethod, principal: || !isStorableKiroClientPart(registration.data.clientSecret)) throw new Error("Kiro registration failed"); flow.clientId = registration.data.clientId; flow.clientSecret = registration.data.clientSecret; - const authorization = await reply(`${OIDC}/device_authorization`, { - clientId: flow.clientId, clientSecret: flow.clientSecret, startUrl: "https://view.awsapps.com/start", + const authorization = await reply(`${oidc}/device_authorization`, { + clientId: flow.clientId, clientSecret: flow.clientSecret, startUrl: iam?.startUrl ?? "https://view.awsapps.com/start", }); if (authorization.status < 200 || authorization.status >= 300) throw new Error("Kiro device authorization failed"); viewFromAuthorization(flow, authorization.data, 1000); @@ -227,9 +251,10 @@ async function poll(flow: Flow, principal: KiroDevicePrincipal): Promise { - if (flows.get(flow.view.flowId) !== flow || flow.ownerPrincipal !== principal - || flow.view.state !== "pending" || clock() >= flow.deadline) throw new Error("Kiro device flow cancelled"); + if (flow.iam) { + const profiles = await listKiroIamProfiles(reply, credential.access); + if (!owned(flow.view.flowId, principal) || !live(flow)) return publicView(flow); + flow.credential = credential; + flow.view = { flowId: flow.view.flowId, method: flow.view.method, state: "select-profile", expiresAt: flow.deadline, profiles }; + if (profiles.length === 1) return await commitIamProfile(flow, principal, profiles[0]!); + return publicView(flow); + } + return await commitCredential(flow, principal, credential); + } catch { + return live(flow) ? terminal(flow, "failed") : publicView(flow); + } +} + +async function commitCredential(flow: Flow, principal: KiroDevicePrincipal, credential: OAuthCredentials): Promise { + try { + const added = await appendKiroAccountFromDeviceLogin(credential, { assertBeforePersist: () => { + if (!owned(flow.view.flowId, principal) || !live(flow)) throw new Error("Kiro device flow cancelled"); flow.commitAccepted = true; - }; - const added = await appendKiroAccountFromDeviceLogin(credential, { assertBeforePersist }); + } }); try { await publishConfig(); } catch { @@ -282,14 +321,32 @@ async function poll(flow: Flow, principal: KiroDevicePrincipal): Promise { + try { + if (!flow.credential) throw new Error("Kiro IAM grant unavailable"); + const credential = await resolveKiroIamCredential(reply, flow.credential, profile); + if (!owned(flow.view.flowId, principal) || !live(flow)) return publicView(flow); + return await commitCredential(flow, principal, credential); + } catch { return live(flow) ? terminal(flow, "failed") : publicView(flow); } +} + +export async function selectKiroDeviceProfile(flowId: string, principal: KiroDevicePrincipal, arn: string): Promise { + const flow = owned(flowId, principal); + const profile = flow?.view.profiles?.find(profile => profile.arn === arn); + if (!flow || flow.view.method !== "iam-identity-center" || flow.view.state !== "select-profile" || !profile) return null; + if (!flow.polling) flow.polling = commitIamProfile(flow, principal, profile).finally(() => { flow.polling = undefined; }); + return flow.polling; +} + export async function statusKiroDeviceLogin(flowId: string, principal: KiroDevicePrincipal): Promise { pruneFlows(); const flow = owned(flowId, principal); if (!flow) return null; + if (flow.view.state === "select-profile") return publicView(flow); if (flow.view.state !== "pending") { forgetFlow(flowId); return publicView(flow); @@ -297,15 +354,15 @@ export async function statusKiroDeviceLogin(flowId: string, principal: KiroDevic if (clock() < flow.nextPollAt) return publicView(flow); if (!flow.polling) flow.polling = poll(flow, principal).finally(() => { flow.polling = undefined; }); const result = await flow.polling; - if (result.state !== "pending") forgetFlow(flowId); + if (!live(flow)) forgetFlow(flowId); return result; } export function cancelKiroDeviceLogin(flowId: string, principal: KiroDevicePrincipal): KiroDeviceView | null { const flow = owned(flowId, principal); if (!flow) return null; - if (flow.commitAccepted && flow.view.state === "pending") return { ...flow.view, state: "done" }; - if (flow.view.state === "pending") { + if (flow.commitAccepted && live(flow)) return { ...flow.view, state: "done" }; + if (live(flow)) { terminal(flow, "cancelled"); forgetFlow(flowId); } diff --git a/src/oauth/kiro-iam-identity-center.ts b/src/oauth/kiro-iam-identity-center.ts new file mode 100644 index 00000000000..81a2aa20033 --- /dev/null +++ b/src/oauth/kiro-iam-identity-center.ts @@ -0,0 +1,107 @@ +import type { OAuthCredentials } from "./types"; + +export interface KiroIamOptions { startUrl: string; region: string } +export interface KiroIamProfile { arn: string; name: string; region: string } +export type KiroIamReply = (url: string, body: Record, headers?: Record) => + Promise<{ status: number; data: Record; errorType: string }>; +const REGION = /^(?:af|ap|ca|eu|il|me|mx|sa|us)-(?:central|east|north|northeast|northwest|south|southeast|southwest|west)-[1-9][0-9]?$/; +const CONTROL = /[\u0000-\u0020\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff]/; +const PROFILE = /^arn:aws:codewhisperer:([a-z0-9-]+):\d{12}:profile\/[A-Za-z0-9-]+$/; +// The AWS CLI profile client enumerates these two service regions independently of SSO. +const PROFILE_REGIONS = ["us-east-1", "eu-central-1"] as const; +class KiroIamRequestError extends Error { + constructor(readonly status: number) { super("Kiro IAM discovery failed"); } +} +const record = (value: unknown): Record => + value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; + +/** Portal URLs are arguments to AWS OIDC, never destinations fetched by the hub. */ +export function parseKiroIamOptions(value: unknown): KiroIamOptions | null { + const { startUrl, region } = record(value); + if (typeof startUrl !== "string" || startUrl.length > 2048 || CONTROL.test(startUrl) + || typeof region !== "string" || !REGION.test(region)) return null; + try { + const url = new URL(startUrl); + const authority = /^https:\/\/([^/?#]+)/.exec(startUrl)?.[1]; + if (!authority || authority.includes(":") || authority.includes("@") || url.protocol !== "https:" + || !/^[a-z0-9][a-z0-9-]{0,62}\.awsapps\.com$/.test(url.hostname) + || !/^\/start\/?$/.test(url.pathname) || url.search || (url.hash && url.hash !== "#/")) return null; + return { startUrl: `https://${url.hostname}/start`, region }; + } catch { return null; } +} + +function managementUrl(region: string): string { + if (!REGION.test(region)) throw new Error("Invalid Kiro IAM region"); + if (!PROFILE_REGIONS.some(candidate => candidate === region)) throw new Error("Unsupported Kiro profile region"); + return `https://q.${region}.amazonaws.com/`; +} + +async function call(reply: KiroIamReply, access: string, region: string, operation: string, + body: Record): Promise> { + const url = new URL(managementUrl(region)); + // GetUsageLimits models its arguments in both the query and JSON body (the CLI contract). + if (operation === "GetUsageLimits") for (const [key, value] of Object.entries(body)) url.searchParams.set(key, String(value)); + const result = await reply(url.href, body, { + authorization: `Bearer ${access}`, "content-type": "application/x-amz-json-1.0", + "x-amz-target": `AmazonCodeWhispererService.${operation}`, "x-amzn-codewhisperer-optout": "true", + }); + if (result.status !== 200 || result.errorType || Object.hasOwn(result.data, "error")) throw new KiroIamRequestError(result.status); + return result.data; +} + +/** Bounded pagination; a partial or ambiguous profile list is never offered as complete. */ +async function listProfilesInRegion(reply: KiroIamReply, access: string, region: string): Promise { + const profiles = new Map(); + const tokens = new Set(); + let nextToken: string | undefined; + for (let page = 0; page < 8; page++) { + const data = await call(reply, access, region, "ListAvailableProfiles", { maxResults: 50, ...(nextToken ? { nextToken } : {}) }); + if (!Array.isArray(data.profiles)) throw new Error("Kiro IAM profiles unavailable"); + for (const item of data.profiles) { + const profile = record(item); + const arn = typeof profile.arn === "string" ? profile.arn : ""; + const match = PROFILE.exec(arn); + const name = typeof profile.profileName === "string" ? profile.profileName.trim() : ""; + if (!match || arn.length > 256 || !REGION.test(match[1]!) || !name || name.length > 256 + || /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/.test(name)) throw new Error("Invalid Kiro IAM profile"); + profiles.set(arn, { arn, name, region: match[1]! }); + if (profiles.size > 64) throw new Error("Too many Kiro IAM profiles"); + } + if (data.nextToken === undefined || data.nextToken === null || data.nextToken === "") { + return [...profiles.values()]; + } + if (typeof data.nextToken !== "string" || data.nextToken.length > 4096 || tokens.has(data.nextToken)) break; + nextToken = data.nextToken; + tokens.add(nextToken); + } + throw new Error("Incomplete Kiro IAM profile list"); +} + +export async function listKiroIamProfiles(reply: KiroIamReply, access: string): Promise { + const regional = await Promise.all(PROFILE_REGIONS.map(async region => { + try { return await listProfilesInRegion(reply, access, region); } + catch (error) { + // Organizations may grant access in only one service region. Other failures must not + // silently present a partial list as if discovery had completed. + if (error instanceof KiroIamRequestError && (error.status === 401 || error.status === 403)) return []; + throw error; + } + })); + const profiles = new Map(regional.flat().map(profile => [profile.arn, profile])); + if (!profiles.size || profiles.size > 64) throw new Error("Kiro IAM profiles unavailable"); + return [...profiles.values()]; +} + +/** Only AWS's response to this grant can name the user; a shared organization ARN cannot. */ +export async function resolveKiroIamCredential(reply: KiroIamReply, credential: OAuthCredentials, + profile: KiroIamProfile): Promise { + const data = await call(reply, credential.access, profile.region, "GetUsageLimits", { + origin: "CLI", isEmailRequired: true, profileArn: profile.arn, + }); + const email = record(data.userInfo).email; + if (typeof email !== "string" || !email.trim() || email.length > 320 || CONTROL.test(email.trim())) { + throw new Error("Kiro IAM user identity unavailable"); + } + return { ...credential, email: email.trim().toLowerCase(), accountId: profile.arn, + kiro: { ...credential.kiro, profileArn: profile.arn, apiRegion: profile.region } }; +} diff --git a/src/oauth/store.ts b/src/oauth/store.ts index cf340dc65a6..12c4339ab8b 100644 --- a/src/oauth/store.ts +++ b/src/oauth/store.ts @@ -1114,7 +1114,8 @@ export async function appendKiroAccountFromDeviceLogin( return await mutateStore(store => { const set = store.kiro; const id = distinctAccountId(safe, set?.accounts ?? []); - const warning = safe.kiro?.profileArn && set?.accounts.some(a => a.credential.kiro?.profileArn === safe.kiro?.profileArn) + const warning = safe.kiro?.profileArn && set?.accounts.some(a => a.credential.kiro?.profileArn === safe.kiro?.profileArn + && (!safe.email || !a.credential.email || a.credential.email.toLowerCase() === safe.email.toLowerCase())) ? "duplicate_profile_arn" as const : undefined; const account: ProviderAccount = { id, credential: safe, addedAt: Date.now(), loginId: randomUUID(), loginOrigin: "kiro-device" }; if (set) set.accounts.push(account); diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index 298d50e3ca5..16a1bb8419d 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -37,7 +37,8 @@ import { waitForLoginSettled, } from "../../oauth"; import { OAuthMutationBusyError, removeCredential } from "../../oauth/store"; -import { cancelKiroDeviceLogin, kiroDeviceConfigBaseline, startKiroDeviceLogin, statusKiroDeviceLogin, type KiroDeviceMethod } from "../../oauth/kiro-device-login"; +import { cancelKiroDeviceLogin, kiroDeviceConfigBaseline, selectKiroDeviceProfile, startKiroDeviceLogin, statusKiroDeviceLogin, type KiroDeviceMethod } from "../../oauth/kiro-device-login"; +import { parseKiroIamOptions } from "../../oauth/kiro-iam-identity-center"; import { providerDestinationResolvedError } from "../../lib/destination-policy"; import { emailMaskingEnabled } from "../../lib/privacy"; import { reconcileLiveStateStores } from "../../lib/state-store-registrations"; @@ -259,7 +260,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< // the provider's loopback callback server (inside this process) captures the redirect in the // background, then the credential is persisted. The GUI opens the URL and polls /api/oauth/status. if (url.pathname === "/api/oauth/login" && req.method === "POST") { - const body = await readManagementJsonBodyOr(req, {}) as { provider?: string; addAccount?: boolean; accountId?: string; reauth?: boolean; openBrowser?: unknown; method?: unknown; mode?: unknown }; + const body = await readManagementJsonBodyOr(req, {}) as { provider?: string; addAccount?: boolean; accountId?: string; reauth?: boolean; openBrowser?: unknown; method?: unknown; mode?: unknown; startUrl?: unknown; region?: unknown; flowId?: unknown; profileArn?: unknown }; const provider = (body.provider ?? "").trim().toLowerCase(); if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400); // Omitted mode is the localhost-callback flow this route has always run. @@ -282,13 +283,28 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< const reauth = body.reauth === true || Boolean(accountId); if (provider === "kiro") { if (reauth && !accountId) return jsonResponse({ error: "Kiro reauth requires an accountId" }, 400); + if (body.flowId !== undefined) { + if (reauth || body.method !== undefined || typeof body.flowId !== "string" || body.flowId.length > 128 + || typeof body.profileArn !== "string" || body.profileArn.length > 256) return jsonResponse({ error: "invalid Kiro profile selection" }, 400); + const owner = principal ?? "admin-token"; + const baseline = kiroDeviceConfigBaseline(body.flowId, owner); + const status = await selectKiroDeviceProfile(body.flowId, owner, body.profileArn); + if (!status) return jsonResponse({ error: "unknown Kiro profile selection" }, 404); + if (status.state === "done") { + reconcileLiveConfigFromDisk(config, baseline ?? structuredClone(config)); + reconcileLiveStateStores(); + } + return jsonResponse(status); + } if (body.method !== undefined) { - if (body.method !== "builder-id" && body.method !== "google" && body.method !== "github") { + if (body.method !== "builder-id" && body.method !== "google" && body.method !== "github" && body.method !== "iam-identity-center") { return jsonResponse({ error: "invalid Kiro device method" }, 400); } if (reauth) return jsonResponse({ error: "native_login_is_add_only" }, 400); + const iam = body.method === "iam-identity-center" ? parseKiroIamOptions(body) : undefined; + if (body.method === "iam-identity-center" && !iam) return jsonResponse({ error: "invalid Kiro IAM portal or region" }, 400); try { - return jsonResponse(await startKiroDeviceLogin(body.method as KiroDeviceMethod, principal ?? "admin-token", readConfigDiagnostics().config)); + return jsonResponse(await startKiroDeviceLogin(body.method as KiroDeviceMethod, principal ?? "admin-token", readConfigDiagnostics().config, iam)); } catch { return jsonResponse({ error: "Kiro device login could not start" }, 409); } diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 0cc92f63148..5b07124fd30 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -101,11 +101,11 @@ unsupported; deployments that previously relied on such embedding must open it a ## Authentication boundaries Kiro management login starts the native device flow only when `POST /api/oauth/login` -supplies `method: "builder-id"`, `"google"`, or `"github"`. A method-less request retains +supplies `method: "builder-id"`, `"google"`, `"github"`, or `"iam-identity-center"`. A method-less request retains the Kiro CLI flow used by the dashboard chooser. The KiroDeviceLoginDialog and useKiroDeviceLogin GUI modules own the native chooser and polling. The kiro-device-login-finalizer GUI module continues terminal status reads after dialog unmount; a provisional cancel result is never treated as confirmed success. Hook and finalizer share one status-read operation that owns fetch, bounded body consumption, parsing and cancellation. The complete read has a 45-second ceiling, the detached loop remains bounded by flow expiry plus 60 seconds (and 16 minutes maximum), and closing the dialog transfers rather than clones an in-flight reader so an already-observed terminal reply can still win. Native status and cancellation require `flowId`; provider-keyed status and cancellation retain their previous behavior. Device-flow responses contain only the flow handle, method, public verification fields, state, expiry, and a -duplicate-profile warning when applicable. The dashboard renders a native device dialog and links only exact Builder ID or Kiro-owned verification hosts. Kiro account rows show automatic-selection exclusion reasons when present. +duplicate-profile warning when applicable. IAM's required settings, owner-scoped profile selection and private staged grants follow the [Kiro native login contract](providers/kiro.md#kiro-cli-executable-resolution). The dashboard links only exact regional AWS device-SSO or Kiro-owned verification hosts. Kiro account rows show automatic-selection exclusion reasons when present. > Decision record: [ADR-6021](decisions/ADR-6021-kiro-status-read-ownership.md) diff --git a/structure/providers/kiro.md b/structure/providers/kiro.md index 04d1c7000be..f324a6b1d0c 100644 --- a/structure/providers/kiro.md +++ b/structure/providers/kiro.md @@ -12,20 +12,38 @@ provider capabilities below; see [Responses compatibility](../transports/respons ## Kiro CLI executable resolution -Native device login is an add-only Kiro account path for Builder ID, Google, and GitHub. -It uses fixed Kiro authorization hosts, guarded outbound POSTs, and a bounded process-local -flow table. A flow ID is returned only at start; status and cancellation require that ID and +Native device login is an add-only Kiro account path for AWS IAM Identity Center, Builder ID, Google, and GitHub. +It uses the fixed AWS OIDC host for Builder ID, the fixed Kiro social authorization host for +Google and GitHub, and the selected region's AWS OIDC host for IAM, with guarded outbound +POSTs and a bounded process-local flow table. A flow ID is returned only at start; status and cancellation require that ID and the same management principal kind. Polling follows the server's interval, and only an exact approval shape reaches the protected OAuth store. Device codes and tokens never enter management responses. Social polling accepts `authorized` and the legacy `approved` alias, with the same token and profile checks. Native slots carry `loginOrigin: "kiro-device"`; kiro-cli reauth refuses them before starting CLI work. Upstream verification URLs require HTTPS, no credentials or control characters, and a 2048-character limit; user codes use 4–32 plain alphanumeric or hyphen characters. Completed flow results are consumed once, terminal entries expire after -60 seconds, and the table holds at most 16 entries. Terminal entries retain no config snapshot. +60 seconds, and the table holds at most 16 entries. Capacity pruning evicts only terminal entries; +pending approval and live profile selection are preserved. Terminal entries retain no config snapshot. +Once the protected credential write accepts a commit, expiry preserves that flow until configuration +publication settles; concurrent status and cancellation cannot turn an accepted write into an expired flow. Explicit reauth rotates login identity, while token refresh preserves it. A first-account config-publication failure compensates the new slot through the existing receipt ownership check. +IAM starts also require an HTTPS `*.awsapps.com/start` portal and a commercial AWS SSO region. +`src/oauth/kiro-iam-identity-center.ts` validates them before registration. The hub sends the portal +as an OIDC argument, never fetches it, and uses the selected region's fixed AWS OIDC host. +After approval, bounded `ListAvailableProfiles` pagination queries the AWS CLI's US East and Frankfurt +service endpoints. A single profile is selected automatically; several profiles produce a live +`select-profile` state containing only ARN, name and region. A selection POST to `/api/oauth/login` +supplies the provider, flow ID and an ARN from that flow's list; it requires the same principal. +`GetUsageLimits` resolves this grant's user email for the selected profile before persistence. +The credential stores its SSO region, profile API region and client registration together. +Two users sharing an organization ARN append distinct slots without a duplicate-profile warning; +an already stored user/profile pair still receives the existing duplicate warning. Selection never +changes the active account. Cancellation, expiry and the active-flow cap also cover profile selection. +Staged grants remain private and are cleared on every terminal outcome. + Forced and add-account login spawn the local CLI, so `resolveKiroCliExecutable` in `src/oauth/kiro-credentials.ts` decides which file runs with credential-flow arguments. The canonical `kiro-cli` name is tried on `PATH` and then in the platform install locations. Only diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 0ff13d3cba4..f8aa6f55867 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -901,7 +901,7 @@ "kiro-account-quota.test.ts": "providers/kiro", "kiro-account-state-disk.test.ts": "providers/kiro", "kiro-account-state-imports.test.ts": "providers/kiro", "kiro-adapter.test.ts": "providers/kiro", "kiro-auth-context-continuation.test.ts": "providers/kiro", "kiro-builder-id-profile.test.ts": "providers/kiro", - "kiro-calibration.test.ts": "providers/kiro", "kiro-device-builder.test.ts": "providers/kiro", + "kiro-calibration.test.ts": "providers/kiro", "kiro-device-builder.test.ts": "providers/kiro", "kiro-device-iam.test.ts": "providers/kiro", "kiro-device-social.test.ts": "providers/kiro", "kiro-fallback-error-body.test.ts": "providers/kiro", "kiro-images.test.ts": "providers/kiro", "kiro-leased-responses.test.ts": "providers/kiro", "kiro-model-catalog.test.ts": "providers/kiro", "kiro-model-preference.test.ts": "providers/kiro", diff --git a/tests/helpers/kiro-device-fixture.ts b/tests/helpers/kiro-device-fixture.ts index f0fc594a6ea..7e1e4556d24 100644 --- a/tests/helpers/kiro-device-fixture.ts +++ b/tests/helpers/kiro-device-fixture.ts @@ -24,7 +24,7 @@ export function kiroDeviceFixture() { let current = 1_000_000; setKiroDeviceClockForTests(() => current); return { - setPost(post: (url: string, body: Record) => Promise) { setKiroDevicePostForTests(post); }, + setPost(post: (url: string, body: Record, headers?: Record) => Promise) { setKiroDevicePostForTests(post); }, advance(ms: number) { current += ms; }, async close() { clearKiroDeviceFlowsForTests(); diff --git a/tests/providers/kiro/kiro-device-iam.test.ts b/tests/providers/kiro/kiro-device-iam.test.ts new file mode 100644 index 00000000000..718d25afb52 --- /dev/null +++ b/tests/providers/kiro/kiro-device-iam.test.ts @@ -0,0 +1,206 @@ +import { afterEach, expect, test } from "bun:test"; +import { cancelKiroDeviceLogin, kiroDeviceFlowCountForTests, selectKiroDeviceProfile, setKiroDevicePublishForTests, startKiroDeviceLogin, statusKiroDeviceLogin } from "../../../src/oauth/kiro-device-login"; +import { listKiroIamProfiles, parseKiroIamOptions } from "../../../src/oauth/kiro-iam-identity-center"; +import { getAccountSet } from "../../../src/oauth/store"; +import { authorization, kiroDeviceFixture, profileArn, response } from "../../helpers/kiro-device-fixture"; +import { STORE_BUDGET_MS } from "../../helpers/test-budget"; + +let fixture: ReturnType | undefined; +afterEach(async () => { await fixture?.close(); fixture = undefined; }); +const options = { startUrl: "https://example.awsapps.com/start", region: "eu-west-1" }; +const profile = { arn: profileArn, profileName: "Team profile" }; +const second = { arn: "arn:aws:codewhisperer:eu-central-1:123456789012:profile/second", profileName: "European profile" }; + +function transport(email = "first@example.test", profiles = [profile]) { + fixture = kiroDeviceFixture(); + const calls: { url: string; body: Record; headers?: Record }[] = []; + fixture.setPost(async (url, body, headers) => { + calls.push({ url, body, headers }); + if (url.endsWith("/client/register")) return response({ clientId: "id", clientSecret: "private-client-secret" }); + if (url.endsWith("/device_authorization")) return response(authorization); + if (url.endsWith("/token")) return response({ accessToken: "private-access", refreshToken: `private-refresh-${email}`, expiresIn: 3600 }); + if (headers?.["x-amz-target"]?.endsWith("ListAvailableProfiles")) return response({ profiles: url.includes("us-east-1") ? profiles : [] }); + if (headers?.["x-amz-target"]?.endsWith("GetUsageLimits")) return response({ userInfo: { email, userId: "user-id" } }); + throw new Error("Unexpected upstream request"); + }); + return calls; +} + +async function approved() { + const started = await startKiroDeviceLogin("iam-identity-center", "gui-session", undefined, options); + fixture!.advance(5_000); + return { started, status: await statusKiroDeviceLogin(started.flowId, "gui-session") }; +} + +test("IAM validates portal and region before any network request", async () => { + expect(parseKiroIamOptions({ ...options, startUrl: options.startUrl+"/#/" })).toEqual(options); + const authenticatedPortal = new URL(options.startUrl); + authenticatedPortal.username = "fixture-user"; + for (const input of [undefined, {}, { ...options, region: "us-east-1.evil.test" }, + { ...options, startUrl: "http://example.awsapps.com/start" }, + { ...options, startUrl: "https://example.awsapps.com.evil.test/start" }, + { ...options, startUrl: "https://example.awsapps.com:443/start" }, + { ...options, startUrl: authenticatedPortal.href }, + { ...options, startUrl: options.startUrl+"?next=https://evil.test" }, + { ...options, startUrl: options.startUrl+"\n" }]) { + expect(parseKiroIamOptions(input)).toBeNull(); + await expect(startKiroDeviceLogin("iam-identity-center", "gui-session", undefined, input)).rejects.toThrow("Invalid Kiro IAM"); + } +}); + +test("SSO uses the chosen region and portal; user and profile come from this grant", async () => { + const calls = transport(); + const { started, status } = await approved(); + expect(status?.state).toBe("done"); + expect(calls[0]!.url).toBe("https://oidc.eu-west-1.amazonaws.com/client/register"); + expect(calls[1]!.body.startUrl).toBe(options.startUrl); + expect(calls[2]!.url).toBe("https://oidc.eu-west-1.amazonaws.com/token"); + const account = getAccountSet("kiro")!.accounts[0]!; + expect(account.credential.email).toBe("first@example.test"); + expect(account.credential.accountId).toBe(profileArn); + expect(account.credential.kiro).toMatchObject({ ssoRegion: "eu-west-1", apiRegion: "us-east-1", profileArn, clientSecret: "private-client-secret" }); + expect(account.loginOrigin).toBe("kiro-device"); + const discovery = calls.filter(call => call.headers?.authorization); + expect(discovery).toHaveLength(3); + expect(discovery.every(call => call.headers?.authorization === "Bearer private-access")).toBe(true); + expect(JSON.stringify([started, status])).not.toMatch(/private-access|private-refresh|private-client|userInfo|first@example/); +}); + +test("multiple profiles wait for an owner-scoped selection and reject arbitrary ARNs", async () => { + const calls = transport("second@example.test", [profile, second]); + const { started, status } = await approved(); + expect(status?.state).toBe("select-profile"); + expect(status?.profiles).toHaveLength(2); + expect(getAccountSet("kiro")).toBeNull(); + expect(await selectKiroDeviceProfile(started.flowId, "admin-token", second.arn)).toBeNull(); + expect(await selectKiroDeviceProfile(started.flowId, "gui-session", profileArn+"-forged")).toBeNull(); + expect((await statusKiroDeviceLogin(started.flowId, "gui-session"))?.state).toBe("select-profile"); + expect((await selectKiroDeviceProfile(started.flowId, "gui-session", second.arn))?.state).toBe("done"); + expect(getAccountSet("kiro")!.accounts[0]!.credential.kiro?.apiRegion).toBe("eu-central-1"); + const identity = calls.find(call => call.headers?.["x-amz-target"]?.endsWith("GetUsageLimits")); + expect(identity?.url).toStartWith("https://q.eu-central-1.amazonaws.com/"); + expect(identity?.body.profileArn).toBe(second.arn); +}); + +test("two users of one organization profile remain separate without a duplicate warning", async () => { + transport(); + await approved(); + const initial = getAccountSet("kiro")!; + const first = structuredClone(initial.accounts[0]!); + fixture!.setPost(async (url, body, headers) => { + if (url.endsWith("/client/register")) return response({ clientId: "second-id", clientSecret: "second-secret" }); + if (url.endsWith("/device_authorization")) return response(authorization); + if (url.endsWith("/token")) return response({ accessToken: "second-access", refreshToken: "second-refresh", expiresIn: 3600 }); + if (headers?.["x-amz-target"]?.endsWith("ListAvailableProfiles")) return response({ profiles: url.includes("us-east-1") ? [profile] : [] }); + return response({ userInfo: { email: "second@example.test" } }); + }); + const { status } = await approved(); + expect(status?.state).toBe("done"); + expect(status?.warning).toBeUndefined(); + const result = getAccountSet("kiro")!; + expect(result.accounts).toHaveLength(2); + expect(result.accounts[0]).toEqual(first); + expect(result.activeAccountId).toBe(initial.activeAccountId); + expect(result.accounts[1]!.credential.email).toBe("second@example.test"); +}); + +test("flow-table pressure preserves live IAM profile selection and the active-flow cap", async () => { + transport("first@example.test", [profile, second]); + const choosing = await approved(); + fixture!.setPost(async (url, _body, headers) => { + if (url.endsWith("/client/register")) return response({ clientId: "id", clientSecret: "private-client-secret" }); + if (url.endsWith("/device_authorization")) return response({ ...authorization, interval: 1 }); + if (url.endsWith("/token")) return response({ accessToken: "private-access", refreshToken: "private-refresh", expiresIn: 3600 }); + if (headers?.["x-amz-target"]?.endsWith("ListAvailableProfiles")) return response({ profiles: url.includes("us-east-1") ? [profile, second] : [] }); + if (headers?.["x-amz-target"]?.endsWith("GetUsageLimits")) return response({ userInfo: { email: "first@example.test" } }); + throw new Error("Unexpected upstream request"); + }); + for (let i = 0; i < 14; i++) { + const finished = await startKiroDeviceLogin("iam-identity-center", "gui-session", undefined, options); + fixture!.advance(1_000); + expect((await statusKiroDeviceLogin(finished.flowId, "gui-session"))?.state).toBe("select-profile"); + expect((await selectKiroDeviceProfile(finished.flowId, "gui-session", profileArn))?.state).toBe("done"); + } + const pending = []; + for (let i = 0; i < 3; i++) pending.push(await startKiroDeviceLogin("builder-id", "gui-session")); + expect(kiroDeviceFlowCountForTests()).toBeLessThanOrEqual(16); + expect((await statusKiroDeviceLogin(choosing.started.flowId, "gui-session"))?.state).toBe("select-profile"); + await expect(startKiroDeviceLogin("builder-id", "gui-session")).rejects.toThrow("Too many Kiro device logins"); + for (const flow of pending) expect((await statusKiroDeviceLogin(flow.flowId, "gui-session"))?.state).toBe("pending"); + expect((await selectKiroDeviceProfile(choosing.started.flowId, "gui-session", second.arn))?.state).toBe("done"); +}, STORE_BUDGET_MS); + +test("cancel and expiry while choosing a profile retain no credential", async () => { + transport("first@example.test", [profile, second]); + const first = await approved(); + expect(cancelKiroDeviceLogin(first.started.flowId, "gui-session")?.state).toBe("cancelled"); + expect(await selectKiroDeviceProfile(first.started.flowId, "gui-session", profileArn)).toBeNull(); + const another = await approved(); + fixture!.advance(600_000); + expect(await selectKiroDeviceProfile(another.started.flowId, "gui-session", profileArn)).toBeNull(); + expect(getAccountSet("kiro")).toBeNull(); +}); + +test.each(["status", "cancel"])("accepted IAM commits survive expiry during concurrent %s", async operation => { + transport("first@example.test", [profile, second]); + const { started } = await approved(); + let release!: () => void; + let entered!: () => void; + const publishing = new Promise(resolve => { release = resolve; }); + const called = new Promise(resolve => { entered = resolve; }); + setKiroDevicePublishForTests(async () => { entered(); await publishing; }); + const selecting = selectKiroDeviceProfile(started.flowId, "gui-session", second.arn); + await called; + fixture!.advance(600_000); + let concurrent; + try { + concurrent = operation === "status" + ? await statusKiroDeviceLogin(started.flowId, "gui-session") + : cancelKiroDeviceLogin(started.flowId, "gui-session"); + } finally { release(); } + expect((await selecting)?.state).toBe("done"); + expect(concurrent?.state).toBe(operation === "status" ? "select-profile" : "done"); + expect((await statusKiroDeviceLogin(started.flowId, "gui-session"))?.state).toBe("done"); + expect(getAccountSet("kiro")?.accounts).toHaveLength(1); +}); + +test("missing user identity and failed discovery never save an ambiguous organization account", async () => { + transport(""); + expect((await approved()).status?.state).toBe("failed"); + expect(getAccountSet("kiro")).toBeNull(); +}); + +test("profile pagination rejects cycles instead of presenting a partial list", async () => { + let calls = 0; + await expect(listKiroIamProfiles(async () => { + calls++; return { status: 200, errorType: "", data: { profiles: [profile], nextToken: "repeated" } }; + }, "private-access")).rejects.toThrow("Incomplete"); + expect(calls).toBeLessThanOrEqual(4); +}); + +test("a region without permission is skipped, but transient discovery failure is not hidden", async () => { + const discovered = await listKiroIamProfiles(async url => ({ + status: url.includes("eu-central-1") ? 403 : 200, errorType: "", + data: { profiles: [profile] }, + }), "private-access"); + expect(discovered.map(profile => profile.arn)).toEqual([profileArn]); + await expect(listKiroIamProfiles(async url => ({ + status: url.includes("eu-central-1") ? 503 : 200, errorType: "", data: { profiles: [profile] }, + }), "private-access")).rejects.toThrow("discovery failed"); +}); + +test("cancelling during the selected profile's identity lookup cannot commit its grant", async () => { + transport("first@example.test", [profile, second]); + const { started } = await approved(); + let release!: (response: Response) => void; + let entered!: () => void; + const pending = new Promise(resolve => { release = resolve; }); + const called = new Promise(resolve => { entered = resolve; }); + fixture!.setPost(async () => { entered(); return pending; }); + const selecting = selectKiroDeviceProfile(started.flowId, "gui-session", second.arn); + await called; + expect(cancelKiroDeviceLogin(started.flowId, "gui-session")?.state).toBe("cancelled"); + release(response({ userInfo: { email: "first@example.test" } })); + expect((await selecting)?.state).toBe("cancelled"); + expect(getAccountSet("kiro")).toBeNull(); +}); diff --git a/tests/providers/kiro/kiro-device-social.test.ts b/tests/providers/kiro/kiro-device-social.test.ts index b035ae92fb0..7dfc5d905da 100644 --- a/tests/providers/kiro/kiro-device-social.test.ts +++ b/tests/providers/kiro/kiro-device-social.test.ts @@ -3,6 +3,7 @@ import { kiroDeviceConfigBaseline, kiroDeviceFlowCountForTests, startKiroDeviceL import { getAccountSet } from "../../../src/oauth/store"; import { kiroDeviceFixture, profileArn, response, socialAuthorization } from "../../helpers/kiro-device-fixture"; import type { OcxConfig } from "../../../src/types"; +import { STORE_BUDGET_MS } from "../../helpers/test-budget"; let fixture: ReturnType | undefined; afterEach(async () => { await fixture?.close(); fixture = undefined; }); @@ -81,5 +82,5 @@ describe("Kiro social device grant", () => { expect(await statusKiroDeviceLogin(started.flowId, "admin-token")).toBeNull(); expect(kiroDeviceFlowCountForTests()).toBeLessThanOrEqual(16); } - }); + }, STORE_BUDGET_MS); }); diff --git a/tests/server/server-kiro-device-login.test.ts b/tests/server/server-kiro-device-login.test.ts index 60f9bd2cff3..cc54f5a39f4 100644 --- a/tests/server/server-kiro-device-login.test.ts +++ b/tests/server/server-kiro-device-login.test.ts @@ -22,6 +22,36 @@ async function api(config: OcxConfig, path: string, body?: Record { + test("IAM starts validate settings and profile selection stays bound to its owner", async () => { + fixture = kiroDeviceFixture(); + const cfg = config(); saveConfig(cfg); + let calls = 0; + fixture.setPost(async (url, body, headers) => { + calls++; + if (url.endsWith("/client/register")) return response({ clientId: "id", clientSecret: "private-secret" }); + if (url.endsWith("/device_authorization")) return response({ ...socialAuthorization, verificationUri: "https://device.sso.us-east-1.amazonaws.com/" }); + if (url.endsWith("/token")) return response({ accessToken: "private-access", refreshToken: "private-refresh", expiresIn: 3600 }); + if (headers?.["x-amz-target"]?.endsWith("ListAvailableProfiles")) return response({ profiles: url.includes("us-east-1") + ? [{ arn: profileArn, profileName: "First" }, { arn: profileArn+"-second", profileName: "Second" }] : [] }); + return response({ userInfo: { email: "user@example.test" } }); + }); + const input = { provider: "kiro", method: "iam-identity-center", startUrl: "https://example.awsapps.com/start", region: "us-east-1" }; + expect((await api(cfg, "/api/oauth/login", { ...input, region: "bad.region" })).status).toBe(400); + expect(calls).toBe(0); + const start = await api(cfg, "/api/oauth/login", input, "gui-session"); + expect(start.status).toBe(200); + fixture.advance(5_000); + const state = await api(cfg, `/api/oauth/status?provider=kiro&flowId=${start.data.flowId}`, undefined, "gui-session"); + expect(state.data.state).toBe("select-profile"); + const selection = { provider: "kiro", flowId: start.data.flowId, profileArn }; + expect((await api(cfg, "/api/oauth/login", selection, "admin-token")).status).toBe(404); + expect((await api(cfg, "/api/oauth/login", { ...selection, profileArn: profileArn+"-forged" }, "gui-session")).status).toBe(404); + const done = await api(cfg, "/api/oauth/login", selection, "gui-session"); + expect(done.data.state).toBe("done"); + expect(cfg.providers.kiro).toBeDefined(); + expect(JSON.stringify([start.data, state.data, done.data])).not.toMatch(/private-access|private-refresh|private-secret|user@example/); + }); + test("native only with a valid method; reauth and invalid methods are rejected", async () => { fixture = kiroDeviceFixture(); const cfg = config(); saveConfig(cfg); From e42c3749518271f7b0f5c06aeb75ff5fa38a91f0 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 29 Oct 2026 13:22:51 +0900 Subject: [PATCH 18/84] fix(test-layout): keep layout.json under the file-size threshold (#7943) #7891 brought scripts/test-layout/layout.json to 2000 lines on dev, which the ratchet rejects as NEW_OVERSIZED. Move its registration onto the preceding windows line; the mapping is unchanged. Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> --- scripts/test-layout/layout.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index f7319a77ebe..8d82b182e72 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1926,8 +1926,7 @@ "web-search.test.ts": "web-search", "win-exec.test.ts": "windows", "win-paths.test.ts": "windows", - "windows-acl-start-cost.test.ts": "windows", - "windows-atomic-write-acl-rearm.test.ts": "windows", + "windows-acl-start-cost.test.ts": "windows", "windows-atomic-write-acl-rearm.test.ts": "windows", "windows-atomic-replace.test.ts": "windows", "windows-deploy-close-regressions.test.ts": "windows", "windows-elevation-spawn.test.ts": "windows", From ad3e3d187182ef9b3cbd8f9ed6091de624ffa7a5 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 29 Oct 2026 13:23:21 +0900 Subject: [PATCH 19/84] fix(providers): default Moonshot API to K3.1 Pro and retire K2.5 (#7880) * fix(providers): move Moonshot API default and retired K2.5 to K3.1 Pro Follow the vendor's current successor recommendation, retain the still-served K2.6/K2.7 API seeds, and preserve coding-plan and reseller catalogs. Refresh only exact legacy capability seeds and keep the pre-K3.1 roster repair working after the retired-id migration. Cover saved-config persistence, custom destination/metadata preservation, registry parity and catalog output. Refs #7653 * fix(providers): keep explicit K3.1 Pro values when migrating Moonshot K2.5 The K2.5 rename refreshed any successor value equal to the legacy seed, including values a user had saved under kimi-k3.1-pro before the migration. The registry never seeded K3.1 Pro with those values, so the Moonshot rename now refreshes only metadata carried over from K2.5 (targetSeedRefresh.onlyTransferred). The Kimi coding renames keep their existing behavior. * fix(providers): keep an explicit K3.1 Pro no-reasoning classification in the Moonshot migration The no-reasoning cleanup still dropped kimi-k3.1-pro when its saved efforts matched the legacy seed, even with no K2.5 left in the config. With onlyTransferred it now removes only the retired id. --------- Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> --- .../src/content/docs/fr/guides/providers.md | 2 +- .../src/content/docs/guides/providers.md | 13 +++- .../src/content/docs/ja/guides/providers.md | 2 +- .../src/content/docs/ko/guides/providers.md | 2 +- .../src/content/docs/ru/guides/providers.md | 2 +- .../src/content/docs/tr/guides/providers.md | 2 +- .../content/docs/zh-cn/guides/providers.md | 2 +- .../content/docs/zh-tw/guides/providers.md | 2 +- src/providers/model-rename-migration.ts | 47 +++++++++-- src/providers/registry/entries-extended.ts | 2 +- src/providers/registry/model-seeds.ts | 3 +- src/providers/stale-model-roster-migration.ts | 6 +- structure/providers-and-adapters.md | 2 +- .../providers/model-rename-migration.test.ts | 78 +++++++++++++++++++ .../provider-registry-parity.test.ts | 13 ++-- 15 files changed, 152 insertions(+), 26 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/providers.md b/docs-site/src/content/docs/fr/guides/providers.md index e75336c090f..8d8a85f0a8d 100644 --- a/docs-site/src/content/docs/fr/guides/providers.md +++ b/docs-site/src/content/docs/fr/guides/providers.md @@ -360,7 +360,7 @@ promotionnels de Cline ne sont accessibles que dans l'IDE ou la CLI Cline, pas p | Novita AI | `https://api.novita.ai/openai/v1` | | Together | `https://api.together.xyz/v1` | | Fireworks | `https://api.fireworks.ai/inference/v1` | -| Moonshot (Kimi API) · Kimi (coding) | `https://api.moonshot.ai/v1` · `https://api.kimi.com/coding/v1` | +| Moonshot (Kimi API) · Kimi (coding) | `https://api.moonshot.ai/v1` · `https://api.kimi.com/coding/v1`. L’API `moonshot` utilise `kimi-k3.1-pro` par défaut ; les sélections `kimi-k2.5` enregistrées pour les adresses API officielles migrent vers ce modèle. | | Hugging Face | `https://router.huggingface.co/v1` | | NVIDIA NIM | `https://integrate.api.nvidia.com/v1` | | Z.AI (GLM Coding) | `https://api.z.ai/api/coding/paas/v4` | diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 9b010a5584b..4959c8a019b 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -440,10 +440,15 @@ think: the picker offers `low` / `high` / `max` (default `max`), Codex `medium` and `xhigh` as `max`, and an explicit `none` leaves `reasoning_effort` out so the upstream default applies. Fixed sampling parameters (`temperature`, `top_p`, penalties) are not sent. A saved `moonshot` model list that is still the previous built-in list gains both ids at startup when it -points at the International or China endpoint; an edited list or a Custom URL is left as it is. -The preset default stays `kimi-k2.7-code`. Moonshot closes the K2 series to new accounts on -2026-10-31 and plans to retire it by the end of December 2026; see the -[Kimi API model list](https://platform.kimi.ai/docs/models). +points at the International or China endpoint; this roster refresh adds no models to an edited +list or a Custom URL. The retired-id migration below applies separately. +The preset defaults to the vendor-recommended `kimi-k3.1-pro`. `kimi-k2.5`, discontinued on +2026-08-31, is removed from the API seeds; saved selections on the official International or China +endpoint migrate to `kimi-k3.1-pro`, refreshing old built-in context and reasoning metadata while +preserving explicit overrides. Custom destinations keep their saved selections. `kimi-k2.6` and +`kimi-k2.7-code` (including highspeed) remain selectable for existing users: Moonshot closes the +K2 series to new accounts on 2026-10-31 and plans to retire it by the end of December 2026; see the +[Kimi API model list](https://platform.kimi.ai/docs/models.md) (checked 2026-10-28). A custom `openai-chat` provider can opt in when its upstream documents support for `prompt_cache_key`: diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index 0d4f2d41c39..e94d4842f7c 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -258,7 +258,7 @@ Cline IDE/CLI のみで API からは使えません。`minimax/minimax-m2.5` | Novita AI | `https://api.novita.ai/openai/v1` | | Together | `https://api.together.xyz/v1` | | Fireworks | `https://api.fireworks.ai/inference/v1` | -| Moonshot (Kimi API) · Kimi (coding) | `https://api.moonshot.ai/v1` · `https://api.kimi.com/coding/v1` | +| Moonshot (Kimi API) · Kimi (coding) | `https://api.moonshot.ai/v1` · `https://api.kimi.com/coding/v1`。`moonshot` API の既定モデルは `kimi-k3.1-pro` です。公式 API の保存済み `kimi-k2.5` 選択はこのモデルへ移行します。 | | Hugging Face | `https://router.huggingface.co/v1` | | NVIDIA NIM | `https://integrate.api.nvidia.com/v1` | | Z.AI (GLM Coding) | `https://api.z.ai/api/coding/paas/v4` | diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 3562976e849..aa3b3f3aff1 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -262,7 +262,7 @@ Cline IDE/CLI에서만 제공되며 API로는 사용할 수 없습니다. `minim | Novita AI | `https://api.novita.ai/openai/v1` | | Together | `https://api.together.xyz/v1` | | Fireworks | `https://api.fireworks.ai/inference/v1` | -| Moonshot (Kimi API) · Kimi (coding) | `https://api.moonshot.ai/v1` · `https://api.kimi.com/coding/v1` | +| Moonshot (Kimi API) · Kimi (coding) | `https://api.moonshot.ai/v1` · `https://api.kimi.com/coding/v1`. `moonshot` API 기본 모델은 `kimi-k3.1-pro`입니다. 공식 API 주소에 저장된 `kimi-k2.5` 선택은 이 모델로 마이그레이션됩니다. | | Hugging Face | `https://router.huggingface.co/v1` | | NVIDIA NIM | `https://integrate.api.nvidia.com/v1` | | Z.AI (GLM Coding) | `https://api.z.ai/api/coding/paas/v4` | diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index 03e1cc7b62b..526b10bd03e 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -272,7 +272,7 @@ opencodex поставляется с 106 встроенными пресета | Novita AI | `https://api.novita.ai/openai/v1` | | Together | `https://api.together.xyz/v1` | | Fireworks | `https://api.fireworks.ai/inference/v1` | -| Moonshot (Kimi API) · Kimi (coding) | `https://api.moonshot.ai/v1` · `https://api.kimi.com/coding/v1` | +| Moonshot (Kimi API) · Kimi (coding) | `https://api.moonshot.ai/v1` · `https://api.kimi.com/coding/v1`. Модель API `moonshot` по умолчанию — `kimi-k3.1-pro`; сохранённые варианты `kimi-k2.5` для официальных адресов API переносятся на неё. | | Hugging Face | `https://router.huggingface.co/v1` | | NVIDIA NIM | `https://integrate.api.nvidia.com/v1` | | Z.AI (GLM Coding) | `https://api.z.ai/api/coding/paas/v4` | diff --git a/docs-site/src/content/docs/tr/guides/providers.md b/docs-site/src/content/docs/tr/guides/providers.md index 09ea15cc2b1..c25565978bf 100644 --- a/docs-site/src/content/docs/tr/guides/providers.md +++ b/docs-site/src/content/docs/tr/guides/providers.md @@ -398,7 +398,7 @@ yalnızca Cline IDE/CLI içinde mevcuttur; `minimax/minimax-m2.5` belgelenmiş A | Novita AI | `https://api.novita.ai/openai/v1` | | Together | `https://api.together.xyz/v1` | | Fireworks | `https://api.fireworks.ai/inference/v1` | -| Moonshot (Kimi API) · Kimi (kodlama) | `https://api.moonshot.ai/v1` · `https://api.kimi.com/coding/v1` | +| Moonshot (Kimi API) · Kimi (kodlama) | `https://api.moonshot.ai/v1` · `https://api.kimi.com/coding/v1`. `moonshot` API varsayılanı `kimi-k3.1-pro` modelidir; resmî API adresleri için kaydedilmiş `kimi-k2.5` seçimleri bu modele taşınır. | | Hugging Face | `https://router.huggingface.co/v1` | | NVIDIA NIM | `https://integrate.api.nvidia.com/v1` | | Z.AI (GLM Kodlama) | `https://api.z.ai/api/coding/paas/v4` | diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index 6396d2bff10..4cbf796c7fb 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -269,7 +269,7 @@ Cline IDE/CLI 中提供,不能通过 API 使用;`minimax/minimax-m2.5` 是 | Novita AI | `https://api.novita.ai/openai/v1` | | Together | `https://api.together.xyz/v1` | | Fireworks | `https://api.fireworks.ai/inference/v1` | -| Moonshot (Kimi API) · Kimi (coding) | `https://api.moonshot.ai/v1` · `https://api.kimi.com/coding/v1` | +| Moonshot (Kimi API) · Kimi (coding) | `https://api.moonshot.ai/v1` · `https://api.kimi.com/coding/v1`。`moonshot` API 默认使用 `kimi-k3.1-pro`;官方 API 地址中已保存的 `kimi-k2.5` 选择会迁移到该模型。 | | Hugging Face | `https://router.huggingface.co/v1` | | NVIDIA NIM | `https://integrate.api.nvidia.com/v1` | | Z.AI (GLM Coding) | `https://api.z.ai/api/coding/paas/v4` | diff --git a/docs-site/src/content/docs/zh-tw/guides/providers.md b/docs-site/src/content/docs/zh-tw/guides/providers.md index f447248d9d0..5f8d0e32108 100644 --- a/docs-site/src/content/docs/zh-tw/guides/providers.md +++ b/docs-site/src/content/docs/zh-tw/guides/providers.md @@ -312,7 +312,7 @@ IDE/CLI,不透過 API;`minimax/minimax-m2.5` 是文件列出的 API 免費 | Novita AI | `https://api.novita.ai/openai/v1` | | Together | `https://api.together.xyz/v1` | | Fireworks | `https://api.fireworks.ai/inference/v1` | -| Moonshot (Kimi API) · Kimi (coding) | `https://api.moonshot.ai/v1` · `https://api.kimi.com/coding/v1` | +| Moonshot (Kimi API) · Kimi (coding) | `https://api.moonshot.ai/v1` · `https://api.kimi.com/coding/v1`。`moonshot` API 預設使用 `kimi-k3.1-pro`;官方 API 位址中已儲存的 `kimi-k2.5` 選擇會遷移到該模型。 | | Hugging Face | `https://router.huggingface.co/v1` | | NVIDIA NIM | `https://integrate.api.nvidia.com/v1` | | Z.AI (GLM Coding) | `https://api.z.ai/api/coding/paas/v4` | diff --git a/src/providers/model-rename-migration.ts b/src/providers/model-rename-migration.ts index 3b677a6d17d..ea8516fe0d2 100644 --- a/src/providers/model-rename-migration.ts +++ b/src/providers/model-rename-migration.ts @@ -19,7 +19,7 @@ // Everything else in the row is left alone. import { PROVIDER_REGISTRY, normalizedProviderEndpoint } from "./registry"; -import { MINIMAX_M31_FLASH, MINIMAX_M31_FLASH_PREVIEW_RETIRED } from "./registry/model-seeds"; +import { KIMI_API_K31_REASONING_EFFORT_MAP, MINIMAX_M31_FLASH, MINIMAX_M31_FLASH_PREVIEW_RETIRED } from "./registry/model-seeds"; import { MODEL_KEYED_RECORDS, MODEL_ID_LISTS, MODEL_ID_SCALARS, MODEL_NESTED_RECORDS } from "./model-rename-fields"; import { providerConfigSeed } from "./derive"; import type { OcxConfig, OcxProviderConfig } from "../types"; @@ -57,6 +57,12 @@ export interface ModelRename { dropNoReasoningModels?: boolean; /** Refresh exact registry defaults already saved under the replacement id. */ targetSeedRefresh?: { + /** + * Refresh only values carried over from the retired id. Set this when the registry never + * seeded the replacement id with the stale values, so anything already saved under it is a + * user override even when it happens to equal the old seed. + */ + onlyTransferred?: boolean; contextWindow?: { from: number; to: number }; reasoning?: { fromEfforts: readonly string[]; @@ -121,6 +127,24 @@ export const MODEL_RENAMES: readonly ModelRename[] = [ // rather than a merely outdated one. ...kimiCodingRenames("kimi", "subscription endpoint after K2.8 Preview shipped"), ...kimiCodingRenames("kimi-code", "coding endpoint after K2.8 Preview shipped"), + { + provider: "moonshot", + from: "kimi-k2.5", + to: "kimi-k3.1-pro", + // Verified 2026-10-28: https://platform.kimi.ai/docs/models.md + reason: "Moonshot discontinued K2.5 on 2026-08-31 and recommends K3.1 Pro for API users", + dropNoReasoningModels: true, + targetSeedRefresh: { + onlyTransferred: true, + contextWindow: { from: 262_144, to: 1_048_576 }, + reasoning: { + fromEfforts: [], + toEfforts: ["low", "high", "max"], + defaultEffort: "max", + effortMap: KIMI_API_K31_REASONING_EFFORT_MAP, + }, + }, + }, // Antigravity Flash generations. Google takes the previous Flash model off Cloud Code // Assist almost immediately when the next ships, so a saved 3.6 (or older 3.5) id is a // dead selection rather than a merely outdated one. Routing already redirects these ids @@ -171,11 +195,12 @@ function dropRenamedIdsFromList( from: string, to: string, dropStaleTarget: boolean, + keepTarget = false, ): string[] | null { if (!Array.isArray(value)) return null; const hasRetired = value.includes(from); - if (!hasRetired && !(dropStaleTarget && value.includes(to))) return null; - return value.filter(entry => typeof entry === "string" && entry !== from && entry !== to); + if (!hasRetired && !(dropStaleTarget && !keepTarget && value.includes(to))) return null; + return value.filter(entry => typeof entry === "string" && entry !== from && (keepTarget || entry !== to)); } function renameInRecord(value: unknown, from: string, to: string): Record | null { @@ -218,18 +243,22 @@ function targetReasoningMatchesStaleSeed(row: Record, rename: M } /** Refresh only exact defaults emitted by the previous registry; preserve user overrides. */ -function refreshTargetSeed(row: Record, rename: ModelRename): boolean { +function refreshTargetSeed( + row: Record, rename: ModelRename, savedBefore: { window: boolean; efforts: boolean }, +): boolean { const refresh = rename.targetSeedRefresh; if (!refresh) return false; let changed = false; const windows = row.modelContextWindows as Record | undefined; - if (windows && refresh.contextWindow && windows[rename.to] === refresh.contextWindow.from) { + if (windows && refresh.contextWindow && windows[rename.to] === refresh.contextWindow.from + && !(refresh.onlyTransferred && savedBefore.window)) { windows[rename.to] = refresh.contextWindow.to; changed = true; } const reasoning = refresh.reasoning; + if (refresh.onlyTransferred && savedBefore.efforts) return changed; const efforts = row.modelReasoningEfforts as Record | undefined; if (!reasoning || !efforts || !sameStringArray(efforts[rename.to], reasoning.fromEfforts)) return changed; efforts[rename.to] = [...reasoning.toEfforts]; @@ -359,6 +388,10 @@ export function projectModelRenames( // view rather than casting at each assignment. const row = prov as unknown as Record; const seed = registrySeed(rename.provider); + const savedBefore = { + window: publishes(row.modelContextWindows, rename.to), + efforts: publishes(row.modelReasoningEfforts, rename.to), + }; let touched = false; for (const field of MODEL_ID_LISTS) { if (isRegistryResidue(seed, field, row[field], rename)) continue; @@ -368,6 +401,8 @@ export function projectModelRenames( rename.from, rename.to, targetReasoningMatchesStaleSeed(row, rename), + // A successor classification saved before the migration is the user's, not registry residue. + rename.targetSeedRefresh?.onlyTransferred === true, ) : renameInList(row[field], rename.from, rename.to); if (!next) continue; @@ -398,7 +433,7 @@ export function projectModelRenames( touched = true; } if (renameDisabledModels(config, rename)) touched = true; - if (touched && refreshTargetSeed(row, rename)) touched = true; + if (touched && refreshTargetSeed(row, rename, savedBefore)) touched = true; if (touched) { changed = true; diff --git a/src/providers/registry/entries-extended.ts b/src/providers/registry/entries-extended.ts index f4816921a72..ec33c812c31 100644 --- a/src/providers/registry/entries-extended.ts +++ b/src/providers/registry/entries-extended.ts @@ -479,7 +479,7 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ id: "moonshot", label: "Moonshot (Kimi API)", baseUrl: MOONSHOT_INTL_BASE_URL, adapter: "openai-chat", authKind: "key", allowBaseUrlOverride: true, baseUrlChoices: MOONSHOT_BASE_URL_CHOICES, - dashboardUrl: "https://platform.moonshot.ai/console/api-keys", defaultModel: "kimi-k2.7-code", jawcodeBundle: "moonshot", + dashboardUrl: "https://platform.moonshot.ai/console/api-keys", defaultModel: "kimi-k3.1-pro", jawcodeBundle: "moonshot", models: KIMI_API_MODELS, modelContextWindows: KIMI_API_MODEL_CONTEXT_WINDOWS, modelInputModalities: KIMI_API_MODEL_INPUT_MODALITIES, diff --git a/src/providers/registry/model-seeds.ts b/src/providers/registry/model-seeds.ts index b60594f8226..023d46aca75 100644 --- a/src/providers/registry/model-seeds.ts +++ b/src/providers/registry/model-seeds.ts @@ -770,7 +770,8 @@ export const KIMI_CODING_K31_MODELS = ["k3.1-pro", "k3.1-pro-highspeed"]; export const KIMI_CODING_K28_MODELS = ["kimi-for-coding"]; export const KIMI_CODING_LIVE_MODELS = [...KIMI_CODING_K3_MODELS, ...KIMI_CODING_K31_MODELS, ...KIMI_CODING_K28_MODELS]; export const KIMI_CODING_ADJUSTABLE_THINKING_MODELS = [...KIMI_CODING_LIVE_MODELS]; -export const KIMI_LEGACY_API_MODELS = ["kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6", "kimi-k2.5"]; +// 2026-10-28: models.md recommends K3.1 Pro; K2.5 ended 2026-08-31. Other K2 ids retire in December. +export const KIMI_LEGACY_API_MODELS = ["kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6"]; // 261025 Kimi K3.1 Pro on the Moonshot API (#7653): the platform model list names kimi-k3.1-pro // and kimi-k3.1-pro-highspeed (same model, 2-3x speed) on both the International and China // platforms. Every K3-series model has a 1,048,576-token window, and both ids share kimi-k3's diff --git a/src/providers/stale-model-roster-migration.ts b/src/providers/stale-model-roster-migration.ts index 1cfc9cf482d..6f5e446f3af 100644 --- a/src/providers/stale-model-roster-migration.ts +++ b/src/providers/stale-model-roster-migration.ts @@ -45,6 +45,8 @@ export interface StaleModelRoster { provider: string; /** The exact saved roster this migration may replace, and nothing else. */ from: readonly string[]; + /** Exact old roster after id renames; metadata additions still compare against `from`. */ + afterModelRenames?: readonly string[]; to: readonly string[]; /** Per-model seed values for the added ids, written only into containers that exist. */ contextWindows?: Readonly>; @@ -131,6 +133,7 @@ export const STALE_MODEL_ROSTERS: readonly StaleModelRoster[] = [ { provider: "moonshot", from: KIMI_API_MODELS_BEFORE_K31, + afterModelRenames: KIMI_API_MODELS_BEFORE_K31.map(id => id === "kimi-k2.5" ? "kimi-k3.1-pro" : id), to: KIMI_API_MODELS, contextWindows: KIMI_API_MODEL_CONTEXT_WINDOWS, defaultReasoningEfforts: KIMI_API_DEFAULT_REASONING_EFFORTS, @@ -193,7 +196,8 @@ export function projectStaleModelRosters( } continue; } - if (!sameList(prov.models, entry.from)) continue; + if (!sameList(prov.models, entry.from) + && !(entry.afterModelRenames && sameList(prov.models, entry.afterModelRenames))) continue; prov.models = [...entry.to]; fillExisting(prov.modelContextWindows, entry.contextWindows, added); fillExisting(prov.modelMaxOutputTokens, entry.maxOutputTokens, added); diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 0d0488f0bb0..987565dcafb 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -91,7 +91,7 @@ Kimi Coding's Chat, API-key, and optional Responses presets consume the same mod `src/providers/registry/model-seeds.ts`, including native `k3-256k` and thinking-only 1M `k3.1-pro[-highspeed]`. The Responses preset shares the `kimi` OAuth account and Coding endpoint, keeps Chat as the featured default, and enables adjacent tool-result repair on its Responses wire. Its metadata alias is generated from -the registry; sharing authentication does not implicitly share a usage-price namespace. +the registry; sharing authentication does not implicitly share a usage-price namespace. The separate `moonshot` API preset defaults to `kimi-k3.1-pro`; `src/providers/model-rename-migration.ts` moves discontinued K2.5 selections on official destinations to it, drops stale no-reasoning classification, and refreshes exact old 256K/empty reasoning seeds to 1M and `low`/`high`/`max` with the API `none` omission map. Explicit successor metadata and Custom destinations keep precedence. Roster repair accepts the literal pre-K3.1 seed and its post-rename form, with metadata additions measured against the original seed. K2.6 and K2.7 Code/highspeed stay seeded for existing API users. OrcaRouter key exchange uses the shared raw-byte reader before returning a durable key. Its 64 KiB response ceiling, single 30-second header/body deadline, and cancellation behavior follow diff --git a/tests/providers/model-rename-migration.test.ts b/tests/providers/model-rename-migration.test.ts index bf63bdd037d..4a9f7d57003 100644 --- a/tests/providers/model-rename-migration.test.ts +++ b/tests/providers/model-rename-migration.test.ts @@ -172,6 +172,66 @@ describe("registry model rename migration (#1610)", () => { } }); + test.each(["https://api.moonshot.ai/v1", "https://api.moonshot.cn/v1/"])("migrates Moonshot K2.5 with API metadata on %s", baseUrl => { + const stale = { defaultProvider: "moonshot", providers: { moonshot: { + adapter: "openai-chat", baseUrl, defaultModel: "kimi-k2.5", + models: ["kimi-k2.5", "kimi-k2.6", "kimi-k2.7-code", "kimi-k2.7-code-highspeed"], + modelContextWindows: { "kimi-k2.5": 262_144 }, + noReasoningModels: ["kimi-k2.5", "kimi-k2.6"], + modelReasoningEfforts: { "kimi-k2.5": [] }, + noTemperatureModels: ["kimi-k2.5"], preserveReasoningContentModels: ["kimi-k2.5"], + } } } as unknown as OcxConfig; + const first = projectModelRenames(stale); + const row = first.config.providers.moonshot!; + expect(first.changed).toBe(true); + expect(row.defaultModel).toBe("kimi-k3.1-pro"); + expect(row.models).toEqual(["kimi-k3.1-pro", "kimi-k2.6", "kimi-k2.7-code", "kimi-k2.7-code-highspeed"]); + expect(row.modelContextWindows).toEqual({ "kimi-k3.1-pro": 1_048_576 }); + expect(row.noReasoningModels).toEqual(["kimi-k2.6"]); + expect(row.modelReasoningEfforts).toEqual({ "kimi-k3.1-pro": ["low", "high", "max"] }); + expect(row.modelDefaultReasoningEfforts).toEqual({ "kimi-k3.1-pro": "max" }); + expect(row.modelReasoningEffortMap?.["kimi-k3.1-pro"]).toEqual({ none: "__omit__", low: "low", medium: "high", high: "high", xhigh: "max", max: "max" }); + expect(row.noTemperatureModels).toEqual(["kimi-k3.1-pro"]); + expect(row.preserveReasoningContentModels).toEqual(["kimi-k3.1-pro"]); + expect(projectModelRenames(structuredClone(first.config)).changed).toBe(false); + }); + + test("Moonshot K2.5 migration preserves explicit successor metadata and Custom destinations", () => { + const stale = { providers: { moonshot: { + adapter: "openai-chat", baseUrl: "https://api.moonshot.ai/v1", defaultModel: "kimi-k2.5", + models: ["kimi-k2.5", "kimi-k3.1-pro"], + modelContextWindows: { "kimi-k2.5": 262_144, "kimi-k3.1-pro": 131_072 }, + modelReasoningEfforts: { "kimi-k2.5": [], "kimi-k3.1-pro": ["low"] }, + modelDefaultReasoningEfforts: { "kimi-k3.1-pro": "low" }, + modelReasoningEffortMap: { "kimi-k3.1-pro": { medium: "low" } }, + } } } as unknown as OcxConfig; + const custom = structuredClone(stale); + custom.providers.moonshot!.baseUrl = "https://gateway.example.test/v1"; + const original = structuredClone(custom); + expect(projectModelRenames(custom).changed).toBe(false); + expect(custom).toEqual(original); + const row = projectModelRenames(stale).config.providers.moonshot!; + expect(row.models).toEqual(["kimi-k3.1-pro"]); + expect(row.modelContextWindows).toEqual({ "kimi-k3.1-pro": 131_072 }); + expect(row.modelReasoningEfforts).toEqual({ "kimi-k3.1-pro": ["low"] }); + expect(row.modelDefaultReasoningEfforts).toEqual({ "kimi-k3.1-pro": "low" }); + expect(row.modelReasoningEffortMap).toEqual({ "kimi-k3.1-pro": { medium: "low" } }); + // An explicit successor value equal to the legacy K2.5 seed is still the user's choice. + const seedEqual = structuredClone(stale); + seedEqual.providers.moonshot!.modelContextWindows = { "kimi-k2.5": 262_144, "kimi-k3.1-pro": 262_144 }; + seedEqual.providers.moonshot!.modelReasoningEfforts = { "kimi-k2.5": [], "kimi-k3.1-pro": [] }; + const kept = projectModelRenames(seedEqual).config.providers.moonshot!; + expect(kept.modelContextWindows).toEqual({ "kimi-k3.1-pro": 262_144 }); + expect(kept.modelReasoningEfforts).toEqual({ "kimi-k3.1-pro": [] }); + // Explicit successor no-reasoning stays, with or without K2.5 still present. + const coexist = structuredClone(seedEqual); + coexist.providers.moonshot!.noReasoningModels = ["kimi-k2.5", "kimi-k3.1-pro"]; + expect(projectModelRenames(coexist).config.providers.moonshot!.noReasoningModels).toEqual(["kimi-k3.1-pro"]); + const successorOnly = { providers: { moonshot: { adapter: "openai-chat", baseUrl: "https://api.moonshot.ai/v1", + models: ["kimi-k3.1-pro"], noReasoningModels: ["kimi-k3.1-pro"], modelReasoningEfforts: { "kimi-k3.1-pro": [] } } } } as unknown as OcxConfig; + expect(projectModelRenames(structuredClone(successorOnly)).changed).toBe(false); + }); + test("repairs a saved kimi row still defaulting to the retired k2.7 id", () => { // The shape a config saved under the pre-K2.8 registry carries: the picker list, // the context-window record and the default all name kimi-k2.7-code, and the old @@ -387,6 +447,24 @@ describe("model rename startup persistence", () => { for (const home of homes.splice(0)) removeTreeWithRetry(home); }); + test("persists a saved Moonshot K2.5 default as K3.1 Pro", () => { + isolate("ocx-moonshot-k25-"); + const live = { port: 10100, defaultProvider: "moonshot", providers: { moonshot: { + adapter: "openai-chat", baseUrl: "https://api.moonshot.ai/v1", apiKey: "fixture-key", + defaultModel: "kimi-k2.5", models: ["kimi-k2.5"], + modelContextWindows: { "kimi-k2.5": 262_144 }, modelReasoningEfforts: { "kimi-k2.5": [] }, + noReasoningModels: ["kimi-k2.5"], + } } } as unknown as OcxConfig; + saveConfig(live); + runModelRenameStartupMigration(live); + const saved = loadConfig().providers.moonshot!; + expect(saved.defaultModel).toBe("kimi-k3.1-pro"); + expect(saved.models).toEqual(["kimi-k3.1-pro"]); + expect(saved.modelContextWindows?.["kimi-k3.1-pro"]).toBe(1_048_576); + expect(saved.modelReasoningEfforts?.["kimi-k3.1-pro"]).toEqual(["low", "high", "max"]); + expect(saved.noReasoningModels).not.toContain("kimi-k3.1-pro"); + }); + test("startup no-op preserves the live config object identity", () => { const clean = projectModelRenames(staleConfig(), [RENAME]).config; const returned = runModelRenameStartupMigration(clean, { diff --git a/tests/providers/provider-registry-parity.test.ts b/tests/providers/provider-registry-parity.test.ts index 0ee42da3376..5709f35e99b 100644 --- a/tests/providers/provider-registry-parity.test.ts +++ b/tests/providers/provider-registry-parity.test.ts @@ -1102,13 +1102,12 @@ describe("provider registry parity", () => { expect(moonshot?.allowBaseUrlOverride).toBe(true); expect(moonshot?.baseUrlChoices?.map(c => c.id)).toEqual(["international", "china", "custom"]); expect(moonshot?.baseUrlChoices?.find(c => c.id === "china")?.baseUrl).toBe("https://api.moonshot.cn/v1"); - // 261025 (#7653): platform.kimi.ai/docs/models.md lists kimi-k3.1-pro and its highspeed - // variant as current models; the K2 ids and the kimi-k2.7-code default stay (owner decision). + // 261028 (#7653): the vendor recommends K3.1 Pro; only the discontinued K2.5 leaves now. expect(moonshot?.models).toEqual([ "kimi-k3", "kimi-k3.1-pro", "kimi-k3.1-pro-highspeed", - "kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6", "kimi-k2.5", + "kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6", ]); - expect(moonshot?.defaultModel).toBe("kimi-k2.7-code"); + expect(moonshot?.defaultModel).toBe("kimi-k3.1-pro"); expect(moonshot?.models).not.toContain("k3"); expect(moonshot?.models).not.toContain("kimi-for-coding"); expect(moonshot?.modelContextWindows).toEqual({ @@ -1118,10 +1117,14 @@ describe("provider registry parity", () => { "kimi-k2.7-code": 262_144, "kimi-k2.7-code-highspeed": 262_144, "kimi-k2.6": 262_144, - "kimi-k2.5": 262_144, }); expect(moonshot?.modelInputModalities?.["kimi-k3"]).toEqual(["text", "image"]); expect(moonshot?.noReasoningModels).not.toContain("kimi-k3"); + expect(moonshot?.noReasoningModels).toEqual(["kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6"]); + for (const field of ["noTemperatureModels", "noTopPModels", "noPenaltyModels", "preserveReasoningContentModels"] as const) { + expect(moonshot?.[field]).toEqual(moonshot?.models); + } + expect(moonshot?.modelReasoningEfforts).not.toHaveProperty("kimi-k2.5"); // kimi-k3 keeps its launch-time max-only list; no default or wire map is seeded for it. expect(moonshot?.modelReasoningEfforts?.["kimi-k3"]).toEqual(["max"]); expect(moonshot?.modelDefaultReasoningEfforts?.["kimi-k3"]).toBeUndefined(); From e3ebd82a5b51926d964e5d0e86dd0177aed9e2da Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 29 Oct 2026 13:23:32 +0900 Subject: [PATCH 20/84] fix(openai-chat): send LM Studio one leading system message (#7882) * fix(openai-chat): normalize LM Studio instructions to one leading system message Add a preset-scoped, typed singleLeadingSystemMessage compatibility policy. Preserve outbound instruction text, user/tool ordering, and input immutability for translated and native Chat requests. Keep every other preset unchanged. Validate and preserve the optional boolean through config/editor boundaries; document the chronology tradeoff and existing IR system-message hoisting. Refs #7761 * docs(providers): count nine carried settings in the save table * fix(openai-chat): preserve developer documents with single-system policy --------- Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> --- .../fr/reference/configuration/providers.md | 13 +- .../src/content/docs/guides/lm-studio.md | 38 ++++++ .../ja/reference/configuration/providers.md | 13 +- .../ko/reference/configuration/providers.md | 13 +- .../docs/reference/configuration/providers.md | 10 +- .../ru/reference/configuration/providers.md | 13 +- .../tr/reference/configuration/providers.md | 13 +- .../reference/configuration/providers.md | 13 +- .../reference/configuration/providers.md | 13 +- scripts/test-layout/layout-adapters.json | 1 + scripts/test-layout/layout-config.json | 1 + src/adapters/openai-chat/messages.ts | 14 +- src/adapters/openai-chat/passthrough.ts | 6 +- src/adapters/openai-chat/single-system.ts | 24 ++++ src/config/schema/leaf-validators.ts | 1 + src/providers/derive.ts | 6 + src/providers/model-rename-fields.ts | 1 + src/providers/registry/entries-core.ts | 2 +- src/providers/registry/model-ids.ts | 1 + src/providers/registry/types.ts | 4 +- src/providers/resolved-model-policy.ts | 4 +- src/providers/routed-config.ts | 2 + src/server/auth-cors.ts | 1 + .../management/provider-overwrite-carry.ts | 11 ++ src/types/provider.ts | 2 + structure/INDEX.md | 13 +- structure/config.md | 2 +- structure/gui-and-management-api.md | 2 +- structure/manifest.json | 7 + structure/providers-and-adapters.md | 2 +- structure/providers/chat-compat.md | 4 +- structure/providers/lm-studio-chat.md | 32 +++++ .../openai/openai-chat-single-system.test.ts | 128 ++++++++++++++++++ .../docs-developer-role-policy.test.ts | 14 ++ tests/config/lmstudio-system-policy.test.ts | 53 ++++++++ tests/fixtures/test-layout-expected.json | 2 +- .../management-provider-compat-carry.test.ts | 6 + 37 files changed, 425 insertions(+), 60 deletions(-) create mode 100644 docs-site/src/content/docs/guides/lm-studio.md create mode 100644 src/adapters/openai-chat/single-system.ts create mode 100644 structure/providers/lm-studio-chat.md create mode 100644 tests/adapters/openai/openai-chat-single-system.test.ts create mode 100644 tests/config/lmstudio-system-policy.test.ts diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index e63eb4e35a0..4463dcc9a25 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -160,7 +160,7 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix | `noPenaltyModels?` | `string[]` | Modèles qui rejettent les pénalités presence/frequency. Le préréglage `xai` intégré y liste ses modèles de raisonnement (`grok-4.7`, `grok-4.6`, `grok-4.5`, `grok-4.3`, `grok-4.20-multi-agent-0309`, `grok-4.20-0309-reasoning`, `grok-build-0.1`), que xAI documente comme les refusant ; les identifiants sans raisonnement conservent les pénalités de l’appelant. | | `noStructuredOutputModels?` | `string[]` | ID de modèle exact dont le point final `openai-chat` rejette `response_format`. Seule une correspondance exacte du modèle demandé omet le champ ; la traduction à sortie structurée reste activée pour tous les autres modèles `openai-chat`. | | `noJsonSchemaModels?` | `string[]` | ID de modèle exact dont le point final `openai-chat` rejette un `response_format` `json_schema` mais accepte encore `json_object`. Une telle requête est rétrogradée vers `json_object` au lieu d’être supprimée, donc un appelant qui demande du JSON en reçoit toujours. `noStructuredOutputModels` l’emporte quand un modèle figure dans les deux listes. Les préréglages `opencode go` et `opencode zen` l’embarquent pour leurs routes DeepSeek. | -| `foldDeveloperRoleToSystem?` | `boolean` | Indique si une destination `openai-chat` accepte le rôle `developer`. `foldDeveloperRoleToSystem` non défini envoie `system`, `true` envoie `system` et `false` envoie `developer`. Non défini signifie que rien n'a été enregistré pour cette destination ; `true` enregistre un service en amont qui refuse le rôle ; `false` en enregistre un qui l'accepte. Dans tous les cas le message conserve sa position dans la conversation ; seul le rôle change. Une destination qui refuse le rôle répond `400 role 'developer' is not allowed` et le tour ne démarre pas, d'où l'état non enregistré replié par défaut. | +| `foldDeveloperRoleToSystem?` | `boolean` | Indique si une destination `openai-chat` accepte le rôle `developer`. `foldDeveloperRoleToSystem` non défini envoie `system`, `true` envoie `system` et `false` envoie `developer`. Non défini signifie que rien n'a été enregistré pour cette destination ; `true` enregistre un service en amont qui refuse le rôle ; `false` en enregistre un qui l'accepte. Si `singleLeadingSystemMessage` n'est pas activé, le message conserve sa position dans la conversation ; seul le rôle change. Une destination qui refuse le rôle répond `400 role 'developer' is not allowed` et le tour ne démarre pas, d'où l'état non enregistré replié par défaut. | | `parallelToolCalls?` | `boolean` | Contrôler les appels d’outils parallèles. Pour `openai-chat`, ils sont activés par défaut ; `false` envoie explicitement `parallel_tool_calls: false`. Les autres adaptateurs ne les annoncent que lorsque la valeur vaut explicitement `true`. | | `terminalContinuationGuard?` | `boolean` | Active, pour un fournisseur `openai-chat`, une relance interne bornée lorsqu’un tour exploitable annonce une action puis s’arrête proprement sans appel d’outil. La valeur par défaut est `false`, et une valeur explicite `false` équivaut à l’absence du champ. Les tentatives de combinaison et les tours de compactage routés sont exclus ; les autres adaptateurs ignorent cette option. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | Réparation SSE en aval désactivée par défaut pour les identifiants d'espace réservé exacts, les identifiants de terminal manquants et (avec `repairInvalidIds`) les identifiants message/reasoning manquant du préfixe canonique `msg_`/`rs_`. Les identifiants d’appel de fonction ne sont jamais réécrits. Le DeepSeek intégré active les deux derniers par défaut. | @@ -187,6 +187,9 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix | `unsafeAllowNativeLocalExec?` | `boolean` | Ancien booléen de Cursor, équivalent à `nativeLocalExec: "on"` uniquement lorsque le champ plus récent n'est pas défini. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Politique d'exécution locale de Cursor. `off` est la valeur par défaut ; actuellement, `codex-sandbox` échoue de manière sûre comme `off`. | +LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. + + La création et le remplacement d’un fournisseur (`POST /api/providers`) valident `responsesPath` et `chatCompletionsPath` avant de modifier la configuration en mémoire ou sur disque. `PATCH /api/providers?name=` fusionne le corps de la requête avec le fournisseur enregistré ; les mises à jour qui touchent des champs autres que `disabled` — à l’exception des mises à jour portant uniquement sur `requestPacing` — valident de la même manière les chemins du fournisseur fusionné avant l’enregistrement, et un chemin conservé invalide renvoie `400` sans modifier la configuration. Les mêmes règles de chemin s’appliquent au chargement d’un fichier de configuration. Les fournisseurs à clé API peuvent détenir une clé littérale ou une référence à une variable d'environnement. Les fournisseurs OAuth utilisent le @@ -195,17 +198,17 @@ configuré sous [`claudeCode.authMode`](/fr/reference/configuration/server/#clau ### Ce qu'une sauvegarde de fournisseur conserve -`POST /api/providers` avec le nom d'un fournisseur existant remplace la ligne enregistrée par une ligne construite à partir de la requête. Le formulaire d'ajout/modification du tableau de bord ne peut pas envoyer tous les champs ; la sauvegarde conserve donc certains champs enregistrés que la requête omet. Huit d'entre eux décrivent le comportement d'un amont précis : `preserveReasoningContentModels`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`. +`POST /api/providers` avec le nom d'un fournisseur existant remplace la ligne enregistrée par une ligne construite à partir de la requête. Le formulaire d'ajout/modification du tableau de bord ne peut pas envoyer tous les champs ; la sauvegarde conserve donc certains champs enregistrés que la requête omet. Dix d'entre eux décrivent le comportement d'un amont précis : `preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`. -| Sauvegarde | Les huit réglages | `apiKeyPool` enregistré | +| Sauvegarde | Les dix réglages | `apiKeyPool` enregistré | | --- | --- | --- | | Même destination, champ omis | Valeur enregistrée conservée, y compris un `[]` ou un `false` explicite | Conservé | | Nouvelle destination, champ omis | Non conservée ; les valeurs par défaut du registre pour la nouvelle destination peuvent s'appliquer | Non conservé | | Champ envoyé dans la requête | La valeur de la requête | La valeur de la requête | -La destination est l'adaptateur, l'URL de base (schéma et hôte comparés sans tenir compte de la casse, barres obliques finales ignorées) et, lorsque la requête en indique un, le mode d'authentification. Déplacer un fournisseur vers une autre destination abandonne les huit réglages, qui décrivent l'amont précédent, ainsi que le pool de clés, dont les clés ont été émises pour lui. Une sauvegarde ne fusionne jamais le reste de l'ancienne ligne dans la nouvelle. +La destination est l'adaptateur, l'URL de base (schéma et hôte comparés sans tenir compte de la casse, barres obliques finales ignorées) et, lorsque la requête en indique un, le mode d'authentification. Déplacer un fournisseur vers une autre destination abandonne les dix réglages, qui décrivent l'amont précédent, ainsi que le pool de clés, dont les clés ont été émises pour lui. Une sauvegarde ne fusionne jamais le reste de l'ancienne ligne dans la nouvelle. -`PATCH /api/providers?name=` ne modifie que les champs qu'il nomme et conserve tous les autres champs enregistrés, quelle que soit la destination. Il accepte les huit réglages ; `null` en efface un. Pour les deux listes de raisonnement, un tableau vide est enregistré comme refus explicite au lieu d'être supprimé. +`PATCH /api/providers?name=` ne modifie que les champs qu'il nomme et conserve tous les autres champs enregistrés, quelle que soit la destination. Il accepte les dix réglages ; `null` en efface un. Pour les deux listes de raisonnement, un tableau vide est enregistré comme refus explicite au lieu d'être supprimé. ## Sécurité des connexions sortantes de diagnostic des fournisseurs diff --git a/docs-site/src/content/docs/guides/lm-studio.md b/docs-site/src/content/docs/guides/lm-studio.md new file mode 100644 index 00000000000..33b43952290 --- /dev/null +++ b/docs-site/src/content/docs/guides/lm-studio.md @@ -0,0 +1,38 @@ +--- +title: LM Studio strict chat templates +description: Configure one leading system message for LM Studio Chat models. +--- + +The **LM Studio (local)** preset enables `singleLeadingSystemMessage: true` for +the `openai-chat` adapter. This combines text-only system/developer instructions +into one leading system message with blank lines between messages. It supports +custom model IDs, including quantization and checkpoint suffixes, without relying +on the model-name compatibility selector. + +For an existing renamed/custom LM Studio provider, add this boolean explicitly +in the provider editor. The stored value is independent of the provider name, +hostname, and port. A same-destination save preserves an omitted setting; a POST +that replaces the destination must resubmit it. PATCH accepts a boolean or `null` +to clear the override. Clearing it on the canonical LM Studio provider restores +the preset default. + +Set `singleLeadingSystemMessage: false` to disable merging for a template that +accepts chronological instruction messages. Other presets keep their defaults. +`foldDeveloperRoleToSystem` continues to control only the developer role; enabling +single-message compatibility takes precedence over that role choice and the +model-name-based late-instruction workaround. + +Hoisting later instructions changes their chronological scope and can invalidate +prompt-cache prefixes. Translated requests preserve IR instruction order: base +system prompts first, followed by timeline developer instructions. The Responses +parser already collects input system items into the base prompt, so their original +positions relative to developer items are not recovered. Direct Chat passthrough +preserves the original relative order of system/developer messages. Both paths +normalize the outbound copy and keep user messages and tool pairs in order. + +Non-text system/developer parts in direct Chat fail explicitly rather than being +silently discarded. Translated developer documents keep their file parts in a +user-compatible message at the existing timeline slot while their text joins the +leading system message. Developer image/audio messages keep their existing +user-compatible mapping. No LM Studio runtime is required to configure this flag; +the served model's template still determines which other wire features it accepts. diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index 4ed880176bd..9096e4aa794 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -153,7 +153,7 @@ account を削除しても mapping は保持され、同じ id を再追加す | `noPenaltyModels?` | `string[]` |存在/周波数ペナルティを拒否するモデル。 組み込みの`xai`プリセットは、xAIがこれらを拒否すると文書化している推論モデル(`grok-4.7`, `grok-4.6`, `grok-4.5`, `grok-4.3`, `grok-4.20-multi-agent-0309`, `grok-4.20-0309-reasoning`, `grok-build-0.1`)をここに登録します。非推論モデルは呼び出し元のペナルティをそのまま受け取ります。 | | `noStructuredOutputModels?` | `string[]` | `openai-chat` エンドポイントが `response_format` を拒否する正確なモデル ID。要求モデルが項目と完全一致する場合だけフィールドを省略し、その他の `openai-chat` モデルでは structured-output 変換を維持します。 | | `noJsonSchemaModels?` | `string[]` | `openai-chat` エンドポイントが `json_schema` 形式は拒否しつつ `json_object` は受け入れる正確なモデル ID。この要求はフィールドを削除せず `json_object` に降格して送るため、JSON を求めた呼び出し側は散文ではなく JSON を受け取れます。両方の一覧に載るモデルでは `noStructuredOutputModels` が優先します。`opencode go` / `opencode zen` プリセットが DeepSeek 経路に既定で載せます。 | -| `foldDeveloperRoleToSystem?` | `boolean` | `openai-chat` の宛先が `developer` ロールを受け付けるかを記録します。`foldDeveloperRoleToSystem` が未設定なら `system`、`true` なら `system`、`false` なら `developer` として送ります。未設定はこの宛先について何も記録されていないことを意味し、`true` は上流がロールを拒否する記録、`false` は受け付ける記録です。いずれの場合もメッセージは会話内の位置を保ち、変わるのはロールだけです。ロールを拒否する宛先は `400 role 'developer' is not allowed` を返してターンが始まらないため、未記録の既定は畳む側にしてあります。 | +| `foldDeveloperRoleToSystem?` | `boolean` | `openai-chat` の宛先が `developer` ロールを受け付けるかを記録します。`foldDeveloperRoleToSystem` が未設定なら `system`、`true` なら `system`、`false` なら `developer` として送ります。未設定はこの宛先について何も記録されていないことを意味し、`true` は上流がロールを拒否する記録、`false` は受け付ける記録です。`singleLeadingSystemMessage` が有効でない場合、メッセージは会話内の位置を保ち、変わるのはロールだけです。ロールを拒否する宛先は `400 role 'developer' is not allowed` を返してターンが始まらないため、未記録の既定は畳む側にしてあります。 | | `parallelToolCalls?` | `boolean` |並列ツール呼び出しを切り替えます。 OpenAI Chat はデフォルトでオンになっています。非チャット アダプターは明示的な `true` でのみアドバタイズします。 | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` |正確なプレースホルダー ID、欠落している端末 ID、および(`repairInvalidIds` で)正規の `msg_`/`rs_` 接頭辞を欠く message/reasoning ID に対するダウンストリーム SSE 修復はデフォルトで無効になっています。関数呼び出し ID は決して書き換えられません。組み込み DeepSeek は最後の 2 つをデフォルトで有効にします。 | | `responsesSnapshotRepair?` | `boolean` | デフォルトで無効のクライアント向け修復です。SSE と JSON の Responses ライフサイクルで欠落した status、output、ツールメタデータを補完し、raw 検査と永続化は変更しません。 | @@ -179,23 +179,26 @@ account を削除しても mapping は保持され、同じ id を再追加す | `unsafeAllowNativeLocalExec?` | `boolean` |カーソルのレガシー ブール値。新しいフィールドが設定されていない場合のみ、`nativeLocalExec: "on"` と同等です。 | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` |カーソルのローカル実行ポリシー。 `off` がデフォルトです。 `codex-sandbox` は現在、`off` と同様にフェールクローズされます。 | +LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. + + プロバイダーの登録・置換(`POST /api/providers`)では、メモリやファイルの設定を変更する前に `responsesPath` と `chatCompletionsPath` を検証します。`PATCH /api/providers?name=` はリクエスト本文を保存済みのプロバイダーにマージします。`disabled` 以外のフィールドを変更する更新(`requestPacing` のみの更新を除く)では、保存前にマージ後のプロバイダーのパスを同じ方法で検証し、保持されているパスが無効な場合は `400` を返して設定を変更しません。設定ファイルの読み込みにも同じ経路の規則が適用されます。 API キープロバイダーは、リテラルキーまたは環境参照を保持する場合があります。 OAuth プロバイダーは、`ocx login` によって設定された資格情報ストアを使用します。サブスクリプションに基づくクロード コードの起動動作は、[`claudeCode.authMode`](/ja/reference/configuration/server/#claude-code-claudecode) で構成されます。 ### プロバイダーの保存で保持されるもの -既存のプロバイダー名で `POST /api/providers` を送ると、保存済みの行はリクエストから組み立てた行で置き換えられます。ダッシュボードの追加・編集フォームはすべてのフィールドを送れないため、リクエストが省略した保存済みフィールドの一部は保存時に引き継がれます。そのうち次の 8 つは、特定のアップストリームの挙動を記録するものです:`preserveReasoningContentModels`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`。 +既存のプロバイダー名で `POST /api/providers` を送ると、保存済みの行はリクエストから組み立てた行で置き換えられます。ダッシュボードの追加・編集フォームはすべてのフィールドを送れないため、リクエストが省略した保存済みフィールドの一部は保存時に引き継がれます。そのうち次の 10 つは、特定のアップストリームの挙動を記録するものです:`preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`。 -| 保存 | 8 つの設定 | 保存済みの `apiKeyPool` | +| 保存 | 10 つの設定 | 保存済みの `apiKeyPool` | | --- | --- | --- | | 同じ宛先、フィールド省略 | 保存済みの値を保持(明示的な `[]` や `false` を含む) | 保持 | | 新しい宛先、フィールド省略 | 保持しない。新しい宛先のレジストリ既定値が適用されることがあります | 保持しない | | リクエストでフィールドを送信 | リクエストの値 | リクエストの値 | -宛先とは、アダプター、ベース URL(スキームとホストは大文字小文字を区別せず、末尾のスラッシュは無視)、そしてリクエストが指定した場合は認証モードです。別の宛先へ移すと、以前のアップストリームを表す 8 つの設定と、そのために発行されたキーのプールは引き継がれません。保存時に古い行の残りを新しい行へマージすることはありません。 +宛先とは、アダプター、ベース URL(スキームとホストは大文字小文字を区別せず、末尾のスラッシュは無視)、そしてリクエストが指定した場合は認証モードです。別の宛先へ移すと、以前のアップストリームを表す 10 つの設定と、そのために発行されたキーのプールは引き継がれません。保存時に古い行の残りを新しい行へマージすることはありません。 -`PATCH /api/providers?name=` は指定したフィールドだけを変更し、宛先に関係なくほかの保存済みフィールドはすべて保持します。8 つの設定をすべて受け付け、`null` で消去します。2 つの推論リストでは、空の配列は削除されず明示的なオプトアウトとして保存されます。 +`PATCH /api/providers?name=` は指定したフィールドだけを変更し、宛先に関係なくほかの保存済みフィールドはすべて保持します。10 つの設定をすべて受け付け、`null` で消去します。2 つの推論リストでは、空の配列は削除されず明示的なオプトアウトとして保存されます。 ## プロバイダーによるアウトバウンドの安全性診断 diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index af503a86dc5..c08b21c9eba 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -153,7 +153,7 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | `noPenaltyModels?` | `string[]` | presence/frequency penalty를 허용하지 않는 모델입니다. 기본 `xai` 프리셋은 xAI 문서가 이 값을 거부한다고 밝힌 추론 모델(`grok-4.7`, `grok-4.6`, `grok-4.5`, `grok-4.3`, `grok-4.20-multi-agent-0309`, `grok-4.20-0309-reasoning`, `grok-build-0.1`)을 여기에 넣습니다. 추론이 없는 모델은 호출자가 보낸 penalty를 그대로 받습니다. | | `noStructuredOutputModels?` | `string[]` | `openai-chat` 엔드포인트가 `response_format`을 거부하는 정확한 모델 ID입니다. 요청 모델이 항목과 정확히 일치할 때만 필드를 생략하며, 그 외 `openai-chat` 모델에서는 structured-output 변환을 유지합니다. | | `noJsonSchemaModels?` | `string[]` | `openai-chat` 엔드포인트가 `json_schema` 형식은 거부하지만 `json_object`는 받는 정확한 모델 ID입니다. 이런 요청은 필드를 지우는 대신 `json_object`로 낮춰 보내므로, JSON을 요청한 클라이언트가 산문 대신 JSON을 받습니다. 한 모델이 두 목록에 모두 있으면 `noStructuredOutputModels`가 우선합니다. `opencode go`, `opencode zen` 프리셋이 DeepSeek 경로에 기본으로 싣습니다. | -| `foldDeveloperRoleToSystem?` | `boolean` | `openai-chat` 목적지가 `developer` 역할을 받는지 기록합니다. `foldDeveloperRoleToSystem`이 없으면 `system`, `true`이면 `system`, `false`이면 `developer`로 보냅니다. 값이 없다는 것은 이 목적지에 대해 기록된 것이 없다는 뜻이고, `true`는 상위 서비스가 역할을 거부한다는 기록, `false`는 받아들인다는 기록입니다. 어느 경우에도 메시지는 대화 안의 원래 위치를 유지하며 역할만 바뀝니다. 역할을 거부하는 목적지는 `400 role 'developer' is not allowed`로 응답해 턴이 시작조차 못 하므로, 기록이 없는 상태의 기본값을 접는 쪽으로 둡니다. | +| `foldDeveloperRoleToSystem?` | `boolean` | `openai-chat` 목적지가 `developer` 역할을 받는지 기록합니다. `foldDeveloperRoleToSystem`이 없으면 `system`, `true`이면 `system`, `false`이면 `developer`로 보냅니다. 값이 없다는 것은 이 목적지에 대해 기록된 것이 없다는 뜻이고, `true`는 상위 서비스가 역할을 거부한다는 기록, `false`는 받아들인다는 기록입니다. `singleLeadingSystemMessage`가 활성화되지 않은 경우 메시지는 대화 안의 원래 위치를 유지하며 역할만 바뀝니다. 역할을 거부하는 목적지는 `400 role 'developer' is not allowed`로 응답해 턴이 시작조차 못 하므로, 기록이 없는 상태의 기본값을 접는 쪽으로 둡니다. | | `parallelToolCalls?` | `boolean` | 병렬 도구 호출을 켜거나 끕니다. OpenAI Chat은 기본으로 켜져 있고, 비-chat 어댑터는 명시적으로 `true`일 때만 이를 노출합니다. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | 기본값이 꺼진 downstream SSE 복구입니다. 정확한 자리표시자 id, 누락된 종료 id, 그리고(`repairInvalidIds`) 정규 `msg_`/`rs_` 접두사가 없는 message/reasoning id를 복구합니다. function-call id는 다시 쓰지 않습니다. 내장 DeepSeek은 마지막 두 가지를 기본으로 켭니다. | | `responsesSnapshotRepair?` | `boolean` | 기본값이 꺼진 클라이언트용 복구입니다. SSE와 JSON의 Responses 수명 주기에서 누락된 status, output, 도구 메타데이터를 채우며 raw 검사와 영속화는 변경하지 않습니다. | @@ -179,23 +179,26 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor 레거시 불리언입니다. 더 새로운 필드가 설정되지 않았을 때만 `nativeLocalExec: "on"`과 같습니다. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor 로컬 실행 정책입니다. 기본값은 `off`입니다. `codex-sandbox`는 현재 `off`처럼 실패를 닫습니다. | +LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. + + 공급자 등록·교체(`POST /api/providers`)는 `responsesPath`와 `chatCompletionsPath`를 검증한 뒤 메모리와 파일의 설정을 변경합니다. `PATCH /api/providers?name=`는 요청 본문을 저장된 공급자에 병합합니다. `disabled` 외의 필드를 변경하는 업데이트(`requestPacing`만 변경하는 업데이트 제외)는 저장 전에 병합된 공급자의 경로를 같은 방식으로 검증하며, 유지된 경로가 유효하지 않으면 `400`을 반환하고 설정을 변경하지 않습니다. 설정 파일을 읽을 때도 같은 경로 규칙을 적용합니다. API 키 공급자는 리터럴 키나 환경 참조를 둘 수 있습니다. OAuth 공급자는 `ocx login`으로 채워지는 자격 증명 저장소를 사용합니다. 구독 기반 Claude Code 실행 동작은 [`claudeCode.authMode`](/ko/reference/configuration/server/#claude-code-claudecode)에서 설정합니다. ### 프로바이더 저장이 유지하는 것 -기존 프로바이더 이름으로 `POST /api/providers`를 보내면 저장된 행이 요청으로 만든 행으로 바뀝니다. 대시보드의 추가/편집 폼은 모든 필드를 보낼 수 없으므로, 요청이 빠뜨린 저장 필드 일부는 저장할 때 이어서 유지됩니다. 그중 여덟 가지는 특정 업스트림의 동작을 기록한 설정입니다: `preserveReasoningContentModels`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`. +기존 프로바이더 이름으로 `POST /api/providers`를 보내면 저장된 행이 요청으로 만든 행으로 바뀝니다. 대시보드의 추가/편집 폼은 모든 필드를 보낼 수 없으므로, 요청이 빠뜨린 저장 필드 일부는 저장할 때 이어서 유지됩니다. 그중 열 가지는 특정 업스트림의 동작을 기록한 설정입니다: `preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`. -| 저장 | 여덟 가지 설정 | 저장된 `apiKeyPool` | +| 저장 | 열 가지 설정 | 저장된 `apiKeyPool` | | --- | --- | --- | | 같은 목적지, 필드 생략 | 저장된 값 유지(명시적인 `[]`나 `false` 포함) | 유지 | | 새 목적지, 필드 생략 | 유지하지 않음. 새 목적지의 레지스트리 기본값이 적용될 수 있음 | 유지하지 않음 | | 요청에 필드를 보냄 | 요청의 값 | 요청의 값 | -목적지는 어댑터, 기본 URL(스킴과 호스트는 대소문자를 구분하지 않고, 끝의 슬래시는 무시), 그리고 요청이 지정한 경우 인증 모드입니다. 프로바이더를 다른 목적지로 옮기면 이전 업스트림을 설명하는 여덟 가지 설정과, 그 업스트림용으로 발급된 키 풀을 가져가지 않습니다. 저장은 이전 행의 나머지를 새 행에 병합하지 않습니다. +목적지는 어댑터, 기본 URL(스킴과 호스트는 대소문자를 구분하지 않고, 끝의 슬래시는 무시), 그리고 요청이 지정한 경우 인증 모드입니다. 프로바이더를 다른 목적지로 옮기면 이전 업스트림을 설명하는 열 가지 설정과, 그 업스트림용으로 발급된 키 풀을 가져가지 않습니다. 저장은 이전 행의 나머지를 새 행에 병합하지 않습니다. -`PATCH /api/providers?name=`는 지정한 필드만 바꾸고, 목적지와 상관없이 나머지 저장 필드는 모두 유지합니다. 여덟 가지 설정을 모두 받고, `null`로 지웁니다. 두 추론 목록에서 빈 배열은 삭제되지 않고 명시적인 옵트아웃으로 저장됩니다. +`PATCH /api/providers?name=`는 지정한 필드만 바꾸고, 목적지와 상관없이 나머지 저장 필드는 모두 유지합니다. 열 가지 설정을 모두 받고, `null`로 지웁니다. 두 추론 목록에서 빈 배열은 삭제되지 않고 명시적인 옵트아웃으로 저장됩니다. ## 공급자 진단용 외부 요청 안전성 diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 64b316c1e0c..adada880699 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -381,7 +381,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `noPenaltyModels?` | `string[]` | Models that reject presence/frequency penalties. The built-in `xai` preset lists its reasoning models (`grok-4.7`, `grok-4.6`, `grok-4.5`, `grok-4.3`, `grok-4.20-multi-agent-0309`, `grok-4.20-0309-reasoning`, `grok-build-0.1`), which xAI documents as rejecting them; non-reasoning ids keep caller penalties. | | `noStructuredOutputModels?` | `string[]` | Exact model IDs whose `openai-chat` endpoint rejects `response_format`. Only an exact requested-model match omits the field; structured-output translation stays enabled for every other `openai-chat` model. | | `noJsonSchemaModels?` | `string[]` | Exact model IDs whose `openai-chat` endpoint rejects a `json_schema` `response_format` but still accepts `json_object`. Such a request is downgraded to `json_object` instead of being dropped, so a caller asking for JSON still gets JSON. `noStructuredOutputModels` wins when a model is on both lists. The `opencode go` and `opencode zen` presets ship this for their DeepSeek routes. | -| `foldDeveloperRoleToSystem?` | `boolean` | Whether an `openai-chat` destination accepts the `developer` role. `foldDeveloperRoleToSystem` unset sends `system`, `true` sends `system`, and `false` sends `developer`. Unset means nothing has been recorded about this destination; `true` records an upstream that rejects the role; `false` records one that accepts it. The message keeps its position in the conversation in every case — only the role changes. A destination that rejects the role answers `400 role 'developer' is not allowed` and the turn never starts, which is why the unrecorded state is the folded one. A provider save that keeps the destination keeps it; see [What a provider save keeps](#what-a-provider-save-keeps). `PATCH` accepts a boolean or `null` to clear it. | +| `foldDeveloperRoleToSystem?` | `boolean` | Whether an `openai-chat` destination accepts the `developer` role. `foldDeveloperRoleToSystem` unset sends `system`, `true` sends `system`, and `false` sends `developer`. Unset means nothing has been recorded about this destination; `true` records an upstream that rejects the role; `false` records one that accepts it. Unless [single-leading-system compatibility](/guides/lm-studio/) is enabled, the message keeps its position in the conversation in every case — only the role changes. A destination that rejects the role answers `400 role 'developer' is not allowed` and the turn never starts, which is why the unrecorded state is the folded one. A provider save that keeps the destination keeps it; see [What a provider save keeps](#what-a-provider-save-keeps). `PATCH` accepts a boolean or `null` to clear it. | | `omitReasoningEffortWithToolsModels?` | `string[]` | Exact `openai-chat` model IDs that accept a reasoning-effort field on an ordinary turn but reject it once function tools are present. The model keeps its advertised effort ladder; OpenCodex omits the wire field for tool-bearing requests only and the upstream default applies. Narrower than `noReasoningModels`, which strips reasoning from every request and costs the model its picker entirely. A provider save that keeps the destination keeps it; see [What a provider save keeps](#what-a-provider-save-keeps). | | `parallelToolCalls?` | `boolean` | Toggle parallel tool calls. OpenAI Chat defaults on; non-chat adapters advertise only on explicit `true`. | | `terminalContinuationGuard?` | `boolean` | Opt in an `openai-chat` provider to one bounded internal re-ask when an actionable turn announces work, then cleanly stops without a tool call. Defaults to `false`; explicit `false` behaves like omission. Combo attempts and routed compaction turns are excluded, and non-`openai-chat` adapters ignore this option. | @@ -487,15 +487,15 @@ and `modelContextWindows` for that exact id when needed. Unknown ids are not gue ### What a provider save keeps -`POST /api/providers` with the name of an existing provider replaces the stored row with one built from the request. The dashboard's add/edit form cannot send every field, so the save keeps some stored fields the request omits. Eight of them record how one upstream behaves: `preserveReasoningContentModels`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `retryOn429`, `transientRetryOn5xx` and `retryOnReset`. +`POST /api/providers` with the name of an existing provider replaces the stored row with one built from the request. The dashboard's add/edit form cannot send every field, so the save keeps some stored fields the request omits. Ten of them record how one upstream behaves: `preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `retryOn429`, `transientRetryOn5xx` and `retryOnReset`. -| Save | The eight settings | Stored `apiKeyPool` | +| Save | The ten settings | Stored `apiKeyPool` | | --- | --- | --- | | Same destination, field omitted | Stored value kept, including an explicit `[]` or `false` | Kept | | New destination, field omitted | Not kept; registry defaults for the new destination may apply | Not kept | | Field sent in the request | The request's value | The request's value | -The destination is the adapter, the base URL (scheme and host compared without regard to case, trailing slashes ignored) and, when the request names one, the auth mode. Moving a provider to another destination drops the eight settings because they describe the previous upstream, and drops the key pool because its keys were issued for it. A save never merges the rest of the old row into the new one. +The destination is the adapter, the base URL (scheme and host compared without regard to case, trailing slashes ignored) and, when the request names one, the auth mode. Moving a provider to another destination drops the ten settings because they describe the previous upstream, and drops the key pool because its keys were issued for it. A save never merges the rest of the old row into the new one. `hideRawReasoning` is an operator display policy, so a save of the same provider name keeps its stored value whenever the request omits it, including after a destination move. A value sent in the request wins, including `false`. @@ -503,7 +503,7 @@ The optional `inferContextWindow` setting also survives same-name saves that omi destination moves. Its read, update, and clear behavior is described under [Context windows on custom providers](#context-windows-on-custom-providers). -`PATCH /api/providers?name=` changes only the fields it names and keeps every other stored field, whatever the destination. It accepts all eight settings; `null` clears one. It also accepts a boolean `hideRawReasoning` or `null` to clear it. For the two reasoning lists an empty array is stored as an explicit opt-out rather than removed. +`PATCH /api/providers?name=` changes only the fields it names and keeps every other stored field, whatever the destination. It accepts all ten settings; `null` clears one. It also accepts a boolean `hideRawReasoning` or `null` to clear it. For the two reasoning lists an empty array is stored as an explicit opt-out rather than removed. With `webSearchBridge` enabled, a search continuation stays bound to the API-key selection that served the first request. Changing the selected key, its reference or resolved value, authentication diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index b3cab619dab..b84570c51bf 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -166,7 +166,7 @@ cross-route credential fallback не существует. Строки API GPT- | `noPenaltyModels?` | `string[]` | Модели, отвергающие penalty presence/frequency. Встроенный пресет `xai` перечисляет здесь свои модели рассуждения (`grok-4.7`, `grok-4.6`, `grok-4.5`, `grok-4.3`, `grok-4.20-multi-agent-0309`, `grok-4.20-0309-reasoning`, `grok-build-0.1`), которые, по документации xAI, отвергают эти параметры; модели без рассуждения сохраняют penalty вызывающей стороны. | | `noStructuredOutputModels?` | `string[]` | Точные идентификаторы моделей, чей endpoint `openai-chat` отклоняет `response_format`. Поле опускается только при точном совпадении запрошенной модели; для остальных моделей `openai-chat` преобразование structured output остаётся включённым. | | `noJsonSchemaModels?` | `string[]` | Точные идентификаторы моделей, чей endpoint `openai-chat` отклоняет `response_format` типа `json_schema`, но принимает `json_object`. Такой запрос понижается до `json_object`, а не отбрасывается, поэтому вызывающая сторона всё равно получает JSON. Если модель есть в обоих списках, побеждает `noStructuredOutputModels`. Пресеты `opencode go` и `opencode zen` включают это для своих маршрутов DeepSeek. | -| `foldDeveloperRoleToSystem?` | `boolean` | Принимает ли назначение `openai-chat` роль `developer`. Если `foldDeveloperRoleToSystem` не задан, сообщение уходит как `system`; при `true` — как `system`; при `false` — как `developer`. Не задано означает, что об этом назначении ничего не записано; `true` фиксирует вышестоящий сервис, который роль отклоняет; `false` — тот, который её принимает. В любом случае сообщение сохраняет свою позицию в разговоре, меняется только роль. Назначение, отклоняющее роль, отвечает `400 role 'developer' is not allowed`, и ход не начинается — поэтому незаписанное состояние по умолчанию свёрнуто. | +| `foldDeveloperRoleToSystem?` | `boolean` | Принимает ли назначение `openai-chat` роль `developer`. Если `foldDeveloperRoleToSystem` не задан, сообщение уходит как `system`; при `true` — как `system`; при `false` — как `developer`. Не задано означает, что об этом назначении ничего не записано; `true` фиксирует вышестоящий сервис, который роль отклоняет; `false` — тот, который её принимает. Если `singleLeadingSystemMessage` не включён, сообщение сохраняет свою позицию в разговоре, меняется только роль. Назначение, отклоняющее роль, отвечает `400 role 'developer' is not allowed`, и ход не начинается — поэтому незаписанное состояние по умолчанию свёрнуто. | | `parallelToolCalls?` | `boolean` | Переключатель parallel tool call'ов. Для OpenAI Chat по умолчанию включено; не-chat adapter'ы рекламируют это только при явном `true`. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | По умолчанию выключенная downstream SSE-repair для exact placeholder-id, отсутствующих terminal-id и (с `repairInvalidIds`) message/reasoning id без канонического префикса `msg_`/`rs_`. Function-call id никогда не переписываются. Встроенный DeepSeek включает последние два по умолчанию. | | `responsesSnapshotRepair?` | `boolean` | По умолчанию выключенная клиентская repair для неполных lifecycle snapshot'ов Responses в SSE и JSON. Добавляет отсутствующие status, output и tool metadata, не меняя raw inspection и persistence. | @@ -192,6 +192,9 @@ cross-route credential fallback не существует. Строки API GPT- | `unsafeAllowNativeLocalExec?` | `boolean` | Legacy boolean Cursor, эквивалентен `nativeLocalExec: "on"` только если новое поле не задано. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Политика local-exec для Cursor. `off` — дефолт; `codex-sandbox` сейчас ведёт себя fail-closed как `off`. | +LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. + + Регистрация и замена провайдера (`POST /api/providers`) проверяют `responsesPath` и `chatCompletionsPath` до изменения конфигурации в памяти или на диске. `PATCH /api/providers?name=` объединяет тело запроса с сохранённым провайдером; обновления, затрагивающие поля помимо `disabled`, — кроме обновлений только `requestPacing` — так же проверяют пути объединённого провайдера перед сохранением, а недопустимый сохранённый путь возвращает `400` без изменения конфигурации. Те же правила путей применяются при загрузке файла конфигурации. Провайдеры с API-key могут хранить literal key или environment-reference. OAuth-провайдеры @@ -201,17 +204,17 @@ Claude Code настраивается через ### Что сохраняется при сохранении провайдера -`POST /api/providers` с именем существующего провайдера заменяет сохранённую строку строкой, собранной из запроса. Форма добавления и редактирования в панели не может отправить все поля, поэтому сохранение переносит часть сохранённых полей, которые запрос опустил. Восемь из них описывают поведение конкретного апстрима: `preserveReasoningContentModels`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`. +`POST /api/providers` с именем существующего провайдера заменяет сохранённую строку строкой, собранной из запроса. Форма добавления и редактирования в панели не может отправить все поля, поэтому сохранение переносит часть сохранённых полей, которые запрос опустил. Десять из них описывают поведение конкретного апстрима: `preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`. -| Сохранение | Восемь настроек | Сохранённый `apiKeyPool` | +| Сохранение | Десять настроек | Сохранённый `apiKeyPool` | | --- | --- | --- | | Та же точка назначения, поле опущено | Сохранённое значение остаётся, включая явный `[]` или `false` | Остаётся | | Новая точка назначения, поле опущено | Не переносится; могут примениться значения реестра по умолчанию для новой точки | Не переносится | | Поле передано в запросе | Значение из запроса | Значение из запроса | -Точка назначения — это адаптер, базовый URL (схема и хост сравниваются без учёта регистра, завершающие слеши игнорируются) и, если запрос его указывает, режим аутентификации. При переносе провайдера на другую точку назначения восемь настроек, описывающих прежний апстрим, отбрасываются, как и пул ключей, выпущенных для него. Сохранение никогда не сливает остальную часть старой строки с новой. +Точка назначения — это адаптер, базовый URL (схема и хост сравниваются без учёта регистра, завершающие слеши игнорируются) и, если запрос его указывает, режим аутентификации. При переносе провайдера на другую точку назначения десять настроек, описывающих прежний апстрим, отбрасываются, как и пул ключей, выпущенных для него. Сохранение никогда не сливает остальную часть старой строки с новой. -`PATCH /api/providers?name=` меняет только названные поля и сохраняет все остальные, независимо от точки назначения. Он принимает все восемь настроек; `null` очищает настройку. Для двух списков рассуждений пустой массив сохраняется как явный отказ, а не удаляется. +`PATCH /api/providers?name=` меняет только названные поля и сохраняет все остальные, независимо от точки назначения. Он принимает все десять настроек; `null` очищает настройку. Для двух списков рассуждений пустой массив сохраняется как явный отказ, а не удаляется. ## Безопасность исходящих диагностических запросов diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index 91f90523d70..e4d42b15be9 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -165,7 +165,7 @@ alanlı seçilmiş kimlikleri yalın kimliklere yeniden yazar. | `noPenaltyModels?` | `string[]` | Varlık/frekans cezalarını reddeden modeller. Yerleşik `xai` ön ayarı, xAI belgelerine göre bu değerleri reddeden akıl yürütme modellerini (`grok-4.7`, `grok-4.6`, `grok-4.5`, `grok-4.3`, `grok-4.20-multi-agent-0309`, `grok-4.20-0309-reasoning`, `grok-build-0.1`) burada listeler; akıl yürütmesiz modeller çağıranın cezalarını korur. | | `noStructuredOutputModels?` | `string[]` | `openai-chat` uç noktası `response_format`'ı reddeden tam model kimlikleri. Yalnızca tam bir istenen model eşleşmesi alanı atlar; yapılandırılmış çıktı çevirisi diğer her `openai-chat` modeli için etkin kalır. | | `noJsonSchemaModels?` | `string[]` | `openai-chat` uç noktası `json_schema` biçimini reddeden ama `json_object` kabul eden tam model kimlikleri. Böyle bir istek atılmak yerine `json_object` seviyesine düşürülür, böylece JSON isteyen çağıran yine JSON alır. Bir model her iki listede de varsa `noStructuredOutputModels` kazanır. `opencode go` ve `opencode zen` hazır ayarları bunu DeepSeek rotaları için getirir. | -| `foldDeveloperRoleToSystem?` | `boolean` | Bir `openai-chat` hedefinin `developer` rolünü kabul edip etmediğini kaydeder. `foldDeveloperRoleToSystem` ayarlanmamışsa `system`, `true` ise `system`, `false` ise `developer` olarak gönderilir. Ayarlanmamış olması bu hedef için hiçbir şey kaydedilmediği anlamına gelir; `true` rolü reddeden bir üst hizmeti, `false` ise kabul edeni kaydeder. Her durumda mesaj konuşmadaki konumunu korur; yalnızca rol değişir. Rolü reddeden bir hedef `400 role 'developer' is not allowed` yanıtı verir ve tur hiç başlamaz; kaydedilmemiş durumun katlanmış olmasının nedeni budur. | +| `foldDeveloperRoleToSystem?` | `boolean` | Bir `openai-chat` hedefinin `developer` rolünü kabul edip etmediğini kaydeder. `foldDeveloperRoleToSystem` ayarlanmamışsa `system`, `true` ise `system`, `false` ise `developer` olarak gönderilir. Ayarlanmamış olması bu hedef için hiçbir şey kaydedilmediği anlamına gelir; `true` rolü reddeden bir üst hizmeti, `false` ise kabul edeni kaydeder. `singleLeadingSystemMessage` etkin değilse mesaj konuşmadaki konumunu korur; yalnızca rol değişir. Rolü reddeden bir hedef `400 role 'developer' is not allowed` yanıtı verir ve tur hiç başlamaz; kaydedilmemiş durumun katlanmış olmasının nedeni budur. | | `parallelToolCalls?` | `boolean` | Paralel araç çağrılarını açıp kapatın. OpenAI Chat varsayılan olarak açıktır; sohbet harici adaptörler yalnızca açık `true` durumunda bildirir. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | Tam yer tutucu kimlikleri, eksik terminal kimlikleri ve (`repairInvalidIds` ile) kurallı `msg_`/`rs_` öneki eksik olan mesaj/akıl yürütme kimlikleri için varsayılan olarak devre dışı bırakılmış aşağı akış SSE onarımı. Fonksiyon çağrısı kimlikleri asla yeniden yazılmaz. Yerleşik DeepSeek son ikisini varsayılan olarak etkinleştirir. | | `responsesSnapshotRepair?` | `boolean` | SSE ve JSON'daki seyrek Responses yaşam döngüsü anlık görüntüleri için varsayılan olarak devre dışı bırakılmış istemciye yönelik onarım. Ham inceleme ve kalıcılık değişmeden kalırken eksik kurallı durumu, çıktıyı ve araç meta verilerini doldurur. | @@ -191,6 +191,9 @@ alanlı seçilmiş kimlikleri yalın kimliklere yeniden yazar. | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor eski boolean değeri, yalnızca daha yeni alan ayarlanmadığında `nativeLocalExec: "on"` değerine eşdeğerdir. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor yerel yürütme politikası. `off` varsayılandır; `codex-sandbox` şu anda `off` gibi kapalı olarak başarısız olur. | +LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. + + Sağlayıcı kaydı ve değiştirme (`POST /api/providers`), bellekteki veya diskteki yapılandırmayı değiştirmeden önce `responsesPath` ve `chatCompletionsPath` değerlerini doğrular. `PATCH /api/providers?name=`, istek gövdesini kayıtlı sağlayıcıyla birleştirir; `disabled` dışındaki alanlara dokunan güncellemeler (yalnızca `requestPacing` içeren güncellemeler hariç), kaydetmeden önce birleştirilmiş sağlayıcının yollarını aynı şekilde doğrular ve geçersiz bir korunmuş yol `400` döndürüp yapılandırmayı değiştirmez. Aynı yol kuralları yapılandırma dosyası yüklenirken de uygulanır. API anahtarı sağlayıcıları değişmez bir anahtar veya bir ortam referansı @@ -201,17 +204,17 @@ yapılandırılır. ### Bir sağlayıcı kaydı neleri korur -Var olan bir sağlayıcının adıyla gönderilen `POST /api/providers`, saklanan satırı istekten oluşturulan bir satırla değiştirir. Panodaki ekleme/düzenleme formu her alanı gönderemez; bu yüzden kayıt, isteğin atladığı bazı saklanan alanları korur. Bunlardan sekizi belirli bir üst kaynağın nasıl davrandığını kaydeder: `preserveReasoningContentModels`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`. +Var olan bir sağlayıcının adıyla gönderilen `POST /api/providers`, saklanan satırı istekten oluşturulan bir satırla değiştirir. Panodaki ekleme/düzenleme formu her alanı gönderemez; bu yüzden kayıt, isteğin atladığı bazı saklanan alanları korur. Bunlardan onu belirli bir üst kaynağın nasıl davrandığını kaydeder: `preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`. -| Kayıt | Sekiz ayar | Saklanan `apiKeyPool` | +| Kayıt | On ayar | Saklanan `apiKeyPool` | | --- | --- | --- | | Aynı hedef, alan atlanmış | Saklanan değer korunur, açık bir `[]` veya `false` dahil | Korunur | | Yeni hedef, alan atlanmış | Korunmaz; yeni hedef için kayıt defteri varsayılanları uygulanabilir | Korunmaz | | Alan istekte gönderilmiş | İsteğin değeri | İsteğin değeri | -Hedef; bağdaştırıcı, temel URL (şema ve ana makine büyük/küçük harf gözetmeden karşılaştırılır, sondaki eğik çizgiler yok sayılır) ve istek bir tane belirtiyorsa kimlik doğrulama modudur. Bir sağlayıcıyı başka bir hedefe taşımak, önceki üst kaynağı tanımlayan sekiz ayarı ve o kaynak için verilmiş anahtar havuzunu bırakır. Bir kayıt, eski satırın geri kalanını yenisiyle asla birleştirmez. +Hedef; bağdaştırıcı, temel URL (şema ve ana makine büyük/küçük harf gözetmeden karşılaştırılır, sondaki eğik çizgiler yok sayılır) ve istek bir tane belirtiyorsa kimlik doğrulama modudur. Bir sağlayıcıyı başka bir hedefe taşımak, önceki üst kaynağı tanımlayan on ayarı ve o kaynak için verilmiş anahtar havuzunu bırakır. Bir kayıt, eski satırın geri kalanını yenisiyle asla birleştirmez. -`PATCH /api/providers?name=` yalnızca adını verdiği alanları değiştirir ve hedef ne olursa olsun diğer tüm saklanan alanları korur. Sekiz ayarın hepsini kabul eder; `null` birini temizler. İki akıl yürütme listesinde boş dizi silinmez, açık bir vazgeçme olarak saklanır. +`PATCH /api/providers?name=` yalnızca adını verdiği alanları değiştirir ve hedef ne olursa olsun diğer tüm saklanan alanları korur. On ayarın hepsini kabul eder; `null` birini temizler. İki akıl yürütme listesinde boş dizi silinmez, açık bir vazgeçme olarak saklanır. ## Sağlayıcı teşhis giden güvenliği diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index 1cff2b9560f..38f051f9596 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -155,7 +155,7 @@ selector,而不是分配一个新名称。 | `noPenaltyModels?` | `string[]` | 会拒绝 presence/frequency penalty 的模型。 内置 `xai` 预设在此列出 xAI 文档说明会拒绝这些参数的推理模型(`grok-4.7`, `grok-4.6`, `grok-4.5`, `grok-4.3`, `grok-4.20-multi-agent-0309`, `grok-4.20-0309-reasoning`, `grok-build-0.1`);非推理模型保留调用方的 penalty。 | | `noStructuredOutputModels?` | `string[]` | `openai-chat` 端点拒绝 `response_format` 的精确模型 ID。仅当请求模型与条目完全匹配时才省略该字段;其他 `openai-chat` 模型仍启用 structured-output 转换。 | | `noJsonSchemaModels?` | `string[]` | `openai-chat` 端点拒绝 `json_schema` 形式但仍接受 `json_object` 的精确模型 ID。这类请求会降级为 `json_object` 而不是被丢弃,因此请求 JSON 的调用方仍能拿到 JSON。同一模型同时出现在两个列表时,以 `noStructuredOutputModels` 为准。`opencode go`、`opencode zen` 预设已为其 DeepSeek 路由内置该项。 | -| `foldDeveloperRoleToSystem?` | `boolean` | 记录某个 `openai-chat` 目的地是否接受 `developer` 角色。`foldDeveloperRoleToSystem` 未设置时按 `system` 发送,`true` 时按 `system` 发送,`false` 时按 `developer` 发送。未设置表示尚未记录该目的地的情况;`true` 记录上游拒绝该角色;`false` 记录其接受该角色。无论哪种情况,消息都保留在对话中的原有位置,只有角色改变。拒绝该角色的目的地会返回 `400 role 'developer' is not allowed`,这一轮根本无法开始,这就是未记录状态默认折叠的原因。 | +| `foldDeveloperRoleToSystem?` | `boolean` | 记录某个 `openai-chat` 目的地是否接受 `developer` 角色。`foldDeveloperRoleToSystem` 未设置时按 `system` 发送,`true` 时按 `system` 发送,`false` 时按 `developer` 发送。未设置表示尚未记录该目的地的情况;`true` 记录上游拒绝该角色;`false` 记录其接受该角色。未启用 `singleLeadingSystemMessage` 时,消息保留在对话中的原有位置,只有角色改变。拒绝该角色的目的地会返回 `400 role 'developer' is not allowed`,这一轮根本无法开始,这就是未记录状态默认折叠的原因。 | | `parallelToolCalls?` | `boolean` | 切换并行工具调用。OpenAI Chat 默认开启;非 chat 适配器只有显式 `true` 时才会声明支持。 | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | 默认关闭的下游 SSE 修复,用于精确占位 id、缺失的终止 id,以及(`repairInvalidIds`)缺少规范 `msg_`/`rs_` 前缀的 message/reasoning id。function-call id 永远不会被重写。内置 DeepSeek 默认启用后两项。 | | `responsesSnapshotRepair?` | `boolean` | 默认关闭的客户端修复,用于补全 SSE 与 JSON 中稀疏 Responses 生命周期快照缺失的 status、output 和工具元数据;原始检查与持久化保持不变。 | @@ -181,23 +181,26 @@ selector,而不是分配一个新名称。 | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor 旧布尔值;仅当更新字段未设置时,等同于 `nativeLocalExec: "on"`。 | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor 本地执行策略。`off` 是默认值;`codex-sandbox` 目前会像 `off` 一样失败关闭。 | +LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. + + 注册或替换提供商(`POST /api/providers`)时,会先验证 `responsesPath` 和 `chatCompletionsPath`,再修改内存或磁盘中的配置。`PATCH /api/providers?name=` 会将请求体与已保存的提供商合并;除仅更新 `requestPacing` 的请求外,凡是修改 `disabled` 以外字段的更新,都会在保存前以同样方式验证合并后提供商的路径,若保留的既有路径无效则返回 `400`,且不更改配置。加载配置文件时也适用同样的路径规则。 API key 提供者可以持有字面量 key,或环境引用。OAuth 提供者使用由 `ocx login` 填充的凭据存储;基于订阅的 Claude Code 启动行为在 [`claudeCode.authMode`](/zh-cn/reference/configuration/server/#claude-code-claudecode) 下配置。 ### 保存提供方时会保留什么 -用已有提供方的名称调用 `POST /api/providers`,会用根据请求构建的行替换已存储的行。仪表板的添加/编辑表单无法发送所有字段,因此保存时会保留请求省略的部分已存储字段。其中八个记录的是某个上游的行为:`preserveReasoningContentModels`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`。 +用已有提供方的名称调用 `POST /api/providers`,会用根据请求构建的行替换已存储的行。仪表板的添加/编辑表单无法发送所有字段,因此保存时会保留请求省略的部分已存储字段。其中十个记录的是某个上游的行为:`preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`。 -| 保存 | 八项设置 | 已存储的 `apiKeyPool` | +| 保存 | 十项设置 | 已存储的 `apiKeyPool` | | --- | --- | --- | | 目的地相同,字段省略 | 保留已存储的值,包括显式的 `[]` 或 `false` | 保留 | | 新目的地,字段省略 | 不保留;可能套用新目的地的注册表默认值 | 不保留 | | 请求中发送了该字段 | 请求中的值 | 请求中的值 | -目的地指适配器、base URL(协议与主机名比较时不区分大小写,忽略末尾斜杠),以及请求中指定了时的认证模式。把提供方移到其他目的地时,描述旧上游的八项设置和为旧上游签发的密钥池都不会带过去。保存从不把旧行的其余部分合并进新行。 +目的地指适配器、base URL(协议与主机名比较时不区分大小写,忽略末尾斜杠),以及请求中指定了时的认证模式。把提供方移到其他目的地时,描述旧上游的十项设置和为旧上游签发的密钥池都不会带过去。保存从不把旧行的其余部分合并进新行。 -`PATCH /api/providers?name=` 只修改它指定的字段,无论目的地如何都保留其他所有已存储字段。它接受全部八项设置,`null` 表示清除。对于两个推理列表,空数组会作为显式退出选项保存,而不会被删除。 +`PATCH /api/providers?name=` 只修改它指定的字段,无论目的地如何都保留其他所有已存储字段。它接受全部十项设置,`null` 表示清除。对于两个推理列表,空数组会作为显式退出选项保存,而不会被删除。 ### 响应服务等级的可信度 diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index 079715c71b3..ee4ed249009 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -128,7 +128,7 @@ ocx models provider openrouter on | `noPenaltyModels?` | `string[]` | 拒絕 presence/frequency penalty 的模型。 內建 `xai` 預設在此列出 xAI 文件說明會拒絕這些參數的推理模型(`grok-4.7`, `grok-4.6`, `grok-4.5`, `grok-4.3`, `grok-4.20-multi-agent-0309`, `grok-4.20-0309-reasoning`, `grok-build-0.1`);非推理模型保留呼叫者的 penalty。 | | `noStructuredOutputModels?` | `string[]` | 其 `openai-chat` 端點拒絕 `response_format` 的精確模型 ID。僅精確符合的請求模型會省略該欄位;structured-output 轉譯對其他每個 `openai-chat` 模型保持啟用。 | | `noJsonSchemaModels?` | `string[]` | 其 `openai-chat` 端點拒絕 `json_schema` 形式但仍接受 `json_object` 的精確模型 ID。這類請求會降級為 `json_object` 而非被丟棄,因此要求 JSON 的呼叫端仍會拿到 JSON。同一模型同時列在兩份清單時,以 `noStructuredOutputModels` 為準。`opencode go`、`opencode zen` 預設已為其 DeepSeek 路由內建。 | -| `foldDeveloperRoleToSystem?` | `boolean` | 記錄某個 `openai-chat` 目的地是否接受 `developer` 角色。`foldDeveloperRoleToSystem` 未設定時以 `system` 傳送,`true` 時以 `system` 傳送,`false` 時以 `developer` 傳送。未設定表示尚未記錄該目的地的情況;`true` 記錄上游拒絕該角色;`false` 記錄其接受該角色。無論何者,訊息都保留在對話中的原有位置,只有角色改變。拒絕該角色的目的地會回應 `400 role 'developer' is not allowed`,該回合根本無法開始,這就是未記錄狀態預設摺疊的原因。 | +| `foldDeveloperRoleToSystem?` | `boolean` | 記錄某個 `openai-chat` 目的地是否接受 `developer` 角色。`foldDeveloperRoleToSystem` 未設定時以 `system` 傳送,`true` 時以 `system` 傳送,`false` 時以 `developer` 傳送。未設定表示尚未記錄該目的地的情況;`true` 記錄上游拒絕該角色;`false` 記錄其接受該角色。未啟用 `singleLeadingSystemMessage` 時,訊息保留在對話中的原有位置,只有角色改變。拒絕該角色的目的地會回應 `400 role 'developer' is not allowed`,該回合根本無法開始,這就是未記錄狀態預設摺疊的原因。 | | `parallelToolCalls?` | `boolean` | 切換平行工具呼叫。OpenAI Chat 預設開啟;非 chat adapter 僅在明確 `true` 時廣告。 | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean }` | 預設停用的下游 SSE 修復,用於精確佔位 id 與缺失的終端 id。Function-call id 永不被重寫。 | | `promptCacheKey?` | `boolean` | 為整個 `openai-chat` provider 啟用 `prompt_cache_key` 轉送。adapter 只轉送收到的 key,不自行產生,但 Claude Messages 轉換會從 `metadata.user_id`,或在沒有 metadata 時從模型/系統/工具組合衍生 key。除非 preset 宣告,否則預設關閉;Mistral 依其[提示快取契約](https://docs.mistral.ai/studio/conversations/advanced/prompt-caching)啟用,明確的 `false` 可停用。僅在上游明確支援時啟用,嚴格閘道可能以 HTTP 400 拒絕未知欄位。 | @@ -152,23 +152,26 @@ ocx models provider openrouter on | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor 舊版布林值,僅在較新欄位未設定時等同於 `nativeLocalExec: "on"`。 | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor 本機執行政策。`off` 為預設;`codex-sandbox` 目前像 `off` 般 fail closed。 | +LM Studio instruction compatibility: `singleLeadingSystemMessage: true` overrides chronological Chat instruction placement by merging text-only system/developer messages into one leading system message. The LM Studio preset enables it by default; set it to `false` to disable merging. + + 註冊或替換供應商(`POST /api/providers`)時,會先驗證 `responsesPath` 和 `chatCompletionsPath`,再修改記憶體或磁碟中的設定。`PATCH /api/providers?name=` 會將請求內容與已儲存的供應商合併;除僅更新 `requestPacing` 的請求外,凡是修改 `disabled` 以外欄位的更新,都會在儲存前以同樣方式驗證合併後供應商的路徑,若保留的既有路徑無效則回傳 `400`,且不變更設定。載入設定檔時也適用相同的路徑規則。 API-key 供應商可持有字面值金鑰或環境參考。OAuth 供應商使用由 `ocx login` 填入的憑證存放;訂閱支援的 Claude Code 啟動行為在 [`claudeCode.authMode`](/zh-tw/reference/configuration/server/#claude-codeclaudecode) 下設定。 ### 儲存供應商時會保留什麼 -以既有供應商的名稱呼叫 `POST /api/providers`,會以根據請求建立的列取代已儲存的列。儀表板的新增/編輯表單無法傳送所有欄位,因此儲存時會保留請求省略的部分已儲存欄位。其中八個記錄的是某個上游的行為:`preserveReasoningContentModels`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`。 +以既有供應商的名稱呼叫 `POST /api/providers`,會以根據請求建立的列取代已儲存的列。儀表板的新增/編輯表單無法傳送所有欄位,因此儲存時會保留請求省略的部分已儲存欄位。其中十個記錄的是某個上游的行為:`preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`。 -| 儲存 | 八項設定 | 已儲存的 `apiKeyPool` | +| 儲存 | 十項設定 | 已儲存的 `apiKeyPool` | | --- | --- | --- | | 目的地相同,欄位省略 | 保留已儲存的值,包括明確的 `[]` 或 `false` | 保留 | | 新目的地,欄位省略 | 不保留;可能套用新目的地的登錄檔預設值 | 不保留 | | 請求中傳送了該欄位 | 請求中的值 | 請求中的值 | -目的地指轉接器、base URL(比較協定與主機時不分大小寫,忽略結尾斜線),以及請求有指定時的驗證模式。把供應商移到其他目的地時,描述舊上游的八項設定和為舊上游核發的金鑰池都不會帶過去。儲存絕不會把舊列的其餘部分合併進新列。 +目的地指轉接器、base URL(比較協定與主機時不分大小寫,忽略結尾斜線),以及請求有指定時的驗證模式。把供應商移到其他目的地時,描述舊上游的十項設定和為舊上游核發的金鑰池都不會帶過去。儲存絕不會把舊列的其餘部分合併進新列。 -`PATCH /api/providers?name=` 只修改它指定的欄位,無論目的地為何都保留其他所有已儲存欄位。它接受全部八項設定,`null` 表示清除。對於兩個推理清單,空陣列會作為明確的退出選項儲存,而不會被刪除。 +`PATCH /api/providers?name=` 只修改它指定的欄位,無論目的地為何都保留其他所有已儲存欄位。它接受全部十項設定,`null` 表示清除。對於兩個推理清單,空陣列會作為明確的退出選項儲存,而不會被刪除。 ## 供應商診斷對外安全 diff --git a/scripts/test-layout/layout-adapters.json b/scripts/test-layout/layout-adapters.json index 92aaad7ae5b..bd9578d7067 100644 --- a/scripts/test-layout/layout-adapters.json +++ b/scripts/test-layout/layout-adapters.json @@ -1,4 +1,5 @@ { + "openai-chat-single-system.test.ts": "adapters/openai", "fix-search-frame-shapes.test.ts": "web-search", "fix-search-orphan-source.test.ts": "web-search", "fix-search-unicode-clamp.test.ts": "web-search", diff --git a/scripts/test-layout/layout-config.json b/scripts/test-layout/layout-config.json index d3e75b19051..ab882cf9083 100644 --- a/scripts/test-layout/layout-config.json +++ b/scripts/test-layout/layout-config.json @@ -1,4 +1,5 @@ { + "lmstudio-system-policy.test.ts": "config", "atomic-write-exclusive.test.ts": "config", "config-removed-opencode-free-preserve-failure.test.ts": "config" } diff --git a/src/adapters/openai-chat/messages.ts b/src/adapters/openai-chat/messages.ts index 5b2c899bef5..7b45c4581cf 100644 --- a/src/adapters/openai-chat/messages.ts +++ b/src/adapters/openai-chat/messages.ts @@ -1,6 +1,7 @@ import { isMistralChatTarget, isNativeOpenAIChatTarget, stripBracketedModelSuffix } from "./wire"; import { reasoningDetailSegmentForWire } from "./response-events"; import { translatedChatDeveloperWireRole } from "./developer-role"; +import { singleLeadingSystemMessages } from "./single-system"; import { isVolcengineArkPaygChatTarget } from "./tool-schema"; import { contentPartsToText } from "../image"; import { EMPTY_TOOL_OUTPUT_ANNOTATION, isWhitespaceOnlyTextPartArray } from "../empty-tool-output-annotation"; @@ -172,7 +173,7 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv // Gateways serve them under namespaced ids (LiteLLM's openai/Qwen3.8-27B, #6674), so any // number of leading path segments is accepted; the final segment names the checkpoint. // Orca's GGUF marker must precede any colon quant tag; other Orca variants are unchanged. - const leadingSystemTemplate = !nativeOpenAI && /^(?:[^/]+\/)*(?:flash-next|Qwen3\.8-Flash-Next|Qwen3\.8-27B|qwen3-8-27b(?:-(?:fp8|lora))?|OrcaSAQ-2-Cyber-27B(?=[^:/\s]*GGUF)[^:/\s]*(?::[\w-]+)?)$/i.test(parsed.modelId); + const leadingSystemTemplate = provider.singleLeadingSystemMessage !== true && !nativeOpenAI && /^(?:[^/]+\/)*(?:flash-next|Qwen3\.8-Flash-Next|Qwen3\.8-27B|qwen3-8-27b(?:-(?:fp8|lora))?|OrcaSAQ-2-Cyber-27B(?=[^:/\s]*GGUF)[^:/\s]*(?::[\w-]+)?)$/i.test(parsed.modelId); // Which role a developer message carries, and why the unrecorded state folds, is stated once // in ./developer-role.ts and read from there by the native passthrough as well. Either way the // message keeps the slot it arrived in — only the role changes, never the position. @@ -271,6 +272,15 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv role: msg.role === "developer" && !hasImages && !hasAudio ? developerWireRole : "user", content: chatParts, }; + if (provider.singleLeadingSystemMessage === true && chatMsg.role !== "user") { + // Keep document bytes in a user-compatible slot; only their accompanying text is + // instruction content. Use the same tool barrier so instruction order is retained. + const instruction = { role: developerWireRole, content: chatParts.filter(p => p.type === "text") }; + if (instruction.content.length > 0) { + (pendingToolCalls.length > 0 ? deferredBarrierMessages : out).push(instruction); + } + chatMsg = { role: "user", content: chatParts.filter(p => p.type !== "text") }; + } } if (pendingToolCalls.length > 0) deferredBarrierMessages.push(chatMsg); else out.push(chatMsg); @@ -429,7 +439,7 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv flushPendingToolCalls(); releaseDeferredBarriers(); - return out; + return singleLeadingSystemMessages(out, provider) as unknown[]; } export function safeToolName(name: string | undefined): string { diff --git a/src/adapters/openai-chat/passthrough.ts b/src/adapters/openai-chat/passthrough.ts index fdcedf66d7c..7bffa02c702 100644 --- a/src/adapters/openai-chat/passthrough.ts +++ b/src/adapters/openai-chat/passthrough.ts @@ -3,6 +3,7 @@ import { openAIChatTransport, stripBracketedModelSuffix } from "./wire"; import type { AdapterRequest } from "../base"; import { frameAgentRouterMessages } from "../agentrouter"; import { applyExplicitChatDeveloperRole } from "./developer-role"; +import { singleLeadingSystemMessages } from "./single-system"; import { openRouterProviderPayload, resolveOpenRouterRouting } from "../../providers/openrouter-routing"; import { resolveVercelGatewayRouting, vercelGatewayProviderPayload } from "../../providers/vercel-gateway-routing"; import { fastPolicyForModel } from "../../providers/service-tier"; @@ -77,10 +78,11 @@ export function buildOpenAIChatPassthroughRequest( // and the conversion changes the role of those messages and nothing else, so the position // of every message and every other field survive unchanged. Missing thinking history is // repaired below using the same preserve policy as the translated Chat builder. - messages: applyExplicitChatDeveloperRole( + // The explicit single-system policy can then merge instruction slots. + messages: singleLeadingSystemMessages(applyExplicitChatDeveloperRole( frameAgentRouterMessages(provider.baseUrl, rawBody.messages), provider, - ), + ), provider), stream, }; for (const field of CHAT_PASSTHROUGH_FIELDS) { diff --git a/src/adapters/openai-chat/single-system.ts b/src/adapters/openai-chat/single-system.ts new file mode 100644 index 00000000000..9acfcdec0e8 --- /dev/null +++ b/src/adapters/openai-chat/single-system.ts @@ -0,0 +1,24 @@ +import type { OcxProviderConfig } from "../../types"; + +/** Normalize the outbound copy only; history and non-instruction messages stay intact. */ +export function singleLeadingSystemMessages(messages: unknown, provider: OcxProviderConfig): unknown { + if (provider.singleLeadingSystemMessage !== true || !Array.isArray(messages)) return messages; + const instructions: string[] = []; + const remaining: unknown[] = []; + for (const message of messages) { + if (message === null || typeof message !== "object" || Array.isArray(message) + || (message.role !== "system" && message.role !== "developer")) { + remaining.push(message); + continue; + } + const content: unknown = message.content; + if (typeof content === "string") instructions.push(content); + else if (Array.isArray(content) && content.every(part => part !== null && typeof part === "object" + && part.type === "text" && typeof part.text === "string")) { + instructions.push(content.map(part => part.text).join("")); + } else { + throw new Error("singleLeadingSystemMessage requires text-only system/developer content"); + } + } + return instructions.length > 0 ? [{ role: "system", content: instructions.join("\n\n") }, ...remaining] : messages; +} diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts index 0268b5822ac..87e27fbcba9 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -352,6 +352,7 @@ export const providerConfigSchema = z.object({ requiresPairedResponsesToolResults: z.boolean().optional(), annotateEmptyToolOutputs: z.boolean().optional(), foldDeveloperRoleToSystem: z.boolean().optional(), + singleLeadingSystemMessage: z.boolean().optional(), fastWire: fastWireSchema.nullable().optional(), responseTierAuthoritative: z.boolean().optional(), fastEnabled: z.boolean().optional(), diff --git a/src/providers/derive.ts b/src/providers/derive.ts index 900500d22f1..fd42233adcd 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -61,6 +61,7 @@ export interface DerivedKeyLoginProvider { escapeBuiltinToolNames?: boolean; openaiChatEofTolerance?: boolean; foldDeveloperRoleToSystem?: boolean; + singleLeadingSystemMessage?: boolean; googleMode?: "ai-studio" | "vertex" | "cloud-code-assist"; project?: string; location?: string; @@ -287,6 +288,7 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon ...(entry.chatServiceTier !== undefined ? { chatServiceTier: entry.chatServiceTier } : {}), ...(entry.openaiChatEofTolerance !== undefined ? { openaiChatEofTolerance: entry.openaiChatEofTolerance } : {}), ...(entry.foldDeveloperRoleToSystem !== undefined ? { foldDeveloperRoleToSystem: entry.foldDeveloperRoleToSystem } : {}), + ...(entry.singleLeadingSystemMessage !== undefined ? { singleLeadingSystemMessage: entry.singleLeadingSystemMessage } : {}), ...(entry.responsesPath !== undefined ? { responsesPath: entry.responsesPath } : {}), ...(entry.chatCompletionsPath !== undefined ? { chatCompletionsPath: entry.chatCompletionsPath } : {}), ...(entry.statelessResponses !== undefined ? { statelessResponses: entry.statelessResponses } : {}), @@ -367,6 +369,7 @@ export function deriveKeyLoginMap(): Record { ...(entry.escapeBuiltinToolNames !== undefined ? { escapeBuiltinToolNames: entry.escapeBuiltinToolNames } : {}), ...(entry.openaiChatEofTolerance !== undefined ? { openaiChatEofTolerance: entry.openaiChatEofTolerance } : {}), ...(entry.foldDeveloperRoleToSystem !== undefined ? { foldDeveloperRoleToSystem: entry.foldDeveloperRoleToSystem } : {}), + ...(entry.singleLeadingSystemMessage !== undefined ? { singleLeadingSystemMessage: entry.singleLeadingSystemMessage } : {}), ...(entry.googleMode ? { googleMode: entry.googleMode } : {}), ...(entry.project ? { project: entry.project } : {}), ...(entry.location ? { location: entry.location } : {}), @@ -615,6 +618,9 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig if (prov.foldDeveloperRoleToSystem === undefined && seed.foldDeveloperRoleToSystem !== undefined) { prov.foldDeveloperRoleToSystem = seed.foldDeveloperRoleToSystem; } + if (prov.singleLeadingSystemMessage === undefined && seed.singleLeadingSystemMessage !== undefined) { + prov.singleLeadingSystemMessage = seed.singleLeadingSystemMessage; + } // Fill-only: a hand-edited path must survive, and a config saved before the registry // learned this route still gets backfilled. if (prov.responsesPath === undefined && seed.responsesPath !== undefined) prov.responsesPath = seed.responsesPath; diff --git a/src/providers/model-rename-fields.ts b/src/providers/model-rename-fields.ts index 7638a46d33f..ea003cedccc 100644 --- a/src/providers/model-rename-fields.ts +++ b/src/providers/model-rename-fields.ts @@ -127,6 +127,7 @@ export const PROVIDER_MODEL_RENAME_ROLES = { terminalContinuationGuard: "none", openaiChatEofTolerance: "none", foldDeveloperRoleToSystem: "none", + singleLeadingSystemMessage: "none", promptCacheKey: "none", chatServiceTier: "none", responsesItemIdRepair: "none", diff --git a/src/providers/registry/entries-core.ts b/src/providers/registry/entries-core.ts index 827e11a1105..59c9c655c52 100644 --- a/src/providers/registry/entries-core.ts +++ b/src/providers/registry/entries-core.ts @@ -1242,7 +1242,7 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ { id: "azure-openai", label: "Azure OpenAI", adapter: "azure-openai", baseUrl: "https://{resource}.openai.azure.com/openai", authKind: "key", featured: true, dashboardUrl: "https://portal.azure.com" }, { id: "ollama", label: "Ollama (local)", adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" }, { id: "vllm", label: "vLLM (local)", adapter: "openai-chat", baseUrl: "http://localhost:8000/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" }, - { id: "lm-studio", label: "LM Studio (local)", adapter: "openai-chat", baseUrl: "http://localhost:1234/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — no key needed" }, + { id: "lm-studio", singleLeadingSystemMessage: true, label: "LM Studio (local)", adapter: "openai-chat", baseUrl: "http://localhost:1234/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — no key needed" }, { id: "deepseek", label: "DeepSeek", diff --git a/src/providers/registry/model-ids.ts b/src/providers/registry/model-ids.ts index ed882732f88..d54117c71e3 100644 --- a/src/providers/registry/model-ids.ts +++ b/src/providers/registry/model-ids.ts @@ -123,6 +123,7 @@ export const REGISTRY_FIELD_MODEL_ID_ROLES = { chatServiceTier: NONE, openaiChatEofTolerance: NONE, foldDeveloperRoleToSystem: NONE, + singleLeadingSystemMessage: NONE, autoToolChoiceOnlyModels: NONE, preserveReasoningContentModels: NONE, requiresReasoningPlaceholderModels: NONE, diff --git a/src/providers/registry/types.ts b/src/providers/registry/types.ts index 7bb127022cf..b7eab706aa1 100644 --- a/src/providers/registry/types.ts +++ b/src/providers/registry/types.ts @@ -373,6 +373,8 @@ export interface ProviderRegistryEntry { * `system` instead. Seeds `foldDeveloperRoleToSystem` on the provider config; a user value wins. */ foldDeveloperRoleToSystem?: boolean; + /** Seed the text-only single-leading-system Chat compatibility policy. A user value wins. */ + singleLeadingSystemMessage?: boolean; autoToolChoiceOnlyModels?: string[]; preserveReasoningContentModels?: string[]; requiresReasoningPlaceholderModels?: string[]; @@ -409,6 +411,6 @@ export type ProviderConfigSeed = Pick< | "modelMaxInputTokens" | "defaultMaxOutputTokens" | "modelMaxOutputTokens" | "reasoningEfforts" | "modelReasoningEfforts" | "modelDefaultReasoningEfforts" | "reasoningEffortMap" | "modelReasoningEffortMap" | "reasoningWireFormat" | "noVisionModels" | "noReasoningModels" | "noTemperatureModels" | "noTopPModels" | "noStopModels" | "noPenaltyModels" - | "autoToolChoiceOnlyModels" | "preserveReasoningContentModels" | "requiresReasoningPlaceholderModels" | "reasoningSplitModels" | "inlineThinkTagModels" | "reasoningDetailsModels" | "thinkingToggleModels" | "thinkingBudgetModels" | "escapeBuiltinToolNames" | "openaiChatEofTolerance" | "foldDeveloperRoleToSystem" | "showThinkingSummary" + | "autoToolChoiceOnlyModels" | "preserveReasoningContentModels" | "requiresReasoningPlaceholderModels" | "reasoningSplitModels" | "inlineThinkTagModels" | "reasoningDetailsModels" | "thinkingToggleModels" | "thinkingBudgetModels" | "escapeBuiltinToolNames" | "openaiChatEofTolerance" | "foldDeveloperRoleToSystem" | "singleLeadingSystemMessage" | "showThinkingSummary" | "googleMode" | "project" | "location" | "headers" >; diff --git a/src/providers/resolved-model-policy.ts b/src/providers/resolved-model-policy.ts index 08c1f4a1099..bacdb6dfb97 100644 --- a/src/providers/resolved-model-policy.ts +++ b/src/providers/resolved-model-policy.ts @@ -33,7 +33,7 @@ export type StaticProviderPolicyField = | "modelDefaultReasoningEfforts" | "reasoningEffortMap" | "modelReasoningEffortMap" | "reasoningWireFormat" | "noVisionModels" | "noReasoningModels" | "noTemperatureModels" | "noTopPModels" | "noStopModels" | "noPenaltyModels" | "noJsonSchemaModels" | "noStructuredOutputModels" | "parallelToolCalls" - | "promptCacheKey" | "chatServiceTier" | "openaiChatEofTolerance" | "foldDeveloperRoleToSystem" | "statelessResponses" + | "promptCacheKey" | "chatServiceTier" | "openaiChatEofTolerance" | "foldDeveloperRoleToSystem" | "singleLeadingSystemMessage" | "statelessResponses" | "requiresAdjacentResponsesToolResults" | "requiresPairedResponsesToolResults" | "annotateEmptyToolOutputs" | "fastWire" | "supportsServiceTier" | "modelSupportsServiceTier" | "supportsOpenAiWebSearchToolFields" | "supportsResponsesCustomTools" | "preserveResponsesReasoningContent" | "dropResponsesReasoningItems" @@ -174,7 +174,7 @@ export function resolveModelPolicy(input: ResolveModelPolicyInput): ResolvedMode "responsesPath", "chatCompletionsPath", "keyOptional", "freeTier", "modelSuffixBracketStrip", "defaultModel", "models", "liveModels", "contextWindow", "defaultMaxOutputTokens", "reasoningWireFormat", "parallelToolCalls", "promptCacheKey", - "openaiChatEofTolerance", "foldDeveloperRoleToSystem", "statelessResponses", + "openaiChatEofTolerance", "foldDeveloperRoleToSystem", "singleLeadingSystemMessage", "statelessResponses", "requiresAdjacentResponsesToolResults", "requiresPairedResponsesToolResults", "annotateEmptyToolOutputs", "fastWire", "supportsServiceTier", "supportsOpenAiWebSearchToolFields", "supportsResponsesCustomTools", diff --git a/src/providers/routed-config.ts b/src/providers/routed-config.ts index b3f0d86a378..3867ede0433 100644 --- a/src/providers/routed-config.ts +++ b/src/providers/routed-config.ts @@ -324,6 +324,8 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider ...(provider.foldDeveloperRoleToSystem === undefined && registryEntry.foldDeveloperRoleToSystem !== undefined ? { foldDeveloperRoleToSystem: registryEntry.foldDeveloperRoleToSystem } : {}), + ...(provider.singleLeadingSystemMessage === undefined && registryEntry.singleLeadingSystemMessage !== undefined + ? { singleLeadingSystemMessage: registryEntry.singleLeadingSystemMessage } : {}), ...(provider.reasoningWireFormat === undefined && registryEntry.reasoningWireFormat !== undefined ? { reasoningWireFormat: registryEntry.reasoningWireFormat } : {}), diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 578426c8f31..00383e2b2d3 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -1193,6 +1193,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = { terminalContinuationGuard: "editor", openaiChatEofTolerance: "editor", foldDeveloperRoleToSystem: "editor", + singleLeadingSystemMessage: "editor", promptCacheKey: "editor", chatServiceTier: "editor", responsesItemIdRepair: "editor", diff --git a/src/server/management/provider-overwrite-carry.ts b/src/server/management/provider-overwrite-carry.ts index b0473a4c155..08f3ceff314 100644 --- a/src/server/management/provider-overwrite-carry.ts +++ b/src/server/management/provider-overwrite-carry.ts @@ -53,6 +53,7 @@ export const PROVIDER_COMPAT_CARRY_FIELDS = [ "preserveResponsesReasoningContent", "requiresReasoningPlaceholderModels", "foldDeveloperRoleToSystem", + "singleLeadingSystemMessage", "reasoningWireFormat", "omitReasoningEffortWithToolsModels", ...RETRY_POLICY_FIELDS, @@ -159,6 +160,9 @@ export function providerCompatFieldConfigError(provider: Record } const fold = provider.foldDeveloperRoleToSystem; if (fold !== undefined && typeof fold !== "boolean") return "foldDeveloperRoleToSystem must be a boolean"; + if (provider.singleLeadingSystemMessage !== undefined && typeof provider.singleLeadingSystemMessage !== "boolean") { + return "singleLeadingSystemMessage must be a boolean"; + } const hideRawReasoning = provider.hideRawReasoning; if (hideRawReasoning !== undefined && typeof hideRawReasoning !== "boolean") return "hideRawReasoning must be a boolean"; const preserveResponses = provider.preserveResponsesReasoningContent; @@ -200,6 +204,13 @@ export function applyProviderCompatPatchFields( else return { error: "foldDeveloperRoleToSystem must be a boolean" }; touched = true; } + if (Object.hasOwn(rawBody, "singleLeadingSystemMessage")) { + const value = rawBody.singleLeadingSystemMessage; + if (value === null) delete next.singleLeadingSystemMessage; + else if (typeof value === "boolean") next.singleLeadingSystemMessage = value; + else return { error: "singleLeadingSystemMessage must be a boolean" }; + touched = true; + } if (Object.hasOwn(rawBody, "hideRawReasoning")) { const value = rawBody.hideRawReasoning; if (value === null) delete next.hideRawReasoning; diff --git a/src/types/provider.ts b/src/types/provider.ts index c41256d14b8..61c66de5bc5 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -1011,6 +1011,8 @@ export interface OcxProviderConfig { * keeps its slot in the conversation. */ foldDeveloperRoleToSystem?: boolean; + /** Merge text-only Chat instruction messages into one leading system message. LM Studio seeds true. */ + singleLeadingSystemMessage?: boolean; /** * Opt-in: forward `prompt_cache_key` to the upstream `/chat/completions` body. * OpenAI-specific extension; strict backends (Groq, Cerebras, etc.) reject unknown diff --git a/structure/INDEX.md b/structure/INDEX.md index 766c596c25a..f0a7d4ddbbb 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -102,6 +102,7 @@ Per-vendor contracts and the adapter authority that constructs them. | [`providers/google.md`](providers/google.md) | Gemini thought-text, response parts, thought-signature replay, and adjacency repair. | | [`providers/kiro.md`](providers/kiro.md) | Kiro parallel-tool hints, Responses text controls, and reasoning round-trip. | | [`providers/xai-grok.md`](providers/xai-grok.md) | Grok Build contract parity and hardening. | +| [`providers/lm-studio-chat.md`](providers/lm-studio-chat.md) | Preset-scoped single-leading-system normalization, configuration and editor policy. | | [`providers/chat-compat.md`](providers/chat-compat.md) | Cross-vendor Chat Completions behavior: reasoning, tool results, structured output, parallel tools. | | [`providers/completed-tool-blocks.md`](providers/completed-tool-blocks.md) | Explicit response-only block completion, ordered safe-call delivery and terminal ownership. | | [`providers/mimo-bare-calls.md`](providers/mimo-bare-calls.md) | Standalone MiMo bare-block recovery into declared freeform calls, separate from identified-wrapper reconciliation. | @@ -205,7 +206,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/adapters/base.ts` | [`providers/completed-tool-blocks.md`](providers/completed-tool-blocks.md) | | `src/adapters/dsh-account.ts` | [`providers/dsh-account.md`](providers/dsh-account.md) | | `src/adapters/openai-chat.ts` | [`providers/completed-tool-blocks.md`](providers/completed-tool-blocks.md)
[`providers/mimo-bare-calls.md`](providers/mimo-bare-calls.md) | -| `src/adapters/openai-chat/` | [`providers/mimo-bare-calls.md`](providers/mimo-bare-calls.md) | +| `src/adapters/openai-chat/` | [`providers/lm-studio-chat.md`](providers/lm-studio-chat.md)
[`providers/mimo-bare-calls.md`](providers/mimo-bare-calls.md) | | `src/adapters/openai-chat/completed-tool-blocks.ts` | [`providers/completed-tool-blocks.md`](providers/completed-tool-blocks.md) | | `src/adapters/openai-chat/required-tool-result.ts` | [`providers/chat-required-tool-results.md`](providers/chat-required-tool-results.md) | | `src/adapters/openai-chat/serialized-tool-call-content.ts` | [`providers/completed-tool-blocks.md`](providers/completed-tool-blocks.md) | @@ -291,7 +292,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/companion/` | [`overview.md`](overview.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`companion.md`](companion.md) | | `src/compatibility/` | [`runtime.md`](runtime.md)
[`adapters/compatibility-contracts.md`](adapters/compatibility-contracts.md) | | `src/config.ts` | [`overview.md`](overview.md)
[`runtime.md`](runtime.md)
[`config.md`](config.md)
[`providers/openai-accounts.md`](providers/openai-accounts.md) | -| `src/config/` | [`runtime.md`](runtime.md)
[`config.md`](config.md)
[`transports/native-reasoning-retention.md`](transports/native-reasoning-retention.md)
[`fallback-policy.md`](fallback-policy.md) | +| `src/config/` | [`runtime.md`](runtime.md)
[`config.md`](config.md)
[`transports/native-reasoning-retention.md`](transports/native-reasoning-retention.md)
[`providers/lm-studio-chat.md`](providers/lm-studio-chat.md)
[`fallback-policy.md`](fallback-policy.md) | | `src/config/account-auto-pause-revisions.ts` | [`account-auto-pause.md`](account-auto-pause.md) | | `src/config/live-reconcile.ts` | [`pricing.md`](pricing.md) | | `src/config/persisted-mutation.ts` | [`runtime.md`](runtime.md) | @@ -329,7 +330,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/oauth/workbuddy.ts` | [`providers/workbuddy.md`](providers/workbuddy.md) | | `src/plugins/` | [`ops/plugins.md`](ops/plugins.md) | | `src/protocols/` | [`data-planes/protocol-paths.md`](data-planes/protocol-paths.md) | -| `src/providers/` | [`runtime.md`](runtime.md)
[`subagents.md`](subagents.md)
[`transports/inventory.md`](transports/inventory.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/anthropic-account-pool.md`](providers/anthropic-account-pool.md)
[`providers/xai-grok.md`](providers/xai-grok.md) | +| `src/providers/` | [`runtime.md`](runtime.md)
[`subagents.md`](subagents.md)
[`transports/inventory.md`](transports/inventory.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/anthropic-account-pool.md`](providers/anthropic-account-pool.md)
[`providers/xai-grok.md`](providers/xai-grok.md)
[`providers/lm-studio-chat.md`](providers/lm-studio-chat.md) | | `src/providers/account-quota-disk.ts` | [`quota-snapshots.md`](quota-snapshots.md) | | `src/providers/anthropic-billing-ownership.ts` | [`providers/anthropic-key-billing.md`](providers/anthropic-key-billing.md) | | `src/providers/anthropic-billing-recovery.ts` | [`providers/anthropic-key-billing.md`](providers/anthropic-key-billing.md) | @@ -361,12 +362,12 @@ A source area can be described by more than one doc, because these docs are orga | `src/routing/history/` | [`dashboard-and-usage.md`](dashboard-and-usage.md) | | `src/server/` | [`transports/timeouts.md`](transports/timeouts.md)
[`providers/generic-account-pool.md`](providers/generic-account-pool.md)
[`runtime.md`](runtime.md)
[`codex-prompts.md`](codex-prompts.md)
[`catalog.md`](catalog.md)
[`subagents.md`](subagents.md)
[`transports/byte-accounting.md`](transports/byte-accounting.md)
[`transports/responses.md`](transports/responses.md)
[`transports/native-reasoning-retention.md`](transports/native-reasoning-retention.md)
[`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md)
[`transports/responses-failover.md`](transports/responses-failover.md)
[`transports/responses-combo-boundaries.md`](transports/responses-combo-boundaries.md)
[`transports/policy-fallback.md`](transports/policy-fallback.md)
[`transports/streaming-health.md`](transports/streaming-health.md)
[`transports/inventory.md`](transports/inventory.md)
[`data-planes/images.md`](data-planes/images.md)
[`data-planes/inbound-compat.md`](data-planes/inbound-compat.md)
[`providers/anthropic-progress.md`](providers/anthropic-progress.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/jev-decision.md`](providers/jev-decision.md)
[`providers/xai-grok.md`](providers/xai-grok.md)
[`adapters/registry.md`](adapters/registry.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`provider-completion-diagnostic.md`](provider-completion-diagnostic.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`fallback-policy.md`](fallback-policy.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`clients/claude-desktop-model-visibility.md`](clients/claude-desktop-model-visibility.md)
[`clients/hermes.md`](clients/hermes.md)
[`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | | `src/server/advisor-admission-scope.ts` | [`advisor.md`](advisor.md) | -| `src/server/auth-cors.ts` | [`transports/response-turn-membership.md`](transports/response-turn-membership.md) | +| `src/server/auth-cors.ts` | [`transports/response-turn-membership.md`](transports/response-turn-membership.md)
[`providers/lm-studio-chat.md`](providers/lm-studio-chat.md) | | `src/server/gui-pair-delivery.ts` | [`remote-link.md`](remote-link.md) | | `src/server/index.ts` | [`adapters/compatibility-lab.md`](adapters/compatibility-lab.md) | | `src/server/index/link-management.ts` | [`remote-link.md`](remote-link.md) | | `src/server/inference/advisor-diagnostics.ts` | [`advisor.md`](advisor.md) | -| `src/server/management/` | [`providers/oauth-code-display-login.md`](providers/oauth-code-display-login.md)
[`clients/export-model-names.md`](clients/export-model-names.md) | +| `src/server/management/` | [`providers/oauth-code-display-login.md`](providers/oauth-code-display-login.md)
[`providers/lm-studio-chat.md`](providers/lm-studio-chat.md)
[`clients/export-model-names.md`](clients/export-model-names.md) | | `src/server/management/anthropic-pool-settings.ts` | [`providers/anthropic-account-pool.md`](providers/anthropic-account-pool.md) | | `src/server/management/codex-cli-update-routes.ts` | [`codex-cli-update.md`](codex-cli-update.md) | | `src/server/management/companion-routes.ts` | [`desktop-shell.md`](desktop-shell.md) | @@ -412,7 +413,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/storage/` | [`runtime.md`](runtime.md) | | `src/tray/` | [`runtime.md`](runtime.md) | | `src/types.ts` | [`runtime.md`](runtime.md)
[`config.md`](config.md) | -| `src/types/` | [`providers/generic-account-pool.md`](providers/generic-account-pool.md)
[`transports/native-reasoning-retention.md`](transports/native-reasoning-retention.md)
[`providers/anthropic-progress.md`](providers/anthropic-progress.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`fallback-policy.md`](fallback-policy.md) | +| `src/types/` | [`providers/generic-account-pool.md`](providers/generic-account-pool.md)
[`transports/native-reasoning-retention.md`](transports/native-reasoning-retention.md)
[`providers/anthropic-progress.md`](providers/anthropic-progress.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/lm-studio-chat.md`](providers/lm-studio-chat.md)
[`fallback-policy.md`](fallback-policy.md) | | `src/types/account-auto-pause.ts` | [`account-auto-pause.md`](account-auto-pause.md) | | `src/types/config.ts` | [`transports/timeouts.md`](transports/timeouts.md) | | `src/types/provider.ts` | [`pricing.md`](pricing.md) | diff --git a/structure/config.md b/structure/config.md index ce3166854ee..4bdc77bfc9b 100644 --- a/structure/config.md +++ b/structure/config.md @@ -1,5 +1,5 @@ # Config Surface -See [local timeout policy and OpenCode export](transports/timeouts.md). Pasted GCP JSON persists only as `gcp-sa:` references in one pool transaction in `src/providers/api-keys.ts`; post-commit deletion follows [Vertex credential markers](providers/google.md#vertex-credential-markers). +See [local timeout policy and OpenCode export](transports/timeouts.md). Pasted GCP JSON persists only as `gcp-sa:` references in one pool transaction in `src/providers/api-keys.ts`; post-commit deletion follows [Vertex credential markers](providers/google.md#vertex-credential-markers). See [LM Studio Chat instruction configuration](providers/lm-studio-chat.md). `src/config/schema/leaf-validators.ts` accepts only boolean `apiKeys[].allowMeteredComboTargets`; malformed entries are dropped rather than promoted. `src/types/config.ts` and combo validation define optional boolean target `metered`, normalized false when omitted, and optional per-combo `allowMeteredTargets` (default false, literal boolean validation shared by config schema and management API). Explicit consent is required under the [combo runtime contract](runtime.md#request-local-target-compatibility). Model-price overrides follow the [pricing contract](pricing.md). Quota activation reuses the existing next-reset fields without adding a polling configuration key. See the [quota activation contract](providers/openai-tiers.md#public-provider-contract). `src/types/config.ts` defines `anthropicAccountPool.routes` as ordered model rules. `src/oauth/anthropic-model-routes.ts` validates bounded names, full case-sensitive globs and stored account IDs; `src/config/diagnostics.ts` rejects malformed candidate writes. Load retains a malformed hand edit so unrelated providers survive, while the enabled Anthropic selector refuses it locally before dispatch. Saved rules remain inert when the pool is disabled; affinity is process-local. Request logs use only the rule’s 1-based `route:#` position, never its configured name. `src/cli/hub-disable.ts` removes interdependent hub/loopback/role/hostname settings through `mutatePersistedConfig` and `validateConfigCandidate` in one commit. Top-level deletion intent uses `src/config/rebase-provenance.ts` and authoritative disk provenance in `src/config/live-reconcile.ts` to preserve removed hub bindings through later live saves, `src/config/live-topology.ts` keeps an independent active-topology baseline and merges desired next-start settings into a separate persistence projection, retaining actual socket identity and live hub/client topology during guarded saves and OAuth disk adoption. Client connections and runtimeRole remain one topology unit through connect/disconnect, including fresh disk-only connections. Disk additions/value updates and deletions all survive repeated saves without changing active topology. `src/config/api-key-reconcile.ts` merges key rows by stable ID under the existing save lock; either side's revocation defeats stale edits while disjoint names and rotations merge; unchanged live key membership/order adopts persisted ordering. `tests/config/hub-disable-live-save.test.ts` exercises disable followed by live key creation, rename, rotation, unrelated settings and repeated saves. Saved API keys have no immutable enrollment marker; explicit exact-ID revocation preserves all unselected keys. The WorkBuddy AI account row pins its adapter and origin under the [WorkBuddy contract](providers/workbuddy.md). JEV `decisionQuotaSignals` and partial `decisionQuotaTiers` use the shared validator and sparse preserve/clear/strategy-switch contract in [loaded advisory quota](providers/jev-decision.md#loaded-advisory-quota); the opt-in JEV `sessionPin` follows the same contract under [session pin](providers/jev-decision.md#session-pin). Native reasoning retention follows [its optional configuration contract](transports/native-reasoning-retention.md#configuration-and-management). Native function-result injection follows [the separate opt-in control contract](transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 5b07124fd30..898df8af8d1 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -1,5 +1,5 @@ # GUI And Management API -Provider PUT, POST and DELETE in `src/server/management/provider-routes.ts` run [GCP credential cleanup](providers/google.md#vertex-credential-markers) only after saving. `src/server/management/provider-gcp-credential-routes.ts` owns credential-input rejection responses and the locked deletion callback, which snapshots live and valid durable markers under the persistence lock. Rejected mutations and failed saves preserve secrets; other persisted references retain them. Route response shapes remain unchanged. +Provider PUT, POST and DELETE in `src/server/management/provider-routes.ts` run [GCP credential cleanup](providers/google.md#vertex-credential-markers) only after saving. `src/server/management/provider-gcp-credential-routes.ts` owns credential-input rejection responses and the locked deletion callback, which snapshots live and valid durable markers under the persistence lock. Rejected mutations and failed saves preserve secrets; other persisted references retain them. Route response shapes remain unchanged. See [LM Studio Chat instruction editor policy](providers/lm-studio-chat.md). `GET/PUT /api/codex-auth/preserve-signed-in` and CLI `account preserve-signed-in` expose the [signed-in quota policy](providers/openai-signed-in-preservation.md); strict boolean writes can explicitly release affected bindings/pins for the next request after persistence. The GUI retains its existing account controls. `src/server/management/combo-routes.ts` retains omitted `metered: true` metadata by trimmed provider/model identity during dashboard-shaped saves. Explicit false clears the flag; false defaults are omitted from persisted targets. Omitted combo `allowMeteredTargets` survives saves and renames; explicit false clears it and stays sparse. The CLI exposes `--allow-metered-targets on|off`, and receipts preserve the boolean. Key and loopback operator consent follow the [runtime contract](runtime.md#request-local-target-compatibility). Model-price APIs and receipt validation follow the [pricing contract](pricing.md). Anthropic OAuth account DTOs include `autoSwitchThresholdOverride` (integer or null), `autoSwitchThreshold` (pool default) and `effectiveAutoSwitchThreshold`. The dedicated `PUT /api/oauth/accounts/auto-switch` accepts `{ provider: "anthropic", accountId, threshold }`; diff --git a/structure/manifest.json b/structure/manifest.json index 693fa4cdd2d..d3f5cb7d26e 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -740,6 +740,13 @@ "src/server/" ] }, + { + "path": "providers/lm-studio-chat.md", + "tier": 4, + "title": "LM Studio Chat instructions", + "scope": "Preset-scoped single-leading-system normalization, configuration and editor policy.", + "documents": ["src/adapters/openai-chat/", "src/providers/", "src/config/", "src/types/", "src/server/management/", "src/server/auth-cors.ts"] + }, { "path": "providers/chat-compat.md", "tier": 4, diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 987565dcafb..e16bd632e26 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -1,6 +1,6 @@ # Providers And Adapters -Reasoning support, thinking on/off control, real reasoning strengths and the thinking default are independent optional boolean axes in `ReasoningCapabilities`, flattened into `ModelCapabilities` and `CatalogModel`. `src/providers/reasoning-capabilities.ts` resolves fixed destination registry facts beneath operator declarations matched by case-insensitive model ID (without suffix or family matching); absent axes stay unknown and explicit false wins. `thinkingToggleModels` declares wire syntax, including adaptive-only protocols, and never implies an independent on/off capability. Legacy effort declarations infer real strength support only when their effective mapped values include genuine strength levels; enabled/disabled/adaptive aliases retain their existing controls without establishing strength support. Inferred strength support does not opt a legacy toggle serializer into sending an additional effort field; that requires an explicit model capability declaration. Legacy declarations still apply unless `supportsReasoningEffort: false`; an empty effort list does not disable the thinking toggle. `noReasoningModels` suppresses routed reasoning controls; native Codex forwarding keeps its snapshot-owned effort clamp. Captured model policy resolves the same axes without rereading the live registry. [Config validation and merge](config.md#explicit-per-model-capability-declarations) preserve each boolean independently. Native Claude family rules in `src/adapters/anthropic-model-contract.ts` outrank generic toggle serialization: adaptive thinking, the Sonnet 5.5 `between_tools` floor, supported explicit disable and budget/output constraints remain authoritative. +Reasoning support, thinking on/off control, real reasoning strengths and the thinking default are independent optional boolean axes in `ReasoningCapabilities`, flattened into `ModelCapabilities` and `CatalogModel`. `src/providers/reasoning-capabilities.ts` resolves fixed destination registry facts beneath operator declarations matched by case-insensitive model ID (without suffix or family matching); absent axes stay unknown and explicit false wins. `thinkingToggleModels` declares wire syntax, including adaptive-only protocols, and never implies an independent on/off capability. Legacy effort declarations infer real strength support only when their effective mapped values include genuine strength levels; enabled/disabled/adaptive aliases retain their existing controls without establishing strength support. Inferred strength support does not opt a legacy toggle serializer into sending an additional effort field; that requires an explicit model capability declaration. Legacy declarations still apply unless `supportsReasoningEffort: false`; an empty effort list does not disable the thinking toggle. `noReasoningModels` suppresses routed reasoning controls; native Codex forwarding keeps its snapshot-owned effort clamp. Captured model policy resolves the same axes without rereading the live registry. [Config validation and merge](config.md#explicit-per-model-capability-declarations) preserve each boolean independently. Native Claude family rules in `src/adapters/anthropic-model-contract.ts` outrank generic toggle serialization: adaptive thinking, the Sonnet 5.5 `between_tools` floor, supported explicit disable and budget/output constraints remain authoritative. See [LM Studio Chat instruction defaults](providers/lm-studio-chat.md). Public MiMo V2.6 is a registry instance with reasoning and toggle support, no real effort strengths, and thinking enabled by default. `src/providers/mimo-thinking.ts` supplies those facts; `thinkingToggleModels` separately declares its existing `thinking.type` wire protocol. Client compatibility alone projects the switch to legacy `none/high` choices. Internal metadata contains no synthetic MiMo effort ladder or high default. Public model rows expose optional `supports_reasoning`, `supports_thinking_toggle`, `supports_reasoning_effort` and `default_thinking_enabled` flags; chained provider discovery reads those flags back without inferring false from absence. Config hints rederive cached projections from their original discovered reasoning metadata. Free, token-plan and third-party MiMo routes keep their existing contracts. Canonical key-auth Mistral Chat at `https://api.mistral.ai/v1` defaults to a patient bounded same-key 429 policy (six replays, ten-second fallback) in `src/providers/key-failover.ts`; explicit retry settings/opt-out win. The Mistral preset forwards supplied prompt-cache keys unless explicitly disabled. `src/providers/key-failover.ts` and transport selection use the same canonical-destination check, including custom provider names. `src/providers/mistral-transport.ts` shares explicit Retry-After deadlines across calls by actual credential digest and exact model, with bounded memory/waiters and cancellation. Without Retry-After, only a real 429 with validated exhausted minute capacity or a measured query charge above remaining but within the full token allowance activates a conservative 60-second recovery hold. This local policy does not claim the server reset instant. One probe gates concurrent recovery callers; a successful probe clears recovery unless a newer refusal intervened. Healthy traffic never predicts future query/cache cost; unknown, zero-entitlement, exhausted-monthly and above-full-allowance evidence does not invent a minute reset. Oversized Retry-After stops same-key replay rather than shortening the server delay: a same-target retry hold in `src/lib/upstream-retry.ts` is checked only by the same-key 429 loops, so key rotation and the original upstream status, body and Retry-After stay available. Either `requestPacing.enabled: false` or `retryOn429.respectRetryAfter: false` disables the shared hold. `tests/providers/mistral-recovery.test.ts` covers GLM/Large 4, destination/key/model isolation, absent headers, cache affinity, streamed/buffered replay, cancellation and send ceilings. There is no mid-stream replay or automatic compaction change. diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 1f856aa6d22..a675d58bba0 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -12,7 +12,7 @@ other providers whose models happen to share a name fragment. ## Chronological in-conversation instructions `src/adapters/openai-chat/messages.ts` keeps text-only timeline developer messages in their -original slots on every Chat destination and model. A later reminder does not rewrite the +original slots unless the [LM Studio instruction policy](lm-studio-chat.md) applies. A later reminder does not rewrite the leading system prompt or move ahead of earlier turns; pending tool results still precede deferred reminders. This behavior previously covered only OpenCode Go and `deepseek-v4.1-flash`. The base system prompt, vision conversion, native OpenAI developer @@ -23,7 +23,7 @@ The role that slot carries is a separate decision, and the setting that makes it `foldDeveloperRoleToSystem` unset sends `system`, `true` sends `system`, and `false` sends `developer`. Unset means nothing has been recorded about the destination; `true` records an upstream that rejects the role; `false` records one that accepts it. The message keeps the slot it -arrived in in every case — only the role changes, never the position. The unrecorded state is the +arrived in unless the [LM Studio instruction policy](lm-studio-chat.md) applies. The unrecorded state is the folded one because a destination that rejects the role answers `400 role 'developer' is not allowed` and the turn never starts, a failure that lands outside this repository where no test can reach it. The role was previously decided by testing the base URL host diff --git a/structure/providers/lm-studio-chat.md b/structure/providers/lm-studio-chat.md new file mode 100644 index 00000000000..9f20a687ecb --- /dev/null +++ b/structure/providers/lm-studio-chat.md @@ -0,0 +1,32 @@ +# LM Studio Chat instructions + +`singleLeadingSystemMessage: true` consolidates text-only Chat system/developer messages +into one leading system message, separated by two newlines. The LM Studio local registry +entry seeds this policy; explicit `false` overrides it. The persisted value survives +provider renaming and endpoint changes; an old renamed row needs an explicit opt-in. +`src/adapters/openai-chat/single-system.ts` owns the outbound-copy normalization used by +both translated Chat and direct Chat passthrough. It preserves instruction order in the +serialized IR (base system prompts, then timeline developer instructions), or native Chat +message order, and preserves non-instruction message/tool-call/result order and content. +The Responses parser already collects system items into the IR system prompt; this policy +does not recover their original positions relative to developer items. Hoisting later +instructions changes their chronological scope and may invalidate prompt-cache prefixes. +The explicit policy takes precedence over the model-name-based late-developer-to-user +mapping. Other presets and unflagged custom endpoints keep their existing behavior. +Non-text native instruction parts fail explicitly rather than being discarded; translated +developer image/audio messages retain the existing user-compatible mapping. +Translated developer documents retain file parts in a user message at the tool-barrier-adjusted +timeline slot; accompanying text joins the leading instruction block in IR order. +Tests: `tests/adapters/openai/openai-chat-single-system.test.ts` and +`tests/config/lmstudio-system-policy.test.ts`. + +Configuration uses the optional boolean in `src/types/provider.ts` and +`src/config/schema/leaf-validators.ts`. `src/providers/registry/entries-core.ts` seeds +true only for LM Studio; `src/providers/derive.ts`, `src/providers/resolved-model-policy.ts` +and `src/providers/routed-config.ts` preserve explicit false during backfill/resolution. + +The field is classified as `editor` in the restricted `src/server/auth-cors.ts` provider +policy table. `src/server/management/provider-overwrite-carry.ts` validates POST booleans, +preserves omitted true/false on same-destination saves, and accepts boolean/null on PATCH. +A destination-changing POST requires resubmitting the flag; it does not inherit the previous +upstream compatibility. The field grants no credential or network access. diff --git a/tests/adapters/openai/openai-chat-single-system.test.ts b/tests/adapters/openai/openai-chat-single-system.test.ts new file mode 100644 index 00000000000..eb83fe8124e --- /dev/null +++ b/tests/adapters/openai/openai-chat-single-system.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, test } from "bun:test"; +import { createOpenAIChatAdapter, buildOpenAIChatPassthroughRequest } from "../../../src/adapters/openai-chat"; +import { parseRequest } from "../../../src/responses/parser"; +import { getProviderRegistryEntry, PROVIDER_REGISTRY } from "../../../src/providers/registry"; +import { enrichProviderFromRegistry, providerConfigSeed } from "../../../src/providers/derive"; +import type { OcxProviderConfig } from "../../../src/types"; + +const model = "qwen3.8-27b-uncensored@q4_k_m"; +const enabled = { adapter: "openai-chat", baseUrl: "http://localhost:1234/v1", singleLeadingSystemMessage: true } as OcxProviderConfig; +const input = { + model, + instructions: "Base instructions", + input: [ + { role: "system", content: "Additional system instructions" }, + { role: "developer", content: [{ type: "input_text", text: "First developer" }] }, + { role: "user", content: "First user" }, + { role: "developer", content: "Late developer" }, + { type: "function_call", call_id: "call_1", name: "lookup", arguments: "{}" }, + { type: "function_call_output", call_id: "call_1", output: "tool result" }, + { role: "user", content: "Continue" }, + ], +}; +function translated(provider: OcxProviderConfig, modelId = model) { + const parsed = parseRequest({ ...input, model: modelId }); + const before = structuredClone(parsed); + const wire = JSON.parse(createOpenAIChatAdapter(provider).buildRequest(parsed).body); + expect(parsed).toEqual(before); + return wire.messages as Record[]; +} +function native(messages: unknown[], provider = enabled) { + const raw = { messages }; + const before = structuredClone(raw); + const wire = JSON.parse(buildOpenAIChatPassthroughRequest(provider, raw, model, false).body); + expect(raw).toEqual(before); + return wire.messages as Record[]; +} + +describe("single leading Chat system message (#7761)", () => { + test("LM Studio seed works after renaming the provider and changing to a LAN endpoint", () => { + const provider = { ...providerConfigSeed(getProviderRegistryEntry("lm-studio")!), baseUrl: "http://192.168.1.42:4321/v1" }; + enrichProviderFromRegistry("renamed-lan", provider); + const messages = translated(provider); + expect(messages.map(m => m.role)).toEqual(["system", "user", "assistant", "tool", "user"]); + expect(messages[0].content).toBe("Base instructions\n\nAdditional system instructions\n\nFirst developer\n\nLate developer"); + expect(messages[1].content).toBe("First user"); + expect(messages[2].tool_calls).toEqual([{ id: "call_1", type: "function", function: { name: "lookup", arguments: "{}" } }]); + expect(messages[3]).toEqual({ role: "tool", tool_call_id: "call_1", content: "tool result" }); + expect(messages[4].content).toBe("Continue"); + }); + + test.each([model, "Qwen3.8-27B", "unrelated-custom-model"])("explicit policy wins over the model selector: %s", modelId => { + const messages = translated({ ...enabled, foldDeveloperRoleToSystem: false }, modelId); + expect(messages.filter(m => m.role === "system")).toHaveLength(1); + expect(messages.some(m => m.role === "developer")).toBe(false); + expect(messages[0].content).toBe("Base instructions\n\nAdditional system instructions\n\nFirst developer\n\nLate developer"); + expect(messages[1].content).toBe("First user"); + }); + + test("other presets and unflagged endpoints retain chronological messages", () => { + for (const entry of PROVIDER_REGISTRY) { + if (entry.id !== "lm-studio") expect((entry as unknown as Record).singleLeadingSystemMessage).toBeUndefined(); + } + const messages = translated({ adapter: "openai-chat", baseUrl: enabled.baseUrl }); + expect(messages.map(m => m.role)).toEqual(["system", "system", "user", "system", "assistant", "tool", "user"]); + expect(messages[1].content).toBe("First developer"); + expect(messages[3].content).toBe("Late developer"); + }); + + test("false overrides the LM Studio default and survives enrichment", () => { + const provider = { ...providerConfigSeed(getProviderRegistryEntry("lm-studio")!), singleLeadingSystemMessage: false } as OcxProviderConfig; + enrichProviderFromRegistry("lm-studio", provider); + expect(translated(provider).map(m => m.role)).toEqual(["system", "system", "user", "system", "assistant", "tool", "user"]); + }); + + test("native Chat preserves instruction order, text-part boundaries, and every non-instruction message", () => { + const messages = [ + { role: "developer", content: [{ type: "text", text: "A" }, { type: "text", text: "B" }] }, + { role: "user", content: [{ type: "image_url", image_url: { url: "https://example.invalid/image.png" } }] }, + { role: "system", content: "C" }, + { role: "assistant", content: null, tool_calls: [{ id: "call_1", type: "function", function: { name: "lookup", arguments: "{}" } }] }, + { role: "tool", tool_call_id: "call_1", content: "result" }, + { role: "developer", content: "D" }, + ]; + expect(native(messages)).toEqual([{ role: "system", content: "AB\n\nC\n\nD" }, messages[1], messages[3], messages[4]]); + }); + + test("no instructions does not invent an empty system message", () => { + const messages = [{ role: "user", content: "Hello" }]; + expect(native(messages)).toEqual(messages); + }); + + test("a late instruction becomes the first message without trimming whitespace", () => { + expect(native([{ role: "user", content: "Hello" }, { role: "developer", content: " Keep spaces\n" }])) + .toEqual([{ role: "system", content: " Keep spaces\n" }, { role: "user", content: "Hello" }]); + }); + + test("unsupported instruction content fails explicitly rather than losing it", () => { + expect(() => native([{ role: "system", content: [{ type: "image_url", image_url: { url: "https://example.invalid/image.png" } }] }])) + .toThrow("singleLeadingSystemMessage requires text-only system/developer content"); + expect(() => native([{ role: "developer", content: [{ type: "file", file: { file_data: "data:application/pdf;base64,JVBERi0xLjQ=" } }] }])) + .toThrow("singleLeadingSystemMessage requires text-only system/developer content"); + }); + + test.each([true, false])("translated developer documents retain bytes and merge text (fold=%s)", fold => { + const file = { type: "input_file", filename: "spec.pdf", file_data: "data:application/pdf;base64,JVBERi0xLjQ=" }; + const parsed = parseRequest({ model, instructions: "Base", input: [ + { role: "user", content: "First" }, + { type: "function_call", call_id: "call_doc", name: "lookup", arguments: "{}" }, + { role: "developer", content: [{ type: "input_text", text: "Read " }, file, { type: "input_text", text: "carefully" }] }, + { role: "developer", content: "Later" }, + { type: "function_call_output", call_id: "call_doc", output: "result" }, + { role: "developer", content: [file] }, + { role: "user", content: "Continue" }, + ] }); + const before = structuredClone(parsed); + const messages = JSON.parse(createOpenAIChatAdapter({ ...enabled, foldDeveloperRoleToSystem: fold }).buildRequest(parsed).body).messages; + expect(parsed).toEqual(before); + expect(messages).toEqual([ + { role: "system", content: "Base\n\nRead carefully\n\nLater" }, + { role: "user", content: "First" }, + { role: "assistant", content: "", tool_calls: [{ id: "call_doc", type: "function", function: { name: "lookup", arguments: "{}" } }] }, + { role: "tool", tool_call_id: "call_doc", content: "result" }, + { role: "user", content: [{ type: "file", file: { filename: file.filename, file_data: file.file_data } }] }, + { role: "user", content: [{ type: "file", file: { filename: file.filename, file_data: file.file_data } }] }, + { role: "user", content: "Continue" }, + ]); + }); +}); diff --git a/tests/ci-workflows/docs-developer-role-policy.test.ts b/tests/ci-workflows/docs-developer-role-policy.test.ts index 1ac65bce687..0e666e37d78 100644 --- a/tests/ci-workflows/docs-developer-role-policy.test.ts +++ b/tests/ci-workflows/docs-developer-role-policy.test.ts @@ -21,6 +21,7 @@ import { describe, expect, test } from "bun:test"; import { createOpenAIChatAdapter } from "../../src/adapters/openai-chat"; import type { OcxParsedRequest, OcxProviderConfig } from "../../src/types"; import { repoPath } from "../helpers/repo-root"; +import { PROVIDER_COMPAT_CARRY_FIELDS } from "../../src/server/management/provider-overwrite-carry"; const INSTRUCTION = "Answer in exactly one sentence."; @@ -133,6 +134,19 @@ describe("the documented developer-role policy is derived from the Chat wire", ( }); describe("translated pages do not contradict the English source", () => { + for (const path of [ENGLISH_REFERENCE, ...LOCALES.map(referencePage)]) { + test(path + " lists every carried compatibility setting and qualifies instruction placement", async () => { + const doc = await read(path); + const saveParagraph = doc.split(/\n\s*\n/).find(block => block.includes("`POST /api/providers`") && block.includes("`preserveReasoningContentModels`")); + expect(saveParagraph).toBeDefined(); + const fields = [...saveParagraph!.matchAll(/`([^`]+)`/g)].map(match => match[1]!); + const carried = new Set(PROVIDER_COMPAT_CARRY_FIELDS); + expect(fields.filter(field => carried.has(field)).sort()) + .toEqual([...PROVIDER_COMPAT_CARRY_FIELDS].sort()); + expect(await referenceRow(path)).toMatch(/singleLeadingSystemMessage|single-leading-system compatibility/); + }); + } + test("the English pages carry roles worth comparing", async () => { expect(roleSpans(await referenceRow(ENGLISH_REFERENCE)).length).toBeGreaterThan(0); expect(roleSpans(await guideParagraph(ENGLISH_GUIDE)).length).toBeGreaterThan(0); diff --git a/tests/config/lmstudio-system-policy.test.ts b/tests/config/lmstudio-system-policy.test.ts new file mode 100644 index 00000000000..1efddd1d23d --- /dev/null +++ b/tests/config/lmstudio-system-policy.test.ts @@ -0,0 +1,53 @@ +import { expect, test } from "bun:test"; +import { providerConfigSchema } from "../../src/config/schema/leaf-validators"; +import { enrichProviderFromRegistry, providerConfigSeed } from "../../src/providers/derive"; +import { getProviderRegistryEntry } from "../../src/providers/registry"; +import { routedProviderConfig } from "../../src/providers/routed-config"; +import { parseProviderEditorConfigDTO, providerEditorConfigDTO } from "../../src/server/auth-cors"; +import { applyProviderCompatPatchFields, carryProviderCompatFields, providerCompatFieldConfigError, sampleProviderOverwrite } from "../../src/server/management/provider-overwrite-carry"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; + +const local: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "http://localhost:1234/v1", authMode: "local" }; + +test("LM Studio backfills old canonical configs at load and route time, while false wins", () => { + const loaded = { ...local }; + enrichProviderFromRegistry("lm-studio", loaded); + expect(loaded.singleLeadingSystemMessage).toBe(true); + expect(routedProviderConfig("lm-studio", local).singleLeadingSystemMessage).toBe(true); + expect(routedProviderConfig("lm-studio", { ...local, singleLeadingSystemMessage: false }).singleLeadingSystemMessage).toBe(false); + expect(providerConfigSeed(getProviderRegistryEntry("ollama")!).singleLeadingSystemMessage).toBeUndefined(); +}); + +test.each([true, false])("schema, editor DTO and PATCH retain boolean %s", value => { + const provider = { ...local, singleLeadingSystemMessage: value }; + expect(providerConfigSchema.parse(provider).singleLeadingSystemMessage).toBe(value); + const editor = providerEditorConfigDTO({ defaultProvider: "local", providers: { local: provider } } as unknown as OcxConfig); + expect(editor.providers.local.singleLeadingSystemMessage).toBe(value); + expect(parseProviderEditorConfigDTO(editor).ok).toBe(true); + const next = { ...local }; + expect(applyProviderCompatPatchFields({ singleLeadingSystemMessage: value }, next)).toEqual({ touched: true }); + expect(next.singleLeadingSystemMessage).toBe(value); +}); + +test("invalid schema and management values are rejected; null clears PATCH", () => { + for (const value of ["true", 1, {}, []]) { + expect(providerConfigSchema.safeParse({ ...local, singleLeadingSystemMessage: value }).success).toBe(false); + expect(providerCompatFieldConfigError({ singleLeadingSystemMessage: value })).toBe("singleLeadingSystemMessage must be a boolean"); + expect(applyProviderCompatPatchFields({ singleLeadingSystemMessage: value }, { ...local })).toEqual({ error: "singleLeadingSystemMessage must be a boolean" }); + } + const next = { ...local, singleLeadingSystemMessage: false }; + expect(applyProviderCompatPatchFields({ singleLeadingSystemMessage: null }, next)).toEqual({ touched: true }); + expect(next.singleLeadingSystemMessage).toBeUndefined(); +}); + +test("a same-destination save keeps an explicit false over a newly seeded true", () => { + const stored = { ...local, singleLeadingSystemMessage: false }; + const candidate = { ...local }; + const sample = sampleProviderOverwrite(candidate); + enrichProviderFromRegistry("lm-studio", candidate); + carryProviderCompatFields(candidate, stored, sample); + expect(candidate.singleLeadingSystemMessage).toBe(false); + const moved = { ...local, baseUrl: "http://other.lan:4321/v1" }; + carryProviderCompatFields(moved, stored, sampleProviderOverwrite(moved)); + expect(moved.singleLeadingSystemMessage).toBeUndefined(); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index f8aa6f55867..9fdf40115d7 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,4 @@ -{ "update-reclaim-authorizer.test.ts": "update", "update-capability-policy.test.ts": "update", "update-service-home.test.ts": "update", "update-service-home-policy.test.ts": "update", "update-home-recovery.test.ts": "update", "update-recovery-authority.test.ts": "update", "update-recovery-boundaries.test.ts": "update", "update-home-launcher.test.ts": "update", "update-home-worker.test.ts": "update", "update-mutation-boundaries.test.ts": "update", "update-mutation-guard.test.ts": "update", "update-guarded-ops.test.ts": "update", "update-effect-authority.test.ts": "update", "update-windows-wrapper-authority.test.ts": "update", "update-mutation-scanner.test.ts": "update", "refreshed-upstream-deadlines.test.ts": "responses", "local-upstream-deadlines.test.ts": "responses", "service-desktop-recovery.test.ts": "service", "provider-input-modalities.test.ts": "providers", "turn-membership.test.ts": "responses", "turn-membership-ingress.test.ts": "responses", "workbuddy-quota.test.ts": "providers", "workbuddy-login.test.ts": "oauth", "workbuddy-adapter.test.ts": "adapters", "workbuddy-provider.test.ts": "providers", "link-tunnel-history.test.ts": "clients", "link-readiness.test.ts": "clients", "link-diagnostics.test.ts": "clients", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "responses-phase-inference.test.ts": "responses", +{ "lmstudio-system-policy.test.ts": "config", "openai-chat-single-system.test.ts": "adapters/openai", "update-reclaim-authorizer.test.ts": "update", "update-capability-policy.test.ts": "update", "update-service-home.test.ts": "update", "update-service-home-policy.test.ts": "update", "update-home-recovery.test.ts": "update", "update-recovery-authority.test.ts": "update", "update-recovery-boundaries.test.ts": "update", "update-home-launcher.test.ts": "update", "update-home-worker.test.ts": "update", "update-mutation-boundaries.test.ts": "update", "update-mutation-guard.test.ts": "update", "update-guarded-ops.test.ts": "update", "update-effect-authority.test.ts": "update", "update-windows-wrapper-authority.test.ts": "update", "update-mutation-scanner.test.ts": "update", "refreshed-upstream-deadlines.test.ts": "responses", "local-upstream-deadlines.test.ts": "responses", "service-desktop-recovery.test.ts": "service", "provider-input-modalities.test.ts": "providers", "turn-membership.test.ts": "responses", "turn-membership-ingress.test.ts": "responses", "workbuddy-quota.test.ts": "providers", "workbuddy-login.test.ts": "oauth", "workbuddy-adapter.test.ts": "adapters", "workbuddy-provider.test.ts": "providers", "link-tunnel-history.test.ts": "clients", "link-readiness.test.ts": "clients", "link-diagnostics.test.ts": "clients", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "responses-phase-inference.test.ts": "responses", "windows-config-root-async-writers.test.ts": "windows", "windows-atomic-write-acl-rearm.test.ts": "windows", "t3code-client.test.ts": "clients", diff --git a/tests/server/management-provider-compat-carry.test.ts b/tests/server/management-provider-compat-carry.test.ts index e60f0de323b..b8ce5a41b7d 100644 --- a/tests/server/management-provider-compat-carry.test.ts +++ b/tests/server/management-provider-compat-carry.test.ts @@ -112,6 +112,12 @@ interface CarryCase { } const CASES: Record = { + singleLeadingSystemMessage: { + seed: { singleLeadingSystemMessage: true }, + probe: provider => nativeBody(provider, { + messages: [{ role: "user", content: "hi" }, { role: "developer", content: "be brief" }], + }).messages, + }, preserveResponsesReasoningContent: { seed: { preserveResponsesReasoningContent: true }, probe: provider => { From 49775ecaf9dc2004b27c90bb53aaec0c6f03a508 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 29 Oct 2026 13:44:28 +0900 Subject: [PATCH 21/84] feat(catalog): opt in to omitting disabled native models (#7889) * feat(catalog): opt in to omitting disabled native models * fix(catalog): advance omitted native templates through upgrades * fix(catalog): pass the upgrade rule into the native template instead of importing metadata native-template.ts importing shouldUpgradeToUpstreamEntry from metadata.ts closed a load-time cycle metadata -> bundled -> native-template that the import-cycle ratchet rejects. Both writers already import the rule and now pass it in. * fix(catalog): address review on disabled-native omission - Leave control-plane rows in place when an omission pass drops nothing, so the opt-in flag cannot change a catalog it did not filter. - Drop the copied "descriptions below" sentence from seven locale tables. - Add the catalog reference page to the Configuration sidebar. - Map src/types/config.ts to structure/config.md and regenerate INDEX.md. - Register the new test on the inline layout line so layout.json keeps its line count under the file-size cap. --------- Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> --- docs-site/astro.config.mjs | 1 + .../docs/fr/guides/codex-app-models.md | 2 + .../fr/reference/configuration/providers.md | 1 + .../content/docs/guides/codex-app-models.md | 12 +- .../docs/ja/guides/codex-app-models.md | 2 + .../ja/reference/configuration/providers.md | 1 + .../docs/ko/guides/codex-app-models.md | 2 + .../ko/reference/configuration/providers.md | 1 + .../docs/reference/configuration/catalog.md | 32 ++ .../docs/reference/configuration/providers.md | 2 +- .../docs/ru/guides/codex-app-models.md | 2 + .../ru/reference/configuration/providers.md | 1 + .../docs/tr/guides/codex-app-models.md | 2 + .../tr/reference/configuration/providers.md | 1 + .../docs/zh-cn/guides/codex-app-models.md | 2 + .../reference/configuration/providers.md | 1 + .../docs/zh-tw/guides/codex-app-models.md | 2 + .../reference/configuration/providers.md | 1 + scripts/test-layout/layout.json | 2 +- src/codex/catalog/bundled.ts | 13 +- src/codex/catalog/metadata.ts | 9 +- src/codex/catalog/native-omission.ts | 28 ++ src/codex/catalog/native-template.ts | 58 ++++ src/codex/catalog/parsing.ts | 32 +- src/codex/catalog/retained-sync.ts | 20 +- src/codex/convergence.ts | 16 +- src/config/schema/config-schema.ts | 2 + src/server/codex-model-list.ts | 3 +- src/types/config.ts | 2 + structure/INDEX.md | 2 +- structure/catalog.md | 6 +- structure/config.md | 2 +- structure/manifest.json | 3 +- .../catalog-omit-disabled-native.test.ts.snap | 19 ++ .../catalog-omit-disabled-native.test.ts | 301 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 36 files changed, 529 insertions(+), 58 deletions(-) create mode 100644 docs-site/src/content/docs/reference/configuration/catalog.md create mode 100644 src/codex/catalog/native-omission.ts create mode 100644 src/codex/catalog/native-template.ts create mode 100644 tests/codex-integration/__snapshots__/catalog-omit-disabled-native.test.ts.snap create mode 100644 tests/codex-integration/catalog-omit-disabled-native.test.ts diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index d4fb8509e9f..e81e8b1fb7e 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -154,6 +154,7 @@ export default defineConfig({ items: [ { label: "Overview", translations: { fr: "Vue d’ensemble", ko: "개요", "zh-CN": "概览", "zh-TW": "概覽", ru: "Обзор", ja: "概要", tr: "Genel Bakış" }, slug: "reference/configuration" }, { label: "Providers", translations: { fr: "Fournisseurs", ko: "프로바이더", "zh-CN": "提供商", "zh-TW": "供應商", ru: "Провайдеры", ja: "プロバイダー", tr: "Sağlayıcılar" }, slug: "reference/configuration/providers" }, + { label: "Catalog", translations: { fr: "Catalogue", ko: "카탈로그", "zh-CN": "目录", "zh-TW": "目錄", ru: "Каталог", ja: "カタログ", tr: "Katalog" }, slug: "reference/configuration/catalog" }, { label: "Routing", translations: { fr: "Routage", ko: "라우팅", "zh-CN": "路由", "zh-TW": "路由", ru: "Маршрутизация", ja: "ルーティング", tr: "Yönlendirme" }, slug: "reference/configuration/routing" }, { label: "Agents", translations: { fr: "Agents", ko: "에이전트", "zh-CN": "代理", "zh-TW": "代理", ru: "Агенты", ja: "エージェント", tr: "Ajanlar" }, slug: "reference/configuration/agents" }, { label: "Advisor", translations: { fr: "Conseiller", ko: "어드바이저", "zh-CN": "顾问", "zh-TW": "顧問", ru: "Консультант", ja: "アドバイザー", tr: "Danışman" }, slug: "reference/configuration/advisor" }, diff --git a/docs-site/src/content/docs/fr/guides/codex-app-models.md b/docs-site/src/content/docs/fr/guides/codex-app-models.md index 8e420079af1..e6a653f337d 100644 --- a/docs-site/src/content/docs/fr/guides/codex-app-models.md +++ b/docs-site/src/content/docs/fr/guides/codex-app-models.md @@ -3,6 +3,8 @@ title: Sélecteur de modèles de Codex App description: Comment les modèles opencodex apparaissent dans Codex App, Codex CLI et Codex TUI par l'intermédiaire du catalogue Codex partagé. --- +Par défaut : `false`. Avec `"omitDisabledNativeModels": true` dans `config.json`, puis `ocx sync`, les lignes natives désactivées sont omises du catalogue, du cache et de la découverte HTTP au format Codex. Le modèle d’héritage natif validé avant omission conserve les métadonnées des lignes routées. Les requêtes directes gardent leur routage habituel. Les descriptions de masquage ci-dessous concernent le comportement par défaut. + opencodex ne modifie pas Codex App. Il écrit la même configuration et le même catalogue de modèles Codex que ceux utilisés par Codex CLI et TUI. Le serveur d'application lit cet état partagé, mais certaines versions de Codex Desktop appliquent dans le moteur de rendu une seconde liste d'autorisation distante et peuvent diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index 4463dcc9a25..85650d22e13 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -44,6 +44,7 @@ Après une inscription ou une connexion OAuth dans l’interface, une boîte de | `providers` | `Record` | — | Mappage du nom du fournisseur avec la configuration du fournisseur. | | `openaiProviderTierVersion?` | `2` | défini par la migration | Marque la projection OpenAI prenant en compte les options uniques comme terminée. | | `disabledModels?` | `string[]` | — | Modèles masqués du catalogue de Codex et de `/v1/models`, mais non bloqués des appels proxy directs. Un identifiant acheminé est supprimé des listes. Un identifiant natif qualifié de compte masque uniquement cette ligne de sélecteur ; un identifiant GPT natif nu masque la ligne nue et chaque ligne de sélecteur de compte pour ce modèle. La page Modèles du tableau de bord expose uniquement les lignes natives routées et nues ; utilisez ce champ de configuration directement pour masquer une ligne qualifiée par le sélecteur. | +| `omitDisabledNativeModels?` | `boolean` | `false` | Par défaut : `false`. Avec `"omitDisabledNativeModels": true` dans `config.json`, puis `ocx sync`, les lignes natives désactivées sont omises du catalogue, du cache et de la découverte HTTP au format Codex. Le modèle d’héritage natif validé avant omission conserve les métadonnées des lignes routées. Les requêtes directes gardent leur routage habituel. | | `providerContextCaps?` | `Record` | `{}` | Limites de contexte actives par fournisseur. Les fenêtres ordinaires sont réduites ; les modèles natifs prenant en charge une fenêtre longue peuvent être étendus uniquement jusqu’à leur propre plafond pris en charge. | | `providerContextCapValues?` | `Record` | `{}` | Dernières limites sélectionnées par fournisseur, conservées après désactivation. Ces valeurs n’activent aucun plafond. Une valeur active est prioritaire sur une valeur mémorisée. | | `contextCapValue?` | `number` | `350000` | Valeur par défaut lors de la première activation. Les activations suivantes restaurent la sélection du fournisseur. Modifier la valeur globale avec `setAll: true` ne modifie que les plafonds actifs ; `setAll: true` sans valeur active tous les fournisseurs configurés à la valeur globale actuelle. | diff --git a/docs-site/src/content/docs/guides/codex-app-models.md b/docs-site/src/content/docs/guides/codex-app-models.md index 4da93918113..2f5805d7b49 100644 --- a/docs-site/src/content/docs/guides/codex-app-models.md +++ b/docs-site/src/content/docs/guides/codex-app-models.md @@ -135,7 +135,7 @@ for the command, disable-key semantics, and safety constraints. ### What this means for a disabled native model -Without a native alias configured, disabling a bare native GPT slug does not remove it from the +By default, without a native alias configured, disabling a bare native GPT slug does not remove it from the catalog. The row stays with `visibility: "hide"`, which `/v1/models` and the dashboard both honour — they stop listing the model — while Desktop, under the policy above, can keep showing it. So the model can still be picked in Desktop after you disabled it, and the surfaces disagree about whether @@ -147,9 +147,13 @@ runs on the model you disabled, or fails on whatever path that id resolves to. E outcome is not the one the toggle implies. The row is retained deliberately. It holds the real upstream metadata, so re-enabling the model -restores that metadata instead of a synthesized guess. When you need the row gone outright rather -than hidden, configure a `nativeAlias` combo: while one exists, disabled bare native rows are -omitted from the effective catalog entirely. +restores that metadata instead of a synthesized guess. To omit disabled native rows without a +native alias, set `"omitDisabledNativeModels": true` in `config.json` and run `ocx sync`. +The option defaults off. Sync, the generated cache, and Codex-shaped HTTP discovery omit the +disabled rows while retaining a separately validated native inheritance template, so routed +rows keep their input and tool metadata even when the only template row was disabled. +Direct requests to a disabled native id still route normally. A `nativeAlias` combo also keeps +its existing omission behavior independently of this flag. If Codex's `config.toml` pins a root `model` that this proxy does not expose — a disabled model among them — every new session starts on a model opencodex does not serve. `ocx doctor` reports diff --git a/docs-site/src/content/docs/ja/guides/codex-app-models.md b/docs-site/src/content/docs/ja/guides/codex-app-models.md index ef99c820f3a..daa8fc7eabf 100644 --- a/docs-site/src/content/docs/ja/guides/codex-app-models.md +++ b/docs-site/src/content/docs/ja/guides/codex-app-models.md @@ -3,6 +3,8 @@ title: Codex App モデル ピッカー description: opencodex モデルが、共有 Codex カタログを通じて Codex App、Codex CLI、Codex TUI にどのように表示されるか。 --- +既定値は `false` です。`config.json` に `"omitDisabledNativeModels": true` を設定して `ocx sync` を実行すると、無効な native 行を catalog、cache、Codex 形式の HTTP モデル一覧から除外します。除外前に検証した継承テンプレートを保持するため routed 行のメタデータは変わりません。直接リクエストのルーティングも変わりません。以下の hide の説明は既定の設定に適用されます。 + opencodex は Codex アプリにパッチを適用しません。Codex CLI/TUI と同じ Codex 設定とモデル カタログを書き込みます。app-server はその共有状態を読み取りますが、一部の Codex Desktop リリースは renderer 側で追加の remote allowlist を適用し、routed row を picker から除外する diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index 9096e4aa794..616cb1e164e 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -43,6 +43,7 @@ GUI で登録または OAuth ログインが完了すると、Models ページ | `providers` | `Record` | — |プロバイダー名からプロバイダー設定へのマップ。 | | `openaiProviderTierVersion?` | `2` |移行によって設定される |単一のオプション対応 OpenAI プロジェクションを完了としてマークします。 | | `disabledModels?` | `string[]` | — | Codex catalog と `/v1/models` から非表示にする model。直接の proxy 呼び出しはブロックしません。routed id は一覧から削除されます。account-qualified native id は該当する selector row だけを非表示にし、bare native GPT id は bare row とその model の全 account-selector row を非表示にします。Models ページに表示されるのは bare native 行と routed 行だけです。selector-qualified 行を 1 つだけ非表示にするには、この設定フィールドを直接編集してください。 | +| `omitDisabledNativeModels?` | `boolean` | `false` | 既定値は `false` です。`config.json` に `"omitDisabledNativeModels": true` を設定して `ocx sync` を実行すると、無効な native 行を catalog、cache、Codex 形式の HTTP モデル一覧から除外します。除外前に検証した継承テンプレートを保持するため routed 行のメタデータは変わりません。直接リクエストのルーティングも変わりません。 | | `providerContextCaps?` | `Record` | `{}` | プロバイダーごとの有効なコンテキスト上限。通常のウィンドウは縮小されます。長いウィンドウに対応したネイティブモデルは、そのモデルが対応する上限まで拡張できます。 | | `providerContextCapValues?` | `Record` | `{}` | プロバイダーごとに最後に選択した上限。無効にしても保持され、この値だけで上限が有効になることはありません。有効な値が保存済みの値より優先されます。 | | `contextCapValue?` | `number` | `350000` | 初回の有効化で使う既定値。再び有効にすると、そのプロバイダーの選択値を復元します。`setAll: true` とともにグローバル値を変更すると、有効な上限だけを更新します。値を指定せずに `setAll: true` を送ると、設定済みの全プロバイダーの上限を現在のグローバル値で有効にします。 | diff --git a/docs-site/src/content/docs/ko/guides/codex-app-models.md b/docs-site/src/content/docs/ko/guides/codex-app-models.md index 40c716687da..f06017e0268 100644 --- a/docs-site/src/content/docs/ko/guides/codex-app-models.md +++ b/docs-site/src/content/docs/ko/guides/codex-app-models.md @@ -3,6 +3,8 @@ title: Codex App 모델 선택기 description: 공유 Codex 카탈로그를 통해 opencodex 모델이 Codex App, Codex CLI, Codex TUI에 표시되는 방식. --- +기본값은 `false`입니다. `config.json`에 `"omitDisabledNativeModels": true`를 설정하고 `ocx sync`를 실행하면 비활성화한 native 행을 catalog와 cache, Codex 형식의 HTTP 모델 목록에서 제외합니다. 제외 전 검증한 상속 템플릿을 보존하므로 routed 행의 메타데이터는 유지되며 직접 요청의 라우팅은 바뀌지 않습니다. 아래 hide 설명은 기본 설정에 해당합니다. + opencodex는 Codex App을 직접 고치지 않습니다. Codex CLI/TUI와 같은 Codex 설정과 모델 카탈로그를 씁니다. app-server는 이 공유 상태를 읽지만, 일부 Codex Desktop 릴리스는 renderer에서 추가 remote allowlist를 적용해 routed row를 picker에서 제거할 수 있습니다. 명시적 `nativeAlias: true` combo가 diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index c08b21c9eba..b454ff2022d 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -43,6 +43,7 @@ GUI에서 등록이나 OAuth 로그인을 마치면 Models 페이지로 이동 | `providers` | `Record` | — | 공급자 이름을 공급자 설정에 매핑합니다. | | `openaiProviderTierVersion?` | `2` | 마이그레이션으로 설정됨 | 옵션을 인식하는 단일 OpenAI 투영이 완료되었음을 표시합니다. | | `disabledModels?` | `string[]` | — | Codex catalog와 `/v1/models`에서는 숨기지만 직접 proxy 호출은 차단하지 않습니다. routed id는 목록에서 제거됩니다. account-qualified native id는 해당 selector row만 숨기고, bare native GPT id는 bare row와 그 model의 모든 account-selector row를 숨깁니다. Models 페이지에는 bare native 행과 routed 행만 표시됩니다. selector-qualified 행 하나만 숨기려면 이 설정 필드에 직접 추가하세요. | +| `omitDisabledNativeModels?` | `boolean` | `false` | 기본값은 `false`입니다. `config.json`에 `"omitDisabledNativeModels": true`를 설정하고 `ocx sync`를 실행하면 비활성화한 native 행을 catalog와 cache, Codex 형식의 HTTP 모델 목록에서 제외합니다. 제외 전 검증한 상속 템플릿을 보존하므로 routed 행의 메타데이터는 유지되며 직접 요청의 라우팅은 바뀌지 않습니다. | | `providerContextCaps?` | `Record` | `{}` | 공급자별 활성 컨텍스트 상한입니다. 일반 윈도는 줄어들며, 장문 윈도를 지원하는 네이티브 모델은 해당 모델의 지원 상한까지만 확장할 수 있습니다. | | `providerContextCapValues?` | `Record` | `{}` | 공급자별로 마지막에 선택한 상한입니다. 꺼도 선택값이 남으며, 저장된 값만으로는 상한이 활성화되지 않습니다. 활성 값이 저장된 선택값보다 우선합니다. | | `contextCapValue?` | `number` | `350000` | 처음 켤 때 쓰는 기본값입니다. 다시 켜면 공급자별 선택값을 복원합니다. `setAll: true`와 함께 전역 값을 바꾸면 활성 상한만 갱신합니다. 값 없이 `setAll: true`를 보내면 설정된 모든 공급자의 상한을 현재 전역 값으로 켭니다. | diff --git a/docs-site/src/content/docs/reference/configuration/catalog.md b/docs-site/src/content/docs/reference/configuration/catalog.md new file mode 100644 index 00000000000..360cf70e9cf --- /dev/null +++ b/docs-site/src/content/docs/reference/configuration/catalog.md @@ -0,0 +1,32 @@ +--- +title: Catalog options +description: Optional native model omission in the generated Codex catalog. +--- + +## Omit disabled native models + +`omitDisabledNativeModels` is an optional boolean in `config.json`, defaulting to `false`. +Enable it and run `ocx sync` to omit explicitly disabled native rows without requiring a +native-alias combo: + +```json +{ + "omitDisabledNativeModels": true, + "disabledModels": ["gpt-5.5"] +} +``` + +The setting applies to sync, convergence, the generated models cache, and Codex-shaped +`/v1/models?client_version=…` discovery. A bare native disable omits that model and all its +account-selector rows; an account-qualified disable omits only that selector row. +The native inheritance template is validated and captured before omission, so routed +rows keep the same input and tool metadata, including when the only template row is disabled. + +Absent, false, or malformed values preserve the default hidden-row behavior. +Existing native-alias omission remains independent of this flag. This setting controls +discovery only: direct requests to disabled native ids still route normally, and unchanged +sync output keeps its existing no-op write and mtime behavior. + +See [Codex App models](/guides/codex-app-models/#what-this-means-for-a-disabled-native-model) +for Desktop visibility and [provider configuration](/reference/configuration/providers/) +for `disabledModels`. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index adada880699..f77855f4cc4 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -107,7 +107,7 @@ separate. Full request URLs such as `/api/v1/responses` are not provider base UR | --- | --- | --- | --- | | `providers` | `Record` | — | Map of provider name to provider config. | | `openaiProviderTierVersion?` | `2` | set by migration | Marks the single option-aware OpenAI projection as complete. | -| `disabledModels?` | `string[]` | — | Models hidden from Codex's catalog and `/v1/models`, but not blocked from direct proxy calls. A routed id is removed from listings. An account-qualified native id hides only that selector row; a bare native GPT id hides the bare row and every account-selector row for that model. The dashboard Models page exposes only routed and bare native rows; use this configuration field directly to hide one selector-qualified row. | +| `disabledModels?` | `string[]` | — | Models hidden from Codex's catalog and `/v1/models`, but not blocked from direct proxy calls. A routed id is removed from listings. An account-qualified native id hides only that selector row; a bare native GPT id hides the bare row and every account-selector row for that model. The dashboard Models page exposes only routed and bare native rows; use this configuration field directly to hide one selector-qualified row. Set [`omitDisabledNativeModels`](/reference/configuration/catalog/) to `true` to omit disabled native rows instead; it defaults off. | | `providerContextCaps?` | `Record` | `{}` | Active provider context limits. Ordinary windows are lowered; native models with a supported long window can expand only up to their own supported ceiling. | | `providerContextCapValues?` | `Record` | `{}` | Last selected provider limits, retained while disabled. These values do not activate a cap. An enabled value takes precedence over a remembered value. | | `contextCapValue?` | `number` | `350000` | Default used on first enable. A later enable restores the selected provider value. Updating the global value with `setAll: true` changes enabled caps only; `setAll: true` without a value enables all configured providers at the current global value. | diff --git a/docs-site/src/content/docs/ru/guides/codex-app-models.md b/docs-site/src/content/docs/ru/guides/codex-app-models.md index 04a4381f8ee..e1f95d377a7 100644 --- a/docs-site/src/content/docs/ru/guides/codex-app-models.md +++ b/docs-site/src/content/docs/ru/guides/codex-app-models.md @@ -3,6 +3,8 @@ title: Селектор моделей Codex App description: Как модели opencodex появляются в Codex App, Codex CLI и Codex TUI через общий каталог Codex. --- +По умолчанию `false`. Установите `"omitDisabledNativeModels": true` в `config.json` и выполните `ocx sync`, чтобы исключить отключённые native-строки из каталога, кеша и HTTP-списка моделей в формате Codex. Проверенный до исключения шаблон сохраняет метаданные routed-строк. Маршрутизация прямых запросов не меняется. Описание скрытия ниже относится к настройкам по умолчанию. + opencodex не патчит Codex App. Он записывает ту же конфигурацию Codex и тот же каталог моделей, которыми пользуются Codex CLI/TUI. App-server читает это общее состояние, но некоторые версии Codex Desktop применяют в renderer дополнительный remote allowlist и могут удалить routed-строки diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index b84570c51bf..f8ef70c7a55 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -44,6 +44,7 @@ ocx models provider openrouter on | `providers` | `Record` | — | Map вида provider name → provider config. | | `openaiProviderTierVersion?` | `2` | set by migration | Отмечает, что единая projection OpenAI с учётом режима уже завершена. | | `disabledModels?` | `string[]` | — | Модели, скрытые из каталога Codex и `/v1/models`, но не заблокированные для прямых вызовов прокси. Routed-id удаляются из списков. Account-qualified native-id скрывает только строку этого селектора; bare native GPT-id скрывает bare-строку и строки всех селекторов аккаунтов для этой модели. Страница Models показывает только bare native- и routed-строки; чтобы скрыть одну selector-qualified строку, задайте это поле конфигурации напрямую. | +| `omitDisabledNativeModels?` | `boolean` | `false` | По умолчанию `false`. Установите `"omitDisabledNativeModels": true` в `config.json` и выполните `ocx sync`, чтобы исключить отключённые native-строки из каталога, кеша и HTTP-списка моделей в формате Codex. Проверенный до исключения шаблон сохраняет метаданные routed-строк. Маршрутизация прямых запросов не меняется. | | `providerContextCaps?` | `Record` | `{}` | Активные лимиты контекста по провайдерам. Обычные окна уменьшаются; нативные модели с поддержкой длинного контекста могут расширять окно только до собственного поддерживаемого предела. | | `providerContextCapValues?` | `Record` | `{}` | Последние выбранные лимиты по провайдерам, сохраняемые после отключения. Эти значения сами по себе не включают ограничение. Активное значение имеет приоритет над сохранённым. | | `contextCapValue?` | `number` | `350000` | Значение по умолчанию при первом включении. При повторном включении восстанавливается выбранное значение провайдера. Изменение глобального значения с `setAll: true` обновляет только активные лимиты; `setAll: true` без значения включает лимиты всех настроенных провайдеров с текущим глобальным значением. | diff --git a/docs-site/src/content/docs/tr/guides/codex-app-models.md b/docs-site/src/content/docs/tr/guides/codex-app-models.md index 6cf1ba16587..3cb5b1380c7 100644 --- a/docs-site/src/content/docs/tr/guides/codex-app-models.md +++ b/docs-site/src/content/docs/tr/guides/codex-app-models.md @@ -3,6 +3,8 @@ title: Codex App model seçicisi description: opencodex modellerinin paylaşılan Codex kataloğu aracılığıyla Codex App, Codex CLI ve Codex TUI içinde nasıl göründüğü. --- +Varsayılan `false`. `config.json` içinde `"omitDisabledNativeModels": true` ayarlayıp `ocx sync` çalıştırarak devre dışı native satırları katalogdan, önbellekten ve Codex biçimli HTTP model listesinden çıkarabilirsiniz. Çıkarmadan önce doğrulanan kalıtım şablonu routed satırların meta verilerini korur. Doğrudan isteklerin yönlendirmesi değişmez. Aşağıdaki gizleme açıklamaları varsayılan ayarlar içindir. + opencodex, Codex App'i yamalamaz. Codex CLI/TUI'nin kullandığı aynı Codex yapılandırmasını ve model kataloğunu yazar. Uygulama sunucusu (app-server) bu paylaşılan durumu okur, ancak bazı Codex Desktop sürümleri oluşturucuda diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index e4d42b15be9..c13c0c45116 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -44,6 +44,7 @@ Arayüzde kayıt veya OAuth girişi tamamlanınca Models sayfasını açan bir b | `providers` | `Record` | — | Sağlayıcı adından sağlayıcı yapılandırmasına eşleme haritası. | | `openaiProviderTierVersion?` | `2` | geçiş tarafından ayarlanır | Tek seçenek duyarlı OpenAI projeksiyonunu tamamlandı olarak işaretler. | | `disabledModels?` | `string[]` | — | Codex kataloğundan ve `/v1/models` listesinden gizlenen, ancak doğrudan proxy çağrılarından engellenmeyen modeller. Yönlendirilen bir kimlik listelerden kaldırılır. Hesap nitelikli bir yerel kimlik yalnızca o seçici satırını gizler; yalın bir yerel GPT kimliği, yalın satırı ve o model için her hesap seçici satırını gizler. Kontrol paneli Modeller sayfası yalnızca yönlendirilen ve yalın yerel satırları gösterir; seçici nitelikli bir satırı gizlemek için doğrudan bu yapılandırma alanını kullanın. | +| `omitDisabledNativeModels?` | `boolean` | `false` | Varsayılan `false`. `config.json` içinde `"omitDisabledNativeModels": true` ayarlayıp `ocx sync` çalıştırarak devre dışı native satırları katalogdan, önbellekten ve Codex biçimli HTTP model listesinden çıkarabilirsiniz. Çıkarmadan önce doğrulanan kalıtım şablonu routed satırların meta verilerini korur. Doğrudan isteklerin yönlendirmesi değişmez. | | `providerContextCaps?` | `Record` | `{}` | Sağlayıcı başına etkin bağlam sınırları. Normal pencereler küçültülür; uzun pencereyi destekleyen yerel modeller yalnızca kendi desteklenen üst sınırlarına kadar genişletilebilir. | | `providerContextCapValues?` | `Record` | `{}` | Sağlayıcı başına son seçilen sınırlar; devre dışı bırakıldığında da saklanır. Bu değerler tek başına sınırı etkinleştirmez. Etkin değer, saklanan değerden önceliklidir. | | `contextCapValue?` | `number` | `350000` | İlk etkinleştirmede kullanılan varsayılan değer. Sonraki etkinleştirmelerde sağlayıcının seçimi geri yüklenir. Genel değeri `setAll: true` ile güncellemek yalnızca etkin sınırları değiştirir; değer olmadan `setAll: true`, yapılandırılmış tüm sağlayıcıların sınırlarını geçerli genel değerle etkinleştirir. | diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md b/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md index 3d2f9b0b3ff..fcfa28dffac 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md @@ -3,6 +3,8 @@ title: Codex App 模型选择器 description: opencodex 中的模型如何通过共享 Codex 目录出现在 Codex App、Codex CLI 和 Codex TUI 中。 --- +默认值为 `false`。在 `config.json` 中设置 `"omitDisabledNativeModels": true` 并运行 `ocx sync`,即可从目录、缓存和 Codex 格式的 HTTP 模型列表中移除已禁用的 native 行。移除前经过验证的继承模板会单独保留,因此 routed 行的元数据不变,直接请求的路由也不变。下文的 hide 说明适用于默认设置。 + opencodex 不会修改 Codex App。它会写入 Codex CLI/TUI 使用的同一套 Codex 配置和模型目录。 app-server 会读取这份共享状态,但部分 Codex Desktop 版本还会在 renderer 中应用第二层远程 allowlist,因此仍可能从选择器里删掉路由模型。 diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index 38f051f9596..2703729ead1 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -43,6 +43,7 @@ ocx models provider openrouter on | `providers` | `Record` | — | 提供者名称到提供者配置的映射。 | | `openaiProviderTierVersion?` | `2` | 由迁移设置 | 标记单一、可感知选项的 OpenAI 投影已完成。 | | `disabledModels?` | `string[]` | — | 从 Codex catalog 和 `/v1/models` 中隐藏、但不阻止直接 proxy 调用的 model。routed id 会从列表中移除。account-qualified native id 只隐藏对应 selector row;bare native GPT id 会隐藏 bare row 以及该 model 的所有 account-selector row。Models 页面只显示裸原生行和路由行;若只隐藏一个 selector-qualified 行,请直接设置此配置字段。 | +| `omitDisabledNativeModels?` | `boolean` | `false` | 默认值为 `false`。在 `config.json` 中设置 `"omitDisabledNativeModels": true` 并运行 `ocx sync`,即可从目录、缓存和 Codex 格式的 HTTP 模型列表中移除已禁用的 native 行。移除前经过验证的继承模板会单独保留,因此 routed 行的元数据不变,直接请求的路由也不变。 | | `providerContextCaps?` | `Record` | `{}` | 按提供商设置的有效上下文上限。普通窗口只能缩小;支持长窗口的原生模型可以扩展到该模型支持的上限。 | | `providerContextCapValues?` | `Record` | `{}` | 各提供商最后选择的上限,关闭后仍保留。仅保存这些值不会启用上限。有效值优先于保存的选择值。 | | `contextCapValue?` | `number` | `350000` | 首次开启时使用的默认值。再次开启时恢复该提供商的选择值。修改全局值时附带 `setAll: true` 只会更新已开启的上限;不带值的 `setAll: true` 会按当前全局值开启所有已配置提供商的上限。 | diff --git a/docs-site/src/content/docs/zh-tw/guides/codex-app-models.md b/docs-site/src/content/docs/zh-tw/guides/codex-app-models.md index 2b9eb7c20aa..bccd018f77b 100644 --- a/docs-site/src/content/docs/zh-tw/guides/codex-app-models.md +++ b/docs-site/src/content/docs/zh-tw/guides/codex-app-models.md @@ -3,6 +3,8 @@ title: Codex App 模型選擇器 description: opencodex 模型如何透過共享 Codex 目錄出現在 Codex App、Codex CLI 和 Codex TUI 中。 --- +預設值為 `false`。在 `config.json` 中設定 `"omitDisabledNativeModels": true` 並執行 `ocx sync`,即可從目錄、快取與 Codex 格式的 HTTP 模型清單中移除已停用的 native 列。移除前經過驗證的繼承範本會另外保留,因此 routed 列的中繼資料不變,直接請求的路由也不變。下文的 hide 說明適用於預設設定。 + opencodex 不會修改 Codex App。它會寫入 Codex CLI/TUI 所使用的同一套 Codex 設定和模型目錄。 Codex 的 app-server 會讀取這份共享狀態,但部分 Codex Desktop 版本會在 renderer 套用第二層 遠端模型 allowlist,仍可能把已路由的列從選擇器中移除。 diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index ee4ed249009..cc8b93b3735 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -43,6 +43,7 @@ ocx models provider openrouter on | `providers` | `Record` | — | 供應商名稱到供應商設定的映射。 | | `openaiProviderTierVersion?` | `2` | 由遷移設定 | 標記單一選項感知的 OpenAI projection 已完成。 | | `disabledModels?` | `string[]` | — | 對 Codex 目錄與 `/v1/models` 隱藏的模型,但不阻擋直接代理呼叫。路由 id 從清單中移除;裸原生 GPT id 取得 `visibility: "hide"`。 | +| `omitDisabledNativeModels?` | `boolean` | `false` | 預設值為 `false`。在 `config.json` 中設定 `"omitDisabledNativeModels": true` 並執行 `ocx sync`,即可從目錄、快取與 Codex 格式的 HTTP 模型清單中移除已停用的 native 列。移除前經過驗證的繼承範本會另外保留,因此 routed 列的中繼資料不變,直接請求的路由也不變。 | | `providerContextCaps?` | `Record` | `{}` | 各供應商目前生效的上下文上限。一般視窗只能縮小;支援長視窗的原生模型可以擴展到該模型支援的上限。 | | `providerContextCapValues?` | `Record` | `{}` | 各供應商最後選擇的上限,停用後仍保留。僅儲存這些值不會啟用上限。生效中的值優先於儲存的選擇值。 | | `contextCapValue?` | `number` | `350000` | 首次啟用時使用的預設值。再次啟用時恢復該供應商的選擇值。修改全域值時附帶 `setAll: true` 只會更新已啟用的上限;不帶值的 `setAll: true` 會以目前全域值啟用所有已設定供應商的上限。 | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 8d82b182e72..da5c4a46306 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -2,7 +2,7 @@ "version": 1, "root": "tests", "explicitShards": ["layout-clients.json", "layout-codex-collision.json", "layout-agent-assertion.json", "layout-chat.json", "layout-replay.json", "layout-adapters.json", "layout-jev.json", "layout-workbuddy.json", "layout-combos.json", "layout-credit-renewal.json", "layout-server-inbound.json", "layout-providers.json", "layout-responses.json", "layout-exec-boundary.json", "layout-persistent-dashboard.json", "layout-start-ownership.json", "layout-config.json", "layout-kiro-usage.json"], - "explicit": { "service-desktop-recovery.test.ts": "service", "provider-input-modalities.test.ts": "providers", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "codex-provider-routing.test.ts": "codex-integration", "config-native-reasoning-retention.test.ts": "config", "server-native-reasoning-retention-merge.test.ts": "server", "server-native-reasoning-retention.test.ts": "server", "responses-retained-reasoning-stream-recovery.test.ts": "responses", "openai-native-hosted-search-history.test.ts": "adapters/openai", "responses-native-compact-search-history.test.ts": "responses", "config-hosted-search-projection.test.ts": "config", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "cli-provider-list-active.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "bridge-terminal-refusal.test.ts": "adapters", "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", "combo-terminal-refusal.test.ts": "routing", "server-terminal-refusal-wiring.test.ts": "server", "incomplete-reason-normalization.test.ts": "responses", "advisor-retention.test.ts": "advisor", "responses-native-key-cooldown.test.ts": "responses", "responses-run-turn-key-readmission.test.ts": "responses", "qoder-spawn-readmission.test.ts": "adapters", "api-key-selection-cooldown.test.ts": "providers", "api-key-auto-pause-distribution.test.ts": "providers", "explicit-key-pause-oauth-boundary.test.ts": "providers", "server-chat-native-key-selection.test.ts": "server", "compact-key-selection.test.ts": "responses", "messages-native-key-selection.test.ts": "claude-integration", "coding-agent-windows-dispatch.test.ts": "adapters", "coding-agent-native-empty-argv.test.ts": "windows", + "explicit": { "service-desktop-recovery.test.ts": "service", "catalog-omit-disabled-native.test.ts": "codex-integration", "provider-input-modalities.test.ts": "providers", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "codex-provider-routing.test.ts": "codex-integration", "config-native-reasoning-retention.test.ts": "config", "server-native-reasoning-retention-merge.test.ts": "server", "server-native-reasoning-retention.test.ts": "server", "responses-retained-reasoning-stream-recovery.test.ts": "responses", "openai-native-hosted-search-history.test.ts": "adapters/openai", "responses-native-compact-search-history.test.ts": "responses", "config-hosted-search-projection.test.ts": "config", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "cli-provider-list-active.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "bridge-terminal-refusal.test.ts": "adapters", "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", "combo-terminal-refusal.test.ts": "routing", "server-terminal-refusal-wiring.test.ts": "server", "incomplete-reason-normalization.test.ts": "responses", "advisor-retention.test.ts": "advisor", "responses-native-key-cooldown.test.ts": "responses", "responses-run-turn-key-readmission.test.ts": "responses", "qoder-spawn-readmission.test.ts": "adapters", "api-key-selection-cooldown.test.ts": "providers", "api-key-auto-pause-distribution.test.ts": "providers", "explicit-key-pause-oauth-boundary.test.ts": "providers", "server-chat-native-key-selection.test.ts": "server", "compact-key-selection.test.ts": "responses", "messages-native-key-selection.test.ts": "claude-integration", "coding-agent-windows-dispatch.test.ts": "adapters", "coding-agent-native-empty-argv.test.ts": "windows", "stop-restore-history-ownership.test.ts": "service", "cli-stop-refusal-transport.test.ts": "cli", "service-proxy-env.test.ts": "service", "openai-chat-standalone-bare-block.test.ts": "adapters/openai", diff --git a/src/codex/catalog/bundled.ts b/src/codex/catalog/bundled.ts index f2b896bfa7a..8b2e88613d7 100644 --- a/src/codex/catalog/bundled.ts +++ b/src/codex/catalog/bundled.ts @@ -33,7 +33,8 @@ import upstreamModelsSnapshot from "../data/upstream-models.json"; import { decodeOverlayState } from "../shim-state-file"; -import { activeCodexModelsCachePath, catalogBackupPathFor, findNativeTemplate, findSupportedNativeTemplate, isDefaultCatalogPath, legacyCatalogBackupPath, parseCatalogJson, readCatalog, readCatalogBackup, readCodexCatalogPath } from "./parsing"; +import { activeCodexModelsCachePath, catalogBackupPathFor, ensureStrictCatalogFields, findNativeTemplate, findSupportedNativeTemplate, isDefaultCatalogPath, legacyCatalogBackupPath, parseCatalogJson, readCatalog, readCatalogBackup, readCodexCatalogPath } from "./parsing"; +import { retainedNativeInheritanceTemplate } from "./native-template"; import type { RawCatalog, RawEntry } from "./parsing"; import { isSpawnableCodexCandidate } from "../exec-invocation"; import { validatedCodexExecInvocation, validatedCodexExecInvocationAsync } from "../exec-boundary"; @@ -729,8 +730,14 @@ export function readCurrentCodexModelsCache(): RawCatalog | null { export function loadCatalogTemplate(): RawEntry | null { const catalogPath = readCodexCatalogPath(); const bundled = bundledCodexCatalogSnapshot(); - const fromRows = (catalog: RawCatalog | null) => findSupportedNativeTemplate(catalog - ? { ...catalog, models: (catalog.models ?? []).filter(isPlainObject) } : null); + const fromRows = (catalog: RawCatalog | null) => { + const template = findSupportedNativeTemplate(catalog + ? { ...catalog, models: (catalog.models ?? []).filter(isPlainObject) } : null); + // An emitted native has already passed strict normalization. Match that read surface, + // including property insertion order, when the native survives only as source evidence. + return template && template === retainedNativeInheritanceTemplate(catalog) + ? ensureStrictCatalogFields(structuredClone(template)) : template; + }; // Template inheritance only. The validity gates in this file keep `findNativeTemplate` // so a catalog carrying only a newly launched native row stays valid (#2813). const native = fromRows(readCatalog(catalogPath)) diff --git a/src/codex/catalog/metadata.ts b/src/codex/catalog/metadata.ts index 5da4d4dccb3..d0177761292 100644 --- a/src/codex/catalog/metadata.ts +++ b/src/codex/catalog/metadata.ts @@ -118,15 +118,14 @@ export function configuredNativeAliasSlugs( } /** - * Bare native rows that must be absent, rather than merely hidden, while Desktop native-alias - * compatibility is active. Codex Desktop's remote allowlist can ignore `visibility: "hide"`; - * omitting disabled native rows is therefore part of the explicit native-alias opt-in. + * Bare native rows omitted by native-alias compatibility or explicit native-omission opt-in. + * Codex Desktop's remote allowlist can ignore `visibility: "hide"`. */ export function desktopAllowlistSuppressedNativeSlugs( - config: Pick, + config: Pick, ): Set { const suppressed = configuredNativeAliasSlugs(config); - if (suppressed.size === 0) return suppressed; + if (suppressed.size === 0 && config.omitDisabledNativeModels !== true) return suppressed; const disabled = disabledNativeSlugs(config); for (const slug of NATIVE_OPENAI_MODELS) { if (disabled.has(slug)) suppressed.add(slug); diff --git a/src/codex/catalog/native-omission.ts b/src/codex/catalog/native-omission.ts new file mode 100644 index 00000000000..0f53f71b6d8 --- /dev/null +++ b/src/codex/catalog/native-omission.ts @@ -0,0 +1,28 @@ +import type { OcxConfig } from "../../types"; +import { trustedAccountBoundNativeCatalogSlug } from "./account-models"; +import { CODEX_NATIVE_ALIAS_CATALOG_KIND } from "./kinds"; +import { SUPPORTED_NATIVE_OPENAI_SLUGS } from "./native-models"; +import { hasOcxRoutedAuthorship } from "./ownership"; +import { hasOrdinaryNativeOpenAiRow } from "./control-plane"; +import { isCodexControlPlaneModel } from "../control-plane-models"; +import type { RawEntry } from "./parsing"; + +/** Final projection also covers exact selector disables and observed account-native ids. */ +export function omitDisabledNativeCatalogEntries( + entries: RawEntry[], + config: Pick, +): RawEntry[] { + if (config.omitDisabledNativeModels !== true) return entries; + const disabled = new Set(config.disabledModels ?? []); + const visible = entries.filter(entry => { + if (entry.opencodex_catalog_kind === CODEX_NATIVE_ALIAS_CATALOG_KIND) return true; + const slug = typeof entry.slug === "string" ? entry.slug : ""; + const accountNative = trustedAccountBoundNativeCatalogSlug(entry); + if (accountNative) return !disabled.has(slug) && !disabled.has(accountNative); + return !SUPPORTED_NATIVE_OPENAI_SLUGS.has(slug) || hasOcxRoutedAuthorship(entry) || !disabled.has(slug); + }); + // Nothing omitted: the flag must not change the catalog, control-plane rows included. + if (visible.length === entries.length) return entries; + return hasOrdinaryNativeOpenAiRow(visible) + ? visible : visible.filter(entry => !isCodexControlPlaneModel(entry.slug)); +} diff --git a/src/codex/catalog/native-template.ts b/src/codex/catalog/native-template.ts new file mode 100644 index 00000000000..844af6f8726 --- /dev/null +++ b/src/codex/catalog/native-template.ts @@ -0,0 +1,58 @@ +import { COMBO_NAMESPACE } from "../../combos/identifiers"; +import { isPlainObject } from "../../lib/plain-data"; +import { CODEX_NATIVE_ALIAS_CATALOG_KIND } from "./kinds"; +import { SUPPORTED_NATIVE_OPENAI_SLUGS } from "./native-models"; +import { hasOcxRoutedAuthorship } from "./ownership"; +import type { RawCatalog, RawEntry } from "./parsing"; + +/** Internal source evidence, outside the emitted model collection. */ +export const NATIVE_INHERITANCE_TEMPLATE_FIELD = "opencodex_native_inheritance_template"; + +function isSupportedNativeTemplate(row: RawEntry): boolean { + return typeof row.slug === "string" + && SUPPORTED_NATIVE_OPENAI_SLUGS.has(row.slug) + && !row.slug.includes("/") + && "base_instructions" in row + && row.opencodex_catalog_kind !== CODEX_NATIVE_ALIAS_CATALOG_KIND + && row.owned_by !== COMBO_NAMESPACE + && !hasOcxRoutedAuthorship(row); +} + +export function retainedNativeInheritanceTemplate(catalog: RawCatalog | null): RawEntry | null { + const retained = catalog?.[NATIVE_INHERITANCE_TEMPLATE_FIELD]; + return isPlainObject(retained) && isSupportedNativeTemplate(retained) ? retained : null; +} + +/** + * Inheritance is stricter than catalog validity: unknown bare rows must never lend native + * eligibility to routed rows (#2813). Retained source evidence uses exactly the same rule. + * Prefer it over emitted rows so omission of the first native cannot change the next build. + * No supported source still returns null, preserving the existing null-template behavior. + */ +export function findSupportedNativeTemplate(catalog: RawCatalog | null): RawEntry | null { + return retainedNativeInheritanceTemplate(catalog) + ?? catalog?.models?.find(isSupportedNativeTemplate) ?? null; +} + +/** Capture before any visibility, capability or ordering projection mutates emitted rows. */ +export function captureNativeInheritanceTemplate(catalog: RawCatalog, enabled: boolean): RawEntry | null { + const template = findSupportedNativeTemplate(catalog); + if (enabled && template) catalog[NATIVE_INHERITANCE_TEMPLATE_FIELD] = structuredClone(template); + else delete catalog[NATIVE_INHERITANCE_TEMPLATE_FIELD]; + return template; +} + +/** + * Replace upgradeable source evidence with the writer's fully normalized native result. + * Non-upgraded source evidence stays pristine, preserving existing byte-idempotent builds. + * The writer passes its upgrade rule in: importing it from `metadata.ts` here would close a + * load-time cycle through `bundled.ts`, which reads this module's retained template. + */ +export function advanceNativeInheritanceTemplate( + catalog: RawCatalog, entries: readonly RawEntry[], shouldUpgrade: (entry: RawEntry) => boolean, +): void { + const retained = retainedNativeInheritanceTemplate(catalog); + if (!retained || !shouldUpgrade(retained)) return; + const upgraded = entries.find(row => row.slug === retained.slug && isSupportedNativeTemplate(row)); + if (upgraded) catalog[NATIVE_INHERITANCE_TEMPLATE_FIELD] = structuredClone(upgraded); +} diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index 7b9bce865d5..dad7bd99c64 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -1,4 +1,6 @@ import type { VendorContextInference } from "./vendor-context"; +import { retainedNativeInheritanceTemplate } from "./native-template"; +export { findSupportedNativeTemplate } from "./native-template"; import { isCodexControlPlaneModel } from "../control-plane-models"; import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; @@ -307,35 +309,7 @@ export function findNativeTemplate(catalog: RawCatalog | null): RawEntry | null && m.opencodex_catalog_kind !== CODEX_NATIVE_ALIAS_CATALOG_KIND && m.owned_by !== COMBO_NAMESPACE && !hasOcxRoutedAuthorship(m), - ) ?? null; -} - -/** - * Template selection, as opposed to catalog VALIDITY. - * - * `findNativeTemplate` answers "does this look like a real catalog?" and must stay - * permissive: four call sites use it as a validity gate, and a catalog holding only a - * newly launched native model has to keep passing or sync falls back to stale data. - * - * This answers a different question — "which row should every routed model inherit - * from?" — and must be strict. `deriveEntry` deep-clones the chosen row, so an unknown - * bare row carrying `base_instructions` would become the template for every routed - * model and hand them its native eligibility metadata. #2813 is the report that made - * that concrete: a Reserve-shaped row injected by the client is exactly such a row. - * - * Returning null is safe and expected; `deriveEntry` falls back to a conservative - * synthetic template. - */ -export function findSupportedNativeTemplate(catalog: RawCatalog | null): RawEntry | null { - return catalog?.models?.find( - m => typeof m.slug === "string" - && SUPPORTED_NATIVE_OPENAI_SLUGS.has(m.slug) - && !m.slug.includes("/") - && "base_instructions" in m - && m.opencodex_catalog_kind !== CODEX_NATIVE_ALIAS_CATALOG_KIND - && m.owned_by !== COMBO_NAMESPACE - && !hasOcxRoutedAuthorship(m), - ) ?? null; + ) ?? retainedNativeInheritanceTemplate(catalog); } /** Does this row already carry an `ultrafast` tier the operator put there themselves? */ diff --git a/src/codex/catalog/retained-sync.ts b/src/codex/catalog/retained-sync.ts index 8cc5a37e758..527f94e1624 100644 --- a/src/codex/catalog/retained-sync.ts +++ b/src/codex/catalog/retained-sync.ts @@ -16,6 +16,8 @@ import { COMBO_NAMESPACE } from "../../combos"; import { codexAccountNamespaceEntries, isMainCodexAccountTarget } from "../account-namespaces"; import { MAIN_CODEX_ACCOUNT_ID } from "../main-account"; import { applyNativeAccessPrograms } from "./access-programs"; +import { advanceNativeInheritanceTemplate, captureNativeInheritanceTemplate } from "./native-template"; +import { omitDisabledNativeCatalogEntries } from "./native-omission"; import { availableAccountGatedNativeModels, codexModelEntitlementStateForAccount, @@ -30,7 +32,6 @@ import { catalogBackupPathFor, catalogHasRoutedEntries, findNativeTemplate, - findSupportedNativeTemplate, isDefaultCatalogPath, legacyCatalogBackupPath, readCatalog, @@ -52,6 +53,7 @@ import { observedReserveCatalogSource, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, + shouldUpgradeToUpstreamEntry, upstreamNativeEntry, } from "./metadata"; import { trustedAccountBoundNativeCatalogSlug } from "./account-models"; @@ -298,9 +300,6 @@ function writeRetainedCatalogSync({ catalog, onDiskCatalog, ); - // Strict selector for template inheritance; the validity gate above keeps the broad one. - const template = findSupportedNativeTemplate(catalog); - try { // Once-only: preserve the PRISTINE pre-opencodex catalog as the native-priority baseline // (later syncs would otherwise overwrite it with featured-modified priorities). @@ -321,6 +320,9 @@ function writeRetainedCatalogSync({ } } catch { /* backup best-effort */ } + // Capture inheritance only after pristine backup preparation, before any row projection. + const template = captureNativeInheritanceTemplate(catalog, config.omitDisabledNativeModels === true); + // Hide disabled models from Codex, then feature the chosen subagent models (native OR routed) // by giving them the lowest priority — see buildCatalogEntries for why priority, not array order. const enabledGo = filterCatalogVisibleModels(goModels, config); @@ -370,6 +372,13 @@ function writeRetainedCatalogSync({ ...desktopAllowlistSuppressedNativeSlugs(config), ...unavailableGatedNativeSlugs, ]); + // An omitted source still needs the same upgrade/normalization as a visible native. + // Keep independent combo-alias and entitlement suppression authoritative. + if (template && shouldUpgradeToUpstreamEntry(template) + && (!ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(String(template.slug)) || availableBareGatedNativeSlugs.has(String(template.slug))) + && !desktopAllowlistSuppressedNativeSlugs({ ...config, omitDisabledNativeModels: false }).has(String(template.slug))) { + suppressedBareNativeSlugs.delete(String(template.slug)); + } const hasPhysicalComboProvider = Object.hasOwn(config.providers, COMBO_NAMESPACE); const includeNativeOpenAi = shouldIncludeNativeOpenAi(config); const includeAccountBoundNativeOpenAi = shouldIncludeAccountBoundNativeOpenAi(config); @@ -549,9 +558,10 @@ function writeRetainedCatalogSync({ applyNativeAccessPrograms(catalog.models, modelEntitlements, accountTargets); clampCatalogModelsToCodexSupport(catalog.models, { observedCatalog: bundledCodexCatalogSnapshot() }); finalizeAutoReviewModelOverride(catalog.models, catalogModelsForMerge, config); + advanceNativeInheritanceTemplate(catalog, catalog.models, shouldUpgradeToUpstreamEntry); // Last mutation before serialization; see `enforceCatalogSlugUniqueness` for why the ordering // against the effort clamp is load-bearing rather than cosmetic. - catalog.models = enforceCatalogSlugUniqueness(catalog.models, true); + catalog.models = enforceCatalogSlugUniqueness(omitDisabledNativeCatalogEntries(catalog.models, config), true); const added = goEntries.length + accountBoundEntries.length; const content = `${JSON.stringify(catalog, null, 2)}\n`; diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts index dbc09ae9e95..926ff835ecb 100644 --- a/src/codex/convergence.ts +++ b/src/codex/convergence.ts @@ -37,7 +37,6 @@ import { import { catalogBackupPathFor, catalogHasRoutedEntries, - findSupportedNativeTemplate, legacyCatalogBackupPath, nativeMultiAgentDefaults, parseCatalogJson, @@ -64,6 +63,7 @@ import { nativeContextLimits, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, + shouldUpgradeToUpstreamEntry, } from "./catalog/metadata"; import { trustedAccountBoundNativeCatalogSlug, @@ -78,6 +78,8 @@ import { codexRuntimeStatePath, peekCodexRuntimeProcessCache } from "./runtime"; import { codexAccountNamespaceEntries, isMainCodexAccountTarget } from "./account-namespaces"; import { MAIN_CODEX_ACCOUNT_ID } from "./main-account"; import { applyNativeAccessPrograms } from "./catalog/access-programs"; +import { advanceNativeInheritanceTemplate, captureNativeInheritanceTemplate } from "./catalog/native-template"; +import { omitDisabledNativeCatalogEntries } from "./catalog/native-omission"; import { availableAccountGatedNativeModels, codexModelEntitlementStateForAccount, @@ -269,7 +271,7 @@ function prepareCatalog( ): RawCatalog { const catalog = JSON.parse(JSON.stringify(source.catalog)) as RawCatalog; // Strict selector: an unknown bare row must never become the routed template (#2813). - const template = findSupportedNativeTemplate(catalog); + const template = captureNativeInheritanceTemplate(catalog, config.omitDisabledNativeModels === true); const enabled = filterCatalogVisibleModels(routedModels, config); const featured = config.subagentModels ?? []; const ordered = orderForSubagents(enabled, featured); @@ -296,6 +298,13 @@ function prepareCatalog( ...desktopAllowlistSuppressedNativeSlugs(config), ...[...ACCOUNT_GATED_NATIVE_OPENAI_MODELS].filter(slug => !availableBareGatedNativeSlugs.has(slug)), ]); + // An omitted source still needs the same upgrade/normalization as a visible native. + // Keep independent combo-alias and entitlement suppression authoritative. + if (template && shouldUpgradeToUpstreamEntry(template) + && (!ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(String(template.slug)) || availableBareGatedNativeSlugs.has(String(template.slug))) + && !desktopAllowlistSuppressedNativeSlugs({ ...config, omitDisabledNativeModels: false }).has(String(template.slug))) { + suppressedBareNativeSlugs.delete(String(template.slug)); + } const hasPhysicalComboProvider = Object.hasOwn(config.providers, COMBO_NAMESPACE); const enabledProviders = Object.entries(config.providers).filter(([, provider]) => provider.disabled !== true); const includeNativeOpenAi = shouldIncludeNativeOpenAi(config); @@ -416,12 +425,13 @@ function prepareCatalog( : null, ); finalizeAutoReviewModelOverride(mergedModels, catalogModels, config); + advanceNativeInheritanceTemplate(catalog, mergedModels, shouldUpgradeToUpstreamEntry); // The second writer of this file. A dashboard model toggle, a combo edit, or a Codex account // login reaches `convergeCodexCatalog` and commits through `fixedCommit`, never through // `writeRetainedCatalogSync`, so the #4730 uniqueness guard has to stand here too or the same // `source-invalid` rejection returns by a different route. Silent because this merge runs under // `warningPolicy: "suppress"`. - catalog.models = enforceCatalogSlugUniqueness(mergedModels, false); + catalog.models = enforceCatalogSlugUniqueness(omitDisabledNativeCatalogEntries(mergedModels, config), false); return catalog; } diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index 959679ed2be..b662e031726 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -235,6 +235,8 @@ export const configSchema = z.object({ defaultProvider: z.string().min(1).default("openai"), defaultModelAliases: z.boolean().optional(), preferModelAliasIds: z.boolean().optional().catch(false), + // Catalog omission is opt-in; malformed hand edits preserve the hidden-row default. + omitDisabledNativeModels: z.boolean().optional().catch(false), // Malformed hand edits disable this opt-in projection without rejecting providers. cursorEffortRows: z.boolean().optional().catch(false), // Fast selectors default on; malformed hand edits disable them without rejecting providers. diff --git a/src/server/codex-model-list.ts b/src/server/codex-model-list.ts index 8d710b89251..4059cc6e404 100644 --- a/src/server/codex-model-list.ts +++ b/src/server/codex-model-list.ts @@ -1,4 +1,5 @@ import { websocketsEnabled } from "../config"; +import { omitDisabledNativeCatalogEntries } from "../codex/catalog/native-omission"; import type { OcxConfig } from "../types"; import { buildCatalogEntries } from "../codex/catalog/build-entries"; import { loadCatalogTemplate } from "../codex/catalog/bundled"; @@ -42,5 +43,5 @@ export function buildCodexModelList({ accountNativeSlugsBySelector, config.keepNativeChatGptOnV1 === true, config.modelPickerOrder, nativeSource, ); - return { models: applyNativeVisibility(entries, disabledModels, accountSelectors.length > 0, new Set(accountNativeSlugs)) }; + return { models: omitDisabledNativeCatalogEntries(applyNativeVisibility(entries, disabledModels, accountSelectors.length > 0, new Set(accountNativeSlugs)), config) }; } diff --git a/src/types/config.ts b/src/types/config.ts index 20ccaa54921..993468de078 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -901,6 +901,8 @@ export interface OcxConfig { * the bare row plus every generated selector row and omit that model family from raw discovery. */ disabledModels?: string[]; + /** Omit explicitly disabled native catalog rows, retaining their validated inheritance template. Default: false; direct routing is unchanged. */ + omitDisabledNativeModels?: boolean; /** 사용자가 대시보드에서 직접 추가한 커스텀 모델 목록. */ customModels?: OcxCustomModel[]; /** diff --git a/structure/INDEX.md b/structure/INDEX.md index f0a7d4ddbbb..58b9e1d61d3 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -415,7 +415,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/types.ts` | [`runtime.md`](runtime.md)
[`config.md`](config.md) | | `src/types/` | [`providers/generic-account-pool.md`](providers/generic-account-pool.md)
[`transports/native-reasoning-retention.md`](transports/native-reasoning-retention.md)
[`providers/anthropic-progress.md`](providers/anthropic-progress.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/lm-studio-chat.md`](providers/lm-studio-chat.md)
[`fallback-policy.md`](fallback-policy.md) | | `src/types/account-auto-pause.ts` | [`account-auto-pause.md`](account-auto-pause.md) | -| `src/types/config.ts` | [`transports/timeouts.md`](transports/timeouts.md) | +| `src/types/config.ts` | [`transports/timeouts.md`](transports/timeouts.md)
[`config.md`](config.md) | | `src/types/provider.ts` | [`pricing.md`](pricing.md) | | `src/update/` | [`runtime.md`](runtime.md)
[`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | | `src/update/guarded-port-reclaim.ts` | [`ops/update-supervised-recovery.md`](ops/update-supervised-recovery.md) | diff --git a/structure/catalog.md b/structure/catalog.md index 6080df44f06..70b2219487e 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -67,9 +67,9 @@ provider-wide fallback. Exact model output limits precede the provider default o native/routed switches do not compact merely because of a synthetic marker. Native rows and Codex-forward aliases keep upstream hashes; foreign rows keep valid hashes. Token limits are unchanged; - forces strict Codex catalog fields required by the current parser. `src/codex/catalog/parsing.ts` preserves model arrays verbatim, including id-only rows used for prompt text and reasoning-summary support, and treats non-array model collections as unavailable. Native provenance in `src/codex/catalog/native-priority.ts`, live native-slug discovery and template selection validate rows locally; baseline priorities must be finite numbers and retained source ranks must be valid. Live discovery tolerates malformed optional provenance without repairing its files. Bundled-catalog validity still rejects non-object rows; retained sync keeps its existing null-row rejection in native recovery before provenance is read; -- hides `disabledModels` without blocking direct routing (routed provider ids are excluded; - account-qualified native ids hide only that selector row; BARE native slugs hide the bare row - and all account-selector clones and drop that model family from raw `/v1/models`); +- hides `disabledModels` without blocking direct routing by default (routed ids are excluded; account-qualified native ids hide only that row; bare native ids hide the bare row and all account clones and drop the family from raw `/v1/models`). Explicit `omitDisabledNativeModels: true` omits disabled native rows in convergence, retained sync, cache and Codex-shaped HTTP discovery through the existing native-alias suppression set and `src/codex/catalog/native-omission.ts`; + `src/codex/catalog/native-template.ts` captures the supported native source before omission in root field `opencodex_native_inheritance_template`, independently of emitted rows. Upgradeable evidence passes through the writer's native upgrade, normalization and runtime effort clamp before that row is omitted; the retained replacement matches the flag-off native result. Non-upgraded evidence stays pristine for byte-idempotent builds. Repeated builds validate retained evidence with the same supported-slug, instructions and non-routed provenance rules; absent or invalid evidence preserves existing null-template behavior. `src/codex/catalog/bundled.ts` applies strict native normalization to retained templates on request-time reads, matching emitted native sources including JSON property order. Omission removes orphan control-plane reviewer rows when no ordinary native remains. Routed bytes remain equal with the flag on or off; + omission defaults off, existing native-alias omission remains independent, and direct routing, pristine recovery and no-op sync mtime behavior are unchanged. Coverage: `tests/codex-integration/catalog-omit-disabled-native.test.ts`; - applies exact provider/model compatibility exclusions after live discovery and metadata augmentation, so upstream-advertised but uncallable rows—including retired aliases retained in generated metadata for historical accounting—never enter dashboard or Codex pickers; diff --git a/structure/config.md b/structure/config.md index 4bdc77bfc9b..66e355292c8 100644 --- a/structure/config.md +++ b/structure/config.md @@ -28,7 +28,7 @@ the [source-owned credential contract](codex-home.md#orca-source-owned-account-i ## Config surface `src/config/persist-unlocked.ts` protects late retired disk edits at the common save boundary, inspecting a detached projection of the same bytes while retaining the unprojected raw snapshot for OS-store reference accounting, client-state guards and auto-pause revision normalization. Caller-side projection reuses an eligible exact recovery copy and the once-per-process notice. `src/config/removed-opencode-free.ts` disables retired OpenCode Free provider entries (name, type or preset identity), their model references, and empty dependent combos/profiles on file load. It warns once per process about the upstream 403 gate (#7255), preserves file bytes on load, and restores the standard OpenAI OAuth default when the retired provider was the default. The next explicit configuration save persists this projection and removes the retired entries and dependent references from config.json, like the OpenCode wire upgrades; `src/config/removed-opencode-free-backup.ts` preserves each original disk snapshot through the hardened atomic writer in exclusive mode. The default sibling is `config.json.pre-opencode-free-removal.bak`; a different or non-regular occupant gets a unique timestamped sibling, while an identical independent regular copy is reused only after owner-only verification: the authoritative SID-based owner/protected-DACL verifier on Windows, or mode `0600` and current-uid ownership on POSIX, followed by stable descriptor/path identity and metadata checks. An unprotected occupant is left unchanged and gets a protected exclusive sibling. Matching multiply linked copies receive three bounded 10 ms rechecks per inspection for a publisher to unlink its temp; persistently linked existing files are ineligible for reuse. A newly published copy can remain multiply linked when temporary-name unlink is persistently denied; the accepted cleanup exception still returns the exact, protected published copy and warns about the temporary residue. These rechecks avoid duplicates during short overlaps, but overlapping first loads may leave multiple byte-identical, owner-only copies when a publisher is delayed beyond the budget. Every certified copy and every copy named in a notice holds the exact original bytes with verified owner-only protection; there is no single-copy guarantee. The read-only `verifySecretPathAcl` export in `src/lib/windows-secret-acl.ts` calls the authoritative SID-based verifier directly, without the name-only fast path, hardening or success-memo admission. Newly published copies are owner-only and unclaimed by uninstall. The loader, disk diagnostics and authoritative live-save read share this boundary and retain the exact raw bytes, including BOM and line endings. Passive reads keep only the in-memory projection, creating and hardening nothing and emitting no notice; imported text stays side-effect free. The notice follows the attempt and names the recovery copy through `redactUserPath` with restore instructions, or asks for an immediate manual copy if preservation failed. Backup and cleanup failures warn without paths or secret content and do not abort loading. Healthy targets and unrelated settings remain intact. Retired provider and route aliases are trimmed before reference matching, including role tiers, sidecars, shadow interception and provider review selectors. Desktop assignment pruning repairs each affected family default to its first sorted remaining route or null; healthy defaults and applied markers survive. Depleted source-model allowlists disable their rule, and removing an injection model also removes its effort and native subagent sync. `src/config/live-reconcile.ts` sanitizes its authoritative raw snapshot before Claude hand-edit comparison so unrelated saves cannot resurrect removed references; healthy disk edits still win. Candidate writes retain normal validation. Custom Responses providers may declare independent boolean `supportsNativeCompactionTrigger` and `supportsNativeCompactEndpoint` capabilities in `src/config/schema/leaf-validators.ts`; absence remains off. They do not imply `decodesNativeCompactionBlobs`, official identity, or credential authority; custom native v1 key/local/forward replay enforces the separate blob capability and known serving-identity changes, while OAuth retains routed credential resolution. The native gateway contract and regression are in [Responses transport](transports/responses.md) and `tests/responses/native-compaction-gateway.test.ts`. Retired model targets in global or `claudeCode` overrides of `visionSidecar` and `webSearchSidecar` leave `enabled: false` with healthy sibling settings intact, whether enabled was false, true or omitted. Healthy sidecar targets retain their configured activation state; absent sections retain the normal defaults. Windows recovery reuse always uses the SID-based private-entry policy in `src/lib/windows-owner-acl.ts`. The opt-in `OPENCODEX_ACL_VERIFY_EXISTING=1` hardening paths in `src/lib/windows-secret-acl.ts` retain their single explicit serving-user Full Control ACE fast check; other shapes fall back to the SID policy, synchronously or asynchronously as appropriate. That policy permits retained explicit SYSTEM and Administrators grants, requires protected ownership and serving-user Full Control, and rejects broad, inherited, foreign or unverifiable ACLs. Protection assertions in `tests/config/config-removed-opencode-free.test.ts` use this shared verifier rather than an ACE count. POSIX mode/uid checks remain independent. -`src/config/schema/config-schema.ts` validates the optional `preferModelAliasIds` flag as a boolean; malformed disk values disable this raw-list projection without rejecting providers. It defaults off. Custom Responses providers can explicitly enable `preserveResponsesInputItemIds` and `preserveResponsesMessageMetadata`; both default off and accept only booleans. The [replay sanitization contract](transports/responses-wire-shapes.md) keeps ordinary destinations unchanged and preserves xAI custom-call ID repair. `src/config/schema/compaction-recovery.ts` strictly validates opt-in `compactionRecovery`; invalid disk values disable it with a warning, while candidate writes reject them. `src/config/schema/blocked-model-redirects.ts` applies the same read-degrade/write-reject boundary to malformed `blockedModelRedirects` maps. The [failure-only contract](transports/responses-failover.md) leaves provider identity, accounts and client compaction unchanged. Optional `desktopCompatibility: { startOnProxyStart: boolean }` likewise rejects invalid writes and degrades invalid disk values to off; it saves neither certificate consent nor correction mode. Its field-scoped dashboard writer adopts committed state into the live comparison baseline; see [Codex Desktop startup](clients/codex-desktop.md#proxy-startup-preference). `src/cli/config-command.ts` accepts one leading UTF-8 BOM when parsing validate/import input from a file or stdin. JSON syntax and schema validation still run before persistence; BOM characters inside string values remain data. +`src/types/config.ts` declares optional boolean `omitDisabledNativeModels`; `src/config/schema/config-schema.ts` preserves it on normal and salvaged loads and degrades malformed values to false, matching neighboring catalog opt-ins. Absence and false preserve default visibility. The [catalog contract](catalog.md#shared-catalog) owns omission, native template retention and unchanged direct routing. `src/config/schema/config-schema.ts` validates the optional `preferModelAliasIds` flag as a boolean; malformed disk values disable this raw-list projection without rejecting providers. It defaults off. Custom Responses providers can explicitly enable `preserveResponsesInputItemIds` and `preserveResponsesMessageMetadata`; both default off and accept only booleans. The [replay sanitization contract](transports/responses-wire-shapes.md) keeps ordinary destinations unchanged and preserves xAI custom-call ID repair. `src/config/schema/compaction-recovery.ts` strictly validates opt-in `compactionRecovery`; invalid disk values disable it with a warning, while candidate writes reject them. `src/config/schema/blocked-model-redirects.ts` applies the same read-degrade/write-reject boundary to malformed `blockedModelRedirects` maps. The [failure-only contract](transports/responses-failover.md) leaves provider identity, accounts and client compaction unchanged. Optional `desktopCompatibility: { startOnProxyStart: boolean }` likewise rejects invalid writes and degrades invalid disk values to off; it saves neither certificate consent nor correction mode. Its field-scoped dashboard writer adopts committed state into the live comparison baseline; see [Codex Desktop startup](clients/codex-desktop.md#proxy-startup-preference). `src/cli/config-command.ts` accepts one leading UTF-8 BOM when parsing validate/import input from a file or stdin. JSON syntax and schema validation still run before persistence; BOM characters inside string values remain data. `skills.catalog_refresh` in the proxy JSON configuration accepts `per_session` (the runtime default when absent) or `per_turn`. The former retains received skills instructions for a conversation; the latter passes through the current catalog. This is separate from Codex's `skills.include_instructions` TOML switch and does not change the live dashboard probe. See the [Responses snapshot contract](transports/responses.md#responses-httpsse). Optional `reasoningRetention` is advanced and off by default: only explicit boolean `enabled: true` activates it, so omitted/false/malformed and legacy limit-only blocks stay off. When enabled, it accepts `maxContextPercent` (>0, <=100; default 20) and `maxTokens` (positive integer; default 100000), using the smaller limit for [routed v1/v2 compaction](transports/responses-failover.md#compaction-routing-overrides). An unknown context window uses the absolute cap only. Malformed disk values degrade only this block with a value-free warning; candidate writes reject them before the load schema's catch. Absence remains unpersisted, and native/opaque protocols are unchanged. diff --git a/structure/manifest.json b/structure/manifest.json index d3f5cb7d26e..4e12855615c 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -147,7 +147,8 @@ "src/codex/", "src/config.ts", "src/config/", - "src/types.ts" + "src/types.ts", + "src/types/config.ts" ] }, { diff --git a/tests/codex-integration/__snapshots__/catalog-omit-disabled-native.test.ts.snap b/tests/codex-integration/__snapshots__/catalog-omit-disabled-native.test.ts.snap new file mode 100644 index 00000000000..003553c32ee --- /dev/null +++ b/tests/codex-integration/__snapshots__/catalog-omit-disabled-native.test.ts.snap @@ -0,0 +1,19 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`default output matches the pre-option snapshot; explicit false is byte-identical 1`] = `"b978228381d45d8c962551c4d563babf0503d7095a70aface28a8b2f4c0267b8"`; + +exports[`upgraded gpt-6-astra after 1 convergence passes (unnamed) 1`] = `"631f9483fe0eb43f05e13aaf4bf5dbeb182c9a416a4c7535029aae7eeafa5444"`; + +exports[`upgraded gpt-6-astra after 2 convergence passes (unnamed) 1`] = `"83ee8352566d3966cc73eb838a2f9d8fd932ab8e7a4beb41407c97b6d754ab98"`; + +exports[`upgraded gpt-6-astra after 1 retained sync passes (unnamed) 1`] = `"631f9483fe0eb43f05e13aaf4bf5dbeb182c9a416a4c7535029aae7eeafa5444"`; + +exports[`upgraded gpt-6-astra after 2 retained sync passes (unnamed) 1`] = `"83ee8352566d3966cc73eb838a2f9d8fd932ab8e7a4beb41407c97b6d754ab98"`; + +exports[`upgraded gpt-6-luna after 1 convergence passes (unnamed) 1`] = `"4b7e6430befcb01070fee32c962d0478412a7000112deab41c9da2bed5ce758b"`; + +exports[`upgraded gpt-6-luna after 2 convergence passes (unnamed) 1`] = `"cad289facd9854199bb0247d1ed25f02c00bd782ef7e0842ebccb32706ae3003"`; + +exports[`upgraded gpt-6-luna after 1 retained sync passes (unnamed) 1`] = `"4b7e6430befcb01070fee32c962d0478412a7000112deab41c9da2bed5ce758b"`; + +exports[`upgraded gpt-6-luna after 2 retained sync passes (unnamed) 1`] = `"cad289facd9854199bb0247d1ed25f02c00bd782ef7e0842ebccb32706ae3003"`; diff --git a/tests/codex-integration/catalog-omit-disabled-native.test.ts b/tests/codex-integration/catalog-omit-disabled-native.test.ts new file mode 100644 index 00000000000..8980d518bf9 --- /dev/null +++ b/tests/codex-integration/catalog-omit-disabled-native.test.ts @@ -0,0 +1,301 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { createHash } from "node:crypto"; +import { captureCatalogAdmissionSnapshot } from "../../src/codex/catalog-admission"; +import { commitCodexCatalogCandidate, gatherCodexCatalogCandidate } from "../../src/codex/convergence"; +import { invalidateCodexModelsCache, resetCatalogRuntimeStateForTests, syncCatalogModels } from "../../src/codex/catalog"; +import { findSupportedNativeTemplate, type RawCatalog, type RawEntry } from "../../src/codex/catalog/parsing"; +import { getConfigPath, loadConfig, saveConfig } from "../../src/config"; +import type { OcxConfig } from "../../src/types"; +import { CODEX_FORWARD_BASE_URL } from "../../src/providers/openai-tiers"; +import { startServer } from "../../src/server"; +import { buildCodexModelList } from "../../src/server/codex-model-list"; +import { desktopAllowlistSuppressedNativeSlugs } from "../../src/codex/catalog/metadata"; +import { NATIVE_OPENAI_MODELS } from "../../src/codex/catalog/native-models"; +import { CODEX_ACCOUNT_BOUND_CATALOG_KIND } from "../../src/codex/catalog/account-models"; +import { omitDisabledNativeCatalogEntries } from "../../src/codex/catalog/native-omission"; +import { routeModel } from "../../src/router"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; +import { removeTreeAfterBackgroundWork } from "../helpers/remove-tree"; + +let home: TempHome; +let path: string; +const SOURCE_FIELD = "opencodex_native_inheritance_template"; +const native = (): RawEntry => ({ + slug: "gpt-5.5", display_name: "Native", priority: 1, visibility: "list", + base_instructions: "Fixture native instructions", shell_type: "shell_command", + model_messages: { instructions_template: "Fixture template" }, + input_modalities: ["text", "image"], supports_image_detail_original: true, + experimental_supported_tools: ["fixture_tool"], + supported_reasoning_levels: [{ effort: "medium", description: "Medium" }], +}); +const config = (flag?: boolean): OcxConfig => ({ + port: 0, hostname: "127.0.0.1", defaultProvider: "fixture", subagentModels: [], + catalogAutoRefresh: { enabled: false }, disabledModels: ["gpt-5.5"], + ...(flag === undefined ? {} : { omitDisabledNativeModels: flag }), + providers: { + openai: { adapter: "openai-responses", baseUrl: CODEX_FORWARD_BASE_URL, authMode: "forward", models: ["gpt-5.5"] }, + fixture: { adapter: "openai-chat", baseUrl: "https://omit-fixture.invalid/v1", liveModels: false, models: ["model"] }, + }, +}); +const source = () => JSON.stringify({ fixture: "native source", models: [native()] }); +const read = (): RawCatalog => JSON.parse(readFileSync(path, "utf8")); +const routed = (catalog: RawCatalog) => JSON.stringify(catalog.models?.filter(row => row.slug === "fixture/model")); +const liveList = (next: OcxConfig): RawCatalog => buildCodexModelList({ + config: next, nativeSlugs: ["gpt-5.5"], availableAccountNativeSlugs: [], accountNativeSlugs: [], + accountNativeSlugsBySelector: new Map(), goOrdered: [{ provider: "fixture", id: "model" }], + exactComboSlugs: new Set(), accountSelectors: [], + suppressedBareNativeSlugs: desktopAllowlistSuppressedNativeSlugs(next), + disabledModels: new Set(next.disabledModels), +}); + +beforeEach(() => { + home = createTempHome("ocx-omit-native-"); + mkdirSync(home.codexHome); + path = join(home.codexHome, "fixture-catalog.json"); + writeFileSync(join(home.codexHome, "config.toml"), 'model_catalog_json = "fixture-catalog.json"\n', "utf8"); + writeFileSync(path, source(), "utf8"); + resetCatalogRuntimeStateForTests(); +}); +afterEach(async () => { + resetCatalogRuntimeStateForTests(); + await removeTreeAfterBackgroundWork(home.root); + home.remove(); +}); + +async function converge(next: OcxConfig) { + saveConfig(next); + const result = await gatherCodexCatalogCandidate(captureCatalogAdmissionSnapshot(next)); + expect(result.kind).toBe("candidate"); + if (result.kind !== "candidate") throw new Error(JSON.stringify(result)); + const committed = await commitCodexCatalogCandidate(result.candidate, 1_000); + expect(committed.kind).toBe("committed"); + return read(); +} + +test("default output matches the pre-option snapshot; explicit false is byte-identical", async () => { + await converge(config()); + const baseline = readFileSync(path, "utf8"); + expect(createHash("sha256").update(baseline).digest("hex")).toMatchSnapshot(); + expect(read().models?.find(row => row.slug === "gpt-5.5")?.visibility).toBe("hide"); + expect(read()[SOURCE_FIELD]).toBeUndefined(); + writeFileSync(path, source(), "utf8"); + await converge(config(false)); + expect(readFileSync(path, "utf8")).toBe(baseline); +}); + +test("convergence omits disabled natives and preserves routed bytes across repeated builds", async () => { + const off = await converge(config(false)); + writeFileSync(path, source(), "utf8"); + const on = await converge(config(true)); + expect(on.models?.some(row => row.slug === "gpt-5.5")).toBe(false); + expect(routed(on)).toBe(routed(off)); + expect(on[SOURCE_FIELD]).toEqual(native()); + const repeated = await converge(config(true)); + expect(routed(repeated)).toBe(routed(off)); + expect(repeated.models?.some(row => row.slug === "gpt-5.5")).toBe(false); +}); + +test("retained sync keeps routed rows byte-identical with omission enabled", async () => { + const off = config(false); + saveConfig(off); + expect((await syncCatalogModels(off, { allowWhenDesiredDisabled: true })).refreshOutcome).toBe("committed"); + const offRows = routed(read()); + const offLiveRows = routed(liveList(off)); + writeFileSync(path, source(), "utf8"); + const next = config(true); + saveConfig(next); + const first = await syncCatalogModels(next, { allowWhenDesiredDisabled: true }); + expect(first.refreshOutcome).toBe("committed"); + expect(read().models?.some(row => row.slug === "gpt-5.5")).toBe(false); + expect(read()[SOURCE_FIELD]).toEqual(native()); + expect(routed(read())).toBe(offRows); + expect(routed(liveList(next))).toBe(offLiveRows); +}); + +test("retained sync keeps the sole disabled template and a second sync is a no-op", async () => { + const next = config(true); + saveConfig(next); + expect((await syncCatalogModels(next, { allowWhenDesiredDisabled: true })).refreshOutcome).toBe("committed"); + const firstBytes = readFileSync(path, "utf8"); + const mtime = statSync(path).mtimeMs; + const second = await syncCatalogModels(next, { allowWhenDesiredDisabled: true }); + expect(second.refreshOutcome).toBe("committed"); + expect(second.catalogWritten).toBe(false); + expect(readFileSync(path, "utf8")).toBe(firstBytes); + expect(statSync(path).mtimeMs).toBe(mtime); + expect(read()[SOURCE_FIELD]).toEqual(native()); +}); + +test("omitting every native row keeps the only supported source through convergence", async () => { + const next = { ...config(true), disabledModels: [...NATIVE_OPENAI_MODELS] }; + const first = await converge(next); + expect(first.models?.map(row => row.slug)).toEqual(["fixture/model"]); + const second = await converge(next); + expect(routed(second)).toBe(routed(first)); + expect(second[SOURCE_FIELD]).toEqual(native()); +}); + +test("exact selector disables omit only their native row and do not consume a combo alias", () => { + const entries = [ + native(), + { ...native(), slug: "first/gpt-5.5", opencodex_catalog_kind: CODEX_ACCOUNT_BOUND_CATALOG_KIND }, + { ...native(), slug: "second/gpt-5.5", opencodex_catalog_kind: CODEX_ACCOUNT_BOUND_CATALOG_KIND }, + { ...native(), slug: "gpt-5.6-sol", opencodex_catalog_kind: "combo-native-alias-v1" }, + ]; + const next = { omitDisabledNativeModels: true, disabledModels: ["first/gpt-5.5", "gpt-5.6-sol"] }; + expect(omitDisabledNativeCatalogEntries(entries, next).map(row => row.slug)) + .toEqual(["gpt-5.5", "second/gpt-5.5", "gpt-5.6-sol"]); + expect(omitDisabledNativeCatalogEntries(entries, { ...next, disabledModels: ["gpt-5.5"] }).map(row => row.slug)) + .toEqual(["gpt-5.6-sol"]); + expect(omitDisabledNativeCatalogEntries(entries, { ...next, omitDisabledNativeModels: false })).toBe(entries); +}); + +test("an omission pass that drops nothing leaves control-plane rows in place", () => { + const entries = [ + { ...native(), slug: "gpt-5.6-sol", opencodex_catalog_kind: "combo-native-alias-v1" }, + { ...native(), slug: "codex-auto-review" }, + ]; + const next = { omitDisabledNativeModels: true, disabledModels: ["gpt-5.5"] }; + expect(omitDisabledNativeCatalogEntries(entries, next).map(row => row.slug)) + .toEqual(["gpt-5.6-sol", "codex-auto-review"]); +}); + +test("no supported native source keeps the existing null-template behavior", async () => { + const unknown = { ...native(), slug: "gpt-unknown-fixture" }; + writeFileSync(path, JSON.stringify({ models: [unknown] }), "utf8"); + const off = await converge(config(false)); + writeFileSync(path, JSON.stringify({ models: [unknown] }), "utf8"); + const on = await converge(config(true)); + expect(on[SOURCE_FIELD]).toBeUndefined(); + expect(routed(on)).toBe(routed(off)); + expect(findSupportedNativeTemplate({ models: [unknown] })).toBeNull(); +}); + +test("config loading and salvage preserve the opt-in and malformed values stay off", () => { + for (const flag of [true, false, "true", 1, null, {}]) { + writeFileSync(getConfigPath(), JSON.stringify({ ...config(), omitDisabledNativeModels: flag }), "utf8"); + expect((loadConfig() as OcxConfig & { omitDisabledNativeModels?: boolean }).omitDisabledNativeModels).toBe(flag === true); + } + writeFileSync(getConfigPath(), JSON.stringify({ ...config(true), combos: { broken: { strategy: "invalid", targets: [] } } }), "utf8"); + const salvaged = loadConfig() as OcxConfig & { omitDisabledNativeModels?: boolean }; + expect(salvaged.omitDisabledNativeModels).toBe(true); + expect(salvaged.providers.fixture?.models).toEqual(["model"]); +}); + +test("retained sync and serve-options omit the same rows without changing routed bytes", async () => { + const outputs: RawCatalog[] = []; + for (const flag of [false, true]) { + const next = config(flag); + outputs.push(liveList(next)); + expect(outputs.at(-1)?.models?.some(row => row.slug === "gpt-5.5")).toBe(!flag); + } + expect(routed(outputs[1]!)).toBe(routed(outputs[0]!)); + const next = config(true); + saveConfig(next); + expect((await syncCatalogModels(next, { allowWhenDesiredDisabled: true })).refreshOutcome).toBe("committed"); + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/models?client_version=0.160.0", server.url)); + expect(response.status).toBe(200); + const live = await response.json() as RawCatalog; + for (const catalog of [read(), live, JSON.parse(readFileSync(join(home.codexHome, "models_cache.json"), "utf8"))]) { + expect(catalog.models?.some((row: RawEntry) => row.slug === "gpt-5.5")).toBe(false); + expect(catalog.models?.find((row: RawEntry) => row.slug === "fixture/model")).toMatchObject({ + input_modalities: ["text", "image"], experimental_supported_tools: ["fixture_tool"], shell_type: "unified_exec", + }); + } + } finally { await server.stop(true); } +}); + +test("disabled native requests still use the same direct routing decision", () => { + const destination = (next: OcxConfig) => { + const { providerName, modelId, provider } = routeModel(next, "gpt-5.5"); + return { providerName, modelId, provider }; + }; + expect(destination(config(true))).toEqual(destination(config(false))); + expect(destination(config(true))).toMatchObject({ providerName: "openai", modelId: "gpt-5.5" }); +}); + +test("a retained template must pass exactly the existing supported native validation", () => { + // Retained root validation must not soften malformed source-row failure behavior. + expect(() => findSupportedNativeTemplate({ models: [null] as unknown as RawEntry[] })).toThrow(); + expect(findSupportedNativeTemplate({ models: [], [SOURCE_FIELD]: native() })).toEqual(native()); + for (const bad of [null, [], 5, { slug: "gpt-5.5" }, + { ...native(), slug: "gpt-unknown-fixture" }, { ...native(), slug: "fixture/gpt-5.5" }, + { ...native(), owned_by: "combo" }, { ...native(), opencodex_catalog_kind: "combo-native-alias-v1" }, + { ...native(), description: "Routed via opencodex → fixture" }]) { + expect(findSupportedNativeTemplate({ models: [], [SOURCE_FIELD]: bad })).toBeNull(); + } +}); + +async function upgradedOutputs(flag: boolean, model: string, method: "convergence" | "retained sync", passCount: number) { + // Astra's old self-authored label and a slug-labelled fallback both need native upgrades. + const old = { ...native(), slug: model, display_name: model === "gpt-6-astra" ? "GPT-6 Astra" : model }; + writeFileSync(path, JSON.stringify({ models: [old] }), "utf8"); + resetCatalogRuntimeStateForTests(); + const next = config(flag); + next.disabledModels = [model]; + next.providers.openai!.models = [model]; + for (let pass = 0; pass < passCount; pass++) { + if (method === "convergence") await converge(next); + else { + saveConfig(next); + expect((await syncCatalogModels(next, { allowWhenDesiredDisabled: true })).refreshOutcome).toBe("committed"); + invalidateCodexModelsCache({ allowWhenDesiredDisabled: true }); + } + } + const catalog = read(); + expect(catalog.models?.some(row => row.slug === model)).toBe(!flag); + const template = flag ? catalog[SOURCE_FIELD] as RawEntry : catalog.models!.find(row => row.slug === model)!; + if (!flag) { + expect(template.display_name).not.toBe(old.display_name); + expect(template.base_instructions).not.toBe(old.base_instructions); + } + const cache = JSON.parse(readFileSync(join(home.codexHome, "models_cache.json"), "utf8")) as RawCatalog; + const live = liveList(next); + const server = startServer(0); + let http: RawCatalog; + try { + const response = await fetch(new URL("/v1/models?client_version=0.160.0", server.url)); + expect(response.status).toBe(200); + http = await response.json() as RawCatalog; + } finally { await server.stop(true); } + for (const output of [catalog, cache, live, http]) { + expect(output.models?.some(row => row.slug === "fixture/model")).toBe(true); + } + return { catalog: routed(catalog), cache: routed(cache), live: routed(live), http: routed(http), template }; +} + +for (const method of ["convergence", "retained sync"] as const) { + test(`an upgraded sole source survives ${method} when every native is omitted without an orphan reviewer`, async () => { + writeFileSync(path, JSON.stringify({ models: [{ ...native(), slug: "gpt-6-astra", display_name: "GPT-6 Astra" }] }), "utf8"); + const next = { ...config(true), disabledModels: [...NATIVE_OPENAI_MODELS] }; + if (method === "convergence") await converge(next); + else { + saveConfig(next); + expect((await syncCatalogModels(next, { allowWhenDesiredDisabled: true })).refreshOutcome).toBe("committed"); + } + expect(read().models?.map(row => row.slug)).toEqual(["fixture/model"]); + expect((read()[SOURCE_FIELD] as RawEntry).display_name).toBe("GPT-6-Astra"); + }); +} + +for (const model of ["gpt-6-astra", "gpt-6-luna"]) { + for (const method of ["convergence", "retained sync"] as const) { + for (const passCount of [1, 2]) { + describe(`upgraded ${model} after ${passCount} ${method} passes`, () => { + let off: Awaited>; + // Keep each independent server lifecycle within the unchanged default test budget. + beforeEach(async () => { + off = await upgradedOutputs(false, model, method, passCount); + expect(createHash("sha256").update(JSON.stringify(off)).digest("hex")).toMatchSnapshot(); + }); + test("preserves catalog, derived cache, live builder and HTTP discovery bytes", async () => { + expect(await upgradedOutputs(true, model, method, passCount)).toEqual(off); + }); + }); + } + } +} diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 9fdf40115d7..6ac4c7a4f56 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -382,6 +382,7 @@ "catalog-opencode-go-context-window.test.ts": "codex-integration", "catalog-remote-pull.test.ts": "codex-integration", "catalog-opencode-go-static.test.ts": "codex-integration", "catalog-retain-models.test.ts": "codex-integration", "catalog-routed-comp-hash.test.ts": "codex-integration", + "catalog-omit-disabled-native.test.ts": "codex-integration", "catalog-seed-window-fill.test.ts": "codex-integration", "catalog-slug-uniqueness-boundary.test.ts": "codex-integration", "catalog-verbosity-default.test.ts": "codex-integration", From fb81e53e96b00a58df49030be8f9709a5a1a4818 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 29 Oct 2026 13:44:35 +0900 Subject: [PATCH 22/84] fix(service): treat headless GUI-domain launchctl 125 as an unreachable domain (#7697) (#7745) * fix(service): recognize headless GUI-domain launchctl exit 125 as a domain answer On a headless macOS session launchctl print gui//
-
+
{t("pws.firstResponse.stepProbe")} {probeStepText}
+
+ {t("pws.firstResponse.stepDisconnect")} + {t("pws.firstResponse.disconnectNote", { provider: item.name })} + {onOpenConnect && ( + + )} +
{apiBase !== undefined && (
@@ -379,11 +361,35 @@ export default function ProviderOverview({ {connectionText} )} + {connectionResult && connectionState === "failed" && fixCredentialsAction && ( + + )}
)} - {apiBase !== undefined && setCompletionProbe({ epoch: probeEpoch, ...next })} />} + {apiBase !== undefined && ( + + )} {onEditSettings && ( + {running && } + + {notice &&

{notice}

} + {rows.length > 0 && ( +
+ {!running &&

+ {stopped && <>{t("pws.completion.stopped", { done: String(rows.length), total: String(models.length) })} · } + {t("pws.completion.summary", { passed: String(counts.ok), failed: String(failedCount), skipped: String(skipped) })} +

} + + + {rows.map(row => ( + + + + + + + ))} + +
{row.model} + + {t(row.outcome === "ok" ? "pws.completion.passed" : row.outcome === "failed" ? "pws.completion.failedShort" : "pws.completion.notTested")} + + {row.latencyMs !== undefined ? `${row.latencyMs} ms` : ""}{row.detail ?? ""}
+ {!running && failedCount > 0 && disable.kind !== "done" && ( + + )} + {disable.kind === "done" &&

{t("pws.completion.disabledDone", { count: String(disable.count) })}

} + {disable.kind === "error" &&

{disable.message}

} +
+ )} + + ); +} diff --git a/gui/src/components/provider-workspace/ProviderCompletionTest.tsx b/gui/src/components/provider-workspace/ProviderCompletionTest.tsx index 7b734358687..4e71ec6c21c 100644 --- a/gui/src/components/provider-workspace/ProviderCompletionTest.tsx +++ b/gui/src/components/provider-workspace/ProviderCompletionTest.tsx @@ -4,6 +4,7 @@ import { confirmAction } from "../../action-dialogs"; import { readJsonOrThrow } from "../../fetch-json"; import { useI18n, useT } from "../../i18n/shared"; import { Select } from "../../ui"; +import ProviderBulkCompletionTest from "./ProviderBulkCompletionTest"; import "./provider-completion-test.css"; type InapplicableReason = "disabled" | "forward_auth" | "oauth_auth" | "unsupported_adapter" | "unsupported_transport"; @@ -21,6 +22,35 @@ export type CompletionTestResult = export type CompletionProbeStatus = "untested" | "testing" | "ok" | "failed" | "not-applicable"; export type CompletionFailureRecovery = "aborted" | "concurrency" | "auth" | "rate_limit" | "connection" | "model" | "upstream"; +/** + * The server admits one completion test per provider at a time and answers an overlap with 409. + * Both probes share this gate so neither control can start while the other is confirming or running. + * Each probe gets a stable gate; whether the other probe holds it arrives as the `blocked` prop. + */ +export type ProbeGate = { + claim(): boolean; + release(): void; +}; +type ProbeOwner = "single" | "bulk"; + +function createProbeGates(onChange: (owner: ProbeOwner | null) => void): Record { + let holder: ProbeOwner | null = null; + const gateFor = (owner: ProbeOwner): ProbeGate => ({ + claim() { + if (holder !== null && holder !== owner) return false; + holder = owner; + onChange(owner); + return true; + }, + release() { + if (holder !== owner) return; + holder = null; + onChange(null); + }, + }); + return { single: gateFor("single"), bulk: gateFor("bulk") }; +} + type Props = { apiBase: string; provider: string; @@ -55,6 +85,9 @@ export default function ProviderCompletionTest({ } const rawModel = controlledModel !== undefined ? controlledModel : internalModel; const selectedModel = models.includes(rawModel) ? rawModel : ""; + const [active, setActive] = useState(null); + // Created once per mount; the holder lives in the closure, so render never reads it. + const [gates] = useState(() => createProbeGates(setActive)); return (

{t("pws.completion.title")}

@@ -70,15 +103,23 @@ export default function ProviderCompletionTest({ apiBase={apiBase} provider={provider} model={selectedModel} initialResult={initialResult} initialError={initialError} onStatusChange={onStatusChange} - onFixCredentials={onFixCredentials} onFixSettings={onFixSettings} onFixModels={onFixModels} /> + onFixCredentials={onFixCredentials} onFixSettings={onFixSettings} onFixModels={onFixModels} + gate={gates.single} blocked={active === "bulk"} /> + {/* Keyed by connection identity like the single run: a credential change remounts it, which + aborts its requests and withdraws a disable consent given under the old credential. */} +
); } function CompletionRun({ apiBase, provider, model, initialResult = null, initialError = null, onStatusChange, - onFixCredentials, onFixSettings, onFixModels, -}: Omit & { model: string }) { + onFixCredentials, onFixSettings, onFixModels, gate, blocked, +}: Omit & { + model: string; gate: ProbeGate; blocked: boolean; +}) { const t = useT(); const { locale } = useI18n(); const [busy, setBusy] = useState(false); @@ -90,13 +131,16 @@ function CompletionRun({ mounted.current = true; return () => { mounted.current = false; - flight.current?.abort(); - flight.current = null; + if (flight.current) { + flight.current.abort(); + flight.current = null; + gate.release(); + } }; - }, []); + }, [gate]); async function run() { - if (!model || flight.current) return; + if (!model || flight.current || !gate.claim()) return; const controller = new AbortController(); flight.current = controller; setBusy(true); @@ -138,13 +182,14 @@ function CompletionRun({ } finally { if (flight.current === controller) { flight.current = null; + gate.release(); if (mounted.current && !controller.signal.aborted) setBusy(false); } } } return <> - {error &&

{error}

} diff --git a/gui/src/components/provider-workspace/provider-completion-test.css b/gui/src/components/provider-workspace/provider-completion-test.css index b9c7ea03477..dba3ccc979f 100644 --- a/gui/src/components/provider-workspace/provider-completion-test.css +++ b/gui/src/components/provider-workspace/provider-completion-test.css @@ -31,6 +31,7 @@ margin-top: var(--space-4); } .pws-completion-test > h4, +.pws-completion-bulk, .pws-completion-result { grid-column: 1 / -1; } @@ -40,6 +41,18 @@ .pws-completion-result { overflow-wrap: anywhere; } +.pws-completion-bulk-actions { + gap: var(--space-2); +} +.pws-completion-table { + width: 100%; + border-collapse: collapse; + margin: var(--space-2) 0; +} +.pws-completion-table td { + padding: var(--space-1) var(--space-2) var(--space-1) 0; + vertical-align: top; +} .pws-completion-recovery { display: flex; flex-wrap: wrap; diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 786b0f9f64a..cdfa3f1531b 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -70,6 +70,19 @@ export const de: Record = { "pws.completion.upstream_http_version": "Eine Upstream-HTTP-Version ist konfiguriert.", "pws.completion.request_pacing": "Die Anfragedrosselung ist aktiviert.", "pws.completion.custom_executor_egress": "Ein eigener Fetch-Executor mit Anbieter-Proxy kann nicht beibehalten werden.", + "pws.completion.runAll": "Alle Modelle testen", + "pws.completion.allConfirm": "Alle Modelle bei {provider} testen ({count} insgesamt)? Dabei werden echte Anfragen nacheinander gesendet (höchstens {count}), jeweils mit einer Ausgabegrenze von 32 Tokens. Manche Modelle werden vor dem Senden übersprungen. Das verbraucht Kontingent und kann Kosten verursachen.", + "pws.completion.stop": "Stopp", + "pws.completion.progress": "Teste {done} von {total}…", + "pws.completion.stopped": "Gestoppt: {done} von {total} getestet", + "pws.completion.summary": "{passed} bestanden, {failed} fehlgeschlagen, {skipped} nicht getestet", + "pws.completion.passed": "Bestanden", + "pws.completion.failedShort": "Fehlgeschlagen", + "pws.completion.notTested": "Nicht getestet", + "pws.completion.disableFailed": "Fehlgeschlagene Modelle deaktivieren ({count})", + "pws.completion.disableConfirm": "Modelle deaktivieren, die den Test bei {provider} nicht bestanden haben ({count})? Sie fehlen dann in Katalog und Modellauswahl, bleiben aber per genauer ID aufrufbar, bis du sie auf der Seite „Modelle“ wieder aktivierst.", + "pws.completion.disabledDone": "Deaktivierte Modelle: {count}. Auf der Seite „Modelle“ kannst du sie wieder aktivieren.", + "pws.completion.disableError": "Die fehlgeschlagenen Modelle konnten nicht deaktiviert werden", "reasoningRetention.advanced": "Erweiterte Einstellungen", "reasoningRetention.title": "Aufbewahrung von Denktext", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 7312751c797..a506d8c7596 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -71,6 +71,19 @@ export const en = { "pws.completion.upstream_http_version": "An upstream HTTP version is configured.", "pws.completion.request_pacing": "Request pacing is enabled.", "pws.completion.custom_executor_egress": "A custom fetch executor with a provider proxy cannot be preserved.", + "pws.completion.runAll": "Test all models", + "pws.completion.allConfirm": "Test every model on {provider} ({count} total)? This sends real completion requests one at a time (up to {count}), each with an output cap of 32 tokens. Some models may be skipped before sending. It consumes quota and may incur costs.", + "pws.completion.stop": "Stop", + "pws.completion.progress": "Testing {done} of {total}…", + "pws.completion.stopped": "Stopped: {done} of {total} tested", + "pws.completion.summary": "{passed} passed, {failed} failed, {skipped} not tested", + "pws.completion.passed": "Passed", + "pws.completion.failedShort": "Failed", + "pws.completion.notTested": "Not tested", + "pws.completion.disableFailed": "Disable failed models ({count})", + "pws.completion.disableConfirm": "Disable the models that failed the test on {provider} ({count})? They leave the model catalog and picker but stay callable by exact id until you re-enable them on the Models page.", + "pws.completion.disabledDone": "Models disabled: {count}. Re-enable them on the Models page.", + "pws.completion.disableError": "Could not disable the failed models", "reasoningRetention.advanced": "Advanced settings", "reasoningRetention.title": "Reasoning retention", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 58a385a02f4..40e1f436ae7 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -69,6 +69,19 @@ export const fr: Record = { "pws.completion.upstream_http_version": "Une version HTTP amont est configurée.", "pws.completion.request_pacing": "La régulation des requêtes est activée.", "pws.completion.custom_executor_egress": "Un exécuteur fetch personnalisé avec un proxy fournisseur ne peut pas être conservé.", + "pws.completion.runAll": "Tester tous les modèles", + "pws.completion.allConfirm": "Tester tous les modèles de {provider} ({count} au total) ? Cela envoie de vraies requêtes une à la fois (au plus {count}), chacune limitée à 32 jetons de sortie. Certains modèles peuvent être ignorés avant l’envoi. Cela consomme du quota et peut entraîner des coûts.", + "pws.completion.stop": "Arrêter", + "pws.completion.progress": "Test {done} sur {total}…", + "pws.completion.stopped": "Arrêté : {done} sur {total} testés", + "pws.completion.summary": "Réussis : {passed}, échoués : {failed}, non testés : {skipped}", + "pws.completion.passed": "Réussi", + "pws.completion.failedShort": "Échoué", + "pws.completion.notTested": "Non testé", + "pws.completion.disableFailed": "Désactiver les modèles en échec ({count})", + "pws.completion.disableConfirm": "Désactiver les modèles ayant échoué au test sur {provider} ({count}) ? Ils sont retirés du catalogue et du sélecteur, mais restent accessibles par leur identifiant exact jusqu’à leur réactivation dans la page Modèles.", + "pws.completion.disabledDone": "Modèles désactivés : {count}. Réactivez-les dans la page Modèles.", + "pws.completion.disableError": "Impossible de désactiver les modèles en échec", "reasoningRetention.advanced": "Paramètres avancés", "reasoningRetention.title": "Conservation du raisonnement", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 1049e8905f2..7d74ccd8c92 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -69,6 +69,19 @@ export const ja: Record = { "pws.completion.upstream_http_version": "上流のHTTPバージョンが設定されています。", "pws.completion.request_pacing": "リクエストのペーシングが有効です。", "pws.completion.custom_executor_egress": "プロバイダーのプロキシを併用するカスタムfetch実行器を維持できません。", + "pws.completion.runAll": "すべてのモデルをテスト", + "pws.completion.allConfirm": "{provider} の {count} 個のモデルをすべてテストしますか?出力上限 32 トークンの実際のリクエストを 1 件ずつ最大 {count} 件送信します。一部のモデルは送信前にスキップされる場合があります。クォータを消費し、費用が発生する場合があります。", + "pws.completion.stop": "停止", + "pws.completion.progress": "{total} 件中 {done} 件をテスト中…", + "pws.completion.stopped": "{total} 件中 {done} 件で停止しました", + "pws.completion.summary": "成功 {passed} 件、失敗 {failed} 件、未テスト {skipped} 件", + "pws.completion.passed": "成功", + "pws.completion.failedShort": "失敗", + "pws.completion.notTested": "未テスト", + "pws.completion.disableFailed": "失敗した {count} 個のモデルを無効化", + "pws.completion.disableConfirm": "{provider} でテストに失敗した {count} 個のモデルを無効化しますか?モデルページで再度有効にするまでカタログとモデル選択から外れますが、正確な id では引き続き直接呼び出せます。", + "pws.completion.disabledDone": "{count} 個のモデルを無効化しました。モデルページで再度有効にできます。", + "pws.completion.disableError": "失敗したモデルを無効化できませんでした", "reasoningRetention.advanced": "詳細設定", "reasoningRetention.title": "推論の保持", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index c8b2b6e6fb7..bc6d056aa59 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -69,6 +69,19 @@ export const ko: Record = { "pws.completion.upstream_http_version": "업스트림 HTTP 버전이 설정되어 있습니다.", "pws.completion.request_pacing": "요청 속도 조절이 활성화되어 있습니다.", "pws.completion.custom_executor_egress": "공급자 프록시와 함께 사용하는 사용자 지정 fetch 실행기를 유지할 수 없습니다.", + "pws.completion.runAll": "모든 모델 테스트", + "pws.completion.allConfirm": "{provider}의 모델 {count}개를 모두 테스트할까요? 출력 상한 32토큰의 실제 요청을 최대 {count}건 하나씩 보냅니다. 일부 모델은 요청을 보내기 전에 건너뛸 수 있습니다. 할당량을 소비하며 비용이 발생할 수 있습니다.", + "pws.completion.stop": "중지", + "pws.completion.progress": "{total}개 중 {done}개 테스트 중…", + "pws.completion.stopped": "{total}개 중 {done}개에서 중지됨", + "pws.completion.summary": "성공 {passed}개, 실패 {failed}개, 미테스트 {skipped}개", + "pws.completion.passed": "성공", + "pws.completion.failedShort": "실패", + "pws.completion.notTested": "미테스트", + "pws.completion.disableFailed": "실패한 모델 {count}개 비활성화", + "pws.completion.disableConfirm": "{provider}에서 테스트에 실패한 모델 {count}개를 비활성화할까요? 모델 페이지에서 다시 활성화할 때까지 카탈로그와 모델 선택기에서 빠지지만, 정확한 id로는 계속 직접 호출할 수 있습니다.", + "pws.completion.disabledDone": "모델 {count}개를 비활성화했습니다. 모델 페이지에서 다시 활성화할 수 있습니다.", + "pws.completion.disableError": "실패한 모델을 비활성화하지 못했습니다", "reasoningRetention.advanced": "고급 설정", "reasoningRetention.title": "추론 보존", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index 2ac1c54aed8..5b5a2278907 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -71,6 +71,19 @@ export const pt: Record = { "pws.completion.upstream_http_version": "Uma versão HTTP de origem está configurada.", "pws.completion.request_pacing": "O controle de ritmo das solicitações está ativado.", "pws.completion.custom_executor_egress": "Não é possível manter um executor fetch personalizado com proxy do provedor.", + "pws.completion.runAll": "Testar todos os modelos", + "pws.completion.allConfirm": "Testar todos os modelos de {provider} ({count} no total)? Isso envia requisições reais, uma de cada vez (no máximo {count}), cada uma com limite de saída de 32 tokens. Alguns modelos podem ser ignorados antes do envio. Consome cota e pode gerar custos.", + "pws.completion.stop": "Parar", + "pws.completion.progress": "Testando {done} de {total}…", + "pws.completion.stopped": "Interrompido: {done} de {total} testados", + "pws.completion.summary": "Aprovados: {passed}; com falha: {failed}; não testados: {skipped}", + "pws.completion.passed": "Aprovado", + "pws.completion.failedShort": "Falhou", + "pws.completion.notTested": "Não testado", + "pws.completion.disableFailed": "Desativar modelos com falha ({count})", + "pws.completion.disableConfirm": "Desativar os modelos que falharam no teste em {provider} ({count})? Eles ficam fora do catálogo e do seletor de modelos, mas continuam acessíveis pelo ID exato até serem reativados na página Modelos.", + "pws.completion.disabledDone": "Modelos desativados: {count}. Reative-os na página Modelos.", + "pws.completion.disableError": "Não foi possível desativar os modelos com falha", "reasoningRetention.advanced": "Configurações avançadas", "reasoningRetention.title": "Retenção de raciocínio", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 5b1fb9df634..f9061168408 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -69,6 +69,19 @@ export const ru: Record = { "pws.completion.upstream_http_version": "Настроена версия HTTP вышестоящего сервера.", "pws.completion.request_pacing": "Включено ограничение частоты запросов.", "pws.completion.custom_executor_egress": "Невозможно сохранить пользовательский исполнитель fetch с прокси провайдера.", + "pws.completion.runAll": "Проверить все модели", + "pws.completion.allConfirm": "Проверить модели у {provider} (всего: {count})? Будут отправлены реальные запросы по одному (не более {count}), каждый с лимитом вывода 32 токена. Некоторые модели могут быть пропущены до отправки. Это расходует квоту и может повлечь расходы.", + "pws.completion.stop": "Остановить", + "pws.completion.progress": "Проверка {done} из {total}…", + "pws.completion.stopped": "Остановлено: проверено {done} из {total}", + "pws.completion.summary": "Успешно: {passed}, ошибок: {failed}, не проверено: {skipped}", + "pws.completion.passed": "Успешно", + "pws.completion.failedShort": "Ошибка", + "pws.completion.notTested": "Не проверено", + "pws.completion.disableFailed": "Отключить модели с ошибкой ({count})", + "pws.completion.disableConfirm": "Отключить модели, не прошедшие проверку у {provider} (всего: {count})? Они исчезнут из каталога и селектора, но останутся вызываемыми по точному id, пока вы не включите их снова на странице «Модели».", + "pws.completion.disabledDone": "Отключено моделей: {count}. Включить их снова можно на странице «Модели».", + "pws.completion.disableError": "Не удалось отключить модели с ошибкой", "reasoningRetention.advanced": "Расширенные настройки", "reasoningRetention.title": "Сохранение рассуждений", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 1f7b601d33d..e11bdc2d8d5 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -70,6 +70,19 @@ export const tr: Record = { "pws.completion.upstream_http_version": "Üst sunucu HTTP sürümü yapılandırılmış.", "pws.completion.request_pacing": "İstek aralığı sınırlaması etkin.", "pws.completion.custom_executor_egress": "Sağlayıcı vekiliyle kullanılan özel fetch yürütücüsü korunamaz.", + "pws.completion.runAll": "Tüm modelleri test et", + "pws.completion.allConfirm": "{provider} üzerindeki {count} modelin tümü test edilsin mi? Her biri 32 token çıktı sınırına sahip en fazla {count} gerçek istek tek tek gönderilir. Bazı modeller gönderimden önce atlanabilir. Kota tüketir ve maliyet oluşturabilir.", + "pws.completion.stop": "Durdur", + "pws.completion.progress": "{total} içinden {done} test ediliyor…", + "pws.completion.stopped": "{total} modelden {done} tanesinden sonra durduruldu", + "pws.completion.summary": "{passed} başarılı, {failed} başarısız, {skipped} test edilmedi", + "pws.completion.passed": "Başarılı", + "pws.completion.failedShort": "Başarısız", + "pws.completion.notTested": "Test edilmedi", + "pws.completion.disableFailed": "Başarısız {count} modeli devre dışı bırak", + "pws.completion.disableConfirm": "{provider} üzerinde testi geçemeyen {count} model devre dışı bırakılsın mı? Modeller sayfasında yeniden etkinleştirilene kadar katalogda ve model seçicide görünmezler, ancak tam kimlikleriyle doğrudan çağrılabilirler.", + "pws.completion.disabledDone": "{count} model devre dışı bırakıldı. Modeller sayfasında yeniden etkinleştirebilirsiniz.", + "pws.completion.disableError": "Başarısız modeller devre dışı bırakılamadı", "reasoningRetention.advanced": "Gelişmiş ayarlar", "reasoningRetention.title": "Akıl yürütmeyi saklama", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 02e572a519b..1d75e051d14 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -71,6 +71,19 @@ export const vi: Record = { "pws.completion.upstream_http_version": "Đã cấu hình phiên bản HTTP của máy chủ nguồn.", "pws.completion.request_pacing": "Đã bật điều tiết yêu cầu.", "pws.completion.custom_executor_egress": "Không thể giữ bộ thực thi fetch tùy chỉnh kết hợp proxy của nhà cung cấp.", + "pws.completion.runAll": "Kiểm tra tất cả mô hình", + "pws.completion.allConfirm": "Kiểm tra cả {count} mô hình trên {provider}? Thao tác này gửi lần lượt tối đa {count} yêu cầu thật, mỗi yêu cầu giới hạn đầu ra 32 token. Một số mô hình có thể được bỏ qua trước khi gửi. Việc này tiêu tốn hạn mức và có thể phát sinh chi phí.", + "pws.completion.stop": "Dừng", + "pws.completion.progress": "Đang kiểm tra {done}/{total}…", + "pws.completion.stopped": "Đã dừng sau {done}/{total} mô hình", + "pws.completion.summary": "{passed} đạt, {failed} lỗi, {skipped} chưa kiểm tra", + "pws.completion.passed": "Đạt", + "pws.completion.failedShort": "Lỗi", + "pws.completion.notTested": "Chưa kiểm tra", + "pws.completion.disableFailed": "Tắt {count} mô hình lỗi", + "pws.completion.disableConfirm": "Tắt {count} mô hình không qua kiểm tra trên {provider}? Cho đến khi bạn bật lại trong trang Mô hình, chúng sẽ không xuất hiện trong catalog và bộ chọn model nhưng vẫn có thể được gọi trực tiếp bằng id chính xác.", + "pws.completion.disabledDone": "Đã tắt {count} mô hình. Bật lại trong trang Mô hình.", + "pws.completion.disableError": "Không thể tắt các mô hình lỗi", "reasoningRetention.advanced": "Cài đặt nâng cao", "reasoningRetention.title": "Lưu giữ suy luận", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 25e0a0dc21a..ef74649ba6f 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -67,6 +67,19 @@ export const zhTW: Record = { "pws.completion.upstream_http_version": "已設定上游 HTTP 版本。", "pws.completion.request_pacing": "已啟用請求速率控制。", "pws.completion.custom_executor_egress": "無法保留同時使用供應商代理的自訂 fetch 執行器。", + "pws.completion.runAll": "測試所有模型", + "pws.completion.allConfirm": "測試 {provider} 上全部 {count} 個模型?這會逐一送出最多 {count} 個真實請求,每個請求的輸出上限為 32 個 token。部分模型可能在送出請求前被略過。這會消耗配額並可能產生費用。", + "pws.completion.stop": "停止", + "pws.completion.progress": "正在測試 {done}/{total}…", + "pws.completion.stopped": "已在 {done}/{total} 個模型後停止", + "pws.completion.summary": "通過 {passed} 個,失敗 {failed} 個,未測試 {skipped} 個", + "pws.completion.passed": "通過", + "pws.completion.failedShort": "失敗", + "pws.completion.notTested": "未測試", + "pws.completion.disableFailed": "停用 {count} 個失敗的模型", + "pws.completion.disableConfirm": "停用 {provider} 上未通過測試的 {count} 個模型?在模型頁面重新啟用之前,它們不會出現在目錄和模型選擇器中,但仍可用精確 id 直接呼叫。", + "pws.completion.disabledDone": "已停用 {count} 個模型。可在模型頁面重新啟用。", + "pws.completion.disableError": "無法停用失敗的模型", "reasoningRetention.advanced": "進階設定", "reasoningRetention.title": "推理保留", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 49d06eaf642..0658144cc0d 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -69,6 +69,19 @@ export const zh: Record = { "pws.completion.upstream_http_version": "已配置上游 HTTP 版本。", "pws.completion.request_pacing": "已启用请求速率控制。", "pws.completion.custom_executor_egress": "无法保留同时使用提供商代理的自定义 fetch 执行器。", + "pws.completion.runAll": "测试所有模型", + "pws.completion.allConfirm": "测试 {provider} 上全部 {count} 个模型?这会逐个发送最多 {count} 个真实请求,每个请求的输出上限为 32 个 token。部分模型可能在发送请求前被跳过。这会消耗配额并可能产生费用。", + "pws.completion.stop": "停止", + "pws.completion.progress": "正在测试 {done}/{total}…", + "pws.completion.stopped": "已在 {done}/{total} 个模型后停止", + "pws.completion.summary": "通过 {passed} 个,失败 {failed} 个,未测试 {skipped} 个", + "pws.completion.passed": "通过", + "pws.completion.failedShort": "失败", + "pws.completion.notTested": "未测试", + "pws.completion.disableFailed": "停用 {count} 个失败的模型", + "pws.completion.disableConfirm": "停用 {provider} 上未通过测试的 {count} 个模型?在模型页面重新启用之前,它们不会出现在目录和模型选择器中,但仍可按精确 id 直接调用。", + "pws.completion.disabledDone": "已停用 {count} 个模型。可在模型页面重新启用。", + "pws.completion.disableError": "无法停用失败的模型", "reasoningRetention.advanced": "高级设置", "reasoningRetention.title": "推理保留", diff --git a/gui/tests/provider-bulk-completion-test.test.tsx b/gui/tests/provider-bulk-completion-test.test.tsx new file mode 100644 index 00000000000..1e684a14ce6 --- /dev/null +++ b/gui/tests/provider-bulk-completion-test.test.tsx @@ -0,0 +1,293 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import ProviderOverview from "../src/components/provider-workspace/ProviderOverview"; +import type { CompletionTestResult } from "../src/components/provider-workspace/ProviderCompletionTest"; +import { LanguageProvider } from "../src/i18n/provider"; +import type { WorkspaceItem } from "../src/provider-workspace/catalog"; +import { acceptActionDialog, actionDialogOpen, actionDialogText, dismissActionDialog } from "./helpers/action-dialog"; + +const globals = ["document", "window", "navigator", "localStorage", "HTMLElement", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previous: Record; +let win: Window; +let root: Root | undefined; +let host: HTMLElement; +let requests: Array<{ url: string; init: RequestInit }>; +const originalFetch = globalThis.fetch; +const item: WorkspaceItem = { name: "vendor", adapter: "openai-chat", baseUrl: "https://example.test/v1", + hasApiKey: true, models: ["good", "bad"], defaultModel: "busy" }; +const success: Extract = { + applicable: true, ok: true, provider: item.name, requestedModel: "good", wireModel: "good", + wire: "openai-chat", upstreamModel: "good", modelComparison: "match", identityVerified: false, + ttftMs: 12, totalLatencyMs: 45, outputTokenCap: 32, termination: "completed", error: null, +}; +let answers: Record Response | Promise>; +let visibilityAnswer: () => Response | Promise; + +beforeEach(() => { + previous = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])); + win = new Window({ url: "http://localhost/#providers" }); + Object.defineProperty(win.navigator, "language", { configurable: true, value: "en-US" }); + for (const [key, value] of Object.entries({ window: win, document: win.document, navigator: win.navigator, + localStorage: win.localStorage, HTMLElement: win.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true })) { + Object.defineProperty(globalThis, key, { configurable: true, writable: true, value }); + } + host = document.createElement("div"); document.body.append(host); + requests = []; + visibilityAnswer = () => Response.json({ ok: true }); + answers = { + good: () => Response.json(success), + bad: () => Response.json({ ...success, ok: false, termination: "failed", ttftMs: null, + error: { code: "http_status", status: 404, message: "model not found" } }), + busy: () => Response.json({ error: "completion test already running for this provider" }, { status: 409 }), + }; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + requests.push({ url: String(input), init: init! }); + if (String(input).includes("/api/model-visibility")) return visibilityAnswer(); + return answers[JSON.parse(String(init!.body)).model as string]!(); + }) as typeof fetch; +}); +afterEach(async () => { + await act(async () => { root?.unmount(); }); root = undefined; + globalThis.fetch = originalFetch; + win.close(); + for (const key of globals) Object.defineProperty(globalThis, key, { configurable: true, writable: true, value: previous[key] }); +}); +async function render(connectionIdentity = "credential") { + const { createRoot } = await import("react-dom/client"); root ??= createRoot(host); + await act(async () => { + root!.render(); + }); +} +function button(text: string | RegExp) { + return Array.from(host.querySelectorAll("button")) + .find(b => typeof text === "string" ? b.textContent === text : text.test(b.textContent ?? "")); +} +async function runAll(accept = true) { + await act(async () => { button("Test all models")!.click(); }); + expect(actionDialogOpen(document)).toBe(true); + if (accept) await act(async () => { acceptActionDialog(document); }); +} +const outcomes = () => Array.from(host.querySelectorAll("[data-model-test-outcome]")) + .map(row => [row.querySelector("code")!.textContent, row.getAttribute("data-model-test-outcome")]); + +test("one confirmation names the count and cost; canceling sends nothing", async () => { + await render(); await runAll(false); + expect(actionDialogText(document)).toContain("(3 total)"); + expect(actionDialogText(document)).toContain("real completion requests one at a time (up to 3)"); + expect(actionDialogText(document)).toContain("skipped before sending"); + expect(actionDialogText(document)).toContain("quota"); + expect(actionDialogText(document)).toContain("32"); + await act(async () => { dismissActionDialog(document); }); + expect(requests).toHaveLength(0); + expect(button("Test all models")!.disabled).toBe(false); +}); + +test("tests every deduplicated model in order and classifies the outcomes", async () => { + await render(); await runAll(); + expect(requests.map(r => JSON.parse(String(r.init.body)))).toEqual([ + { model: "good", confirm: true }, { model: "bad", confirm: true }, { model: "busy", confirm: true }, + ]); + expect(outcomes()).toEqual([["good", "ok"], ["bad", "failed"], ["busy", "skipped"]]); + expect(host.textContent).toContain("1 passed, 1 failed, 1 not tested"); + expect(host.textContent).toContain("http_status (404)"); +}); + +test("disabling failed models needs a second consent and hides only upstream failures", async () => { + await render(); await runAll(); + await act(async () => { button("Disable failed models (1)")!.click(); }); + expect(actionDialogText(document)).toContain("stay callable by exact id"); + await act(async () => { dismissActionDialog(document); }); + expect(requests.filter(r => r.url.includes("model-visibility"))).toHaveLength(0); + await act(async () => { button("Disable failed models (1)")!.click(); }); + await act(async () => { acceptActionDialog(document); }); + const put = requests.find(r => r.url === "http://localhost/api/model-visibility")!; + expect(put.init.method).toBe("PUT"); + expect(JSON.parse(String(put.init.body))).toEqual({ scope: "models", provider: "vendor", targets: [{ id: "bad" }], enabled: false }); + expect(host.textContent).toContain("Models disabled: 1"); + expect(button(/^Disable/)).toBeUndefined(); +}); + +test("no disable action is offered when nothing failed upstream", async () => { + answers.bad = answers.good!; + await render(); await runAll(); + expect(button(/^Disable/)).toBeUndefined(); +}); + +test("a provider-level inapplicable result stops the run after one request", async () => { + answers.good = () => Response.json({ applicable: false, reason: "oauth_auth" }); + await render(); await runAll(); + expect(requests).toHaveLength(1); + expect(host.textContent).toContain("OAuth authentication is not supported."); + expect(outcomes()).toEqual([]); +}); + +test("stop aborts the in-flight request and sends no more", async () => { + let release!: (response: Response) => void; + answers.bad = () => new Promise(resolve => { release = resolve; }); + await render(); await runAll(); + expect(requests).toHaveLength(2); + const signal = requests[1]!.init.signal!; + await act(async () => { button("Stop")!.click(); }); + expect(signal.aborted).toBe(true); + await act(async () => { release(Response.json(success)); }); + expect(requests).toHaveLength(2); + expect(outcomes()).toEqual([["good", "ok"]]); + expect(host.textContent).toContain("Stopped: 1 of 3 tested"); +}); + +test("stopped run cleanup preserves the restarted run and its Stop control", async () => { + const releases: Array<(response: Response) => void> = []; + answers.bad = () => new Promise(resolve => { releases.push(resolve); }); + await render(); await runAll(); + expect(requests).toHaveLength(2); + const firstSignal = requests[1]!.init.signal!; + await act(async () => { button("Stop")!.click(); }); + expect(firstSignal.aborted).toBe(true); + + await runAll(); + expect(requests).toHaveLength(4); + const restartedSignal = requests[3]!.init.signal!; + expect(restartedSignal.aborted).toBe(false); + expect(button("Stop")).toBeDefined(); + + // Let the stopped request settle only after the next run is already in flight. + await act(async () => { releases[0]!(Response.json(success)); }); + expect(requests).toHaveLength(4); + expect(outcomes()).toEqual([["good", "ok"]]); + expect(host.querySelector("[data-completion-bulk-state]")?.getAttribute("data-completion-bulk-state")).toBe("running"); + expect(button(/^Testing/)!.disabled).toBe(true); + expect(button("Stop")).toBeDefined(); + expect(restartedSignal.aborted).toBe(false); + + await act(async () => { button("Stop")!.click(); }); + expect(restartedSignal.aborted).toBe(true); + await act(async () => { releases[1]!(Response.json(success)); }); + expect(requests).toHaveLength(4); + expect(outcomes()).toEqual([["good", "ok"]]); + expect(button("Stop")).toBeUndefined(); + expect(button("Test all models")!.disabled).toBe(false); + expect(host.textContent).toContain("Stopped: 1 of 3 tested"); +}); + +test.each(["credential_missing", "credential_changed", "destination_refused"])( + "%s is not evidence that a model failed", async code => { + answers.good = () => Response.json({ ...success, ok: false, termination: "failed", + error: { code, message: "provider admission refused" } }); + answers.bad = answers.good!; + await render(); await runAll(); + expect(outcomes()).toEqual([["good", "skipped"], ["bad", "skipped"], ["busy", "skipped"]]); + expect(host.textContent).toContain("0 passed, 0 failed, 3 not tested"); + expect(host.textContent).toContain(code); + expect(button(/^Disable/)).toBeUndefined(); + }, +); + +test("an unsupported model wire does not stop later supported models", async () => { + answers.good = () => Response.json({ applicable: false, reason: "unsupported_adapter", adapter: "anthropic" }); + answers.bad = () => Response.json(success); + await render(); await runAll(); + expect(requests).toHaveLength(3); + expect(outcomes()).toEqual([["good", "skipped"], ["bad", "ok"], ["busy", "skipped"]]); + expect(host.textContent).toContain("1 passed, 0 failed, 2 not tested"); + expect(button(/^Disable/)).toBeUndefined(); +}); + +test.each(["unmount", "credential change"])("%s withdraws stale disable consent", async change => { + await render(); await runAll(); + await act(async () => { button("Disable failed models (1)")!.click(); }); + expect(actionDialogOpen(document)).toBe(true); + const oldAccept = document.querySelector("dialog button[type=submit]") as HTMLButtonElement; + if (change === "unmount") { + await act(async () => { root!.unmount(); }); root = undefined; + } else await render("replacement-credential"); + const stillOpen = actionDialogOpen(document); + // Even a queued click on the retired dialog must not issue the mutation. + await act(async () => { oldAccept.click(); }); + expect(requests.filter(request => request.url.includes("model-visibility"))).toHaveLength(0); + expect(stillOpen).toBe(false); +}); + +test("a pending disable is serialized before a new model-test batch", async () => { + let release!: (response: Response) => void; + visibilityAnswer = () => new Promise(resolve => { release = resolve; }); + await render(); await runAll(); + await act(async () => { button("Disable failed models (1)")!.click(); }); + expect(button("Test all models")!.disabled).toBe(true); + await act(async () => { acceptActionDialog(document); }); + expect(requests.filter(request => request.url.includes("model-visibility"))).toHaveLength(1); + expect(button("Test all models")!.disabled).toBe(true); + await act(async () => { button("Test all models")!.click(); }); + expect(actionDialogOpen(document)).toBe(false); + expect(requests).toHaveLength(4); + await act(async () => { release(Response.json({ ok: true })); }); + expect(button("Test all models")!.disabled).toBe(false); + await runAll(); + expect(outcomes()).toEqual([["good", "ok"], ["bad", "failed"], ["busy", "skipped"]]); + expect(button("Disable failed models (1)")!.disabled).toBe(false); + expect(host.textContent).not.toContain("Models disabled: 1"); +}); + +test("a credential change aborts a pending disable write and ignores its late result", async () => { + let release!: (response: Response) => void; + visibilityAnswer = () => new Promise(resolve => { release = resolve; }); + await render(); await runAll(); + await act(async () => { button("Disable failed models (1)")!.click(); }); + await act(async () => { acceptActionDialog(document); }); + const mutation = requests.find(request => request.url.includes("model-visibility"))!; + expect(mutation.init.signal?.aborted).toBe(false); + await render("replacement-credential"); + expect(mutation.init.signal?.aborted).toBe(true); + await runAll(); + await act(async () => { release(Response.json({ ok: true })); }); + expect(button("Disable failed models (1)")!.disabled).toBe(false); + expect(host.textContent).not.toContain("Models disabled: 1"); +}); + +test.each([401, 403, 429])("upstream HTTP %s is not a disable target", async status => { + answers.good = () => Response.json({ ...success, ok: false, termination: "failed", + error: { code: "http_status", status, message: "provider refused the request" } }); + answers.bad = answers.good!; + await render(); await runAll(); + expect(outcomes()).toEqual([["good", "skipped"], ["bad", "skipped"], ["busy", "skipped"]]); + expect(host.textContent).toContain(`http_status (${status})`); + expect(host.textContent).toContain("0 passed, 0 failed, 3 not tested"); + expect(button(/^Disable/)).toBeUndefined(); +}); + +async function selectSingle(model: string) { + await act(async () => { host.querySelector('[role="combobox"][aria-label="Model to test"]')!.click(); }); + const option = Array.from(document.querySelectorAll('[role="option"]')).find(o => o.textContent === model)!; + await act(async () => { option.click(); }); +} + +test("the single and all-model probes share one in-flight guard, so neither can overlap the other", async () => { + let releaseSingle!: (response: Response) => void; + answers.good = () => new Promise(resolve => { releaseSingle = resolve; }); + await render(); await selectSingle("good"); + await act(async () => { button("Test model")!.click(); }); + expect(button("Test all models")!.disabled).toBe(true); + await act(async () => { acceptActionDialog(document); }); + expect(requests).toHaveLength(1); + expect(button("Test all models")!.disabled).toBe(true); + await act(async () => { button("Test all models")!.click(); }); + expect(actionDialogOpen(document)).toBe(false); + expect(requests).toHaveLength(1); + await act(async () => { releaseSingle(Response.json(success)); }); + expect(button("Test all models")!.disabled).toBe(false); + + let releaseBulk!: (response: Response) => void; + answers.good = () => new Promise(resolve => { releaseBulk = resolve; }); + await runAll(); + expect(requests).toHaveLength(2); + expect(button("Test model")!.disabled).toBe(true); + await act(async () => { button("Test model")!.click(); }); + expect(actionDialogOpen(document)).toBe(false); + expect(requests).toHaveLength(2); + await act(async () => { button("Stop")!.click(); }); + await act(async () => { releaseBulk(Response.json(success)); }); + expect(button("Test model")!.disabled).toBe(false); + expect(host.querySelector("[role=alert]")).toBeNull(); +}); diff --git a/structure/provider-completion-diagnostic.md b/structure/provider-completion-diagnostic.md index 32067a70e12..133671184ca 100644 --- a/structure/provider-completion-diagnostic.md +++ b/structure/provider-completion-diagnostic.md @@ -11,3 +11,9 @@ Local HTTP probes materialize the existing effective private-network policy (ope A memory-only fingerprint captures wire, URL, wire model, computed gateway routing, local-cleartext admission, sorted headers, auth, key selection/resolved key, destination policy, egress and disabled state. Before physical send the same live row must reproduce it, with current key-selection authority for credentialed rows; changes return `credential_changed` with no send. The prompt is `Reply exactly OK.`, output cap 32, deadline 20 seconds and stream cap 64 KiB including partial frames. Shared SSE decoding records TTFT at the first non-empty text delta. Chat DONE or finish-reason plus EOF, and Responses completed/incomplete events, establish completion only with text. Every exit aborts, cancels and disposes the diagnostic's translator budget. Errors use fixed messages; HTTP failures include status without reading bodies. Raw model comparison precedes known-secret masking, control stripping, 256-character truncation and generic redaction. Missing/conflicting metadata is unknown; `identityVerified` is false. No output preview, config, health, history or catalog mutation is produced. + +## Dashboard bulk coordinator + +`gui/src/components/provider-workspace/ProviderBulkCompletionTest.tsx` runs the deduplicated model list sequentially after one explicit cost confirmation naming the maximum request count; pre-send refusals can reduce actual sends. It and the single-model probe claim one provider-level gate owned by `ProviderCompletionTest.tsx` for their confirmation and requests, so the dashboard never provokes the route's concurrent-test 409. Stop aborts its controller; a retiring run cannot clear a newer run's controls. `unsupported_adapter` is per-model because effective wires can differ within a provider, so it records a not-tested row and continues. Other applicability refusals stop the provider run. Management HTTP refusals, aborts, `credential_missing`, `credential_changed`, and `destination_refused` are not model failures and never become disable targets. Upstream HTTP 401, 403 and 429 responses also produce not-tested rows because authentication, access or quota refusal does not establish that the model failed. + +Only rows with an upstream failure are offered to the separately confirmed `putModelVisibility` mutation in `gui/src/model-visibility.ts`. A dedicated controller owns both that consent and its write, is aborted on probe unmount (including a changed provider connection identity), and is rechecked before sending or publishing a result. Consent and the visibility write serialize against new batches. `gui/tests/provider-bulk-completion-test.test.tsx` covers the classification, mixed-wire continuation, stale-consent refusal, serialized mutation and Stop/restart ownership. From a391835c235d6a173460a7b606058a3c151da157 Mon Sep 17 00:00:00 2001 From: Omar Date: Thu, 29 Oct 2026 13:49:32 +0600 Subject: [PATCH 37/84] feat(responses): complete gracefully on max_output_tokens (#7770) * feat(responses): complete gracefully on max_output_tokens When an upstream model hits max_output_tokens / max_tokens, Codex CLI and consumers can fail (e.g. 'Error running remote compact task: Incomplete response returned, reason: max_output_tokens', retry loop exhaustion, or parsing errors on open markdown code fences). - Adds closeOpenMarkdownBlocks to close dangling fences and paired tags. - Introduces completeOnMaxOutputTokens config option (defaults to true). - In bridge SSE, response JSON, and codex-ws exchange, gracefully completes truncated turns with status completed, repairs markdown fences, and synthesizes compaction items when compaction was requested. * test(responses): remove empty catch in graceful max output tokens test * fix(config): default completeOnMaxOutputTokens to false to preserve strict unconfigured contracts * fix(responses): narrow opted-in token-limit completion to plain text Preserve the contributor history while removing compaction exceptions, native WebSocket rewrites, and output repair. Exclude structured output and all non-text operations before bridge cleanup; retain provider continuation state and usage. Register bridge and real delivery regressions in the existing layout shards. Co-authored-by: Omar <13569609+omar391@users.noreply.github.com> * docs(responses): document narrowed token-limit completion policy * docs(responses): index token-limit setting and bridge ownership Add the English server-field index entry and map the streaming and buffered bridge builders to their completion-policy contract. Preserve the detailed eligibility explanation and runtime behavior. Co-authored-by: Omar <13569609+omar391@users.noreply.github.com> --------- Co-authored-by: Omar <13569609+omar391@users.noreply.github.com> Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> --- .../docs/reference/configuration/server.md | 10 + scripts/test-layout/layout-responses.json | 2 + src/bridge/max-output-completion.ts | 41 ++++ src/bridge/response-json.ts | 13 +- src/bridge/sse.ts | 15 +- src/config/schema/config-schema.ts | 1 + src/server/responses/adapter-delivery.ts | 4 + src/server/responses/run-turn-execution.ts | 4 + src/types/config.ts | 2 + structure/INDEX.md | 11 +- structure/config.md | 2 +- structure/manifest.json | 15 ++ structure/transports/max-output-completion.md | 29 +++ structure/transports/responses.md | 1 + tests/fixtures/test-layout-expected.json | 2 + ...ceful-max-output-tokens-exclusions.test.ts | 168 ++++++++++++++ .../graceful-max-output-tokens.test.ts | 215 ++++++++++++++++++ 17 files changed, 525 insertions(+), 10 deletions(-) create mode 100644 src/bridge/max-output-completion.ts create mode 100644 structure/transports/max-output-completion.md create mode 100644 tests/responses/graceful-max-output-tokens-exclusions.test.ts create mode 100644 tests/responses/graceful-max-output-tokens.test.ts diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index dea19520ee9..1c2cf8c9a7a 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -8,8 +8,18 @@ runs helper features around provider requests. ## Server fields +`completeOnMaxOutputTokens` is an optional boolean, default `false`. Set it to `true` to +report a token-limited translated Responses text answer as `completed` rather than `incomplete`. +The answer remains truncated: OpenCodex preserves its exact text, including unfinished Markdown. +Only nonempty plain-text output qualifies. Tool calls and hosted operations (even if completed), +reasoning, `text.format` with `json_schema` or `json_object`, compaction, explicitly unfinished +turns, errors and missing terminal events remain excluded. Context-window exhaustion and +`pause_turn` remain incomplete. Chat Completions, Anthropic Messages and native Responses +passthrough, including native WebSocket output, retain their existing stop behavior. + | Field | Type | Default | Meaning | | --- | --- | --- | --- | +| `completeOnMaxOutputTokens?` | `boolean` | `false` | Opt in to completing eligible token-limited translated Responses text output. Text stays truncated; see the eligibility and exclusions above. | | `port` | `number` | `10100` | Proxy listen port. | | `hostname?` | `string` | `"127.0.0.1"` | Bind address. A non-loopback bind requires a data-admission token, resolved from `OPENCODEX_API_AUTH_TOKEN`, then `OCX_API_TOKEN_FILE`, then the installed owner-only `service-api-token` — nothing has to be exported by hand. See [Remote access](#remote-access). | | `proxy?` | `string` | — | Outbound HTTP(S) or SOCKS5 proxy URL (`socks5://host:port`), `${ENV_VAR}`, or `"auto"`. HTTP URLs apply to `HTTP_PROXY` / `HTTPS_PROXY` when those are unset. SOCKS5 URLs use OpenCodex's real SOCKS5 transport and are also exposed through `ALL_PROXY` (`ocx start --socks5`); inherited `HTTP(S)_PROXY` is cleared in this process. Loopback stays in `NO_PROXY`. `"auto"` reads Windows WinINET or macOS static HTTP/HTTPS settings once at startup. Inherited HTTP(S) proxy variables skip discovery; on macOS, inherited `ALL_PROXY`/`all_proxy` also skips it. Windows keeps separate `http=` and `https=` entries; a bare `ProxyServer` applies to both. macOS translates IP literals, `*`, and a valid `*.` glob to Bun's `.` bypass. That glob also bypasses the bare apex ``. The exact link-local ranges `169.254/16`, `169.254.0.0/16`, and `fe80::/10` are omitted with a diagnostic: link-local IP literals use the proxy. Other CIDRs, globs, and simple-host exceptions refuse discovery without changing the proxy environment. Disabled, malformed, PAC/WPAD, SOCKS-only, and live changes are not followed. Restart after changing system settings. | diff --git a/scripts/test-layout/layout-responses.json b/scripts/test-layout/layout-responses.json index 9561e2eefbb..306719dbad2 100644 --- a/scripts/test-layout/layout-responses.json +++ b/scripts/test-layout/layout-responses.json @@ -1,4 +1,6 @@ { + "graceful-max-output-tokens.test.ts": "responses", + "graceful-max-output-tokens-exclusions.test.ts": "responses", "advisor-web-search.test.ts": "advisor", "passthrough-media-type.test.ts": "responses", "passthrough-override.test.ts": "responses", diff --git a/src/bridge/max-output-completion.ts b/src/bridge/max-output-completion.ts new file mode 100644 index 00000000000..3ec9e1408f6 --- /dev/null +++ b/src/bridge/max-output-completion.ts @@ -0,0 +1,41 @@ +import type { AdapterEvent, OcxRequestOptions } from "../types"; + +export interface MaxOutputCompletionOptions { + /** Set only for an explicitly opted-in Responses inbound request. */ + completeOnMaxOutputTokens?: boolean; + textFormat?: OcxRequestOptions["textFormat"]; + compaction?: boolean; +} + +/** Track the original event stream: bridge cleanup must not erase an unfinished operation. */ +export function createMaxOutputCompletion(options?: MaxOutputCompletionOptions) { + let textOnly = options?.completeOnMaxOutputTokens === true && !options.compaction && !options.textFormat; + let hasText = false; + let completed = false; + return { + observe(event: AdapterEvent): void { + switch (event.type) { + case "text_delta": + if (event.anthropicThinkingSignature !== undefined) textOnly = false; + if (event.text.length > 0) hasText = true; + break; + case "heartbeat": + case "assistant_boundary": + break; + case "done": + case "incomplete": { + const reason = event.type === "done" ? event.stopReason : event.reason; + completed = textOnly && hasText && event.endTurn !== false + && (event.type !== "done" || event.compactionEncryptedContent === undefined) + && (reason === "max_tokens" || reason === "length" || reason === "max_output_tokens"); + break; + } + default: + // All non-text output and unknown future operations are excluded, even if hidden or closed. + textOnly = false; + completed = false; + } + }, + get completed(): boolean { return completed; }, + }; +} diff --git a/src/bridge/response-json.ts b/src/bridge/response-json.ts index a699947161c..f4bdc5a902b 100644 --- a/src/bridge/response-json.ts +++ b/src/bridge/response-json.ts @@ -45,6 +45,7 @@ import { import { adapterFailureFromEvent, emptyChunks, joinChunks, responsesUsage, toolCallArgumentsUsable, uuid, webSearchAction, webSearchCellItemId } from "./internal"; import type { OutputItem, StringChunks } from "./internal"; import { bridgeToResponsesSSE } from "./sse"; +import { createMaxOutputCompletion, type MaxOutputCompletionOptions } from "./max-output-completion"; /** Build a buffered Responses result within a caller-owned or temporary translator budget. */ export function buildResponseJSON( @@ -84,7 +85,7 @@ export function buildResponseJSON( function buildResponseJSONWithBudget( events: AdapterEvent[], modelId: string, - options?: { + options?: MaxOutputCompletionOptions & { hideThinkingSummary?: boolean; /** Internal Chat delivery override for raw reasoning only; summaries keep their policy. */ chatRawReasoningVisible?: boolean; @@ -202,6 +203,7 @@ function buildResponseJSONWithBudget( // which is only true for a `done` without a stop reason. A buffered turn whose adapter simply // stopped emitting has no terminal at all, and must not be reported as a success. let sawTerminal = false; + const maxOutputCompletion = createMaxOutputCompletion(options); let batchCompaction = emptyChunks(); let compactionEncryptedContent: string | undefined; @@ -371,6 +373,7 @@ function buildResponseJSONWithBudget( }; for (const e of events) { + maxOutputCompletion.observe(e); if (errorEvent) { // Match streaming: once the turn fails, later parallel calls must not become executable // completed output. Still release every retained event in order and preserve terminal usage. @@ -570,6 +573,7 @@ function buildResponseJSONWithBudget( break; case "incomplete": incompleteEvent = e; + usage = e.usage ?? usage; sawTerminal = true; endTurn = e.endTurn; if (e.providerState) options?.onProviderState?.(e.providerState); @@ -640,9 +644,10 @@ function buildResponseJSONWithBudget( } const failure = errorEvent ? adapterFailureFromEvent(errorEvent) : undefined; + const gracefulComplete = maxOutputCompletion.completed && !errorEvent; const status = errorEvent ? "failed" - : incompleteEvent || stopReason === "max_tokens" || stopReason === "content_filter" + : !gracefulComplete && (incompleteEvent || stopReason === "max_tokens" || stopReason === "content_filter") ? "incomplete" : sawTerminal ? "completed" @@ -662,13 +667,13 @@ function buildResponseJSONWithBudget( ...(failure && isCyberPolicyCode(failure.error.code) ? { retryable: false } : errorEvent?.retryable !== undefined ? { retryable: errorEvent.retryable } : {}), - ...(incompleteEvent ? { + ...(!gracefulComplete && incompleteEvent ? { incomplete_details: { reason: truncationReasonFor(incompleteEvent.reason) ?? incompleteEvent.reason, ...(incompleteEvent.message ? { message: incompleteEvent.message } : {}), ...(incompleteEvent.retryable !== undefined ? { retryable: incompleteEvent.retryable } : {}), }, - } : stopReason === "max_tokens" ? { + } : !gracefulComplete && stopReason === "max_tokens" ? { incomplete_details: { reason: "max_output_tokens" }, } : stopReason === "content_filter" ? { incomplete_details: { reason: "content_filter" }, diff --git a/src/bridge/sse.ts b/src/bridge/sse.ts index fbbeefcd002..99e4463f29b 100644 --- a/src/bridge/sse.ts +++ b/src/bridge/sse.ts @@ -53,6 +53,7 @@ import { import { adapterFailureFromEvent, emptyChunks, joinChunks, ownedBudgetAbandonedMs, responsesUsage, toolCallArgumentsCouldBeJson, toolCallArgumentsUsable, uuid, webSearchAction, webSearchCellItemId } from "./internal"; import type { OutputItem, StringChunks } from "./internal"; import { terminalRefusalFailure } from "./terminal-refusal"; +import { createMaxOutputCompletion, type MaxOutputCompletionOptions } from "./max-output-completion"; function sseEvent(name: string, data: Record): string { return `event: ${name}\ndata: ${JSON.stringify(data)}\n\n`; @@ -73,7 +74,7 @@ export function bridgeToResponsesSSE( toolSearchToolNames?: Set, onCancel?: () => void, heartbeatMs = 2_000, - options?: { + options?: MaxOutputCompletionOptions & { responseId?: string; /** Trusted structural grouping only; never provider state or execution authority. */ outputItemIds?: { next(kind: "message" | "reasoning" | "function_call" | "custom_tool_call" | "tool_search_call"): string }; @@ -165,6 +166,7 @@ export function bridgeToResponsesSSE( }, ): ReadableStream { const replayCacheScope = options?.replayCacheScope; + const maxOutputCompletion = createMaxOutputCompletion(options); const outputItemId = (kind: "message" | "reasoning" | "function_call" | "custom_tool_call" | "tool_search_call", prefix: string): string => options?.outputItemIds?.next(kind) ?? `${prefix}_${uuid()}`; @@ -852,6 +854,7 @@ export function bridgeToResponsesSSE( } if (next.done) { upstreamDone = true; break; } const event = next.value; + maxOutputCompletion.observe(event); let terminalEvent = false; // Invisible adapter heartbeats (and buffered web-search progress) count as upstream // liveness only — they must not suppress wire keepalives that re-arm Codex idle timers. @@ -1293,7 +1296,7 @@ export function bridgeToResponsesSSE( // Suppression and terminal status must agree: withholding the compaction item while // still reporting success hands codex-rs a completed response with zero compaction // items, which it treats as fatal. - if (truncationReasonFor(event.stopReason)) { + if (truncationReasonFor(event.stopReason) && !maxOutputCompletion.completed) { // Upstream stopped before a normal completion. Surface as incomplete so the // client can distinguish a truncated/filtered turn from a finished one. // #1926 gap 2: bound the window in which a handed-out thought signature is @@ -1337,6 +1340,14 @@ export function bridgeToResponsesSSE( flushHiddenReasoningEnvelope(); options?.onUsage?.(event.usage); await awaitThoughtSignatureDurability(); + if (maxOutputCompletion.completed) { + const response = { ...responseSnapshot("completed", finishedItems, event.endTurn), usage: responsesUsage(event.usage) }; + options?.onCompletedResponse?.(response, event.providerState); + emit("response.completed", { response }); + reportTerminal("completed"); + terminalEvent = true; + break; + } const refusal = options?.terminalRefusalAsFailed ? terminalRefusalFailure(event) : undefined; if (refusal) { emit("response.failed", { diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index b662e031726..4b5353c68d0 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -265,6 +265,7 @@ export const configSchema = z.object({ emptyCompletionRetry: z.boolean().optional().catch(false), // Header suppression changes what Codex sees, so absence and malformed edits stay off. dropCodexSafetyBuffering: z.boolean().optional().catch(false), + completeOnMaxOutputTokens: z.boolean().optional().catch(false), // A malformed hand edit must not silently stop opening the browser: fall back // to undefined, which resolves to the historical auto-open behavior. oauthOpenBrowser: z.boolean().optional().catch(undefined), diff --git a/src/server/responses/adapter-delivery.ts b/src/server/responses/adapter-delivery.ts index 986bd4c0a45..968ea8d3119 100644 --- a/src/server/responses/adapter-delivery.ts +++ b/src/server/responses/adapter-delivery.ts @@ -269,6 +269,8 @@ export async function deliverAdapterResponse( toolParameterSchemas, ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}), ...(routedCompaction ? { compaction: true, compactionRetention: parsed._compactionRetention } : {}), + completeOnMaxOutputTokens: config.completeOnMaxOutputTokens === true && options.inboundWire !== "chat" && options.inboundWire !== "anthropic", + textFormat: parsed.options.textFormat, // Same grok-surface split as the runTurn branch above. ...(logCtx.surface === "grok" ? { heartbeatStyle: "comment" as const } : {}), onUsage: usage => { @@ -378,6 +380,8 @@ export async function deliverAdapterResponse( freeformToolNames, toolSearchToolNames, ...(routedCompaction ? { compaction: true, compactionRetention: parsed._compactionRetention } : {}), + completeOnMaxOutputTokens: config.completeOnMaxOutputTokens === true && options.inboundWire !== "chat" && options.inboundWire !== "anthropic", + textFormat: parsed.options.textFormat, onProviderState: state => { providerState = state; }, onUsage: usage => { transportState.bindKeyUsageFromBridge(usage); diff --git a/src/server/responses/run-turn-execution.ts b/src/server/responses/run-turn-execution.ts index e2f6277004f..d6a607ba15d 100644 --- a/src/server/responses/run-turn-execution.ts +++ b/src/server/responses/run-turn-execution.ts @@ -911,6 +911,8 @@ export async function executeResponsesRunTurn( toolParameterSchemas, ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}), ...(routedCompaction ? { compaction: true, compactionRetention: parsed._compactionRetention } : {}), + completeOnMaxOutputTokens: config.completeOnMaxOutputTokens === true && enforceDeclaredToolNames, + textFormat: parsed.options.textFormat, // grok-build's strict decoder dies on the typed response.heartbeat frame; its // eventsource layer tolerates comment keep-alives. Codex needs the opposite. ...(logCtx.surface === "grok" ? { heartbeatStyle: "comment" as const } : {}), @@ -1072,6 +1074,8 @@ export async function executeResponsesRunTurn( bareCustomToolNames, toolSearchToolNames, ...(routedCompaction ? { compaction: true, compactionRetention: parsed._compactionRetention } : {}), + completeOnMaxOutputTokens: config.completeOnMaxOutputTokens === true && enforceDeclaredToolNames, + textFormat: parsed.options.textFormat, onProviderState: state => { providerState = state; }, onUsage: usage => { transportState.bindKeyUsageFromBridge(usage); diff --git a/src/types/config.ts b/src/types/config.ts index 993468de078..ec3da2f025a 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -583,6 +583,8 @@ export interface OcxConfig { emptyCompletionRetry?: boolean; /** Suppress allowlisted client-facing Codex transport hints; provider enforcement is unchanged. */ dropCodexSafetyBuffering?: boolean; + /** Opt in to completing truncated plain-text translated Responses turns; default off. */ + completeOnMaxOutputTokens?: boolean; /** * Whether a login may open a browser on the machine running the proxy. * diff --git a/structure/INDEX.md b/structure/INDEX.md index 58b9e1d61d3..9da7ad067cf 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -52,6 +52,7 @@ The wire surfaces a client actually talks to. | [`transports/anthropic-thinking-recovery.md`](transports/anthropic-thinking-recovery.md) | One-shot exact signature-binding recovery before output, and successful-retry memo boundaries. | | [`transports/response-turn-membership.md`](transports/response-turn-membership.md) | Embedding-owned signing material, dedicated authenticated caller/own-thread scope and pre-parser structural normalization. | | [`transports/response-citations.md`](transports/response-citations.md) | OpenAI-operated outbound citation repair and generated JSON/SSE annotation titles. | +| [`transports/max-output-completion.md`](transports/max-output-completion.md) | Default-off translated Responses text-only token-limit completion and exclusions. | | [`transports/responses.md`](transports/responses.md) | The Responses HTTP/SSE endpoint, dispatch, credential and upload boundaries, and core module ownership. | | [`transports/native-reasoning-retention.md`](transports/native-reasoning-retention.md) | Optional native reasoning ciphertext forwarding on model/account switches, configuration and management controls. | | [`transports/responses-citations.md`](transports/responses-citations.md) | Destination-gated citation repair for ordinary Responses and every native compact send. | @@ -220,7 +221,9 @@ A source area can be described by more than one doc, because these docs are orga | `src/advisor/` | [`advisor.md`](advisor.md) | | `src/bridge.ts` | [`transports/responses.md`](transports/responses.md) | | `src/bridge/` | [`transports/response-citations.md`](transports/response-citations.md)
[`transports/responses.md`](transports/responses.md)
[`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md)
[`providers/anthropic-progress.md`](providers/anthropic-progress.md) | -| `src/bridge/sse.ts` | [`transports/response-turn-membership.md`](transports/response-turn-membership.md) | +| `src/bridge/max-output-completion.ts` | [`transports/max-output-completion.md`](transports/max-output-completion.md) | +| `src/bridge/response-json.ts` | [`transports/max-output-completion.md`](transports/max-output-completion.md) | +| `src/bridge/sse.ts` | [`transports/response-turn-membership.md`](transports/response-turn-membership.md)
[`transports/max-output-completion.md`](transports/max-output-completion.md) | | `src/chat/` | [`runtime.md`](runtime.md)
[`transports/byte-accounting.md`](transports/byte-accounting.md)
[`transports/inventory.md`](transports/inventory.md)
[`data-planes/inbound-compat.md`](data-planes/inbound-compat.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/chat-compat.md`](providers/chat-compat.md) | | `src/chat/reasoning-details.ts` | [`data-planes/chat-reasoning-details.md`](data-planes/chat-reasoning-details.md) | | `src/chatgpt/` | [`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md) | @@ -297,6 +300,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/config/live-reconcile.ts` | [`pricing.md`](pricing.md) | | `src/config/persisted-mutation.ts` | [`runtime.md`](runtime.md) | | `src/config/schema/account-auto-pause.ts` | [`account-auto-pause.md`](account-auto-pause.md) | +| `src/config/schema/config-schema.ts` | [`transports/max-output-completion.md`](transports/max-output-completion.md) | | `src/config/schema/leaf-validators.ts` | [`transports/timeouts.md`](transports/timeouts.md)
[`pricing.md`](pricing.md) | | `src/generated/` | [`runtime.md`](runtime.md) | | `src/github/` | [`runtime.md`](runtime.md) | @@ -376,7 +380,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/server/management/quota-pool-routes.ts` | [`quota-pool.md`](quota-pool.md) | | `src/server/responses/` | [`data-planes/claude-tool-loading.md`](data-planes/claude-tool-loading.md) | | `src/server/responses/account-auto-pause-recovery.ts` | [`account-auto-pause.md`](account-auto-pause.md) | -| `src/server/responses/adapter-delivery.ts` | [`transports/response-turn-membership.md`](transports/response-turn-membership.md) | +| `src/server/responses/adapter-delivery.ts` | [`transports/response-turn-membership.md`](transports/response-turn-membership.md)
[`transports/max-output-completion.md`](transports/max-output-completion.md) | | `src/server/responses/adapter-dispatch.ts` | [`transports/response-turn-membership.md`](transports/response-turn-membership.md) | | `src/server/responses/advisor-plan-slot.ts` | [`advisor.md`](advisor.md) | | `src/server/responses/advisor-slot.ts` | [`advisor.md`](advisor.md) | @@ -387,6 +391,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/server/responses/passthrough-dispatch.ts` | [`providers/opengateway.md`](providers/opengateway.md) | | `src/server/responses/request-prepare.ts` | [`transports/response-turn-membership.md`](transports/response-turn-membership.md) | | `src/server/responses/request-transport.ts` | [`transports/response-turn-membership.md`](transports/response-turn-membership.md)
[`account-auto-pause.md`](account-auto-pause.md) | +| `src/server/responses/run-turn-execution.ts` | [`transports/max-output-completion.md`](transports/max-output-completion.md) | | `src/server/responses/turn-membership.ts` | [`transports/response-turn-membership.md`](transports/response-turn-membership.md) | | `src/service-manager-probe.ts` | [`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | | `src/service.ts` | [`runtime.md`](runtime.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | @@ -415,7 +420,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/types.ts` | [`runtime.md`](runtime.md)
[`config.md`](config.md) | | `src/types/` | [`providers/generic-account-pool.md`](providers/generic-account-pool.md)
[`transports/native-reasoning-retention.md`](transports/native-reasoning-retention.md)
[`providers/anthropic-progress.md`](providers/anthropic-progress.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/lm-studio-chat.md`](providers/lm-studio-chat.md)
[`fallback-policy.md`](fallback-policy.md) | | `src/types/account-auto-pause.ts` | [`account-auto-pause.md`](account-auto-pause.md) | -| `src/types/config.ts` | [`transports/timeouts.md`](transports/timeouts.md)
[`config.md`](config.md) | +| `src/types/config.ts` | [`transports/timeouts.md`](transports/timeouts.md)
[`config.md`](config.md)
[`transports/max-output-completion.md`](transports/max-output-completion.md) | | `src/types/provider.ts` | [`pricing.md`](pricing.md) | | `src/update/` | [`runtime.md`](runtime.md)
[`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | | `src/update/guarded-port-reclaim.ts` | [`ops/update-supervised-recovery.md`](ops/update-supervised-recovery.md) | diff --git a/structure/config.md b/structure/config.md index 66e355292c8..4f330eebdd9 100644 --- a/structure/config.md +++ b/structure/config.md @@ -1,5 +1,5 @@ # Config Surface -See [local timeout policy and OpenCode export](transports/timeouts.md). Pasted GCP JSON persists only as `gcp-sa:` references in one pool transaction in `src/providers/api-keys.ts`; post-commit deletion follows [Vertex credential markers](providers/google.md#vertex-credential-markers). See [LM Studio Chat instruction configuration](providers/lm-studio-chat.md). +See [local timeout policy and OpenCode export](transports/timeouts.md) and default-off [`completeOnMaxOutputTokens`](transports/max-output-completion.md). Pasted GCP JSON persists only as `gcp-sa:` references in one pool transaction in `src/providers/api-keys.ts`; post-commit deletion follows [Vertex credential markers](providers/google.md#vertex-credential-markers). See [LM Studio Chat instruction configuration](providers/lm-studio-chat.md). `src/config/schema/leaf-validators.ts` accepts only boolean `apiKeys[].allowMeteredComboTargets`; malformed entries are dropped rather than promoted. `src/types/config.ts` and combo validation define optional boolean target `metered`, normalized false when omitted, and optional per-combo `allowMeteredTargets` (default false, literal boolean validation shared by config schema and management API). Explicit consent is required under the [combo runtime contract](runtime.md#request-local-target-compatibility). Model-price overrides follow the [pricing contract](pricing.md). Quota activation reuses the existing next-reset fields without adding a polling configuration key. See the [quota activation contract](providers/openai-tiers.md#public-provider-contract). `src/types/config.ts` defines `anthropicAccountPool.routes` as ordered model rules. `src/oauth/anthropic-model-routes.ts` validates bounded names, full case-sensitive globs and stored account IDs; `src/config/diagnostics.ts` rejects malformed candidate writes. Load retains a malformed hand edit so unrelated providers survive, while the enabled Anthropic selector refuses it locally before dispatch. Saved rules remain inert when the pool is disabled; affinity is process-local. Request logs use only the rule’s 1-based `route:#` position, never its configured name. `src/cli/hub-disable.ts` removes interdependent hub/loopback/role/hostname settings through `mutatePersistedConfig` and `validateConfigCandidate` in one commit. Top-level deletion intent uses `src/config/rebase-provenance.ts` and authoritative disk provenance in `src/config/live-reconcile.ts` to preserve removed hub bindings through later live saves, `src/config/live-topology.ts` keeps an independent active-topology baseline and merges desired next-start settings into a separate persistence projection, retaining actual socket identity and live hub/client topology during guarded saves and OAuth disk adoption. Client connections and runtimeRole remain one topology unit through connect/disconnect, including fresh disk-only connections. Disk additions/value updates and deletions all survive repeated saves without changing active topology. `src/config/api-key-reconcile.ts` merges key rows by stable ID under the existing save lock; either side's revocation defeats stale edits while disjoint names and rotations merge; unchanged live key membership/order adopts persisted ordering. `tests/config/hub-disable-live-save.test.ts` exercises disable followed by live key creation, rename, rotation, unrelated settings and repeated saves. Saved API keys have no immutable enrollment marker; explicit exact-ID revocation preserves all unselected keys. The WorkBuddy AI account row pins its adapter and origin under the [WorkBuddy contract](providers/workbuddy.md). JEV `decisionQuotaSignals` and partial `decisionQuotaTiers` use the shared validator and sparse preserve/clear/strategy-switch contract in [loaded advisory quota](providers/jev-decision.md#loaded-advisory-quota); the opt-in JEV `sessionPin` follows the same contract under [session pin](providers/jev-decision.md#session-pin). Native reasoning retention follows [its optional configuration contract](transports/native-reasoning-retention.md#configuration-and-management). Native function-result injection follows [the separate opt-in control contract](transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. diff --git a/structure/manifest.json b/structure/manifest.json index 4e12855615c..3655093e224 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -355,6 +355,21 @@ "scope": "OpenAI-operated outbound citation repair and generated JSON/SSE annotation titles.", "documents": ["src/adapters/openai-responses/", "src/bridge/"] }, + { + "path": "transports/max-output-completion.md", + "tier": 3, + "title": "Opt-in Responses Text Completion", + "scope": "Default-off translated Responses text-only token-limit completion and exclusions.", + "documents": [ + "src/bridge/max-output-completion.ts", + "src/bridge/sse.ts", + "src/bridge/response-json.ts", + "src/config/schema/config-schema.ts", + "src/types/config.ts", + "src/server/responses/adapter-delivery.ts", + "src/server/responses/run-turn-execution.ts" + ] + }, { "path": "transports/responses.md", "tier": 3, diff --git a/structure/transports/max-output-completion.md b/structure/transports/max-output-completion.md new file mode 100644 index 00000000000..fd5114dfccf --- /dev/null +++ b/structure/transports/max-output-completion.md @@ -0,0 +1,29 @@ +# Opt-in Responses text completion + +`completeOnMaxOutputTokens` is an optional boolean in `src/config/schema/config-schema.ts` +and `src/types/config.ts`. Absence, false and malformed values leave it off. +`src/server/responses/adapter-delivery.ts` and `src/server/responses/run-turn-execution.ts` +pass request-local opt-in and parsed `textFormat` only to translated Responses delivery. +Chat and Messages retain their existing stop semantics. Native Responses passthrough, +including native WebSocket delivery, does not participate or reload configuration for this policy. + +`src/bridge/max-output-completion.ts` observes the original adapter events before bridge cleanup. +Only nonempty plain text, optional message boundaries and heartbeats qualify. Any tool or +hosted operation, including one later closed by bridge cleanup or explicitly completed, +excludes the turn. Reasoning (including hidden and signed progress), structured `textFormat`, +compaction and explicit `endTurn: false` also exclude it. The allowlist excludes future event +types until they are explicitly reviewed. Errors and missing terminals retain their failure +or incomplete shape. Raw `max_tokens`, `length` and `max_output_tokens` stops qualify; +`pause_turn` and context-window exhaustion remain incomplete even though the general +truncation classifier maps them to the same public incomplete reason. + +The builders in `src/bridge/sse.ts` and `src/bridge/response-json.ts` turn an eligible +terminal into `completed` and omit `incomplete_details`. The model's text +is unchanged, including earlier messages, open Markdown and valid empty fenced blocks. +Usage, `end_turn` and provider continuation state survive both buffered and streamed delivery. +Compaction retains its existing history and retention-commit rules, without placeholders. + +`tests/responses/graceful-max-output-tokens-exclusions.test.ts` covers both bridge shapes +and terminal forms. `tests/responses/graceful-max-output-tokens.test.ts` exercises the +real server's adapter and runTurn delivery, both structured formats, Chat/Messages stop +semantics, malformed/default config, native WS framing and no config reload after reservation. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 810c8d782e4..82585590f56 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -573,6 +573,7 @@ reuses `beginInferenceAttempt` and `createFinalRequestLog` with its own 401/429 ## Adapter-to-Responses bridge +Opt-in translated Responses text-only token-limit completion follows [the text completion policy](max-output-completion.md). `src/bridge.ts` is a re-export facade; the implementation lives in `src/bridge/`. `src/bridge/sse.ts` (`bridgeToResponsesSSE`) turns adapter events into the Responses SSE stream; its once-only first-output observer includes nonempty `tool_call_delta` arguments (including custom-tool input), but not tool-start scaffolding or empty arguments, and `src/bridge/response-json.ts` (`buildResponseJSON`) builds the non-streaming Responses body diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 395e8228048..ed8f7650a8f 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,6 @@ { "lmstudio-system-policy.test.ts": "config", "openai-chat-single-system.test.ts": "adapters/openai", "update-reclaim-authorizer.test.ts": "update", "update-capability-policy.test.ts": "update", "update-service-home.test.ts": "update", "update-service-home-policy.test.ts": "update", "update-home-recovery.test.ts": "update", "update-recovery-authority.test.ts": "update", "update-recovery-boundaries.test.ts": "update", "update-home-launcher.test.ts": "update", "update-home-worker.test.ts": "update", "update-mutation-boundaries.test.ts": "update", "update-mutation-guard.test.ts": "update", "update-guarded-ops.test.ts": "update", "update-effect-authority.test.ts": "update", "update-windows-wrapper-authority.test.ts": "update", "update-mutation-scanner.test.ts": "update", "refreshed-upstream-deadlines.test.ts": "responses", "local-upstream-deadlines.test.ts": "responses", "service-desktop-recovery.test.ts": "service", "provider-input-modalities.test.ts": "providers", "turn-membership.test.ts": "responses", "turn-membership-ingress.test.ts": "responses", "workbuddy-quota.test.ts": "providers", "workbuddy-login.test.ts": "oauth", "workbuddy-adapter.test.ts": "adapters", "workbuddy-provider.test.ts": "providers", "link-tunnel-history.test.ts": "clients", "link-readiness.test.ts": "clients", "link-diagnostics.test.ts": "clients", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "responses-phase-inference.test.ts": "responses", + "graceful-max-output-tokens.test.ts": "responses", + "graceful-max-output-tokens-exclusions.test.ts": "responses", "windows-config-root-async-writers.test.ts": "windows", "windows-atomic-write-acl-rearm.test.ts": "windows", "advisor-web-search.test.ts": "advisor", diff --git a/tests/responses/graceful-max-output-tokens-exclusions.test.ts b/tests/responses/graceful-max-output-tokens-exclusions.test.ts new file mode 100644 index 00000000000..4c1e60313d0 --- /dev/null +++ b/tests/responses/graceful-max-output-tokens-exclusions.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, test } from "bun:test"; +import { bridgeToResponsesSSE, buildResponseJSON } from "../../src/bridge"; +import type { AdapterEvent, OcxProviderContinuationState } from "../../src/types"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; + +type Options = NonNullable[7]>; +type Frame = { type: string; response?: Record; delta?: string; text?: string }; +const text: AdapterEvent = { type: "text_delta", text: "Partial answer\n```ts\nconst x =" }; +const terminals: AdapterEvent[] = [ + { type: "done", stopReason: "max_tokens", usage: { inputTokens: 5, outputTokens: 20 } }, + { type: "incomplete", reason: "max_output_tokens", usage: { inputTokens: 5, outputTokens: 20 } }, +]; + +async function deliver(events: AdapterEvent[], options: Options, streaming: boolean) { + if (!streaming) { + let providerState: OcxProviderContinuationState | undefined; + const response = buildResponseJSON(events, "test-model", { + ...options, onProviderState: state => { providerState = state; }, + }); + return { response, frames: [] as Frame[], providerState }; + } + const budget = createTestTranslatorBudget(); + let providerState: OcxProviderContinuationState | undefined; + async function* source() { yield* events; } + try { + const body = await new Response(bridgeToResponsesSSE( + source(), "test-model", undefined, undefined, undefined, undefined, 2_000, + { ...options, translatorBudget: budget, onCompletedResponse: (_response, state) => { providerState = state; } }, + )).text(); + const frames: Frame[] = body.split("\n").filter(line => line.startsWith("data: {")).map(line => JSON.parse(line.slice(6))); + const terminal = frames.filter(frame => ["response.completed", "response.incomplete", "response.failed"].includes(frame.type)); + expect(terminal).toHaveLength(1); + expect(terminal[0]!.type).toBe(`response.${terminal[0]!.response!.status}`); + return { response: terminal[0]!.response!, frames, providerState }; + } finally { budget.dispose(); } +} + +for (const streaming of [false, true]) { + for (const terminal of terminals) { + describe(`${streaming ? "SSE" : "JSON"} ${terminal.type} max-output completion`, () => { + test("explicit opt-in completes text without changing deltas, done text, or usage", async () => { + const { response, frames } = await deliver([text, terminal], { completeOnMaxOutputTokens: true }, streaming); + expect(response.status).toBe("completed"); + expect(response.incomplete_details).toBeUndefined(); + expect(response.usage).toMatchObject({ input_tokens: 5, output_tokens: 20 }); + expect((response.output as any[])[0].content[0].text).toBe(text.text); + if (streaming) { + expect(frames.filter(frame => frame.type === "response.output_text.delta").map(frame => frame.delta).join("")).toBe(text.text); + expect(frames.find(frame => frame.type === "response.output_text.done")!.text).toBe(text.text); + } + }); + + for (const enabled of [undefined, false]) { + test(`setting ${enabled} preserves incomplete`, async () => { + const { response } = await deliver([text, terminal], { completeOnMaxOutputTokens: enabled }, streaming); + expect(response.status).toBe("incomplete"); + expect(response.incomplete_details).toMatchObject({ reason: "max_output_tokens" }); + }); + } + + for (const type of ["json_schema", "json_object"] as const) { + test(`structured ${type} stays incomplete`, async () => { + const options = { completeOnMaxOutputTokens: true, textFormat: { type } }; + const { response } = await deliver([text, terminal], options, streaming); + expect(response.status).toBe("incomplete"); + expect(response.incomplete_details).toMatchObject({ reason: "max_output_tokens" }); + }); + } + + const exclusions: [string, AdapterEvent[]][] = [ + ["unfinished function", [{ type: "tool_call_start", id: "call_a", name: "lookup" }, { type: "tool_call_delta", arguments: '{"q":' }]], + ["unfinished function before later text", [{ type: "tool_call_start", id: "call_a", name: "lookup" }, { type: "tool_call_delta", arguments: "{}" }, text]], + ["explicitly incomplete tool end", [{ type: "tool_call_start", id: "call_a", name: "lookup" }, { type: "tool_call_delta", arguments: "{}" }, { type: "tool_call_end", incomplete: true }]], + ["completed tool output", [{ type: "tool_call_start", id: "call_a", name: "lookup" }, { type: "tool_call_delta", arguments: "{}" }, { type: "tool_call_end" }, text]], + ["orphan tool delta", [{ type: "tool_call_delta", arguments: "{}" }, text]], + ["unfinished hosted search", [{ type: "web_search_call_begin", id: "search_a" }, text]], + ["completed hosted search", [{ type: "web_search_call_end", id: "search_a", queries: ["fixture query"] }, text]], + ["reasoning output", [{ type: "thinking_delta", thinking: "pending thought" }, text]], + ["hidden raw reasoning", [{ type: "reasoning_raw_delta", text: "pending thought" }, text]], + ["signed progress", [{ type: "text_delta", text: "progress", anthropicThinkingSignature: "fixture-signature" }, text]], + ]; + for (const [name, events] of exclusions) { + test(`${name} stays incomplete`, async () => { + const { response } = await deliver([text, ...events, terminal], { completeOnMaxOutputTokens: true }, streaming); + expect(response.status).toBe("incomplete"); + expect(response.incomplete_details).toMatchObject({ reason: "max_output_tokens" }); + }); + } + + test("empty output stays incomplete", async () => { + const { response } = await deliver([terminal], { completeOnMaxOutputTokens: true }, streaming); + expect(response.status).toBe("incomplete"); + }); + + test("compaction truncation emits no replacement and commits no retention", async () => { + let commits = 0; + const { response } = await deliver([text, terminal], { + completeOnMaxOutputTokens: true, compaction: true, + compactionRetention: { encode: summary => `encoded:${summary}`, commit: () => { commits++; } }, + }, streaming); + expect(response.status).toBe("incomplete"); + expect(response.output).toEqual([]); + expect(commits).toBe(0); + }); + + test("provider state and end_turn survive the converted terminal", async () => { + const state = { cursor: { conversationId: "fixture-conversation", checkpointRef: "fixture-checkpoint" } }; + const { response, providerState } = await deliver([text, { ...terminal, providerState: state, endTurn: true }], { completeOnMaxOutputTokens: true }, streaming); + expect(response.status).toBe("completed"); + expect(providerState).toEqual(state); + expect(response.end_turn).toBe(true); + }); + + test("explicitly unfinished turn stays incomplete", async () => { + const { response } = await deliver([text, { ...terminal, endTurn: false }], { completeOnMaxOutputTokens: true }, streaming); + expect(response.status).toBe("incomplete"); + expect(response.end_turn).toBe(false); + }); + }); + } + + describe(`${streaming ? "SSE" : "JSON"} other exclusions and output integrity`, () => { + for (const reason of ["length", "max_output_tokens"]) { + test(`raw done ${reason} qualifies`, async () => { + const { response } = await deliver([text, { type: "done", stopReason: reason }], { completeOnMaxOutputTokens: true }, streaming); + expect(response.status).toBe("completed"); + }); + } + for (const stopReason of ["pause_turn", "model_context_window_exceeded", "model_context_window_exceeded_exception"]) { + test(`raw done ${stopReason} stays incomplete`, async () => { + const { response } = await deliver([text, { type: "done", stopReason }], { completeOnMaxOutputTokens: true }, streaming); + expect(response.status).toBe("incomplete"); + }); + } + for (const reason of ["content_filter", "pause_turn", "model_context_window_exceeded", "model_context_window_exceeded_exception", "adapter_eof"]) { + test(`${reason} stays incomplete`, async () => { + const { response } = await deliver([text, { type: "incomplete", reason }], { completeOnMaxOutputTokens: true }, streaming); + expect(response.status).toBe("incomplete"); + }); + } + test("adapter EOF stays incomplete", async () => { + const { response } = await deliver([text], { completeOnMaxOutputTokens: true }, streaming); + expect(response.status).toBe("incomplete"); + }); + test("errors stay failed", async () => { + const { response } = await deliver([text, { type: "error", message: "fixture failure", status: 502 }], { completeOnMaxOutputTokens: true }, streaming); + expect(response.status).toBe("failed"); + }); + test("earlier completed messages and valid empty fences remain exact", async () => { + const first = "```\n```"; + const { response } = await deliver([ + { type: "text_delta", text: first }, { type: "assistant_boundary" }, text, terminals[0]!, + ], { completeOnMaxOutputTokens: true }, streaming); + expect(response.status).toBe("completed"); + expect((response.output as any[]).map(item => item.content[0].text)).toEqual([first, text.text]); + }); + for (const enabled of [false, true]) { + test(`clean empty compaction never substitutes history (setting ${enabled})`, async () => { + const encoded: string[] = []; + await deliver([{ type: "done" }], { + completeOnMaxOutputTokens: enabled, compaction: true, + compactionRetention: { encode: summary => { encoded.push(summary); return "fixture-ciphertext"; }, commit: () => {} }, + }, streaming); + expect(encoded).toEqual([""]); + }); + } + }); +} diff --git a/tests/responses/graceful-max-output-tokens.test.ts b/tests/responses/graceful-max-output-tokens.test.ts new file mode 100644 index 00000000000..608c97bae74 --- /dev/null +++ b/tests/responses/graceful-max-output-tokens.test.ts @@ -0,0 +1,215 @@ +import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ADAPTER_REGISTRY } from "../../src/adapters/registry"; +import { waitForNativeMainStartupGate } from "../../src/codex/native-profile-startup"; +import { saveConfig } from "../../src/config"; +import * as configModule from "../../src/config"; +import { configSchema } from "../../src/config/schema/config-schema"; +import { startServer } from "../../src/server"; +import { codexWsExchange } from "../../src/server/responses/codex-ws-exchange"; +import { CODEX_RESPONSES_HTTP_URL, prepareCodexWsRequest } from "../../src/server/responses/codex-ws-request"; +import { CodexWsSession } from "../../src/server/responses/codex-ws-session"; +import { codexWsUpstreamFetch } from "../../src/server/responses/ws-upstream"; +import type { OcxConfig } from "../../src/types"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { installCompactionRoutingAclFixture } from "../helpers/compaction-routing-fixtures"; + +installCompactionRoutingAclFixture(); + +const TEXT = "Partial code\n```ts\nconst x ="; +const oldHome = process.env.OPENCODEX_HOME; +const originalCreate = ADAPTER_REGISTRY.anthropic.create; +let home: string; +let isolated: IsolatedCodexHome; +let upstream: ReturnType; +let server: ReturnType; +let runTurnCalls = 0; + +function frames(body: string): { name?: string; data: Record }[] { + return body.split("\n\n").flatMap(block => { + const lines = block.split("\n"); + const data = lines.find(line => line.startsWith("data: "))?.slice(6); + return !data || data === "[DONE]" ? [] : [{ + name: lines.find(line => line.startsWith("event: "))?.slice(7), data: JSON.parse(data), + }]; + }); +} + +beforeAll(async () => { + home = mkdtempSync(join(tmpdir(), "ocx-graceful-output-")); + isolated = installIsolatedCodexHome("ocx-graceful-output-codex-"); + process.env.OPENCODEX_HOME = home; + upstream = Bun.serve({ + hostname: "127.0.0.1", port: 0, + async fetch(request) { + const body = await request.json() as { stream?: boolean }; + if (!body.stream) return Response.json({ + id: "msg_fixture", type: "message", role: "assistant", model: "fixture-model", + content: [{ type: "text", text: TEXT }], stop_reason: "max_tokens", + usage: { input_tokens: 5, output_tokens: 20 }, + }); + const frame = (type: string, data: Record) => `event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`; + return new Response([ + frame("message_start", { message: { id: "msg_fixture", model: "fixture-model", usage: { input_tokens: 5, output_tokens: 0 } } }), + frame("content_block_start", { index: 0, content_block: { type: "text", text: "" } }), + frame("content_block_delta", { index: 0, delta: { type: "text_delta", text: TEXT } }), + frame("content_block_stop", { index: 0 }), + frame("message_delta", { delta: { stop_reason: "max_tokens" }, usage: { output_tokens: 20 } }), + frame("message_stop", {}), + ].join(""), { headers: { "content-type": "text/event-stream" } }); + }, + }); + ADAPTER_REGISTRY.anthropic.create = (provider, context) => { + const adapter = originalCreate(provider, context); + if (provider.defaultModel !== "runturn-model") return adapter; + return { ...adapter, async runTurn(_parsed, _incoming, emit) { + runTurnCalls++; + emit({ type: "text_delta", text: TEXT }); + emit({ type: "incomplete", reason: "max_output_tokens", usage: { inputTokens: 5, outputTokens: 20 } }); + } }; + }; + const provider = (model: string) => ({ + adapter: "anthropic", baseUrl: `http://127.0.0.1:${upstream.port}`, apiKey: "sk-test", + allowPrivateNetwork: true, liveModels: false, defaultModel: model, models: [model], + }); + saveConfig({ + hostname: "127.0.0.1", port: 0, defaultProvider: "fixture", completeOnMaxOutputTokens: true, + providers: { fixture: provider("fixture-model"), runturn: provider("runturn-model") }, + } as OcxConfig); + server = startServer(0); + await waitForNativeMainStartupGate(); +}); + +afterAll(async () => { + await server?.stop(true); + await upstream?.stop(true); + ADAPTER_REGISTRY.anthropic.create = originalCreate; + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + isolated?.restore(); + if (home) removeTreeWithRetry(home); +}); + +async function post(path: string, body: Record) { + const response = await fetch(new URL(path, server.url), { + method: "POST", headers: { "content-type": "application/json", "anthropic-version": "2023-06-01" }, + body: JSON.stringify(body), + }); + expect(response.status).toBe(200); + return response; +} + +describe("opt-in delivery stays on Responses text only", () => { + for (const model of ["fixture/fixture-model", "runturn/runturn-model"]) { + for (const stream of [false, true]) { + test(`${model} ${stream ? "SSE" : "JSON"} completes text`, async () => { + const before = runTurnCalls; + const response = await post("/v1/responses", { model, input: "hi", stream }); + const result = stream ? frames(await response.text()).find(frame => frame.name === "response.completed")?.data.response : await response.json(); + expect(result?.status).toBe("completed"); + expect(result.output[0].content[0].text).toBe(TEXT); + if (model.startsWith("runturn/")) expect(runTurnCalls - before).toBe(1); + }); + for (const type of ["json_schema", "json_object"]) { + test(`${model} ${stream ? "SSE" : "JSON"} excludes text.format ${type}`, async () => { + const response = await post("/v1/responses", { + model, input: "hi", stream, text: { format: { type, name: "fixture", schema: { type: "object" } } }, + }); + const result = stream ? frames(await response.text()).find(frame => frame.name === "response.incomplete")?.data.response : await response.json(); + expect(result?.status).toBe("incomplete"); + expect(result.incomplete_details.reason).toBe("max_output_tokens"); + }); + } + test(`${model} ${stream ? "SSE" : "JSON"} Chat preserves length`, async () => { + const response = await post("/v1/chat/completions", { model, stream, messages: [{ role: "user", content: "hi" }] }); + const result = stream ? frames(await response.text()).find(frame => frame.data.choices?.[0]?.finish_reason)?.data : await response.json(); + expect(result.choices[0].finish_reason).toBe("length"); + }); + test(`${model} ${stream ? "SSE" : "JSON"} Messages preserves max_tokens`, async () => { + const response = await post("/v1/messages", { model, stream, max_tokens: 20, messages: [{ role: "user", content: "hi" }] }); + const result = stream ? frames(await response.text()).find(frame => frame.data.delta?.stop_reason)?.data.delta : await response.json(); + expect(result.stop_reason).toBe("max_tokens"); + }); + } + } + test("missing, false, and malformed settings do not enable the policy", () => { + const base = { defaultProvider: "fixture", providers: { fixture: { + adapter: "anthropic", baseUrl: "http://127.0.0.1:1", allowPrivateNetwork: true, defaultModel: "fixture-model", models: ["fixture-model"], + } } }; + for (const value of [undefined, false, "true", 1, null]) { + const parsed = configSchema.parse({ ...base, completeOnMaxOutputTokens: value }); + expect(parsed.completeOnMaxOutputTokens === true).toBe(false); + } + expect(configSchema.parse({ ...base, completeOnMaxOutputTokens: true }).completeOnMaxOutputTokens).toBe(true); + }); +}); + +test("native WebSocket preserves incomplete framing with global opt-in enabled", async () => { + const payload = { type: "response.incomplete", response: { + id: "resp_fixture", status: "incomplete", output: [], incomplete_details: { reason: "max_output_tokens" }, + } }; + const listener = Bun.serve({ + hostname: "127.0.0.1", port: 0, + fetch(req, server) { if (!server.upgrade(req)) return new Response(null, { status: 400 }); }, + websocket: { message(socket) { socket.send(JSON.stringify(payload)); } }, + }); + const init: RequestInit = { method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "fixture-model", stream: true, input: [] }) }; + const prepared = prepareCodexWsRequest(CODEX_RESPONSES_HTTP_URL, init)!; + const session = new CodexWsSession(`ws://127.0.0.1:${listener.port}`, prepared.headers); + try { + expect(session.reserve()).toBe(true); + const response = await codexWsExchange({ session, prepared, init, url: CODEX_RESPONSES_HTTP_URL, + sseFallback: (async () => { throw new Error("unexpected fallback"); }) as typeof fetch }); + const all = frames(await response.text()); + expect(all).toEqual([{ name: "response.incomplete", data: payload }]); + } finally { session.dispose(); await listener.stop(true); } +}); + +test("WS upstream does not reload config after reserving its session", async () => { + const realSocket = globalThis.WebSocket; + const realLoad = configModule.loadConfig; + const reserve = CodexWsSession.prototype.reserve; + const proxyKeys = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"]; + const proxyEnv = Object.fromEntries(proxyKeys.map(key => [key, process.env[key]])); + let reserved = false; + let lateLoads = 0; + const loadSpy = spyOn(configModule, "loadConfig").mockImplementation((...args) => { + if (reserved) lateLoads++; + return realLoad(...args); + }); + const reserveSpy = spyOn(CodexWsSession.prototype, "reserve").mockImplementation(function (this: CodexWsSession) { + const result = reserve.call(this); + if (result) reserved = true; + return result; + }); + class Socket extends EventTarget { + readyState = 1; + constructor() { super(); queueMicrotask(() => this.dispatchEvent(new Event("open"))); } + send() { queueMicrotask(() => this.dispatchEvent(new MessageEvent("message", { data: JSON.stringify({ + type: "response.incomplete", response: { id: "r_no_reload", status: "incomplete", output: [], incomplete_details: { reason: "max_output_tokens" } }, + }) }))); } + close() { this.dispatchEvent(new Event("close")); } + } + try { + for (const key of proxyKeys) delete process.env[key]; + globalThis.WebSocket = Socket as unknown as typeof WebSocket; + const response = await codexWsUpstreamFetch(CODEX_RESPONSES_HTTP_URL, { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "fixture-model", stream: true, input: [] }), + }, (async () => { throw new Error("unexpected HTTP fallback"); }) as typeof fetch, "1.4.0"); + const all = frames(await response.text()); + expect(reserved).toBe(true); + expect(lateLoads).toBe(0); + expect(all.map(frame => frame.name)).toEqual(["response.incomplete"]); + } finally { + loadSpy.mockRestore(); reserveSpy.mockRestore(); globalThis.WebSocket = realSocket; + for (const key of proxyKeys) { + if (proxyEnv[key] === undefined) delete process.env[key]; + else process.env[key] = proxyEnv[key]; + } + } +}); From 297f29939c5479a9da28da3c8806afea90e654ec Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 29 Oct 2026 17:05:06 +0900 Subject: [PATCH 38/84] fix(advisor): apply the caller's paid-combo consent and device fallback policy (#7894) * fix(advisor): preserve captured caller routing policy Recover the caller-policy implementation carried in c7e80fa and separated from the admission-scope change by f77b3d05. Capture immutable caller policy before async request parsing and register a private per-consultation handle behind the existing process capability. Intersect caller and host limits at shared model, metered-combo and fallback admission guards, including nested decision turns. Reject missing or unknown internal policy; ignore external policy metadata. Revoke registry handles immediately on abort and release on every settled exit. Add routing, immutability, forgery, host-limit and lifecycle regressions and document the policy contract. Refs #7859 Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> Co-authored-by: Epinephrine * fix(advisor): capture Messages caller policy before reading the body Claude Messages read the body before replaying through handleResponses, which then captured the Advisor caller policy from the replay config. A config change during the body read could widen paid-combo consent or drop the device fallback policy. Capture the admitted policy synchronously at Messages ingress, as Chat already does, and pass it into the replay. * fix(advisor): capture caller policy at HTTP authentication With maxInboundBodyBytes above the default ceiling, runAdmittedHttpTurn waits for inbound-body capacity before calling the handler, so the Responses, Chat and Messages handlers could capture the Advisor caller policy from a config widened during that wait. Capture it in serve-options right after authentication and hand the snapshot to each handler; an internal Advisor hop still inherits its registration. * fix(advisor): forward the caller policy into detached JEV decision turns The JEV decision invoker forwarded admission and scope but not the Advisor caller policy, so the detached decision turn re-captured it from mutable config while the parent was suspended. Forward the parent's snapshot into the child handleResponses call. * fix(advisor): preserve caller policy across sidecars and retries * test(advisor): enforce immutable ingress and helper authority boundaries * docs(advisor): keep policy contract links within document budgets * test(server): pin the Messages route call with its captured Advisor policy The listener-policy source oracle pins the exact handleClaudeMessages call; it now carries the advisorAdmissionPolicy captured at authentication (#7859). The policy-not-config CORS invariant it guards is unchanged. * docs(advisor): name the admission key that supplies the host limits * docs(advisor): qualify key ordering and translate the caller policy section Key order only matters when neither OPENCODEX_API_AUTH_TOKEN nor a service token is set. Add the caller-scope, paid-combo, device fallback and policy-capture guidance to all seven locales. * fix(advisor): bind the self-fetch key's host policy on loopback ingress A loopback listener (loopback bind or the secondary loopback listener) admits Advisor's internal self-fetch before reading any token, so the host snapshot carried neither the selected admission key's model scope nor its paid-combo consent or fallback device identity. A caller whose own scope allowed the Advisor model got a consultation that Advisor's own key forbids. After the internal capability and policy registration are validated, Chat now resolves the presented x-opencodex-api-key and intersects that key's captured policy with the listener snapshot and the caller registration. An unknown presented credential is refused. Other loopback callers are unchanged. * test(advisor): drop the trailing blank line in the host-key regression * fix(advisor): honor validated self-fetch key consent and skip unadmitted credentials (#7894) On a public bind, the secondary loopback listener admits an Advisor or routed-helper self-fetch as loopback, and loopback on a public proxy never consents to metered combo targets. A consenting caller and consenting host key therefore could not reach a metered target the combo opted into, unlike a direct request with that key. When a loopback listener admitted the hop, selfFetchHostPolicy now takes paid-combo consent from the validated key, and the final metered check defers to the captured caller policy on a loopback-admitted internal hop. The combo opt-in and the caller's consent still bind; unkeyed loopback callers are still refused; transport identity is unchanged. On a loopback bind, startup can leave the installed service-token file (a hub client key) out of this proxy's admission credentials, but both self-fetch callers presented it anyway and Chat refused every consult as an unrecognized credential. selfFetchAdmissionToken keeps the ladder unchanged where the destination requires admission and otherwise sends only the first ladder credential this data plane recognizes, so a restricted configured key still binds. An unknown credential that is presented is still refused. --------- Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> Co-authored-by: Epinephrine --- .../fr/reference/configuration/advisor.md | 4 + .../ja/reference/configuration/advisor.md | 4 + .../ko/reference/configuration/advisor.md | 4 + .../docs/reference/configuration/advisor.md | 15 + .../ru/reference/configuration/advisor.md | 4 + .../tr/reference/configuration/advisor.md | 4 + .../zh-cn/reference/configuration/advisor.md | 4 + .../zh-tw/reference/configuration/advisor.md | 4 + .../test-layout/layout-agent-assertion.json | 11 + src/advisor/consult.ts | 32 +- src/advisor/runtime.ts | 4 +- src/lib/local-chat-completion.ts | 14 +- src/lib/local-destinations.ts | 35 +- src/routing/fallback-policy.ts | 10 +- src/server/admission-model-scope.ts | 25 +- src/server/advisor-admission-scope.ts | 29 ++ src/server/auth-cors.ts | 5 +- src/server/capture-admission-policy.ts | 91 +++++ src/server/chat-completions.ts | 40 ++- src/server/claude-messages.ts | 15 +- src/server/index/serve-options.ts | 14 +- src/server/index/websocket-handler.ts | 3 + src/server/messages-native-scope.ts | 7 +- src/server/responses/advisor-plan-slot.ts | 1 + src/server/responses/compact.ts | 5 +- src/server/responses/core-options.ts | 2 + src/server/responses/core.ts | 2 + src/server/responses/jev-model-invoke.ts | 6 +- src/server/responses/policy-fallback.ts | 2 + src/server/responses/request-prepare.ts | 2 + src/server/responses/request-sidecar-auth.ts | 1 + src/server/responses/sidecar-execution.ts | 2 + src/types/admission-policy.ts | 13 + src/vision/index.ts | 2 + src/vision/plan.ts | 4 + src/vision/routed-describe.ts | 61 ++-- structure/INDEX.md | 2 + structure/advisor.md | 49 ++- structure/data-planes/protocol-paths.md | 2 +- structure/fallback-policy.md | 5 + structure/manifest.json | 2 + structure/runtime.md | 2 +- structure/transports/policy-fallback.md | 2 + structure/transports/responses-failover.md | 2 +- .../advisor-caller-policy-compact.test.ts | 83 +++++ .../advisor-caller-policy-host-key.test.ts | 212 ++++++++++++ .../advisor-caller-policy-http.test.ts | 80 +++++ .../advisor-caller-policy-ingress.test.ts | 55 +++ .../advisor/advisor-caller-policy-jev.test.ts | 81 +++++ .../advisor-caller-policy-replay.test.ts | 104 ++++++ .../advisor-caller-policy-routing.test.ts | 187 +++++++++++ .../advisor-caller-policy-self-fetch.test.ts | 256 ++++++++++++++ ...or-caller-policy-sidecar-authority.test.ts | 65 ++++ .../advisor-caller-policy-vision.test.ts | 94 ++++++ tests/advisor/advisor-caller-policy.test.ts | 316 ++++++++++++++++++ tests/advisor/advisor-scope-transport.test.ts | 8 +- tests/fixtures/test-layout-expected.json | 11 + tests/responses/chat-native-combo.test.ts | 18 +- .../loopback-listener-admission.test.ts | 2 +- 59 files changed, 2035 insertions(+), 84 deletions(-) create mode 100644 src/server/capture-admission-policy.ts create mode 100644 src/types/admission-policy.ts create mode 100644 tests/advisor/advisor-caller-policy-compact.test.ts create mode 100644 tests/advisor/advisor-caller-policy-host-key.test.ts create mode 100644 tests/advisor/advisor-caller-policy-http.test.ts create mode 100644 tests/advisor/advisor-caller-policy-ingress.test.ts create mode 100644 tests/advisor/advisor-caller-policy-jev.test.ts create mode 100644 tests/advisor/advisor-caller-policy-replay.test.ts create mode 100644 tests/advisor/advisor-caller-policy-routing.test.ts create mode 100644 tests/advisor/advisor-caller-policy-self-fetch.test.ts create mode 100644 tests/advisor/advisor-caller-policy-sidecar-authority.test.ts create mode 100644 tests/advisor/advisor-caller-policy-vision.test.ts create mode 100644 tests/advisor/advisor-caller-policy.test.ts diff --git a/docs-site/src/content/docs/fr/reference/configuration/advisor.md b/docs-site/src/content/docs/fr/reference/configuration/advisor.md index 9c48ca2b7fc..206cbde2454 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/advisor.md +++ b/docs-site/src/content/docs/fr/reference/configuration/advisor.md @@ -77,6 +77,10 @@ Une consultation peut envoyer : Le fournisseur conseiller configuré peut différer de celui du worker. +Les consultations respectent à la fois la portée de modèles capturée de la clé d'API de l'appelant, son consentement aux combos payants et ses limites de repli par appareil, et les limites d'hôte de la propre clé d'admission d'Advisor : `OPENCODEX_API_AUTH_TOKEN` ou le fichier de jeton du service s'il est défini et accepté par ce proxy, sinon la première clé d'API configurée. L'ordre des clés ne compte que dans ce dernier cas : placez en premier une clé qui peut atteindre le modèle Advisor, sinon chaque consultation est indisponible. Lorsqu'un jeton d'environnement ou de service est défini, c'est lui qui contrôle la consultation. Ces règles s'appliquent aussi aux choix de combo, aux modèles de décision imbriqués et aux nouvelles tentatives. Une consultation qui ne peut pas les satisfaire renvoie au worker un avis d'indisponibilité. + +Par exemple, une clé `laptop` avec `allowMeteredComboTargets: false` ne peut pas dépenser sur des cibles de combo facturées via Advisor, et `fallback.devices.laptop.provider: false` bloque aussi les sauts de repli de fournisseur d'Advisor. Un appelant loopback sans clé a toujours besoin de l'option `allowMeteredTargets: true` du combo. Lorsque les deux clés définissent `allowMeteredComboTargets: true` et que le combo définit `allowMeteredTargets: true`, Advisor peut utiliser ses cibles facturées, y compris via l'écouteur loopback d'une liaison publique. Le serveur capture la politique de l'appelant lorsqu'il authentifie la requête, avant d'attendre de la capacité pour le corps ou de le lire ; des modifications ultérieures des clés ou du repli ne peuvent pas élargir cette consultation. Cela couvre aussi les assistants de vision routés, les requêtes compact et leurs modèles de décision, ainsi que chaque nouvelle tentative de candidat dans un tour WebSocket. Chaque nouveau tour WebSocket capture sa propre politique. + OpenCodex n'insère pas dans ce prompt de clés d'API de fournisseur, d'en-têtes Authorization, de jetons OAuth, de secrets de configuration réservés au backend, d'environnement de processus, ni de chaîne de pensée cachée. Il ne déchiffre pas et ne transmet pas un raisonnement privé chiffré du fournisseur. **Le contenu de la tâche n'est pas expurgé de secrets.** Une clé collée dans la tâche, un secret dans un fichier lu par les outils, ou un jeton imprimé par un outil ou un journal peut être envoyé. OpenCodex n'exécute pas de DLP général. ## Autorité diff --git a/docs-site/src/content/docs/ja/reference/configuration/advisor.md b/docs-site/src/content/docs/ja/reference/configuration/advisor.md index d78a179f231..4a5615b8c10 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/advisor.md +++ b/docs-site/src/content/docs/ja/reference/configuration/advisor.md @@ -60,6 +60,10 @@ description: manual、preflight、証拠に基づく adaptive ポリシーによ 設定されたアドバイザーのプロバイダーは、ワーカーのプロバイダーと異なる場合があります。 +相談は、呼び出し元の取得済み API キーのモデル範囲・有料 combo への同意・デバイス別 fallback 制限と、Advisor 自身の受付キーのホスト制限の両方に従います。受付キーは、設定されていれば `OPENCODEX_API_AUTH_TOKEN` またはこのプロキシが受け付けるサービストークンファイル、なければ最初に設定された API キーです。キーの順序が意味を持つのはこの最後の場合だけです。Advisor モデルに届くキーを先頭に置かないと、すべての相談が利用できません。環境トークンまたはサービストークンが設定されているときは、そのトークンが相談を決めます。これらの規則は combo の選択、入れ子の判定モデル、再試行にも適用されます。満たせない相談は worker に利用不可の通知を返します。 + +たとえば `allowMeteredComboTargets: false` の `laptop` キーは Advisor 経由で従量課金の combo ターゲットに費用を使えず、`fallback.devices.laptop.provider: false` は Advisor のプロバイダー fallback ホップも止めます。キーのない loopback 呼び出し元には、引き続き combo の `allowMeteredTargets: true` のオプトインが必要です。両方のキーが `allowMeteredComboTargets: true` を設定し、combo が `allowMeteredTargets: true` を設定していれば、公開バインドの loopback リスナー経由でも Advisor は従量課金ターゲットを使えます。サーバーは本文の容量待ちや本文の読み取りより前、リクエストを認証した時点で呼び出し元のポリシーを取得します。その後のキーや fallback の編集でその相談の範囲が広がることはありません。これはルーティングされた画像補助、compact リクエストとその判定モデル、WebSocket ターン内のすべての候補再試行にも適用されます。新しい WebSocket ターンはそれぞれ自分のポリシーを取得します。 + OpenCodex はプロバイダー API キー、Authorization ヘッダー、OAuth トークン、バックエンド専用の設定秘密、プロセス環境、隠された思考連鎖をそのプロンプトへ入れません。暗号化されたプロバイダー私有の推論を復号して転送することもしません。**タスク内容は一般には秘密除去されません。** タスクへ貼られた鍵、ツールが読んだファイル内の秘密、ツールやログが出力したトークンは送られることがあります。OpenCodex は汎用の DLP を実行しません。 ## 権威 diff --git a/docs-site/src/content/docs/ko/reference/configuration/advisor.md b/docs-site/src/content/docs/ko/reference/configuration/advisor.md index 0e1b4d940fe..c49656a19fb 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/advisor.md +++ b/docs-site/src/content/docs/ko/reference/configuration/advisor.md @@ -60,6 +60,10 @@ description: manual, preflight 및 증거 기반 adaptive 정책을 제공하는 설정된 어드바이저 프로바이더는 워커의 프로바이더와 다를 수 있습니다. +상담은 호출자에게서 캡처한 API 키의 모델 범위, 유료 combo 동의, 디바이스 fallback 제한과 Advisor 자체 승인 키의 호스트 제한을 모두 따릅니다. 승인 키는 설정된 경우 `OPENCODEX_API_AUTH_TOKEN` 또는 이 프록시가 받아들이는 서비스 토큰 파일이고, 그렇지 않으면 첫 번째로 구성된 API 키입니다. 키 순서는 이 마지막 경우에만 의미가 있습니다. Advisor 모델에 접근할 수 있는 키를 맨 앞에 두지 않으면 모든 상담을 사용할 수 없습니다. 환경 토큰이나 서비스 토큰이 설정되어 있으면 그 토큰이 상담을 결정합니다. 이 규칙은 combo 선택, 중첩 판정 모델, 재시도에도 적용됩니다. 규칙을 충족하지 못한 상담은 worker에게 사용 불가 안내를 반환합니다. + +예를 들어 `allowMeteredComboTargets: false`인 `laptop` 키는 Advisor를 통해 종량제 combo 대상에 비용을 쓸 수 없고, `fallback.devices.laptop.provider: false`는 Advisor의 프로바이더 fallback 홉도 막습니다. 키가 없는 loopback 호출자는 여전히 combo의 `allowMeteredTargets: true` 옵트인이 필요합니다. 두 키가 모두 `allowMeteredComboTargets: true`를 설정하고 combo가 `allowMeteredTargets: true`를 설정하면, 공개 바인드의 loopback 리스너를 거치더라도 Advisor가 종량제 대상을 사용할 수 있습니다. 서버는 본문 용량을 기다리거나 본문을 읽기 전, 요청을 인증할 때 호출자 정책을 캡처합니다. 이후 키나 fallback을 편집해도 그 상담의 범위는 넓어지지 않습니다. 라우팅된 비전 도우미, compact 요청과 그 판정 모델, WebSocket 턴 안의 모든 후보 재시도에도 똑같이 적용됩니다. 새 WebSocket 턴은 각자 자신의 정책을 캡처합니다. + OpenCodex는 프로바이더 API 키, Authorization 헤더, OAuth 토큰, 백엔드 전용 설정 비밀, 프로세스 환경, 숨겨진 사고 과정을 그 프롬프트에 넣지 않습니다. 암호화된 프로바이더 사유 추론을 복호해 전달하지도 않습니다. **작업 내용의 비밀은 일반적으로 제거되지 않습니다.** 작업에 붙여 넣은 키, 도구가 읽은 파일 속의 비밀, 도구나 로그가 출력한 토큰은 전송될 수 있습니다. OpenCodex는 범용 DLP를 실행하지 않습니다. ## 권한 diff --git a/docs-site/src/content/docs/reference/configuration/advisor.md b/docs-site/src/content/docs/reference/configuration/advisor.md index 2657e39ee16..aacd2ff6d7e 100644 --- a/docs-site/src/content/docs/reference/configuration/advisor.md +++ b/docs-site/src/content/docs/reference/configuration/advisor.md @@ -118,6 +118,21 @@ A consultation may send: The configured Advisor provider may differ from the worker provider. +Consultations obey both the caller's captured API-key model scope, paid-combo consent and device +fallback limits, and the host limits of Advisor's own admission key: `OPENCODEX_API_AUTH_TOKEN` or the service token file when set and accepted by this proxy, otherwise the first configured API key. Only in that last case does key +order matter: put a key that can reach the Advisor model first, or every consultation is unavailable. +When an environment or service token is set, that token controls the consultation instead. These rules also apply to combo choices, nested +decision models and retries. A consultation that cannot satisfy them returns an unavailable +notice to the worker. + +For example, a `laptop` key with `allowMeteredComboTargets: false` cannot spend on +metered combo targets through Advisor, and `fallback.devices.laptop.provider: false` +also blocks Advisor's provider fallback hops. An unkeyed loopback caller still needs +the combo's `allowMeteredTargets: true` opt-in. When both keys set `allowMeteredComboTargets: true` and the combo sets `allowMeteredTargets: true`, Advisor can use its metered targets, also through the loopback listener of a public bind. The server captures caller policy when it +authenticates the request, before waiting for body capacity or reading the body; later key or fallback edits cannot broaden that consultation. +This also covers routed vision helpers, compact requests and their decision models, and every +candidate retry within a WebSocket turn. Each new WebSocket turn captures its own policy. + OpenCodex does not insert provider API keys, authorization headers, OAuth tokens, backend-only config secrets, process environment, or hidden chain-of-thought into that prompt. It does not decrypt or forward encrypted provider-private reasoning. **Task content is not secret-redacted.** diff --git a/docs-site/src/content/docs/ru/reference/configuration/advisor.md b/docs-site/src/content/docs/ru/reference/configuration/advisor.md index 7619ab6724c..bc0f09bd583 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/advisor.md +++ b/docs-site/src/content/docs/ru/reference/configuration/advisor.md @@ -60,6 +60,10 @@ description: Независимые консультации с политика Настроенный провайдер консультанта может отличаться от провайдера воркера. +Консультации подчиняются одновременно захваченным ограничениям API-ключа вызывающей стороны (область моделей, согласие на платные combo, лимиты резервного перехода для устройства) и ограничениям хоста для собственного ключа допуска Advisor: `OPENCODEX_API_AUTH_TOKEN` или файл токена службы, если они заданы и этот прокси его принимает, иначе первый настроенный API-ключ. Порядок ключей важен только в последнем случае: поставьте первым ключ, которому доступна модель Advisor, иначе все консультации будут недоступны. Если задан токен окружения или службы, консультацию определяет он. Эти правила действуют и для выбора combo, вложенных моделей принятия решений и повторных попыток. Консультация, которая не может им удовлетворить, возвращает worker уведомление о недоступности. + +Например, ключ `laptop` с `allowMeteredComboTargets: false` не может тратить средства на платные цели combo через Advisor, а `fallback.devices.laptop.provider: false` блокирует и переходы Advisor на резервных провайдеров. Вызывающей стороне loopback без ключа по-прежнему нужно согласие combo `allowMeteredTargets: true`. Если оба ключа задают `allowMeteredComboTargets: true`, а combo задаёт `allowMeteredTargets: true`, Advisor может использовать платные цели, в том числе через loopback-слушатель публичной привязки. Сервер захватывает политику вызывающей стороны при аутентификации запроса, до ожидания ёмкости для тела и до его чтения; последующие изменения ключей или резервных переходов не расширяют эту консультацию. Это касается и маршрутизируемых помощников зрения, запросов compact и их моделей решений, а также каждой повторной попытки кандидата в пределах хода WebSocket. Каждый новый ход WebSocket захватывает свою политику. + OpenCodex не вставляет в этот запрос ключи API провайдера, заголовки Authorization, токены OAuth, секреты конфигурации только для бэкенда, окружение процесса или скрытую цепочку рассуждений. Он не расшифровывает и не пересылает зашифрованное закрытое рассуждение провайдера. **Содержимое задачи от секретов не очищается.** Вставленный в задачу ключ, секрет в файле, который прочитали инструменты, или токен, напечатанный инструментом или журналом, может быть отправлен. OpenCodex не выполняет общий DLP. ## Полномочия diff --git a/docs-site/src/content/docs/tr/reference/configuration/advisor.md b/docs-site/src/content/docs/tr/reference/configuration/advisor.md index 0a44487f45e..4d20a4c9b6e 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/advisor.md +++ b/docs-site/src/content/docs/tr/reference/configuration/advisor.md @@ -60,6 +60,10 @@ Bir danışma şunları gönderebilir: Yapılandırılmış danışman sağlayıcısı, worker sağlayıcısından farklı olabilir. +Danışmalar hem çağıranın yakalanan API anahtarı model kapsamına, ücretli combo onayına ve cihaz yedekleme sınırlarına, hem de Advisor'ın kendi kabul anahtarının ana makine sınırlarına uyar: ayarlıysa `OPENCODEX_API_AUTH_TOKEN` veya bu proxy'nin kabul ettiği hizmet belirteci dosyası, aksi halde yapılandırılmış ilk API anahtarı. Anahtar sırası yalnızca bu son durumda önemlidir: Advisor modeline ulaşabilen bir anahtarı başa koyun, aksi halde her danışma kullanılamaz. Bir ortam veya hizmet belirteci ayarlıysa danışmayı o belirteç belirler. Bu kurallar combo seçimlerine, iç içe karar modellerine ve yeniden denemelere de uygulanır. Bunları karşılayamayan bir danışma, worker'a kullanılamıyor bildirimi döndürür. + +Örneğin `allowMeteredComboTargets: false` olan bir `laptop` anahtarı Advisor üzerinden ücretli combo hedeflerine harcama yapamaz ve `fallback.devices.laptop.provider: false` Advisor'ın sağlayıcı yedekleme adımlarını da engeller. Anahtarsız bir loopback çağıranı yine de combo'nun `allowMeteredTargets: true` onayına ihtiyaç duyar. Her iki anahtar da `allowMeteredComboTargets: true` ayarlarsa ve combo `allowMeteredTargets: true` ayarlarsa, Advisor ücretli hedefleri genel bir bağlamanın loopback dinleyicisi üzerinden de kullanabilir. Sunucu çağıran politikasını isteği doğruladığı anda, gövde kapasitesini beklemeden veya gövdeyi okumadan önce yakalar; sonraki anahtar veya yedekleme düzenlemeleri o danışmayı genişletemez. Bu, yönlendirilmiş görsel yardımcıları, compact isteklerini ve karar modellerini ve bir WebSocket turundaki her aday yeniden denemesini de kapsar. Her yeni WebSocket turu kendi politikasını yakalar. + OpenCodex bu isteme sağlayıcı API anahtarlarını, Authorization başlıklarını, OAuth belirteçlerini, yalnızca arka uca ait yapılandırma sırlarını, süreç ortamını veya gizli düşünce zincirini koymaz. Şifreli sağlayıcıya özel akıl yürütmeyi çözüp iletmez. **Görev içeriği sırlardan arındırılmaz.** Göreve yapıştırılan bir anahtar, araçların okuduğu dosyadaki bir sır veya bir aracın ya da günlüğün yazdırdığı belirteç gönderilebilir. OpenCodex genel bir DLP çalıştırmaz. ## Yetki diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/advisor.md b/docs-site/src/content/docs/zh-cn/reference/configuration/advisor.md index 33d2ef714ab..41f38dcafea 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/advisor.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/advisor.md @@ -60,6 +60,10 @@ description: 独立专家咨询,支持 manual、preflight 与基于证据的 a 所配置的顾问 provider 可能与 Worker 的 provider 不同。 +咨询同时遵守调用方捕获的 API key 模型范围、付费 combo 同意和设备 fallback 限制,以及 Advisor 自身准入密钥的主机限制:设置时为 `OPENCODEX_API_AUTH_TOKEN` 或本代理接受的服务 token 文件,否则为第一个配置的 API key。只有在最后这种情况下密钥顺序才有影响:请把能访问 Advisor 模型的密钥放在第一位,否则每次咨询都不可用。设置了环境或服务 token 时,由该 token 决定咨询。这些规则同样适用于 combo 选择、嵌套决策模型和重试。无法满足这些规则的咨询会向 worker 返回不可用通知。 + +例如,`allowMeteredComboTargets: false` 的 `laptop` 密钥无法通过 Advisor 在计费 combo 目标上花费,`fallback.devices.laptop.provider: false` 也会阻止 Advisor 的 provider fallback 跳转。没有密钥的 loopback 调用方仍需要 combo 的 `allowMeteredTargets: true` 选择加入。当两个密钥都设置 `allowMeteredComboTargets: true` 且 combo 设置 `allowMeteredTargets: true` 时,即使经由公共绑定的 loopback 监听器,Advisor 也可以使用计费目标。服务器在验证请求时捕获调用方策略,早于等待正文容量或读取正文;之后的密钥或 fallback 编辑无法扩大该次咨询。这也覆盖路由的视觉辅助、compact 请求及其决策模型,以及 WebSocket 轮次内的每次候选重试。每个新的 WebSocket 轮次会捕获自己的策略。 + OpenCodex 不会把 provider API key、Authorization 头、OAuth token、仅后端使用的配置密钥、进程环境或隐藏的思维链写进该提示,也不会解密或转发加密的 provider 私有推理。**任务内容不做通用脱敏。** 贴进任务的密钥、工具读到的文件里的秘密、工具或日志打印出的 token 都可能被发送。OpenCodex 不运行通用 DLP。 ## 权威 diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/advisor.md b/docs-site/src/content/docs/zh-tw/reference/configuration/advisor.md index 05a2936e8f5..2bcaa37e209 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/advisor.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/advisor.md @@ -60,6 +60,10 @@ description: 獨立專家諮詢,支援 manual、preflight 與依據證據的 a 所設定的顧問 provider 可能與 Worker 的 provider 不同。 +諮詢同時遵守呼叫者擷取的 API key 模型範圍、付費 combo 同意與裝置 fallback 限制,以及 Advisor 自身准入金鑰的主機限制:設定時為 `OPENCODEX_API_AUTH_TOKEN` 或本代理接受的服務 token 檔案,否則為第一個設定的 API key。只有在最後這種情況下金鑰順序才有影響:請把能存取 Advisor 模型的金鑰放在第一位,否則每次諮詢都無法使用。設定了環境或服務 token 時,由該 token 決定諮詢。這些規則同樣適用於 combo 選擇、巢狀決策模型與重試。無法滿足這些規則的諮詢會向 worker 回傳不可用通知。 + +例如,`allowMeteredComboTargets: false` 的 `laptop` 金鑰無法透過 Advisor 在計費 combo 目標上花費,`fallback.devices.laptop.provider: false` 也會阻擋 Advisor 的 provider fallback 跳轉。沒有金鑰的 loopback 呼叫者仍需要 combo 的 `allowMeteredTargets: true` 選擇加入。當兩個金鑰都設定 `allowMeteredComboTargets: true` 且 combo 設定 `allowMeteredTargets: true` 時,即使經由公開綁定的 loopback 監聽器,Advisor 也能使用計費目標。伺服器在驗證請求時擷取呼叫者政策,早於等待本文容量或讀取本文;之後的金鑰或 fallback 編輯無法擴大該次諮詢。這也涵蓋路由的視覺輔助、compact 請求及其決策模型,以及 WebSocket 回合內的每次候選重試。每個新的 WebSocket 回合會擷取自己的政策。 + OpenCodex 不會把 provider API key、Authorization 標頭、OAuth token、僅後端使用的設定密鑰、行程環境或隱藏的思維鏈寫進該提示,也不會解密或轉送加密的 provider 私有推理。**任務內容不做通用脫敏。** 貼進任務的金鑰、工具讀到的檔案裡的秘密、工具或日誌印出的 token 都可能被傳送。OpenCodex 不執行通用 DLP。 ## 權威 diff --git a/scripts/test-layout/layout-agent-assertion.json b/scripts/test-layout/layout-agent-assertion.json index 88e910c252e..190baeb209e 100644 --- a/scripts/test-layout/layout-agent-assertion.json +++ b/scripts/test-layout/layout-agent-assertion.json @@ -1,4 +1,15 @@ { + "advisor-caller-policy.test.ts": "advisor", + "advisor-caller-policy-sidecar-authority.test.ts": "advisor", + "advisor-caller-policy-vision.test.ts": "advisor", + "advisor-caller-policy-replay.test.ts": "advisor", + "advisor-caller-policy-compact.test.ts": "advisor", + "advisor-caller-policy-ingress.test.ts": "advisor", + "advisor-caller-policy-http.test.ts": "advisor", + "advisor-caller-policy-jev.test.ts": "advisor", + "advisor-caller-policy-routing.test.ts": "advisor", + "advisor-caller-policy-host-key.test.ts": "advisor", + "advisor-caller-policy-self-fetch.test.ts": "advisor", "windows-native-principal.test.ts": "windows", "fixture-windows-runtime-preload.test.ts": "ci-workflows", "agent-assertion-auth.test.ts": "server", diff --git a/src/advisor/consult.ts b/src/advisor/consult.ts index c27b79daa79..a08429e697d 100644 --- a/src/advisor/consult.ts +++ b/src/advisor/consult.ts @@ -18,7 +18,7 @@ * unavailable context or with nothing, depending on the trigger. */ import type { OcxConfig } from "../types"; -import { localAdmissionToken, localInferenceDestination } from "../lib/local-destinations"; +import { localInferenceDestination, selfFetchAdmissionToken, type LocalInferenceDestination } from "../lib/local-destinations"; import { signalWithTimeout, cancelBodyOnAbort } from "../lib/abort"; import { redactSecretString } from "../lib/redact"; import { sidecarEnter } from "../lib/sidecar-tracker"; @@ -33,8 +33,12 @@ import { ADVISOR_ADMISSION_SCOPE_HEADER, AdvisorAdmissionScopeEncodedBoundExceeded, encodeAdvisorAdmissionScope, + ADVISOR_ADMISSION_POLICY_HEADER, + registerAdvisorAdmissionPolicy, } from "../server/advisor-admission-scope"; import type { AdmissionModelScope } from "../server/admission-model-scope"; +import type { CapturedAdmissionPolicy } from "../types/admission-policy"; +import { captureAdmissionPolicy } from "../server/capture-admission-policy"; /** * The header the advisor presents on its own loopback request. Its VALUE is this process's @@ -66,12 +70,18 @@ export interface AdvisorConsultationResult { export function advisorDestinationOrigin( config: Pick, ): string { + return advisorDestination(config).origin; +} + +function advisorDestination( + config: Pick, +): LocalInferenceDestination { // Same port resolution rule as the vision sidecar: config.port can be 0 (ephemeral bind, tests) // or stale after a live port override, so prefer the recorded actual bind port when present. const port = config.port && config.port > 0 ? config.port : Number(configuredPort()) || 10_100; - return localInferenceDestination(config, port).origin; + return localInferenceDestination(config, port); } function extractContent(payload: unknown): string | undefined { @@ -123,6 +133,7 @@ export async function consultAdvisor( abortSignal?: AbortSignal, baseUrlOverride?: string, admissionScope?: AdmissionModelScope, + admissionPolicy?: CapturedAdmissionPolicy, ): Promise { const t0 = Date.now(); const headers: Record = { @@ -130,15 +141,12 @@ export async function consultAdvisor( [ADVISOR_INTERNAL_CAPABILITY_HEADER]: internalCallCapability(), }; // Admission ladder identical to the vision sidecar: env token || service token file || first - // configured API key, sent as `x-opencodex-api-key` — never Authorization. Loopback binds that - // require no token simply omit it. - const admission = localAdmissionToken(config); + // configured API key, sent as `x-opencodex-api-key` — never Authorization. A destination that + // requires no admission gets only a credential this data plane recognizes (#7894). + const admission = selfFetchAdmissionToken(config, advisorDestination(config)); if (admission) headers["x-opencodex-api-key"] = admission; - // Always send the caller's resolved snapshot, including the unrestricted empty form: an - // omitted header would be re-resolved against the host admission token above, and a - // restricted host key would then narrow a caller whose own scope allows the destination. - // Empty lists decode as a defined-but-unrestricted scope, so the capability fence and the - // malformed-header refusal are unchanged. + // Keep the bounded scope envelope for transport validation; the process-local policy + // registration below owns the complete snapshot. The receiver also enforces host limits. try { headers[ADVISOR_ADMISSION_SCOPE_HEADER] = encodeAdvisorAdmissionScope(admissionScope ?? { providers: [], models: [] }); } catch (error) { @@ -167,7 +175,10 @@ export async function consultAdvisor( linkedSignal.cleanup(); return { ok: false, cancelled: true, advice: "", advisorModel: input.advisorModel, error: "advisor cancelled before dispatch", durationMs: Date.now() - t0 }; } + let policyRegistration: ReturnType | undefined; try { + policyRegistration = registerAdvisorAdmissionPolicy(admissionPolicy ?? captureAdmissionPolicy(config, undefined, admissionScope), linkedSignal.signal); + headers[ADVISOR_ADMISSION_POLICY_HEADER] = policyRegistration.token; const res = await fetch(`${baseUrlOverride ?? advisorBaseUrl(config)}/v1/chat/completions`, { method: "POST", headers, @@ -239,6 +250,7 @@ export async function consultAdvisor( durationMs, }; } finally { + policyRegistration?.release(); sidecarExit(); linkedSignal.cleanup(); } diff --git a/src/advisor/runtime.ts b/src/advisor/runtime.ts index 8e62f7bcd4b..82c3e6d1287 100644 --- a/src/advisor/runtime.ts +++ b/src/advisor/runtime.ts @@ -55,6 +55,7 @@ export interface AdvisorRuntimeDeps { workerIdentity: string; workerModelId: string; admissionScope?: AdmissionModelScope; + admissionPolicy?: import("../types/admission-policy").CapturedAdmissionPolicy; abortSignal?: AbortSignal; /** Test seam; production always self-fetches the resolved local destination. */ baseUrlOverride?: string; @@ -222,7 +223,8 @@ export function createAdvisorRuntimePlan(deps: AdvisorRuntimeDeps): AdvisorRunti current.timeoutMs, consultationSignal, deps.baseUrlOverride, - deps.admissionScope, + deps.admissionScope, + deps.admissionPolicy, ); } catch (error) { activities.finish(activityId, { status: "failed", durationMs: Math.max(0, now() - startedAt) }); diff --git a/src/lib/local-chat-completion.ts b/src/lib/local-chat-completion.ts index 04c60ca0e19..c500aa71769 100644 --- a/src/lib/local-chat-completion.ts +++ b/src/lib/local-chat-completion.ts @@ -7,12 +7,13 @@ * * Destination: the unauthenticated loopback listener when one is enabled, otherwise the bind * address (#4236), resolved by localInferenceDestination rather than composing 127.0.0.1 by hand. - * Admission: localAdmissionToken (env token, hardened service token file, first apiKeys entry), - * sent as x-opencodex-api-key and never Authorization, because an admission secret in a - * forwardable header is a forwarding hazard. Never the admin token. + * Admission: selfFetchAdmissionToken (env token, hardened service token file, first apiKeys entry; + * only a recognized one when the destination requires no admission), sent as x-opencodex-api-key + * and never Authorization, because an admission secret in a forwardable header is a forwarding + * hazard. Never the admin token. */ import type { OcxConfig } from "../types"; -import { localAdmissionToken, localInferenceDestination } from "./local-destinations"; +import { localInferenceDestination, selfFetchAdmissionToken } from "./local-destinations"; import { signalWithTimeout, cancelBodyOnAbort } from "./abort"; import { readBoundedResponseBytes } from "./bounded-body"; import { redactSecretString } from "./redact"; @@ -64,12 +65,13 @@ export function localChatCompletionBaseUrl( export async function postLocalChatCompletion(request: LocalChatCompletionRequest): Promise { const { config, label, logTag } = request; const headers: Record = { "Content-Type": "application/json", ...request.headers }; - const admission = localAdmissionToken(config); + const target = destination(config); + const admission = selfFetchAdmissionToken(config, target); if (admission) headers["x-opencodex-api-key"] = admission; // A bind that demands admission with no resolvable credential would return 401 with a body // the caller reports as a failure; naming the cause once is the difference between "it is // broken" and a fixable configuration note. - if (!admission && !request.baseUrlOverride && destination(config).requiresAdmissionToken) { + if (!admission && !request.baseUrlOverride && target.requiresAdmissionToken) { console.warn( `[${logTag}] ${label} has no opencodex data-plane credential for ` + `${localChatCompletionBaseUrl(config)} — the self-fetch will be refused.`, diff --git a/src/lib/local-destinations.ts b/src/lib/local-destinations.ts index 4dfb1a0469e..8e8713440bd 100644 --- a/src/lib/local-destinations.ts +++ b/src/lib/local-destinations.ts @@ -40,6 +40,7 @@ import { isIP } from "node:net"; import { effectiveLoopbackListenerPort, isLoopbackHostname, isWildcardHostname, shouldInjectApiAuthHeader } from "../codex/loopback-target"; import { probeHostname } from "../server/proxy-liveness"; +import { resolveDataPlaneAdmissionSecret } from "../server/auth-cors"; import { loadServiceTokenFromFile, serviceApiTokenFilePath } from "./service-secrets"; import type { OcxConfig } from "../types"; @@ -150,8 +151,16 @@ export function localAdmissionToken( config: Pick | undefined, env: NodeJS.ProcessEnv = process.env, ): string | undefined { + return localAdmissionTokenCandidates(config, env)[0]; +} + +function localAdmissionTokenCandidates( + config: Pick | undefined, + env: NodeJS.ProcessEnv, +): string[] { + const candidates: string[] = []; const envToken = env.OPENCODEX_API_AUTH_TOKEN?.trim(); - if (envToken) return envToken; + if (envToken) candidates.push(envToken); const lookup = env.OCX_API_TOKEN_FILE?.trim() ? env : { ...env, OCX_API_TOKEN_FILE: serviceApiTokenFilePath() }; @@ -160,9 +169,29 @@ export function localAdmissionToken( // deliberately and pass through verbatim; a path, by contrast, can be pointed at or replaced // by something that is not a credential at all, and sending that as one leaks file contents // into a request header. Configured keys are never checked, so no existing key can be broken. - if (fileToken && ADMISSION_TOKEN_SHAPE.test(fileToken)) return fileToken; + if (fileToken && ADMISSION_TOKEN_SHAPE.test(fileToken)) candidates.push(fileToken); const configured = config?.apiKeys?.find(entry => entry.key.trim().length > 0)?.key.trim(); - return configured || undefined; + if (configured) candidates.push(configured); + return candidates; +} + +/** + * The credential an in-process self-fetch (Advisor, routed helpers) presents to `destination`. + * + * A destination that demands admission gets the `localAdmissionToken` ladder unchanged. One that + * does not still receives the first ladder credential this data plane recognizes, because Chat + * applies that key's limits to an internal hop a loopback listener admitted without reading it + * (#7894). An unrecognized one, such as a hub client key a loopback bind deliberately left out of + * its admission credentials, is not sent: Chat refuses any credential it cannot resolve. + */ +export function selfFetchAdmissionToken( + config: Pick | undefined, + destination: Pick, + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + const candidates = localAdmissionTokenCandidates(config, env); + if (destination.requiresAdmissionToken) return candidates[0]; + return candidates.find(token => resolveDataPlaneAdmissionSecret(token, config ?? {}) !== null); } /** An admission credential is an opaque printable token — never JSON, a path, or multi-line. */ diff --git a/src/routing/fallback-policy.ts b/src/routing/fallback-policy.ts index f6db069b56d..6a47f71301f 100644 --- a/src/routing/fallback-policy.ts +++ b/src/routing/fallback-policy.ts @@ -46,7 +46,7 @@ export interface FallbackEvent { } /** The admission slice this module reads; a configured key is the only device identity. */ -export type FallbackAdmission = { kind: string; keyId?: string } | null | undefined; +export type FallbackAdmission = { kind: string; keyId?: string; readonly callerPolicy?: import("../types/admission-policy").CapturedAdmissionPolicy } | null | undefined; const RING_SIZE = 20; const ring: FallbackEvent[] = []; @@ -210,9 +210,13 @@ export function guardFallbackHop(config: OcxConfig, hop: FallbackHop): FallbackG const kind = fallbackHopKind(hop.from, hop.to); if (kind === null) return { ok: true, kind: null, commit: NO_COMMIT }; const now = hop.now ?? Date.now(); - const deviceIds = fallbackDeviceIds(config, hop.admission, now); + let deviceIds = fallbackDeviceIds(config, hop.admission, now); const routes = [hop.route, ...(hop.aliases ?? [])].filter((value, index, all) => value && all.indexOf(value) === index); - const decision = resolveFallbackPolicy(config, { kind, routes, provider: hop.from.provider, deviceIds }); + let decision = resolveFallbackPolicy(config, { kind, routes, provider: hop.from.provider, deviceIds }); + for (const captured of hop.admission?.callerPolicy?.fallbackPolicies ?? []) { + const caller = resolveFallbackPolicy(captured.config, { kind, routes, provider: hop.from.provider, deviceIds: captured.deviceIds }); + if (!caller.allowed) { decision = caller; deviceIds = [...captured.deviceIds]; break; } + } const event: FallbackEvent = { at: new Date(now).toISOString(), site: hop.site, diff --git a/src/server/admission-model-scope.ts b/src/server/admission-model-scope.ts index 4a1b4aee57b..e6a486b5495 100644 --- a/src/server/admission-model-scope.ts +++ b/src/server/admission-model-scope.ts @@ -25,6 +25,8 @@ import { markResponseNonReplayable } from "../lib/upstream-retry"; * the management credential. */ export interface AdmissionModelScope { + /** Every inherited scope must also admit the final destination. */ + readonly allOf?: readonly AdmissionModelScope[]; /** Resolved provider names this key may reach. Empty means every provider. */ readonly providers: readonly string[]; /** Resolved destinations this key may reach. Empty means every model. */ @@ -44,6 +46,11 @@ function providerAllowedByScope(scope: AdmissionModelScope, providerName: string return scope.providers.length === 0 || scope.providers.includes(normalize(providerName)); } +function unnamedDestinationAllowedByScope(scope: AdmissionModelScope, providerName: string): boolean { + return providerAllowedByScope(scope, providerName) && scope.models.length === 0 + && (scope.allOf?.every(part => unnamedDestinationAllowedByScope(part, providerName)) ?? true); +} + function normalizedList(values: readonly string[] | undefined): readonly string[] { if (!Array.isArray(values)) return []; const seen = new Set(); @@ -73,9 +80,13 @@ export function resolveAdmissionModelScope( config: Pick, admission: DataPlaneAdmission | undefined, ): AdmissionModelScope | undefined { - if (!admission || admission.kind !== "configured") return undefined; - const entry = (config.apiKeys ?? []).find(key => key.id === admission.keyId); - return entry ? admissionModelScopeOf(entry) : undefined; + const entry = admission?.kind === "configured" + ? (config.apiKeys ?? []).find(key => key.id === admission.keyId) : undefined; + return intersectAdmissionModelScopes(entry ? admissionModelScopeOf(entry) : undefined, admission?.callerPolicy?.modelScope); +} + +export function intersectAdmissionModelScopes(a: AdmissionModelScope | undefined, b: AdmissionModelScope | undefined): AdmissionModelScope | undefined { + return a && b ? { providers: [], models: [], allOf: [a, b] } : a ?? b; } /** Consent comes from stored operator config, never request headers. Use the full listener config. */ @@ -84,10 +95,15 @@ export function admissionMayUseMeteredComboTargets( admission: DataPlaneAdmission | undefined, combo?: { allowMeteredTargets?: boolean }, ): boolean { + const caller = admission?.callerPolicy; + if (caller && (!caller.allowMeteredComboTargets || caller.meteredRequiresComboOptIn && combo?.allowMeteredTargets !== true)) return false; if (admission?.kind === "configured") { const entry = (config.apiKeys ?? []).find(key => key.id === admission.keyId); return entry?.allowMeteredComboTargets === true; } + // A loopback-admitted internal hop's captured policy already holds this branch's decision for + // its listener, or the consent of the validated key it presented (#7894); it was checked above. + if (admission?.kind === "loopback" && caller) return true; // Linked ingress always carries keyed admission, even when the primary bind is loopback. // A secondary loopback listener on a public proxy does not qualify either. return (!admission || admission.kind === "loopback") @@ -111,6 +127,7 @@ export function routeAllowedByScope( route: ScopedRoute, ): boolean { if (!scope) return true; + if (scope.allOf?.some(part => !routeAllowedByScope(part, route))) return false; const model = normalize(route.modelId); if (!providerAllowedByScope(scope, route.providerName)) return false; if (scope.models.length === 0) return true; @@ -226,7 +243,7 @@ export function admissionScopeDenial( const scope = resolveAdmissionModelScope(config, admission); if (!scope) return undefined; const allowed = destination.modelId === undefined - ? providerAllowedByScope(scope, destination.providerName) && scope.models.length === 0 + ? unnamedDestinationAllowedByScope(scope, destination.providerName) : routeAllowedByScope(scope, { providerName: destination.providerName, modelId: destination.modelId }); if (allowed) return undefined; return admissionModelDeniedResponse(new AdmissionModelDeniedError( diff --git a/src/server/advisor-admission-scope.ts b/src/server/advisor-admission-scope.ts index ff0fe6767c0..289683a22b1 100644 --- a/src/server/advisor-admission-scope.ts +++ b/src/server/advisor-admission-scope.ts @@ -1,4 +1,33 @@ import type { AdmissionModelScope } from "./admission-model-scope"; +import type { CapturedAdmissionPolicy } from "../types/admission-policy"; +import { internalCallCapability, isInternalCallCapability } from "../lib/local-internal-call-capability"; +import { immutableAdmissionPolicy } from "./capture-admission-policy"; + +export const ADVISOR_ADMISSION_POLICY_HEADER = "x-opencodex-advisor-admission-policy"; +/** Routed helpers inherit policy without acquiring the Advisor recursion-suppression flag. */ +export const SIDECAR_ADMISSION_POLICY_HEADER = "x-opencodex-sidecar-admission-policy"; +export const SIDECAR_INTERNAL_CAPABILITY_HEADER = "x-opencodex-sidecar-internal"; +const policies = new Map(); + +/** A consult owns its registry entry until completion, failure or cancellation. */ +export function registerAdvisorAdmissionPolicy(policy: CapturedAdmissionPolicy, signal?: AbortSignal) { + const token = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url"); + const release = () => { + policies.delete(token); + signal?.removeEventListener("abort", release); + }; + if (!signal?.aborted) { + policies.set(token, { capability: internalCallCapability(), policy: immutableAdmissionPolicy(policy) }); + signal?.addEventListener("abort", release, { once: true }); + } + return { token, release }; +} + +export function readAdvisorAdmissionPolicy(token: string | null, capability: string | null): CapturedAdmissionPolicy | undefined { + if (!token || !isInternalCallCapability(capability)) return undefined; + const entry = policies.get(token); + return entry?.capability === capability ? entry.policy : undefined; +} /** Scope metadata accepted only alongside the process-owned Advisor capability. */ export const ADVISOR_ADMISSION_SCOPE_HEADER = "x-opencodex-advisor-admission-scope"; diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 00383e2b2d3..4d124d4ac7e 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -433,10 +433,11 @@ function mintContextPrincipal(kind: string, keyId: string, credential: string): .digest("hex"); } -export type DataPlaneAdmission = +export type DataPlaneAdmission = ( | { kind: "configured"; keyId: string; source: DataPlaneAdmissionSource; contextPrincipalId?: string } | { kind: "environment"; source: DataPlaneAdmissionSource; contextPrincipalId?: string } - | { kind: "loopback"; source: "loopback" }; + | { kind: "loopback"; source: "loopback" } +) & { readonly callerPolicy?: import("../types/admission-policy").CapturedAdmissionPolicy }; /** * Which admission secret `token` is, or null when it is none of them. diff --git a/src/server/capture-admission-policy.ts b/src/server/capture-admission-policy.ts new file mode 100644 index 00000000000..745752eadda --- /dev/null +++ b/src/server/capture-admission-policy.ts @@ -0,0 +1,91 @@ +import type { OcxConfig } from "../types"; +import type { CapturedAdmissionPolicy } from "../types/admission-policy"; +import type { DataPlaneAdmission } from "./auth-cors"; +import { resolveDataPlaneAdmissionSecret } from "./auth-cors"; +import { admissionMayUseMeteredComboTargets, intersectAdmissionModelScopes, resolveAdmissionModelScope } from "./admission-model-scope"; +import { fallbackDeviceIds } from "../routing/fallback-policy"; + +function freeze(value: T): T { + if (value && typeof value === "object") { + for (const child of Object.values(value)) freeze(child); + Object.freeze(value); + } + return value; +} + +export function immutableAdmissionPolicy(policy: CapturedAdmissionPolicy): CapturedAdmissionPolicy { + return freeze(structuredClone(policy)); +} + +/** Combine already captured inputs without reading mutable caller or host configuration. */ +export function intersectAdmissionPolicies(host: CapturedAdmissionPolicy, caller: CapturedAdmissionPolicy): CapturedAdmissionPolicy { + return immutableAdmissionPolicy({ + modelScope: intersectAdmissionModelScopes(host.modelScope, caller.modelScope)!, + allowMeteredComboTargets: host.allowMeteredComboTargets && caller.allowMeteredComboTargets, + meteredRequiresComboOptIn: host.meteredRequiresComboOptIn || caller.meteredRequiresComboOptIn, + fallbackPolicies: [...host.fallbackPolicies, ...caller.fallbackPolicies], + }); +} + +export function captureAdmissionPolicy( + config: Pick, + admission?: DataPlaneAdmission, + modelScope = resolveAdmissionModelScope(config, admission), +): CapturedAdmissionPolicy { + return immutableAdmissionPolicy({ + modelScope: modelScope ?? { providers: [], models: [] }, + allowMeteredComboTargets: admissionMayUseMeteredComboTargets(config, admission, { allowMeteredTargets: true }), + meteredRequiresComboOptIn: !admission || admission.kind === "loopback" + || admission.callerPolicy?.meteredRequiresComboOptIn === true, + fallbackPolicies: [ + ...(admission?.callerPolicy?.fallbackPolicies ?? []), + { config: { fallback: config.fallback }, deviceIds: fallbackDeviceIds(config, admission) }, + ], + }); +} + +/** Attach constraints without changing the host's transport/principal identity. */ +export function inheritAdmissionPolicy( + admission: DataPlaneAdmission | undefined, + callerPolicy: CapturedAdmissionPolicy, +): DataPlaneAdmission { + return Object.freeze({ ...(admission ?? { kind: "loopback", source: "loopback" }), callerPolicy }) as DataPlaneAdmission; +} + +/** + * Host policy of the data-plane credential an internal self-fetch presents (#7894). + * + * A loopback listener admits the self-fetch before it reads any token, so the listener snapshot + * says "loopback" and carries none of the selected key's model scope, paid-combo consent or + * device fallback identity. Call only after the internal capability and policy registration have + * been validated. Returns undefined when no credential is presented, and null when the data plane + * does not recognize it, which the caller must refuse rather than treat as unrestricted. + */ +export function captureSelfFetchCredentialPolicy( + req: Request, + config: Pick, +): CapturedAdmissionPolicy | null | undefined { + const presented = req.headers.get("x-opencodex-api-key")?.trim(); + if (!presented) return undefined; + const admission = resolveDataPlaneAdmissionSecret(presented, config, "dedicated"); + return admission ? captureAdmissionPolicy(config, admission) : null; +} + +/** + * Host policy of an internal self-fetch whose presented credential was validated (#7894). + * + * Scope and fallback limits of the listener snapshot and the key both apply. Paid-combo consent + * is the key's own when a loopback listener admitted the hop: that snapshot holds only the + * loopback default, which a public proxy always refuses, so a consenting key would otherwise be + * weaker over Advisor than on a direct request. The loopback snapshot still demands the combo's + * own opt-in, and the caller's consent is intersected afterwards. + */ +export function selfFetchHostPolicy( + listener: CapturedAdmissionPolicy, + presented: CapturedAdmissionPolicy, + listenerAdmission: DataPlaneAdmission | undefined, +): CapturedAdmissionPolicy { + const merged = intersectAdmissionPolicies(listener, presented); + if (listenerAdmission && listenerAdmission.kind !== "loopback") return merged; + return immutableAdmissionPolicy({ ...merged, allowMeteredComboTargets: presented.allowMeteredComboTargets }); +} diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index f387e85a1f4..eb876f0983b 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -23,7 +23,11 @@ import { responsesSseToChatCompletionsSse, } from "../chat/outbound"; import { ADVISOR_INTERNAL_CAPABILITY_HEADER, isInternalCallCapability } from "../lib/local-internal-call-capability"; -import { ADVISOR_ADMISSION_SCOPE_HEADER, decodeAdvisorAdmissionScope } from "./advisor-admission-scope"; +import type { CapturedAdmissionPolicy } from "../types/admission-policy"; +import { ADVISOR_ADMISSION_SCOPE_HEADER, decodeAdvisorAdmissionScope, ADVISOR_ADMISSION_POLICY_HEADER, readAdvisorAdmissionPolicy, SIDECAR_ADMISSION_POLICY_HEADER, SIDECAR_INTERNAL_CAPABILITY_HEADER } from "./advisor-admission-scope"; +import { + captureAdmissionPolicy, captureSelfFetchCredentialPolicy, inheritAdmissionPolicy, intersectAdmissionPolicies, selfFetchHostPolicy, +} from "./capture-admission-policy"; import { classifyError, cyberPolicyErrorType, CYBER_POLICY_ERROR_CODE, isCyberPolicyCode, UNDECLARED_TOOL_CALL_ERROR_CODE } from "../lib/errors"; import { redactSecretString } from "../lib/redact"; import { resolveClientRetryAfter } from "../lib/retry-after"; @@ -118,7 +122,10 @@ export async function handleChatCompletions( req: Request, config: OcxConfig, logCtx: RequestLogContext, - logIds?: { requestId: string; start: number; turnAdmissionLease?: AdmissionLease; admission?: DataPlaneAdmission }, + logIds?: { + requestId: string; start: number; turnAdmissionLease?: AdmissionLease; admission?: DataPlaneAdmission; + advisorAdmissionPolicy?: CapturedAdmissionPolicy; + }, ): Promise { // Set before routing so pre-dispatch refusals stay correlated. A Hermes-only id is marked as a // fallback, so a native id resolved later on the Responses bridge still replaces it. @@ -152,17 +159,35 @@ async function handleChatCompletionsWithBudget( config: OcxConfig, logCtx: RequestLogContext, translatorBudget: TranslatorBudget, - logIds?: { requestId: string; start: number; turnAdmissionLease?: AdmissionLease; admission?: DataPlaneAdmission }, + logIds?: { + requestId: string; start: number; turnAdmissionLease?: AdmissionLease; admission?: DataPlaneAdmission; + advisorAdmissionPolicy?: CapturedAdmissionPolicy; + }, ): Promise { const advisorInternal = isInternalCallCapability(req.headers.get(ADVISOR_INTERNAL_CAPABILITY_HEADER)); - const inheritedAdmissionScope = advisorInternal + const sidecarInternal = isInternalCallCapability(req.headers.get(SIDECAR_INTERNAL_CAPABILITY_HEADER)); + const callerPolicy = advisorInternal + ? readAdvisorAdmissionPolicy(req.headers.get(ADVISOR_ADMISSION_POLICY_HEADER), req.headers.get(ADVISOR_INTERNAL_CAPABILITY_HEADER)) + : readAdvisorAdmissionPolicy(req.headers.get(SIDECAR_ADMISSION_POLICY_HEADER), req.headers.get(SIDECAR_INTERNAL_CAPABILITY_HEADER)); + const decodedScope = advisorInternal ? decodeAdvisorAdmissionScope(req.headers.get(ADVISOR_ADMISSION_SCOPE_HEADER)) : undefined; - if (advisorInternal && req.headers.has(ADVISOR_ADMISSION_SCOPE_HEADER) && !inheritedAdmissionScope) { + // Only a validated internal hop gets its presented credential resolved; loopback admission for + // every other caller is untouched. An unknown credential fails closed (#7894). + const selfFetchPolicy = callerPolicy ? captureSelfFetchCredentialPolicy(req, config) : undefined; + if ((advisorInternal || sidecarInternal) && (!callerPolicy || selfFetchPolicy === null + || advisorInternal && req.headers.has(ADVISOR_ADMISSION_SCOPE_HEADER) && !decodedScope)) { // Refused requests still own a final log row like every other early rejection. if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 403, { closeReason: "non_stream" }); return chatCompletionsErrorResponse(403, "invalid inherited advisor admission scope", "permission_error", MODEL_NOT_ALLOWED_FOR_KEY); } + // The listener snapshot was captured at authentication; internal hops add the policy of the + // credential they selected (which loopback admission never read) and their frozen registration. + const listenerPolicy = logIds?.advisorAdmissionPolicy ?? captureAdmissionPolicy(config, logIds?.admission); + const hostPolicy = selfFetchPolicy ? selfFetchHostPolicy(listenerPolicy, selfFetchPolicy, logIds?.admission) : listenerPolicy; + const advisorAdmissionPolicy = callerPolicy ? intersectAdmissionPolicies(hostPolicy, callerPolicy) : hostPolicy; + const admission = callerPolicy ? inheritAdmissionPolicy(logIds?.admission, advisorAdmissionPolicy) : logIds?.admission; + const inheritedAdmissionScope = callerPolicy ? advisorAdmissionPolicy.modelScope : undefined; let chatBody: Rec; try { const rawBody = await readChatBody(req, translatorBudget, resolveInboundBodyLimitBytes(config.maxInboundBodyBytes)); @@ -525,7 +550,8 @@ async function handleChatCompletionsWithBudget( ...(logIds?.turnAdmissionLease ? { turnAdmissionLease: logIds.turnAdmissionLease } : {}), // #1686: the Chat surface translates its body and replays here, so the admission fact has // to ride along or a bearer-admitted Chat caller would still be refused by Direct. - ...(logIds?.admission ? { admission: logIds.admission } : {}), + ...(admission ? { admission } : {}), + advisorAdmissionPolicy, // The bridge request carries no caller credential, so resolve the replay principal here. replayPrincipal: { clientPrincipalId: resolveContextPrincipal(req, config, logIds?.admission) }, abortSignal: req.signal, @@ -545,7 +571,7 @@ async function handleChatCompletionsWithBudget( req, config, envelope, requestedModel, requestedStream: stream, translatorBudget, // Children run without logIds; the request's admission rides the source so their // replay scope resolves the same caller principal as every other surface. - ...(logIds?.admission ? { admission: logIds.admission } : {}), + ...(admission ? { admission } : {}), }), } : {}), // Terminal vision-describe marker (roadmap 180): the bridge rebuilds diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index c38cbbe2064..2c26e6e6e32 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -32,6 +32,8 @@ import { isKnownDesktop3pModelId, resolveDesktop3pAlias } from "../claude/deskto import { resolveAlias, claudeCodeNativeAlias, legacyAliasForNative } from "../claude/alias"; import { recordDesktopRequest } from "../claude/desktop-health"; import { stripOneMillionMarker } from "../claude/context-windows"; +import { captureAdmissionPolicy } from "./capture-admission-policy"; +import type { CapturedAdmissionPolicy } from "../types/admission-policy"; import { captureClaudeInbound } from "../claude/inbound-debug"; import { declaredAnthropicServerTools, replayedAnthropicServerBlocks } from "../claude/inbound-content-options"; import { claudeCodeForIngress } from "../claude/intercept/model-bindings"; @@ -543,7 +545,10 @@ export async function handleClaudeMessages( req: Request, config: OcxConfig, logCtx: RequestLogContext, - logIds?: { requestId: string; start: number; turnAdmissionLease?: AdmissionLease; admission?: DataPlaneAdmission }, + logIds?: { + requestId: string; start: number; turnAdmissionLease?: AdmissionLease; admission?: DataPlaneAdmission; + advisorAdmissionPolicy?: CapturedAdmissionPolicy; + }, requestPolicy: RequestPolicyView = config, ingress: ClaudeIngressOptions = {}, ): Promise { @@ -586,7 +591,10 @@ async function handleClaudeMessagesWithBudget( config: OcxConfig, logCtx: RequestLogContext, translatorBudget: TranslatorBudget, - logIds?: { requestId: string; start: number; turnAdmissionLease?: AdmissionLease; admission?: DataPlaneAdmission }, + logIds?: { + requestId: string; start: number; turnAdmissionLease?: AdmissionLease; admission?: DataPlaneAdmission; + advisorAdmissionPolicy?: CapturedAdmissionPolicy; + }, requestPolicy: RequestPolicyView = config, ingress: ClaudeIngressOptions = {}, ): Promise { @@ -618,6 +626,8 @@ async function handleClaudeMessagesWithBudget( // Built only under the reject policy; the legacy default leaves this request untouched. let envelope: ProtocolEnvelope | undefined; let messagesFeatures: () => Iterable = () => []; + // The Advisor caller policy is the admitted snapshot: capture it before the first await (#7859). + const advisorAdmissionPolicy = logIds?.advisorAdmissionPolicy ?? captureAdmissionPolicy(config, logIds?.admission); try { anthropicBody = await readAnthropicBody(req, translatorBudget, resolveInboundBodyLimitBytes(config.maxInboundBodyBytes)); // Defensive [1m] strip (devlog 138): clients normally remove the context-variant @@ -1122,6 +1132,7 @@ async function handleClaudeMessagesWithBudget( // Routing keeps Claude-only sidecar overrides; admission policy must follow the live owner. codexAuthPolicy: config, ...(logIds?.admission ? { admission: logIds.admission } : {}), + advisorAdmissionPolicy, // The translated request never forwards the caller credential; resolve the principal first. replayPrincipal: { clientPrincipalId: resolveContextPrincipal(req, config, logIds?.admission) }, ...(logIds?.turnAdmissionLease ? { turnAdmissionLease: logIds.turnAdmissionLease } : {}), diff --git a/src/server/index/serve-options.ts b/src/server/index/serve-options.ts index 38d70eb8f2b..34a9a920bb7 100644 --- a/src/server/index/serve-options.ts +++ b/src/server/index/serve-options.ts @@ -109,6 +109,7 @@ import { } from "../auth-cors"; import { managementSessionIssuance } from "../management-auth"; import { resolveAdmissionModelScope, routeAllowedByScope } from "../admission-model-scope"; +import { captureAdmissionPolicy } from "../capture-admission-policy"; import { disableResponsesRequestTimeout, handleResponses, @@ -1323,10 +1324,12 @@ export function createServeOptions(ctx: ServeOptionsContext) { ...admissionFields(admission), inboundProtocol: "responses", }; + const advisorAdmissionPolicy = captureAdmissionPolicy(config, admission); return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => { let response: Response; try { response = await handleResponsesCompact(req, config, logCtx, turnAdmissionLease, admission, { + advisorAdmissionPolicy, onRequestBodyRead: () => disableResponsesRequestTimeout(req, requestServer), }); } catch (error) { @@ -1482,12 +1485,15 @@ export function createServeOptions(ctx: ServeOptionsContext) { addFinalRequestLog(requestId, start, logCtx, status, meta); }; const sessionReq = withCallerSessionIdentity(withGrokSessionIdentity(req), admission); + // Capture at authentication: runAdmittedHttpTurn can wait for body capacity first (#7859). + const advisorAdmissionPolicy = captureAdmissionPolicy(config, admission); return runAdmittedHttpTurn(sessionReq, policy, async turnAdmissionLease => { let response: Response; try { response = await handleResponses(sessionReq, config, logCtx, { turnAdmissionLease, admission, + advisorAdmissionPolicy, onRequestBodyRead: () => disableResponsesRequestTimeout(req, requestServer), abortSignal: req.signal, onFirstOutput: () => recordFirstOutput(logCtx, start), @@ -1571,8 +1577,10 @@ export function createServeOptions(ctx: ServeOptionsContext) { let ownsRequestLog = false; const claimRequestLog = (work: (lease: ActiveTurnLease) => Promise) => (lease: ActiveTurnLease) => { ownsRequestLog = true; return work(lease); }; + // Capture at authentication: runAdmittedHttpTurn can wait for body capacity first (#7859). + const advisorAdmissionPolicy = captureAdmissionPolicy(config, admission); const response = await runAdmittedHttpTurn(sessionReq, policy, claimRequestLog(async turnAdmissionLease => withCors( - await handleClaudeMessages(sessionReq, config, logCtx, { requestId, start, turnAdmissionLease, admission }, policy, { claudeIntercept: ingress === "claude-intercept" }), + await handleClaudeMessages(sessionReq, config, logCtx, { requestId, start, turnAdmissionLease, admission, advisorAdmissionPolicy }, policy, { claudeIntercept: ingress === "claude-intercept" }), req, policy, )), { requestId, start, logCtx, onLogged: () => { ownsRequestLog = true; } }); @@ -1607,10 +1615,12 @@ export function createServeOptions(ctx: ServeOptionsContext) { const sessionReq = withCallerSessionIdentity(req, admission); // Only expose an id when the handler or a logged refusal owns its history row. let ownsRequestLog = false; + // Capture at authentication: runAdmittedHttpTurn can wait for body capacity first (#7859). + const advisorAdmissionPolicy = captureAdmissionPolicy(config, admission); const response = await runAdmittedHttpTurn(sessionReq, policy, async turnAdmissionLease => { ownsRequestLog = true; return withCors( - await handleChatCompletions(sessionReq, config, logCtx, { requestId, start, turnAdmissionLease, admission }), + await handleChatCompletions(sessionReq, config, logCtx, { requestId, start, turnAdmissionLease, admission, advisorAdmissionPolicy }), req, policy, ); diff --git a/src/server/index/websocket-handler.ts b/src/server/index/websocket-handler.ts index f37522f9bc1..632b3f6e038 100644 --- a/src/server/index/websocket-handler.ts +++ b/src/server/index/websocket-handler.ts @@ -3,6 +3,7 @@ import { NativeInjectionChannel } from "../responses/native-injection"; import { NativeSteeringChannel, NativeSteeringError } from "../responses/native-steering"; import { createNativeSteeringLogObserver } from "../responses/native-steering-log"; import { reasoningArchiveErrorResponse } from "../responses/reasoning-archive-error"; +import { captureAdmissionPolicy } from "../capture-admission-policy"; import type { Server, ServerWebSocket } from "bun"; import { LIVE_SIDEBAND_UPSTREAM_OPEN_TIMEOUT_MS, @@ -303,6 +304,7 @@ export function createWebsocketHandler( } // Only a genuinely admitted turn may receive steering or continuations. + const advisorAdmissionPolicy = captureAdmissionPolicy(config, ws.data.admission); const finishActivity = ws.data.responsesActivity?.beginTurn(frame.input); ws.data.nativeControl = nativeControl; const payload: Record = { ...frame }; @@ -343,6 +345,7 @@ export function createWebsocketHandler( try { let terminalRecorder: ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined; const response = await handleResponses(req, config, logCtx, { + advisorAdmissionPolicy, ...(wsAdmission ? { admission: wsAdmission } : {}), forceEmptyResponseId: true, inboundTransport: "websocket", diff --git a/src/server/messages-native-scope.ts b/src/server/messages-native-scope.ts index ba18004ef16..6f217621a8b 100644 --- a/src/server/messages-native-scope.ts +++ b/src/server/messages-native-scope.ts @@ -13,7 +13,12 @@ export function nativeMessagesToolScopeDenial( requestedModel: string, body: Readonly, ): AdmissionModelDeniedError | undefined { - if (!scope || scope.models.length === 0) return undefined; + if (!scope) return undefined; + for (const part of scope.allOf ?? []) { + const denial = nativeMessagesToolScopeDenial(part, providerName, requestedModel, body); + if (denial) return denial; + } + if (scope.models.length === 0) return undefined; const definitions = new Map(); const withdrawn = new Set(); const anonymous: Rec[] = []; diff --git a/src/server/responses/advisor-plan-slot.ts b/src/server/responses/advisor-plan-slot.ts index 501354d099b..55174d00c6e 100644 --- a/src/server/responses/advisor-plan-slot.ts +++ b/src/server/responses/advisor-plan-slot.ts @@ -9,6 +9,7 @@ export interface AdvisorPlanInput { workerModelId: string; /** Immutable scope captured from the request that is paying for this sidecar. */ admissionScope?: AdmissionModelScope; + admissionPolicy?: import("../../types/admission-policy").CapturedAdmissionPolicy; abortSignal?: AbortSignal; } diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index 41e60a18aad..7e665b0a9cd 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -1,5 +1,6 @@ import { captureAccountAutoPauseAttempt, wasAccountAutoPaused, wasPermanentAccountRefusal } from "../../oauth/account-auto-pause"; import { spendLedgerStorageErrorResponse } from "./spend-storage-error"; +import { captureAdmissionPolicy } from "../capture-admission-policy"; import { createPhysicalSendReporter, rebindPhysicalSend } from "../../lib/request-execution-budget"; import { hasResponseMediaType } from "../../lib/http-response-semantics"; import { createInferenceSendBudget } from "../inference/context"; @@ -319,6 +320,7 @@ function compactHandoffRoute( } export interface HandleResponsesCompactOptions { + advisorAdmissionPolicy?: import("../../types/admission-policy").CapturedAdmissionPolicy; compactionRoutingOverride?: CompactionRoutingOverride | null; nativeMainRefreshDependencies?: NativeMainRefreshDependencies; /** Release the listener's idle guard only after the complete request body is accepted. */ @@ -660,6 +662,7 @@ export async function handleResponsesCompact( admission?: DataPlaneAdmission, options: HandleResponsesCompactOptions = {}, ): Promise { + options = { ...options, advisorAdmissionPolicy: options.advisorAdmissionPolicy ?? captureAdmissionPolicy(config, admission) }; let body: unknown; try { body = await readJsonRequestBody(req, undefined, resolveInboundBodyLimitBytes(config.maxInboundBodyBytes)); @@ -1739,7 +1742,7 @@ export async function handleResponsesCompact( // fallback spend four more. // internalReq keeps only FORWARD_HEADERS, so the caller principal is resolved from req. const replayPrincipal = { clientPrincipalId: resolveContextPrincipal(req, config, admission) }; - const response = await handleResponses(internalReq, config, logCtx, { abortSignal: req.signal, turnAdmissionLease, sendBudget, compactionRecoveryKind: "compaction-v1", compactionRoutingOverride: options.compactionRoutingOverride, replayPrincipal, ...(admission ? { admission } : {}) }); + const response = await handleResponses(internalReq, config, logCtx, { abortSignal: req.signal, turnAdmissionLease, sendBudget, compactionRecoveryKind: "compaction-v1", compactionRoutingOverride: options.compactionRoutingOverride, advisorAdmissionPolicy: options.advisorAdmissionPolicy, replayPrincipal, ...(admission ? { admission } : {}) }); if (req.signal.aborted) { void response.body?.cancel(req.signal.reason).catch(() => undefined); return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request"); diff --git a/src/server/responses/core-options.ts b/src/server/responses/core-options.ts index 37bf1bd03dc..36473e8ed7b 100644 --- a/src/server/responses/core-options.ts +++ b/src/server/responses/core-options.ts @@ -98,6 +98,8 @@ export interface DirectRequestOption { export interface HandleResponsesOptions { /** Immutable caller scope inherited by a capability-authenticated internal sidecar request. */ admissionScope?: AdmissionModelScope; + /** Captured synchronously at ingress, before parsing or child replay can suspend. */ + advisorAdmissionPolicy?: import("../../types/admission-policy").CapturedAdmissionPolicy; /** Internal request-owned policy authorization; never read from client headers or body. */ policyRequestScope?: PolicyRequestScope; /** Internal concrete selector chosen from the original policy evaluation. */ diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index e3e77fd6cb1..bc5cda6d911 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -14,6 +14,7 @@ import type { TranslatorBudget } from "../../lib/translator-budget"; import { executeComboResponses } from "./core-combo"; import { registerTurn, unregisterTurn } from "../lifecycle"; import { runWithCompactionRecovery } from "./compaction-recovery"; +import { captureAdmissionPolicy } from "../capture-admission-policy"; /** * Route one `/v1/responses` request through the adapter pipeline: recovery loop, passthrough * wire, image/web-search bridges, and the terminal-guard continuation. @@ -35,6 +36,7 @@ export async function handleResponses( try { const response = await runWithCompactionRecovery(req, config, logCtx, { ...options, abortSignal, + advisorAdmissionPolicy: options.advisorAdmissionPolicy ?? captureAdmissionPolicy(config, options.admission, options.admissionScope), openAiSidecarAuth: options.openAiSidecarAuth === undefined ? captureExplicitOpenAiCallerAuth(req.headers, config) : options.openAiSidecarAuth, nativeCallerAuth: options.nativeCallerAuth === undefined diff --git a/src/server/responses/jev-model-invoke.ts b/src/server/responses/jev-model-invoke.ts index 68b670357c0..ba90114ca70 100644 --- a/src/server/responses/jev-model-invoke.ts +++ b/src/server/responses/jev-model-invoke.ts @@ -24,8 +24,8 @@ export interface JevModelInvokerContext { /** The request that owns the combo; only its spend root is reused, never its headers. */ req: Pick; config: OcxConfig; - /** Parent options: only the resolved admission scope and Codex auth policy cross over. */ - options: Pick; + /** Parent options: only the resolved admission scope, Advisor caller policy and Codex auth policy cross over. */ + options: Pick; handleResponses: HandleResponses; } @@ -154,6 +154,8 @@ export function createJevModelInvoker(context: JevModelInvokerContext): JevModel // capability-authenticated internal call carries the caller's scope while its own // admission is the host's broad key. ...(context.options.admissionScope ? { admissionScope: context.options.admissionScope } : {}), + // The parent's ingress snapshot; without it the detached turn would re-read mutable config (#7859). + ...(context.options.advisorAdmissionPolicy ? { advisorAdmissionPolicy: context.options.advisorAdmissionPolicy } : {}), ...(context.options.codexAuthPolicy ? { codexAuthPolicy: context.options.codexAuthPolicy } : {}), // Explicit nulls: handleResponses captures caller credentials only when these are undefined. callerDirectAuth: null, diff --git a/src/server/responses/policy-fallback.ts b/src/server/responses/policy-fallback.ts index 49e497814e3..e4c997f4422 100644 --- a/src/server/responses/policy-fallback.ts +++ b/src/server/responses/policy-fallback.ts @@ -15,6 +15,7 @@ import { parseSyntheticRowId } from "../fast-row"; import { routeConcreteModel } from "../../router"; import { isPolicyCandidateRefusal, policyWireDestination, type PolicyRequestScope } from "./policy-request-scope"; import { fallbackTriggerForStatus, guardFallbackHop } from "../../routing/fallback-policy"; +import { captureAdmissionPolicy } from "../capture-admission-policy"; type CoreHandler = typeof handleResponsesCore; type CoreOptions = Parameters[3]; @@ -182,6 +183,7 @@ export async function handleResponsesWithPolicyFallback( let rawBody: Record | null = null; const coreOptions: CoreOptions = { ...options, + advisorAdmissionPolicy: options.advisorAdmissionPolicy ?? captureAdmissionPolicy(config, options.admission, options.admissionScope), policyRequestScope: policyScope, openAiSidecarAuth: options.openAiSidecarAuth === undefined ? captureExplicitOpenAiCallerAuth(req.headers, config) : options.openAiSidecarAuth, diff --git a/src/server/responses/request-prepare.ts b/src/server/responses/request-prepare.ts index 637c3a74262..8a7c561933a 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -665,6 +665,7 @@ export async function prepareResponsesRequest( return formatErrorResponse(401, "authentication_error", "Anthropic Pool 2 requires an enabled configured provider"); } const admissionScope = options.admissionScope ?? resolveAdmissionModelScope(config, options.admission); + const advisorAdmissionPolicy = options.advisorAdmissionPolicy; const captureInboundRoutePolicy = (candidate: RouteResult, captureRequestPolicy = true): RouteResult => { // Router-collapsed aliases bypass the combo loop. Re-pick before capturing destination // policy so random/RR cannot refuse while an unmetered target is still usable. @@ -1629,6 +1630,7 @@ export async function prepareResponsesRequest( // the key scope as it stood when this request was admitted. inboundSelector, admissionScope, + advisorAdmissionPolicy, translatorBudget, parsed, toolBridgeMaps, diff --git a/src/server/responses/request-sidecar-auth.ts b/src/server/responses/request-sidecar-auth.ts index d345f01db16..0469007c41c 100644 --- a/src/server/responses/request-sidecar-auth.ts +++ b/src/server/responses/request-sidecar-auth.ts @@ -131,6 +131,7 @@ export async function prepareResponsesSidecarAuth( ? undefined : planVisionSidecar(config, route.provider, route.modelId, parsed, openAiSidecar, { admission: options.admission, codexAuthPolicy: options.codexAuthPolicy, providerName: route.providerName, + advisorAdmissionPolicy: options.advisorAdmissionPolicy, }); const recordSidecarOutcome = openAiSidecar?.recordOutcome; if (visionPlan) { diff --git a/src/server/responses/sidecar-execution.ts b/src/server/responses/sidecar-execution.ts index b82ff4a3e0e..cc1990bd336 100644 --- a/src/server/responses/sidecar-execution.ts +++ b/src/server/responses/sidecar-execution.ts @@ -75,6 +75,7 @@ export async function executeResponsesSidecars( | "rememberKiroDeliveredFinalAnswer" | "responseStateOptions" | "admissionScope" + | "advisorAdmissionPolicy" >, transportState: Pick< ResponsesTransport, @@ -195,6 +196,7 @@ export async function executeResponsesSidecars( workerIdentity: `${route.modelId} (provider ${route.providerName})`, workerModelId: route.modelId, admissionScope, + admissionPolicy: requestState.advisorAdmissionPolicy, abortSignal: options.abortSignal, }); // Preflight only on real worker turns: a routed-compaction turn is a summarization request, diff --git a/src/types/admission-policy.ts b/src/types/admission-policy.ts new file mode 100644 index 00000000000..4a988641a2d --- /dev/null +++ b/src/types/admission-policy.ts @@ -0,0 +1,13 @@ +import type { AdmissionModelScope } from "../server/admission-model-scope"; +import type { OcxFallbackConfig } from "./fallback"; + +/** Credential-free, immutable decision inputs captured for an internal child. */ +export interface CapturedAdmissionPolicy { + readonly modelScope: AdmissionModelScope; + readonly allowMeteredComboTargets: boolean; + readonly meteredRequiresComboOptIn: boolean; + readonly fallbackPolicies: readonly { + readonly config: { readonly fallback?: OcxFallbackConfig }; + readonly deviceIds: readonly string[]; + }[]; +} diff --git a/src/vision/index.ts b/src/vision/index.ts index 33a0efd58da..aa0aacc640a 100644 --- a/src/vision/index.ts +++ b/src/vision/index.ts @@ -246,6 +246,8 @@ async function executeDescription( plan.routedConfig, plan.settings, abortSignal, + undefined, + plan.advisorAdmissionPolicy, ); } if (plan.backend === "anthropic") { diff --git a/src/vision/plan.ts b/src/vision/plan.ts index d684a1400e4..56afea03c8b 100644 --- a/src/vision/plan.ts +++ b/src/vision/plan.ts @@ -5,6 +5,7 @@ import type { ResolvedOpenAiForwardSidecar } from "../providers/openai-sidecar"; import type { CodexAuthPolicyConfig } from "../codex/auth-context"; import { isCodexReserveRequestEligible } from "../codex/loopback-target"; import type { DataPlaneAdmission } from "../server/auth-cors"; +import type { CapturedAdmissionPolicy } from "../types/admission-policy"; import { customRowInputModalities, isModelVisionSidecarConsumer as isModelTextOnly, @@ -192,6 +193,7 @@ export interface VisionPlan { routedModel?: string; /** Loopback dispatch inputs for the routed backend (the listener decides WHICH local port). */ routedConfig?: Pick; + advisorAdmissionPolicy?: CapturedAdmissionPolicy; settings: VisionSettings; maxDescriptionsPerTurn: number; } @@ -214,6 +216,7 @@ export function planVisionSidecar( admission?: Pick; codexAuthPolicy?: CodexAuthPolicyConfig; providerName?: string; + advisorAdmissionPolicy?: CapturedAdmissionPolicy; } = {}, ): VisionPlan | undefined { if (!requiresVisionPreprocessing(config, provider, modelId, options.providerName)) return undefined; @@ -231,6 +234,7 @@ export function planVisionSidecar( return { backend: "routed", routedModel, + advisorAdmissionPolicy: options.advisorAdmissionPolicy, routedConfig: { port: config.port, ...(config.apiKeys ? { apiKeys: config.apiKeys } : {}), diff --git a/src/vision/routed-describe.ts b/src/vision/routed-describe.ts index 4b1cf4a5e79..a0f95743bd8 100644 --- a/src/vision/routed-describe.ts +++ b/src/vision/routed-describe.ts @@ -27,6 +27,9 @@ import type { OcxConfig } from "../types"; import { localAdmissionToken } from "../lib/local-destinations"; import { localChatCompletionBaseUrl, postLocalChatCompletion } from "../lib/local-chat-completion"; import type { DescribeOutcome, VisionSettings } from "./describe"; +import type { CapturedAdmissionPolicy } from "../types/admission-policy"; +import { registerAdvisorAdmissionPolicy, SIDECAR_ADMISSION_POLICY_HEADER, SIDECAR_INTERNAL_CAPABILITY_HEADER } from "../server/advisor-admission-scope"; +import { internalCallCapability } from "../lib/local-internal-call-capability"; export const VISION_DESCRIBE_TERMINAL_HEADER = "x-opencodex-vision-describe"; @@ -84,31 +87,43 @@ export async function describeImageRouted( settings: VisionSettings, abortSignal?: AbortSignal, baseUrlOverride?: string, + advisorAdmissionPolicy?: CapturedAdmissionPolicy, ): Promise { const invalid = validateImageUrl(imageUrl); if (invalid) return { text: "", error: invalid }; - return postLocalChatCompletion({ - config, - label: "routed describe", - logTag: "vision", - timeoutMs: settings.timeoutMs, - maxResponseBytes: MAX_ROUTED_RESPONSE_BYTES, - headers: { [VISION_DESCRIBE_TERMINAL_HEADER]: "1" }, - abortSignal, - baseUrlOverride, - body: { - model: routedModel, - messages: [ - { role: "system", content: DESCRIBE_INSTRUCTION }, - { - role: "user", - content: [ - ...(contextText ? [{ type: "text", text: `User's request context: ${contextText}` }] : []), - { type: "image_url", image_url: { url: imageUrl } }, - ], - }, - ], - }, - }); + const registration = advisorAdmissionPolicy ? registerAdvisorAdmissionPolicy(advisorAdmissionPolicy, abortSignal) : undefined; + try { + return await postLocalChatCompletion({ + config, + label: "routed describe", + logTag: "vision", + timeoutMs: settings.timeoutMs, + maxResponseBytes: MAX_ROUTED_RESPONSE_BYTES, + headers: { + [VISION_DESCRIBE_TERMINAL_HEADER]: "1", + ...(registration ? { + [SIDECAR_ADMISSION_POLICY_HEADER]: registration.token, + [SIDECAR_INTERNAL_CAPABILITY_HEADER]: internalCallCapability(), + } : {}), + }, + abortSignal, + baseUrlOverride, + body: { + model: routedModel, + messages: [ + { role: "system", content: DESCRIBE_INSTRUCTION }, + { + role: "user", + content: [ + ...(contextText ? [{ type: "text", text: `User's request context: ${contextText}` }] : []), + { type: "image_url", image_url: { url: imageUrl } }, + ], + }, + ], + }, + }); + } finally { + registration?.release(); + } } diff --git a/structure/INDEX.md b/structure/INDEX.md index 9da7ad067cf..8d715846755 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -367,6 +367,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/server/` | [`transports/timeouts.md`](transports/timeouts.md)
[`providers/generic-account-pool.md`](providers/generic-account-pool.md)
[`runtime.md`](runtime.md)
[`codex-prompts.md`](codex-prompts.md)
[`catalog.md`](catalog.md)
[`subagents.md`](subagents.md)
[`transports/byte-accounting.md`](transports/byte-accounting.md)
[`transports/responses.md`](transports/responses.md)
[`transports/native-reasoning-retention.md`](transports/native-reasoning-retention.md)
[`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md)
[`transports/responses-failover.md`](transports/responses-failover.md)
[`transports/responses-combo-boundaries.md`](transports/responses-combo-boundaries.md)
[`transports/policy-fallback.md`](transports/policy-fallback.md)
[`transports/streaming-health.md`](transports/streaming-health.md)
[`transports/inventory.md`](transports/inventory.md)
[`data-planes/images.md`](data-planes/images.md)
[`data-planes/inbound-compat.md`](data-planes/inbound-compat.md)
[`providers/anthropic-progress.md`](providers/anthropic-progress.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/jev-decision.md`](providers/jev-decision.md)
[`providers/xai-grok.md`](providers/xai-grok.md)
[`adapters/registry.md`](adapters/registry.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`provider-completion-diagnostic.md`](provider-completion-diagnostic.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`fallback-policy.md`](fallback-policy.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`clients/claude-desktop-model-visibility.md`](clients/claude-desktop-model-visibility.md)
[`clients/hermes.md`](clients/hermes.md)
[`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | | `src/server/advisor-admission-scope.ts` | [`advisor.md`](advisor.md) | | `src/server/auth-cors.ts` | [`transports/response-turn-membership.md`](transports/response-turn-membership.md)
[`providers/lm-studio-chat.md`](providers/lm-studio-chat.md) | +| `src/server/capture-admission-policy.ts` | [`advisor.md`](advisor.md) | | `src/server/gui-pair-delivery.ts` | [`remote-link.md`](remote-link.md) | | `src/server/index.ts` | [`adapters/compatibility-lab.md`](adapters/compatibility-lab.md) | | `src/server/index/link-management.ts` | [`remote-link.md`](remote-link.md) | @@ -420,6 +421,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/types.ts` | [`runtime.md`](runtime.md)
[`config.md`](config.md) | | `src/types/` | [`providers/generic-account-pool.md`](providers/generic-account-pool.md)
[`transports/native-reasoning-retention.md`](transports/native-reasoning-retention.md)
[`providers/anthropic-progress.md`](providers/anthropic-progress.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/lm-studio-chat.md`](providers/lm-studio-chat.md)
[`fallback-policy.md`](fallback-policy.md) | | `src/types/account-auto-pause.ts` | [`account-auto-pause.md`](account-auto-pause.md) | +| `src/types/admission-policy.ts` | [`advisor.md`](advisor.md) | | `src/types/config.ts` | [`transports/timeouts.md`](transports/timeouts.md)
[`config.md`](config.md)
[`transports/max-output-completion.md`](transports/max-output-completion.md) | | `src/types/provider.ts` | [`pricing.md`](pricing.md) | | `src/update/` | [`runtime.md`](runtime.md)
[`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | diff --git a/structure/advisor.md b/structure/advisor.md index f6acffb5e40..993c99328d3 100644 --- a/structure/advisor.md +++ b/structure/advisor.md @@ -93,11 +93,50 @@ The loopback call re-enters the normal data plane, so model resolution, provider mapping, and usage accounting are the routing authority's job. Any model string the router accepts works as the advisor: a bare native model, an explicit `provider/model`, or an account-qualified native model. The advisor never builds its own router and never touches -provider credentials. `src/server/advisor-admission-scope.ts` carries the originating request's -immutable provider/model admission scope over the loopback hop as capability-authenticated -metadata and remains authoritative for every final -physical Advisor destination, including alias, policy, combo, and retry resolution; the local -transport credential cannot widen it. +provider credentials. `src/server/capture-admission-policy.ts` freezes a credential-free copy of +the caller's model scope, metered consent class, fallback policy and resolved device ids at HTTP +authentication, before any inbound-body capacity wait or body parsing; `src/server/index/serve-options.ts` +hands that snapshot to the Responses, Chat, Messages and compact handlers. WebSocket turns capture +before dispatch; policy-profile retries retain the outer owner's snapshot. Compact retains it through +native retries and routed Responses/JEV handoffs. `src/types/admission-policy.ts` defines these decision inputs. +`src/server/advisor-admission-scope.ts` holds them in a process-local registry for one consultation. +Its random 256-bit handle is honored only beside the current process-owned capability; unknown +or missing handles are refused. Consultation completion or failure releases the entry; abort +revokes it immediately, even while transport work is still settling. Registration owns a frozen copy. +The bounded scope envelope remains validated separately. Neither handle nor capability is sent upstream. + +Routed vision carries the same frozen policy through its plan and a scoped registry entry. Its +separate sidecar capability header authenticates inheritance without suppressing Advisor on the helper. +Chat intersects that registration with the host snapshot captured at authentication; missing or +expired registrations presented with a valid capability are refused. Completion, failure and abort +release the entry. Ordinary client headers cannot grant inherited policy authority. +A loopback listener (a loopback bind or the secondary loopback listener) admits the self-fetch before +reading any token, so that snapshot carries no key limits. After the capability and registration +are validated, Chat therefore also resolves the `x-opencodex-api-key` the self-fetch presents and +intersects that key's scope, paid-combo consent and fallback device identity; a presented credential +the data plane does not recognize is refused. Other loopback callers keep plain loopback admission. +`tests/advisor/advisor-caller-policy-host-key.test.ts` drives both loopback ingress shapes. +Senders pick that credential with `selfFetchAdmissionToken` (`src/lib/local-destinations.ts`): the +unchanged ladder where the destination requires admission, otherwise only the first ladder credential +this data plane recognizes, so a hub client key a loopback bind left out of its admission credentials +is not sent. When a loopback listener admitted the hop, `selfFetchHostPolicy` takes paid-combo +consent from the validated key instead of the loopback default a public bind always refuses; the +combo's own opt-in and the caller's consent still bind, and the hop keeps loopback transport identity. +`tests/advisor/advisor-caller-policy-self-fetch.test.ts` covers both on the real listener. + +The host keeps its admission identity and adds the captured caller constraints. Shared scope, +metered-consent and fallback guards require the intersection of caller and host policy for every +resolved destination, combo pick (including router-collapsed picks), proactive/reactive fallback +and child invocation. Nested JEV decision turns retain that same admission. Scope intersection +checks each original scope, preserving bare and provider-qualified model semantics. Later edits +or removal of the caller's key or fallback settings cannot broaden the frozen decision inputs. +`tests/advisor/advisor-caller-policy.test.ts` exercises these constraints through real handlers. +`tests/advisor/advisor-caller-policy-routing.test.ts` covers laptop provider-fallback refusals, +native combo dispatch, early capture, external forgeries and worker-unavailable delivery. +`advisor-caller-policy-ingress.test.ts` and `advisor-caller-policy-http.test.ts` widen live config during body reads and capacity waits. +`advisor-caller-policy-vision.test.ts`, `advisor-caller-policy-replay.test.ts` and +`advisor-caller-policy-compact.test.ts` cover routed helpers, policy candidates and compact JEV turns; +`advisor-caller-policy-sidecar-authority.test.ts` covers sidecar capability admission and registration lifetime. ## Context and safety boundaries diff --git a/structure/data-planes/protocol-paths.md b/structure/data-planes/protocol-paths.md index 68ae998da50..44439672f09 100644 --- a/structure/data-planes/protocol-paths.md +++ b/structure/data-planes/protocol-paths.md @@ -392,7 +392,7 @@ is `envelope.freshBody()` when a source envelope exists, otherwise the ingress's `src/server/messages-native.ts` is imported lazily, only for an eligible route. Native Messages errors reuse the shared OpenCode Zen guidance before resolving client retry headers. A Zen rate-limit 429, identified by message or explicit `rate_limit_error` type, without an upstream retry hint receives the same 15-second hint as the translated path; quota/billing and spent-usage-limit errors remain unchanged and valid upstream retry headers retain precedence. Both native Messages and `src/server/responses/adapter-dispatch.ts` forward parsed error types and codes to the shared guidance. Configured admission-key model scope also applies to separately billed Advisor models before -managed native key or OAuth dispatch. `src/server/messages-native-scope.ts` resolves active +managed native key or OAuth dispatch. Scope intersections retain each member's tool-model restrictions. `src/server/messages-native-scope.ts` resolves active top-level and system-message tool declarations, including replacement, removal and re-offer; deferred definitions remain scoped because tool search can load them. Missing Advisor model identity cannot satisfy a model list. Anthropic rejects tool changes in a turn-scoped system diff --git a/structure/fallback-policy.md b/structure/fallback-policy.md index f8465a63fa3..929837a9612 100644 --- a/structure/fallback-policy.md +++ b/structure/fallback-policy.md @@ -40,6 +40,11 @@ Device identity comes from admission only: a `configured` admission's key id, pl id whose `apiKeyId` matches it in the link store. The store is read only when `devices` is non-empty and is cached for five seconds. Loopback and unkeyed requests have no device scope. +Advisor children retain the host admission and additionally enforce frozen caller policy and +device ids at every `guardFallbackHop` boundary. Both must permit the hop; caller refusal uses +the caller's captured device identity. The [Advisor policy contract](advisor.md#cross-provider-consultation) +owns capture, capability authentication and child propagation. + ## Sites | Site | Hop point | Reactive status | Proactive | diff --git a/structure/manifest.json b/structure/manifest.json index 3655093e224..e1858e00a40 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -302,6 +302,8 @@ "src/server/responses/advisor-slot.ts", "src/server/responses/advisor-plan-slot.ts", "src/server/advisor-admission-scope.ts", + "src/server/capture-admission-policy.ts", + "src/types/admission-policy.ts", "src/server/inference/advisor-diagnostics.ts", "src/lib/advisor-activation.ts" ] diff --git a/structure/runtime.md b/structure/runtime.md index 92902110e31..e40e2fc0ed0 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -479,7 +479,7 @@ Canonical ChatGPT Codex forwarding uses the generated `openai-codex` capability An explicitly configured routed `visionSidecar.model` is dispatchable unless capability evidence positively proves it cannot accept images; an unknown custom sidecar is not guessed blind. If a proven text-only main target has no usable sidecar plan, image parts are stripped before the upstream request rather than forwarded raw. `modelInputModalities` is symmetric evidence: `["text","image"]` proves image support while `["text"]` triggers preprocessing. Provider-wide fallback declarations follow the [validated modality contract](config.md#explicit-per-model-capability-declarations); malformed saved values restrict to text before runtime enrichment. Runtime provider hooks such as injected `fetch` functions are preserved without mutation during capability enrichment. -Regression coverage: `tests/vision/vision-cache.test.ts`, `tests/vision/vision-eligibility.test.ts`, `tests/vision/vision-routed.test.ts`, and `tests/adapters/openai/openai-chat-native-policy.test.ts`. +Routed vision helpers retain frozen caller policy and intersect host limits under the [Advisor admission contract](advisor.md#cross-provider-consultation). Regression coverage: `tests/vision/vision-cache.test.ts`, `tests/vision/vision-eligibility.test.ts`, `tests/vision/vision-routed.test.ts`, and `tests/adapters/openai/openai-chat-native-policy.test.ts`. Provider-scoped approval reviewer settings are projected by the [catalog owner](catalog.md#provider-scoped-approval-reviewer); this surface retains its existing routing, transport and account-selection behavior. diff --git a/structure/transports/policy-fallback.md b/structure/transports/policy-fallback.md index 12bb2adffdb..57fdc2e5f32 100644 --- a/structure/transports/policy-fallback.md +++ b/structure/transports/policy-fallback.md @@ -9,6 +9,8 @@ accepted, so a declined policy target never scopes the request's own route. `src/server/responses/policy-fallback.ts` inspects and executes each retry as a concrete candidate, so a public combo or profile alias cannot reselect its destination. Redirects remain inside the original evaluation, including eligible destinations omitted from the bounded diagnostic trace. +The outer retry owner captures caller admission policy before its first attempt when ingress has not +already supplied it. Every candidate receives that same immutable Advisor policy snapshot. When a main-account hard-lock fallback selects a profile, `src/server/responses/request-prepare.ts` checks its first ranked destination against the original model under the diff --git a/structure/transports/responses-failover.md b/structure/transports/responses-failover.md index daf950da183..f33532918c5 100644 --- a/structure/transports/responses-failover.md +++ b/structure/transports/responses-failover.md @@ -262,7 +262,7 @@ including when the destination is a combo, so request history names the phase th ## Compaction routing overrides -For routed v2 summaries served by the resolved Anthropic Messages adapter, `src/server/responses/response-effects.ts` +Compact HTTP captures caller policy before capacity/body waits; native retries and routed Responses/JEV handoffs retain it under the [Advisor admission contract](../advisor.md#cross-provider-consultation). For routed v2 summaries served by the resolved Anthropic Messages adapter, `src/server/responses/response-effects.ts` preserves client tool definitions, system text, `toolChoice` and `parallelToolCalls`. Other adapters retain tool removal. Search/media sidecar execution stays disabled; Anthropic server-tool declarations and replay remain, with wire `tool_choice: none` when server tools exist, preventing upstream execution. diff --git a/tests/advisor/advisor-caller-policy-compact.test.ts b/tests/advisor/advisor-caller-policy-compact.test.ts new file mode 100644 index 00000000000..29fcce90026 --- /dev/null +++ b/tests/advisor/advisor-caller-policy-compact.test.ts @@ -0,0 +1,83 @@ +import { expect, test } from "bun:test"; +import { handleResponsesCompact } from "../../src/server/responses/compact"; +import { handleChatCompletions } from "../../src/server/chat-completions"; +import { activateAdvisor } from "../../src/lib/advisor-activation"; +import { advisorBaseUrl } from "../../src/advisor/consult"; +import { resolveDataPlaneAdmissionSecret } from "../../src/server/auth-cors"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { clearComboSelectionState, clearComboTargetCooldowns } from "../../src/combos"; +import { setAdvisorPlanFactory } from "../../src/server/responses/advisor-plan-slot"; +import { createAdvisorRuntimePlan } from "../../src/advisor/runtime"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; + +// #7859: a JEV decision turn runs detached from its parent. Its synthetic Advisor consultation must keep +// the caller policy admitted at ingress even when the live config widens while the parent is suspended. + +for (const kind of ["metered", "fallback"] as const) for (const widen of [false, true]) test(`detached JEV Advisor preserves ingress ${kind} refusal (widen=${widen})`, async () => { + const release = acquireOwnedSpendHome(); + const oldFetch = globalThis.fetch; + clearComboSelectionState(); clearComboTargetCooldowns(); + const calls: string[] = []; + const policies: { paid?: boolean; fallback?: boolean }[] = []; + let decisionSends = 0; + const sse = (frames: unknown[]) => new Response(frames.map(frame => `data: ${JSON.stringify(frame)}\n\n`).join("") + "data: [DONE]\n\n", { headers: { "content-type": "text/event-stream" } }); + const provider = (name: string): OcxProviderConfig => ({ + adapter: "openai-chat", apiKey: "fixture", models: ["m"], liveModels: false, + baseUrl: `https://${name}.test/v1`, fetch: (async (_url, init) => { + calls.push(`${name}/m`); + const body = JSON.parse(String(init?.body)); + if (kind === "fallback" && name === "paid") return Response.json({ error: { code: "model_not_found", message: "fixture missing model" } }, { status: 404 }); + if (name === "decision" && decisionSends++ === 0) { + expect(body.tools.some((tool: any) => tool.function?.name === "advisor")).toBe(true); + return sse([ + { choices: [{ delta: { role: "assistant", tool_calls: [{ index: 0, id: "ask1", type: "function", function: { name: "advisor", arguments: '{"question":"routing advice?"}' } }] }, finish_reason: null }] }, + { choices: [{ delta: {}, finish_reason: "tool_calls" }] }, + ]); + } + const content = name === "decision" ? '{"choice":"worker/m:none"}' : "fixture answer"; + return body.stream ? sse([ + { choices: [{ delta: { content }, finish_reason: null }] }, + { choices: [{ delta: {}, finish_reason: "stop" }] }, + ]) : Response.json({ choices: [{ message: { content }, finish_reason: "stop" }] }); + }) as typeof fetch, + }); + const config: OcxConfig = { + port: 10100, defaultProvider: "worker", + providers: { worker: provider("worker"), other: provider("other"), decision: provider("decision"), paid: provider("paid"), backup: provider("backup") }, + apiKeys: [ + { id: "host", name: "host", key: "ocx_data_" + "h".repeat(40), createdAt: "2026-01-01", allowMeteredComboTargets: true }, + { id: "laptop", name: "laptop", key: "ocx_data_" + "c".repeat(40), createdAt: "2026-01-01", allowMeteredComboTargets: false }, + ], + fallback: { devices: { laptop: { provider: false } } }, + combos: { + outer: { strategy: "jev", decisionModel: "decision/m", targets: [{ provider: "worker", model: "m" }, { provider: "other", model: "m" }] }, + inner: { targets: kind === "metered" ? [{ provider: "paid", model: "m", metered: true }] : [{ provider: "paid", model: "m" }, { provider: "backup", model: "m" }] }, + }, + advisor: { enabled: true, model: "combo/inner", policy: "manual", effort: "high", contextSharingConsent: "v1" }, + }; + activateAdvisor(config); + setAdvisorPlanFactory(config, input => { + policies.push({ paid: input.admissionPolicy?.allowMeteredComboTargets, fallback: input.admissionPolicy?.fallbackPolicies[0]?.config.fallback?.devices?.laptop?.provider }); + return createAdvisorRuntimePlan(input); + }); + globalThis.fetch = (async (input, init) => { + expect(String(input)).toBe(`${advisorBaseUrl(config)}/v1/chat/completions`); + const headers = new Headers(init?.headers); + const admission = resolveDataPlaneAdmissionSecret(headers.get("x-opencodex-api-key")!, config, "x-api-key")!; + return handleChatCompletions(new Request(String(input), init), config, { model: "", provider: "" }, { requestId: "r3-jev", start: Date.now(), admission }); + }) as typeof fetch; + try { + const pending = handleResponsesCompact(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "combo/outer", input: [{ role: "user", content: "fixture routing task" }], stream: false }), + }), config, { model: "", provider: "" }, undefined, { kind: "configured", keyId: "laptop", source: "bearer" }); + if (widen) { config.apiKeys![1]!.allowMeteredComboTargets = true; config.fallback = {}; } + const response = await pending; + await response.text(); + expect(response.status).toBe(200); + expect(decisionSends).toBe(2); + expect(calls).not.toContain(kind === "metered" ? "paid/m" : "backup/m"); + expect(policies.length).toBeGreaterThan(0); + for (const policy of policies) expect(policy).toEqual({ paid: false, fallback: false }); + } finally { globalThis.fetch = oldFetch; release(); clearComboSelectionState(); clearComboTargetCooldowns(); } +}); diff --git a/tests/advisor/advisor-caller-policy-host-key.test.ts b/tests/advisor/advisor-caller-policy-host-key.test.ts new file mode 100644 index 00000000000..78013b93fec --- /dev/null +++ b/tests/advisor/advisor-caller-policy-host-key.test.ts @@ -0,0 +1,212 @@ +/** + * #7894: Advisor's self-fetch credential carries host policy of its own. + * + * A loopback listener admits every request before reading a token, so the host snapshot taken at + * authentication says "loopback" even when the consult presents a configured key. That key's model + * scope, paid-combo consent and device fallback identity must still bind the consultation; + * otherwise a caller allowed onto a model gets an Advisor consult the host key forbids. Each case + * drives the real listener (createServeOptions) on both loopback ingress shapes. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { activateAdvisor } from "../../src/lib/advisor-activation"; +import { createServeOptions, type ServeOptionsContext } from "../../src/server/index/serve-options"; +import { requestPolicyView } from "../../src/server/auth-cors"; +import { tryAdmitTurn } from "../../src/server/lifecycle"; +import { advisorBaseUrl } from "../../src/advisor/consult"; +import { handleChatCompletions } from "../../src/server/chat-completions"; +import { registerAdvisorAdmissionPolicy, ADVISOR_ADMISSION_POLICY_HEADER } from "../../src/server/advisor-admission-scope"; +import { captureAdmissionPolicy, captureSelfFetchCredentialPolicy, intersectAdmissionPolicies } from "../../src/server/capture-admission-policy"; +import { ADVISOR_INTERNAL_CAPABILITY_HEADER, internalCallCapability } from "../../src/lib/local-internal-call-capability"; +import { MODEL_NOT_ALLOWED_FOR_KEY } from "../../src/server/admission-model-scope"; +import { collectSse } from "../helpers/responses-conformance"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import type { OcxConfig } from "../../src/types"; + +const originalFetch = globalThis.fetch; +const savedEnv = { token: process.env.OPENCODEX_API_AUTH_TOKEN, file: process.env.OCX_API_TOKEN_FILE }; +let releaseSpendHome: (() => void) | undefined; +beforeEach(() => { + // Make localAdmissionToken select the first configured key: no env token, no service file. + delete process.env.OPENCODEX_API_AUTH_TOKEN; + process.env.OCX_API_TOKEN_FILE = "missing-service-api-token-7894"; +}); +afterEach(() => { + try { releaseSpendHome?.(); } finally { + releaseSpendHome = undefined; + globalThis.fetch = originalFetch; + if (savedEnv.token === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN; else process.env.OPENCODEX_API_AUTH_TOKEN = savedEnv.token; + if (savedEnv.file === undefined) delete process.env.OCX_API_TOKEN_FILE; else process.env.OCX_API_TOKEN_FILE = savedEnv.file; + } +}); + +const HOST_KEY = "ocx_data_" + "h".repeat(40); +const CALLER_KEY = "ocx_data_" + "c".repeat(40); +const ADVICE = "Sequence the fix: token store first."; +const advisorCall = [ + { choices: [{ delta: { tool_calls: [{ index: 0, id: "call_adv_host", type: "function", + function: { name: "advisor", arguments: JSON.stringify({ question: "Where do I start?" }) } }] }, finish_reason: null }] }, + { choices: [{ delta: {}, finish_reason: "tool_calls" }] }, +]; +const plain = [ + { choices: [{ delta: { content: "Continuing." }, finish_reason: null }] }, + { choices: [{ delta: {}, finish_reason: "stop" }] }, +]; +const sse = (frames: unknown[]) => new Response( + frames.map(frame => "data: " + JSON.stringify(frame)).join("\n\n") + "\n\ndata: [DONE]\n\n", + { headers: { "content-type": "text/event-stream" } }); +const completion = (content: string) => Response.json({ + choices: [{ message: { content }, finish_reason: "stop" }], + usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 }, +}); + +type Ingress = "primary" | "secondary"; +interface Harness { config: OcxConfig; workerBodies: string[]; expertCalls: number } + +function harness(ingress: Ingress, hostAllowsExpert: boolean): Harness { + const h = { workerBodies: [] as string[], expertCalls: 0 } as Harness; + let leg = 0; + h.config = { + port: 10100, + hostname: ingress === "primary" ? "127.0.0.1" : "0.0.0.0", + ...(ingress === "secondary" ? { unauthenticatedLoopbackListener: { enabled: true, port: 10200 } } : {}), + providers: { + worker: { adapter: "openai-chat", baseUrl: "https://worker.test/v1", apiKey: "w", models: ["deepseek-v4"], liveModels: false, + fetch: (async (_input: RequestInfo | URL, init?: RequestInit) => { + h.workerBodies.push(String(init?.body)); + return sse(leg++ === 0 ? advisorCall : plain); + }) as typeof fetch }, + expert: { adapter: "openai-chat", baseUrl: "https://expert.test/v1", apiKey: "e", models: ["gpt-6-astra"], liveModels: false, + fetch: (async () => { h.expertCalls += 1; return completion(ADVICE); }) as typeof fetch }, + }, + advisor: { enabled: true, model: "expert/gpt-6-astra", contextSharingConsent: "v1", effort: "high", policy: "manual" }, + apiKeys: [ + // First configured key: the credential localAdmissionToken selects for the consult. + { id: "host", name: "host", key: HOST_KEY, createdAt: "2026-01-01T00:00:00.000Z", + ...(hostAllowsExpert ? {} : { allowedProviders: ["worker"], allowedModels: ["worker/deepseek-v4"] }) }, + { id: "caller", name: "caller", key: CALLER_KEY, createdAt: "2026-01-01T00:00:00.000Z", + allowedProviders: ["worker", "expert"], allowedModels: ["worker/deepseek-v4", "expert/gpt-6-astra"] }, + ], + } as OcxConfig; + activateAdvisor(h.config); + return h; +} + +const publicServer = { timeout() {} }; +const loopbackServer = { timeout() {} }; +type ServerArg = Parameters["fetch"]>[1]; + +function listener(config: OcxConfig) { + const runAdmittedHttpTurn: ServeOptionsContext["runAdmittedHttpTurn"] = (req, _policy, work) => { + const lease = tryAdmitTurn(undefined, req.signal)!; + return work(lease).finally(() => { if (!lease.isTransferred()) lease.release(); }); + }; + return createServeOptions({ + config, deps: {}, runAdmittedHttpTurn, + ingressForServer: (server: unknown) => server === loopbackServer ? "unauthenticated-loopback" : "public", + loopbackPolicy: () => requestPolicyView(config, "127.0.0.1"), + loopbackRouteAllowed: () => true, + packageTreeIntegrity: { status: () => ({ ok: true }) }, + } as unknown as ServeOptionsContext); +} + +/** Every Advisor self-fetch re-enters the real listener the destination resolver chose. */ +function routeSelfFetch(h: Harness, serve: ReturnType, ingress: Ingress, consults: number[]) { + const expected = advisorBaseUrl(h.config) + "/v1/chat/completions"; + expect(expected).toBe(ingress === "primary" ? "http://127.0.0.1:10100/v1/chat/completions" : "http://127.0.0.1:10200/v1/chat/completions"); + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + if (String(input) !== expected) throw new Error("unexpected fetch: " + String(input)); + const res = await serve.fetch(new Request(expected, init), + (ingress === "primary" ? publicServer : loopbackServer) as unknown as ServerArg); + consults.push(res.status); + return res; + }) as typeof fetch; +} + +async function runCallerTurn(ingress: Ingress, hostAllowsExpert: boolean) { + releaseSpendHome = acquireOwnedSpendHome(); + const h = harness(ingress, hostAllowsExpert); + const serve = listener(h.config); + const consults: number[] = []; + routeSelfFetch(h, serve, ingress, consults); + // Primary loopback bind: every caller is loopback-admitted. Secondary: a remote caller presents + // its own key on the authenticated public listener, and that key may reach the expert model. + const response = await serve.fetch(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json", "thread-id": "thread-host-key-" + ingress, + ...(ingress === "secondary" ? { "x-opencodex-api-key": CALLER_KEY } : {}) }, + body: JSON.stringify({ model: "worker/deepseek-v4", input: "Fix the failing auth tests", stream: true }), + }), publicServer as unknown as ServerArg); + expect(response.status).toBe(200); + const frames = await collectSse(response.body!); + expect(frames.some(frame => frame.event === "response.completed")).toBe(true); + return { h, consults }; +} + +describe("Advisor self-fetch credential host policy on the real listener (#7894)", () => { + for (const ingress of ["primary", "secondary"] as const) { + test(ingress + " loopback ingress: a host key that forbids the Advisor model fails the consult closed", async () => { + const { h, consults } = await runCallerTurn(ingress, false); + expect(consults).toEqual([403]); + expect(h.expertCalls).toBe(0); + // The worker continuation receives the existing unavailable notice, never advice. + expect(h.workerBodies.length).toBeGreaterThanOrEqual(2); + expect(h.workerBodies[1]!).toContain("opencodex_advisor_unavailable"); + expect(h.workerBodies[1]!).not.toContain(ADVICE); + }); + + test(ingress + " loopback ingress: a host key that allows the Advisor model still consults", async () => { + const { h, consults } = await runCallerTurn(ingress, true); + expect(consults).toEqual([200]); + expect(h.expertCalls).toBe(1); + expect(h.workerBodies[1]!).toContain(ADVICE); + }); + } + + test("an ordinary loopback caller presenting the restricted host key keeps loopback admission", async () => { + releaseSpendHome = acquireOwnedSpendHome(); + const h = harness("secondary", false); + const response = await listener(h.config).fetch(new Request("http://127.0.0.1:10200/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json", "x-opencodex-api-key": HOST_KEY }, + body: JSON.stringify({ model: "expert/gpt-6-astra", messages: [{ role: "user", content: "hi" }], stream: false }), + }), loopbackServer as unknown as ServerArg); + expect(response.status).toBe(200); + expect(h.expertCalls).toBe(1); + }); + + test("an internal self-fetch presenting a credential the data plane does not know is refused", async () => { + releaseSpendHome = acquireOwnedSpendHome(); + const h = harness("primary", true); + const registration = registerAdvisorAdmissionPolicy(captureAdmissionPolicy(h.config)); + try { + const response = await handleChatCompletions(new Request("http://127.0.0.1:10100/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json", "x-opencodex-api-key": "ocx_data_" + "u".repeat(40), + [ADVISOR_INTERNAL_CAPABILITY_HEADER]: internalCallCapability(), + [ADVISOR_ADMISSION_POLICY_HEADER]: registration.token }, + body: JSON.stringify({ model: "expert/gpt-6-astra", messages: [{ role: "user", content: "hi" }], stream: false }), + }), h.config, { model: "", provider: "" }, { + requestId: "host-key-unknown", start: Date.now(), admission: { kind: "loopback", source: "loopback" }, + }); + expect(response.status).toBe(403); + expect((await response.json() as { error?: { code?: string } }).error?.code).toBe(MODEL_NOT_ALLOWED_FOR_KEY); + expect(h.expertCalls).toBe(0); + } finally { registration.release(); } + }); + + test("the self-fetch key's paid-combo consent and device fallback identity bind the consult", () => { + const h = harness("primary", true); + h.config.apiKeys![0]!.allowMeteredComboTargets = false; + h.config.apiKeys![1]!.allowMeteredComboTargets = true; + h.config.fallback = { devices: { host: { provider: false } } }; + const presented = captureSelfFetchCredentialPolicy(new Request("http://127.0.0.1:10100/v1/chat/completions", { + headers: { "x-opencodex-api-key": HOST_KEY }, + }), h.config)!; + // The loopback listener alone would grant paid targets on opt-in and name no device. + const listenerOnly = captureAdmissionPolicy(h.config, { kind: "loopback", source: "loopback" }); + expect(listenerOnly.allowMeteredComboTargets).toBe(true); + const merged = intersectAdmissionPolicies(listenerOnly, presented); + expect(merged.allowMeteredComboTargets).toBe(false); + expect(merged.fallbackPolicies.flatMap(policy => policy.deviceIds)).toEqual(["host"]); + }); +}); diff --git a/tests/advisor/advisor-caller-policy-http.test.ts b/tests/advisor/advisor-caller-policy-http.test.ts new file mode 100644 index 00000000000..3f41e0d3c8a --- /dev/null +++ b/tests/advisor/advisor-caller-policy-http.test.ts @@ -0,0 +1,80 @@ +import { expect, mock, test } from "bun:test"; +import * as slot from "../../src/server/responses/advisor-plan-slot"; +import type { AdvisorPlanInput } from "../../src/server/responses/advisor-plan-slot"; +import type { OcxConfig } from "../../src/types"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { runAdmittedBodyWork } from "../../src/server/inbound-body-admission"; +import { inboundBodyBudget } from "../../src/server/inbound-body-budget"; +import { MAX_DECOMPRESSED_BODY_BYTES } from "../../src/server/request-decompress"; +import { tryAdmitTurn } from "../../src/server/lifecycle"; +import type { ServeOptionsContext } from "../../src/server/index/serve-options"; + +// #7859: a raised maxInboundBodyBytes makes the HTTP wrapper wait for inbound-body capacity before +// the handler runs. The Advisor caller policy must be the one admitted at authentication, not one +// read from the live config after that wait. +// Pass-through: mock.module is process-wide, so other files in the same run keep the real plan. +const originalCreate = slot.createRegisteredAdvisorPlan; +const inputs: AdvisorPlanInput[] = []; +mock.module("../../src/server/responses/advisor-plan-slot", () => ({ + ...slot, + createRegisteredAdvisorPlan(input: AdvisorPlanInput) { inputs.push(input); return originalCreate(input); }, +})); +const { createServeOptions } = await import("../../src/server/index/serve-options"); +const stream = 'data: {"choices":[{"delta":{"content":"fixture"},"finish_reason":null}]}\n\n' + + 'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n'; +function config(raised: boolean): OcxConfig { + return { + hostname: "0.0.0.0", defaultProvider: "worker", + ...(raised ? { maxInboundBodyBytes: MAX_DECOMPRESSED_BODY_BYTES + 1 } : {}), + providers: { worker: { adapter: "openai-chat", apiKey: "fixture", models: ["m"], + liveModels: false, baseUrl: "https://worker.test/v1", + fetch: (async (_input, init) => JSON.parse(String(init?.body)).stream + ? new Response(stream, { headers: { "content-type": "text/event-stream" } }) + : Response.json({ choices: [{ message: { content: "fixture" }, finish_reason: "stop" }] })) as typeof fetch } }, + apiKeys: [{ id: "laptop", name: "laptop", key: "ocx_data_" + "c".repeat(40), createdAt: "2026-01-01", allowMeteredComboTargets: false }], + fallback: { devices: { laptop: { provider: false } } }, + }; +} + +for (const wire of ["responses", "chat/completions", "messages", "responses/compact"] as const) { + for (const raised of [false, true]) { + test(`HTTP ${wire} keeps the admitted caller policy across the capacity wait (raised limit: ${raised})`, async () => { + const release = acquireOwnedSpendHome(); + inputs.length = 0; + const cfg = config(raised); + const runAdmittedHttpTurn: ServeOptionsContext["runAdmittedHttpTurn"] = (req, policy, work, refusalLog) => { + const lease = tryAdmitTurn(undefined, req.signal)!; + // The same body-admission helper index.ts runs before handing the turn to its handler. + return runAdmittedBodyWork(req, policy, cfg.maxInboundBodyBytes, () => work(lease), refusalLog) + .finally(() => { if (!lease.isTransferred()) lease.release(); }); + }; + const ctx = { + config: cfg, deps: {}, ingressForServer: () => "public", + packageTreeIntegrity: { status: () => ({ ok: true }) }, runAdmittedHttpTurn, + } as unknown as ServeOptionsContext; + const serve = createServeOptions(ctx); + const request = new Request(`http://localhost/v1/${wire}`, { + method: "POST", headers: { "content-type": "application/json", "x-opencodex-api-key": cfg.apiKeys![0]!.key }, + body: JSON.stringify({ model: "worker/m", store: true, max_tokens: 100, stream: false, + input: [{ role: "user", content: "fixture" }], messages: [{ role: "user", content: "fixture" }] }), + }); + const holder = raised ? await inboundBodyBudget.acquire(inboundBodyBudget.capacity(), request.signal, 0) : null; + try { + const requestServer = { timeout() {} } as unknown as Parameters[1]; + const pending = serve.fetch(request, requestServer); + if (raised) expect(inboundBodyBudget.snapshot().queuedRequests).toBe(1); + cfg.apiKeys![0]!.allowMeteredComboTargets = true; + cfg.fallback = {}; + holder?.release(); + const response = await pending; + await response.text(); + expect(response.status).toBe(200); + expect(inputs).toHaveLength(1); + const captured = inputs[0]!.admissionPolicy!; + expect({ paid: captured.allowMeteredComboTargets, + providerFallback: captured.fallbackPolicies[0]!.config.fallback?.devices?.laptop?.provider }) + .toEqual({ paid: false, providerFallback: false }); + } finally { holder?.release(); release(); } + }); + } +} diff --git a/tests/advisor/advisor-caller-policy-ingress.test.ts b/tests/advisor/advisor-caller-policy-ingress.test.ts new file mode 100644 index 00000000000..d1a4984d076 --- /dev/null +++ b/tests/advisor/advisor-caller-policy-ingress.test.ts @@ -0,0 +1,55 @@ +import { afterEach, expect, mock, test } from "bun:test"; +import * as slot from "../../src/server/responses/advisor-plan-slot"; +import type { AdvisorPlanInput } from "../../src/server/responses/advisor-plan-slot"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import type { OcxConfig } from "../../src/types"; + +// #7859: the Advisor plan must receive the caller policy admitted at ingress, captured before the +// first await. Each case widens the live config while the body is still being read. +const originalCreate = slot.createRegisteredAdvisorPlan; +const inputs: AdvisorPlanInput[] = []; +mock.module("../../src/server/responses/advisor-plan-slot", () => ({ + ...slot, + createRegisteredAdvisorPlan(input: AdvisorPlanInput) { + inputs.push(input); + return originalCreate(input); + }, +})); +const { handleClaudeMessages } = await import("../../src/server/claude-messages"); +const { handleChatCompletions } = await import("../../src/server/chat-completions"); +afterEach(() => { inputs.length = 0; }); + +const workerStream = 'data: {"choices":[{"delta":{"content":"fixture"},"finish_reason":null}]}\n\n' + + 'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n'; + +for (const wire of ["messages", "chat"] as const) { + for (const kind of ["metered", "fallback"] as const) { + test(`${wire} keeps the admitted caller ${kind} policy when config widens during body read`, async () => { + const release = acquireOwnedSpendHome(); + try { + const config: OcxConfig = { + defaultProvider: "worker", + providers: { worker: { adapter: "openai-chat", apiKey: "fixture", models: ["m"], + liveModels: false, baseUrl: "https://worker.test/v1", + fetch: (async () => new Response(workerStream, { headers: { "content-type": "text/event-stream" } })) as typeof fetch } }, + apiKeys: [{ id: "laptop", name: "laptop", key: "fixture", createdAt: "2026-01-01", allowMeteredComboTargets: false }], + fallback: { devices: { laptop: { provider: false } } }, + }; + const request = new Request(`http://localhost/v1/${wire}`, { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "worker/m", store: true, max_tokens: 100, stream: false, messages: [{ role: "user", content: "fixture" }] }), + }); + const pending = (wire === "messages" ? handleClaudeMessages : handleChatCompletions)(request, config, + { model: "", provider: "" }, { requestId: "capture-probe", start: Date.now(), admission: { kind: "configured", keyId: "laptop", source: "bearer" } }); + config.apiKeys![0]!.allowMeteredComboTargets = true; + config.fallback = {}; + const response = await pending; + await response.text(); + expect(response.status).toBe(200); + expect(inputs).toHaveLength(1); + if (kind === "metered") expect(inputs[0]!.admissionPolicy!.allowMeteredComboTargets).toBe(false); + else expect(inputs[0]!.admissionPolicy!.fallbackPolicies[0]!.config.fallback?.devices?.laptop?.provider).toBe(false); + } finally { release(); } + }); + } +} diff --git a/tests/advisor/advisor-caller-policy-jev.test.ts b/tests/advisor/advisor-caller-policy-jev.test.ts new file mode 100644 index 00000000000..f43f4bba635 --- /dev/null +++ b/tests/advisor/advisor-caller-policy-jev.test.ts @@ -0,0 +1,81 @@ +import { expect, test } from "bun:test"; +import { handleResponses } from "../../src/server/responses/core"; +import { handleChatCompletions } from "../../src/server/chat-completions"; +import { activateAdvisor } from "../../src/lib/advisor-activation"; +import { advisorBaseUrl } from "../../src/advisor/consult"; +import { resolveDataPlaneAdmissionSecret } from "../../src/server/auth-cors"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { clearComboSelectionState, clearComboTargetCooldowns } from "../../src/combos"; +import { setAdvisorPlanFactory } from "../../src/server/responses/advisor-plan-slot"; +import { createAdvisorRuntimePlan } from "../../src/advisor/runtime"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; + +// #7859: a JEV decision turn runs detached from its parent. Its synthetic Advisor consultation must keep +// the caller policy admitted at ingress even when the live config widens while the parent is suspended. + +for (const kind of ["metered", "fallback"] as const) for (const widen of [false, true]) test(`detached JEV Advisor preserves ingress ${kind} refusal (widen=${widen})`, async () => { + const release = acquireOwnedSpendHome(); + const oldFetch = globalThis.fetch; + clearComboSelectionState(); clearComboTargetCooldowns(); + const calls: string[] = []; + const policies: unknown[] = []; + let decisionSends = 0; + const sse = (frames: unknown[]) => new Response(frames.map(frame => `data: ${JSON.stringify(frame)}\n\n`).join("") + "data: [DONE]\n\n", { headers: { "content-type": "text/event-stream" } }); + const provider = (name: string): OcxProviderConfig => ({ + adapter: "openai-chat", apiKey: "fixture", models: ["m"], liveModels: false, + baseUrl: `https://${name}.test/v1`, fetch: (async (_url, init) => { + calls.push(`${name}/m`); + const body = JSON.parse(String(init?.body)); + if (kind === "fallback" && name === "paid") return Response.json({ error: { code: "model_not_found", message: "fixture missing model" } }, { status: 404 }); + if (name === "decision" && decisionSends++ === 0) { + expect(body.tools.some((tool: any) => tool.function?.name === "advisor")).toBe(true); + return sse([ + { choices: [{ delta: { role: "assistant", tool_calls: [{ index: 0, id: "ask1", type: "function", function: { name: "advisor", arguments: '{"question":"routing advice?"}' } }] }, finish_reason: null }] }, + { choices: [{ delta: {}, finish_reason: "tool_calls" }] }, + ]); + } + const content = name === "decision" ? '{"choice":"worker/m:none"}' : "fixture answer"; + return body.stream ? sse([ + { choices: [{ delta: { content }, finish_reason: null }] }, + { choices: [{ delta: {}, finish_reason: "stop" }] }, + ]) : Response.json({ choices: [{ message: { content }, finish_reason: "stop" }] }); + }) as typeof fetch, + }); + const config: OcxConfig = { + port: 10100, defaultProvider: "worker", + providers: { worker: provider("worker"), other: provider("other"), decision: provider("decision"), paid: provider("paid"), backup: provider("backup") }, + apiKeys: [ + { id: "host", name: "host", key: "ocx_data_" + "h".repeat(40), createdAt: "2026-01-01", allowMeteredComboTargets: true }, + { id: "laptop", name: "laptop", key: "ocx_data_" + "c".repeat(40), createdAt: "2026-01-01", allowMeteredComboTargets: false }, + ], + fallback: { devices: { laptop: { provider: false } } }, + combos: { + outer: { strategy: "jev", decisionModel: "decision/m", targets: [{ provider: "worker", model: "m" }, { provider: "other", model: "m" }] }, + inner: { targets: kind === "metered" ? [{ provider: "paid", model: "m", metered: true }] : [{ provider: "paid", model: "m" }, { provider: "backup", model: "m" }] }, + }, + advisor: { enabled: true, model: "combo/inner", policy: "manual", effort: "high", contextSharingConsent: "v1" }, + }; + activateAdvisor(config); + setAdvisorPlanFactory(config, input => { + policies.push({ worker: input.workerModelId, paid: input.admissionPolicy?.allowMeteredComboTargets, fallback: input.admissionPolicy?.fallbackPolicies[0]?.config.fallback?.devices?.laptop?.provider }); + return createAdvisorRuntimePlan(input); + }); + globalThis.fetch = (async (input, init) => { + expect(String(input)).toBe(`${advisorBaseUrl(config)}/v1/chat/completions`); + const headers = new Headers(init?.headers); + const admission = resolveDataPlaneAdmissionSecret(headers.get("x-opencodex-api-key")!, config, "x-api-key")!; + return handleChatCompletions(new Request(String(input), init), config, { model: "", provider: "" }, { requestId: "r3-jev", start: Date.now(), admission }); + }) as typeof fetch; + try { + const pending = handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "combo/outer", input: "fixture routing task", stream: false }), + }), config, { model: "", provider: "" }, { admission: { kind: "configured", keyId: "laptop", source: "bearer" } }); + if (widen) { config.apiKeys![1]!.allowMeteredComboTargets = true; config.fallback = {}; } + const response = await pending; + await response.text(); + expect(response.status).toBe(200); + expect(decisionSends).toBe(2); + expect(calls).not.toContain(kind === "metered" ? "paid/m" : "backup/m"); + } finally { globalThis.fetch = oldFetch; release(); clearComboSelectionState(); clearComboTargetCooldowns(); } +}); diff --git a/tests/advisor/advisor-caller-policy-replay.test.ts b/tests/advisor/advisor-caller-policy-replay.test.ts new file mode 100644 index 00000000000..75cf67920d2 --- /dev/null +++ b/tests/advisor/advisor-caller-policy-replay.test.ts @@ -0,0 +1,104 @@ +import { expect, test } from "bun:test"; +import { handleResponses } from "../../src/server/responses/policy-fallback"; +import { handleChatCompletions } from "../../src/server/chat-completions"; +import { activateAdvisor } from "../../src/lib/advisor-activation"; +import { resolveDataPlaneAdmissionSecret } from "../../src/server/auth-cors"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { setAdvisorPlanFactory } from "../../src/server/responses/advisor-plan-slot"; +import { createAdvisorRuntimePlan } from "../../src/advisor/runtime"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; +import { createWebsocketHandler } from "../../src/server/index/websocket-handler"; +import { tryReserveCodexWebSocket } from "../../src/codex/websocket-registry"; +import { buildResponsesWsData, type WsData } from "../../src/server/ws-bridge"; +import type { ServerWebSocket } from "bun"; +import { closeRequestHistoryIndex } from "../../src/routing/history/indexer"; +import { runOptionalShutdownHooks } from "../../src/lib/optional-shutdown-hooks"; +import { clearComboSelectionState, clearComboTargetCooldowns } from "../../src/combos"; + +for (const ingress of ["wrapper", "websocket"] as const) test(`policy replay keeps ingress paid refusal (${ingress})`, async () => { + const release = acquireOwnedSpendHome(); + clearComboSelectionState(); clearComboTargetCooldowns(); + const oldFetch = globalThis.fetch; + const calls: string[] = []; + const policies: { worker: string; paid?: boolean }[] = []; + let secondSends = 0; + const sse = (frames: unknown[]) => new Response(frames.map(frame => `data: ${JSON.stringify(frame)}\n\n`).join("") + "data: [DONE]\n\n", { headers: { "content-type": "text/event-stream" } }); + const provider = (name: string): OcxProviderConfig => ({ + adapter: "openai-chat", apiKey: "fixture", models: ["m"], liveModels: false, + baseUrl: `https://${name}.test/v1`, fetch: (async (_url, init) => { + calls.push(`${name}/m`); + const body = JSON.parse(String(init?.body)); + if (name === "a") { + config.apiKeys![1]!.allowMeteredComboTargets = true; + return Response.json({ error: { code: "model_not_found", message: "fixture missing model" } }, { status: 404 }); + } + if (name === "b" && secondSends++ === 0) return sse([ + { choices: [{ delta: { role: "assistant", tool_calls: [{ index: 0, id: "ask1", type: "function", function: { name: "advisor", arguments: '{"question":"routing advice?"}' } }] }, finish_reason: null }] }, + { choices: [{ delta: {}, finish_reason: "tool_calls" }] }, + ]); + return body.stream ? sse([ + { choices: [{ delta: { content: "fixture answer" }, finish_reason: null }] }, + { choices: [{ delta: {}, finish_reason: "stop" }] }, + ]) : Response.json({ choices: [{ message: { content: "fixture answer" }, finish_reason: "stop" }] }); + }) as typeof fetch, + }); + const config: OcxConfig = { + port: 10100, defaultProvider: "a", + providers: { a: provider("a"), b: provider("b"), paid: provider("paid") }, + apiKeys: [ + { id: "host", name: "host", key: "ocx_data_" + "h".repeat(40), createdAt: "2026-01-01", allowMeteredComboTargets: true }, + { id: "laptop", name: "laptop", key: "ocx_data_" + "c".repeat(40), createdAt: "2026-01-01", allowMeteredComboTargets: false }, + ], + routingProfiles: { daily: { candidates: [{ provider: "a", model: "m" }, { provider: "b", model: "m" }] } }, + combos: { inner: { targets: [{ provider: "paid", model: "m", metered: true }] } }, + advisor: { enabled: true, model: "combo/inner", policy: "manual", effort: "high", contextSharingConsent: "v1" }, + }; + activateAdvisor(config); + setAdvisorPlanFactory(config, async input => { + policies.push({ worker: input.workerIdentity, paid: input.admissionPolicy?.allowMeteredComboTargets }); + return createAdvisorRuntimePlan(input); + }); + globalThis.fetch = (async (input, init) => { + expect(new URL(String(input)).pathname).toBe("/v1/chat/completions"); + const headers = new Headers(init?.headers); + const admission = resolveDataPlaneAdmissionSecret(headers.get("x-opencodex-api-key")!, config, "x-api-key")!; + return handleChatCompletions(new Request(String(input), init), config, { model: "", provider: "" }, { requestId: "r4-policy", start: Date.now(), admission }); + }) as typeof fetch; + const admission = { kind: "configured", keyId: "laptop", source: "bearer" } as const; + try { + if (ingress === "websocket") { + const handler = createWebsocketHandler({ config, deps: {} } as Parameters[0]); + const sent: any[] = []; + let readyState = 1; + const ws = { + data: buildResponsesWsData(new Headers(), admission, tryReserveCodexWebSocket() ?? undefined), + get readyState() { return readyState; }, getBufferedAmount: () => 0, + send(text: string) { sent.push(JSON.parse(text)); return 1; }, + close(code = 1000, reason = "") { if (readyState === 3) return; readyState = 3; handler.close(ws, code, reason); }, + } as unknown as ServerWebSocket; + handler.open(ws); + try { + handler.message(ws, JSON.stringify({ type: "response.create", model: "policy/daily", input: "fixture routing task" })); + const deadline = Date.now() + 4000; + while (ws.data.cancel && Date.now() < deadline) await Bun.sleep(1); + expect(ws.data.cancel).toBeUndefined(); + expect(secondSends).toBe(2); + expect(sent.some(f => f.type === "response.completed")).toBe(true); + expect(calls).not.toContain("paid/m"); + expect(policies.map(policy => policy.paid)).toEqual([false, false]); + } finally { ws.close(); } + return; + } + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "policy/daily", input: "fixture routing task", stream: true }), + }), config, { model: "", provider: "" }, { + admission, inboundTransport: "websocket", + }); + await response.text(); + expect(response.status).toBe(200); + expect(secondSends).toBe(2); + expect(calls).not.toContain("paid/m"); + expect(policies.map(policy => policy.paid)).toEqual([false, false]); + } finally { globalThis.fetch = oldFetch; closeRequestHistoryIndex(); runOptionalShutdownHooks(); release(); clearComboSelectionState(); clearComboTargetCooldowns(); } +}); diff --git a/tests/advisor/advisor-caller-policy-routing.test.ts b/tests/advisor/advisor-caller-policy-routing.test.ts new file mode 100644 index 00000000000..a52f1506e46 --- /dev/null +++ b/tests/advisor/advisor-caller-policy-routing.test.ts @@ -0,0 +1,187 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { activateAdvisor } from "../../src/lib/advisor-activation"; +import { handleResponses } from "../../src/server/responses/core"; +import { handleChatCompletions } from "../../src/server/chat-completions"; +import { resolveDataPlaneAdmissionSecret } from "../../src/server/auth-cors"; +import { advisorBaseUrl } from "../../src/advisor/consult"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { clearComboSelectionState, clearComboTargetCooldowns } from "../../src/combos"; +import { noteSubagentModelFailure, resetSubagentModelFallbackStateForTests } from "../../src/codex/subagent-model-fallback"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; +import { ADVISOR_INTERNAL_CAPABILITY_HEADER, internalCallCapability } from "../../src/lib/local-internal-call-capability"; + +const originalFetch = globalThis.fetch; +const caller = { kind: "configured", keyId: "laptop", source: "bearer" } as const; +let release: (() => void) | undefined; +beforeEach(() => { + release = acquireOwnedSpendHome(); + clearComboSelectionState(); + clearComboTargetCooldowns(); + resetSubagentModelFallbackStateForTests(); +}); +afterEach(() => { + globalThis.fetch = originalFetch; + release?.(); + clearComboSelectionState(); + clearComboTargetCooldowns(); + resetSubagentModelFallbackStateForTests(); +}); + +function answer(init?: RequestInit, text = "fixture advice"): Response { + const body = JSON.parse(String(init?.body)); + return body.stream ? new Response([ + { choices: [{ delta: { content: text }, finish_reason: null }] }, + { choices: [{ delta: {}, finish_reason: "stop" }] }, + ].map(frame => `data: ${JSON.stringify(frame)}\n\n`).join("") + "data: [DONE]\n\n", { + headers: { "content-type": "text/event-stream" }, + }) : Response.json({ choices: [{ message: { content: text }, finish_reason: "stop" }] }); +} + +type Variant = "combo" | "nested" | "collapsed" | "native"; +function fixture(kind: "metered" | "fallback", variant: Variant, consent = false) { + const calls: string[] = []; + let consults = 0; + const workerBodies: string[] = []; + const provider = (id: string): OcxProviderConfig => ({ + adapter: "openai-chat", apiKey: "fixture-key", baseUrl: `https://${id}.test/v1`, + models: ["m", "primary", "backup"], liveModels: false, reasoningEfforts: ["low", "high"], + fetch: (async (_input, init) => { + const headers = new Headers(init?.headers); + expect(headers.has("x-opencodex-advisor-admission-policy")).toBe(false); + expect(headers.has(ADVISOR_INTERNAL_CAPABILITY_HEADER)).toBe(false); + const model = JSON.parse(String(init?.body)).model; + if (id === "worker") workerBodies.push(String(init?.body)); + calls.push(`${id}/${model}`); + if (id === "expert" && model === "primary") { + return Response.json({ error: { message: "fixture unavailable model", code: "model_not_found" } }, { status: 404 }); + } + return answer(init, variant === "nested" && id === "expert" ? '{"choice":"free/m:high"}' : "fixture advice"); + }) as typeof fetch, + }); + const config: OcxConfig = { + port: 10100, defaultProvider: "worker", + protocols: variant === "native" ? { rollout: { nativeChatCombos: true } } : undefined, + providers: { worker: provider("worker"), expert: provider("expert"), free: provider("free"), primary: provider("primary"), backup: provider("backup") }, + apiKeys: [ + { id: "host", name: "host", key: "ocx_data_" + "h".repeat(40), createdAt: "2026-01-01", allowMeteredComboTargets: true }, + { id: "laptop", name: "laptop", key: "ocx_data_" + "c".repeat(40), createdAt: "2026-01-01", allowMeteredComboTargets: consent }, + ], + fallback: { devices: { laptop: { model: consent, provider: consent } } }, + combos: { inner: { targets: kind === "metered" + ? [{ provider: "expert", model: "m", metered: true }] + : [{ provider: "expert", model: "primary" }, { provider: "backup", model: "m" }] } }, + advisor: { enabled: true, model: "combo/inner", effort: "high", policy: "preflight", contextSharingConsent: "v1" }, + }; + if (variant === "nested") { + config.combos!.outer = { strategy: "jev", decisionModel: "combo/inner", targets: [{ provider: "free", model: "m" }] }; + config.advisor!.model = "combo/outer"; + } + if (variant === "collapsed") { + config.providers.primary!.adapter = "openai-responses"; + config.combos!.inner!.alias = "collapsed-expert"; + config.subagentModelFallback = ["collapsed-expert"]; + config.advisor!.model = "primary/m"; + noteSubagentModelFailure("primary/m", "429", config); + } + activateAdvisor(config); + globalThis.fetch = (async (input, init) => { + if (String(input) !== `${advisorBaseUrl(config)}/v1/chat/completions`) throw new Error("unexpected external fetch"); + consults++; + const headers = new Headers(init?.headers); + if (variant === "collapsed") headers.set("x-openai-subagent", "collab_spawn"); + const admission = resolveDataPlaneAdmissionSecret(headers.get("x-opencodex-api-key")!, config, "x-api-key")!; + return handleChatCompletions(new Request(String(input), { ...init, headers }), config, { model: "", provider: "" }, { + requestId: `policy-consult-${consults}`, start: Date.now(), admission, + }); + }) as typeof fetch; + return { config, calls, workerBodies, consults: () => consults }; +} + +async function run(f: ReturnType, direct = false, collapsed = false) { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json", ...(collapsed ? { "x-openai-subagent": "collab_spawn" } : {}) }, + body: JSON.stringify({ model: direct ? f.config.advisor!.model : "worker/m", input: [ + { role: "user", content: "fixture task" }, + { type: "function_call", name: "shell", call_id: "orientation", arguments: "{}" }, + { type: "function_call_output", call_id: "orientation", output: "fixture orientation" }, + ], stream: false }), + }), f.config, { model: "", provider: "" }, { admission: caller, ...(direct ? { advisorInternal: true } : {}) }); + await response.text(); + return response.status; +} + +for (const variant of ["combo", "nested", "collapsed", "native"] as const) { + test(`laptop paid-combo refusal survives Advisor ${variant}`, async () => { + const f = fixture("metered", variant); + expect(await run(f)).toBe(200); + expect(f.consults()).toBe(1); + expect(f.calls.filter(call => call.startsWith("expert/"))).toEqual([]); + expect(f.calls).toContain("worker/m"); + if (variant !== "nested") expect(f.workerBodies.join()).toContain("opencodex_advisor_unavailable"); + }); +} +for (const variant of ["combo", "nested", "native"] as const) { + test(`laptop device fallback refusal survives Advisor ${variant}`, async () => { + const f = fixture("fallback", variant); + expect(await run(f)).toBe(200); + expect(f.consults()).toBe(1); + expect(f.calls).toContain("expert/primary"); + expect(f.calls).not.toContain("backup/m"); + if (variant !== "nested") expect(f.workerBodies.join()).toContain("opencodex_advisor_unavailable"); + }); +} + +for (const kind of ["metered", "fallback"] as const) { + test(`caller ${kind} policy is captured before asynchronous body parsing`, async () => { + const f = fixture(kind, "combo"); + const pending = run(f); + f.config.apiKeys![1]!.allowMeteredComboTargets = true; + f.config.fallback = {}; + expect(await pending).toBe(200); + expect(f.consults()).toBe(1); + expect(f.calls).not.toContain(kind === "metered" ? "expert/m" : "backup/m"); + }); + test(`consenting laptop ${kind} control reaches the destination`, async () => { + const f = fixture(kind, "combo", true); + expect(await run(f)).toBe(200); + expect(f.calls).toContain(kind === "metered" ? "expert/m" : "backup/m"); + }); +} + +for (const capability of [undefined, "1", "A".repeat(43)] as const) { + test(`external forged policy has no effect with capability ${capability ?? "absent"}`, async () => { + const f = fixture("metered", "combo", true); + const response = await handleChatCompletions(new Request("http://localhost/v1/chat/completions", { + method: "POST", headers: { + "content-type": "application/json", + "x-opencodex-advisor-admission-policy": "client-supplied-policy", + ...(capability ? { [ADVISOR_INTERNAL_CAPABILITY_HEADER]: capability } : {}), + }, body: JSON.stringify({ model: "expert/m", messages: [{ role: "user", content: "fixture" }] }), + }), f.config, { model: "", provider: "" }); + await response.text(); + expect(response.status).toBe(200); + expect(f.calls).toEqual(["expert/m"]); + }); +} + +test("an authenticated internal hop with missing policy fails closed", async () => { + const f = fixture("metered", "combo", true); + const response = await handleChatCompletions(new Request("http://localhost/v1/chat/completions", { + method: "POST", headers: { + "content-type": "application/json", + [ADVISOR_INTERNAL_CAPABILITY_HEADER]: internalCallCapability(), + }, body: JSON.stringify({ model: "expert/m", messages: [{ role: "user", content: "fixture" }] }), + }), f.config, { model: "", provider: "" }); + expect(response.status).toBe(403); + expect(f.calls).toEqual([]); +}); + +test("a laptop without paid consent still receives advice from an unmetered combo target", async () => { + const f = fixture("metered", "combo"); + f.config.combos!.inner!.targets.push({ provider: "free", model: "m" }); + f.config.fallback!.devices!.laptop!.provider = true; + expect(await run(f)).toBe(200); + expect(f.calls).not.toContain("expert/m"); + expect(f.calls).toContain("free/m"); + expect(f.workerBodies.join()).toContain("fixture advice"); +}); diff --git a/tests/advisor/advisor-caller-policy-self-fetch.test.ts b/tests/advisor/advisor-caller-policy-self-fetch.test.ts new file mode 100644 index 00000000000..9c9d29d99e7 --- /dev/null +++ b/tests/advisor/advisor-caller-policy-self-fetch.test.ts @@ -0,0 +1,256 @@ +/** + * #7894 round 3: which credential an internal self-fetch presents, and whose paid-combo consent + * governs it, on the real listener (createServeOptions). + * + * Paid consent: on a public bind the secondary loopback listener admits the consult as loopback, + * and loopback on a public proxy never consents to metered combo targets. A validated self-fetch + * key is the operator's consent for that key, so a consenting caller and host key must reach a + * metered target the combo opted into, exactly as a direct request with that key does. The + * caller's consent and the combo opt-in still bind; an unkeyed loopback caller is still refused. + * + * Presented credential: a loopback bind can deliberately leave the installed service-token file + * (a hub client key) out of this proxy's admission credentials. A destination that requires no + * admission must not receive a credential this data plane does not recognize, or every + * consultation and routed helper fails closed; a configured key it does recognize still binds. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { activateAdvisor } from "../../src/lib/advisor-activation"; +import { createServeOptions, type ServeOptionsContext } from "../../src/server/index/serve-options"; +import { requestPolicyView } from "../../src/server/auth-cors"; +import { tryAdmitTurn } from "../../src/server/lifecycle"; +import { advisorBaseUrl } from "../../src/advisor/consult"; +import { postLocalChatCompletion } from "../../src/lib/local-chat-completion"; +import { registerAdvisorAdmissionPolicy, SIDECAR_ADMISSION_POLICY_HEADER, SIDECAR_INTERNAL_CAPABILITY_HEADER } from "../../src/server/advisor-admission-scope"; +import { captureAdmissionPolicy } from "../../src/server/capture-admission-policy"; +import { internalCallCapability } from "../../src/lib/local-internal-call-capability"; +import { clearComboSelectionState, clearComboTargetCooldowns } from "../../src/combos"; +import { collectSse } from "../helpers/responses-conformance"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import type { OcxApiKeyEntry, OcxConfig } from "../../src/types"; + +const originalFetch = globalThis.fetch; +const savedEnv = { token: process.env.OPENCODEX_API_AUTH_TOKEN, file: process.env.OCX_API_TOKEN_FILE }; +let releaseSpendHome: (() => void) | undefined; +let scratch: string | undefined; +beforeEach(() => { + delete process.env.OPENCODEX_API_AUTH_TOKEN; + process.env.OCX_API_TOKEN_FILE = "missing-service-api-token-7894-r3"; + clearComboSelectionState(); + clearComboTargetCooldowns(); + releaseSpendHome = acquireOwnedSpendHome(); +}); +afterEach(() => { + try { releaseSpendHome?.(); } finally { + releaseSpendHome = undefined; + globalThis.fetch = originalFetch; + clearComboSelectionState(); + clearComboTargetCooldowns(); + if (scratch) rmSync(scratch, { recursive: true, force: true }); + scratch = undefined; + if (savedEnv.token === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN; else process.env.OPENCODEX_API_AUTH_TOKEN = savedEnv.token; + if (savedEnv.file === undefined) delete process.env.OCX_API_TOKEN_FILE; else process.env.OCX_API_TOKEN_FILE = savedEnv.file; + } +}); + +const HOST_KEY = "ocx_data_" + "h".repeat(40); +const CALLER_KEY = "ocx_data_" + "c".repeat(40); +const HUB_CLIENT_KEY = "ocx_data_" + "f".repeat(40); +const ADVICE = "Sequence the fix: token store first."; +const advisorCall = [ + { choices: [{ delta: { tool_calls: [{ index: 0, id: "call_adv_r3", type: "function", + function: { name: "advisor", arguments: JSON.stringify({ question: "Where do I start?" }) } }] }, finish_reason: null }] }, + { choices: [{ delta: {}, finish_reason: "tool_calls" }] }, +]; +const plain = [ + { choices: [{ delta: { content: "Continuing." }, finish_reason: null }] }, + { choices: [{ delta: {}, finish_reason: "stop" }] }, +]; +const sse = (frames: unknown[]) => new Response( + frames.map(frame => "data: " + JSON.stringify(frame)).join("\n\n") + "\n\ndata: [DONE]\n\n", + { headers: { "content-type": "text/event-stream" } }); +const completion = (content: string) => Response.json({ + choices: [{ message: { content }, finish_reason: "stop" }], + usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 }, +}); +const key = (id: string, secret: string, extra: Partial = {}): OcxApiKeyEntry => + ({ id, name: id, key: secret, createdAt: "2026-01-01T00:00:00.000Z", ...extra }); + +interface Harness { config: OcxConfig; workerBodies: string[]; expertCalls: number } + +function harness(config: Omit & { advisorModel: string }): Harness { + const h = { workerBodies: [] as string[], expertCalls: 0 } as Harness; + let leg = 0; + const { advisorModel, ...rest } = config; + h.config = { + port: 10100, + ...rest, + providers: { + worker: { adapter: "openai-chat", baseUrl: "https://worker.test/v1", apiKey: "w", models: ["deepseek-v4"], liveModels: false, + fetch: (async (_input: RequestInfo | URL, init?: RequestInit) => { + h.workerBodies.push(String(init?.body)); + return sse(leg++ === 0 ? advisorCall : plain); + }) as typeof fetch }, + expert: { adapter: "openai-chat", baseUrl: "https://expert.test/v1", apiKey: "e", models: ["gpt-6-astra", "m"], liveModels: false, + fetch: (async (_input: RequestInfo | URL, init?: RequestInit) => { + h.expertCalls += 1; + return JSON.parse(String(init?.body)).stream + ? sse([{ choices: [{ delta: { content: ADVICE }, finish_reason: null }] }, { choices: [{ delta: {}, finish_reason: "stop" }] }]) + : completion(ADVICE); + }) as typeof fetch }, + }, + advisor: { enabled: true, model: advisorModel, contextSharingConsent: "v1", effort: "high", policy: "manual" }, + } as OcxConfig; + activateAdvisor(h.config); + return h; +} + +const publicServer = { timeout() {} }; +const loopbackServer = { timeout() {} }; +type ServerArg = Parameters["fetch"]>[1]; + +function listener(config: OcxConfig) { + const runAdmittedHttpTurn: ServeOptionsContext["runAdmittedHttpTurn"] = (req, _policy, work) => { + const lease = tryAdmitTurn(undefined, req.signal)!; + return work(lease).finally(() => { if (!lease.isTransferred()) lease.release(); }); + }; + return createServeOptions({ + config, deps: {}, runAdmittedHttpTurn, + ingressForServer: (server: unknown) => server === loopbackServer ? "unauthenticated-loopback" : "public", + loopbackPolicy: () => requestPolicyView(config, "127.0.0.1"), + loopbackRouteAllowed: () => true, + packageTreeIntegrity: { status: () => ({ ok: true }) }, + } as unknown as ServeOptionsContext); +} + +interface SelfFetch { status: number; presented: string | null } + +/** Every self-fetch re-enters the real listener the destination resolver chose. */ +function routeSelfFetch(config: OcxConfig, serve: ReturnType, viaLoopbackListener: boolean): SelfFetch[] { + const seen: SelfFetch[] = []; + const expected = advisorBaseUrl(config) + "/v1/chat/completions"; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + if (String(input) !== expected) throw new Error("unexpected fetch: " + String(input)); + const res = await serve.fetch(new Request(expected, init), + (viaLoopbackListener ? loopbackServer : publicServer) as unknown as ServerArg); + seen.push({ status: res.status, presented: new Headers(init?.headers).get("x-opencodex-api-key") }); + return res; + }) as typeof fetch; + return seen; +} + +async function workerTurn(serve: ReturnType, callerKey?: string) { + const response = await serve.fetch(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json", "thread-id": "thread-r3-" + crypto.randomUUID(), + ...(callerKey ? { "x-opencodex-api-key": callerKey } : {}) }, + body: JSON.stringify({ model: "worker/deepseek-v4", input: "Fix the failing auth tests", stream: true }), + }), publicServer as unknown as ServerArg); + expect(response.status).toBe(200); + const frames = await collectSse(response.body!); + expect(frames.some(frame => frame.event === "response.completed")).toBe(true); +} + +describe("a validated self-fetch key's paid-combo consent on a public bind's loopback listener", () => { + function meteredHarness(consent: { host: boolean; caller: boolean; comboOptIn: boolean }) { + return harness({ + hostname: "0.0.0.0", + unauthenticatedLoopbackListener: { enabled: true, port: 10200 }, + apiKeys: [ + key("host", HOST_KEY, { allowMeteredComboTargets: consent.host }), + key("caller", CALLER_KEY, { allowMeteredComboTargets: consent.caller }), + ], + combos: { inner: { ...(consent.comboOptIn ? { allowMeteredTargets: true } : {}), + targets: [{ provider: "expert", model: "m", metered: true }] } }, + advisorModel: "combo/inner", + }); + } + + test("consenting caller, host key and combo reach the metered target", async () => { + const h = meteredHarness({ host: true, caller: true, comboOptIn: true }); + const serve = listener(h.config); + const seen = routeSelfFetch(h.config, serve, true); + await workerTurn(serve, CALLER_KEY); + expect(seen).toEqual([{ status: 200, presented: HOST_KEY }]); + expect(h.expertCalls).toBe(1); + expect(h.workerBodies[1]!).toContain(ADVICE); + }); + + for (const [label, consent] of [ + ["the host key", { host: false, caller: true, comboOptIn: true }], + ["the caller", { host: true, caller: false, comboOptIn: true }], + ["the combo", { host: true, caller: true, comboOptIn: false }], + ] as const) { + test("a refusal by " + label + " still keeps the consult off the metered target", async () => { + const h = meteredHarness(consent); + const serve = listener(h.config); + const seen = routeSelfFetch(h.config, serve, true); + await workerTurn(serve, CALLER_KEY); + expect(seen.length).toBe(1); + expect(seen[0]!.status).not.toBe(200); + expect(h.expertCalls).toBe(0); + expect(h.workerBodies[1]!).toContain("opencodex_advisor_unavailable"); + }); + } + + test("an unkeyed caller on the loopback listener is still refused the metered target", async () => { + const h = meteredHarness({ host: true, caller: true, comboOptIn: true }); + const response = await listener(h.config).fetch(new Request("http://127.0.0.1:10200/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json", "x-opencodex-api-key": HOST_KEY }, + body: JSON.stringify({ model: "combo/inner", messages: [{ role: "user", content: "hi" }], stream: false }), + }), loopbackServer as unknown as ServerArg); + expect(response.status).not.toBe(200); + expect(h.expertCalls).toBe(0); + }); +}); + +describe("the credential a loopback-bind self-fetch presents", () => { + function installHubClientToken() { + scratch = mkdtempSync(join(tmpdir(), "ocx-7894-r3-")); + const file = join(scratch, "service-api-token"); + writeFileSync(file, HUB_CLIENT_KEY + "\n"); + process.env.OCX_API_TOKEN_FILE = file; + } + + test("an installed hub client token this proxy does not admit is not presented to Advisor", async () => { + installHubClientToken(); + const h = harness({ hostname: "127.0.0.1", advisorModel: "expert/gpt-6-astra" }); + const serve = listener(h.config); + const seen = routeSelfFetch(h.config, serve, false); + await workerTurn(serve); + expect(seen).toEqual([{ status: 200, presented: null }]); + expect(h.expertCalls).toBe(1); + expect(h.workerBodies[1]!).toContain(ADVICE); + }); + + test("a recognized restricted key behind that token still binds the consult", async () => { + installHubClientToken(); + const h = harness({ hostname: "127.0.0.1", advisorModel: "expert/gpt-6-astra", + apiKeys: [key("host", HOST_KEY, { allowedProviders: ["worker"], allowedModels: ["worker/deepseek-v4"] })] }); + const serve = listener(h.config); + const seen = routeSelfFetch(h.config, serve, false); + await workerTurn(serve); + expect(seen).toEqual([{ status: 403, presented: HOST_KEY }]); + expect(h.expertCalls).toBe(0); + expect(h.workerBodies[1]!).toContain("opencodex_advisor_unavailable"); + }); + + test("a routed sidecar self-fetch does not present the unadmitted hub client token", async () => { + installHubClientToken(); + const h = harness({ hostname: "127.0.0.1", advisorModel: "expert/gpt-6-astra" }); + const seen = routeSelfFetch(h.config, listener(h.config), false); + const registration = registerAdvisorAdmissionPolicy(captureAdmissionPolicy(h.config)); + try { + const outcome = await postLocalChatCompletion({ + config: h.config, label: "routed describe", logTag: "vision", timeoutMs: 10_000, maxResponseBytes: 64_000, + headers: { [SIDECAR_ADMISSION_POLICY_HEADER]: registration.token, [SIDECAR_INTERNAL_CAPABILITY_HEADER]: internalCallCapability() }, + body: { model: "expert/gpt-6-astra", messages: [{ role: "user", content: "describe" }] }, + }); + expect(outcome).toEqual({ text: ADVICE }); + expect(seen).toEqual([{ status: 200, presented: null }]); + } finally { registration.release(); } + }); +}); diff --git a/tests/advisor/advisor-caller-policy-sidecar-authority.test.ts b/tests/advisor/advisor-caller-policy-sidecar-authority.test.ts new file mode 100644 index 00000000000..f9c7f04769d --- /dev/null +++ b/tests/advisor/advisor-caller-policy-sidecar-authority.test.ts @@ -0,0 +1,65 @@ +import { expect, test } from "bun:test"; +import { handleChatCompletions } from "../../src/server/chat-completions"; +import { captureAdmissionPolicy } from "../../src/server/capture-admission-policy"; +import { readAdvisorAdmissionPolicy, registerAdvisorAdmissionPolicy, SIDECAR_ADMISSION_POLICY_HEADER, SIDECAR_INTERNAL_CAPABILITY_HEADER } from "../../src/server/advisor-admission-scope"; +import { internalCallCapability } from "../../src/lib/local-internal-call-capability"; +import { describeImageRouted } from "../../src/vision/routed-describe"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import type { OcxConfig } from "../../src/types"; + +for (const state of ["missing", "released", "aborted", "forged", "live"] as const) { + test(`routed helper policy authority: ${state}`, async () => { + const release = acquireOwnedSpendHome(); + let sends = 0; + const config: OcxConfig = { providers: { worker: { + adapter: "openai-chat", models: ["m"], liveModels: false, apiKey: "fixture", + baseUrl: "https://worker.test/v1", fetch: (async () => { + sends++; + return Response.json({ choices: [{ message: { content: "fixture" }, finish_reason: "stop" }] }); + }) as typeof fetch, + } } }; + const registration = registerAdvisorAdmissionPolicy(captureAdmissionPolicy(config, undefined, { providers: ["other"], models: [] })); + if (state === "released") registration.release(); + const controller = new AbortController(); + const aborted = registerAdvisorAdmissionPolicy(captureAdmissionPolicy(config), controller.signal); + if (state === "aborted") controller.abort(); + try { + const response = await handleChatCompletions(new Request("http://localhost/v1/chat/completions", { + method: "POST", headers: { + "content-type": "application/json", + [SIDECAR_INTERNAL_CAPABILITY_HEADER]: state === "forged" ? "1" : internalCallCapability(), + ...(state === "missing" ? {} : { [SIDECAR_ADMISSION_POLICY_HEADER]: state === "aborted" ? aborted.token : registration.token }), + }, body: JSON.stringify({ model: "worker/m", messages: [{ role: "user", content: "fixture" }] }), + }), config, { model: "", provider: "" }); + await response.text(); + expect(response.status).toBe(state === "forged" ? 200 : 403); + expect(sends).toBe(state === "forged" ? 1 : 0); + } finally { registration.release(); aborted.release(); release(); } + }); +} + +for (const outcome of ["success", "failure", "abort"] as const) { + test(`routed helper releases its registration on ${outcome}`, async () => { + const originalFetch = globalThis.fetch; + const controller = new AbortController(); + let token: string | null = null; + globalThis.fetch = (async (_input, init) => { + const headers = new Headers(init?.headers); + token = headers.get(SIDECAR_ADMISSION_POLICY_HEADER); + expect(readAdvisorAdmissionPolicy(token, headers.get(SIDECAR_INTERNAL_CAPABILITY_HEADER))).toBeDefined(); + if (outcome === "abort") { + controller.abort(); + expect(readAdvisorAdmissionPolicy(token, internalCallCapability())).toBeUndefined(); + } + if (outcome !== "success") throw new Error("fixture connection failure"); + return Response.json({ choices: [{ message: { content: "fixture description" } }] }); + }) as typeof fetch; + try { + const result = await describeImageRouted("data:image/png;base64,aGVsbG8=", undefined, "fixture", "worker/m", {}, + { model: "worker/m", reasoning: "low", timeoutMs: 1000 }, controller.signal, "http://localhost", captureAdmissionPolicy({})); + expect(Boolean(result.error)).toBe(outcome !== "success"); + expect(token).toBeTruthy(); + expect(readAdvisorAdmissionPolicy(token, internalCallCapability())).toBeUndefined(); + } finally { globalThis.fetch = originalFetch; } + }); +} diff --git a/tests/advisor/advisor-caller-policy-vision.test.ts b/tests/advisor/advisor-caller-policy-vision.test.ts new file mode 100644 index 00000000000..d8812020d51 --- /dev/null +++ b/tests/advisor/advisor-caller-policy-vision.test.ts @@ -0,0 +1,94 @@ +import { expect, test } from "bun:test"; +import { handleResponses } from "../../src/server/responses/core"; +import { handleChatCompletions } from "../../src/server/chat-completions"; +import { activateAdvisor } from "../../src/lib/advisor-activation"; +import { resolveDataPlaneAdmissionSecret } from "../../src/server/auth-cors"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { resetVisionDescriptionCache } from "../../src/vision"; +import { setAdvisorPlanFactory } from "../../src/server/responses/advisor-plan-slot"; +import { createAdvisorRuntimePlan } from "../../src/advisor/runtime"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; +import { clearComboSelectionState, clearComboTargetCooldowns } from "../../src/combos"; + +for (const refusing of ["caller", "host"] as const) for (const kind of ["metered", "fallback"] as const) test(`routed vision Advisor must retain ${refusing} ${kind} refusal`, async () => { + const release = acquireOwnedSpendHome(); + const oldFetch = globalThis.fetch; + resetVisionDescriptionCache(); + clearComboSelectionState(); clearComboTargetCooldowns(); + const calls: string[] = []; + const policies: { paid?: boolean; devices?: readonly (readonly string[])[] }[] = []; + let helperSends = 0; + const sse = (frames: unknown[]) => new Response(frames.map(frame => `data: ${JSON.stringify(frame)}\n\n`).join("") + "data: [DONE]\n\n", { headers: { "content-type": "text/event-stream" } }); + const provider = (name: string): OcxProviderConfig => ({ + adapter: name === "helper" ? "anthropic" : "openai-chat", apiKey: "fixture", models: ["m"], liveModels: false, + baseUrl: `https://${name}.test/v1`, + ...(name === "worker" ? { noVisionModels: ["m"] } : {}), + ...(name === "helper" ? { modelCapabilities: { m: { inputModalities: ["text", "image"] } } } : {}), + fetch: (async (_url, init) => { + const headers = new Headers(init?.headers); + expect(headers.has("x-opencodex-sidecar-internal")).toBe(false); + expect(headers.has("x-opencodex-sidecar-admission-policy")).toBe(false); + calls.push(`${name}/m`); + const body = JSON.parse(String(init?.body)); + if (kind === "fallback" && name === "paid") return Response.json({ error: { code: "model_not_found", message: "fixture missing model" } }, { status: 404 }); + if (name === "helper" && helperSends++ === 0) { + expect(body.tools.some((tool: any) => tool.name === "advisor")).toBe(true); + return sse([ + { type: "message_start", message: { id: "fixture", role: "assistant", model: "m", content: [], usage: { input_tokens: 1, output_tokens: 0 } } }, + { type: "content_block_start", index: 0, content_block: { type: "tool_use", id: "ask1", name: "advisor", input: {} } }, + { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: '{"question":"routing advice?"}' } }, + { type: "content_block_stop", index: 0 }, + { type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 1 } }, + { type: "message_stop" }, + ]); + } + if (name === "helper") return sse([ + { type: "message_start", message: { id: "fixture2", role: "assistant", model: "m", content: [], usage: { input_tokens: 1, output_tokens: 0 } } }, + { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "fixture answer" } }, + { type: "content_block_stop", index: 0 }, + { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } }, + { type: "message_stop" }, + ]); + return body.stream ? sse([ + { choices: [{ delta: { content: "fixture answer" }, finish_reason: null }] }, + { choices: [{ delta: {}, finish_reason: "stop" }] }, + ]) : Response.json({ choices: [{ message: { content: "fixture answer" }, finish_reason: "stop" }] }); + }) as typeof fetch, + }); + const config: OcxConfig = { + port: 10100, defaultProvider: "worker", + providers: { worker: provider("worker"), helper: provider("helper"), paid: provider("paid"), backup: provider("backup") }, + apiKeys: [ + { id: "host", name: "host", key: "ocx_data_" + "h".repeat(40), createdAt: "2026-01-01", allowMeteredComboTargets: refusing !== "host" }, + { id: "laptop", name: "laptop", key: "ocx_data_" + "c".repeat(40), createdAt: "2026-01-01", allowMeteredComboTargets: refusing !== "caller" }, + ], + fallback: { devices: { [refusing === "caller" ? "laptop" : "host"]: { provider: false } } }, + combos: { inner: { targets: kind === "metered" ? [{ provider: "paid", model: "m", metered: true }] : [{ provider: "paid", model: "m" }, { provider: "backup", model: "m" }] } }, + visionSidecar: { enabled: true, backend: "routed", model: "helper/m" }, + advisor: { enabled: true, model: "combo/inner", policy: "manual", effort: "high", contextSharingConsent: "v1" }, + }; + activateAdvisor(config); + setAdvisorPlanFactory(config, async input => { + policies.push({ paid: input.admissionPolicy?.allowMeteredComboTargets, devices: input.admissionPolicy?.fallbackPolicies.map(p => p.deviceIds) }); + return createAdvisorRuntimePlan(input); + }); + globalThis.fetch = (async (input, init) => { + expect(new URL(String(input)).pathname).toBe("/v1/chat/completions"); + const headers = new Headers(init?.headers); + const admission = resolveDataPlaneAdmissionSecret(headers.get("x-opencodex-api-key")!, config, "x-api-key")!; + return handleChatCompletions(new Request(String(input), init), config, { model: "", provider: "" }, { requestId: "r4-vision", start: Date.now(), admission }); + }) as typeof fetch; + try { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "worker/m", input: [{ role: "user", content: [{ type: "input_text", text: "Describe this fixture" }, { type: "input_image", image_url: "data:image/png;base64,aGVsbG8=" }] }], stream: false }), + }), config, { model: "", provider: "" }, { admission: { kind: "configured", keyId: "laptop", source: "bearer" } }); + await response.text(); + expect(response.status).toBe(200); + expect(helperSends).toBe(2); + expect(calls).not.toContain(kind === "metered" ? "paid/m" : "backup/m"); + expect(policies[0]?.paid).toBe(false); + expect(policies[0]?.devices?.flat()).toEqual(expect.arrayContaining(["host", "laptop"])); + } finally { globalThis.fetch = oldFetch; resetVisionDescriptionCache(); release(); clearComboSelectionState(); clearComboTargetCooldowns(); } +}); diff --git a/tests/advisor/advisor-caller-policy.test.ts b/tests/advisor/advisor-caller-policy.test.ts new file mode 100644 index 00000000000..edd8b7c24c9 --- /dev/null +++ b/tests/advisor/advisor-caller-policy.test.ts @@ -0,0 +1,316 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { activateAdvisor } from "../../src/lib/advisor-activation"; +import { handleResponses } from "../../src/server/responses/core"; +import { handleChatCompletions } from "../../src/server/chat-completions"; +import { resolveDataPlaneAdmissionSecret } from "../../src/server/auth-cors"; +import { advisorBaseUrl, consultAdvisor } from "../../src/advisor/consult"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { clearComboSelectionState, clearComboTargetCooldowns } from "../../src/combos"; +import { noteSubagentModelFailure, resetSubagentModelFallbackStateForTests } from "../../src/codex/subagent-model-fallback"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; +import { setAdvisorPlanFactory } from "../../src/server/responses/advisor-plan-slot"; +import { createAdvisorRuntimePlan } from "../../src/advisor/runtime"; +import { ADVISOR_ADMISSION_POLICY_HEADER, readAdvisorAdmissionPolicy, registerAdvisorAdmissionPolicy } from "../../src/server/advisor-admission-scope"; +import { ADVISOR_INTERNAL_CAPABILITY_HEADER, internalCallCapability } from "../../src/lib/local-internal-call-capability"; +import { captureAdmissionPolicy, inheritAdmissionPolicy } from "../../src/server/capture-admission-policy"; +import { parseRequest } from "../../src/responses/parser"; +import { admissionMayUseMeteredComboTargets, admissionScopeDenial, resolveAdmissionModelScope, routeAllowedByScope } from "../../src/server/admission-model-scope"; +import { nativeMessagesToolScopeDenial } from "../../src/server/messages-native-scope"; + +const originalFetch = globalThis.fetch; +const caller = { kind: "configured", keyId: "caller", source: "bearer" } as const; +let release: (() => void) | undefined; +beforeEach(() => { + release = acquireOwnedSpendHome(); + clearComboSelectionState(); + clearComboTargetCooldowns(); + resetSubagentModelFallbackStateForTests(); +}); +afterEach(() => { + globalThis.fetch = originalFetch; + release?.(); + clearComboSelectionState(); + clearComboTargetCooldowns(); + resetSubagentModelFallbackStateForTests(); +}); + +function answer(init?: RequestInit, text = "fixture advice"): Response { + const body = JSON.parse(String(init?.body)); + return body.stream ? new Response([ + { choices: [{ delta: { content: text }, finish_reason: null }] }, + { choices: [{ delta: {}, finish_reason: "stop" }] }, + ].map(frame => `data: ${JSON.stringify(frame)}\n\n`).join("") + "data: [DONE]\n\n", { + headers: { "content-type": "text/event-stream" }, + }) : Response.json({ choices: [{ message: { content: text }, finish_reason: "stop" }] }); +} + +type Variant = "combo" | "nested" | "collapsed"; +function fixture(kind: "metered" | "fallback", variant: Variant, consent = false) { + const calls: string[] = []; + let consults = 0; + let token: string | null = null; + const provider = (id: string): OcxProviderConfig => ({ + adapter: "openai-chat", apiKey: "fixture-key", baseUrl: `https://${id}.test/v1`, + models: ["m", "primary", "backup"], liveModels: false, reasoningEfforts: ["low", "high"], + fetch: (async (_input, init) => { + const headers = new Headers(init?.headers); + expect(headers.has(ADVISOR_ADMISSION_POLICY_HEADER)).toBe(false); + expect(headers.has(ADVISOR_INTERNAL_CAPABILITY_HEADER)).toBe(false); + const model = JSON.parse(String(init?.body)).model; + calls.push(`${id}/${model}`); + if (id === "expert" && model === "primary") { + return Response.json({ error: { message: "fixture unavailable model", code: "model_not_found" } }, { status: 404 }); + } + return answer(init, variant === "nested" && id === "expert" ? '{"choice":"free/m:high"}' : "fixture advice"); + }) as typeof fetch, + }); + const config: OcxConfig = { + port: 10100, defaultProvider: "worker", + providers: { worker: provider("worker"), expert: provider("expert"), free: provider("free"), primary: provider("primary") }, + apiKeys: [ + { id: "host", name: "host", key: "ocx_data_" + "h".repeat(40), createdAt: "2026-01-01", allowMeteredComboTargets: true }, + { id: "caller", name: "caller", key: "ocx_data_" + "c".repeat(40), createdAt: "2026-01-01", allowMeteredComboTargets: consent }, + ], + fallback: { devices: { caller: { model: consent, provider: consent } } }, + combos: { inner: { targets: kind === "metered" + ? [{ provider: "expert", model: "m", metered: true }] + : [{ provider: "expert", model: "primary" }, { provider: "expert", model: "backup" }] } }, + advisor: { enabled: true, model: "combo/inner", effort: "high", policy: "preflight", contextSharingConsent: "v1" }, + }; + if (variant === "nested") { + config.combos!.outer = { strategy: "jev", decisionModel: "combo/inner", targets: [{ provider: "free", model: "m" }] }; + config.advisor!.model = "combo/outer"; + } + if (variant === "collapsed") { + config.providers.primary!.adapter = "openai-responses"; + config.combos!.inner!.alias = "collapsed-expert"; + config.subagentModelFallback = ["collapsed-expert"]; + config.advisor!.model = "primary/m"; + noteSubagentModelFailure("primary/m", "429", config); + } + activateAdvisor(config); + globalThis.fetch = (async (input, init) => { + if (String(input) !== `${advisorBaseUrl(config)}/v1/chat/completions`) throw new Error("unexpected external fetch"); + consults++; + const headers = new Headers(init?.headers); + token = headers.get(ADVISOR_ADMISSION_POLICY_HEADER); + expect(readAdvisorAdmissionPolicy(token, null)).toBeUndefined(); + expect(readAdvisorAdmissionPolicy(token, "A".repeat(43))).toBeUndefined(); + expect(readAdvisorAdmissionPolicy(token, internalCallCapability())).toBeDefined(); + if (variant === "collapsed") headers.set("x-openai-subagent", "collab_spawn"); + const admission = resolveDataPlaneAdmissionSecret(headers.get("x-opencodex-api-key")!, config, "x-api-key")!; + return handleChatCompletions(new Request(String(input), { ...init, headers }), config, { model: "", provider: "" }, { + requestId: `policy-consult-${consults}`, start: Date.now(), admission, + }); + }) as typeof fetch; + return { config, calls, consults: () => consults, token: () => token }; +} + +async function run(f: ReturnType, direct = false, collapsed = false) { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json", ...(collapsed ? { "x-openai-subagent": "collab_spawn" } : {}) }, + body: JSON.stringify({ model: direct ? f.config.advisor!.model : "worker/m", input: [ + { role: "user", content: "fixture task" }, + { type: "function_call", name: "shell", call_id: "orientation", arguments: "{}" }, + { type: "function_call_output", call_id: "orientation", output: "fixture orientation" }, + ], stream: false }), + }), f.config, { model: "", provider: "" }, { admission: caller, ...(direct ? { advisorInternal: true } : {}) }); + await response.text(); + if (!direct) { + expect(f.token()).toBeTruthy(); + expect(readAdvisorAdmissionPolicy(f.token(), internalCallCapability())).toBeUndefined(); + } + return response.status; +} + +for (const variant of ["combo", "nested", "collapsed"] as const) { + test(`caller without paid opt-in: ${variant} makes zero metered sends`, async () => { + const f = fixture("metered", variant); + expect(await run(f)).toBe(200); + expect(f.consults()).toBe(1); + expect(f.calls.filter(call => call.startsWith("expert/"))).toEqual([]); + expect(f.calls).toContain("worker/m"); + }); +} +for (const variant of ["combo", "nested"] as const) { + test(`caller fallback disabled: ${variant} makes zero backup sends`, async () => { + const f = fixture("fallback", variant); + expect(await run(f)).toBe(200); + expect(f.consults()).toBe(1); + expect(f.calls).toContain("expert/primary"); + expect(f.calls).not.toContain("expert/backup"); + }); +} +for (const kind of ["metered", "fallback"] as const) { + test(`direct caller ${kind} control refuses the same destination`, async () => { + const f = fixture(kind, "combo"); + expect(await run(f, true)).toBeGreaterThanOrEqual(400); + expect(f.consults()).toBe(0); + expect(f.calls).not.toContain(kind === "metered" ? "expert/m" : "expert/backup"); + }); + for (const variant of ["combo", "nested", ...(kind === "metered" ? ["collapsed" as const] : [])] as const) { + test(`consenting caller ${kind} ${variant} control reaches the destination`, async () => { + const f = fixture(kind, variant, true); + expect(await run(f)).toBe(200); + expect(f.consults()).toBe(1); + expect(f.calls).toContain(kind === "metered" ? "expert/m" : "expert/backup"); + }); + } + for (const mutation of ["broadened", "deleted"] as const) { + test(`captured caller ${kind} limits survive ${mutation} live config`, async () => { + const f = fixture(kind, "combo"); + setAdvisorPlanFactory(f.config, async input => { + if (mutation === "deleted") f.config.apiKeys = f.config.apiKeys!.filter(key => key.id !== "caller"); + else f.config.apiKeys![1]!.allowMeteredComboTargets = true; + f.config.fallback = {}; + expect(Object.isFrozen(input.admissionPolicy)).toBe(true); + expect(Object.isFrozen(input.admissionPolicy!.fallbackPolicies[0]!.config.fallback!.devices!.caller)).toBe(true); + return createAdvisorRuntimePlan(input); + }); + expect(await run(f)).toBe(200); + expect(f.consults()).toBe(1); + expect(f.calls).not.toContain(kind === "metered" ? "expert/m" : "expert/backup"); + }); + } + test(`host ${kind} limits also apply to a consenting caller`, async () => { + const f = fixture(kind, "combo", true); + if (kind === "metered") f.config.apiKeys![0]!.allowMeteredComboTargets = false; + else f.config.fallback!.devices!.host = { model: false }; + expect(await run(f)).toBe(200); + expect(f.consults()).toBe(1); + expect(f.calls).not.toContain(kind === "metered" ? "expert/m" : "expert/backup"); + }); +} + +test("a made-up snapshot token with the process capability fails closed", async () => { + const f = fixture("metered", "combo", true); + const response = await handleChatCompletions(new Request("http://localhost/v1/chat/completions", { + method: "POST", headers: { + "content-type": "application/json", + [ADVISOR_INTERNAL_CAPABILITY_HEADER]: internalCallCapability(), + [ADVISOR_ADMISSION_POLICY_HEADER]: "A".repeat(43), + }, body: JSON.stringify({ model: "expert/m", messages: [{ role: "user", content: "fixture" }] }), + }), f.config, { model: "", provider: "" }); + expect(response.status).toBe(403); + expect(f.calls).toEqual([]); +}); + +test("a live snapshot token without the process capability grants no policy authority", async () => { + const f = fixture("metered", "combo", true); + const registration = registerAdvisorAdmissionPolicy(captureAdmissionPolicy(f.config, caller, { providers: ["worker"], models: [] })); + try { + const response = await handleChatCompletions(new Request("http://localhost/v1/chat/completions", { + method: "POST", headers: { "content-type": "application/json", [ADVISOR_ADMISSION_POLICY_HEADER]: registration.token }, + body: JSON.stringify({ model: "expert/m", messages: [{ role: "user", content: "fixture" }] }), + }), f.config, { model: "", provider: "" }); + await response.text(); + expect(response.status).toBe(200); + expect(f.calls).toEqual(["expert/m"]); + } finally { registration.release(); } +}); + +for (const failure of ["network", "cancelled"] as const) { + test(`the policy registry releases a ${failure} consultation`, async () => { + let token: string | null = null; + const controller = new AbortController(); + globalThis.fetch = (async (_input, init) => { + token = new Headers(init?.headers).get(ADVISOR_ADMISSION_POLICY_HEADER); + expect(readAdvisorAdmissionPolicy(token, internalCallCapability())).toBeDefined(); + if (failure === "cancelled") controller.abort(); + throw new Error("fixture connection failure"); + }) as typeof fetch; + const outcome = await consultAdvisor({ + parsed: parseRequest({ model: "worker/m", input: "fixture" }), + workerIdentity: "worker/m", advisorModel: "expert/m", reason: "manual", + }, {}, "high", 5_000, controller.signal, "http://localhost"); + expect(outcome.ok).toBe(false); + if (failure === "cancelled") expect(outcome.cancelled).toBe(true); + expect(token).toBeTruthy(); + expect(readAdvisorAdmissionPolicy(token, internalCallCapability())).toBeUndefined(); + }); +} + +test("scope intersection preserves qualified models and unnamed-destination refusals", () => { + const f = fixture("metered", "combo", true); + f.config.apiKeys![0]!.allowedProviders = ["expert"]; + f.config.apiKeys![0]!.allowedModels = ["m"]; + const policy = captureAdmissionPolicy(f.config, caller, { providers: [], models: ["free/m"] }); + const inherited = inheritAdmissionPolicy({ kind: "configured", keyId: "host", source: "bearer" }, policy); + const scope = resolveAdmissionModelScope(f.config, inherited); + expect(routeAllowedByScope(scope, { providerName: "expert", modelId: "m" })).toBe(false); + expect(routeAllowedByScope(scope, { providerName: "free", modelId: "m" })).toBe(false); + expect(nativeMessagesToolScopeDenial(scope, "expert", "expert/m", { + tools: [{ type: "advisor_20260101", name: "advisor", model: "m" }], + })).toBeDefined(); + expect(admissionScopeDenial(f.config, inherited, undefined, { providerName: "expert", modelId: undefined })?.status).toBe(403); + f.config.apiKeys![0]!.allowedModels = []; + expect(admissionScopeDenial(f.config, inherited, undefined, { providerName: "expert", modelId: undefined })?.status).toBe(403); +}); + +test("registry owns an immutable copy and releases only its consultation", () => { + const policy = { ...structuredClone(captureAdmissionPolicy({}, undefined)) }; + const controller = new AbortController(); + const first = registerAdvisorAdmissionPolicy(policy, controller.signal); + const second = registerAdvisorAdmissionPolicy(policy); + try { + policy.allowMeteredComboTargets = false; + const captured = readAdvisorAdmissionPolicy(first.token, internalCallCapability())!; + expect(captured.allowMeteredComboTargets).toBe(true); + expect(Object.isFrozen(captured)).toBe(true); + expect(Object.isFrozen(captured.fallbackPolicies)).toBe(true); + expect(first.token).not.toBe(second.token); + controller.abort(); + expect(readAdvisorAdmissionPolicy(first.token, internalCallCapability())).toBeUndefined(); + expect(readAdvisorAdmissionPolicy(second.token, internalCallCapability())).toBeDefined(); + first.release(); + expect(readAdvisorAdmissionPolicy(second.token, internalCallCapability())).toBeDefined(); + } finally { first.release(); second.release(); } + expect(readAdvisorAdmissionPolicy(second.token, internalCallCapability())).toBeUndefined(); +}); + +test("registry revokes an aborted consultation before fetch settles", async () => { + const controller = new AbortController(); + let token: string | null = null; + let finish!: (response: Response) => void; + globalThis.fetch = (async (_input, init) => { + token = new Headers(init?.headers).get(ADVISOR_ADMISSION_POLICY_HEADER); + return await new Promise(resolve => { finish = resolve; }); + }) as typeof fetch; + const pending = consultAdvisor({ + parsed: parseRequest({ model: "worker/m", input: "fixture" }), + workerIdentity: "worker/m", advisorModel: "expert/m", reason: "manual", + }, {}, "high", 5_000, controller.signal, "http://localhost"); + expect(readAdvisorAdmissionPolicy(token, internalCallCapability())).toBeDefined(); + controller.abort(); + expect(readAdvisorAdmissionPolicy(token, internalCallCapability())).toBeUndefined(); + finish(Response.json({ error: "fixture" }, { status: 503 })); + expect((await pending).ok).toBe(false); +}); + +test("unkeyed caller combo opt-in remains required behind a consenting host key", () => { + const f = fixture("metered", "combo", true); + const policy = captureAdmissionPolicy({}, { kind: "loopback", source: "loopback" }); + const inherited = inheritAdmissionPolicy({ kind: "configured", keyId: "host", source: "bearer" }, policy); + expect(admissionMayUseMeteredComboTargets(f.config, inherited, {})).toBe(false); + expect(admissionMayUseMeteredComboTargets(f.config, inherited, { allowMeteredTargets: false })).toBe(false); + expect(admissionMayUseMeteredComboTargets(f.config, inherited, { allowMeteredTargets: true })).toBe(true); + f.config.apiKeys![0]!.allowMeteredComboTargets = false; + expect(admissionMayUseMeteredComboTargets(f.config, inherited, { allowMeteredTargets: true })).toBe(false); +}); + +test("a live permissive policy token cannot grant an external caller paid consent", async () => { + const f = fixture("metered", "combo"); + const registration = registerAdvisorAdmissionPolicy(captureAdmissionPolicy({}, undefined)); + try { + const response = await handleChatCompletions(new Request("http://localhost/v1/chat/completions", { + method: "POST", headers: { + "content-type": "application/json", + [ADVISOR_INTERNAL_CAPABILITY_HEADER]: "A".repeat(43), + [ADVISOR_ADMISSION_POLICY_HEADER]: registration.token, + }, body: JSON.stringify({ model: "combo/inner", messages: [{ role: "user", content: "fixture" }] }), + }), f.config, { model: "", provider: "" }, { requestId: "forged-paid-policy", start: Date.now(), admission: caller }); + await response.text(); + expect(response.status).toBeGreaterThanOrEqual(400); + expect(f.calls).toEqual([]); + } finally { registration.release(); } +}); diff --git a/tests/advisor/advisor-scope-transport.test.ts b/tests/advisor/advisor-scope-transport.test.ts index a06053584b8..c345b110f97 100644 --- a/tests/advisor/advisor-scope-transport.test.ts +++ b/tests/advisor/advisor-scope-transport.test.ts @@ -256,7 +256,7 @@ describe("advisor admission-scope transport (end-to-end)", () => { expect(JSON.stringify(frames)).toContain("Following the advice"); }); - test("an unrestricted caller's consult is not narrowed by a restricted host key", async () => { + test("a restricted host key also narrows an unrestricted caller's consult", async () => { releaseSpendHome = acquireOwnedSpendHome(); const workerBodies: string[] = []; const expertCalls: string[] = []; @@ -285,12 +285,12 @@ describe("advisor admission-scope transport (end-to-end)", () => { const frames = await collectSse(response.body!); // The consult carries an explicit empty scope — defined but unrestricted — so the - // receiver keeps the capability fence yet does not re-resolve the restricted host key. + // receiver intersects it with the host key rather than replacing host limits. expect(consults).toHaveLength(1); expect(decodeAdvisorAdmissionScope(consults[0]!.scopeHeader)) .toEqual({ providers: [], models: [] }); - expect(consults[0]!.status).toBe(200); - expect(expertCalls).toHaveLength(1); + expect(consults[0]!.status).toBe(403); + expect(expertCalls).toHaveLength(0); expect(workerBodies.length).toBeGreaterThanOrEqual(2); expect(frames.some(frame => frame.event === "response.completed")).toBe(true); }); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index ed8f7650a8f..3da4d6372b0 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,15 @@ { "lmstudio-system-policy.test.ts": "config", "openai-chat-single-system.test.ts": "adapters/openai", "update-reclaim-authorizer.test.ts": "update", "update-capability-policy.test.ts": "update", "update-service-home.test.ts": "update", "update-service-home-policy.test.ts": "update", "update-home-recovery.test.ts": "update", "update-recovery-authority.test.ts": "update", "update-recovery-boundaries.test.ts": "update", "update-home-launcher.test.ts": "update", "update-home-worker.test.ts": "update", "update-mutation-boundaries.test.ts": "update", "update-mutation-guard.test.ts": "update", "update-guarded-ops.test.ts": "update", "update-effect-authority.test.ts": "update", "update-windows-wrapper-authority.test.ts": "update", "update-mutation-scanner.test.ts": "update", "refreshed-upstream-deadlines.test.ts": "responses", "local-upstream-deadlines.test.ts": "responses", "service-desktop-recovery.test.ts": "service", "provider-input-modalities.test.ts": "providers", "turn-membership.test.ts": "responses", "turn-membership-ingress.test.ts": "responses", "workbuddy-quota.test.ts": "providers", "workbuddy-login.test.ts": "oauth", "workbuddy-adapter.test.ts": "adapters", "workbuddy-provider.test.ts": "providers", "link-tunnel-history.test.ts": "clients", "link-readiness.test.ts": "clients", "link-diagnostics.test.ts": "clients", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "responses-phase-inference.test.ts": "responses", + "advisor-caller-policy.test.ts": "advisor", + "advisor-caller-policy-sidecar-authority.test.ts": "advisor", + "advisor-caller-policy-vision.test.ts": "advisor", + "advisor-caller-policy-replay.test.ts": "advisor", + "advisor-caller-policy-compact.test.ts": "advisor", + "advisor-caller-policy-ingress.test.ts": "advisor", + "advisor-caller-policy-http.test.ts": "advisor", + "advisor-caller-policy-jev.test.ts": "advisor", + "advisor-caller-policy-routing.test.ts": "advisor", + "advisor-caller-policy-host-key.test.ts": "advisor", + "advisor-caller-policy-self-fetch.test.ts": "advisor", "graceful-max-output-tokens.test.ts": "responses", "graceful-max-output-tokens-exclusions.test.ts": "responses", "windows-config-root-async-writers.test.ts": "windows", diff --git a/tests/responses/chat-native-combo.test.ts b/tests/responses/chat-native-combo.test.ts index 10291c3faf9..917775f4060 100644 --- a/tests/responses/chat-native-combo.test.ts +++ b/tests/responses/chat-native-combo.test.ts @@ -26,8 +26,11 @@ import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { ADVISOR_ADMISSION_SCOPE_HEADER, + ADVISOR_ADMISSION_POLICY_HEADER, + registerAdvisorAdmissionPolicy, encodeAdvisorAdmissionScope, } from "../../src/server/advisor-admission-scope"; +import { captureAdmissionPolicy } from "../../src/server/capture-admission-policy"; import { ADVISOR_INTERNAL_CAPABILITY_HEADER, internalCallCapability, @@ -47,6 +50,7 @@ let previousHome: string | undefined; let isolatedCodexHome: IsolatedCodexHome | null = null; let releaseSpendHome: (() => void) | undefined; const servers: Array> = []; +const policyRegistrations: Array> = []; beforeEach(() => { previousHome = process.env.OPENCODEX_HOME; @@ -64,6 +68,7 @@ beforeEach(() => { }); afterEach(async () => { + for (const registration of policyRegistrations.splice(0)) registration.release(); releaseSpendHome?.(); releaseSpendHome = undefined; for (const server of servers.splice(0)) await server.stop(true); @@ -537,10 +542,15 @@ describe("advisor-inherited admission scope on native combos", () => { return config; } - const advisorHeaders = (scope: { providers: string[]; models: string[] }) => ({ - [ADVISOR_INTERNAL_CAPABILITY_HEADER]: internalCallCapability(), - [ADVISOR_ADMISSION_SCOPE_HEADER]: encodeAdvisorAdmissionScope(scope), - }); + const advisorHeaders = (scope: { providers: string[]; models: string[] }) => { + const registration = registerAdvisorAdmissionPolicy(captureAdmissionPolicy({}, undefined, scope)); + policyRegistrations.push(registration); + return { + [ADVISOR_INTERNAL_CAPABILITY_HEADER]: internalCallCapability(), + [ADVISOR_ADMISSION_SCOPE_HEADER]: encodeAdvisorAdmissionScope(scope), + [ADVISOR_ADMISSION_POLICY_HEADER]: registration.token, + }; + }; test("a native-eligible failover target outside the inherited scope is never dispatched", async () => { const a = upstream(() => Response.json({ error: { message: "fixture outage" } }, { status: 503 })); diff --git a/tests/server/loopback-listener-admission.test.ts b/tests/server/loopback-listener-admission.test.ts index 438b79bfe73..ea3075085f7 100644 --- a/tests/server/loopback-listener-admission.test.ts +++ b/tests/server/loopback-listener-admission.test.ts @@ -76,7 +76,7 @@ describe("loopback listener policy view", () => { 'await handleClaudeCountTokens(req, config, policy, { claudeIntercept: ingress === "claude-intercept" })', ); expect(source.slice(messagesStart, chatStart)).toContain( - 'await handleClaudeMessages(sessionReq, config, logCtx, { requestId, start, turnAdmissionLease, admission }, policy, { claudeIntercept: ingress === "claude-intercept" })', + 'await handleClaudeMessages(sessionReq, config, logCtx, { requestId, start, turnAdmissionLease, admission, advisorAdmissionPolicy }, policy, { claudeIntercept: ingress === "claude-intercept" })', ); for (const branch of [ source.slice(countTokensStart, messagesStart), From 96e0e009e2c9134441ec01a3a60b01a364894199 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 29 Oct 2026 17:23:25 +0900 Subject: [PATCH 39/84] feat(clients): let the omo integration write the Responses dialect (#7890) * feat(clients): opt-in Responses dialect for the managed omo block omoClientExport.api selects openai-responses for the Pi-based omo provider block; export and enable/refresh read the same setting, the default stays openai-completions, and Pi-format clients are unchanged. Refs #7431 * test(omo): cover stateless Responses tool replay and clarify restart * test(omo): model the loop request from the exported reasoning capability The fixture model now carries a reasoning ladder, so the exporter marks it reasoning-capable; the test asserts that flag and the level map and only then sends reasoning/include, as pi-ai does. * test(omo): build the fixture bearer from a named synthetic key The literal Bearer string tripped the CI privacy scan's bearer-token rule. * docs(structure): document the omo export dialect contract Adds structure/clients/omo-export-dialect.md for omoClientExport.api: omo-only scope, the Chat fallback for absent or malformed values, and the export/refresh lifecycle. Registered in the manifest; INDEX.md regenerated. * docs(structure): map the root config schema to the omo dialect contract * docs(omo): name the manual restart commands Replace 'stop and start ocx start' with 'ocx stop, then ocx start' in the server reference and the integrations guide. * docs(omo): add the Responses dialect opt-in to the ja, ko, ru and zh-cn integration guides --------- Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> --- .../src/content/docs/guides/integrations.md | 18 ++ .../content/docs/ja/guides/integrations.md | 10 + .../content/docs/ko/guides/integrations.md | 10 + .../docs/reference/configuration/server.md | 2 + .../content/docs/ru/guides/integrations.md | 10 + .../content/docs/zh-cn/guides/integrations.md | 10 + scripts/test-layout/layout-clients.json | 1 + src/clients/config-export.ts | 17 +- src/config/schema/config-schema.ts | 2 + src/config/schema/leaf-validators.ts | 5 + src/types/config.ts | 2 + structure/INDEX.md | 9 +- structure/clients/omo-export-dialect.md | 26 +++ structure/manifest.json | 7 + tests/clients/integrations-writer.test.ts | 27 +++ tests/clients/omo-client.test.ts | 20 ++ tests/clients/omo-responses-loop.test.ts | 183 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 2 +- 18 files changed, 353 insertions(+), 8 deletions(-) create mode 100644 structure/clients/omo-export-dialect.md create mode 100644 tests/clients/omo-responses-loop.test.ts diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index b478bae8d9b..d76818a097c 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -69,6 +69,24 @@ This integration retains Pi’s loopback-only export policy and stores no real A [omo (Codex / LazyCodex) role models](#omo-codex--lazycodex-role-models). OpenCode-based omo (oh-my-opencode) keeps its own config under OpenCode; opencodex does not read or write it. +The managed Pi-based omo block uses the Chat dialect (`api: "openai-completions"`) by default. Chat +drops the previous step's reasoning across tool calls, which long omo tool loops notice. To have +enable and every refresh write the Responses dialect instead, set this config-file-only key in +your opencodex config: + +```json +{ "omoClientExport": { "api": "openai-responses" } } +``` + +Restart the proxy to load the edited config (`ocx service restart` for a service install, or `ocx stop`, then `ocx start`), then refresh the omo integration from **Connect → omo** in the dashboard. +Refreshing alone uses the running proxy's config and does not reload the config file. + +The setting applies only to omo; Pi, Gentle Shell and the other Pi-format clients keep +`openai-completions`. To return to Chat, remove the key, restart the proxy in the same way, then +refresh **Connect → omo**. An unknown value is ignored. +opencodex still owns only `providers.opencodex` in `~/.omo/agent/models.json`, so other providers +in that file are left as they are. + ### Qoder CN and WorkBuddy editions In **Connect**, enable **Qoder CN**, **WorkBuddy (CN)** or **WorkBuddy AI**, then choose diff --git a/docs-site/src/content/docs/ja/guides/integrations.md b/docs-site/src/content/docs/ja/guides/integrations.md index 8017713dde2..78a542c29b8 100644 --- a/docs-site/src/content/docs/ja/guides/integrations.md +++ b/docs-site/src/content/docs/ja/guides/integrations.md @@ -223,6 +223,16 @@ Undo は、もともと存在しなかったファイルも含め、**元の両 ダウンロードされる `cline-config-bundle.json` には、`providers.json` 用の `settings` と `models.json` 用の `catalog` という 2 つのネイティブ文書要素が含まれます。それ自体は Cline の設定ファイルではありません。ジャーナル付きのマージとロールバックには統合コマンドを使ってください。生成された統合はリモートの受け入れ認証に対応せず、認証不要のループバックアクセスが必要です。 +## omo (Pi ベース) の Responses 方言 + +管理対象の Pi ベース omo ブロックは、既定で Chat 方言 (`api: "openai-completions"`) を使います。Chat はツール呼び出しをまたいで前のステップの推論を落とすため、長い omo のツールループで影響が出ます。有効化とすべての更新で Responses 方言を書き込むには、opencodex の設定ファイルにだけ設定できる次のキーを追加します。 + +```json +{ "omoClientExport": { "api": "openai-responses" } } +``` + +編集した設定を読み込むためにプロキシを再起動し (サービスとしてインストールした場合は `ocx service restart`、それ以外は `ocx stop` の後に `ocx start`)、ダッシュボードの **Connect → omo** から omo 統合を更新してください。更新だけでは実行中のプロキシの設定を使い、設定ファイルは読み直しません。この設定は omo にだけ適用され、Pi、Gentle Shell などほかの Pi 形式のクライアントは `openai-completions` のままです。Chat に戻すには、キーを削除して同じ手順で再起動し、**Connect → omo** を更新します。不明な値は無視されます。opencodex が所有するのは引き続き `~/.omo/agent/models.json` の `providers.opencodex` だけで、ほかのプロバイダーはそのまま残ります。 + ## omo (Codex / LazyCodex) のロール別推論負荷 LazyCodex が検出されると、omo タブの **omo (Codex / LazyCodex)** セクションで、`$CODEX_HOME/agents/*.toml` にある各ロールのモデルと推論負荷を選んで **保存** できます。モデルが固定されていないロールの行では推論負荷だけを保存でき、そのロールは引き続きモデルを継承します。opencodex はそのロールファイルのルートにある `model` と `model_reasoning_effort` のキーだけを更新し、存在しない固定値はファイルの先頭付近に追加します。ロールの指示、ほかのコメントやキーはそのまま残ります。モデルだけを変えて保存すると `effort` を送らないため、両方のファイルの推論負荷は変わりません。段階を選ぶとロールファイルの `model_reasoning_effort` を追加または置き換え、LazyCodex がその段階に対応していれば `~/.omo/omo.jsonc` の `[codex].agents..reasoning` にも書き込みます(`ultra` はロールファイルにだけ残り、以前の `reasoning` は削除されます)。コマンドラインでは `ocx agent roles set --effort ` で同じ操作ができます。 diff --git a/docs-site/src/content/docs/ko/guides/integrations.md b/docs-site/src/content/docs/ko/guides/integrations.md index d017176dd71..ba1a77a7ea9 100644 --- a/docs-site/src/content/docs/ko/guides/integrations.md +++ b/docs-site/src/content/docs/ko/guides/integrations.md @@ -233,6 +233,16 @@ Undo는 원래 없던 파일까지 포함해 **두 원본 바이트 문자열 다운로드되는 `cline-config-bundle.json`에는 두 네이티브 문서 구성 요소가 있습니다. `providers.json`용 `settings`와 `models.json`용 `catalog`입니다. 번들 자체가 Cline 설정 파일은 아닙니다. 저널을 남기는 병합과 롤백에는 연동 명령을 권장합니다. 생성된 연동은 원격 수용 연결을 지원하지 않으며 인증이 없는 루프백 접근이 필요합니다. +## omo (Pi 기반) Responses 방언 + +관리되는 Pi 기반 omo 블록은 기본으로 Chat 방언(`api: "openai-completions"`)을 씁니다. Chat은 도구 호출 사이에서 이전 단계의 추론을 버리므로 긴 omo 도구 루프에서 차이가 납니다. 연결과 모든 갱신에서 Responses 방언을 쓰게 하려면 opencodex 설정 파일에만 넣을 수 있는 다음 키를 추가하세요. + +```json +{ "omoClientExport": { "api": "openai-responses" } } +``` + +수정한 설정을 읽도록 프록시를 다시 시작한 뒤(서비스로 설치했다면 `ocx service restart`, 아니면 `ocx stop` 다음 `ocx start`), 대시보드의 **Connect → omo**에서 omo 통합을 갱신하세요. 갱신만 하면 실행 중인 프록시의 설정을 쓰므로 설정 파일을 다시 읽지 않습니다. 이 설정은 omo에만 적용되며 Pi, Gentle Shell 등 다른 Pi 형식 클라이언트는 `openai-completions`를 유지합니다. Chat으로 되돌리려면 키를 지우고 같은 방법으로 다시 시작한 뒤 **Connect → omo**를 갱신하세요. 알 수 없는 값은 무시됩니다. opencodex는 여전히 `~/.omo/agent/models.json`의 `providers.opencodex`만 관리하며 다른 프로바이더는 그대로 둡니다. + ## omo (Codex / LazyCodex) 역할별 추론 강도 LazyCodex가 감지되면 omo 탭의 **omo (Codex / LazyCodex)** 섹션에서 `$CODEX_HOME/agents/*.toml`에 있는 역할마다 모델과 추론 강도를 고른 뒤 **저장**을 누를 수 있습니다. 모델 고정이 없는 역할의 행은 강도만 저장할 수 있고, 그 역할은 계속 모델을 상속합니다. opencodex는 그 역할 파일의 루트 `model`과 `model_reasoning_effort` 키만 고치고, 없는 고정값은 파일 위쪽에 추가합니다. 역할의 지침, 다른 주석과 다른 키는 그대로 둡니다. 모델만 바꿔 저장하면 `effort`를 보내지 않으므로 두 파일의 추론 강도가 그대로 유지됩니다. 단계를 고르면 역할 파일의 `model_reasoning_effort`를 추가하거나 바꾸고, LazyCodex가 그 단계를 지원하면 `~/.omo/omo.jsonc`의 `[codex].agents..reasoning`에도 씁니다(`ultra`는 역할 파일에만 남고, 이전 `reasoning`은 제거합니다). 명령줄에서는 `ocx agent roles set --effort `로 같은 작업을 합니다. diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 1c2cf8c9a7a..bff693deae6 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -1212,3 +1212,5 @@ Locality is a timeout policy, not network-access authorization. Classification u Disabling the header clock preserves client cancellation and any enclosing overall deadline. An explicitly positive `stallTimeoutSec` still bounds local body silence; unset local stall/header clocks provide no automatic bound before headers. Native Chat/Messages local JSON reads use the local stall budget; public JSON reads retain their previous `max(connectTimeoutMs ?? 200000, 5000)` budget. Compact body reads retain their 300-second default even locally. Image/search sidecar budgets are unchanged. Optional `opencodeClientExport` controls the generated OpenCode provider's `headerTimeoutMs`, `chunkTimeoutMs`, and `timeoutMs` (positive milliseconds or `false` to disable). Unset values preserve OpenCode defaults; malformed fields are omitted. These provider-wide settings are emitted into V1 options and V2 settings. See [slow local models](/guides/opencode/#slow-local-models-prefill-timeouts). + +Optional, config-file-only `omoClientExport.api` selects the dialect of the managed Pi-based omo provider block: `openai-completions` (default) or `openai-responses`. Other values are ignored. Only omo reads it. After adding, changing or removing this key, restart the proxy (`ocx service restart` for a service install, or `ocx stop`, then `ocx start`), then refresh **Connect → omo** in the dashboard. Refresh uses the running proxy's config; it does not reload the file. See [integrations](/guides/integrations/). diff --git a/docs-site/src/content/docs/ru/guides/integrations.md b/docs-site/src/content/docs/ru/guides/integrations.md index 22a006254e1..47299f9e003 100644 --- a/docs-site/src/content/docs/ru/guides/integrations.md +++ b/docs-site/src/content/docs/ru/guides/integrations.md @@ -557,6 +557,16 @@ OpenCodex требует явного `--overwrite-conflict`. Отключени поддерживает удалённую настройку допуска и требует loopback-доступа без аутентификации. +## Диалект Responses для omo (на базе Pi) + +Управляемый блок omo на базе Pi по умолчанию использует диалект Chat (`api: "openai-completions"`). Chat отбрасывает рассуждения предыдущего шага между вызовами инструментов, и в длинных циклах инструментов omo это заметно. Чтобы подключение и каждое обновление записывали диалект Responses, добавьте в конфигурацию opencodex этот ключ, который задаётся только в файле конфигурации: + +```json +{ "omoClientExport": { "api": "openai-responses" } } +``` + +Перезапустите прокси, чтобы он загрузил изменённую конфигурацию (`ocx service restart` для установки как службы, иначе `ocx stop`, затем `ocx start`), и обновите интеграцию omo в **Connect → omo** на дашборде. Одно обновление использует конфигурацию запущенного прокси и не перечитывает файл. Настройка действует только для omo: Pi, Gentle Shell и другие клиенты формата Pi остаются на `openai-completions`. Чтобы вернуться к Chat, удалите ключ, так же перезапустите прокси и обновите **Connect → omo**. Неизвестное значение игнорируется. opencodex по-прежнему владеет только `providers.opencodex` в `~/.omo/agent/models.json`, остальные провайдеры в этом файле не меняются. + ## Уровень рассуждений ролей omo (Codex / LazyCodex) Когда обнаружен LazyCodex, в разделе **omo (Codex / LazyCodex)** на вкладке omo можно выбрать модель и уровень рассуждений для каждой роли из `$CODEX_HOME/agents/*.toml` и нажать **Сохранить**. Строка роли без закреплённой модели может сохранить только уровень; роль при этом продолжает наследовать модель. opencodex меняет в файле роли только корневые ключи `model` и `model_reasoning_effort`, а отсутствующие значения добавляет ближе к началу файла. Инструкции роли, остальные комментарии и ключи остаются без изменений. Сохранение, которое меняет только модель, не отправляет `effort`, поэтому оба значения уровня сохраняются. Выбор уровня добавляет или заменяет `model_reasoning_effort` в файле роли и, если LazyCodex поддерживает этот уровень, записывает его в `[codex].agents..reasoning` в `~/.omo/omo.jsonc` (`ultra` остаётся только в файле роли, а прежний `reasoning` удаляется). В командной строке то же самое делает `ocx agent roles set --effort `. diff --git a/docs-site/src/content/docs/zh-cn/guides/integrations.md b/docs-site/src/content/docs/zh-cn/guides/integrations.md index f40a9a00ddd..41c8042427e 100644 --- a/docs-site/src/content/docs/zh-cn/guides/integrations.md +++ b/docs-site/src/content/docs/zh-cn/guides/integrations.md @@ -221,6 +221,16 @@ Undo 会恢复**两个原始字节串**,包括原本不存在的文件。操 下载文件 `cline-config-bundle.json` 包含两个原生文档成员:对应 `providers.json` 的 `settings`,以及对应 `models.json` 的 `catalog`。它本身不是 Cline 设置文件。建议使用集成命令,以获得带日志的合并和回滚。此生成集成不支持远程准入接线,需要免认证的回环访问。 +## omo(基于 Pi)的 Responses 方言 + +托管的基于 Pi 的 omo 配置块默认使用 Chat 方言(`api: "openai-completions"`)。Chat 会在工具调用之间丢弃上一步的推理,较长的 omo 工具循环会受到影响。要让启用和每次刷新都写入 Responses 方言,请在 opencodex 配置中添加以下仅能在配置文件中设置的键: + +```json +{ "omoClientExport": { "api": "openai-responses" } } +``` + +重启代理以加载修改后的配置(服务安装使用 `ocx service restart`,否则先 `ocx stop` 再 `ocx start`),然后在仪表盘的 **Connect → omo** 中刷新 omo 集成。仅刷新会使用运行中代理的配置,不会重新读取配置文件。该设置只对 omo 生效;Pi、Gentle Shell 等其他 Pi 格式客户端仍使用 `openai-completions`。要改回 Chat,请删除该键,按同样方式重启代理,再刷新 **Connect → omo**。未知值会被忽略。opencodex 仍只管理 `~/.omo/agent/models.json` 中的 `providers.opencodex`,该文件中的其他提供商保持不变。 + ## omo (Codex / LazyCodex) 角色推理强度 检测到 LazyCodex 后,可以在 omo 标签页的 **omo (Codex / LazyCodex)** 部分为 `$CODEX_HOME/agents/*.toml` 中的每个角色选择模型和推理强度,然后点击 **保存**。没有固定模型的角色所在行可以只保存推理强度,该角色仍继承其模型。opencodex 只更新该角色文件根级的 `model` 和 `model_reasoning_effort` 键,缺少的固定值会插入到文件开头附近;角色的指令、其他注释和其他键保持不变。只更改模型的保存不会发送 `effort`,两个文件中的推理强度都保持原样。选择某个级别会插入或替换角色文件中的 `model_reasoning_effort`;如果 LazyCodex 支持该级别,还会写入 `~/.omo/omo.jsonc` 的 `[codex].agents..reasoning`(`ultra` 只保留在角色文件中,并移除旧的 `reasoning`)。在命令行中可以用 `ocx agent roles set --effort ` 完成同样的操作。 diff --git a/scripts/test-layout/layout-clients.json b/scripts/test-layout/layout-clients.json index 309ea779c10..fa50b9af986 100644 --- a/scripts/test-layout/layout-clients.json +++ b/scripts/test-layout/layout-clients.json @@ -1,4 +1,5 @@ { + "omo-responses-loop.test.ts": "clients", "claude-translated-tool-restrictions.test.ts": "claude-integration", "windows-config-root-async-writers.test.ts": "windows", "t3code-client-lifecycle.test.ts": "clients", diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts index 91a164dec59..a7ea64125be 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -906,6 +906,8 @@ interface PiExportOptions { * states it rather than leaving each user to hand-edit a block the next export rewrites. */ foldDeveloperRole?: boolean; + /** Provider dialect; omitted writes `PI_API_DIALECT`. */ + api?: "openai-completions" | "openai-responses"; } export interface PiGeneratedConfig { @@ -1085,7 +1087,7 @@ function buildPiClientConfig(ctx: ExportContext, options: PiExportOptions = {}): providers: { [OPENCODE_PROVIDER_ID]: { baseUrl: ctx.baseUrl, - api: PI_API_DIALECT, + api: options.api ?? PI_API_DIALECT, apiKey: LOOPBACK_API_KEY_PLACEHOLDER, ...(Object.keys(compat).length > 0 ? { compat } : {}), models, @@ -1391,12 +1393,21 @@ function buildAsideContribution(ctx: ExportContext): ManagedContribution { * are consistent; passing it on only one would make `ocx export --client omo` * emit a `compat` block while enable and refresh wrote a file without it, and * the two would drift apart at the first refresh. + * + * The same rule covers the opt-in `omoClientExport.api` dialect (#7431): both paths read it from + * the live config, so a refresh keeps the Responses dialect instead of reverting it to Chat. */ function buildOmoContribution(ctx: ExportContext): ManagedContribution { - const doc = buildPiClientConfig(ctx, PI_EXPORT_OPTIONS); + const doc = buildPiClientConfig(ctx, omoExportOptions(ctx)); return singleFragment("omo", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]); } +function omoExportOptions(ctx: ExportContext): PiExportOptions { + return ctx.config?.omoClientExport?.api === "openai-responses" + ? { ...PI_EXPORT_OPTIONS, api: "openai-responses" } + : PI_EXPORT_OPTIONS; +} + /** Cline's shared SDK store; command-local --config must be mirrored by the env override. */ export function clineConfigPath(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string { const explicit = env.CLINE_PROVIDER_SETTINGS_PATH?.trim(); @@ -1652,7 +1663,7 @@ export const EXPORT_CLIENTS: Record = { destination: env => omoConfigPath(env), apiKeyEnv: "", exportHint: "omo reads a non-secret placeholder from models.json; loopback needs no key.", - build: ctx => buildPiClientConfig(ctx, PI_EXPORT_OPTIONS), + build: ctx => buildPiClientConfig(ctx, omoExportOptions(ctx)), format: "json", summarize: summarizePi, buildContribution: buildOmoContribution, diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index 4b5353c68d0..2bacde053c6 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -33,6 +33,7 @@ import { asideProfileSyncSchema, clientIntegrationsSchema, opencodeClientExportSchema, + omoClientExportSchema, CODEX_ACCOUNT_NAMESPACE_ACCOUNT_ID_COLLISION_ERROR, codexAccountNamespacesSchema, modelPinnedEffortsSchema, @@ -282,6 +283,7 @@ export const configSchema = z.object({ // Opt-in opencode-side export timeouts; a malformed block degrades to undefined so opencode // keeps its native waits rather than failing the parse and hiding valid providers/accounts. opencodeClientExport: opencodeClientExportSchema.optional().catch(undefined), + omoClientExport: omoClientExportSchema.optional().catch(undefined), // A malformed profile policy must not fall back to legacy all-profile activation. asideProfileSync: asideProfileSyncSchema.optional().catch({ allProfiles: false }), providerContextCaps: z.record(z.string(), z.number().int().positive()).optional(), diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts index 87e27fbcba9..245222ad08e 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -865,6 +865,11 @@ export const opencodeClientExportSchema = z.object({ timeoutMs: opencodeClientExportTimeout.optional().catch(undefined), }).passthrough(); +/** omo dialect: an unknown value is dropped so the export falls back to the Chat default. */ +export const omoClientExportSchema = z.object({ + api: z.enum(["openai-completions", "openai-responses"]).optional().catch(undefined), +}).passthrough(); + export const asideProfileSyncSchema = z.object({ allProfiles: z.boolean().optional(), profiles: z.record( diff --git a/src/types/config.ts b/src/types/config.ts index ec3da2f025a..b3ca6bd575c 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -716,6 +716,8 @@ export interface OcxConfig { * See {@link OcxOpencodeClientExportConfig}. */ opencodeClientExport?: OcxOpencodeClientExportConfig; + /** Opt-in dialect for the managed Pi-based omo block; omitted keeps `openai-completions` (#7431). */ + omoClientExport?: { api?: "openai-completions" | "openai-responses" }; /** Aside account-backed profile synchronization; individual overrides survive bulk refresh. */ asideProfileSync?: { allProfiles?: boolean; diff --git a/structure/INDEX.md b/structure/INDEX.md index 8d715846755..457990d8f35 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -125,6 +125,7 @@ The dashboard, the management API, and third-party client config ownership. | [`quota-pool.md`](quota-pool.md) | The passive GET /api/quota-pool read GOAT consumes (ocx.quota-pool/1): sources, window mapping, absent-not-zero, and the install identity behind homeId and accountRef. | | [`pricing.md`](pricing.md) | User price policies, prompt-length rate selection, persistence and estimate cache invalidation. | | [`clients/export-model-names.md`](clients/export-model-names.md) | Per-connection naming policy, router-proven aliases and passive export previews. | +| [`clients/omo-export-dialect.md`](clients/omo-export-dialect.md) | Opt-in Responses dialect for the managed Pi-based omo export and its refresh lifecycle. | | [`clients/integrations.md`](clients/integrations.md) | Third-party client config ownership, snapshots, refresh, disable, and restore. | | [`clients/codex-desktop.md`](clients/codex-desktop.md) | Windows package activation, restart context, and protected compatibility authority persistence. | | [`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md) | Experimental macOS app-server stdout shim, opt-in launch, restore and failure boundaries. | @@ -246,7 +247,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/client/link-parity-reporter.ts` | [`remote-link.md`](remote-link.md) | | `src/client/runtime-restart.ts` | [`remote-link.md`](remote-link.md) | | `src/clients/` | [`clients/integrations.md`](clients/integrations.md)
[`clients/qoder.md`](clients/qoder.md)
[`clients/client-support-matrix.md`](clients/client-support-matrix.md)
[`clients/t3code.md`](clients/t3code.md) | -| `src/clients/config-export.ts` | [`transports/timeouts.md`](transports/timeouts.md)
[`clients/export-model-names.md`](clients/export-model-names.md) | +| `src/clients/config-export.ts` | [`transports/timeouts.md`](transports/timeouts.md)
[`clients/export-model-names.md`](clients/export-model-names.md)
[`clients/omo-export-dialect.md`](clients/omo-export-dialect.md) | | `src/clients/config-export/` | [`clients/export-model-names.md`](clients/export-model-names.md) | | `src/codex/` | [`runtime.md`](runtime.md)
[`config.md`](config.md)
[`codex-home.md`](codex-home.md)
[`codex-prompts.md`](codex-prompts.md)
[`catalog.md`](catalog.md)
[`subagents.md`](subagents.md)
[`subagent-capacity.md`](subagent-capacity.md)
[`transports/responses-failover.md`](transports/responses-failover.md)
[`providers/openai-tiers.md`](providers/openai-tiers.md)
[`providers/openai-accounts.md`](providers/openai-accounts.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`fallback-policy.md`](fallback-policy.md)
[`clients/codex-desktop.md`](clients/codex-desktop.md)
[`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `src/codex/account-pause.ts` | [`account-auto-pause.md`](account-auto-pause.md) | @@ -300,8 +301,8 @@ A source area can be described by more than one doc, because these docs are orga | `src/config/live-reconcile.ts` | [`pricing.md`](pricing.md) | | `src/config/persisted-mutation.ts` | [`runtime.md`](runtime.md) | | `src/config/schema/account-auto-pause.ts` | [`account-auto-pause.md`](account-auto-pause.md) | -| `src/config/schema/config-schema.ts` | [`transports/max-output-completion.md`](transports/max-output-completion.md) | -| `src/config/schema/leaf-validators.ts` | [`transports/timeouts.md`](transports/timeouts.md)
[`pricing.md`](pricing.md) | +| `src/config/schema/config-schema.ts` | [`transports/max-output-completion.md`](transports/max-output-completion.md)
[`clients/omo-export-dialect.md`](clients/omo-export-dialect.md) | +| `src/config/schema/leaf-validators.ts` | [`transports/timeouts.md`](transports/timeouts.md)
[`pricing.md`](pricing.md)
[`clients/omo-export-dialect.md`](clients/omo-export-dialect.md) | | `src/generated/` | [`runtime.md`](runtime.md) | | `src/github/` | [`runtime.md`](runtime.md) | | `src/grok/` | [`runtime.md`](runtime.md) | @@ -422,7 +423,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/types/` | [`providers/generic-account-pool.md`](providers/generic-account-pool.md)
[`transports/native-reasoning-retention.md`](transports/native-reasoning-retention.md)
[`providers/anthropic-progress.md`](providers/anthropic-progress.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/lm-studio-chat.md`](providers/lm-studio-chat.md)
[`fallback-policy.md`](fallback-policy.md) | | `src/types/account-auto-pause.ts` | [`account-auto-pause.md`](account-auto-pause.md) | | `src/types/admission-policy.ts` | [`advisor.md`](advisor.md) | -| `src/types/config.ts` | [`transports/timeouts.md`](transports/timeouts.md)
[`config.md`](config.md)
[`transports/max-output-completion.md`](transports/max-output-completion.md) | +| `src/types/config.ts` | [`transports/timeouts.md`](transports/timeouts.md)
[`config.md`](config.md)
[`transports/max-output-completion.md`](transports/max-output-completion.md)
[`clients/omo-export-dialect.md`](clients/omo-export-dialect.md) | | `src/types/provider.ts` | [`pricing.md`](pricing.md) | | `src/update/` | [`runtime.md`](runtime.md)
[`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | | `src/update/guarded-port-reclaim.ts` | [`ops/update-supervised-recovery.md`](ops/update-supervised-recovery.md) | diff --git a/structure/clients/omo-export-dialect.md b/structure/clients/omo-export-dialect.md new file mode 100644 index 00000000000..c0692867a3d --- /dev/null +++ b/structure/clients/omo-export-dialect.md @@ -0,0 +1,26 @@ +# omo Client Export Dialect + +## Scope and policy + +`omoClientExport.api` (`OcxConfig.omoClientExport` in `src/types/config.ts`, validated by +`omoClientExportSchema` in `src/config/schema/leaf-validators.ts`) is a config-file-only opt-in for +the managed Pi-based omo block described under +[omo variants](integrations.md#omo-variants). `"openai-responses"` makes `buildPiClientConfig` write +`api: "openai-responses"` for omo. An absent, unknown or malformed value degrades to undefined and +keeps `PI_API_DIALECT` (`openai-completions`), so a bad value never fails the config parse. The +top-level `omoClientExport` field in `src/config/schema/config-schema.ts` adds the outer fallback: a +malformed block (not an object) is dropped as a whole with `.optional().catch(undefined)`. + +The scope is omo only. Pi, Gentle Shell and the other Pi-format clients keep `PI_EXPORT_OPTIONS`. + +## Export and refresh + +`ocx export --client omo` and the managed contribution used by enable and refresh both derive their +options from the live config through `omoExportOptions()` in `src/clients/config-export.ts`, so a +refresh cannot revert the dialect to Chat. The value applies once the proxy has loaded the edited +config and the omo integration is refreshed; a refresh alone does not reload the config file. +Ownership is unchanged: only `providers.opencodex` in `models.json`. + +`tests/clients/omo-client.test.ts`, `tests/clients/integrations-writer.test.ts` and +`tests/clients/omo-responses-loop.test.ts` cover the export, the refresh path and a Responses tool +loop. diff --git a/structure/manifest.json b/structure/manifest.json index e1858e00a40..5ee1c6ebb0d 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -931,6 +931,13 @@ "scope": "Per-connection naming policy, router-proven aliases and passive export previews.", "documents": ["src/clients/config-export.ts", "src/clients/config-export/", "src/integrations/", "src/cli/", "src/server/management/"] }, + { + "path": "clients/omo-export-dialect.md", + "tier": 5, + "title": "omo Client Export Dialect", + "scope": "Opt-in Responses dialect for the managed Pi-based omo export and its refresh lifecycle.", + "documents": ["src/clients/config-export.ts", "src/types/config.ts", "src/config/schema/leaf-validators.ts", "src/config/schema/config-schema.ts"] + }, { "path": "clients/integrations.md", "tier": 5, diff --git a/tests/clients/integrations-writer.test.ts b/tests/clients/integrations-writer.test.ts index 4a9ed11a1dc..e2d96f06a8e 100644 --- a/tests/clients/integrations-writer.test.ts +++ b/tests/clients/integrations-writer.test.ts @@ -15,6 +15,7 @@ import { applyIntegration, disableIntegration, overwriteIntegration, + refreshIntegration, restoreIntegration, type IntegrationWriteInput, } from "../../src/integrations/writer"; @@ -1632,3 +1633,29 @@ describe("the managed file size bound", () => { expect(readIntegrationState(input({ clientId: "pi" })).state).toBe("absent"); }); }); + +describe("omo dialect opt-in (#7431)", () => { + const RESPONSES = { ...CONFIG, omoClientExport: { api: "openai-responses" } } as OcxConfig; + const FOREIGN = { baseUrl: "http://elsewhere/v1", api: "anthropic-messages", apiKey: "OTHER_KEY", models: [] }; + + test("refresh keeps the Responses dialect and leaves foreign provider rows alone", () => { + const configPath = installOmo(); + writeFileSync(configPath, JSON.stringify({ providers: { other: FOREIGN } }, null, 2) + "\n"); + expect(applyIntegration(input({ clientId: "omo", config: RESPONSES })).ok).toBe(true); + const refreshed = refreshIntegration(input({ clientId: "omo", config: RESPONSES })); + expect(refreshed.ok).toBe(true); + const written = JSON.parse(readFileSync(configPath, "utf8")); + expect(written.providers.opencodex.api).toBe("openai-responses"); + expect(written.providers.other).toEqual(FOREIGN); + }); + + test("dropping the opt-in writes Chat again on the next refresh, and disable keeps foreign rows", () => { + const configPath = installOmo(); + writeFileSync(configPath, JSON.stringify({ providers: { other: FOREIGN } }, null, 2) + "\n"); + expect(applyIntegration(input({ clientId: "omo", config: RESPONSES })).ok).toBe(true); + expect(refreshIntegration(input({ clientId: "omo" })).ok).toBe(true); + expect(JSON.parse(readFileSync(configPath, "utf8")).providers.opencodex.api).toBe("openai-completions"); + expect(disableIntegration(input({ clientId: "omo" })).ok).toBe(true); + expect(JSON.parse(readFileSync(configPath, "utf8")).providers).toEqual({ other: FOREIGN }); + }); +}); diff --git a/tests/clients/omo-client.test.ts b/tests/clients/omo-client.test.ts index 158d2bca46a..062706c36d6 100644 --- a/tests/clients/omo-client.test.ts +++ b/tests/clients/omo-client.test.ts @@ -15,6 +15,7 @@ import { type PiGeneratedConfig, } from "../../src/clients/config-export"; import { INTEGRATION_CLIENTS } from "../../src/integrations/registry"; +import { configSchema } from "../../src/config/schema/config-schema"; import type { OcxConfig } from "../../src/types"; const CONFIG = { @@ -38,6 +39,25 @@ function context(): ExportContext { } describe("omo client config", () => { + test("defaults to the Chat dialect and opts into Responses on both paths without touching Pi (#7431)", () => { + expect((buildClientConfig("omo", context()) as PiGeneratedConfig).providers[OPENCODE_PROVIDER_ID]!.api).toBe("openai-completions"); + const optedIn = { ...context(), config: { ...CONFIG, omoClientExport: { api: "openai-responses" } } as OcxConfig }; + const document = buildClientConfig("omo", optedIn) as PiGeneratedConfig; + expect(document.providers[OPENCODE_PROVIDER_ID]!.api).toBe("openai-responses"); + expect(document.providers[OPENCODE_PROVIDER_ID]!.compat).toEqual({ sendSessionAffinityHeaders: true, supportsDeveloperRole: false }); + expect(buildClientContribution("omo", optedIn).fragments[0]!.value).toEqual(document.providers[OPENCODE_PROVIDER_ID]); + expect((buildClientConfig("pi", optedIn) as PiGeneratedConfig).providers[OPENCODE_PROVIDER_ID]!.api).toBe("openai-completions"); + }); + + test("an unknown omo dialect is dropped by the config schema, leaving the default", () => { + const providers = { mock: { adapter: "openai-chat", baseUrl: "https://example.test/v1" } }; + const parsed = configSchema.safeParse({ ...CONFIG, providers, omoClientExport: { api: "openai-realtime" } }); + expect(parsed.success).toBe(true); + if (parsed.success) expect((parsed.data as OcxConfig).omoClientExport?.api).toBeUndefined(); + const kept = configSchema.safeParse({ ...CONFIG, providers, omoClientExport: { api: "openai-responses" } }); + expect(kept.success && (kept.data as OcxConfig).omoClientExport?.api).toBe("openai-responses"); + }); + /** * The opposite of Prime's assertion, deliberately. * diff --git a/tests/clients/omo-responses-loop.test.ts b/tests/clients/omo-responses-loop.test.ts new file mode 100644 index 00000000000..fb151e0325b --- /dev/null +++ b/tests/clients/omo-responses-loop.test.ts @@ -0,0 +1,183 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + buildClientConfig, LOOPBACK_API_KEY_PLACEHOLDER, OPENCODE_PROVIDER_ID, type PiGeneratedConfig, +} from "../../src/clients/config-export"; +import { saveConfig } from "../../src/config"; +import { startServer } from "../../src/server"; +import { drainAndShutdown } from "../../src/server/lifecycle"; +import type { OcxConfig } from "../../src/types"; +import { installIsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { streamFromText } from "../helpers/sse-stream"; + +/** Synthetic upstream credential for the fake upstream; never a real key. */ +const UPSTREAM_FIXTURE_KEY = "omo-upstream-fixture-key"; + +/** + * Wire contract: @mariozechner/pi-ai 0.57.1, a9cedccdde77e9d765303463d8a6cd11c58f7a7f. + * https://github.com/badlogic/pi-mono/blob/a9cedccdde77e9d765303463d8a6cd11c58f7a7f/packages/ai/src/providers/openai-responses.ts + * https://github.com/badlogic/pi-mono/blob/a9cedccdde77e9d765303463d8a6cd11c58f7a7f/packages/ai/src/providers/openai-responses-shared.ts + * buildParams sends full input, store:false and include:["reasoning.encrypted_content"]. + * processResponsesStream serializes the complete reasoning item as thinkingSignature; + * convertResponsesMessages parses it back, then emits function_call + function_call_output. + * pi-ai always streams; the JSON row additionally covers ocx's nonstream ingress contract. + * This models the source-verified wire shape; it does not launch the senpi executable. + */ +type Item = Record; +type WireResponse = { id: string; output: Item[] }; + +const reasoning = { + type: "reasoning", id: "rs_omo_fixture", summary: [{ type: "summary_text", text: "Look up the fixture value." }], + encrypted_content: "omo-fixture-opaque-reasoning", +}; +const call = { + type: "function_call", id: "fc_omo_fixture", call_id: "call_omo_fixture", name: "lookup", + arguments: '{"key":"fixture"}', status: "completed", +}; +const answer = { + type: "message", id: "msg_omo_fixture", role: "assistant", status: "completed", + content: [{ type: "output_text", text: "The fixture value is 42.", annotations: [] }], +}; + +function responseFixture(turn: number, stream: boolean): Response { + const output = turn === 1 ? [reasoning, call] : [answer]; + const response = { + id: `resp_omo_${turn}`, object: "response", status: "completed", model: "omo-fixture", + output, usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 }, + }; + if (!stream) return Response.json(response); + const events: Item[] = [{ type: "response.created", response: { ...response, status: "in_progress", output: [] } }]; + output.forEach((item, output_index) => { + events.push({ type: "response.output_item.added", output_index, item }); + events.push({ type: "response.output_item.done", output_index, item }); + }); + events.push({ type: "response.completed", response }); + return new Response(streamFromText(events.map(event => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join("")), { + headers: { "content-type": "text/event-stream" }, + }); +} + +async function readResponse(response: Response, stream: boolean): Promise { + expect(response.status).toBe(200); + if (!stream) { + expect(response.headers.get("content-type")).toContain("application/json"); + return await response.json() as WireResponse; + } + expect(response.headers.get("content-type")).toContain("text/event-stream"); + const events = (await response.text()).split("\n") + .filter(line => line.startsWith("data: ") && line !== "data: [DONE]") + .map(line => JSON.parse(line.slice(6))); + expect(events.some(event => event.type === "response.failed" || event.type === "error")).toBe(false); + // pi-ai records reasoning signatures on output_item.done, not just response.completed. + expect(events.filter(event => event.type === "response.output_item.done").map(event => event.item)) + .toEqual(events.find(event => event.type === "response.completed")?.response.output); + const completed = events.find(event => event.type === "response.completed"); + expect(completed).toBeDefined(); + return completed.response; +} + +describe("omo Responses export: stateless reasoning/tool continuity over loopback (#7431)", () => { + let upstream: ReturnType; + let proxy: ReturnType; + let home: string; + let codexHome: ReturnType; + const priorHome = process.env.OPENCODEX_HOME; + const captured: Array<{ path: string; authorization: string | null; body: Record }> = []; + let exported: PiGeneratedConfig["providers"][string]; + + beforeAll(() => { + home = mkdtempSync(join(tmpdir(), "ocx-omo-responses-")); + process.env.OPENCODEX_HOME = home; + codexHome = installIsolatedCodexHome("ocx-omo-responses-codex-"); + upstream = Bun.serve({ + hostname: "127.0.0.1", port: 0, + async fetch(request) { + const path = new URL(request.url).pathname; + if (path === "/v1/models") return Response.json({ data: [{ id: "omo-fixture" }] }); + if (path !== "/v1/responses") return new Response("unexpected upstream path", { status: 404 }); + const body = await request.json() as Record; + captured.push({ path, authorization: request.headers.get("authorization"), body }); + return responseFixture((body.input as Item[]).some(item => item.type === "function_call_output") ? 2 : 1, body.stream === true); + }, + }); + const config: OcxConfig = { + port: 0, hostname: "127.0.0.1", defaultProvider: "fixture", + omoClientExport: { api: "openai-responses" }, + providers: { fixture: { + adapter: "openai-responses", baseUrl: `${upstream.url.origin}/v1`, allowPrivateNetwork: true, + authMode: "key", apiKey: UPSTREAM_FIXTURE_KEY, models: ["omo-fixture"], upstreamWebsocket: false, + } }, + }; + saveConfig(config); + proxy = startServer(0); + exported = (buildClientConfig("omo", { + config, baseUrl: `${proxy.url.origin}/v1`, + // A reasoning ladder is what makes the Pi exporter mark the model as reasoning-capable. + models: [{ namespaced: "fixture/omo-fixture", provider: "fixture", id: "omo-fixture", reasoningEfforts: ["low", "medium", "high"] }], + }) as PiGeneratedConfig).providers[OPENCODE_PROVIDER_ID]!; + }); + + afterAll(async () => { + try { + if (proxy) await drainAndShutdown(proxy, 5_000); + } finally { + if (upstream) await upstream.stop(true); + if (codexHome) { codexHome.restoreEnvironment(); codexHome.removeAfterDrain(); } + if (priorHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = priorHome; + if (home) removeTreeWithRetry(home); + } + }); + + for (const stream of [false, true]) { + test(`exported placeholder admits two tool turns and preserves reasoning (${stream ? "SSE" : "JSON"})`, async () => { + expect(exported.api).toBe("openai-responses"); + expect(exported.apiKey).toBe(LOOPBACK_API_KEY_PLACEHOLDER); + const entry = exported.models[0]!; + const model = entry.id; + // pi-ai sends reasoning/include only for a reasoning model, with the effort its level map names. + expect(entry.reasoning).toBe(true); + const effort = entry.thinkingLevelMap?.medium; + expect(effort).toBe("medium"); + const mark = captured.length; + const user = { role: "user", content: [{ type: "input_text", text: "Look up the fixture value." }] }; + const send = (input: Item[]) => fetch(`${exported.baseUrl}/responses`, { + method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${exported.apiKey}` }, + body: JSON.stringify({ model, stream, store: false, + ...(entry.reasoning ? { include: ["reasoning.encrypted_content"], reasoning: { effort, summary: "auto" } } : {}), input, + tools: [{ type: "function", name: "lookup", description: "Look up a fixture value", strict: null, + parameters: { type: "object", properties: { key: { type: "string" } }, required: ["key"] } }], + }), + }); + const first = await readResponse(await send([user]), stream); + expect(first.output).toEqual([reasoning, call]); + // Match thinkingSignature restoration and function-call reconstruction in pi-ai. + const thinkingSignature = JSON.stringify(first.output[0]); + const returnedCall = first.output[1]!; + const replayedCall = { type: "function_call", id: returnedCall.id, call_id: returnedCall.call_id, + name: returnedCall.name, arguments: JSON.stringify(JSON.parse(returnedCall.arguments as string)) }; + const result = { type: "function_call_output", call_id: returnedCall.call_id, output: "42" }; + const second = await readResponse(await send([user, JSON.parse(thinkingSignature), replayedCall, result]), stream); + expect(second.output).toEqual([answer]); + const turns = captured.slice(mark); + expect(turns).toHaveLength(2); + for (const turn of turns) { + expect(turn.path).toBe("/v1/responses"); + expect(turn.authorization).toBe(`Bearer ${UPSTREAM_FIXTURE_KEY}`); + expect(turn.body.model).toBe("omo-fixture"); + expect(turn.body.store).toBe(false); + expect(turn.body.previous_response_id).toBeUndefined(); + expect(turn.body.include).toContain("reasoning.encrypted_content"); + } + expect(turns[0]!.body.input).toEqual([user]); + // store:false strips server-side item ids (stripItemIdsWhenUnstored), while + // retaining the complete reasoning payload and call_id that links the tool result. + const { id: _reasoningId, ...unstoredReasoning } = reasoning; + const { id: _callId, ...unstoredCall } = replayedCall; + expect(turns[1]!.body.input).toEqual([user, unstoredReasoning, unstoredCall, result]); + }); + } +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 3da4d6372b0..8b637ab2aa2 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1067,7 +1067,7 @@ "ollama-native-structured-output.test.ts": "providers/ollama", "ollama-native-v4.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", - "omo-client.test.ts": "clients", "omp-path-contract.test.ts": "clients", + "omo-client.test.ts": "clients", "omo-responses-loop.test.ts": "clients", "omp-path-contract.test.ts": "clients", "omp-yaml-source-inline-comments.test.ts": "clients", "opaque-blob-wrapped-rejection.test.ts": "responses", "openai-api-virtual-models.test.ts": "adapters/openai", "openai-chat-audio.test.ts": "adapters/openai", "openai-chat-bounded-tool-names.test.ts": "adapters/openai", From f5f47b057997c0d60ec420456d9450e109ca4ed1 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 29 Oct 2026 17:36:19 +0900 Subject: [PATCH 40/84] feat(provider): add keyless CC-Switch provider import with preview, apply and restore (#7935) * feat(provider): add keyless CC-Switch provider import with preview, apply and restore Adds ocx provider import-cc-switch to preview, selectively apply, and restore keyless provider entries from a CC-Switch SQLite database or legacy JSON file without reading, decoding, or writing credential values. * fix(provider): tighten CC-Switch byte-level TOML projection and private-network check * fix(provider): restrict SQLite TOML section check to two-segment model_providers tables and zero buffers in outer finally * fix(provider): allow escaped quotes inside quoted SQLite TOML provider keys * docs(cli): document ocx provider import-cc-switch and accept whitespace in SQLite TOML headers * fix(provider): sort SQLite TOML lines by index in JS and report zero-import apply accurately * fix(provider): remove trailing comma after projected_lines CTE * fix(provider): bound Codex TOML byte size, deduplicate SQLite header predicate, and refine CLI docs * fix(provider): retain imported providers with stored OAuth accounts and allow model IDs matching key-name patterns * fix(provider): fail closed on unreadable or malformed OAuth store during CC-Switch restore * fix(provider): harden CC-Switch source projection, metadata allowlist, and non-blocking open * fix(provider): unify SQLite/JSON/TOML pre-decode byte gates and deserialize SQLite in memory * fix(provider): validate unescaped TOML provider IDs directly and align SQLite/byte scalar checks * fix(provider): unescape TOML quoted scalars before SQLite metadata validation * fix(provider): state structural non-secret allowlist contract, narrow URL paths, byte header check, and fail-closed WAL guard * fix(provider): emit pre-write candidate report in --apply --json and assert pre-journal ordering * fix(provider): refuse hard-linked SQLite sources (fstat nlink > 1) so WAL checks cannot be bypassed * fix(provider): allowlist GOOGLE_GEMINI_BASE_URL and accept dated model IDs * fix(provider): accept canonical UUID provider IDs and YYYY-MM-DD or param-size model tokens * docs(cli): stop proxy before --restore and clarify separate rollback path across locales * docs(cli): stop proxy before --apply and mirror full import-cc-switch sequence across locales * docs(cli): align CC-Switch transient-byte projection wording and full restore retention rules across locales --- .../fr/reference/cli/providers-accounts.md | 22 + .../ja/reference/cli/providers-accounts.md | 22 + .../ko/reference/cli/providers-accounts.md | 22 + .../docs/reference/cli/providers-accounts.md | 22 + .../ru/reference/cli/providers-accounts.md | 22 + .../tr/reference/cli/providers-accounts.md | 22 + .../zh-cn/reference/cli/providers-accounts.md | 22 + .../zh-tw/reference/cli/providers-accounts.md | 22 + scripts/test-layout/layout-providers.json | 1 + scripts/test-layout/layout.json | 2 +- .../ocx/references/01_management_surface.md | 10 +- .../references/01_surface_providers-models.md | 24 +- src/cli/capabilities-provider-models.ts | 7 + src/cli/provider-cc-switch-import.ts | 185 +++ src/cli/provider.ts | 5 + src/cli/registry.ts | 1 + src/providers/cc-switch-import-bytes.ts | 991 +++++++++++++++ src/providers/cc-switch-import-source.ts | 1083 ++++++++++++++++ src/providers/cc-switch-import.ts | 942 ++++++++++++++ structure/INDEX.md | 3 + structure/cli-management.md | 6 + structure/manifest.json | 3 + .../cli/cli-provider-cc-switch-import.test.ts | 127 ++ tests/fixtures/test-layout-expected.json | 4 +- tests/providers/cc-switch-import.test.ts | 1102 +++++++++++++++++ 25 files changed, 4665 insertions(+), 7 deletions(-) create mode 100644 src/cli/provider-cc-switch-import.ts create mode 100644 src/providers/cc-switch-import-bytes.ts create mode 100644 src/providers/cc-switch-import-source.ts create mode 100644 src/providers/cc-switch-import.ts create mode 100644 tests/cli/cli-provider-cc-switch-import.test.ts create mode 100644 tests/providers/cc-switch-import.test.ts diff --git a/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md b/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md index 25e82d8de21..acdc51645c5 100644 --- a/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md @@ -25,6 +25,7 @@ Gestion des fournisseurs non interactive. Les entrées de registre sont classée | `quota` | `--refresh`, `--json` | Lire les rapports sur les quotas des fournisseurs. | | `presets` | `--json` | Répertoriez les préréglages du fournisseur de tableau de bord. | | `account-mode` | `pool`, `direct`, `--json` | Sélectionnez le routage de compte mutualisé ou direct Codex. | +| `import-cc-switch` | `--source `, `--provider `, `--apply`, `--restore`, `--allow-private-network`, `--json` | Prévisualiser, appliquer sélectivement ou restaurer des entrées de fournisseurs sans clé depuis une base SQLite (`cc-switch.db`) ou un fichier JSON CC-Switch. | Par défaut, `add`, `remove` et `set-default` modifient la configuration locale. Ajoutez `--live` pour modifier le proxy actif, et `--yes` pour une suppression en direct. `--sync --json` tente aussi la synchronisation après enregistrement ; en cas d’échec, la sauvegarde reste acquise, avec un code non nul et `needsSync: true`. `--live` et `--sync` sont incompatibles. Voir le [guide anglais](/reference/cli/providers-accounts/#snapshot-edit-and-apply-with-a-baseline) pour pacing et snapshot/apply. @@ -60,6 +61,27 @@ la frontière est à vous de la respecter. Deux raisons pour lesquelles c'est im Utilisez `--api-key` ou un identifiant OAuth pour tout ce qui est secret. ::: +### Import de fournisseurs CC-Switch sans clé + +```bash +ocx provider import-cc-switch --source ~/.cc-switch/cc-switch.db +ocx stop +ocx provider import-cc-switch --source ~/.cc-switch/cc-switch.db --provider my-provider --apply +# Démarrez le proxy dans un autre terminal (ou exécutez ocx ensure), puis ajoutez les clés : +ocx start +printf '%s\n' "$API_KEY" | ocx account add-key my-provider + +# Chemin d’annulation distinct (arrêtez d’abord le proxy ; supprime uniquement les entrées sans clé non modifiées) : +ocx stop +ocx provider import-cc-switch --restore +``` + +`ocx provider import-cc-switch` projette des valeurs **uniquement** depuis une liste blanche explicite de champs de métadonnées non secrets (identifiants de fournisseur, types d’application, URL de base, dialectes et modèles par défaut) à partir d’une base SQLite ou d’un fichier JSON CC-Switch ordinaire. Les octets de la source et de la configuration intégrée sont lus de manière transitoire pour analyser l’entrée, tandis que les champs secrets, noms libres et commentaires TOML sont ignorés au niveau des octets, ne sont jamais décodés en chaînes, écrits ni journalisés, et les tampons d’octets transitoires sont remis à zéro dans `finally`. Les règles strictes de chemin d’URL (`http(s)://host[:port]` sans chemin ou avec 1 à 4 segments d’API en minuscules `[a-z0-9._-]` tels que `/v1`, `/api/v1` ou `/compatible-mode/v1`) et les contrôles syntaxiques d’identifiant/modèle servent de défense en profondeur sans faire écho aux valeurs rejetées. Les fichiers sources sont ouverts une seule fois avec vérification de descripteur `O_NOFOLLOW | O_NONBLOCK` et désérialisés en lecture seule en mémoire pour SQLite sans fichier temporaire ; les fichiers SQLite ayant plusieurs liens physiques (`nlink > 1`) sont refusés afin d’empêcher le contournement des contrôles de fichiers annexes, et si un fichier voisin `-wal` ou `-journal` est non vide, l’import refuse et demande de fermer CC-Switch (ou de consolider la base) avant de réessayer. N’importez que depuis une source locale CC-Switch de confiance et vérifiez chaque ID, modèle et `baseUrl` affichés avant d’ajouter des identifiants : + +- **Aperçu (par défaut) :** exige `--source `, n’écrit rien et indique les entrées découvertes, éligibles, en conflit et invalides. +- **Application (`--apply`) :** exige `--source ` et un proxy arrêté (`ocx stop`), et est refusée sur un Remote Link Child. Répétez `--provider ` pour n’importer que certaines entrées, ou passez `--allow-private-network` pour autoriser les URL de bouclage ou de réseau privé. Même sans aperçu préalable, `--apply` affiche chaque ID de fournisseur, modèle par défaut et URL de destination avant d’écrire les entrées sans clé et le journal d’annulation `$OPENCODEX_HOME/cc-switch-provider-import-journal.json` (`stdout` en mode texte, `stderr` en mode `--json`) ; un second `--apply` est refusé tant qu’un journal actif existe. Vérifiez les entrées affichées, démarrez le proxy (`ocx start` dans un autre terminal ou `ocx ensure`), puis ajoutez les clés avec `printf '%s\n' "$API_KEY" | ocx account add-key ` (entrée standard redirigée) ou `ocx login `. +- **Restauration (`--restore`) :** chemin d’annulation distinct (et non la suite de l’ajout d’identifiants) qui exige un proxy arrêté (`ocx stop`), est refusé sur un Remote Link Child et n’accepte ni `--source`, ni `--provider`, ni `--allow-private-network`. Il inspecte `auth.json` en lecture seule au niveau des octets (échec fermé s’il est illisible ou mal formé) et ne supprime du journal actif que les fournisseurs qui correspondent encore à leur état importé sans clé et qui ne sont ni sélectionnés comme `defaultProvider` ni référencés par le routage, les combos, la politique de repli (fallback policy), les comptes OAuth ou les clés API. + ## Authentification ### `ocx login ` diff --git a/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md index 7e2a2c06150..b199c822ffc 100644 --- a/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md @@ -24,6 +24,7 @@ description: プロバイダー構成、資格情報、クォータ、および | `quota` | `--refresh`、`--json` |プロバイダー クォータ レポートを読み取ります。 | | `presets` | `--json` |ダッシュボードプロバイダーのプリセットを一覧表示します。 | | `account-mode` | `pool`、`direct`、`--json` |プールされた Codex アカウント ルーティングまたは直接の Codex アカウント ルーティングを選択します。 | +| `import-cc-switch` | `--source `、`--provider `、`--apply`、`--restore`、`--allow-private-network`、`--json` | CC-Switch の SQLite(`cc-switch.db`)または JSON ファイルからキーなしのプロバイダー項目をプレビュー・選択適用・復元します。 | 既定の `add`、`remove`、`set-default` はローカル設定を変更します。稼働中のプロキシを変更するには `--live`、ライブ削除にはさらに `--yes` が必要です。`--sync --json` も保存後に同期を試み、失敗時は保存を保持して非ゼロ終了と `needsSync: true` を返します。`--live` と `--sync` は併用できません。pacing と snapshot/apply の手順は[英語版](/reference/cli/providers-accounts/#snapshot-edit-and-apply-with-a-baseline)を参照してください。 @@ -57,6 +58,27 @@ ocx models live --provider ark --json 秘密にあたる値は `--api-key` か OAuth ログインを使ってください。 ::: +### キーなしの CC-Switch プロバイダーインポート + +```bash +ocx provider import-cc-switch --source ~/.cc-switch/cc-switch.db +ocx stop +ocx provider import-cc-switch --source ~/.cc-switch/cc-switch.db --provider my-provider --apply +# 別のターミナルでプロキシを起動(または ocx ensure を実行)してからキーを登録: +ocx start +printf '%s\n' "$API_KEY" | ocx account add-key my-provider + +# ロールバック用の別経路(先にプロキシを停止;未編集のキーなし項目のみ削除): +ocx stop +ocx provider import-cc-switch --restore +``` + +`ocx provider import-cc-switch` は通常の CC-Switch SQLite データベースまたはレガシー JSON ファイルから、非秘密メタデータフィールドの明示的な許可リスト(プロバイダー ID、アプリ種別、ベース URL、アダプター、既定モデル)の値**のみ**を射影します。入力を走査するためソースと埋め込み設定のバイト列を一時的に読み取りますが、秘密フィールド・自由記述の表示名・TOML コメントはバイト単位でスキップされ、文字列へのデコード・書き込み・ログ出力は一切行われず、一時バイトバッファは `finally` でゼロ消去されます。ベース URL パスの厳格な規則(`http(s)://host[:port]` に空パスまたは `/v1`・`/api/v1`・`/compatible-mode/v1` などの小文字 `[a-z0-9._-]` による 1〜4 個の API パスセグメント)と識別子/モデルの構文検査は多層防御として機能し、不適合な値をエコーせずに拒否します。ソースファイルは `O_NOFOLLOW | O_NONBLOCK` の記述子検証付きで一度だけ開かれ、SQLite は一時ファイルを作らずメモリ上で読み取り専用展開されます。複数ハードリンク(`nlink > 1`)を持つ SQLite ファイルは別名パスによるサイドカー検査回避を防ぐため拒否され、隣接する `-wal` または `-journal` ファイルが空でない場合は CC-Switch を終了(またはデータベースをチェックポイント)して再試行するよう求めて拒否します。信頼できるローカルの CC-Switch ソースのみを使用し、出力されたすべての ID・モデル・`baseUrl` を確認してから資格情報を登録してください: + +- **プレビュー(既定):** `--source ` が必要で、何も書き込まず、検出・対象・競合・無効の件数を報告します。 +- **適用(`--apply`):** `--source ` とプロキシの停止(`ocx stop`)が必要で、Remote Link Child では拒否されます。`--provider ` を繰り返して対象を絞り込むか、ループバックやプライベートネットワークのベース URL には `--allow-private-network` を指定します。事前のプレビューなしでも、`--apply` はキーなしのプロバイダー項目とロールバックジャーナル `$OPENCODEX_HOME/cc-switch-provider-import-journal.json` を書き込む前にすべてのプロバイダー ID・既定モデル・接続先 URL を出力します(テキストモードでは `stdout`、`--json` モードでは `stderr`)。アクティブなジャーナルが存在する間、2 回目の `--apply` は拒否されます。出力内容を確認したらプロキシを起動(別ターミナルで `ocx start` または `ocx ensure`)し、`printf '%s\n' "$API_KEY" | ocx account add-key `(パイプ標準入力)または `ocx login ` で資格情報を追加します。 +- **復元(`--restore`):** 資格情報登録の続きではなく独立したロールバック経路であり、プロキシの停止(`ocx stop`)が必要で、Remote Link Child では拒否され、`--source`・`--provider`・`--allow-private-network` は受け付けません。`auth.json` をバイト単位で読み取り専用検査(読み取り不能または不正形式時はフェイルクローズ)し、インポート時のキーなし状態と一致したままで、かつ `defaultProvider` に選択されておらず、ルーティング・コンボ(combos)・フォールバックポリシー(fallback policy)・OAuth アカウント・API キーのいずれからも参照されていないプロバイダーのみをアクティブなジャーナルから削除します。 + ## 認証 ### `ocx login ` diff --git a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md index a0dae108083..4355de7071b 100644 --- a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md @@ -24,6 +24,7 @@ description: 제공자 설정, 자격 증명, 할당량, 모델 카탈로그 명 | `quota` | `--refresh`, `--json` | 제공자 할당량 보고서를 읽습니다. | | `presets` | `--json` | 대시보드 제공자 프리셋을 나열합니다. | | `account-mode` | `pool`, `direct`, `--json` | Codex 계정 라우팅을 풀 기반으로 할지 직접 연결로 할지 선택합니다. | +| `import-cc-switch` | `--source `, `--provider `, `--apply`, `--restore`, `--allow-private-network`, `--json` | CC-Switch SQLite(`cc-switch.db`) 또는 레거시 JSON 파일에서 키 없는 프로바이더 항목을 미리보기·선택 적용·복원합니다. | 기본 `add`, `remove`, `set-default`는 로컬 설정을 바꿉니다. 실행 중인 프록시를 바꾸려면 `--live`를 지정하세요. 실시간 삭제에는 `--yes`도 필요합니다. `--sync --json`도 저장 후 동기화를 실제로 시도하며, 실패하면 저장은 유지한 채 0이 아닌 코드와 `needsSync: true`를 반환합니다. `--live`와 `--sync`는 함께 쓸 수 없습니다. pacing·snapshot/apply 절차는 [영문 명령 안내](/reference/cli/providers-accounts/#snapshot-edit-and-apply-with-a-baseline)를 참고하세요. @@ -57,6 +58,27 @@ ocx models live --provider ark --json 비밀에 해당하는 값은 `--api-key`나 OAuth 로그인을 쓰세요. ::: +### 키 없는 CC-Switch 프로바이더 가져오기 + +```bash +ocx provider import-cc-switch --source ~/.cc-switch/cc-switch.db +ocx stop +ocx provider import-cc-switch --source ~/.cc-switch/cc-switch.db --provider my-provider --apply +# 다른 터미널에서 프록시를 시작(또는 ocx ensure 실행)한 뒤 키를 등록: +ocx start +printf '%s\n' "$API_KEY" | ocx account add-key my-provider + +# 별도의 롤백 경로(먼저 프록시를 중지; 수정되지 않은 키 없는 항목만 제거): +ocx stop +ocx provider import-cc-switch --restore +``` + +`ocx provider import-cc-switch`는 일반 CC-Switch SQLite 데이터베이스나 레거시 JSON 파일에서 명시적으로 허용된 비비밀 메타데이터 필드(프로바이더 ID, 앱 유형, 기본 URL, 프로토콜 어댑터, 기본 모델)의 값**만** 투영합니다. 입력을 스캔하기 위해 소스와 내장 설정 바이트를 일시적으로 읽지만, 비밀 필드·자유 텍스트 표시 이름·TOML 주석은 바이트 수준에서 건너뛰어 문자열로 디코딩하거나 기록·로깅하지 않으며 임시 바이트 버퍼는 `finally`에서 0으로 지워집니다. 좁은 기본 URL 경로 규칙(`http(s)://host[:port]`와 빈 경로 또는 `/v1`, `/api/v1`, `/compatible-mode/v1` 같은 1~4개의 소문자 `[a-z0-9._-]` API 경로 세그먼트)과 식별자/모델 구문 검사는 심층 방어로 동작하며 부적합한 값을 에코하지 않고 거부합니다. 소스 파일은 `O_NOFOLLOW | O_NONBLOCK` 디스크립터 검증과 함께 한 번만 열리고 SQLite는 임시 파일 없이 메모리에서 읽기 전용으로 역직렬화됩니다. 여러 하드 링크(`nlink > 1`)를 가진 SQLite 파일은 별칭 경로를 통한 사이드카 검사 우회를 막기 위해 거부되며, 인접한 `-wal` 또는 `-journal` 파일이 비어 있지 않으면 CC-Switch를 종료(또는 데이터베이스 체크포인트)한 뒤 다시 시도하라고 안내하며 거부합니다. 신뢰할 수 있는 로컬 CC-Switch 소스만 사용하고, 출력된 모든 ID·모델·`baseUrl`을 확인한 뒤 자격 증명을 추가하세요: + +- **미리보기(기본값):** `--source `이 필요하며 아무것도 쓰지 않고 발견·적격·충돌·무효 항목 수를 보고합니다. +- **적용(`--apply`):** `--source `과 중지된 프록시(`ocx stop`)가 필요하며 Remote Link Child에서는 거부됩니다. 선택한 항목만 가져오려면 `--provider `를 반복하고, 루프백이나 사설 네트워크 기본 URL을 허용하려면 `--allow-private-network`를 지정하세요. 사전 미리보기 없이 실행하더라도 `--apply`는 키 없는 프로바이더 항목과 롤백 저널(`$OPENCODEX_HOME/cc-switch-provider-import-journal.json`)을 기록하기 전에 가져올 모든 프로바이더 ID·기본 모델·대상 URL을 먼저 출력합니다(텍스트 모드에서는 `stdout`, `--json` 모드에서는 `stderr`). 활성 저널이 있는 동안 두 번째 `--apply`는 거부됩니다. 출력된 항목을 확인한 뒤 프록시를 시작(다른 터미널에서 `ocx start` 또는 `ocx ensure`)하고 `printf '%s\n' "$API_KEY" | ocx account add-key `(파이프 표준 입력) 또는 `ocx login `로 자격 증명을 등록하세요. +- **복원(`--restore`):** 자격 증명 추가의 연속이 아닌 별도의 롤백 경로로, 중지된 프록시(`ocx stop`)가 필요하며 Remote Link Child에서는 거부되고 `--source`·`--provider`·`--allow-private-network`는 받지 않습니다. `auth.json`을 바이트 수준에서 읽기 전용으로 검사(읽을 수 없거나 형식이 잘못되면 안전하게 보류)하며, 가져온 키 없는 상태와 여전히 일치하고 `defaultProvider`로 선택되지 않았으며 라우팅·콤보(combos)·폴백 정책(fallback policy)·OAuth 계정·API 키 어디에서도 참조되지 않은 프로바이더만 활성 저널에서 제거합니다. + ## 인증 ### `ocx login ` diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index 8c0c237f941..3100c891189 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -36,6 +36,7 @@ both `--adapter` and `--base-url`. | `resets` | `--limit `, `--json` | List recently detected quota-window resets. | | `presets` | `--json` | List dashboard provider presets. | | `account-mode` | `pool`, `direct`, `--json` | Select pooled or direct Codex account routing. | +| `import-cc-switch` | `--source `, `--provider `, `--apply`, `--restore`, `--allow-private-network`, `--json` | Preview, selectively apply, or restore keyless provider entries from a CC-Switch SQLite database (`cc-switch.db`) or legacy JSON file. | ```bash ocx provider list --json @@ -220,6 +221,27 @@ serialized `{baseline,next}` request must also fit 4 MiB. Interactive stdin, special files, conflicting flags, invalid fields and unconfirmed removals are refused before mutation with exit 2. Input errors never echo the submitted values. +### Keyless CC-Switch provider import + +```bash +ocx provider import-cc-switch --source ~/.cc-switch/cc-switch.db +ocx stop +ocx provider import-cc-switch --source ~/.cc-switch/cc-switch.db --provider my-provider --apply +# Start the proxy in another terminal (or run ocx ensure), then add credentials: +ocx start +printf '%s\n' "$API_KEY" | ocx account add-key my-provider + +# Separate rollback path (stop the proxy first; removes unedited keyless entries only): +ocx stop +ocx provider import-cc-switch --restore +``` + +`ocx provider import-cc-switch` projects values **only** from an explicit allowlist of non-secret metadata fields (provider IDs, app types, base URLs, wire dialects, and default models) from a plain regular CC-Switch SQLite database or legacy JSON file. Source and embedded-config bytes are read transiently to scan the input, while secret fields, free-text display names, and TOML comments are skipped at the byte level, never decoded into strings, written, or logged, and transient byte buffers are zeroed in `finally`. Narrow base-URL path rules (`http(s)://host[:port]` with an empty path or 1–4 lowercase `[a-z0-9._-]` API path segments such as `/v1`, `/api/v1`, or `/compatible-mode/v1`) and identifier/model syntax checks act as defense in depth and reject non-conforming values without echoing them. Source files are opened once with `O_NOFOLLOW | O_NONBLOCK` descriptor verification and deserialized read-only in memory for SQLite without temporary files; SQLite files with multiple hard links (`nlink > 1`) are refused so sidecar checks cannot be bypassed via an alias path, and if a sibling `-wal` or `-journal` file is non-empty, import refuses and asks you to close CC-Switch (or checkpoint the database) and retry. Only import from a trusted local CC-Switch source, and verify every printed ID, model, and `baseUrl` before adding credentials: + +- **Preview (default):** requires `--source `, writes nothing, and reports discovered, eligible, conflicting, and invalid entries. +- **Apply (`--apply`):** requires `--source ` and a stopped proxy (`ocx stop`), and is refused on a Remote Link Child. Repeat `--provider ` to import only selected entries, or pass `--allow-private-network` to permit loopback or private-network base URLs. Even without a prior preview, `--apply` prints every imported provider ID, default model, and destination URL before writing keyless provider entries and recording a rollback journal at `$OPENCODEX_HOME/cc-switch-provider-import-journal.json` (`stdout` in text mode, `stderr` in `--json` mode); a second apply is refused while an active journal exists. Verify the printed entries, start the proxy (`ocx start` in another terminal or `ocx ensure`), and add credentials with `printf '%s\n' "$API_KEY" | ocx account add-key ` (piped stdin) or `ocx login `. +- **Restore (`--restore`):** separate rollback path (not a continuation of adding credentials) that requires a stopped proxy (`ocx stop`), is refused on a Remote Link Child, and takes no `--source`, `--provider`, or `--allow-private-network`. It inspects `auth.json` read-only at the byte level (failing closed if unreadable or malformed) and removes only providers from the active journal that still match their imported keyless state and are not selected as `defaultProvider` or referenced by routing, combos, fallback policy, OAuth accounts, or API keys. + ## Authentication ### Diagnosing missing main-account quota diff --git a/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md index c1a53a5103f..80861b69725 100644 --- a/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md @@ -26,6 +26,7 @@ pool'ами и контролируют каталог моделей, кото | `quota` | `--refresh`, `--json` | Прочитать отчёты по quota провайдеров. | | `presets` | `--json` | Показать provider preset'ы дашборда. | | `account-mode` | `pool`, `direct`, `--json` | Выбрать pooled или direct routing для аккаунтов Codex. | +| `import-cc-switch` | `--source `, `--provider `, `--apply`, `--restore`, `--allow-private-network`, `--json` | Предпросмотр, выборочное применение или откат провайдеров без ключей из базы SQLite (`cc-switch.db`) или JSON-файла CC-Switch. | По умолчанию `add`, `remove` и `set-default` меняют локальную конфигурацию. Для работающего прокси добавьте `--live`, а для удаления — также `--yes`. `--sync --json` тоже пытается синхронизировать после сохранения; при сбое сохранение остаётся, код выхода ненулевой и `needsSync: true`. Флаги `--live` и `--sync` несовместимы. Pacing и snapshot/apply описаны в [английской инструкции](/reference/cli/providers-accounts/#snapshot-edit-and-apply-with-a-baseline). @@ -61,6 +62,27 @@ ocx models live --provider ark --json Для всего секретного используйте `--api-key` или вход через OAuth. ::: +### Импорт провайдеров CC-Switch без ключей + +```bash +ocx provider import-cc-switch --source ~/.cc-switch/cc-switch.db +ocx stop +ocx provider import-cc-switch --source ~/.cc-switch/cc-switch.db --provider my-provider --apply +# Запустите прокси в другом терминале (или выполните ocx ensure), затем добавьте ключи: +ocx start +printf '%s\n' "$API_KEY" | ocx account add-key my-provider + +# Отдельный сценарий отката (сначала остановите прокси; удаляет только неизменённые записи без ключей): +ocx stop +ocx provider import-cc-switch --restore +``` + +`ocx provider import-cc-switch` проецирует значения **только** из явного разрешённого списка несекретных полей метаданных (ID провайдеров, типы приложений, базовые URL, адаптеры и модели по умолчанию) из обычной базы SQLite или устаревшего JSON-файла CC-Switch. Байты источника и встроенной конфигурации временно считываются для сканирования входа, при этом секретные поля, произвольные отображаемые имена и комментарии TOML пропускаются на уровне байтов, никогда не декодируются в строки, не записываются и не логируются, а временные байтовые буферы обнуляются в `finally`. Узкие правила пути базового URL (`http(s)://host[:port]` с пустым путём или 1–4 сегментами API-пути в нижнем регистре `[a-z0-9._-]`, например `/v1`, `/api/v1` или `/compatible-mode/v1`) и проверки синтаксиса идентификаторов/моделей служат эшелонированной защитой и отклоняют неподходящие значения без их вывода. Исходные файлы открываются один раз с проверкой дескриптора `O_NOFOLLOW | O_NONBLOCK` и для SQLite десериализуются только для чтения в памяти без временных файлов; файлы SQLite с несколькими жёсткими ссылками (`nlink > 1`) отклоняются, чтобы проверки sidecar-файлов нельзя было обойти через альтернативный путь, а если соседний файл `-wal` или `-journal` не пуст, импорт завершается отказом и просит закрыть CC-Switch (или выполнить checkpoint базы) и повторить попытку. Импортируйте только из доверенного локального источника CC-Switch и проверяйте все выведенные ID, модели и `baseUrl` перед добавлением учётных данных: + +- **Предпросмотр (по умолчанию):** требует `--source `, ничего не записывает и сообщает число найденных, подходящих, конфликтующих и некорректных записей. +- **Применение (`--apply`):** требует `--source ` и остановленного прокси (`ocx stop`) и отклоняется на Remote Link Child. Повторите `--provider ` для выбора отдельных записей или укажите `--allow-private-network` для loopback и частных сетей. Даже без предварительного предпросмотра `--apply` выводит каждый импортируемый ID провайдера, модель по умолчанию и целевой URL до записи провайдеров без ключей и журнала отката `$OPENCODEX_HOME/cc-switch-provider-import-journal.json` (в `stdout` в текстовом режиме, в `stderr` при `--json`); повторный `--apply` отклоняется, пока существует активный журнал. Проверьте выведенные записи, запустите прокси (`ocx start` в другом терминале или `ocx ensure`) и добавьте ключи через `printf '%s\n' "$API_KEY" | ocx account add-key ` (конвейерный stdin) или `ocx login `. +- **Откат (`--restore`):** отдельный сценарий отката (не продолжение добавления ключей), который требует остановленного прокси (`ocx stop`), отклоняется на Remote Link Child и не принимает `--source`, `--provider` или `--allow-private-network`. Команда проверяет `auth.json` только для чтения на уровне байтов (с безопасным отказом, если файл нечитаем или повреждён) и удаляет из активного журнала только те провайдеры, которые по-прежнему совпадают со своим импортированным состоянием без ключей, не выбраны как `defaultProvider` и не используются в маршрутизации, комбо (combos), политике резервирования (fallback policy), аккаунтах OAuth или API-ключах. + ## Аутентификация ### `ocx login ` diff --git a/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md b/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md index 184e193ca23..eec9aabd8d2 100644 --- a/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md @@ -27,6 +27,7 @@ bir ad hem `--adapter` hem de `--base-url` gerektirir. | `quota` | `--refresh`, `--json` | Sağlayıcı kota raporlarını okuyun. | | `presets` | `--json` | Kontrol paneli sağlayıcı önayarlarını listeleyin. | | `account-mode` | `pool`, `direct`, `--json` | Havuzlanmış veya doğrudan Codex hesap yönlendirmesini seçin. | +| `import-cc-switch` | `--source `, `--provider `, `--apply`, `--restore`, `--allow-private-network`, `--json` | CC-Switch SQLite (`cc-switch.db`) veya JSON dosyasından anahtarsız sağlayıcı girdilerini önizleyin, seçerek uygulayın veya geri yükleyin. | Varsayılan `add`, `remove` ve `set-default` yerel yapılandırmayı değiştirir. Çalışan proxy için `--live`, canlı silme için ayrıca `--yes` kullanın. `--sync --json` da kayıttan sonra eşitlemeyi dener; başarısızlıkta kayıt korunur, sıfır olmayan çıkış kodu ve `needsSync: true` döner. `--live` ile `--sync` birlikte kullanılamaz. Pacing ve snapshot/apply için [İngilizce kılavuza](/reference/cli/providers-accounts/#snapshot-edit-and-apply-with-a-baseline) bakın. @@ -63,6 +64,27 @@ saygı duymanız gereken bir sınırdır. Önemli olmasının iki nedeni: Gizli olan her şey için `--api-key` veya bir OAuth girişi kullanın. ::: +### Anahtarsız CC-Switch sağlayıcı içe aktarma + +```bash +ocx provider import-cc-switch --source ~/.cc-switch/cc-switch.db +ocx stop +ocx provider import-cc-switch --source ~/.cc-switch/cc-switch.db --provider my-provider --apply +# Proxy'yi başka bir terminalde başlatın (veya ocx ensure çalıştırın), ardından anahtar ekleyin: +ocx start +printf '%s\n' "$API_KEY" | ocx account add-key my-provider + +# Ayrı geri alma yolu (önce proxy'yi durdurun; yalnızca düzenlenmemiş anahtarsız girdileri kaldırır): +ocx stop +ocx provider import-cc-switch --restore +``` + +`ocx provider import-cc-switch`, düz bir CC-Switch SQLite veritabanından veya eski JSON dosyasından **yalnızca** açıkça izin verilen gizli olmayan meta veri alanlarındaki değerleri (sağlayıcı kimlikleri, uygulama türleri, temel URL'ler, bağdaştırıcılar ve varsayılan modeller) yansıtır. Girdiyi taramak için kaynak ve gömülü yapılandırma baytları geçici olarak okunur; gizli alanlar, serbest metin görünen adları ve TOML yorumları bayt düzeyinde atlanır, hiçbir zaman dizelere çözülmez, yazılmaz veyagünlüklenmez ve geçici bayt arabellekleri `finally` içinde sıfırlanır. Dar temel URL yolu kuralları (boş yola veya `/v1`, `/api/v1`, `/compatible-mode/v1` gibi 1–4 küçük harfli `[a-z0-9._-]` API yolu segmentine sahip `http(s)://host[:port]`) ve tanımlayıcı/model sözdizimi denetimleri derinlemesine savunma sağlar ve uygun olmayan değerleri yansıtmadan reddeder. Kaynak dosyalar `O_NOFOLLOW | O_NONBLOCK` tanımlayıcı doğrulamasıyla bir kez açılır ve SQLite için geçici dosya olmadan bellekte salt okunur olarak açılır; birden fazla sabit bağlantıya (`nlink > 1`) sahip SQLite dosyaları yan dosya denetimlerinin takma ad yoluyla aşılmasını önlemek için reddedilir ve kardeş `-wal` veya `-journal` dosyası boş değilse içe aktarma reddedilerek CC-Switch'i kapatıp (veya veritabanını checkpoint edip) yeniden denemenizi ister. Yalnızca güvendiğiniz yerel bir CC-Switch kaynağından içe aktarın ve kimlik bilgisi eklemeden önce yazdırılan her kimliği, modeli ve `baseUrl` değerini doğrulayın: + +- **Önizleme (varsayılan):** `--source ` gerektirir, hiçbir şey yazmaz ve bulunan, uygun, çakışan ve geçersiz girdileri raporlar. +- **Uygulama (`--apply`):** `--source ` ve durdurulmuş bir proxy (`ocx stop`) gerektirir; Remote Link Child üzerinde reddedilir. Yalnızca seçili girdileri aktarmak için `--provider ` bayrağını tekrarlayın veya geri döngü/özel ağ temel URL'lerine izin vermek için `--allow-private-network` geçirin. Önceden önizleme yapılmasa bile `--apply`, anahtarsız sağlayıcı girdilerini ve `$OPENCODEX_HOME/cc-switch-provider-import-journal.json` geri alma günlüğünü yazmadan önce içe aktarılan her sağlayıcı kimliğini, varsayılan modeli ve hedef URL'yi yazdırır (metin modunda `stdout`, `--json` modunda `stderr`); etkin bir günlük varken ikinci bir `--apply` reddedilir. Yazdırılan girdileri doğrulayın, proxy'yi başlatın (başka bir terminalde `ocx start` veya `ocx ensure`) ve `printf '%s\n' "$API_KEY" | ocx account add-key ` (standart girdi borusu) ya da `ocx login ` ile kimlik bilgilerini ekleyin. +- **Geri yükleme (`--restore`):** kimlik bilgisi eklemenin devamı olmayan ayrı bir geri alma yoludur; durdurulmuş bir proxy (`ocx stop`) gerektirir, Remote Link Child üzerinde reddedilir ve `--source`, `--provider` veya `--allow-private-network` almaz. `auth.json` dosyasını bayt düzeyinde salt okunur denetler (okunamaz veya bozuksa güvenli biçimde korur) ve etkin günlükten yalnızca içe aktarılan anahtarsız durumuyla hâlâ eşleşen, `defaultProvider` olarak seçilmemiş ve yönlendirme, kombolar (combos), yedekleme politikası (fallback policy), OAuth hesapları veya API anahtarları tarafından başvurulmayan sağlayıcıları kaldırır. + ## Kimlik Doğrulama ### `ocx login ` diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md b/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md index d30ce4d9ea7..6c7a259ad2c 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md @@ -32,6 +32,7 @@ description: 提供方配置、凭据、配额,以及模型目录命令。 | `quota` | `--refresh`, `--json` | 读取提供方配额报告。 | | `presets` | `--json` | 列出仪表盘提供方预设。 | | `account-mode` | `pool`, `direct`, `--json` | 选择 Codex 账号的池化或直连路由。 | +| `import-cc-switch` | `--source `, `--provider `, `--apply`, `--restore`, `--allow-private-network`, `--json` | 从 CC-Switch SQLite(`cc-switch.db`)或旧版 JSON 文件预览、选择性应用或恢复无密钥提供商条目。 | 默认的 `add`、`remove` 和 `set-default` 修改本地配置。修改运行中的代理需加 `--live`,在线删除还需 `--yes`。`--sync --json` 同样会在保存后尝试同步;失败时保留保存结果,以非零状态退出,并返回 `needsSync: true`。`--live` 不能与 `--sync` 同用。pacing 和 snapshot/apply 操作见[英文指南](/reference/cli/providers-accounts/#snapshot-edit-and-apply-with-a-baseline)。 @@ -62,6 +63,27 @@ ocx models live --provider ark --json 任何机密内容请使用 `--api-key` 或 OAuth 登录。 ::: +### 无密钥导入 CC-Switch 提供商 + +```bash +ocx provider import-cc-switch --source ~/.cc-switch/cc-switch.db +ocx stop +ocx provider import-cc-switch --source ~/.cc-switch/cc-switch.db --provider my-provider --apply +# 在另一个终端启动代理(或运行 ocx ensure),再添加密钥: +ocx start +printf '%s\n' "$API_KEY" | ocx account add-key my-provider + +# 独立的回滚路径(先停止代理;仅移除未修改的无密钥条目): +ocx stop +ocx provider import-cc-switch --restore +``` + +`ocx provider import-cc-switch` 仅从普通本地 CC-Switch SQLite 数据库或旧版 JSON 文件中投射显式白名单内的非机密元数据字段(提供商 ID、应用类型、base URL、协议适配器与默认模型)。为扫描输入会临时读取源文件与内嵌配置字节,但密钥/凭据字段、自由文本显示名称与 TOML 注释会在字节层跳过,绝不解码为字符串、写入磁盘或记录日志,且临时字节缓冲区会在 `finally` 中清零。窄化 base URL 路径规则(`http(s)://host[:port]` 配空路径或 1–4 个小写 `[a-z0-9._-]` API 路径段,如 `/v1`、`/api/v1` 或 `/compatible-mode/v1`)与标识符/模型语法校验仅作为纵深防御并拒绝不合规值(不回显原值)。源文件通过 `O_NOFOLLOW | O_NONBLOCK` 描述符校验单次打开,SQLite 在内存中只读反序列化且不写入临时文件;具有多个硬链接(`nlink > 1`)的 SQLite 文件会被拒绝以防通过别名路径绕过伴随文件检查,若同级 `-wal` 或 `-journal` 文件非空则会拒绝导入并提示先关闭 CC-Switch(或执行数据库 checkpoint)再重试。请仅从受信任的本地 CC-Switch 源文件导入,并在添加凭据前核对输出的每个 ID、模型与 `baseUrl`: + +- **预览(默认):** 需要 `--source `,不写入任何内容,报告发现、可用、冲突与无效条目数量。 +- **应用(`--apply`):** 需要 `--source ` 且代理已停止(`ocx stop`),在 Remote Link Child 上会被拒绝。重复指定 `--provider ` 可仅导入所选条目,传入 `--allow-private-network` 可允许环回或私有网络 base URL。即使未事先预览,`--apply` 也会在写入无密钥提供商条目及回滚日志 `$OPENCODEX_HOME/cc-switch-provider-import-journal.json` 之前输出每个导入提供商的 ID、默认模型与目标 URL(文本模式输出到 `stdout`,`--json` 模式输出到 `stderr`);存在活动回滚日志时再次运行 `--apply` 会被拒绝。核对输出条目后启动代理(在另一终端运行 `ocx start` 或运行 `ocx ensure`),再通过 `printf '%s\n' "$API_KEY" | ocx account add-key `(管道标准输入)或 `ocx login ` 添加凭据。 +- **恢复(`--restore`):** 独立的回滚路径(而非添加凭据的后续步骤),要求代理已停止(`ocx stop`),在 Remote Link Child 上会被拒绝,且不接受 `--source`、`--provider` 或 `--allow-private-network`。它会以只读字节方式检查 `auth.json`(不可读或格式损坏时安全保留),仅从活动日志中移除仍保持导入时无密钥状态、未被选为 `defaultProvider`,且未被路由、组合(combos)、回退策略(fallback policy)、OAuth 账户或 API 密钥引用的提供商。 + ## 认证 ### `ocx login ` diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md b/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md index e5e3c6dd68f..a743fe07a5d 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md @@ -24,6 +24,7 @@ description: 供應商設定、憑證、配額與模型目錄指令。 | `quota` | `--refresh`, `--json` | 讀取供應商配額報告。 | | `presets` | `--json` | 列出儀表板供應商預設。 | | `account-mode` | `pool`, `direct`, `--json` | 選擇池化或直接的 Codex 帳號路由。 | +| `import-cc-switch` | `--source `, `--provider `, `--apply`, `--restore`, `--allow-private-network`, `--json` | 從 CC-Switch SQLite(`cc-switch.db`)或舊版 JSON 檔案預覽、選擇性套用或還原無金鑰供應商項目。 | 預設的 `add`、`remove` 與 `set-default` 修改本機設定。修改執行中的代理需加 `--live`,線上刪除還需 `--yes`。`--sync --json` 同樣會在儲存後嘗試同步;失敗時保留儲存結果,以非零狀態結束,並回傳 `needsSync: true`。`--live` 不能與 `--sync` 同用。pacing 與 snapshot/apply 操作請見[英文指南](/reference/cli/providers-accounts/#snapshot-edit-and-apply-with-a-baseline)。 @@ -40,6 +41,27 @@ ocx models live --provider ark --json `--jsonl` 僅輸出已設定的供應商,每行一個 JSON 物件。每個物件的欄位與 `--json` 輸出中 `configured` 陣列的元素相同,不包含 `registryCount` 摘要。指令碼可以逐行處理這些物件。`--json` 與 `--jsonl` 不能同時使用。 +### 無金鑰匯入 CC-Switch 供應商 + +```bash +ocx provider import-cc-switch --source ~/.cc-switch/cc-switch.db +ocx stop +ocx provider import-cc-switch --source ~/.cc-switch/cc-switch.db --provider my-provider --apply +# 在另一個終端機啟動代理(或執行 ocx ensure),再新增金鑰: +ocx start +printf '%s\n' "$API_KEY" | ocx account add-key my-provider + +# 獨立的回滾路徑(先停止代理;僅移除未修改的無金鑰項目): +ocx stop +ocx provider import-cc-switch --restore +``` + +`ocx provider import-cc-switch` 僅從一般本機 CC-Switch SQLite 資料庫或舊版 JSON 檔案投射明確白名單內的非機密中繼資料欄位(供應商 ID、應用類型、base URL、協定轉接器與預設模型)。為掃描輸入會暫時讀取來源與內嵌設定位元組,但金鑰/憑證欄位、自由文字顯示名稱與 TOML 註解會在位元組層級略過,絕不解碼為字串、寫入磁碟或記錄日誌,且暫存位元組緩衝區會在 `finally` 中清零。窄化 base URL 路徑規則(`http(s)://host[:port]` 配空路徑或 1–4 個小寫 `[a-z0-9._-]` API 路徑段,如 `/v1`、`/api/v1` 或 `/compatible-mode/v1`)與識別碼/模型語法檢查僅作為縱深防禦並拒絕不合規值(不回顯原值)。來源檔案透過 `O_NOFOLLOW | O_NONBLOCK` 描述元驗證單次開啟,SQLite 在記憶體中唯讀反序列化且不寫入暫存檔;具有多個硬連結(`nlink > 1`)的 SQLite 檔案會被拒絕以防透過別名路徑繞過附屬檔檢查,若同層 `-wal` 或 `-journal` 檔案非空則會拒絕匯入並提示先關閉 CC-Switch(或執行資料庫 checkpoint)再重試。請僅從受信任的本機 CC-Switch 來源檔案匯入,並在新增憑證前核對輸出的每個 ID、模型與 `baseUrl`: + +- **預覽(預設):** 需要 `--source `,不寫入任何內容,回報發現、可用、衝突與無效項目數量。 +- **套用(`--apply`):** 需要 `--source ` 且代理已停止(`ocx stop`),在 Remote Link Child 上會被拒絕。重複指定 `--provider ` 可僅匯入所選項目,傳入 `--allow-private-network` 可允許迴路或私有網路 base URL。即使未事先預覽,`--apply` 也會在寫入無金鑰供應商項目與回滾日誌 `$OPENCODEX_HOME/cc-switch-provider-import-journal.json` 之前輸出每個匯入供應商的 ID、預設模型與目標 URL(文字模式輸出至 `stdout`,`--json` 模式輸出至 `stderr`);存在使用中回滾日誌時再次執行 `--apply` 會被拒絕。核對輸出項目後啟動代理(在另一終端機執行 `ocx start` 或執行 `ocx ensure`),再透過 `printf '%s\n' "$API_KEY" | ocx account add-key `(管線標準輸入)或 `ocx login ` 新增憑證。 +- **還原(`--restore`):** 獨立的回滾路徑(而非新增憑證的後續步驟),要求代理已停止(`ocx stop`),在 Remote Link Child 上會被拒絕,且不接受 `--source`、`--provider` 或 `--allow-private-network`。它會以唯讀位元組方式檢查 `auth.json`(無法讀取或格式損毀時安全保留),僅從使用中日誌移除仍維持匯入時無金鑰狀態、未被選為 `defaultProvider`,且未被路由、組合(combos)、回退策略(fallback policy)、OAuth 帳號或 API 金鑰參照的供應商。 + ## 認證 ### `ocx login ` diff --git a/scripts/test-layout/layout-providers.json b/scripts/test-layout/layout-providers.json index cabfce58fb6..e79f4b53fc7 100644 --- a/scripts/test-layout/layout-providers.json +++ b/scripts/test-layout/layout-providers.json @@ -1,4 +1,5 @@ { "free-directory-runcrate.test.ts": "providers", + "cc-switch-import.test.ts": "providers", "kiro-completion-with-real-tools.test.ts": "providers/kiro" } diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 2f832dd7e0d..dd2e8519dcf 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -470,7 +470,7 @@ "clearable-deadline.test.ts": "lib", "cli-account-alias-target.test.ts": "cli", "cli-account-slot-identity.test.ts": "cli", "cli-account-cancel-flow.test.ts": "cli", - "cli-account-kiro-device.test.ts": "cli", "cli-account-orca-import.test.ts": "cli", + "cli-account-kiro-device.test.ts": "cli", "cli-account-orca-import.test.ts": "cli", "cli-provider-cc-switch-import.test.ts": "cli", "cli-account-pin-drain.test.ts": "cli", "cli-account-pool-verbs.test.ts": "cli", "cli-account-threshold.test.ts": "cli", diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index 2c6cab6bca0..4a1e28959bd 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -26,7 +26,7 @@ These answer in the CLI head and never reach the proxy, so they work with nothin | Chapter | Declared capabilities | |---|---| | [lifecycle](01_surface_lifecycle.md) | 14 | -| [providers-models](01_surface_providers-models.md) | 49 | +| [providers-models](01_surface_providers-models.md) | 50 | | [accounts](01_surface_accounts.md) | 42 | | [agents-routing](01_surface_agents-routing.md) | 67 | | [integrations](01_surface_integrations.md) | 41 | @@ -415,6 +415,10 @@ Original invocation order. These headings preserve links to the previous single- [State-changing task](01_surface_providers-models.md#ocx-provider-selected) +### `ocx provider import-cc-switch` + +[State-changing task](01_surface_providers-models.md#ocx-provider-import-cc-switch) + ### `ocx models list` [Read-oriented task](01_surface_providers-models.md#ocx-models-list) @@ -1509,6 +1513,6 @@ Original invocation order. These headings preserve links to the previous single- ## Counts -- declared capabilities: 365 -- of those, state-changing: 223 +- declared capabilities: 366 +- of those, state-changing: 224 - head-resolved invocations: 2 diff --git a/skills/ocx/references/01_surface_providers-models.md b/skills/ocx/references/01_surface_providers-models.md index 74abd07eda5..5f9804ef3fe 100644 --- a/skills/ocx/references/01_surface_providers-models.md +++ b/skills/ocx/references/01_surface_providers-models.md @@ -8,7 +8,7 @@ Use these declarations to choose a task, then check its flags and authority before execution. Non-mutating probes may still contact providers, consume quota or refresh caches. -Declared capabilities: 49. +Declared capabilities: 50. ### `ocx models price` @@ -359,6 +359,28 @@ State-changing: yes. JSON mode: `envelope`. - Without a mutation flag this reads only. --clear sends models:[], not a model named -. - Read JSON is the CLI projection {provider, selected: string[], available: string[]}, not the API's provider-keyed maps; missing entries become empty arrays. --set/--clear JSON returns the server's write receipt unchanged. +### `ocx provider import-cc-switch` + +Usage: `ocx provider import-cc-switch [--source ] [--provider ]... [--apply | --restore] [--allow-private-network] [--json]` + +Preview, apply, or restore keyless CC-Switch provider entries locally. + +State-changing: yes. JSON mode: `payload`. + +Drives no management route. + +| Flag | Value | Meaning | +|---|---|---| +| `--source` | string | CC-Switch SQLite database or legacy JSON file; required for preview and apply. | +| `--provider` | string | Repeatable source ID or target provider name filter. | +| `--apply` | boolean | Write eligible keyless provider entries and record a rollback journal; requires a stopped proxy. | +| `--restore` | boolean | Remove unedited keyless providers recorded by the active import journal; requires a stopped proxy. | +| `--allow-private-network` | boolean | Permit loopback or private-network provider base URLs. | +| `--json` | boolean | Emit the preview, apply, or restore report as JSON. | + +- Projects values only from an explicit allowlist of non-secret metadata fields (provider IDs, app types, base URLs, wire dialects, and default models); source and embedded-config bytes are read transiently while secret fields, free-text names, and TOML comments are skipped at the byte level and never decoded into strings, written, or logged, and transient byte buffers are zeroed in finally. +- Only import from a trusted local CC-Switch source: apply prints every imported ID, model, and destination URL before writing, refuses SQLite hard links (nlink > 1) and non-empty -wal/-journal sidecars until CC-Switch is closed, and opens sources read-only with O_NOFOLLOW|O_NONBLOCK descriptor checks; restore inspects auth.json read-only at the byte level and retains any provider that was edited, selected as defaultProvider, or referenced by routing, combos, fallback policy, OAuth accounts, or API keys. + ### `ocx models list` Usage: `ocx models list [--provider ] [--json]` diff --git a/src/cli/capabilities-provider-models.ts b/src/cli/capabilities-provider-models.ts index 7f364594657..871712a790e 100644 --- a/src/cli/capabilities-provider-models.ts +++ b/src/cli/capabilities-provider-models.ts @@ -79,6 +79,13 @@ export const PROVIDER_MODEL_CAPABILITIES: readonly Capability[] = [ flags: [{ name: "--set", value: "string", summary: "Replace with comma-separated upstream IDs." }, { name: "--clear", value: "boolean", summary: "Clear the allowlist to show all; exclusive with --set." }, { name: "--json", value: "boolean", summary: "Emit the selected view or receipt." }], details: ["Without a mutation flag this reads only. --clear sends models:[], not a model named -.", "Read JSON is the CLI projection {provider, selected: string[], available: string[]}, not the API's provider-keyed maps; missing entries become empty arrays. --set/--clear JSON returns the server's write receipt unchanged."], }, + { + command: ["provider", "import-cc-switch"], summary: "Preview, apply, or restore keyless CC-Switch provider entries locally.", + usage: "ocx provider import-cc-switch [--source ] [--provider ]... [--apply | --restore] [--allow-private-network] [--json]", + routes: [], mutates: true, json: "payload", + flags: [{ name: "--source", value: "string", summary: "CC-Switch SQLite database or legacy JSON file; required for preview and apply." }, { name: "--provider", value: "string", summary: "Repeatable source ID or target provider name filter." }, { name: "--apply", value: "boolean", summary: "Write eligible keyless provider entries and record a rollback journal; requires a stopped proxy." }, { name: "--restore", value: "boolean", summary: "Remove unedited keyless providers recorded by the active import journal; requires a stopped proxy." }, { name: "--allow-private-network", value: "boolean", summary: "Permit loopback or private-network provider base URLs." }, { name: "--json", value: "boolean", summary: "Emit the preview, apply, or restore report as JSON." }], + details: ["Projects values only from an explicit allowlist of non-secret metadata fields (provider IDs, app types, base URLs, wire dialects, and default models); source and embedded-config bytes are read transiently while secret fields, free-text names, and TOML comments are skipped at the byte level and never decoded into strings, written, or logged, and transient byte buffers are zeroed in finally.", "Only import from a trusted local CC-Switch source: apply prints every imported ID, model, and destination URL before writing, refuses SQLite hard links (nlink > 1) and non-empty -wal/-journal sidecars until CC-Switch is closed, and opens sources read-only with O_NOFOLLOW|O_NONBLOCK descriptor checks; restore inspects auth.json read-only at the byte level and retains any provider that was edited, selected as defaultProvider, or referenced by routing, combos, fallback policy, OAuth accounts, or API keys."], + }, { command: ["models", "list"], summary: "List static configured models locally.", usage: "ocx models list [--provider ] [--json]", routes: [], mutates: false, json: "envelope", diff --git a/src/cli/provider-cc-switch-import.ts b/src/cli/provider-cc-switch-import.ts new file mode 100644 index 00000000000..d603843fcf3 --- /dev/null +++ b/src/cli/provider-cc-switch-import.ts @@ -0,0 +1,185 @@ +import type { runCcSwitchProviderImport } from "../providers/cc-switch-import"; +import { terminalSafeText } from "./runtime-api"; + +const USAGE = "Usage: ocx provider import-cc-switch [--source ] [--provider ]... [--apply | --restore] [--allow-private-network] [--json]"; + +export interface CcSwitchImportCommandDeps { + runImport?: typeof runCcSwitchProviderImport; +} + +const SAFE_ERROR_CODES: Readonly> = { + "Stop the opencodex proxy before applying or restoring a CC-Switch provider import.": "proxy_running", + "Initialize a valid opencodex configuration before importing or restoring providers.": "config_unavailable", + "An active CC-Switch import journal already exists; run 'ocx provider import-cc-switch --restore' before applying another import.": "active_journal_exists", + "No CC-Switch provider import journal found to restore.": "journal_missing", + "Existing CC-Switch import journal is corrupt; inspect or restore before importing.": "journal_corrupt", + "Existing CC-Switch import journal is unreadable; inspect or restore before importing.": "journal_unreadable", + "CC-Switch source does not contain one or more requested --provider IDs.": "provider_not_found", + "CC-Switch SQLite source has uncheckpointed WAL changes (-wal is non-empty); close CC-Switch (or checkpoint the database) and retry.": "sqlite_wal_active", + "CC-Switch SQLite source has an unreadable WAL file (-wal); close CC-Switch (or checkpoint the database) and retry.": "sqlite_wal_active", + "CC-Switch SQLite source has an active rollback journal (-journal); close CC-Switch and retry.": "sqlite_journal_active", + "CC-Switch SQLite source has an unreadable rollback journal (-journal); close CC-Switch and retry.": "sqlite_journal_active", + "CC-Switch SQLite source has multiple hard links; import the original CC-Switch database path directly.": "sqlite_hardlink_refused", +}; + +function formatCandidateLine(p: { name: string; sourceId: string; appType: string; adapter: string; baseUrl: string; defaultModel?: string }): string { + const modelText = terminalSafeText(p.defaultModel ?? "none"); + return ` id=${terminalSafeText(p.name)} (source=${terminalSafeText(p.sourceId)}, ${terminalSafeText(p.appType)} -> ${terminalSafeText(p.adapter)}) model=${modelText} url=${terminalSafeText(p.baseUrl)}`; +} + +/** Local-only keyless provider import from CC-Switch SQLite or JSON. */ +export async function cmdProviderCcSwitchImport(args: string[], deps: CcSwitchImportCommandDeps = {}): Promise { + let sourcePath: string | undefined; + const selectedProviders: string[] = []; + let apply = false; + let restore = false; + let allowPrivateNetwork = false; + const wantsJson = args.includes("--json"); + const seenBooleans = new Set(); + + for (let i = 0; i < args.length; i++) { + const arg = args[i]!; + if (arg === "--source" || arg === "--provider") { + const val = args[++i]; + if (!val || val.startsWith("-")) { + console.error(USAGE); + return 1; + } + if (arg === "--source") { + if (sourcePath !== undefined) { + console.error(USAGE); + return 1; + } + sourcePath = val; + } else { + selectedProviders.push(val); + } + } else if (arg === "--apply" || arg === "--restore" || arg === "--allow-private-network" || arg === "--json") { + if (seenBooleans.has(arg)) { + console.error(USAGE); + return 1; + } + seenBooleans.add(arg); + if (arg === "--apply") apply = true; + else if (arg === "--restore") restore = true; + else if (arg === "--allow-private-network") allowPrivateNetwork = true; + } else { + console.error(USAGE); + return 1; + } + } + + if (apply && restore) { + console.error(USAGE); + return 1; + } + if (restore) { + if (sourcePath !== undefined || selectedProviders.length > 0 || allowPrivateNetwork) { + console.error(USAGE); + return 1; + } + } else if (!sourcePath) { + console.error(USAGE); + return 1; + } + + const mode = restore ? "restore" : apply ? "apply" : "preview"; + if (mode !== "preview") { + const refusal = (await import("./link-child")).linkChildProviderChangeRefusal("ocx provider import-cc-switch"); + if (refusal) { + if (wantsJson) console.log(JSON.stringify({ error: "link_child_refused", message: refusal })); + else console.error(refusal); + return 1; + } + } else { + await (await import("./link-child")).printLinkChildLocalNotice("The CC-Switch provider import preview"); + } + + try { + const run = deps.runImport ?? (await import("../providers/cc-switch-import")).runCcSwitchProviderImport; + let printedBeforeWrite = false; + const result = await run({ + mode, + sourcePath, + selectedProviders, + allowPrivateNetwork, + ...(apply + ? { + onBeforeApplyWrite: providers => { + const emit = wantsJson ? console.error : console.log; + printedBeforeWrite = true; + emit("Importing keyless provider entries (projects allowlisted non-secret metadata only; secret fields are skipped at the byte level and never decoded, written, or logged — verify IDs, models, and URLs from a trusted CC-Switch source before adding credentials):"); + for (const p of providers) { + emit(formatCandidateLine(p)); + } + }, + } + : {}), + }); + + if (result.mode === "restore") { + if (wantsJson) { + console.log(JSON.stringify({ + mode: result.mode, + restored: result.restored, + restoredNames: result.restoredNames, + alreadyAbsent: result.alreadyAbsent, + retainedEdited: result.retainedEdited, + retainedNames: result.retainedNames, + })); + } else { + console.log(`CC-Switch restore: ${result.restored} removed, ${result.alreadyAbsent} already absent, ${result.retainedEdited} retained (edited or referenced).`); + if (result.retainedNames.length > 0) { + console.log(`Retained providers: ${result.retainedNames.map(terminalSafeText).join(", ")}`); + } + } + return 0; + } + + if (wantsJson) { + console.log(JSON.stringify({ + mode: result.mode, + discovered: result.discovered, + eligible: result.eligible, + imported: result.imported, + conflicts: result.conflicts, + conflictNames: result.conflictNames, + invalid: result.invalid, + invalidReasons: result.invalidReasons, + providers: result.providers, + ...(apply && result.imported > 0 + ? { trustNotice: "Verify imported baseUrl endpoints come from a trusted CC-Switch source before adding credentials." } + : {}), + })); + } else { + console.log(`CC-Switch (${result.mode}): ${result.discovered} discovered, ${result.eligible} eligible, ${result.imported} imported, ${result.conflicts} conflicts, ${result.invalid} invalid.`); + if (!printedBeforeWrite) { + if (apply && result.imported > 0) { + console.log("Importing keyless provider entries (projects allowlisted non-secret metadata only; secret fields are skipped at the byte level and never decoded, written, or logged — verify IDs, models, and URLs from a trusted CC-Switch source before adding credentials):"); + } + for (const p of result.providers) { + console.log(formatCandidateLine(p)); + } + } + if (result.invalid > 0) { + const reasons = Object.entries(result.invalidReasons).map(([k, v]) => `${k}=${v}`).join(", "); + console.log(`Invalid reasons: ${reasons}`); + } + console.log(apply + ? (result.imported > 0 + ? "Keyless provider entries saved. Only add credentials to endpoints you trust: start the proxy ('ocx start') and run 'ocx account add-key ' (piped stdin) or 'ocx login ', or undo while stopped with 'ocx provider import-cc-switch --restore'." + : "No eligible non-conflicting providers were imported; configuration and journal are unchanged.") + : "Preview only (projects allowlisted non-secret metadata only; secret fields are skipped at the byte level and never decoded, written, or logged). Verify IDs, models, and destination URLs from a trusted source and stop the proxy before repeating with --apply."); + } + return result.invalid > 0 && result.eligible === 0 ? 1 : 0; + } catch (err) { + const msg = err instanceof Error ? err.message : ""; + const code = SAFE_ERROR_CODES[msg] ?? "cc_switch_import_failed"; + const safeMsg = SAFE_ERROR_CODES[msg] || msg.startsWith("CC-Switch ") + ? terminalSafeText(msg) + : "CC-Switch provider import failed. Verify the source file, destination config, and stopped proxy."; + if (wantsJson) console.log(JSON.stringify({ error: code, message: safeMsg })); + else console.error(safeMsg); + return 1; + } +} diff --git a/src/cli/provider.ts b/src/cli/provider.ts index e2bd461240f..9d8688b84bc 100644 --- a/src/cli/provider.ts +++ b/src/cli/provider.ts @@ -655,6 +655,11 @@ export async function handleProviderCommand(args: string[], deps: ProviderComman process.exitCode = await handleProviderPacingCommand(subArgs, deps); return; } + if (sub === "import-cc-switch") { + const { cmdProviderCcSwitchImport } = await import("./provider-cc-switch-import"); + process.exitCode = await cmdProviderCcSwitchImport(subArgs); + return; + } // #6957: a link Child's local providers are inactive; changing them is refused like any other // provider change, which belongs to the Home. diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 26ba3e5db96..2a73eec650c 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -310,6 +310,7 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ " show Show provider config details", " set-default Change the default provider", " add/remove/set-default support --live; live removal also requires --yes.", + " import-cc-switch Preview, apply, or restore keyless CC-Switch provider entries", " selected Show or set the provider model allowlist", " quota Show provider quota reports", " resets Show recently detected quota resets", diff --git a/src/providers/cc-switch-import-bytes.ts b/src/providers/cc-switch-import-bytes.ts new file mode 100644 index 00000000000..da68528250c --- /dev/null +++ b/src/providers/cc-switch-import-bytes.ts @@ -0,0 +1,991 @@ +/** + * Pure byte-level validators, RFC 8259 JSON string/key scanners, and Codex TOML byte parser + * for keyless CC-Switch provider import. + */ +export class CcSwitchImportError extends Error { + constructor(message: string) { + super(message); + this.name = "CcSwitchImportError"; + } +} + +export type CcSwitchInvalidReason = + | "missing_base_url" + | "invalid_base_url" + | "private_network_refused" + | "invalid_provider_name" + | "unsupported_app_type" + | "ambiguous_codex_provider" + | "disallowed_metadata" + | "malformed_entry"; + +export const METADATA_REFUSED_SENTINEL = "__METADATA_REFUSED__"; +export const URL_SYNTAX_REFUSED_SENTINEL = "__URL_REFUSED__"; +const MAX_SQLITE_BYTES = 32 * 1024 * 1024; +export const MAX_CODEX_TOML_BYTES = 64 * 1024; + +export type MetadataFieldKind = "id" | "app" | "model" | "url" | "wire"; + +function hasControlChars(value: string): boolean { + for (let i = 0; i < value.length; i++) { + const c = value.charCodeAt(i); + if (c <= 0x1f || c === 0x7f) return true; + } + return false; +} + +function isDigitByte(b: number): boolean { + return b >= 0x30 && b <= 0x39; +} +function isLowerByte(b: number): boolean { + return b >= 0x61 && b <= 0x7a; +} +function isUpperByte(b: number): boolean { + return b >= 0x41 && b <= 0x5a; +} +function isAlphaByte(b: number): boolean { + return isLowerByte(b) || isUpperByte(b); +} +function isHexByte(b: number): boolean { + return isDigitByte(b) || (b >= 0x61 && b <= 0x66) || (b >= 0x41 && b <= 0x46); +} +function toLowerByte(b: number): number { + return isUpperByte(b) ? b + 32 : b; +} + +export function equalsAsciiBytes(slice: Uint8Array, ascii: string): boolean { + if (slice.length !== ascii.length) return false; + for (let i = 0; i < ascii.length; i++) { + if (slice[i] !== ascii.charCodeAt(i)) return false; + } + return true; +} + +export function parseHex4Bytes(bytes: Uint8Array, offset: number): number { + if (offset + 4 > bytes.length) return -1; + let out = 0; + for (let i = 0; i < 4; i++) { + const b = bytes[offset + i]!; + let v: number; + if (b >= 0x30 && b <= 0x39) v = b - 0x30; + else if (b >= 0x41 && b <= 0x46) v = b - 0x41 + 10; + else if (b >= 0x61 && b <= 0x66) v = b - 0x61 + 10; + else return -1; + out = (out << 4) | v; + } + return out; +} + +export function encodeCodePointUtf8(cp: number, target: Uint8Array, offset: number): number { + if (cp <= 0x7f) { + target[offset] = cp; + return 1; + } + if (cp <= 0x7ff) { + target[offset] = 0xc0 | (cp >> 6); + target[offset + 1] = 0x80 | (cp & 0x3f); + return 2; + } + if (cp <= 0xffff) { + target[offset] = 0xe0 | (cp >> 12); + target[offset + 1] = 0x80 | ((cp >> 6) & 0x3f); + target[offset + 2] = 0x80 | (cp & 0x3f); + return 3; + } + target[offset] = 0xf0 | (cp >> 18); + target[offset + 1] = 0x80 | ((cp >> 12) & 0x3f); + target[offset + 2] = 0x80 | ((cp >> 6) & 0x3f); + target[offset + 3] = 0x80 | (cp & 0x3f); + return 4; +} + +/** + * Strict RFC 8259 + UTF-8 byte-level JSON string validator. + * Validates escapes (including paired UTF-16 surrogates) and multi-byte UTF-8 sequences + * without decoding string contents into JavaScript strings. + */ +export function validateUtf8AndJsonStringBounds( + bytes: Uint8Array, + quotePos: number, +): { start: number; end: number; hasEscapes: boolean; nextPos: number } | null { + if (quotePos >= bytes.length || bytes[quotePos] !== 0x22) return null; + let pos = quotePos + 1; + const start = pos; + let hasEscapes = false; + while (pos < bytes.length) { + const b = bytes[pos]!; + if (b < 0x20) return null; + if (b === 0x22) { + return { start, end: pos, hasEscapes, nextPos: pos + 1 }; + } + if (b === 0x5c) { + hasEscapes = true; + pos++; + if (pos >= bytes.length) return null; + const esc = bytes[pos]!; + pos++; + if ( + esc === 0x22 || esc === 0x5c || esc === 0x2f + || esc === 0x62 || esc === 0x66 || esc === 0x6e || esc === 0x72 || esc === 0x74 + ) { + continue; + } + if (esc === 0x75) { + const cu = parseHex4Bytes(bytes, pos); + if (cu < 0) return null; + pos += 4; + if (cu >= 0xdc00 && cu <= 0xdfff) return null; + if (cu >= 0xd800 && cu <= 0xdbff) { + if (pos + 6 > bytes.length || bytes[pos] !== 0x5c || bytes[pos + 1] !== 0x75) return null; + const low = parseHex4Bytes(bytes, pos + 2); + if (low < 0xdc00 || low > 0xdfff) return null; + pos += 6; + } + continue; + } + return null; + } + if (b < 0x80) { + pos++; + continue; + } + if (b >= 0xc2 && b <= 0xdf) { + if (pos + 1 >= bytes.length) return null; + const c1 = bytes[pos + 1]!; + if ((c1 & 0xc0) !== 0x80) return null; + pos += 2; + continue; + } + if (b >= 0xe0 && b <= 0xef) { + if (pos + 2 >= bytes.length) return null; + const c1 = bytes[pos + 1]!; + const c2 = bytes[pos + 2]!; + if ((c1 & 0xc0) !== 0x80 || (c2 & 0xc0) !== 0x80) return null; + if (b === 0xe0 && c1 < 0xa0) return null; + if (b === 0xed && c1 >= 0xa0) return null; + pos += 3; + continue; + } + if (b >= 0xf0 && b <= 0xf4) { + if (pos + 3 >= bytes.length) return null; + const c1 = bytes[pos + 1]!; + const c2 = bytes[pos + 2]!; + const c3 = bytes[pos + 3]!; + if ((c1 & 0xc0) !== 0x80 || (c2 & 0xc0) !== 0x80 || (c3 & 0xc0) !== 0x80) return null; + if (b === 0xf0 && c1 < 0x90) return null; + if (b === 0xf4 && c1 > 0x8f) return null; + pos += 4; + continue; + } + return null; + } + return null; +} + +export function validateJsonPrimitiveBytes(bytes: Uint8Array, start: number, end: number): boolean { + const slice = bytes.subarray(start, end); + if (slice.length === 0) return false; + if (equalsAsciiBytes(slice, "true") || equalsAsciiBytes(slice, "false") || equalsAsciiBytes(slice, "null")) { + return true; + } + let i = 0; + if (slice[i] === 0x2d) { + i++; + if (i >= slice.length) return false; + } + if (slice[i] === 0x30) { + i++; + } else if (slice[i]! >= 0x31 && slice[i]! <= 0x39) { + while (i < slice.length && isDigitByte(slice[i]!)) i++; + } else { + return false; + } + if (i < slice.length && slice[i] === 0x2e) { + i++; + if (i >= slice.length || !isDigitByte(slice[i]!)) return false; + while (i < slice.length && isDigitByte(slice[i]!)) i++; + } + if (i < slice.length && (slice[i] === 0x65 || slice[i] === 0x45)) { + i++; + if (i < slice.length && (slice[i] === 0x2b || slice[i] === 0x2d)) i++; + if (i >= slice.length || !isDigitByte(slice[i]!)) return false; + while (i < slice.length && isDigitByte(slice[i]!)) i++; + } + return i === slice.length; +} + +export function unescapeJsonStringBytesBounded( + bytes: Uint8Array, + bounds: { start: number; end: number; hasEscapes: boolean }, + maxBytes: number, +): Uint8Array | null { + const rawLen = bounds.end - bounds.start; + if (rawLen > maxBytes * 6) return null; + const out = new Uint8Array(Math.max(rawLen, 4)); + let r = bounds.start; + let w = 0; + while (r < bounds.end) { + const b = bytes[r]!; + if (b !== 0x5c) { + out[w++] = b; + r++; + continue; + } + r++; + const esc = bytes[r]!; + r++; + if (esc === 0x22 || esc === 0x5c || esc === 0x2f) out[w++] = esc; + else if (esc === 0x62) out[w++] = 0x08; + else if (esc === 0x66) out[w++] = 0x0c; + else if (esc === 0x6e) out[w++] = 0x0a; + else if (esc === 0x72) out[w++] = 0x0d; + else if (esc === 0x74) out[w++] = 0x09; + else if (esc === 0x75) { + let cp = parseHex4Bytes(bytes, r); + if (cp < 0) { out.fill(0); return null; } + r += 4; + if (cp >= 0xd800 && cp <= 0xdbff) { + const low = parseHex4Bytes(bytes, r + 2); + if (low < 0xdc00 || low > 0xdfff) { out.fill(0); return null; } + r += 6; + cp = ((cp - 0xd800) << 10) + (low - 0xdc00) + 0x10000; + } else if (cp >= 0xdc00 && cp <= 0xdfff) { + out.fill(0); + return null; + } + w += encodeCodePointUtf8(cp, out, w); + } else { + out.fill(0); + return null; + } + } + if (w > maxBytes) { + out.fill(0); + return null; + } + const result = new Uint8Array(w); + result.set(out.subarray(0, w)); + out.fill(0); + return result; +} + +export function matchKnownJsonKeyBytes( + bytes: Uint8Array, + bounds: { start: number; end: number; hasEscapes: boolean }, + allowed: readonly T[], +): T | null { + if (!bounds.hasEscapes) { + const raw = bytes.subarray(bounds.start, bounds.end); + for (const candidate of allowed) { + if (equalsAsciiBytes(raw, candidate)) return candidate; + } + return null; + } + const unescaped = unescapeJsonStringBytesBounded(bytes, bounds, 48); + if (!unescaped) return null; + try { + for (const candidate of allowed) { + if (equalsAsciiBytes(unescaped, candidate)) return candidate; + } + return null; + } finally { + unescaped.fill(0); + } +} + +const FORBIDDEN_SUBSTRINGS_LOWER = [ + "sk-", "rk-", "pk-", "ak-", "ghp_", "gho_", "github_pat_", "xoxb-", "xoxp-", "xoxa-", "xoxr-", "xoxs-", + "aiza", "eyj", "bearer", "secret", "apikey", "api_key", "passwd", "password", "private_key", + "access_token", "refresh_token", "auth_token", +].map(s => Buffer.from(s, "ascii")); + +function containsForbiddenBytes(slice: Uint8Array): boolean { + for (const pat of FORBIDDEN_SUBSTRINGS_LOWER) { + if (slice.length < pat.length) continue; + for (let i = 0; i <= slice.length - pat.length; i++) { + let match = true; + for (let j = 0; j < pat.length; j++) { + if (toLowerByte(slice[i + j]!) !== pat[j]!) { + match = false; + break; + } + } + if (match) return true; + } + } + // Reject 3+ grouped 4-hex segments like a1b2-c3d4-e5f6 (UUID / grouped hex key). + if (slice.length >= 14) { + for (let i = 0; i <= slice.length - 14; i++) { + if ( + isHexByte(slice[i]!) && isHexByte(slice[i + 1]!) && isHexByte(slice[i + 2]!) && isHexByte(slice[i + 3]!) + && slice[i + 4] === 0x2d + && isHexByte(slice[i + 5]!) && isHexByte(slice[i + 6]!) && isHexByte(slice[i + 7]!) && isHexByte(slice[i + 8]!) + && slice[i + 9] === 0x2d + && isHexByte(slice[i + 10]!) && isHexByte(slice[i + 11]!) && isHexByte(slice[i + 12]!) && isHexByte(slice[i + 13]!) + ) { + return true; + } + } + } + return false; +} + +const ALLOWED_API_TERMINAL_PATH_SEGMENTS = [ + "api", "openai", "anthropic", "codex", "chat", "responses", "gateway", "compat", "models", "inference", "llm", "proxy", +] as const; + +function isCanonicalUuidBytes(slice: Uint8Array): boolean { + if (slice.length !== 36) return false; + for (let i = 0; i < 36; i++) { + const b = slice[i]!; + if (i === 8 || i === 13 || i === 18 || i === 23) { + if (b !== 0x2d) return false; + } else if (!isHexByte(b)) { + return false; + } + } + return true; +} + +function isModelParamSizeSeg(slice: Uint8Array, start: number, end: number): boolean { + const n = end - start; + if (n < 2 || n > 4 || slice[end - 1] !== 0x62) return false; + const dStart = slice[start] === 0x61 ? start + 1 : start; + if (dStart >= end - 1 || slice[dStart] === 0x30) return false; + for (let k = dStart; k < end - 1; k++) if (!isDigitByte(slice[k]!)) return false; + return true; +} + +function isApiVersionSegmentBytes(seg: Uint8Array): boolean { + // Matches v1..v9, v1.1, v1beta, v1beta1, etc. + if (seg.length < 2 || seg.length > 10 || seg[0] !== 0x76 || !(seg[1]! >= 0x31 && seg[1]! <= 0x39)) { + return false; + } + for (let i = 2; i < seg.length; i++) { + const b = seg[i]!; + if (!isLowerByte(b) && !isDigitByte(b) && b !== 0x2e) return false; + } + return seg[seg.length - 1] !== 0x2e; +} + +function isAllowedTerminalUrlSegmentBytes(seg: Uint8Array): boolean { + if (isApiVersionSegmentBytes(seg)) return true; + for (const allowed of ALLOWED_API_TERMINAL_PATH_SEGMENTS) { + if (equalsAsciiBytes(seg, allowed)) return true; + } + return false; +} + +function validateAuthorityBytes(auth: Uint8Array): boolean { + if (auth.length === 0 || auth.length > 190) return false; + let hostEnd = auth.length; + if (auth[0] === 0x5b) { + // Bracketed IPv6 literal: [hex:...] with optional :port + let closeIdx = -1; + let sawColon = false; + for (let i = 1; i < auth.length; i++) { + const b = auth[i]!; + if (b === 0x5d) { closeIdx = i; break; } + if (b === 0x3a) sawColon = true; + else if (!isHexByte(b) && b !== 0x2e) return false; + } + if (closeIdx < 3 || !sawColon) return false; + if (closeIdx === auth.length - 1) return true; + if (auth[closeIdx + 1] !== 0x3a) return false; + hostEnd = closeIdx + 1; + } else { + const colonIdx = auth.indexOf(0x3a); + if (colonIdx === 0) return false; + const host = colonIdx > 0 ? auth.subarray(0, colonIdx) : auth; + if (host.length === 0 || host.length > 180) return false; + if (host[0] === 0x2e || host[0] === 0x2d || host[host.length - 1] === 0x2d) return false; + for (let i = 0; i < host.length; i++) { + const b = host[i]!; + if (!isAlphaByte(b) && !isDigitByte(b) && b !== 0x2e && b !== 0x2d) return false; + if (b === 0x2e && i > 0 && host[i - 1] === 0x2e) return false; + } + if (colonIdx < 0) return true; + hostEnd = colonIdx; + } + // Validate :port (1..65535) + const portSlice = auth.subarray(hostEnd + 1); + if (portSlice.length === 0 || portSlice.length > 5) return false; + let port = 0; + for (let i = 0; i < portSlice.length; i++) { + const b = portSlice[i]!; + if (!isDigitByte(b)) return false; + port = port * 10 + (b - 0x30); + } + return port >= 1 && port <= 65535; +} + +/** + * Byte-level syntax & defense-in-depth validation enforced BEFORE decoding into a JavaScript string. + * Note: the primary security boundary is structural (secret-named fields are never read); this + * enforces narrow URL/identifier/model syntax as defense in depth. + */ +export function isAllowedMetadataBytes(slice: Uint8Array, kind: MetadataFieldKind): boolean { + const len = slice.length; + if (len === 0) return false; + for (let i = 0; i < len; i++) { + const b = slice[i]!; + if (b <= 0x20 || b >= 0x7f) return false; + } + // CC-Switch generates canonical 36-char randomUUID() primary keys for custom providers. + if (kind === "id" && isCanonicalUuidBytes(slice)) return true; + if (containsForbiddenBytes(slice)) return false; + + if (kind === "app") { + if (len > 16) return false; + let hasLower = false; + let hasUpper = false; + for (let i = 0; i < len; i++) { + const b = slice[i]!; + if (!isAlphaByte(b) && b !== 0x2d && b !== 0x5f) return false; + if (isLowerByte(b)) hasLower = true; + if (isUpperByte(b)) hasUpper = true; + } + if (len >= 10 && hasLower && hasUpper) return false; + return true; + } + + if (kind === "wire") { + if (len > 24) return false; + for (let i = 0; i < len; i++) { + const b = slice[i]!; + if (!isAlphaByte(b) && b !== 0x2d && b !== 0x5f) return false; + } + return true; + } + + if (kind === "url") { + if (len < 8 || len > 256) return false; + // Must start with http:// or https:// + const isHttp = + toLowerByte(slice[0]!) === 0x68 && toLowerByte(slice[1]!) === 0x74 && toLowerByte(slice[2]!) === 0x74 + && toLowerByte(slice[3]!) === 0x70; + if (!isHttp) return false; + let schemeEnd = 4; + if (toLowerByte(slice[4]!) === 0x73) schemeEnd = 5; + if (slice[schemeEnd] !== 0x3a || slice[schemeEnd + 1] !== 0x2f || slice[schemeEnd + 2] !== 0x2f) return false; + + let firstPathSlash = -1; + for (let i = schemeEnd + 3; i < len; i++) { + const b = slice[i]!; + // Reject @, ?, #, %, ~, quotes, backslashes + if (b === 0x40 || b === 0x3f || b === 0x23 || b === 0x25 || b === 0x7e || b === 0x22 || b === 0x27 || b === 0x5c) return false; + if (b === 0x2f && firstPathSlash < 0) firstPathSlash = i; + } + const authorityEnd = firstPathSlash >= 0 ? firstPathSlash : len; + if (!validateAuthorityBytes(slice.subarray(schemeEnd + 3, authorityEnd))) return false; + if (firstPathSlash < 0) return true; + const pathLen = len - firstPathSlash; + if (pathLen === 1) return true; + if (pathLen > 48) return false; + + const pathEnd = slice[len - 1] === 0x2f ? len - 1 : len; + if (pathEnd <= firstPathSlash + 1) return false; // Reject "//" + let segStart = firstPathSlash + 1; + let segCount = 0; + let totalPathDigits = 0; + let lastSeg: Uint8Array | null = null; + for (let i = firstPathSlash + 1; i <= pathEnd; i++) { + if (i < pathEnd && slice[i] !== 0x2f) continue; + const segLen = i - segStart; + if (segLen <= 0 || segLen > 20) return false; // Reject empty interior segment "//" + segCount++; + if (segCount > 4) return false; + const seg = slice.subarray(segStart, i); + if (equalsAsciiBytes(seg, ".") || equalsAsciiBytes(seg, "..")) return false; + const firstB = seg[0]!; + const lastB = seg[segLen - 1]!; + if ((!isLowerByte(firstB) && !isDigitByte(firstB)) || (!isLowerByte(lastB) && !isDigitByte(lastB))) { + return false; + } + let segDigits = 0; + let segHex = true; + for (let k = 0; k < segLen; k++) { + const b = seg[k]!; + // Strict lowercase [a-z0-9._-] path grammar only + if (!isLowerByte(b) && !isDigitByte(b) && b !== 0x2e && b !== 0x2d && b !== 0x5f) return false; + if (isDigitByte(b)) segDigits++; + if (!isHexByte(b)) segHex = false; + } + totalPathDigits += segDigits; + if (segDigits >= 3) return false; + if (segHex && segLen >= 8) return false; + lastSeg = seg; + segStart = i + 1; + } + if (totalPathDigits > 2) return false; + return lastSeg !== null && isAllowedTerminalUrlSegmentBytes(lastSeg); + } + + // kind === "id" or "model" + const maxLen = kind === "id" ? 48 : 80; + if (len > maxLen) return false; + let sepCount = 0; + let totalDigits = 0; + for (let i = 0; i < len; i++) { + const b = slice[i]!; + const isSep = b === 0x2d || b === 0x5f || b === 0x2e || (kind === "model" && (b === 0x2f || b === 0x3a)); + if (isSep) { + sepCount++; + continue; + } + if (!isAlphaByte(b) && !isDigitByte(b)) return false; + if (isDigitByte(b)) totalDigits++; + } + if (sepCount === 0 && len >= 16) return false; + if (kind === "id" && totalDigits > 5) return false; + + let segStart = 0; + let mixedDigitsTotal = 0; + let nonDateDigits = totalDigits; + let usedDateExemption = false; + let skipHyphenDateParts = 0; + let paramSizeSegs = 0; + for (let i = 0; i <= len; i++) { + const b = i < len ? slice[i]! : 0x2d; + const isSep = i === len || b === 0x2d || b === 0x5f || b === 0x2e || b === 0x2f || b === 0x3a; + if (!isSep) continue; + const segLen = i - segStart; + if (segLen > 16) return false; + if (segLen > 0) { + let segDigits = 0; + let segAllDigits = true; + let segHex = true; + let hasLower = false; + let hasUpper = false; + for (let k = segStart; k < i; k++) { + const cb = slice[k]!; + if (isDigitByte(cb)) segDigits++; + else segAllDigits = false; + if (!isHexByte(cb)) segHex = false; + if (isLowerByte(cb)) hasLower = true; + if (isUpperByte(cb)) hasUpper = true; + } + if (segAllDigits) { + if (skipHyphenDateParts > 0) { + skipHyphenDateParts--; + segStart = i + 1; + continue; + } + const isDate8 = + kind === "model" && !usedDateExemption && segLen === 8 + && slice[segStart] === 0x32 && slice[segStart + 1] === 0x30 + && ((slice[segStart + 4]! - 0x30) * 10 + (slice[segStart + 5]! - 0x30)) >= 1 + && ((slice[segStart + 4]! - 0x30) * 10 + (slice[segStart + 5]! - 0x30)) <= 12 + && ((slice[segStart + 6]! - 0x30) * 10 + (slice[segStart + 7]! - 0x30)) >= 1 + && ((slice[segStart + 6]! - 0x30) * 10 + (slice[segStart + 7]! - 0x30)) <= 31; + const isHyphenDate = + kind === "model" && !usedDateExemption && segLen === 4 + && slice[segStart] === 0x32 && slice[segStart + 1] === 0x30 + && segStart + 10 <= len && slice[segStart + 4] === 0x2d && slice[segStart + 7] === 0x2d + && isDigitByte(slice[segStart + 5]!) && isDigitByte(slice[segStart + 6]!) + && isDigitByte(slice[segStart + 8]!) && isDigitByte(slice[segStart + 9]!) + && ((slice[segStart + 5]! - 0x30) * 10 + (slice[segStart + 6]! - 0x30)) >= 1 + && ((slice[segStart + 5]! - 0x30) * 10 + (slice[segStart + 6]! - 0x30)) <= 12 + && ((slice[segStart + 8]! - 0x30) * 10 + (slice[segStart + 9]! - 0x30)) >= 1 + && ((slice[segStart + 8]! - 0x30) * 10 + (slice[segStart + 9]! - 0x30)) <= 31 + && (segStart + 10 === len || [0x2d, 0x5f, 0x2e, 0x2f, 0x3a].includes(slice[segStart + 10]!)); + if (isDate8) { + usedDateExemption = true; + nonDateDigits -= 8; + } else if (isHyphenDate) { + usedDateExemption = true; + skipHyphenDateParts = 2; + nonDateDigits -= 8; + } else if (segLen > 4) { + return false; + } + } else { + if (kind === "model" && paramSizeSegs < 2 && isModelParamSizeSeg(slice, segStart, i)) { + paramSizeSegs++; + nonDateDigits -= segDigits; + segStart = i + 1; + continue; + } + if (segHex && segLen >= 10) return false; + if (segDigits >= 4) return false; + if (segLen >= 10 && hasLower && hasUpper && segDigits >= 1) return false; + mixedDigitsTotal += segDigits; + } + } + segStart = i + 1; + } + if (kind === "model" && nonDateDigits > 8) return false; + if (mixedDigitsTotal >= 5) return false; + return true; +} + +export function isDisallowedMetadataValue(value: string, kind: MetadataFieldKind): boolean { + if (value === METADATA_REFUSED_SENTINEL || value === URL_SYNTAX_REFUSED_SENTINEL || hasControlChars(value)) return true; + const bytes = Buffer.from(value, "utf8"); + try { + return !isAllowedMetadataBytes(bytes, kind); + } finally { + bytes.fill(0); + } +} + +/** + * Syntax-aware, single-line TOML projection for Codex settings_config.config. + * Operates on a transient UTF-8 byte buffer (zeroed by the caller in finally) and decodes ONLY: + * - [table] headers (bare or quoted) + * - root model, model_provider, base_url, openai_base_url, wire_api + * - [model_providers.] base_url and wire_api + * Rejects triple-quoted strings, inline tables, arrays, unclosed quotes, or non-assignment lines. + */ +export interface CodexTomlProjection { + model?: string; + modelProvider?: string; + rootBaseUrl?: string; + rootWireApi?: string; + providers: Map; +} + +const ALLOWED_TOML_KEYS = ["model", "model_provider", "base_url", "openai_base_url", "wire_api"] as const; +type AllowedTomlKey = (typeof ALLOWED_TOML_KEYS)[number]; + +function tomlKeyMetadataKind(key: AllowedTomlKey): MetadataFieldKind { + if (key === "model") return "model"; + if (key === "model_provider") return "id"; + if (key === "wire_api") return "wire"; + return "url"; +} + +/** + * Unescape a single-line TOML quoted string slice (without surrounding quotes) into a transient + * Uint8Array. Caller must zero `out` in finally. + */ +export function unescapeTomlStringBytes( + raw: Uint8Array, + quoteByte: number, +): Uint8Array | null { + const out = new Uint8Array(raw.length); + let r = 0; + let w = 0; + while (r < raw.length) { + const b = raw[r]!; + if (b < 0x20 || b === 0x7f) { + out.fill(0); + return null; + } + if (quoteByte === 0x22 && b === 0x5c) { + r++; + if (r >= raw.length) { out.fill(0); return null; } + const esc = raw[r]!; + r++; + if (esc === 0x22 || esc === 0x5c || esc === 0x2f) out[w++] = esc; + else if (esc === 0x6e || esc === 0x72 || esc === 0x74 || esc === 0x62 || esc === 0x66) { + out.fill(0); + return null; // Control escapes are never valid in provider IDs, models, wire APIs, or URLs. + } else if (esc === 0x75) { + const cp = parseHex4Bytes(raw, r); + if (cp <= 0x20 || cp >= 0x7f) { out.fill(0); return null; } + r += 4; + w += encodeCodePointUtf8(cp, out, w); + } else { + out.fill(0); + return null; + } + continue; + } + out[w++] = b; + r++; + } + return out.subarray(0, w); +} + +function hasOnlyCommentOrSpaceBytes(slice: Uint8Array, fromIndex: number): boolean { + let i = fromIndex; + while (i < slice.length && (slice[i] === 0x20 || slice[i] === 0x09)) i++; + return i >= slice.length || slice[i] === 0x23; +} + +function findQuotedEndInBytes(slice: Uint8Array, quoteStart: number): number { + const q = slice[quoteStart]!; + if (q !== 0x22 && q !== 0x27) return -1; + if (quoteStart + 2 < slice.length && slice[quoteStart + 1] === q && slice[quoteStart + 2] === q) { + return -1; // Triple-quoted strings are forbidden. + } + let i = quoteStart + 1; + while (i < slice.length) { + const b = slice[i]!; + if (b < 0x20 || b === 0x7f) return -1; + if (q === 0x22 && b === 0x5c) { + i += 2; + continue; + } + if (b === q) return i; + i++; + } + return -1; +} + +function parseTomlHeaderBytes( + innerRaw: Uint8Array, + decoder: TextDecoder, +): { kind: "other" } | { kind: "model_provider"; id: string } | { kind: "error"; reason: CcSwitchInvalidReason } { + let s = 0; + let e = innerRaw.length; + while (s < e && (innerRaw[s] === 0x20 || innerRaw[s] === 0x09)) s++; + while (e > s && (innerRaw[e - 1] === 0x20 || innerRaw[e - 1] === 0x09)) e--; + const inner = innerRaw.subarray(s, e); + if (inner.length === 0) return { kind: "error", reason: "malformed_entry" }; + if (equalsAsciiBytes(inner, "__MULTILINE_REFUSED__") || equalsAsciiBytes(inner, "__MALFORMED_LINE__")) { + return { kind: "error", reason: "malformed_entry" }; + } + if (equalsAsciiBytes(inner, METADATA_REFUSED_SENTINEL)) { + return { kind: "error", reason: "disallowed_metadata" }; + } + + const segments: Uint8Array[] = []; + const owned: Uint8Array[] = []; + try { + let i = 0; + while (i < inner.length) { + while (i < inner.length && (inner[i] === 0x20 || inner[i] === 0x09)) i++; + if (i >= inner.length) return { kind: "error", reason: "malformed_entry" }; + const b0 = inner[i]!; + if (b0 === 0x22 || b0 === 0x27) { + const qEnd = findQuotedEndInBytes(inner, i); + if (qEnd < 0) return { kind: "error", reason: "malformed_entry" }; + const unesc = unescapeTomlStringBytes(inner.subarray(i + 1, qEnd), b0); + if (!unesc || unesc.length === 0) return { kind: "error", reason: "malformed_entry" }; + owned.push(unesc); + segments.push(unesc); + i = qEnd + 1; + } else { + const segStart = i; + while (i < inner.length) { + const b = inner[i]!; + if (isAlphaByte(b) || isDigitByte(b) || b === 0x5f || b === 0x2d) i++; + else break; + } + if (i === segStart) return { kind: "error", reason: "malformed_entry" }; + segments.push(inner.subarray(segStart, i)); + } + while (i < inner.length && (inner[i] === 0x20 || inner[i] === 0x09)) i++; + if (i === inner.length) break; + if (inner[i] === 0x2e) { + i++; + if (i >= inner.length) return { kind: "error", reason: "malformed_entry" }; + continue; + } + return { kind: "error", reason: "malformed_entry" }; + } + + if (segments.length === 2 && equalsAsciiBytes(segments[0]!, "model_providers")) { + const idSlice = segments[1]!; + if (!isAllowedMetadataBytes(idSlice, "id")) { + return { kind: "error", reason: "disallowed_metadata" }; + } + const id = decoder.decode(idSlice); + return { kind: "model_provider", id }; + } + return { kind: "other" }; + } catch { + return { kind: "error", reason: "malformed_entry" }; + } finally { + for (const buf of owned) buf.fill(0); + } +} + +function classifyTomlKeyBytes(keySlice: Uint8Array): AllowedTomlKey | "other_key" | null { + const b0 = keySlice[0]!; + if (b0 === 0x22 || b0 === 0x27) { + const qEnd = findQuotedEndInBytes(keySlice, 0); + if (qEnd !== keySlice.length - 1) return null; + const unesc = unescapeTomlStringBytes(keySlice.subarray(1, qEnd), b0); + if (!unesc || unesc.length === 0) return null; + try { + for (const allowed of ALLOWED_TOML_KEYS) { + if (equalsAsciiBytes(unesc, allowed)) return allowed; + } + return "other_key"; + } finally { + unesc.fill(0); + } + } + for (let i = 0; i < keySlice.length; i++) { + const b = keySlice[i]!; + if (!isAlphaByte(b) && !isDigitByte(b) && b !== 0x5f && b !== 0x2d) return null; + } + for (const allowed of ALLOWED_TOML_KEYS) { + if (equalsAsciiBytes(keySlice, allowed)) return allowed; + } + return "other_key"; +} + +export function parseCodexTomlBytes(bytes: Uint8Array): { ok: true; value: CodexTomlProjection } | { ok: false; reason: CcSwitchInvalidReason } { + if (bytes.length > MAX_CODEX_TOML_BYTES) return { ok: false, reason: "malformed_entry" }; + const projection: CodexTomlProjection = { providers: new Map() }; + let currentSection: { kind: "root" } | { kind: "model_provider"; id: string } | { kind: "other" } = { kind: "root" }; + const decoder = new TextDecoder("utf-8", { fatal: true }); + let lineStart = 0; + let lineCount = 0; + + for (let idx = 0; idx <= bytes.length; idx++) { + if (idx < bytes.length && bytes[idx] !== 0x0a) continue; + lineCount++; + if (lineCount > 501) return { ok: false, reason: "malformed_entry" }; + let lineEnd = idx; + if (lineEnd > lineStart && bytes[lineEnd - 1] === 0x0d) lineEnd--; + const slice = bytes.subarray(lineStart, lineEnd); + lineStart = idx + 1; + + let p = 0; + while (p < slice.length && (slice[p] === 0x20 || slice[p] === 0x09)) p++; + if (p >= slice.length || slice[p] === 0x23) continue; + if (slice[p] === 0x5b) { + // Scan and classify table headers in raw bytes so neither trailing comments nor unvalidated/unrelated headers are decoded. + if (p + 1 < slice.length && slice[p + 1] === 0x5b) return { ok: false, reason: "malformed_entry" }; + let closeB = -1; + let inQ = 0; + for (let k = p + 1; k < slice.length; k++) { + const b = slice[k]!; + if (inQ === 0 && b === 0x23) break; + if (inQ === 0 && (b === 0x22 || b === 0x27)) { inQ = b; continue; } + if (inQ === 0x22 && b === 0x5c) { k++; continue; } + if (inQ !== 0 && b === inQ) { inQ = 0; continue; } + if (inQ === 0 && b === 0x5d) { closeB = k; break; } + } + if (closeB < 0 || !hasOnlyCommentOrSpaceBytes(slice, closeB + 1)) { + return { ok: false, reason: "malformed_entry" }; + } + const header = parseTomlHeaderBytes(slice.subarray(p + 1, closeB), decoder); + if (header.kind === "error") return { ok: false, reason: header.reason }; + currentSection = header; + continue; + } + + let eq = -1; + let inKeyQuote = 0; + for (let k = p; k < slice.length; k++) { + const b = slice[k]!; + if (inKeyQuote === 0 && b === 0x23) break; + if (inKeyQuote === 0 && (b === 0x22 || b === 0x27)) { inKeyQuote = b; continue; } + if (inKeyQuote !== 0 && b === 0x5c && inKeyQuote === 0x22) { k++; continue; } + if (inKeyQuote !== 0 && b === inKeyQuote) { inKeyQuote = 0; continue; } + if (inKeyQuote === 0 && b === 0x3d) { eq = k; break; } + } + if (eq < 0) return { ok: false, reason: "malformed_entry" }; + let kStart = p; + let kEnd = eq; + while (kStart < kEnd && (slice[kStart] === 0x20 || slice[kStart] === 0x09)) kStart++; + while (kEnd > kStart && (slice[kEnd - 1] === 0x20 || slice[kEnd - 1] === 0x09)) kEnd--; + if (kEnd - kStart <= 0 || kEnd - kStart > 128) return { ok: false, reason: "malformed_entry" }; + + const classifiedKey = classifyTomlKeyBytes(slice.subarray(kStart, kEnd)); + if (classifiedKey === null) return { ok: false, reason: "malformed_entry" }; + + let vStart = eq + 1; + while (vStart < slice.length && (slice[vStart] === 0x20 || slice[vStart] === 0x09)) vStart++; + if (vStart >= slice.length) return { ok: false, reason: "malformed_entry" }; + if ( + vStart + 2 < slice.length + && ((slice[vStart] === 0x22 && slice[vStart + 1] === 0x22 && slice[vStart + 2] === 0x22) + || (slice[vStart] === 0x27 && slice[vStart + 1] === 0x27 && slice[vStart + 2] === 0x27)) + ) { + return { ok: false, reason: "malformed_entry" }; + } + + const relevant = + classifiedKey !== "other_key" + && ( + currentSection.kind === "root" + || (currentSection.kind === "model_provider" && (classifiedKey === "base_url" || classifiedKey === "wire_api")) + ); + if (!relevant) { + let q = 0; + let depth = 0; + for (let k = vStart; k < slice.length; k++) { + const b = slice[k]!; + if (q === 0 && b === 0x23) break; + if (q === 0 && (b === 0x22 || b === 0x27)) { q = b; continue; } + if (q !== 0 && b === 0x5c && q === 0x22) { k++; continue; } + if (q !== 0 && b === q) { q = 0; continue; } + if (q === 0 && (b === 0x5b || b === 0x7b)) depth++; + if (q === 0 && (b === 0x5d || b === 0x7d)) { + depth--; + if (depth < 0) return { ok: false, reason: "malformed_entry" }; + } + } + if (q !== 0 || depth !== 0) return { ok: false, reason: "malformed_entry" }; + continue; + } + + // Scan the quoted value in raw bytes so trailing '# comment' is never decoded! + const vEnd = findQuotedEndInBytes(slice, vStart); + if (vEnd < 0 || !hasOnlyCommentOrSpaceBytes(slice, vEnd + 1)) { + return { ok: false, reason: "malformed_entry" }; + } + const key: AllowedTomlKey = classifiedKey as AllowedTomlKey; + const unescaped = unescapeTomlStringBytes(slice.subarray(vStart + 1, vEnd), slice[vStart]!); + if (!unescaped || unescaped.length === 0) return { ok: false, reason: "malformed_entry" }; + let value: string; + try { + const metaKind = tomlKeyMetadataKind(key); + if (metaKind === "url" && equalsAsciiBytes(unescaped, URL_SYNTAX_REFUSED_SENTINEL)) { + return { ok: false, reason: "invalid_base_url" }; + } + if (equalsAsciiBytes(unescaped, METADATA_REFUSED_SENTINEL)) { + return { ok: false, reason: "disallowed_metadata" }; + } + if (!isAllowedMetadataBytes(unescaped, metaKind)) { + return { ok: false, reason: metaKind === "url" ? "invalid_base_url" : "disallowed_metadata" }; + } + value = decoder.decode(unescaped); + if (value === URL_SYNTAX_REFUSED_SENTINEL) return { ok: false, reason: "invalid_base_url" }; + if (value === METADATA_REFUSED_SENTINEL) return { ok: false, reason: "disallowed_metadata" }; + } catch { + return { ok: false, reason: "malformed_entry" }; + } finally { + unescaped.fill(0); + } + + if (currentSection.kind === "root") { + if (key === "model") projection.model = value; + else if (key === "model_provider") projection.modelProvider = value; + else if (key === "base_url" || key === "openai_base_url") projection.rootBaseUrl = value; + else if (key === "wire_api") projection.rootWireApi = value; + } else if (currentSection.kind === "model_provider") { + const entry = projection.providers.get(currentSection.id) ?? {}; + if (key === "base_url") entry.baseUrl = value; + else if (key === "wire_api") entry.wireApi = value; + projection.providers.set(currentSection.id, entry); + } + } + + return { ok: true, value: projection }; +} + +export function resolveCodexProjection(proj: CodexTomlProjection): { ok: true; baseUrl: string; wireApi: string; defaultModel?: string } | { ok: false; reason: CcSwitchInvalidReason } { + let baseUrl: string | undefined; + let wireApi = proj.rootWireApi ?? "responses"; + if (proj.modelProvider !== undefined) { + const selected = proj.providers.get(proj.modelProvider); + if (!selected?.baseUrl) return { ok: false, reason: "missing_base_url" }; + baseUrl = selected.baseUrl; + if (selected.wireApi) wireApi = selected.wireApi; + } else if (proj.providers.size > 1) { + return { ok: false, reason: "ambiguous_codex_provider" }; + } else if (proj.providers.size === 1) { + const only = [...proj.providers.values()][0]!; + if (!only.baseUrl) return { ok: false, reason: "missing_base_url" }; + baseUrl = only.baseUrl; + if (only.wireApi) wireApi = only.wireApi; + } else if (proj.rootBaseUrl) { + baseUrl = proj.rootBaseUrl; + } else { + return { ok: false, reason: "missing_base_url" }; + } + return { + ok: true, + baseUrl, + wireApi, + ...(proj.model ? { defaultModel: proj.model } : {}), + }; +} diff --git a/src/providers/cc-switch-import-source.ts b/src/providers/cc-switch-import-source.ts new file mode 100644 index 00000000000..576ad2c7300 --- /dev/null +++ b/src/providers/cc-switch-import-source.ts @@ -0,0 +1,1083 @@ +/** + * Byte-level and SQLite scalar source readers for keyless CC-Switch provider import. + * + * Projects values ONLY from an explicit allowlist of non-secret metadata fields; source bytes + * are read transiently while secret-named fields, free-text names, and TOML comments are skipped + * at the byte level without string decoding and transient buffers are zeroed in finally. Opens + * with O_NOFOLLOW | O_NONBLOCK descriptor verification, refuses active -wal sidecars, and + * deserializes SQLite read-only in memory. + */ +import { Database } from "bun:sqlite"; +import { + closeSync, + constants, + fstatSync, + lstatSync, + openSync, + readSync, +} from "node:fs"; +import { resolve } from "node:path"; +import { assertPlainLocalPath } from "../codex/orca-auth-source"; + +import { + CcSwitchImportError, + MAX_CODEX_TOML_BYTES, + METADATA_REFUSED_SENTINEL, + URL_SYNTAX_REFUSED_SENTINEL, + encodeCodePointUtf8, + equalsAsciiBytes, + isAllowedMetadataBytes, + isDisallowedMetadataValue, + matchKnownJsonKeyBytes, + parseCodexTomlBytes, + parseHex4Bytes, + resolveCodexProjection, + unescapeJsonStringBytesBounded, + unescapeTomlStringBytes, + validateJsonPrimitiveBytes, + validateUtf8AndJsonStringBounds, + type CcSwitchInvalidReason, + type CodexTomlProjection, + type MetadataFieldKind, +} from "./cc-switch-import-bytes"; + +export { + CcSwitchImportError, + METADATA_REFUSED_SENTINEL, + URL_SYNTAX_REFUSED_SENTINEL, + equalsAsciiBytes, + isAllowedMetadataBytes, + isDisallowedMetadataValue, + parseCodexTomlBytes, + resolveCodexProjection, + unescapeJsonStringBytesBounded, + validateJsonPrimitiveBytes, + validateUtf8AndJsonStringBounds, + type CcSwitchInvalidReason, + type CodexTomlProjection, + type MetadataFieldKind, +}; + +const MAX_SQLITE_BYTES = 32 * 1024 * 1024; +export const MAX_JSON_BYTES = 4 * 1024 * 1024; +const SQLITE_MAGIC_BYTES = new Uint8Array([ + 0x53, 0x51, 0x4c, 0x69, 0x74, 0x65, 0x20, 0x66, + 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x20, 0x33, 0x00, +]); + +function hasSqliteMagicBytes(header: Uint8Array, bytesRead: number): boolean { + if (bytesRead < 16 || header.length < 16) return false; + for (let i = 0; i < 16; i++) { + if (header[i] !== SQLITE_MAGIC_BYTES[i]) return false; + } + return true; +} + +function assertNoUncheckpointedSqliteWal(resolvedSqlitePath: string): void { + const walPath = `${resolvedSqlitePath}-wal`; + try { + const walStat = lstatSync(walPath); + if (!walStat.isFile() || walStat.size > 0) { + throw new CcSwitchImportError( + "CC-Switch SQLite source has uncheckpointed WAL changes (-wal is non-empty); close CC-Switch (or checkpoint the database) and retry.", + ); + } + } catch (error) { + if (error instanceof CcSwitchImportError) throw error; + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw new CcSwitchImportError( + "CC-Switch SQLite source has an unreadable WAL file (-wal); close CC-Switch (or checkpoint the database) and retry.", + ); + } + } + const journalPath = `${resolvedSqlitePath}-journal`; + try { + const jStat = lstatSync(journalPath); + if (!jStat.isFile() || jStat.size > 0) { + throw new CcSwitchImportError( + "CC-Switch SQLite source has an active rollback journal (-journal); close CC-Switch and retry.", + ); + } + } catch (error) { + if (error instanceof CcSwitchImportError) throw error; + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw new CcSwitchImportError( + "CC-Switch SQLite source has an unreadable rollback journal (-journal); close CC-Switch and retry.", + ); + } + } +} + +let sourceOpenHookForTests: ((resolvedPath: string) => void) | null = null; +export function setCcSwitchSourceOpenHookForTests(hook: ((resolvedPath: string) => void) | null): void { + sourceOpenHookForTests = hook; +} + +export interface RawExtractedRow { + id: string; + appType: string; + settingsType: string | null; + claudeBaseUrl: string | null; + claudeModel: string | null; + genericBaseUrl: string | null; + genericModel: string | null; + genericWireApi: string | null; + codexTomlLines: string | null; + codexProjection?: { ok: true; value: CodexTomlProjection } | { ok: false; reason: CcSwitchInvalidReason }; +} + +function sqlLineExpr(col: string): string { + return `trim(replace(substr(${col}, 1, instr(${col}, char(10)) - 1), char(13), ''))`; +} + +function sqlSafeScalar(expr: string, kind: MetadataFieldKind): string { + const refused = kind === "url" ? URL_SYNTAX_REFUSED_SENTINEL : METADATA_REFUSED_SENTINEL; + if (kind === "app") { + return `CASE + WHEN ${expr} IS NULL OR ${expr} = '' THEN '' + WHEN ocx_is_safe_meta(CAST(${expr} AS BLOB), 'app') = 1 THEN ${expr} + ELSE '${METADATA_REFUSED_SENTINEL}' + END`; + } + return `CASE + WHEN ${expr} IS NULL THEN NULL + WHEN ocx_is_safe_meta(CAST(${expr} AS BLOB), '${kind}') = 1 + THEN ${expr} + ELSE '${refused}' + END`; +} + +function sqlModelProvidersHeaderPredicate(line: string): string { + const inner = `trim(substr(${line}, 2, instr(${line}, ']') - 2))`; + const afterKey = `ltrim(substr(${inner}, 16))`; + const seg = `trim(substr(${afterKey}, 2))`; + return `( + substr(${line}, 1, 1) = '[' + AND substr(${line}, 1, 2) != '[[' + AND instr(${line}, ']') > 1 + AND substr(${inner}, 1, 15) = 'model_providers' + AND substr(${afterKey}, 1, 1) = '.' + AND ${seg} != '' + AND ( + ( + substr(${seg}, 1, 1) = '"' + AND length(${seg}) >= 3 + AND substr(${seg}, -1, 1) = '"' + AND substr(replace(${seg}, char(92) || char(92), ''), -2, 2) != (char(92) || '"') + AND instr(replace(replace(substr(${seg}, 2, length(${seg}) - 2), char(92) || char(92), ''), char(92) || char(34), ''), '"') = 0 + ) + OR ( + substr(${seg}, 1, 1) = '''' + AND length(${seg}) >= 3 + AND substr(${seg}, -1, 1) = '''' + AND instr(substr(${seg}, 2, length(${seg}) - 2), '''') = 0 + ) + OR ( + substr(${seg}, 1, 1) NOT IN ('"', '''') + AND instr(${seg}, '.') = 0 + AND instr(${seg}, ' ') = 0 + AND instr(${seg}, char(9)) = 0 + ) + ) + )`; +} + +const SQLITE_SAFE_QUERY = ` +WITH RECURSIVE + raw_base AS ( + SELECT + rowid AS rid, + CAST(id AS TEXT) AS raw_id, + CAST(app_type AS TEXT) AS app_type, + COALESCE(sort_index, 0) AS sort_ord, + CASE WHEN json_valid(settings_config) THEN json_type(settings_config) ELSE NULL END AS settings_type, + CASE WHEN json_valid(settings_config) AND json_type(settings_config, ?1) = 'text' THEN json_extract(settings_config, ?1) ELSE NULL END AS raw_claude_url, + CASE + WHEN json_valid(settings_config) AND json_type(settings_config, ?2) = 'text' THEN json_extract(settings_config, ?2) + WHEN json_valid(settings_config) AND json_type(settings_config, ?3) = 'text' THEN json_extract(settings_config, ?3) + ELSE NULL + END AS raw_claude_model, + CASE + WHEN json_valid(settings_config) AND json_type(settings_config, ?4) = 'text' THEN json_extract(settings_config, ?4) + WHEN json_valid(settings_config) AND json_type(settings_config, ?5) = 'text' THEN json_extract(settings_config, ?5) + WHEN json_valid(settings_config) AND json_type(settings_config, ?6) = 'text' THEN json_extract(settings_config, ?6) + WHEN json_valid(settings_config) AND json_type(settings_config, ?14) = 'text' THEN json_extract(settings_config, ?14) + WHEN json_valid(settings_config) AND json_type(settings_config, ?7) = 'text' THEN json_extract(settings_config, ?7) + ELSE NULL + END AS raw_generic_url, + CASE + WHEN json_valid(settings_config) AND json_type(settings_config, ?8) = 'text' THEN json_extract(settings_config, ?8) + WHEN json_valid(settings_config) AND json_type(settings_config, ?9) = 'text' THEN json_extract(settings_config, ?9) + WHEN json_valid(settings_config) AND json_type(settings_config, ?10) = 'text' THEN json_extract(settings_config, ?10) + WHEN json_valid(settings_config) AND json_type(settings_config, ?11) = 'text' THEN json_extract(settings_config, ?11) + ELSE NULL + END AS raw_generic_model, + CASE WHEN json_valid(settings_config) AND json_type(settings_config, ?12) = 'text' THEN json_extract(settings_config, ?12) ELSE NULL END AS raw_wire_api, + CASE + WHEN json_valid(settings_config) AND json_type(settings_config, ?13) = 'text' THEN + CASE + WHEN length(CAST(json_extract(settings_config, ?13) AS BLOB)) > ${MAX_CODEX_TOML_BYTES} + THEN '[__MALFORMED_LINE__]' || char(10) + WHEN instr(json_extract(settings_config, ?13), '"""') > 0 OR instr(json_extract(settings_config, ?13), '''''''') > 0 + THEN '[__MULTILINE_REFUSED__]' || char(10) + ELSE json_extract(settings_config, ?13) || char(10) + END + ELSE NULL + END AS raw_cfg + FROM providers + ), + base AS ( + SELECT + rid, + ${sqlSafeScalar("raw_id", "id")} AS id, + ${sqlSafeScalar("app_type", "app")} AS app_type, + sort_ord, + settings_type, + ${sqlSafeScalar("raw_claude_url", "url")} AS claude_base_url, + ${sqlSafeScalar("raw_claude_model", "model")} AS claude_model, + ${sqlSafeScalar("raw_generic_url", "url")} AS generic_base_url, + ${sqlSafeScalar("raw_generic_model", "model")} AS generic_model, + ${sqlSafeScalar("raw_wire_api", "wire")} AS generic_wire_api, + raw_cfg + FROM raw_base + ), + lines(rid, line, rest, idx, sec) AS ( + SELECT + rid, + ${sqlLineExpr("raw_cfg")}, + substr(raw_cfg, instr(raw_cfg, char(10)) + 1), + 1, + CASE + WHEN substr(${sqlLineExpr("raw_cfg")}, 1, 1) = '[' THEN + CASE + WHEN ${sqlModelProvidersHeaderPredicate(sqlLineExpr("raw_cfg"))} THEN 'mp' + ELSE 'other' + END + ELSE 'root' + END + FROM base WHERE raw_cfg IS NOT NULL AND instr(raw_cfg, char(10)) > 0 + UNION ALL + SELECT + rid, + ${sqlLineExpr("rest")}, + substr(rest, instr(rest, char(10)) + 1), + idx + 1, + CASE + WHEN substr(${sqlLineExpr("rest")}, 1, 1) = '[' THEN + CASE + WHEN ${sqlModelProvidersHeaderPredicate(sqlLineExpr("rest"))} THEN 'mp' + ELSE 'other' + END + ELSE sec + END + FROM lines WHERE instr(rest, char(10)) > 0 AND idx <= 500 + ), + split_lines AS ( + SELECT + rid, + idx, + sec, + line, + instr(line, ']') AS close_b, + trim(substr(ltrim(substr(trim(substr(line, 2, instr(line, ']') - 2)), 16)), 2)) AS hdr_seg, + trim(trim(substr(line, 1, instr(line, '=') - 1)), ' ''' || char(34)) AS norm_key, + ltrim(substr(line, instr(line, '=') + 1)) AS rhs + FROM lines + ), + quoted_lines AS ( + SELECT + rid, + idx, + sec, + line, + close_b, + hdr_seg, + norm_key, + rhs, + CASE + WHEN substr(rhs, 1, 1) = char(34) AND substr(rhs, 1, 3) != '"""' + THEN instr(replace(replace(substr(rhs, 2), char(92) || char(92), '__'), char(92) || char(34), '__'), char(34)) + WHEN substr(rhs, 1, 1) = '''' AND substr(rhs, 1, 3) != '''''''' + THEN instr(substr(rhs, 2), '''') + ELSE 0 + END AS end_q + FROM split_lines + ), + val_lines AS ( + SELECT + rid, + idx, + sec, + line, + close_b, + hdr_seg, + norm_key, + rhs, + end_q, + substr(rhs, 1, end_q + 1) AS rhs_tok, + ltrim(substr(rhs, end_q + 2)) AS tail + FROM quoted_lines + ), + projected_lines AS ( + SELECT rid, idx, + CASE + WHEN idx > 500 THEN '[__MALFORMED_LINE__]' + WHEN line = '' OR substr(line, 1, 1) = '#' THEN NULL + WHEN line IN ('[__MULTILINE_REFUSED__]', '[__MALFORMED_LINE__]') THEN line + WHEN substr(line, 1, 1) = '[' THEN + CASE + WHEN sec = 'mp' AND close_b > 0 AND (ltrim(substr(line, close_b + 1)) = '' OR substr(ltrim(substr(line, close_b + 1)), 1, 1) = '#') THEN + CASE + WHEN ocx_is_safe_toml_token(CAST(hdr_seg AS BLOB), 'id', 1) = 1 + THEN substr(line, 1, close_b) + ELSE '[${METADATA_REFUSED_SENTINEL}]' + END + WHEN close_b > 0 AND (ltrim(substr(line, close_b + 1)) = '' OR substr(ltrim(substr(line, close_b + 1)), 1, 1) = '#') THEN '[other]' + ELSE '[__MALFORMED_LINE__]' + END + WHEN instr(line, '=') = 0 THEN '[__MALFORMED_LINE__]' + WHEN (sec = 'root' AND norm_key IN ('model', 'model_provider', 'base_url', 'openai_base_url', 'wire_api')) + OR (sec = 'mp' AND norm_key IN ('base_url', 'wire_api')) THEN + CASE + WHEN end_q = 0 OR (tail != '' AND substr(tail, 1, 1) != '#') THEN '[__MALFORMED_LINE__]' + WHEN norm_key = 'model' THEN + CASE + WHEN ocx_is_safe_toml_token(CAST(rhs_tok AS BLOB), 'model', 0) = 1 THEN 'model = ' || rhs_tok + ELSE 'model = "${METADATA_REFUSED_SENTINEL}"' + END + WHEN norm_key IN ('base_url', 'openai_base_url') THEN + CASE + WHEN ocx_is_safe_toml_token(CAST(rhs_tok AS BLOB), 'url', 0) = 1 THEN norm_key || ' = ' || rhs_tok + ELSE norm_key || ' = "${URL_SYNTAX_REFUSED_SENTINEL}"' + END + WHEN norm_key = 'model_provider' THEN + CASE + WHEN ocx_is_safe_toml_token(CAST(rhs_tok AS BLOB), 'id', 0) = 1 THEN 'model_provider = ' || rhs_tok + ELSE 'model_provider = "${METADATA_REFUSED_SENTINEL}"' + END + WHEN norm_key = 'wire_api' THEN + CASE + WHEN ocx_is_safe_toml_token(CAST(rhs_tok AS BLOB), 'wire', 0) = 1 THEN 'wire_api = ' || rhs_tok + ELSE 'wire_api = "${METADATA_REFUSED_SENTINEL}"' + END + ELSE 'ignored_key = "omitted"' + END + ELSE 'ignored_key = "omitted"' + END AS safe_line + FROM val_lines + ) +SELECT + b.rid AS rid, + CAST(b.id AS BLOB) AS idBlob, + CAST(b.app_type AS BLOB) AS appTypeBlob, + b.settings_type AS settingsType, + CAST(b.claude_base_url AS BLOB) AS claudeBaseUrlBlob, + CAST(b.claude_model AS BLOB) AS claudeModelBlob, + CAST(b.generic_base_url AS BLOB) AS genericBaseUrlBlob, + CAST(b.generic_model AS BLOB) AS genericModelBlob, + CAST(b.generic_wire_api AS BLOB) AS genericWireApiBlob, + p.idx AS lineIdx, + CAST(p.safe_line AS BLOB) AS safeLineBlob +FROM base b +LEFT JOIN projected_lines p ON b.rid = p.rid AND p.safe_line IS NOT NULL +ORDER BY b.sort_ord ASC, b.id ASC, b.app_type ASC, b.rid ASC, p.idx ASC +`; + +function decodeSafeBlob(blob: Uint8Array | null, kind: MetadataFieldKind, decoder: TextDecoder): string | null { + if (blob === null) return null; + const refused = kind === "url" ? URL_SYNTAX_REFUSED_SENTINEL : METADATA_REFUSED_SENTINEL; + try { + if (kind === "app" && blob.length === 0) return ""; + if (equalsAsciiBytes(blob, METADATA_REFUSED_SENTINEL)) return METADATA_REFUSED_SENTINEL; + if (equalsAsciiBytes(blob, URL_SYNTAX_REFUSED_SENTINEL)) return URL_SYNTAX_REFUSED_SENTINEL; + if (!isAllowedMetadataBytes(blob, kind)) return refused; + return decoder.decode(blob); + } finally { + blob.fill(0); + } +} + +function readSqliteRowsFromBytes(sqliteBytes: Uint8Array): RawExtractedRow[] { + let db: Database | undefined; + const blobsToZero: Uint8Array[] = []; + try { + // Normalize SQLite WAL read/write format version bytes (offsets 18..19) on the private + // in-memory buffer so sqlite3_deserialize opens checkpointed WAL databases without touching disk. + if (sqliteBytes.length >= 20) { + if (sqliteBytes[18] === 2) sqliteBytes[18] = 1; + if (sqliteBytes[19] === 2) sqliteBytes[19] = 1; + } + db = Database.deserialize(sqliteBytes, { readonly: true }); + db.function("ocx_is_safe_meta", (val: unknown, kind: unknown) => { + if (!(val instanceof Uint8Array) || typeof kind !== "string") return 0; + try { + return isAllowedMetadataBytes(val, kind as MetadataFieldKind) ? 1 : 0; + } finally { + val.fill(0); + } + }); + db.function("ocx_is_safe_toml_token", (val: unknown, kind: unknown, allowBare: unknown) => { + if (!(val instanceof Uint8Array) || typeof kind !== "string" || val.length === 0) return 0; + let unescaped: Uint8Array | null = null; + try { + const q = val[0]!; + if (q === 0x22 || q === 0x27) { + if (val.length < 2 || val[val.length - 1] !== q) return 0; + unescaped = unescapeTomlStringBytes(val.subarray(1, val.length - 1), q); + if (!unescaped || unescaped.length === 0) return 0; + return isAllowedMetadataBytes(unescaped, kind as MetadataFieldKind) ? 1 : 0; + } + if (!allowBare) return 0; + return isAllowedMetadataBytes(val, kind as MetadataFieldKind) ? 1 : 0; + } finally { + unescaped?.fill(0); + val.fill(0); + } + }); + const rawRows = db.query(SQLITE_SAFE_QUERY).all( + "$.env.ANTHROPIC_BASE_URL", + "$.env.ANTHROPIC_MODEL", + "$.env.ANTHROPIC_DEFAULT_SONNET_MODEL", + "$.baseUrl", + "$.base_url", + "$.env.OPENAI_BASE_URL", + "$.env.GEMINI_BASE_URL", + "$.model", + "$.defaultModel", + "$.env.OPENAI_MODEL", + "$.env.GEMINI_MODEL", + "$.wire_api", + "$.config", + "$.env.GOOGLE_GEMINI_BASE_URL", + ) as Array<{ + rid: number; + idBlob: Uint8Array | null; + appTypeBlob: Uint8Array | null; + settingsType: string | null; + claudeBaseUrlBlob: Uint8Array | null; + claudeModelBlob: Uint8Array | null; + genericBaseUrlBlob: Uint8Array | null; + genericModelBlob: Uint8Array | null; + genericWireApiBlob: Uint8Array | null; + lineIdx: number | null; + safeLineBlob: Uint8Array | null; + }>; + for (const item of rawRows) { + if (item.idBlob) blobsToZero.push(item.idBlob); + if (item.appTypeBlob) blobsToZero.push(item.appTypeBlob); + if (item.claudeBaseUrlBlob) blobsToZero.push(item.claudeBaseUrlBlob); + if (item.claudeModelBlob) blobsToZero.push(item.claudeModelBlob); + if (item.genericBaseUrlBlob) blobsToZero.push(item.genericBaseUrlBlob); + if (item.genericModelBlob) blobsToZero.push(item.genericModelBlob); + if (item.genericWireApiBlob) blobsToZero.push(item.genericWireApiBlob); + if (item.safeLineBlob) blobsToZero.push(item.safeLineBlob); + } + const decoder = new TextDecoder("utf-8", { fatal: true }); + const byRid = new Map }>(); + for (const item of rawRows) { + let group = byRid.get(item.rid); + if (!group) { + group = { + row: { + id: decodeSafeBlob(item.idBlob, "id", decoder) ?? "", + appType: decodeSafeBlob(item.appTypeBlob, "app", decoder) ?? "", + settingsType: item.settingsType, + claudeBaseUrl: decodeSafeBlob(item.claudeBaseUrlBlob, "url", decoder), + claudeModel: decodeSafeBlob(item.claudeModelBlob, "model", decoder), + genericBaseUrl: decodeSafeBlob(item.genericBaseUrlBlob, "url", decoder), + genericModel: decodeSafeBlob(item.genericModelBlob, "model", decoder), + genericWireApi: decodeSafeBlob(item.genericWireApiBlob, "wire", decoder), + codexTomlLines: null, + }, + lines: [], + }; + byRid.set(item.rid, group); + } + if (item.lineIdx !== null && item.safeLineBlob !== null) { + const copy = new Uint8Array(item.safeLineBlob.length); + copy.set(item.safeLineBlob); + blobsToZero.push(copy); + group.lines.push({ idx: item.lineIdx, bytes: copy }); + } + } + const result: RawExtractedRow[] = []; + for (const group of byRid.values()) { + if (group.lines.length > 0) { + group.lines.sort((a, b) => a.idx - b.idx); + let totalLen = 0; + for (let i = 0; i < group.lines.length; i++) { + totalLen += group.lines[i]!.bytes.length + (i > 0 ? 1 : 0); + } + const joined = new Uint8Array(totalLen); + let offset = 0; + for (let i = 0; i < group.lines.length; i++) { + if (i > 0) joined[offset++] = 0x0a; + const lineBytes = group.lines[i]!.bytes; + joined.set(lineBytes, offset); + offset += lineBytes.length; + } + try { + group.row.codexProjection = parseCodexTomlBytes(joined); + } finally { + joined.fill(0); + } + } + result.push(group.row); + } + return result; + } catch { + throw new CcSwitchImportError("CC-Switch SQLite source could not be read or has an unsupported schema."); + } finally { + for (const b of blobsToZero) b.fill(0); + try { db?.close(); } catch { /* ignore */ } + } +} + +export function readSqliteRows(sourcePath: string): RawExtractedRow[] { + return readSourceRows(sourcePath, true); +} + +/** + * Byte-level UTF-8 JSON scanner for legacy CC-Switch JSON files. + * Walks the JSON structure on a transient Uint8Array (zeroed in finally) and decodes ONLY + * allowlisted non-secret scalar fields, skipping over secret values without decoding them into JS strings. + */ +export function scanCcSwitchJsonBytes(bytes: Uint8Array): RawExtractedRow[] { + try { + return scanCcSwitchJsonBytesInternal(bytes); + } finally { + bytes.fill(0); + } +} + +function scanCcSwitchJsonBytesInternal(bytes: Uint8Array): RawExtractedRow[] { + const decoder = new TextDecoder("utf-8", { fatal: true }); + let pos = 0; + if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) { + pos = 3; + } + + function skipWs(): void { + while (pos < bytes.length) { + const b = bytes[pos]!; + if (b === 0x20 || b === 0x09 || b === 0x0a || b === 0x0d) pos++; + else break; + } + } + + function scanStringBounds(): { start: number; end: number; hasEscapes: boolean } { + const res = validateUtf8AndJsonStringBounds(bytes, pos); + if (!res) throw new CcSwitchImportError("Malformed CC-Switch JSON source."); + pos = res.nextPos; + return { start: res.start, end: res.end, hasEscapes: res.hasEscapes }; + } + + function decodeMetadataStringFromBounds(bounds: { start: number; end: number }, kind: MetadataFieldKind): string { + const refused = kind === "url" ? URL_SYNTAX_REFUSED_SENTINEL : METADATA_REFUSED_SENTINEL; + if (bounds.end - bounds.start > 512) return refused; + const out = new Uint8Array(bounds.end - bounds.start); + let readIdx = bounds.start; + let writeIdx = 0; + try { + while (readIdx < bounds.end) { + const b = bytes[readIdx]!; + if (b !== 0x5c) { + out[writeIdx++] = b; + readIdx++; + continue; + } + readIdx++; + if (readIdx >= bounds.end) return refused; + const esc = bytes[readIdx]!; + readIdx++; + if (esc === 0x22 || esc === 0x5c || esc === 0x2f) out[writeIdx++] = esc; + else if (esc === 0x6e || esc === 0x72 || esc === 0x74 || esc === 0x62 || esc === 0x66) { + return refused; + } else if (esc === 0x75) { + const cp = parseHex4Bytes(bytes.subarray(0, bounds.end), readIdx); + if (cp <= 0x20 || cp >= 0x7f) return refused; + readIdx += 4; + writeIdx += encodeCodePointUtf8(cp, out, writeIdx); + } else { + return refused; + } + } + const view = out.subarray(0, writeIdx); + if (!isAllowedMetadataBytes(view, kind)) return refused; + return decoder.decode(view); + } finally { + out.fill(0); + } + } + + function parseJsonEscapedTomlInPlace(bounds: { start: number; end: number }): { ok: true; value: CodexTomlProjection } | { ok: false; reason: CcSwitchInvalidReason } { + if (bounds.end - bounds.start > MAX_CODEX_TOML_BYTES) return { ok: false, reason: "malformed_entry" }; + const out = new Uint8Array(bounds.end - bounds.start); + let readIdx = bounds.start; + let writeIdx = 0; + try { + while (readIdx < bounds.end) { + const b = bytes[readIdx]!; + if (b !== 0x5c) { + out[writeIdx++] = b; + readIdx++; + continue; + } + readIdx++; + if (readIdx >= bounds.end) return { ok: false, reason: "malformed_entry" }; + const esc = bytes[readIdx]!; + readIdx++; + if (esc === 0x22 || esc === 0x5c || esc === 0x2f) out[writeIdx++] = esc; + else if (esc === 0x6e) out[writeIdx++] = 0x0a; + else if (esc === 0x72) out[writeIdx++] = 0x0d; + else if (esc === 0x74) out[writeIdx++] = 0x09; + else if (esc === 0x62) out[writeIdx++] = 0x08; + else if (esc === 0x66) out[writeIdx++] = 0x0c; + else if (esc === 0x75) { + let codePoint = parseHex4Bytes(bytes.subarray(0, bounds.end), readIdx); + if (codePoint < 0) return { ok: false, reason: "malformed_entry" }; + readIdx += 4; + if (codePoint >= 0xd800 && codePoint <= 0xdbff) { + if (readIdx + 6 > bounds.end || bytes[readIdx] !== 0x5c || bytes[readIdx + 1] !== 0x75) { + return { ok: false, reason: "malformed_entry" }; + } + const low = parseHex4Bytes(bytes.subarray(0, bounds.end), readIdx + 2); + if (low < 0xdc00 || low > 0xdfff) return { ok: false, reason: "malformed_entry" }; + readIdx += 6; + codePoint = ((codePoint - 0xd800) << 10) + (low - 0xdc00) + 0x10000; + } else if (codePoint >= 0xdc00 && codePoint <= 0xdfff) { + return { ok: false, reason: "malformed_entry" }; + } + writeIdx += encodeCodePointUtf8(codePoint, out, writeIdx); + } else { + return { ok: false, reason: "malformed_entry" }; + } + } + return parseCodexTomlBytes(out.subarray(0, writeIdx)); + } finally { + out.fill(0); + } + } + + function skipPrimitive(): void { + const start = pos; + while (pos < bytes.length) { + const b = bytes[pos]!; + if (b === 0x20 || b === 0x09 || b === 0x0a || b === 0x0d || b === 0x2c || b === 0x7d || b === 0x5d) break; + pos++; + } + if (!validateJsonPrimitiveBytes(bytes, start, pos)) { + throw new CcSwitchImportError("Malformed CC-Switch JSON source."); + } + } + + function skipValue(depth = 0): void { + if (depth > 64) throw new CcSwitchImportError("Malformed CC-Switch JSON source."); + skipWs(); + if (pos >= bytes.length) throw new CcSwitchImportError("Malformed CC-Switch JSON source."); + const b = bytes[pos]!; + if (b === 0x22) { + scanStringBounds(); + } else if (b === 0x7b) { + pos++; + skipWs(); + if (bytes[pos] === 0x7d) { pos++; return; } + for (;;) { + skipWs(); + scanStringBounds(); + skipWs(); + if (bytes[pos] !== 0x3a) throw new CcSwitchImportError("Malformed CC-Switch JSON source."); + pos++; + skipValue(depth + 1); + skipWs(); + if (bytes[pos] === 0x2c) { pos++; continue; } + if (bytes[pos] === 0x7d) { pos++; break; } + throw new CcSwitchImportError("Malformed CC-Switch JSON source."); + } + } else if (b === 0x5b) { + pos++; + skipWs(); + if (bytes[pos] === 0x5d) { pos++; return; } + for (;;) { + skipValue(depth + 1); + skipWs(); + if (bytes[pos] === 0x2c) { pos++; continue; } + if (bytes[pos] === 0x5d) { pos++; break; } + throw new CcSwitchImportError("Malformed CC-Switch JSON source."); + } + } else { + skipPrimitive(); + } + } + + function readOptionalScalarString(kind: MetadataFieldKind): string | null { + skipWs(); + if (bytes[pos] === 0x22) return decodeMetadataStringFromBounds(scanStringBounds(), kind); + skipValue(); + return null; + } + + function parseSettingsObject(row: RawExtractedRow): void { + skipWs(); + if (bytes[pos] !== 0x7b) { + row.settingsType = null; + skipValue(); + return; + } + row.settingsType = "object"; + pos++; + skipWs(); + if (bytes[pos] === 0x7d) { pos++; return; } + for (;;) { + skipWs(); + const key = matchKnownJsonKeyBytes(bytes, scanStringBounds(), [ + "env", "baseUrl", "base_url", "model", "defaultModel", "wire_api", "config", + ]); + skipWs(); + if (bytes[pos] !== 0x3a) throw new CcSwitchImportError("Malformed CC-Switch JSON source."); + pos++; + skipWs(); + if (key === "env" && bytes[pos] === 0x7b) { + pos++; + skipWs(); + if (bytes[pos] === 0x7d) { pos++; } + else { + for (;;) { + skipWs(); + const envKey = matchKnownJsonKeyBytes(bytes, scanStringBounds(), [ + "ANTHROPIC_BASE_URL", + "ANTHROPIC_MODEL", + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "OPENAI_BASE_URL", + "GOOGLE_GEMINI_BASE_URL", + "GEMINI_BASE_URL", + "OPENAI_MODEL", + "GEMINI_MODEL", + ]); + skipWs(); + if (bytes[pos] !== 0x3a) throw new CcSwitchImportError("Malformed CC-Switch JSON source."); + pos++; + if (envKey === "ANTHROPIC_BASE_URL") row.claudeBaseUrl = readOptionalScalarString("url"); + else if (envKey === "ANTHROPIC_MODEL") { + const v = readOptionalScalarString("model"); + if (v) row.claudeModel = v; + } else if (envKey === "ANTHROPIC_DEFAULT_SONNET_MODEL") { + const v = readOptionalScalarString("model"); + if (v && !row.claudeModel) row.claudeModel = v; + } else if (envKey === "OPENAI_BASE_URL" || envKey === "GOOGLE_GEMINI_BASE_URL" || envKey === "GEMINI_BASE_URL") { + const v = readOptionalScalarString("url"); + if (v && !row.genericBaseUrl) row.genericBaseUrl = v; + } else if (envKey === "OPENAI_MODEL" || envKey === "GEMINI_MODEL") { + const v = readOptionalScalarString("model"); + if (v && !row.genericModel) row.genericModel = v; + } else { + skipValue(); + } + skipWs(); + if (bytes[pos] === 0x2c) { pos++; continue; } + if (bytes[pos] === 0x7d) { pos++; break; } + throw new CcSwitchImportError("Malformed CC-Switch JSON source."); + } + } + } else if (key === "baseUrl" || key === "base_url") { + const v = readOptionalScalarString("url"); + if (v && !row.genericBaseUrl) row.genericBaseUrl = v; + } else if (key === "model" || key === "defaultModel") { + const v = readOptionalScalarString("model"); + if (v && !row.genericModel) row.genericModel = v; + } else if (key === "wire_api") { + const v = readOptionalScalarString("wire"); + if (v) row.genericWireApi = v; + } else if (key === "config") { + skipWs(); + if (bytes[pos] === 0x22) { + row.codexProjection = parseJsonEscapedTomlInPlace(scanStringBounds()); + } else { + skipValue(); + } + } else { + skipValue(); + } + skipWs(); + if (bytes[pos] === 0x2c) { pos++; continue; } + if (bytes[pos] === 0x7d) { pos++; break; } + throw new CcSwitchImportError("Malformed CC-Switch JSON source."); + } + } + + function parseProviderEntryObject(fallbackId = "", fallbackApp = ""): RawExtractedRow { + skipWs(); + if (bytes[pos] !== 0x7b) throw new CcSwitchImportError("Malformed CC-Switch JSON provider entry."); + pos++; + const row: RawExtractedRow = { + id: fallbackId, + appType: fallbackApp, + settingsType: null, + claudeBaseUrl: null, + claudeModel: null, + genericBaseUrl: null, + genericModel: null, + genericWireApi: null, + codexTomlLines: null, + }; + skipWs(); + if (bytes[pos] === 0x7d) { pos++; return row; } + for (;;) { + skipWs(); + const key = matchKnownJsonKeyBytes(bytes, scanStringBounds(), [ + "id", "app_type", "appType", "settings_config", "settingsConfig", + ]); + skipWs(); + if (bytes[pos] !== 0x3a) throw new CcSwitchImportError("Malformed CC-Switch JSON source."); + pos++; + if (key === "id") { + const v = readOptionalScalarString("id"); + if (v !== null) row.id = v; + } else if (key === "app_type" || key === "appType") { + const v = readOptionalScalarString("app"); + if (v !== null) row.appType = v; + } else if (key === "settings_config" || key === "settingsConfig") { + parseSettingsObject(row); + } else { + // Never decode free-text "name", "notes", "websiteUrl", "meta", or unknown fields! + skipValue(); + } + skipWs(); + if (bytes[pos] === 0x2c) { pos++; continue; } + if (bytes[pos] === 0x7d) { pos++; break; } + throw new CcSwitchImportError("Malformed CC-Switch JSON source."); + } + return row; + } + + function parseProvidersCollection(appHint = ""): RawExtractedRow[] { + const out: RawExtractedRow[] = []; + skipWs(); + if (bytes[pos] === 0x5b) { + pos++; + skipWs(); + if (bytes[pos] === 0x5d) { pos++; return out; } + for (;;) { + out.push(parseProviderEntryObject("", appHint)); + skipWs(); + if (bytes[pos] === 0x2c) { pos++; continue; } + if (bytes[pos] === 0x5d) { pos++; break; } + throw new CcSwitchImportError("Malformed CC-Switch JSON source."); + } + } else if (bytes[pos] === 0x7b) { + pos++; + skipWs(); + if (bytes[pos] === 0x7d) { pos++; return out; } + for (;;) { + skipWs(); + const id = decodeMetadataStringFromBounds(scanStringBounds(), "id"); + skipWs(); + if (bytes[pos] !== 0x3a) throw new CcSwitchImportError("Malformed CC-Switch JSON source."); + pos++; + out.push(parseProviderEntryObject(id, appHint)); + skipWs(); + if (bytes[pos] === 0x2c) { pos++; continue; } + if (bytes[pos] === 0x7d) { pos++; break; } + throw new CcSwitchImportError("Malformed CC-Switch JSON source."); + } + } else { + throw new CcSwitchImportError("Malformed CC-Switch JSON providers section."); + } + return out; + } + + function parseAppSection(appName: string): RawExtractedRow[] { + const out: RawExtractedRow[] = []; + skipWs(); + if (bytes[pos] !== 0x7b) { skipValue(); return out; } + pos++; + skipWs(); + if (bytes[pos] === 0x7d) { pos++; return out; } + for (;;) { + skipWs(); + const k = matchKnownJsonKeyBytes(bytes, scanStringBounds(), ["providers"]); + skipWs(); + if (bytes[pos] !== 0x3a) throw new CcSwitchImportError("Malformed CC-Switch JSON source."); + pos++; + if (k === "providers") out.push(...parseProvidersCollection(appName)); + else skipValue(); + skipWs(); + if (bytes[pos] === 0x2c) { pos++; continue; } + if (bytes[pos] === 0x7d) { pos++; break; } + throw new CcSwitchImportError("Malformed CC-Switch JSON source."); + } + return out; + } + + const rows: RawExtractedRow[] = []; + let sawStructure = false; + skipWs(); + if (bytes[pos] !== 0x7b) throw new CcSwitchImportError("CC-Switch JSON source must be a JSON object."); + pos++; + skipWs(); + if (bytes[pos] === 0x7d) { + pos++; + } else { + for (;;) { + skipWs(); + const topKey = matchKnownJsonKeyBytes(bytes, scanStringBounds(), [ + "providers", "apps", "claude", "codex", "openai", "gemini", "opencode", + ]); + skipWs(); + if (bytes[pos] !== 0x3a) throw new CcSwitchImportError("Malformed CC-Switch JSON source."); + pos++; + if (topKey === "providers") { + sawStructure = true; + rows.push(...parseProvidersCollection("")); + } else if (topKey === "apps") { + sawStructure = true; + skipWs(); + if (bytes[pos] !== 0x7b) throw new CcSwitchImportError("Malformed CC-Switch JSON apps section."); + pos++; + skipWs(); + if (bytes[pos] === 0x7d) { pos++; } + else { + for (;;) { + skipWs(); + const appName = decodeMetadataStringFromBounds(scanStringBounds(), "app"); + skipWs(); + if (bytes[pos] !== 0x3a) throw new CcSwitchImportError("Malformed CC-Switch JSON source."); + pos++; + rows.push(...parseAppSection(appName)); + skipWs(); + if (bytes[pos] === 0x2c) { pos++; continue; } + if (bytes[pos] === 0x7d) { pos++; break; } + throw new CcSwitchImportError("Malformed CC-Switch JSON source."); + } + } + } else if (topKey === "claude" || topKey === "codex" || topKey === "openai" || topKey === "gemini" || topKey === "opencode") { + sawStructure = true; + rows.push(...parseAppSection(topKey)); + } else { + skipValue(); + } + skipWs(); + if (bytes[pos] === 0x2c) { pos++; continue; } + if (bytes[pos] === 0x7d) { pos++; break; } + throw new CcSwitchImportError("Malformed CC-Switch JSON source."); + } + } + skipWs(); + if (pos !== bytes.length || !sawStructure) { + throw new CcSwitchImportError("CC-Switch JSON source does not contain a recognized providers structure."); + } + return rows; +} + +export function readSourceRows(sourcePath: string, requireSqlite = false): RawExtractedRow[] { + const resolved = resolve(sourcePath); + let beforeStat: ReturnType; + try { + assertPlainLocalPath(resolved); + beforeStat = lstatSync(resolved); + } catch { + throw new CcSwitchImportError("CC-Switch source path must be a plain regular local file without symlink traversal."); + } + if (!beforeStat.isFile()) { + throw new CcSwitchImportError("CC-Switch source path must be a regular file."); + } + sourceOpenHookForTests?.(resolved); + let fd: number | undefined; + let isSqlite = false; + let size = 0; + const header = Buffer.alloc(16); + let rawBuf: Buffer | undefined; + try { + try { + fd = openSync( + resolved, + constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0), + ); + const opened = fstatSync(fd); + if (!opened.isFile()) { + throw new CcSwitchImportError("CC-Switch source path must be a regular file."); + } + if ( + opened.dev !== beforeStat.dev + || opened.ino !== beforeStat.ino + || opened.uid !== beforeStat.uid + || opened.gid !== beforeStat.gid + || opened.mode !== beforeStat.mode + ) { + throw new CcSwitchImportError("CC-Switch source file changed while opening."); + } + size = opened.size; + const n = readSync(fd, header, 0, 16, 0); + if (hasSqliteMagicBytes(header, n)) { + isSqlite = true; + if (opened.nlink > 1) { + throw new CcSwitchImportError("CC-Switch SQLite source has multiple hard links; import the original CC-Switch database path directly."); + } + if (size === 0 || size > MAX_SQLITE_BYTES) { + throw new CcSwitchImportError("CC-Switch SQLite source exceeds the maximum supported size."); + } + assertNoUncheckpointedSqliteWal(resolved); + } else if (requireSqlite || /\.(?:db|sqlite|sqlite3)$/i.test(resolved)) { + throw new CcSwitchImportError("CC-Switch SQLite source is not a valid SQLite 3 file."); + } else { + if (size === 0 || size > MAX_JSON_BYTES) { + throw new CcSwitchImportError("CC-Switch JSON source is empty or exceeds the maximum supported size."); + } + } + rawBuf = Buffer.alloc(size + 1); + let offset = 0; + while (offset < rawBuf.length) { + const got = readSync(fd, rawBuf, offset, rawBuf.length - offset, offset); + if (got <= 0) break; + offset += got; + } + if (offset !== size) { + throw new CcSwitchImportError("CC-Switch source file changed while reading."); + } + if (isSqlite) { + assertNoUncheckpointedSqliteWal(resolved); + } + assertPlainLocalPath(resolved); + const afterStat = lstatSync(resolved); + const afterFd = fstatSync(fd); + if (isSqlite && afterFd.nlink > 1) { + throw new CcSwitchImportError("CC-Switch SQLite source has multiple hard links; import the original CC-Switch database path directly."); + } + if ( + afterStat.dev !== opened.dev + || afterStat.ino !== opened.ino + || afterStat.nlink !== opened.nlink + || afterStat.uid !== opened.uid + || afterStat.gid !== opened.gid + || afterStat.mode !== opened.mode + || afterStat.size !== opened.size + || afterStat.mtimeMs !== opened.mtimeMs + || afterFd.dev !== opened.dev + || afterFd.ino !== opened.ino + || afterFd.nlink !== opened.nlink + || afterFd.uid !== opened.uid + || afterFd.gid !== opened.gid + || afterFd.mode !== opened.mode + || afterFd.size !== opened.size + || afterFd.mtimeMs !== opened.mtimeMs + ) { + throw new CcSwitchImportError("CC-Switch source file changed while reading."); + } + } catch (error) { + if (error instanceof CcSwitchImportError) throw error; + throw new CcSwitchImportError("CC-Switch source file could not be opened."); + } finally { + if (fd !== undefined) { + try { closeSync(fd); } catch { /* ignore */ } + } + } + + if (isSqlite) { + return readSqliteRowsFromBytes(rawBuf!.subarray(0, size)); + } + return scanCcSwitchJsonBytes(rawBuf!.subarray(0, size)); + } finally { + header.fill(0); + rawBuf?.fill(0); + } +} diff --git a/src/providers/cc-switch-import.ts b/src/providers/cc-switch-import.ts new file mode 100644 index 00000000000..3eb49563ba2 --- /dev/null +++ b/src/providers/cc-switch-import.ts @@ -0,0 +1,942 @@ +/** + * Keyless CC-Switch provider configuration importer (preview, selective apply, and journaled restore). + * + * Security & privacy boundary: + * - Structural non-secret projection: projects values ONLY from an explicit allowlist of non-secret + * metadata fields (id, app_type, baseUrl, wire adapter, defaultModel); source bytes are scanned + * transiently while secret fields, free-text names, and TOML comments are skipped at the byte + * level (never decoded into strings, written, or logged) and transient buffers are zeroed in finally. + * - Enforces a narrow URL path grammar and identifier/model syntax checks as defense in depth. + * - Source files are opened read-only with O_NOFOLLOW | O_NONBLOCK, descriptor-verified, and + * refused if an active SQLite -wal/-journal sidecar is non-empty. + * - Apply and restore run only while the proxy is stopped, under the service ownership lease and + * the shared config mutation lock, with a durable streaming journal for rollback. + */ +import { createHash, randomUUID } from "node:crypto"; +import { + closeSync, + constants, + fstatSync, + fsyncSync, + openSync, + readFileSync, + readSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { dirname, join } from "node:path"; +import { + getConfigDir, + getConfigPath, + hasOwnProvider, + isValidProviderName, + mutatePersistedConfig, + readConfigDiagnostics, + validateConfigCandidate, + withConfigMutationLockSync, +} from "../config"; +import { atomicWriteFileStreamed } from "../config/atomic-write"; +import { readConfigFileSnapshot } from "../config/diagnostics"; +import { withConfigDirHardeningSettled } from "../config/paths"; +import { readAlivePid, readRuntimePort } from "../config/process-state"; +import { providerBaseUrlConfigError } from "../config/provider-validation"; +import { codexAccountNamespaceProviderCollisionError } from "../codex/account-namespace-match"; +import { providerDestinationConfigError } from "../lib/destination-policy"; +import { getAuthStorePath } from "../oauth/store"; +import { rewriteProviderReferences } from "./provider-id-rewrite"; +import { acquireOwnershipMutationLease } from "../service/ownership-mutation-lease.mjs"; +import { serviceStatePaths } from "../service/state"; +import type { OcxConfig, OcxProviderConfig } from "../types"; +import { + CcSwitchImportError, + MAX_JSON_BYTES, + METADATA_REFUSED_SENTINEL, + URL_SYNTAX_REFUSED_SENTINEL, + equalsAsciiBytes, + isAllowedMetadataBytes, + isDisallowedMetadataValue, + parseCodexTomlBytes, + readSourceRows, + readSqliteRows, + resolveCodexProjection, + scanCcSwitchJsonBytes, + setCcSwitchSourceOpenHookForTests, + unescapeJsonStringBytesBounded, + validateJsonPrimitiveBytes, + validateUtf8AndJsonStringBounds, + type CcSwitchInvalidReason, + type CodexTomlProjection, + type MetadataFieldKind, + type RawExtractedRow, +} from "./cc-switch-import-source"; + +export { + CcSwitchImportError, + METADATA_REFUSED_SENTINEL, + URL_SYNTAX_REFUSED_SENTINEL, + isAllowedMetadataBytes, + parseCodexTomlBytes, + readSqliteRows, + scanCcSwitchJsonBytes, + setCcSwitchSourceOpenHookForTests, + type CcSwitchInvalidReason, + type CodexTomlProjection, + type MetadataFieldKind, +}; + +export interface CcSwitchCandidateProvider { + name: string; + sourceId: string; + appType: string; + adapter: string; + baseUrl: string; + defaultModel?: string; + allowPrivateNetwork?: boolean; +} + +export interface CcSwitchImportPreviewOrApplyResult { + mode: "preview" | "apply"; + discovered: number; + eligible: number; + imported: number; + conflicts: number; + conflictNames: string[]; + invalid: number; + invalidReasons: Partial>; + providers: CcSwitchCandidateProvider[]; +} + +export interface CcSwitchImportRestoreResult { + mode: "restore"; + restored: number; + restoredNames: string[]; + alreadyAbsent: number; + retainedEdited: number; + retainedNames: string[]; +} + +export type CcSwitchImportResult = CcSwitchImportPreviewOrApplyResult | CcSwitchImportRestoreResult; + +export const CC_SWITCH_IMPORT_JOURNAL_FILENAME = "cc-switch-provider-import-journal.json"; +const JOURNAL_SCHEMA_VERSION = 1; + +interface JournalEntry { + name: string; + sourceId: string; + appType: string; + note: string; + fingerprint: string; +} + +interface ImportJournal { + schemaVersion: 1; + state: "prepared" | "committed"; + importId: string; + createdAt: number; + entries: JournalEntry[]; +} + +function normalizeProviderSlug(raw: string): string { + return raw + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function canonicalProviderJson(provider: OcxProviderConfig): string { + const keys = Object.keys(provider).sort(); + const ordered: Record = {}; + for (const key of keys) { + const val = (provider as unknown as Record)[key]; + if (val !== undefined) ordered[key] = val; + } + return JSON.stringify(ordered); +} + +export function canonicalProviderFingerprint(provider: OcxProviderConfig): string { + return createHash("sha256").update(canonicalProviderJson(provider), "utf8").digest("hex"); +} + +function syncDirectoryBestEffort(dirPath: string): void { + if (process.platform === "win32") return; + let fd: number | undefined; + try { + fd = openSync(dirPath, "r"); + fsyncSync(fd); + } catch { + // Best-effort directory sync after unlink on POSIX. + } finally { + if (fd !== undefined) { + try { closeSync(fd); } catch { /* ignore */ } + } + } +} + +function writeJournalDurably(journalPath: string, journal: ImportJournal): void { + const bytes = Buffer.from(JSON.stringify(journal, null, 2) + "\n", "utf8"); + atomicWriteFileStreamed(journalPath, (fd: number) => { + writeFileSync(fd, bytes); + }); +} + +function unlinkJournalDurably(journalPath: string): void { + try { + unlinkSync(journalPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + syncDirectoryBestEffort(dirname(journalPath)); +} + +function readJournal(journalPath: string): ImportJournal | null { + let raw: string; + try { + raw = readFileSync(journalPath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw new CcSwitchImportError("Existing CC-Switch import journal is unreadable; inspect or restore before importing."); + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new CcSwitchImportError("Existing CC-Switch import journal is corrupt; inspect or restore before importing."); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new CcSwitchImportError("Existing CC-Switch import journal is corrupt; inspect or restore before importing."); + } + const obj = parsed as Record; + if ( + obj.schemaVersion !== JOURNAL_SCHEMA_VERSION + || (obj.state !== "prepared" && obj.state !== "committed") + || typeof obj.importId !== "string" + || !obj.importId.trim() + || typeof obj.createdAt !== "number" + || !Array.isArray(obj.entries) + || obj.entries.length === 0 + ) { + throw new CcSwitchImportError("Existing CC-Switch import journal is corrupt; inspect or restore before importing."); + } + const entries: JournalEntry[] = []; + const seenNames = new Set(); + for (const item of obj.entries) { + if (!item || typeof item !== "object" || Array.isArray(item)) { + throw new CcSwitchImportError("Existing CC-Switch import journal is corrupt; inspect or restore before importing."); + } + const rec = item as Record; + if ( + typeof rec.name !== "string" || !isValidProviderName(rec.name) + || typeof rec.sourceId !== "string" || !rec.sourceId + || typeof rec.appType !== "string" || !rec.appType + || typeof rec.note !== "string" || !rec.note.startsWith(`imported:cc-switch:${obj.importId}:`) + || typeof rec.fingerprint !== "string" || !/^[0-9a-f]{64}$/.test(rec.fingerprint) + || seenNames.has(rec.name) + ) { + throw new CcSwitchImportError("Existing CC-Switch import journal is corrupt; inspect or restore before importing."); + } + seenNames.add(rec.name); + entries.push({ + name: rec.name, + sourceId: rec.sourceId, + appType: rec.appType, + note: rec.note, + fingerprint: rec.fingerprint, + }); + } + return { + schemaVersion: 1, + state: obj.state, + importId: obj.importId, + createdAt: obj.createdAt, + entries, + }; +} + +function evaluateExtractedRow( + row: RawExtractedRow, + targetName: string, + allowPrivateNetwork: boolean, +): { ok: true; candidate: CcSwitchCandidateProvider } | { ok: false; reason: CcSwitchInvalidReason } { + const sourceId = (row.id ?? "").trim(); + const appType = (row.appType || "").trim().toLowerCase(); + if (!sourceId) return { ok: false, reason: "malformed_entry" }; + if (isDisallowedMetadataValue(sourceId, "id") || (appType !== "" && isDisallowedMetadataValue(appType, "app"))) { + return { ok: false, reason: "disallowed_metadata" }; + } + if (!isValidProviderName(targetName)) return { ok: false, reason: "invalid_provider_name" }; + if (row.settingsType !== "object") return { ok: false, reason: "malformed_entry" }; + + let adapter: string; + let baseUrl: string | undefined; + let defaultModel: string | undefined; + + if (appType === "claude") { + adapter = "anthropic"; + baseUrl = (row.claudeBaseUrl ?? row.genericBaseUrl ?? "").trim() || undefined; + defaultModel = (row.claudeModel ?? row.genericModel ?? "").trim() || undefined; + } else if (appType === "codex") { + if (row.codexProjection !== undefined) { + if (!row.codexProjection.ok) return row.codexProjection; + const resolved = resolveCodexProjection(row.codexProjection.value); + if (!resolved.ok) return resolved; + baseUrl = resolved.baseUrl.trim(); + defaultModel = resolved.defaultModel?.trim() || (row.genericModel ?? "").trim() || undefined; + const wire = resolved.wireApi.trim().toLowerCase(); + if (wire === METADATA_REFUSED_SENTINEL.toLowerCase()) return { ok: false, reason: "disallowed_metadata" }; + if (wire === "responses" || wire === "openai-responses") adapter = "openai-responses"; + else if (wire === "chat" || wire === "chat_completions" || wire === "openai-completions" || wire === "openai-chat") adapter = "openai-chat"; + else return { ok: false, reason: "malformed_entry" }; + } else if (row.codexTomlLines !== null) { + const encoder = new TextEncoder(); + const tomlBytes = encoder.encode(row.codexTomlLines); + try { + const parsed = parseCodexTomlBytes(tomlBytes); + if (!parsed.ok) return parsed; + const resolved = resolveCodexProjection(parsed.value); + if (!resolved.ok) return resolved; + baseUrl = resolved.baseUrl.trim(); + defaultModel = resolved.defaultModel?.trim() || (row.genericModel ?? "").trim() || undefined; + const wire = resolved.wireApi.trim().toLowerCase(); + if (wire === METADATA_REFUSED_SENTINEL.toLowerCase()) return { ok: false, reason: "disallowed_metadata" }; + if (wire === "responses" || wire === "openai-responses") adapter = "openai-responses"; + else if (wire === "chat" || wire === "chat_completions" || wire === "openai-completions" || wire === "openai-chat") adapter = "openai-chat"; + else return { ok: false, reason: "malformed_entry" }; + } finally { + tomlBytes.fill(0); + } + } else if (row.genericBaseUrl) { + baseUrl = row.genericBaseUrl.trim(); + defaultModel = (row.genericModel ?? "").trim() || undefined; + const wire = (row.genericWireApi ?? "responses").trim().toLowerCase(); + if (wire === METADATA_REFUSED_SENTINEL.toLowerCase()) return { ok: false, reason: "disallowed_metadata" }; + if (wire === "responses" || wire === "openai-responses") adapter = "openai-responses"; + else if (wire === "chat" || wire === "chat_completions" || wire === "openai-completions" || wire === "openai-chat") adapter = "openai-chat"; + else return { ok: false, reason: "malformed_entry" }; + } else { + return { ok: false, reason: "missing_base_url" }; + } + } else if (appType === "openai" || appType === "opencode") { + adapter = "openai-chat"; + baseUrl = (row.genericBaseUrl ?? "").trim() || undefined; + defaultModel = (row.genericModel ?? "").trim() || undefined; + } else if (appType === "gemini") { + adapter = "google"; + baseUrl = (row.genericBaseUrl ?? "").trim() || undefined; + defaultModel = (row.genericModel ?? "").trim() || undefined; + } else { + return { ok: false, reason: "unsupported_app_type" }; + } + + if (!baseUrl) return { ok: false, reason: "missing_base_url" }; + if (defaultModel !== undefined && isDisallowedMetadataValue(defaultModel, "model")) { + return { ok: false, reason: "disallowed_metadata" }; + } + if (baseUrl === URL_SYNTAX_REFUSED_SENTINEL || isDisallowedMetadataValue(baseUrl, "url")) { + return { ok: false, reason: "invalid_base_url" }; + } + if (providerBaseUrlConfigError(baseUrl) !== null) { + return { ok: false, reason: "invalid_base_url" }; + } + // Classify against a synthetic non-registry name so local registry names (like "ollama") + // still require explicit --allow-private-network opt-in when imported from CC-Switch. + const strictDestErr = providerDestinationConfigError("__cc_switch_imported__", { baseUrl, allowPrivateNetwork: false }); + if (strictDestErr !== null) { + const permissiveDestErr = providerDestinationConfigError("__cc_switch_imported__", { baseUrl, allowPrivateNetwork: true }); + if (permissiveDestErr !== null || providerDestinationConfigError(targetName, { baseUrl, allowPrivateNetwork: true }) !== null) { + return { ok: false, reason: "invalid_base_url" }; + } + if (!allowPrivateNetwork) { + return { ok: false, reason: "private_network_refused" }; + } + return { + ok: true, + candidate: { + name: targetName, + sourceId, + appType, + adapter, + baseUrl, + ...(defaultModel ? { defaultModel } : {}), + allowPrivateNetwork: true, + }, + }; + } + + return { + ok: true, + candidate: { + name: targetName, + sourceId, + appType, + adapter, + baseUrl, + ...(defaultModel ? { defaultModel } : {}), + }, + }; +} + +function buildImportedProviderConfig(candidate: CcSwitchCandidateProvider, importId: string): OcxProviderConfig { + return { + adapter: candidate.adapter, + baseUrl: candidate.baseUrl, + ...(candidate.defaultModel ? { defaultModel: candidate.defaultModel } : {}), + ...(candidate.allowPrivateNetwork ? { allowPrivateNetwork: true } : {}), + note: `imported:cc-switch:${importId}:${candidate.sourceId}:${candidate.appType}`, + }; +} + +function assertStopped(): void { + let running = readAlivePid() !== null; + const runtime = readRuntimePort(); + if (runtime) { + try { + process.kill(runtime.pid, 0); + running = true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") running = true; + } + } + if (running) { + throw new CcSwitchImportError("Stop the opencodex proxy before applying or restoring a CC-Switch provider import."); + } +} + +/** + * Read-only byte-level inspection of ~/.opencodex/auth.json to determine whether `name` has stored + * OAuth accounts (or whether auth.json is unreadable/malformed) without decoding any OAuth tokens. + */ +export function hasOrCannotVerifyOAuthAccounts(name: string): boolean { + const authPath = getAuthStorePath(); + const nameBytes = Buffer.from(name, "utf8"); + let fd: number | undefined; + let buf: Buffer | undefined; + try { + try { + fd = openSync( + authPath, + constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0), + ); + const st = fstatSync(fd); + if (!st.isFile() || st.size > MAX_JSON_BYTES) return true; + if (st.size === 0) return true; + buf = Buffer.alloc(st.size); + let off = 0; + while (off < st.size) { + const got = readSync(fd, buf, off, st.size - off, off); + if (got <= 0) break; + off += got; + } + if (off !== st.size) return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + return true; + } finally { + if (fd !== undefined) { + try { closeSync(fd); } catch { /* ignore */ } + } + } + const bytes = buf!; + let pos = 0; + if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) pos = 3; + const skipWs = () => { + while (pos < bytes.length && (bytes[pos] === 0x20 || bytes[pos] === 0x09 || bytes[pos] === 0x0a || bytes[pos] === 0x0d)) pos++; + }; + const scanStr = (): { start: number; end: number; hasEsc: boolean } => { + const res = validateUtf8AndJsonStringBounds(bytes, pos); + if (!res) throw new Error("malformed"); + pos = res.nextPos; + return { start: res.start, end: res.end, hasEsc: res.hasEscapes }; + }; + const keyEqualsBytes = (b: { start: number; end: number; hasEsc: boolean }, target: Uint8Array): boolean => { + if (!b.hasEsc) { + const raw = bytes.subarray(b.start, b.end); + if (raw.length !== target.length) return false; + for (let i = 0; i < raw.length; i++) if (raw[i] !== target[i]) return false; + return true; + } + const unesc = unescapeJsonStringBytesBounded(bytes, { start: b.start, end: b.end, hasEscapes: true }, 128); + if (!unesc) throw new Error("malformed"); + try { + if (unesc.length !== target.length) return false; + for (let i = 0; i < unesc.length; i++) if (unesc[i] !== target[i]) return false; + return true; + } finally { + unesc.fill(0); + } + }; + const keyEqualsAscii = (b: { start: number; end: number; hasEsc: boolean }, target: string): boolean => { + if (!b.hasEsc) return equalsAsciiBytes(bytes.subarray(b.start, b.end), target); + const unesc = unescapeJsonStringBytesBounded(bytes, { start: b.start, end: b.end, hasEscapes: true }, 128); + if (!unesc) throw new Error("malformed"); + try { + return equalsAsciiBytes(unesc, target); + } finally { + unesc.fill(0); + } + }; + const skipPrim = () => { + const s = pos; + while (pos < bytes.length && ![0x20, 0x09, 0x0a, 0x0d, 0x2c, 0x7d, 0x5d].includes(bytes[pos]!)) pos++; + if (!validateJsonPrimitiveBytes(bytes, s, pos)) { + throw new Error("malformed"); + } + }; + const skipVal = (depth = 0): void => { + if (depth > 32) throw new Error("malformed"); + skipWs(); + const b = bytes[pos]; + if (b === 0x22) { scanStr(); return; } + if (b === 0x7b) { + pos++; skipWs(); + if (bytes[pos] === 0x7d) { pos++; return; } + for (;;) { + skipWs(); scanStr(); skipWs(); + if (bytes[pos] !== 0x3a) throw new Error("malformed"); + pos++; skipVal(depth + 1); skipWs(); + if (bytes[pos] === 0x2c) { pos++; continue; } + if (bytes[pos] === 0x7d) { pos++; return; } + throw new Error("malformed"); + } + } + if (b === 0x5b) { + pos++; skipWs(); + if (bytes[pos] === 0x5d) { pos++; return; } + for (;;) { + skipVal(depth + 1); skipWs(); + if (bytes[pos] === 0x2c) { pos++; continue; } + if (bytes[pos] === 0x5d) { pos++; return; } + throw new Error("malformed"); + } + } + skipPrim(); + }; + skipWs(); + if (bytes[pos] !== 0x7b) return true; + pos++; skipWs(); + let foundAccounts = false; + if (bytes[pos] === 0x7d) { + pos++; + } else { + for (;;) { + skipWs(); + const provKeyBounds = scanStr(); + skipWs(); + if (bytes[pos] !== 0x3a) return true; + pos++; skipWs(); + if (!keyEqualsBytes(provKeyBounds, nameBytes)) { + skipVal(); + } else { + // Inspect the provider entry without decoding any token strings. + if (bytes[pos] !== 0x7b) return true; + pos++; skipWs(); + if (bytes[pos] === 0x7d) { pos++; } + else { + let sawAccountsArray = false; + for (;;) { + skipWs(); + const fieldKeyBounds = scanStr(); + skipWs(); + if (bytes[pos] !== 0x3a) return true; + pos++; skipWs(); + if (keyEqualsAscii(fieldKeyBounds, "accounts")) { + if (bytes[pos] !== 0x5b) return true; + sawAccountsArray = true; + pos++; skipWs(); + if (bytes[pos] === 0x5d) { + pos++; + } else { + foundAccounts = true; + for (;;) { + skipVal(1); skipWs(); + if (bytes[pos] === 0x2c) { pos++; continue; } + if (bytes[pos] === 0x5d) { pos++; break; } + return true; + } + } + } else if (keyEqualsAscii(fieldKeyBounds, "activeAccountId") || keyEqualsAscii(fieldKeyBounds, "selectionRevision")) { + skipVal(1); + } else { + // Legacy single-credential shape or unknown field -> retain provider. + foundAccounts = true; + skipVal(1); + } + skipWs(); + if (bytes[pos] === 0x2c) { pos++; continue; } + if (bytes[pos] === 0x7d) { pos++; break; } + return true; + } + if (!sawAccountsArray) foundAccounts = true; + } + } + skipWs(); + if (bytes[pos] === 0x2c) { pos++; continue; } + if (bytes[pos] === 0x7d) { pos++; break; } + return true; + } + } + skipWs(); + if (pos !== bytes.length) return true; + return foundAccounts; + } catch { + return true; + } finally { + nameBytes.fill(0); + buf?.fill(0); + } +} + +/** + * Exhaustive check that no setting in OcxConfig references the given provider name before + * --restore deletes it. Runs inside the mutatePersistedConfig callback on every rebase attempt. + */ +export function isProviderReferencedInConfig(config: OcxConfig, name: string): boolean { + if (config.defaultProvider === name) return true; + if (Object.keys(config.providers ?? {}).length <= 1) return true; + if (hasOrCannotVerifyOAuthAccounts(name)) return true; + const prefix = `${name}/`; + const rewriteProbe = rewriteProviderReferences(structuredClone(config), name, "__cc_switch_probe__"); + if (rewriteProbe.changed > 0 || rewriteProbe.collisions.length > 0) return true; + + if (config.fallback?.providers && Object.hasOwn(config.fallback.providers, name)) return true; + if (config.fallback?.routes && Object.keys(config.fallback.routes).some(k => k === name || k.startsWith(prefix))) return true; + + const startsWithPrefix = (v: unknown): boolean => typeof v === "string" && v.trim().startsWith(prefix); + if (startsWithPrefix(config.claudeCode?.classifierModel)) return true; + if ((config.claudeCode?.classifierFallbacks ?? []).some(startsWithPrefix)) return true; + if (config.subagentModelFallbackByModel) { + for (const [k, list] of Object.entries(config.subagentModelFallbackByModel)) { + if (k.startsWith(prefix) || (Array.isArray(list) && list.some(startsWithPrefix))) return true; + } + } + for (const tierList of [config.codexRoleTiers?.fast, config.codexRoleTiers?.standard, config.codexRoleTiers?.frontier]) { + if ((tierList ?? []).some(startsWithPrefix)) return true; + } + if ((config.modelPickerOrder ?? []).some(startsWithPrefix)) return true; + if (config.modelPinnedEfforts && Object.keys(config.modelPinnedEfforts).some(k => k.startsWith(prefix))) return true; + if (startsWithPrefix(config.compactionRouting?.model)) return true; + if ((config.compactionRouting?.sourceModels ?? []).some(startsWithPrefix)) return true; + if (startsWithPrefix(config.compactionRecovery?.model)) return true; + if (startsWithPrefix(config.memoryModels?.extract?.model) || startsWithPrefix(config.memoryModels?.consolidation?.model)) return true; + if (startsWithPrefix(config.autoReviewOverride?.model)) return true; + if (startsWithPrefix(config.codexMainAccountHardLockFallback?.model)) return true; + if (startsWithPrefix(config.agentTaskRecovery?.model)) return true; + if (startsWithPrefix(config.advisor?.model)) return true; + if (config.blockedModelRedirects) { + for (const [k, v] of Object.entries(config.blockedModelRedirects)) { + if (k.startsWith(prefix) || startsWithPrefix(v)) return true; + } + } + for (const keyEntry of config.apiKeys ?? []) { + if ((keyEntry.allowedProviders ?? []).includes(name)) return true; + if ((keyEntry.allowedModels ?? []).some(startsWithPrefix)) return true; + } + if (config.spendPoolAliases && Object.values(config.spendPoolAliases).some(v => v === name)) return true; + for (const group of config.pool?.credentialGroups ?? []) { + if ((group.credentials ?? []).some(c => typeof c === "string" && c.toLowerCase().startsWith(`${name.toLowerCase()}:`))) { + return true; + } + } + return false; +} + +function requireValidPersistedConfig(): OcxConfig { + const diag = readConfigDiagnostics(); + if (diag.source !== "file" || diag.error !== null) { + throw new CcSwitchImportError("Initialize a valid opencodex configuration before importing or restoring providers."); + } + return diag.config; +} + +function assignTargetNames(rows: RawExtractedRow[]): string[] { + const slugCounts = new Map(); + const rawSlugs = rows.map(r => normalizeProviderSlug(r.id)); + for (const s of rawSlugs) { + slugCounts.set(s, (slugCounts.get(s) ?? 0) + 1); + } + const used = new Set(); + return rows.map((r, i) => { + const base = rawSlugs[i]!; + const appSlug = normalizeProviderSlug(r.appType); + let candidate = (slugCounts.get(base) ?? 0) > 1 && appSlug ? `${base}-${appSlug}` : base; + if (used.has(candidate)) { + let suffix = 2; + while (used.has(`${candidate}-${suffix}`)) suffix++; + candidate = `${candidate}-${suffix}`; + } + used.add(candidate); + return candidate; + }); +} + +function prepareCandidates(options: { + sourcePath: string; + selectedProviders?: readonly string[]; + allowPrivateNetwork?: boolean; + config: OcxConfig; +}): CcSwitchImportPreviewOrApplyResult { + const rows = readSourceRows(options.sourcePath); + const targetNames = assignTargetNames(rows); + const filterSet = options.selectedProviders && options.selectedProviders.length > 0 + ? new Set(options.selectedProviders.map(s => s.trim()).filter(Boolean)) + : null; + + if (filterSet) { + for (const wanted of filterSet) { + const matchesAny = rows.some((r, idx) => r.id === wanted || targetNames[idx] === wanted); + if (!matchesAny) { + throw new CcSwitchImportError("CC-Switch source does not contain one or more requested --provider IDs."); + } + } + } + + const result: CcSwitchImportPreviewOrApplyResult = { + mode: "preview", + discovered: 0, + eligible: 0, + imported: 0, + conflicts: 0, + conflictNames: [], + invalid: 0, + invalidReasons: {}, + providers: [], + }; + + rows.forEach((row, idx) => { + const targetName = targetNames[idx]!; + if (filterSet && !filterSet.has(row.id) && !filterSet.has(targetName)) return; + result.discovered++; + const evaluated = evaluateExtractedRow(row, targetName, options.allowPrivateNetwork === true); + if (!evaluated.ok) { + result.invalid++; + result.invalidReasons[evaluated.reason] = (result.invalidReasons[evaluated.reason] ?? 0) + 1; + return; + } + const { candidate } = evaluated; + if ( + hasOwnProvider(options.config.providers, candidate.name) + || Boolean(codexAccountNamespaceProviderCollisionError(options.config.codexAccountNamespaces, candidate.name)) + ) { + result.conflicts++; + result.conflictNames.push(candidate.name); + return; + } + result.eligible++; + result.providers.push(candidate); + }); + + return result; +} + +function reconcileJournalForApply(config: OcxConfig, journalPath: string): void { + const existing = readJournal(journalPath); + if (!existing) return; + const anyPublished = existing.entries.some(e => { + const current = config.providers?.[e.name]; + return current !== undefined && current.note === e.note; + }); + if (existing.state === "committed" || anyPublished) { + throw new CcSwitchImportError( + "An active CC-Switch import journal already exists; run 'ocx provider import-cc-switch --restore' before applying another import.", + ); + } + // Stale prepared journal where zero entries carry this journal's importId note. + unlinkJournalDurably(journalPath); +} + +export async function runCcSwitchProviderImport(options: { + mode: "preview" | "apply" | "restore"; + sourcePath?: string; + selectedProviders?: readonly string[]; + allowPrivateNetwork?: boolean; + onBeforeApplyWrite?: (providers: readonly CcSwitchCandidateProvider[]) => void; +}): Promise { + if (options.mode === "preview") { + if (!options.sourcePath) throw new CcSwitchImportError("--source is required for preview and apply."); + const config = requireValidPersistedConfig(); + return prepareCandidates({ + sourcePath: options.sourcePath, + selectedProviders: options.selectedProviders, + allowPrivateNetwork: options.allowPrivateNetwork, + config, + }); + } + + assertStopped(); + return await withConfigDirHardeningSettled(getConfigDir(), () => { + const lease = acquireOwnershipMutationLease(serviceStatePaths()); + try { + assertStopped(); + return withConfigMutationLockSync(() => { + assertStopped(); + const journalPath = join(getConfigDir(), CC_SWITCH_IMPORT_JOURNAL_FILENAME); + + if (options.mode === "apply") { + if (!options.sourcePath) throw new CcSwitchImportError("--source is required for preview and apply."); + const initialConfig = requireValidPersistedConfig(); + reconcileJournalForApply(initialConfig, journalPath); + + const prepared = prepareCandidates({ + sourcePath: options.sourcePath, + selectedProviders: options.selectedProviders, + allowPrivateNetwork: options.allowPrivateNetwork, + config: initialConfig, + }); + prepared.mode = "apply"; + if (prepared.providers.length === 0) return prepared; + options.onBeforeApplyWrite?.(prepared.providers); + + const importId = randomUUID(); + const builtEntries = prepared.providers.map(candidate => { + const providerConfig = buildImportedProviderConfig(candidate, importId); + const fingerprint = canonicalProviderFingerprint(providerConfig); + return { + candidate, + providerConfig, + journalEntry: { + name: candidate.name, + sourceId: candidate.sourceId, + appType: candidate.appType, + note: providerConfig.note!, + fingerprint, + }, + }; + }); + + const journal: ImportJournal = { + schemaVersion: 1, + state: "prepared", + importId, + createdAt: Date.now(), + entries: builtEntries.map(b => b.journalEntry), + }; + writeJournalDurably(journalPath, journal); + + try { + const outcome = mutatePersistedConfig(config => { + assertStopped(); + for (const { candidate, providerConfig } of builtEntries) { + if ( + hasOwnProvider(config.providers, candidate.name) + || Boolean(codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, candidate.name)) + ) { + throw new CcSwitchImportError("Target configuration changed and now conflicts with imported provider names; retry."); + } + config.providers[candidate.name] = structuredClone(providerConfig); + } + const validation = validateConfigCandidate(config); + if (!validation.ok) { + throw new CcSwitchImportError("CC-Switch imported provider configuration failed schema validation."); + } + return { changed: true, value: builtEntries.length }; + }); + if (outcome.status !== "committed") { + throw new CcSwitchImportError("Target configuration could not be committed."); + } + writeJournalDurably(journalPath, { ...journal, state: "committed" }); + prepared.imported = builtEntries.length; + return prepared; + } catch (error) { + // Never blindly unlink the prepared journal if the config write actually published! + const snap = readConfigFileSnapshot(); + if (snap.diagnostics.source === "file" && snap.diagnostics.error === null) { + const landed = journal.entries.filter(e => snap.diagnostics.config.providers?.[e.name]?.note === e.note); + if (landed.length === 0) { + unlinkJournalDurably(journalPath); + } else { + writeJournalDurably(journalPath, { ...journal, state: "committed", entries: landed }); + } + } + if (error instanceof CcSwitchImportError) throw error; + throw new CcSwitchImportError("CC-Switch provider import could not be committed."); + } + } + + // mode === "restore" + requireValidPersistedConfig(); + const journal = readJournal(journalPath); + if (!journal) { + throw new CcSwitchImportError("No CC-Switch provider import journal found to restore."); + } + + try { + const outcome = mutatePersistedConfig(config => { + assertStopped(); + const restoredNames: string[] = []; + let alreadyAbsent = 0; + const retainedEntries: JournalEntry[] = []; + + for (const entry of journal.entries) { + if (!hasOwnProvider(config.providers, entry.name)) { + alreadyAbsent++; + continue; + } + const current = config.providers[entry.name]!; + if ( + current.note !== entry.note + || canonicalProviderFingerprint(current) !== entry.fingerprint + || isProviderReferencedInConfig(config, entry.name) + ) { + retainedEntries.push(entry); + continue; + } + const probe = structuredClone(config); + delete probe.providers[entry.name]; + if (!validateConfigCandidate(probe).ok) { + retainedEntries.push(entry); + continue; + } + delete config.providers[entry.name]; + restoredNames.push(entry.name); + } + + if (restoredNames.length > 0) { + const validation = validateConfigCandidate(config); + if (!validation.ok) { + throw new CcSwitchImportError("Configuration after restoring CC-Switch providers failed validation."); + } + } + return { + changed: restoredNames.length > 0, + value: { restoredNames, alreadyAbsent, retainedEntries }, + }; + }); + + if (outcome.status === "unavailable") { + throw new CcSwitchImportError("Target configuration could not be committed during restore."); + } + const { restoredNames, alreadyAbsent, retainedEntries } = outcome.value; + if (retainedEntries.length === 0) { + unlinkJournalDurably(journalPath); + } else { + writeJournalDurably(journalPath, { + ...journal, + state: "committed", + entries: retainedEntries, + }); + } + return { + mode: "restore", + restored: restoredNames.length, + restoredNames, + alreadyAbsent, + retainedEdited: retainedEntries.length, + retainedNames: retainedEntries.map(e => e.name), + }; + } catch (error) { + const snap = readConfigFileSnapshot(); + if (snap.diagnostics.source === "file" && snap.diagnostics.error === null) { + const remaining = journal.entries.filter(e => hasOwnProvider(snap.diagnostics.config.providers, e.name)); + if (remaining.length > 0) { + writeJournalDurably(journalPath, { ...journal, state: "committed", entries: remaining }); + } + } + if (error instanceof CcSwitchImportError) throw error; + throw new CcSwitchImportError("CC-Switch provider restore could not complete."); + } + }); + } finally { + lease.release(); + } + }); +} diff --git a/structure/INDEX.md b/structure/INDEX.md index 457990d8f35..ebef961aade 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -342,6 +342,9 @@ A source area can be described by more than one doc, because these docs are orga | `src/providers/anthropic-billing-refusal.ts` | [`providers/anthropic-key-billing.md`](providers/anthropic-key-billing.md) | | `src/providers/antigravity-quota-identity.ts` | [`providers/jev-decision.md`](providers/jev-decision.md) | | `src/providers/api-key-pause.ts` | [`account-auto-pause.md`](account-auto-pause.md) | +| `src/providers/cc-switch-import-bytes.ts` | [`cli-management.md`](cli-management.md) | +| `src/providers/cc-switch-import-source.ts` | [`cli-management.md`](cli-management.md) | +| `src/providers/cc-switch-import.ts` | [`cli-management.md`](cli-management.md) | | `src/providers/codex-capacity.ts` | [`providers/openai-tiers.md`](providers/openai-tiers.md) | | `src/providers/dsh-account-destination.ts` | [`providers/dsh-account.md`](providers/dsh-account.md) | | `src/providers/quota-decision-antigravity.ts` | [`providers/jev-decision.md`](providers/jev-decision.md) | diff --git a/structure/cli-management.md b/structure/cli-management.md index c3aab111345..e601840594a 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -180,3 +180,9 @@ eligibility, drift refusal and recovery. No endpoint or credential is printed by On Windows the same durable record and status read are served by the handle-bound job-store backend described in `codex-cli-update.md`. The capability declarations document `ocx system codex-cli-update plan`, `apply` and `status` as the local counterparts of `GET /api/codex/cli-update/plan`, `POST /api/codex/cli-update/apply` and `GET /api/codex/cli-update/jobs/{jobId}`. They share the plan builder and durable job owner with the dashboard rather than sending HTTP management requests, so their driven-route lists remain empty. CLI apply requires `--yes`; HTTP apply separately requires explicit confirmation from a trusted loopback GUI session. The GUI parity test resolves these registered capabilities, checks their mutation classifications and documented resource counterparts, and includes all three resources in endpoint coverage. + +## Keyless CC-Switch provider import + +`src/cli/provider-cc-switch-import.ts`, `src/providers/cc-switch-import-bytes.ts`, `src/providers/cc-switch-import-source.ts` and `src/providers/cc-switch-import.ts` implement `ocx provider import-cc-switch`. Its primary security guarantee is structural: the importer projects values only from an explicit allowlist of non-secret metadata fields (provider IDs, app types, base URLs, wire dialects and default models); source and embedded-config bytes are read transiently while secret fields, free-text display names, and TOML comments are skipped at the byte level and never decoded into strings, written, or logged. Narrow base-URL grammar (`http(s)://host[:port]` with an empty path or 1–4 lowercase `[a-z0-9._-]` API path segments such as `/v1`, `/api/v1`, or `/compatible-mode/v1`) and identifier/model syntax checks act strictly as defense in depth before `TextDecoder.decode` and return non-echoing invalid-entry categories (`invalid_base_url`, `disallowed_metadata`); transient byte buffers are zeroed in `finally`. The source file is opened once with `O_RDONLY | O_NOFOLLOW | O_NONBLOCK`, format-detected by pure 16-byte header comparison without string decoding, verified before and after reading by `fstatSync` and `lstatSync` identity checks (`dev`, `ino`, `uid`, `gid`, `mode`, `size`, `mtimeMs`), refused fail-closed for SQLite when `fstatSync(fd).nlink > 1` or when a sibling `-wal` or `-journal` file is non-empty or unreadable, and deserialized for SQLite read-only in memory via `Database.deserialize` without reopening user pathnames or writing temporary snapshots. Destinations are validated with `providerBaseUrlConfigError` and `providerDestinationConfigError`, and `--apply` prints every imported ID, model, and destination URL before writing the journal or `config.json` (to `stdout` in text mode and `stderr` in `--json` mode, plus `trustNotice` in the JSON payload). + +Preview writes nothing. Apply and restore refuse on a link Child or while a proxy is running, acquire `acquireOwnershipMutationLease`, and run inside `withConfigMutationLockSync` and `mutatePersistedConfig`. Each apply generates a per-transaction UUID `importId` embedded in `providers..note` and writes `cc-switch-provider-import-journal.json` via `atomicWriteFileStreamed`. Repeated apply is refused while a committed or published journal exists; a post-publication throw retains the journal whenever any entry with `importId` landed on disk. Restore inspects `auth.json` read-only at the byte level (without decoding OAuth tokens, failing closed if unreadable or malformed) and rechecks fingerprint, `importId` note, `isProviderReferencedInConfig` and `validateConfigCandidate` on every rebase attempt, removing only unedited, unreferenced imported entries. Coverage: `tests/providers/cc-switch-import.test.ts` and `tests/cli/cli-provider-cc-switch-import.test.ts`. diff --git a/structure/manifest.json b/structure/manifest.json index 5ee1c6ebb0d..2857b699d95 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -833,6 +833,9 @@ "scope": "Terminal help and capability metadata, management-client output and the generated operating reference.", "documents": [ "src/cli/", + "src/providers/cc-switch-import-bytes.ts", + "src/providers/cc-switch-import-source.ts", + "src/providers/cc-switch-import.ts", "scripts/generate-ocx-skill-surface.ts", "skills/ocx/" ] diff --git a/tests/cli/cli-provider-cc-switch-import.test.ts b/tests/cli/cli-provider-cc-switch-import.test.ts new file mode 100644 index 00000000000..20f565fa3fd --- /dev/null +++ b/tests/cli/cli-provider-cc-switch-import.test.ts @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { cmdProviderCcSwitchImport, type CcSwitchImportCommandDeps } from "../../src/cli/provider-cc-switch-import"; + +const output: string[] = []; +let logSpy: ReturnType; +let errorSpy: ReturnType; + +beforeEach(() => { + logSpy = spyOn(console, "log").mockImplementation(val => { output.push(String(val)); }); + errorSpy = spyOn(console, "error").mockImplementation(val => { output.push(String(val)); }); +}); + +afterEach(() => { + output.length = 0; + logSpy.mockRestore(); + errorSpy.mockRestore(); +}); + +function makeDeps(calls: unknown[], invalid = 0, eligible = 1): CcSwitchImportCommandDeps { + return { + runImport: async options => { + calls.push(options); + if (options.mode === "restore") { + return { + mode: "restore", + restored: 1, + restoredNames: ["acme"], + alreadyAbsent: 0, + retainedEdited: 0, + retainedNames: [], + }; + } + const providers = eligible > 0 ? [{ + name: "acme", + sourceId: "acme", + appType: "claude", + adapter: "anthropic", + baseUrl: "https://api.acme.example", + defaultModel: "claude-sonnet-4-6", + }] : []; + if (options.mode === "apply" && providers.length > 0) { + options.onBeforeApplyWrite?.(providers); + } + return { + mode: options.mode, + discovered: eligible + invalid, + eligible, + imported: options.mode === "apply" ? eligible : 0, + conflicts: 0, + conflictNames: [], + invalid, + invalidReasons: invalid > 0 ? { malformed_entry: invalid } : {}, + providers, + }; + }, + }; +} + +describe("ocx provider import-cc-switch CLI", () => { + test("defaults to preview and supports repeatable --provider and --json", async () => { + const calls: unknown[] = []; + expect( + await cmdProviderCcSwitchImport( + ["--source", "cc-switch.db", "--provider", "p1", "--provider", "p2", "--json"], + makeDeps(calls), + ), + ).toBe(0); + expect(calls).toEqual([{ + mode: "preview", + sourcePath: "cc-switch.db", + selectedProviders: ["p1", "p2"], + allowPrivateNetwork: false, + }]); + expect(JSON.parse(output[0]!)).toMatchObject({ mode: "preview", eligible: 1, imported: 0 }); + }); + + test("dispatches --apply and --restore and rejects invalid flag combinations", async () => { + const calls: unknown[] = []; + expect(await cmdProviderCcSwitchImport(["--source", "cc-switch.db", "--apply"], makeDeps(calls))).toBe(0); + expect(output.join("\n")).toContain("id=acme (source=acme, claude -> anthropic) model=claude-sonnet-4-6 url=https://api.acme.example"); + expect(output.join("\n")).toContain("Keyless provider entries saved"); + + output.length = 0; + const jsonStdout: string[] = []; + const jsonStderr: string[] = []; + logSpy.mockImplementation(val => { output.push(String(val)); jsonStdout.push(String(val)); }); + errorSpy.mockImplementation(val => { output.push(String(val)); jsonStderr.push(String(val)); }); + expect(await cmdProviderCcSwitchImport(["--source", "cc-switch.db", "--apply", "--json"], makeDeps(calls))).toBe(0); + expect(jsonStderr.join("\n")).toContain("id=acme (source=acme, claude -> anthropic) model=claude-sonnet-4-6 url=https://api.acme.example"); + expect(JSON.parse(jsonStdout[0]!)).toMatchObject({ mode: "apply", imported: 1, trustNotice: expect.stringContaining("trusted CC-Switch source") }); + logSpy.mockImplementation(val => { output.push(String(val)); }); + errorSpy.mockImplementation(val => { output.push(String(val)); }); + + output.length = 0; + expect(await cmdProviderCcSwitchImport(["--source", "cc-switch.db", "--apply"], makeDeps(calls, 0, 0))).toBe(0); + expect(output.join("\n")).toContain("No eligible non-conflicting providers were imported"); + expect(output.join("\n")).not.toContain("Keyless provider entries saved"); + + output.length = 0; + expect(await cmdProviderCcSwitchImport(["--restore", "--json"], makeDeps(calls))).toBe(0); + expect(JSON.parse(output[0]!)).toMatchObject({ mode: "restore", restored: 1 }); + + for (const bad of [ + [], + ["--apply"], + ["--restore", "--source", "cc-switch.db"], + ["--restore", "--apply"], + ["--source", "a", "--source", "b"], + ["--source", "a", "--unknown"], + ]) { + const badCalls: unknown[] = []; + expect(await cmdProviderCcSwitchImport(bad, makeDeps(badCalls))).toBe(1); + expect(badCalls).toHaveLength(0); + } + }); + + test("redacts unexpected exceptions in JSON and text modes", async () => { + const throwing: CcSwitchImportCommandDeps = { + runImport: async () => { + throw new Error("internal-secret-path-and-token"); + }, + }; + expect(await cmdProviderCcSwitchImport(["--source", "cc-switch.db", "--json"], throwing)).toBe(1); + expect(output.join("\n")).not.toContain("internal-secret-path-and-token"); + expect(JSON.parse(output[0]!)).toMatchObject({ error: "cc_switch_import_failed" }); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 8b637ab2aa2..d1c0c9f03c4 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -504,7 +504,7 @@ "claude-source-envelope.test.ts": "claude-integration", "claude-system-env-auto.test.ts": "claude-integration", "cleanup-orphaned-workflows.test.ts": "ci-workflows", "clearable-deadline.test.ts": "lib", "cli-account-alias-target.test.ts": "cli", "cli-account-cancel-flow.test.ts": "cli", - "cli-account-kiro-device.test.ts": "cli", "cli-account-orca-import.test.ts": "cli", + "cli-account-kiro-device.test.ts": "cli", "cli-account-orca-import.test.ts": "cli", "cli-provider-cc-switch-import.test.ts": "cli", "cli-account-pin-drain.test.ts": "cli", "cli-account-pool-verbs.test.ts": "cli", "cli-account-threshold.test.ts": "cli", "cli-account-verify-status.test.ts": "cli", "cli-account.test.ts": "cli", "cli-capabilities-arguments.test.ts": "cli", @@ -1117,7 +1117,7 @@ "gpt61-terra-provider-rows.test.ts": "providers", "aistupidlevel-provider.test.ts": "providers", "tokenlab-provider.test.ts": "providers", "optional-shutdown-hooks.test.ts": "lib", - "orca-import.test.ts": "codex-integration", "orcarouter-provider.test.ts": "providers", + "orca-import.test.ts": "codex-integration", "orcarouter-provider.test.ts": "providers", "cc-switch-import.test.ts": "providers", "outbound-body-guard.test.ts": "server", "owned-service-home.test.ts": "server", "package-tree-fenced-restart.test.ts": "ci-workflows", "package-tree-integrity.test.ts": "ci-workflows", "package-tree-restart-ownership.test.ts": "ci-workflows", "package-tree-retarget.test.ts": "ci-workflows", diff --git a/tests/providers/cc-switch-import.test.ts b/tests/providers/cc-switch-import.test.ts new file mode 100644 index 00000000000..185f1a7ab85 --- /dev/null +++ b/tests/providers/cc-switch-import.test.ts @@ -0,0 +1,1102 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, statSync, unlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + getConfigPath, + loadConfig, + saveConfig, + setPersistedConfigMutationBeforeCommitForTests, +} from "../../src/config"; +import { flushConfigDirHardeningForTests } from "../../src/config/paths"; +import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; +import { getAuthStorePath, saveCredential } from "../../src/oauth/store"; +import { + CC_SWITCH_IMPORT_JOURNAL_FILENAME, + CcSwitchImportError, + METADATA_REFUSED_SENTINEL, + URL_SYNTAX_REFUSED_SENTINEL, + parseCodexTomlBytes, + readSqliteRows, + runCcSwitchProviderImport, + scanCcSwitchJsonBytes, + setCcSwitchSourceOpenHookForTests, +} from "../../src/providers/cc-switch-import"; +import { cmdProviderCcSwitchImport } from "../../src/cli/provider-cc-switch-import"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +// Runtime-assembled canary so privacy:scan never sees a literal token prefix in the source file. +const CANARY_SECRET = ["sk", "canary", "DO", "NOT", "READ", "9876543210abcdef"].join("-"); +const PREFIXLESS_HEX_SECRET = ["9f86d081", "884c7d65", "9a2feaa0", "c55ad015"].join(""); +const PREFIXLESS_MIXED_SECRET = "Ab3Def9Gh1Jk2Lm4Np6Qr8St0Uv"; +const WORD_CANARY = "violetotter"; + +let scratch: string; +let targetHome: string; +let sourceDir: string; +let originalHome: string | undefined; + +function createSqliteFixture( + rows: Array<{ id: string; app_type: string; name: string; settings_config: string; sort_index?: number }>, + walMode = false, +): string { + const dbPath = join(sourceDir, walMode ? "cc-switch-wal.db" : "cc-switch.db"); + const db = new Database(dbPath, { create: true }); + try { + if (walMode) db.run("PRAGMA journal_mode = WAL;"); + db.run(` + CREATE TABLE providers ( + id TEXT NOT NULL, + app_type TEXT NOT NULL, + name TEXT NOT NULL, + settings_config TEXT NOT NULL, + website_url TEXT, + category TEXT, + created_at INTEGER, + sort_index INTEGER, + notes TEXT, + icon TEXT, + icon_color TEXT, + meta TEXT NOT NULL DEFAULT '{}', + is_current BOOLEAN NOT NULL DEFAULT 0, + in_failover_queue BOOLEAN NOT NULL DEFAULT 0, + PRIMARY KEY (id, app_type) + ) + `); + const insert = db.prepare("INSERT INTO providers (id, app_type, name, settings_config, sort_index) VALUES (?1, ?2, ?3, ?4, ?5)"); + try { + for (const r of rows) { + insert.run(r.id, r.app_type, r.name, r.settings_config, r.sort_index ?? 0); + } + } finally { + insert.finalize(); + } + if (walMode) db.run("PRAGMA wal_checkpoint(TRUNCATE);"); + } finally { + db.close(); + } + return dbPath; +} + +beforeEach(() => { + originalHome = process.env.OPENCODEX_HOME; + const ok = { success: true, exitCode: 0, timedOut: false, stdout: "" }; + setIcaclsRunnerForTests(() => ok); + setAsyncIcaclsRunnerForTests(async () => ok); + scratch = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-cc-switch-"))); + targetHome = join(scratch, "ocx-home"); + sourceDir = join(scratch, "cc-switch"); + mkdirSync(targetHome, { recursive: true }); + mkdirSync(sourceDir, { recursive: true }); + process.env.OPENCODEX_HOME = targetHome; + saveConfig(loadConfig()); +}); + +afterEach(async () => { + await flushConfigDirHardeningForTests(); + setIcaclsRunnerForTests(null); + setAsyncIcaclsRunnerForTests(null); + setPersistedConfigMutationBeforeCommitForTests(null); + setCcSwitchSourceOpenHookForTests(null); + if (originalHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = originalHome; + removeTreeWithRetry(scratch); +}); + +describe("CC-Switch keyless provider import", () => { + test("SQLite preview and apply strip TOML comments, drop free-text names, keep source read-only, and never materialize secrets", async () => { + const codexToml = [ + `model = "gpt-5.6-sol" # inline_comment_token = "${CANARY_SECRET}" and ${PREFIXLESS_HEX_SECRET}`, + `model_provider = "acme.\\u0071uoted" # ${PREFIXLESS_MIXED_SECRET}`, + `experimental_bearer_token = "${CANARY_SECRET}"`, + `[ model_providers . "acme.\\u0071uoted" ] # header_secret = "${CANARY_SECRET}"`, + `base_url = "https://codex.acme.example/v1" # experimental_bearer_token = "${CANARY_SECRET}" ${PREFIXLESS_HEX_SECRET}`, + `wire_api = "responses" # ${CANARY_SECRET}`, + `api_key = "${CANARY_SECRET}"`, + ].join("\n"); + + const dbPath = createSqliteFixture([ + { + id: "acme-claude", + app_type: "claude", + name: `FreeTextNameWithSecret ${CANARY_SECRET} ${PREFIXLESS_HEX_SECRET}`, + settings_config: JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: "https://claude.acme.example", + ANTHROPIC_API_KEY: CANARY_SECRET, + ANTHROPIC_AUTH_TOKEN: PREFIXLESS_HEX_SECRET, + ANTHROPIC_MODEL: "claude-opus-4-5-20251101", + }, + }), + sort_index: 1, + }, + { + id: "acme-codex", + app_type: "codex", + name: `CodexNameSecret ${PREFIXLESS_MIXED_SECRET}`, + settings_config: JSON.stringify({ + auth: { OPENAI_API_KEY: CANARY_SECRET }, + config: codexToml + `\n[unrelated_section]\nbase_url = "${CANARY_SECRET}"\nmodel = "${CANARY_SECRET}"\n[model_providers.acme.extra]\nbase_url = "${CANARY_SECRET}"\n[model_providers."acme".extra]\nwire_api = "${CANARY_SECRET}"\n[model_providers."a"."b"]\nbase_url = "${CANARY_SECRET}"\n`, + }), + sort_index: 2, + }, + { + id: "acme-gemini", + app_type: "gemini", + name: `GeminiSecret ${CANARY_SECRET}`, + settings_config: JSON.stringify({ + env: { + GOOGLE_GEMINI_BASE_URL: "https://subrouter.ai/v1beta", + GEMINI_API_KEY: CANARY_SECRET, + GEMINI_MODEL: "gemini-2.5-pro", + }, + }), + sort_index: 3, + }, + { + id: "550e8400-e29b-41d4-a716-446655440000", + app_type: "openai", + name: `UuidSecret ${CANARY_SECRET}`, + settings_config: JSON.stringify({ + baseUrl: "https://api.openai.com/v1", + model: "qwen3-235b-a22b", + apiKey: CANARY_SECRET, + }), + sort_index: 4, + }, + ]); + const walDbPath = createSqliteFixture([ + { + id: "wal-claude", + app_type: "claude", + name: `WalSecret ${CANARY_SECRET}`, + settings_config: JSON.stringify({ env: { ANTHROPIC_BASE_URL: "https://wal.acme.example" } }), + }, + ], true); + const dbBytesBefore = readFileSync(dbPath); + const decodedStrings: string[] = []; + const origDecode = TextDecoder.prototype.decode; + const decodeSpy = spyOn(TextDecoder.prototype, "decode").mockImplementation(function (this: TextDecoder, ...args: unknown[]) { + const res = origDecode.apply(this, args as [BufferSource?, TextDecodeOptions?]); + decodedStrings.push(res); + return res; + }); + + let preview; + let walRows; + try { + const sqliteProjected = JSON.stringify(readSqliteRows(dbPath)); + expect(sqliteProjected).not.toContain(CANARY_SECRET); + expect(sqliteProjected).not.toContain(PREFIXLESS_HEX_SECRET); + expect(sqliteProjected).not.toContain(PREFIXLESS_MIXED_SECRET); + walRows = readSqliteRows(walDbPath); + + preview = await runCcSwitchProviderImport({ mode: "preview", sourcePath: dbPath }); + } finally { + decodeSpy.mockRestore(); + } + expect(walRows).toHaveLength(1); + expect(walRows[0]?.id).toBe("wal-claude"); + expect(walRows[0]?.claudeBaseUrl).toBe("https://wal.acme.example"); + for (const s of decodedStrings) { + expect(s).not.toContain(CANARY_SECRET); + expect(s).not.toContain(PREFIXLESS_HEX_SECRET); + expect(s).not.toContain(PREFIXLESS_MIXED_SECRET); + } + + expect(preview).toEqual({ + mode: "preview", + discovered: 4, + eligible: 4, + imported: 0, + conflicts: 0, + conflictNames: [], + invalid: 0, + invalidReasons: {}, + providers: [ + { + name: "acme-claude", + sourceId: "acme-claude", + appType: "claude", + adapter: "anthropic", + baseUrl: "https://claude.acme.example", + defaultModel: "claude-opus-4-5-20251101", + }, + { + name: "acme-codex", + sourceId: "acme-codex", + appType: "codex", + adapter: "openai-responses", + baseUrl: "https://codex.acme.example/v1", + defaultModel: "gpt-5.6-sol", + }, + { + name: "acme-gemini", + sourceId: "acme-gemini", + appType: "gemini", + adapter: "google", + baseUrl: "https://subrouter.ai/v1beta", + defaultModel: "gemini-2.5-pro", + }, + { + name: "550e8400-e29b-41d4-a716-446655440000", + sourceId: "550e8400-e29b-41d4-a716-446655440000", + appType: "openai", + adapter: "openai-chat", + baseUrl: "https://api.openai.com/v1", + defaultModel: "qwen3-235b-a22b", + }, + ], + }); + expect(JSON.stringify(preview)).not.toContain(CANARY_SECRET); + expect(existsSync(join(targetHome, CC_SWITCH_IMPORT_JOURNAL_FILENAME))).toBe(false); + + const applied = await runCcSwitchProviderImport({ mode: "apply", sourcePath: dbPath }); + expect(applied.mode).toBe("apply"); + if (applied.mode === "apply") { + expect(applied.imported).toBe(4); + } + + const configRaw = readFileSync(getConfigPath(), "utf8"); + const journalRaw = readFileSync(join(targetHome, CC_SWITCH_IMPORT_JOURNAL_FILENAME), "utf8"); + expect(configRaw).not.toContain(CANARY_SECRET); + expect(journalRaw).not.toContain(CANARY_SECRET); + + const saved = loadConfig(); + expect(saved.providers["acme-claude"]).toMatchObject({ + adapter: "anthropic", + baseUrl: "https://claude.acme.example", + defaultModel: "claude-opus-4-5-20251101", + }); + expect(saved.providers["acme-claude"]?.apiKey).toBeUndefined(); + expect(saved.providers["acme-codex"]).toMatchObject({ + adapter: "openai-responses", + baseUrl: "https://codex.acme.example/v1", + defaultModel: "gpt-5.6-sol", + }); + expect(saved.providers["acme-codex"]?.apiKey).toBeUndefined(); + expect(saved.providers["acme-gemini"]).toMatchObject({ + adapter: "google", + baseUrl: "https://subrouter.ai/v1beta", + defaultModel: "gemini-2.5-pro", + }); + expect(saved.providers["acme-gemini"]?.apiKey).toBeUndefined(); + expect(saved.providers["550e8400-e29b-41d4-a716-446655440000"]).toMatchObject({ + adapter: "openai-chat", + baseUrl: "https://api.openai.com/v1", + defaultModel: "qwen3-235b-a22b", + }); + expect(readFileSync(dbPath).equals(dbBytesBefore)).toBe(true); + }); + + test("legacy JSON byte scanner zeroes its buffer, skips secret fields, and disambiguates multi-app IDs", async () => { + const jsonPath = join(sourceDir, "config.json"); + const doc = { + apps: { + claude: { + providers: { + shared: { + name: `NameSecret ${CANARY_SECRET} ${PREFIXLESS_HEX_SECRET}`, + settingsConfig: { + env: { + ANTHROPIC_BASE_URL: "https://claude.shared.example", + ANTHROPIC_MODEL: "claude-sonnet-4-20250514", + ANTHROPIC_API_KEY: CANARY_SECRET, + }, + }, + }, + }, + }, + gemini: { + providers: { + "6ba7b810-9dad-11d1-80b4-00c04fd430c8": { + name: `GemName ${CANARY_SECRET}`, + settingsConfig: { + env: { + GOOGLE_GEMINI_BASE_URL: "https://openrouter.ai/api", + GEMINI_API_KEY: CANARY_SECRET, + GEMINI_MODEL: "gpt-4o-2024-08-06", + }, + }, + }, + }, + }, + openai: { + providers: { + shared: { + name: "Shared OpenAI", + settingsConfig: { + baseUrl: "https://openai.shared.example/v1", + apiKey: CANARY_SECRET, + model: "gpt-4.1", + }, + }, + }, + }, + codex: { + providers: { + "codex-json": { + name: "Codex JSON", + settingsConfig: { + [PREFIXLESS_HEX_SECRET]: CANARY_SECRET, + auth: { OPENAI_API_KEY: CANARY_SECRET }, + config: [ + `model = "gpt-5.6-sol" # comment_secret = "${CANARY_SECRET}" ${PREFIXLESS_HEX_SECRET}`, + `model_provider = "cjson" # ${PREFIXLESS_MIXED_SECRET}`, + `experimental_bearer_token = "${CANARY_SECRET}"`, + `"${PREFIXLESS_HEX_SECRET}" = "ignored"`, + `[model_providers.cjson] # ${CANARY_SECRET}`, + `base_url = "https://codex.json.example/v1" # ${CANARY_SECRET} ${PREFIXLESS_HEX_SECRET}`, + 'wire_api = "responses"', + `api_key = "${CANARY_SECRET}"`, + '[unrelated]', + `[unrelated.${PREFIXLESS_HEX_SECRET}]`, + `base_url = "${CANARY_SECRET}"`, + ].join("\n"), + }, + }, + }, + }, + }, + }; + writeFileSync(jsonPath, "\uFEFF" + JSON.stringify(doc, null, 2)); + const rawBytes = new Uint8Array(readFileSync(jsonPath)); + const decodedStrings: string[] = []; + const origDecode = TextDecoder.prototype.decode; + const decodeSpy = spyOn(TextDecoder.prototype, "decode").mockImplementation(function (this: TextDecoder, ...args: unknown[]) { + const res = origDecode.apply(this, args as [BufferSource?, TextDecodeOptions?]); + decodedStrings.push(res); + return res; + }); + let scanned; + let secretHeaderResult; + let escapedQuoteHeaderResult; + let escapedQuoteModelProviderResult; + try { + scanned = scanCcSwitchJsonBytes(rawBytes); + const secretHeaderBytes = Buffer.from(`[model_providers.${PREFIXLESS_HEX_SECRET}]\nbase_url = "https://x.example.com/v1"\n`, "utf8"); + const escapedQuoteHeaderBytes = Buffer.from('[model_providers."foo\\"bar"]\nbase_url = "https://x.example.com/v1"\n', "utf8"); + const escapedQuoteMpBytes = Buffer.from('model_provider = "foo\\"bar"\n[model_providers.foo]\nbase_url = "https://x.example.com/v1"\n', "utf8"); + try { + secretHeaderResult = parseCodexTomlBytes(secretHeaderBytes); + escapedQuoteHeaderResult = parseCodexTomlBytes(escapedQuoteHeaderBytes); + escapedQuoteModelProviderResult = parseCodexTomlBytes(escapedQuoteMpBytes); + } finally { + secretHeaderBytes.fill(0); + escapedQuoteHeaderBytes.fill(0); + escapedQuoteMpBytes.fill(0); + } + } finally { + decodeSpy.mockRestore(); + } + expect(secretHeaderResult).toEqual({ ok: false, reason: "disallowed_metadata" }); + expect(escapedQuoteHeaderResult).toEqual({ ok: false, reason: "disallowed_metadata" }); + expect(escapedQuoteModelProviderResult).toEqual({ ok: false, reason: "disallowed_metadata" }); + expect(decodedStrings.join("|")).not.toContain('foo"bar'); + expect(rawBytes.every(b => b === 0)).toBe(true); + expect(JSON.stringify(scanned)).not.toContain(CANARY_SECRET); + expect(JSON.stringify(scanned)).not.toContain(PREFIXLESS_HEX_SECRET); + expect(JSON.stringify(scanned)).not.toContain(PREFIXLESS_MIXED_SECRET); + for (const s of decodedStrings) { + expect(s).not.toContain(CANARY_SECRET); + expect(s).not.toContain(PREFIXLESS_HEX_SECRET); + expect(s).not.toContain(PREFIXLESS_MIXED_SECRET); + } + expect(scanned.find(r => r.id === "codex-json")?.codexTomlLines).toBeNull(); + + const applied = await runCcSwitchProviderImport({ + mode: "apply", + sourcePath: jsonPath, + selectedProviders: ["shared-claude", "6ba7b810-9dad-11d1-80b4-00c04fd430c8"], + }); + expect(applied.mode).toBe("apply"); + if (applied.mode === "apply") { + expect(applied.imported).toBe(2); + expect(applied.providers.map(p => p.name)).toEqual(["shared-claude", "6ba7b810-9dad-11d1-80b4-00c04fd430c8"]); + } + const saved = loadConfig(); + expect(saved.providers["shared-claude"]?.baseUrl).toBe("https://claude.shared.example"); + expect(saved.providers["shared-claude"]?.defaultModel).toBe("claude-sonnet-4-20250514"); + expect(saved.providers["6ba7b810-9dad-11d1-80b4-00c04fd430c8"]).toMatchObject({ + adapter: "google", + baseUrl: "https://openrouter.ai/api", + defaultModel: "gpt-4o-2024-08-06", + }); + expect(saved.providers["shared-openai"]).toBeUndefined(); + }); + + test("restore removes unedited imported entries, retains edited or referenced ones, and blocks repeated apply until restored", async () => { + const dbPath = createSqliteFixture([ + { + id: "prov-a", + app_type: "claude", + name: "Prov A", + settings_config: JSON.stringify({ env: { ANTHROPIC_BASE_URL: "https://a.example.com", ANTHROPIC_MODEL: "token" } }), + }, + { + id: "prov-b", + app_type: "openai", + name: "Prov B", + settings_config: JSON.stringify({ baseUrl: "https://b.example.com/v1" }), + }, + { + id: "prov-c", + app_type: "openai", + name: "Prov C", + settings_config: JSON.stringify({ baseUrl: "https://c.example.com/v1" }), + }, + { + id: "kimi", + app_type: "openai", + name: "Kimi", + settings_config: JSON.stringify({ baseUrl: "https://api.moonshot.ai/v1" }), + }, + ]); + + await runCcSwitchProviderImport({ mode: "apply", sourcePath: dbPath }); + expect(loadConfig().providers["prov-a"]?.defaultModel).toBe("token"); + + // Repeated apply while journal is active must be refused. + await expect(runCcSwitchProviderImport({ mode: "apply", sourcePath: dbPath })).rejects.toThrow( + /active CC-Switch import journal already exists/, + ); + + // User edits prov-b (adds a model), references prov-c in fallback.providers, and logs into kimi via OAuth store. + const edited = loadConfig(); + edited.providers["prov-b"]!.defaultModel = "user-added-model"; + edited.fallback = { providers: { "prov-c": { provider: false } } }; + saveConfig(edited); + await saveCredential("kimi", { + access: "oauth-access-fixture", + refresh: "oauth-refresh-fixture", + expires: Date.now() + 3600_000, + }); + + const restored1 = await runCcSwitchProviderImport({ mode: "restore" }); + expect(restored1).toEqual({ + mode: "restore", + restored: 1, + restoredNames: ["prov-a"], + alreadyAbsent: 0, + retainedEdited: 3, + retainedNames: ["kimi", "prov-b", "prov-c"], + }); + + const after1 = loadConfig(); + expect(after1.providers["prov-a"]).toBeUndefined(); + expect(after1.providers["prov-b"]?.defaultModel).toBe("user-added-model"); + expect(after1.providers["prov-c"]).toBeDefined(); + expect(after1.providers["kimi"]).toBeDefined(); + // Journal remains for the retained entries. + expect(existsSync(join(targetHome, CC_SWITCH_IMPORT_JOURNAL_FILENAME))).toBe(true); + + // If auth.json becomes malformed, restore fails closed and retains remaining journaled entries. + const cleanCfg = loadConfig(); + delete cleanCfg.fallback; + saveConfig(cleanCfg); + writeFileSync(getAuthStorePath(), "{corrupt-json"); + const restoredWithCorruptAuth = await runCcSwitchProviderImport({ mode: "restore" }); + expect(restoredWithCorruptAuth).toEqual({ + mode: "restore", + restored: 0, + restoredNames: [], + alreadyAbsent: 0, + retainedEdited: 3, + retainedNames: ["kimi", "prov-b", "prov-c"], + }); + expect(loadConfig().providers["prov-c"]).toBeDefined(); + + // Malformed escapes or unpaired UTF-16 surrogates in an unrelated provider's auth.json entry also fail closed + // without decoding any auth.json strings. + for (const badAuthJson of [ + '{"other":{"accounts":[{"access":"bad\\xescape"}]}}', + '{"other":{"accounts":[{"access":"bad\\u123Ghex"}]}}', + '{"other":{"accounts":[{"access":"lone\\uD800surrogate"}]}}', + ]) { + writeFileSync(getAuthStorePath(), badAuthJson); + const authDecoded: string[] = []; + const origDecode = TextDecoder.prototype.decode; + const spy = spyOn(TextDecoder.prototype, "decode").mockImplementation(function (this: TextDecoder, ...args: unknown[]) { + const s = origDecode.apply(this, args as [BufferSource?, TextDecodeOptions?]); + authDecoded.push(s); + return s; + }); + try { + const res = await runCcSwitchProviderImport({ mode: "restore" }); + expect(res).toEqual({ + mode: "restore", + restored: 0, + restoredNames: [], + alreadyAbsent: 0, + retainedEdited: 3, + retainedNames: ["kimi", "prov-b", "prov-c"], + }); + } finally { + spy.mockRestore(); + } + expect(authDecoded.join("|")).not.toContain("escape"); + expect(authDecoded.join("|")).not.toContain("surrogate"); + expect(loadConfig().providers["prov-c"]).toBeDefined(); + } + }); + + test("rejects adversarial TOML multiline strings, non-scalar JSON paths, secret-like URLs, and private networks without opt-in", async () => { + const dbPath = createSqliteFixture([ + { + id: "multiline-toml", + app_type: "codex", + name: "Multiline", + settings_config: JSON.stringify({ + config: `secret = """\nbase_url = "https://evil.example.com/v1"\n"""`, + }), + }, + { + id: "non-scalar-url", + app_type: "claude", + name: "NonScalar", + settings_config: JSON.stringify({ + env: { ANTHROPIC_BASE_URL: { nested: CANARY_SECRET } }, + }), + }, + { + id: "hex-path-url", + app_type: "openai", + name: "SecretUrl", + settings_config: JSON.stringify({ + baseUrl: `https://api.example.com/v1/${PREFIXLESS_HEX_SECRET}`, + }), + }, + { + id: "prefixless-secret-in-model", + app_type: "claude", + name: "PrefixlessModel", + settings_config: JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: "https://claude.acme.example", + ANTHROPIC_MODEL: PREFIXLESS_MIXED_SECRET, + }, + }), + }, + { + id: PREFIXLESS_HEX_SECRET, + app_type: "openai", + name: "PrefixlessId", + settings_config: JSON.stringify({ + baseUrl: "https://api.example.com/v1", + }), + }, + { + id: "ollama", + app_type: "openai", + name: "Loopback", + settings_config: JSON.stringify({ + baseUrl: "http://127.0.0.1:11434/v1", + }), + }, + { + id: "ambiguous-codex", + app_type: "codex", + name: "Ambiguous", + settings_config: JSON.stringify({ + config: '[model_providers.one]\nbase_url = "https://one.example.com/v1"\n[model_providers.two]\nbase_url = "https://two.example.com/v1"\n', + }), + }, + { + id: "overlong-toml", + app_type: "codex", + name: "Overlong", + settings_config: JSON.stringify({ + config: Array.from({ length: 505 }, (_, i) => `# comment ${i}`).concat(['base_url = "https://late.example.com/v1"']).join("\n"), + }), + }, + { + id: "oversize-toml-bytes", + app_type: "codex", + name: "OversizeBytes", + settings_config: JSON.stringify({ + config: "#" + "x".repeat(70_000) + '\nbase_url = "https://huge.example.com/v1"\n', + }), + }, + { + id: "AbcDefGhiJ1-prov", + app_type: "openai", + name: "MixedCaseDigitId", + settings_config: JSON.stringify({ baseUrl: "https://api.example.com/v1" }), + }, + { + id: "mixed-url-path", + app_type: "openai", + name: "MixedUrlPath", + settings_config: JSON.stringify({ baseUrl: "https://api.example.com/AbCdEfGhIjKl" }), + }, + { + id: "bad-app-kind", + app_type: PREFIXLESS_MIXED_SECRET, + name: "SecretAppType", + settings_config: JSON.stringify({ baseUrl: "https://api.example.com/v1" }), + }, + { + id: "escaped-quote-sqlite", + app_type: "codex", + name: "EscapedQuoteSqlite", + settings_config: JSON.stringify({ + config: 'model_provider = "foo\\"bar"\n[model_providers."foo\\"bar"]\nbase_url = "https://x.example.com/v1"\n', + }), + }, + { + id: "non-date-digits-model", + app_type: "claude", + name: "NonDateDigits", + settings_config: JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: "https://claude.acme.example", + ANTHROPIC_MODEL: "12345678-12345678", + }, + }), + }, + { + id: "uuid-in-model-rejected", + app_type: "claude", + name: "UuidModel", + settings_config: JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: "https://claude.acme.example", + ANTHROPIC_MODEL: "550e8400-e29b-41d4-a716-446655440000", + }, + }), + }, + ]); + + const decodedDuringSqlite: string[] = []; + const origDecode = TextDecoder.prototype.decode; + const decodeSpy = spyOn(TextDecoder.prototype, "decode").mockImplementation(function (this: TextDecoder, ...args: unknown[]) { + const s = origDecode.apply(this, args as [BufferSource?, TextDecodeOptions?]); + decodedDuringSqlite.push(s); + return s; + }); + let rawSqliteRows; + try { + rawSqliteRows = readSqliteRows(dbPath); + } finally { + decodeSpy.mockRestore(); + } + const projected = JSON.stringify(rawSqliteRows); + expect(projected).not.toContain(PREFIXLESS_HEX_SECRET); + expect(projected).not.toContain(PREFIXLESS_MIXED_SECRET); + expect(projected).not.toContain("AbcDefGhiJ1-prov"); + expect(projected).not.toContain("AbCdEfGhIjKl"); + expect(projected).not.toContain('foo"bar'); + expect(rawSqliteRows.find(r => r.id === "mixed-url-path")?.genericBaseUrl).toBe(URL_SYNTAX_REFUSED_SENTINEL); + expect(rawSqliteRows.find(r => r.id === "bad-app-kind")?.appType).toBe(METADATA_REFUSED_SENTINEL); + for (const s of decodedDuringSqlite) { + expect(s).not.toContain(PREFIXLESS_HEX_SECRET); + expect(s).not.toContain(PREFIXLESS_MIXED_SECRET); + expect(s).not.toContain("AbcDefGhiJ1-prov"); + expect(s).not.toContain("AbCdEfGhIjKl"); + expect(s).not.toContain('foo"bar'); + } + + const preview = await runCcSwitchProviderImport({ mode: "preview", sourcePath: dbPath }); + expect(preview.mode).toBe("preview"); + if (preview.mode === "preview") { + expect(preview.eligible).toBe(0); + expect(preview.invalid).toBe(15); + expect(preview.invalidReasons).toEqual({ + malformed_entry: 3, + missing_base_url: 1, + invalid_base_url: 2, + disallowed_metadata: 7, + private_network_refused: 1, + ambiguous_codex_provider: 1, + }); + } + + const withPrivate = await runCcSwitchProviderImport({ + mode: "preview", + sourcePath: dbPath, + selectedProviders: ["ollama"], + allowPrivateNetwork: true, + }); + if (withPrivate.mode === "preview") { + expect(withPrivate.eligible).toBe(1); + expect(withPrivate.providers[0]?.allowPrivateNetwork).toBe(true); + } + }); + + test("rebase conflict inside mutatePersistedConfig fails closed and cleans up the un-published prepared journal", async () => { + const dbPath = createSqliteFixture([ + { + id: "raced-prov", + app_type: "openai", + name: "Raced", + settings_config: JSON.stringify({ baseUrl: "https://raced.example.com/v1" }), + }, + ]); + + setPersistedConfigMutationBeforeCommitForTests(() => { + const concurrent = loadConfig(); + concurrent.providers["raced-prov"] = { + adapter: "openai-chat", + baseUrl: "https://other-writer.example.com/v1", + }; + saveConfig(concurrent); + }); + + await expect(runCcSwitchProviderImport({ mode: "apply", sourcePath: dbPath })).rejects.toThrow( + CcSwitchImportError, + ); + // Because the other writer's raced-prov does not carry our importId note, the prepared journal is cleaned up + // and cannot delete the other writer's provider on restore! + expect(existsSync(join(targetHome, CC_SWITCH_IMPORT_JOURNAL_FILENAME))).toBe(false); + expect(loadConfig().providers["raced-prov"]?.baseUrl).toBe("https://other-writer.example.com/v1"); + }); + + test("prepared journal left behind after config publication still blocks re-apply and restores cleanly", async () => { + const dbPath = createSqliteFixture([ + { + id: "crash-prov", + app_type: "openai", + name: "Crash Prov", + settings_config: JSON.stringify({ baseUrl: "https://crash.example.com/v1" }), + }, + ]); + + await runCcSwitchProviderImport({ mode: "apply", sourcePath: dbPath }); + const journalPath = join(targetHome, CC_SWITCH_IMPORT_JOURNAL_FILENAME); + const journal = JSON.parse(readFileSync(journalPath, "utf8")); + // Simulate a crash right after config publication before the journal state transitioned from prepared to committed. + writeFileSync(journalPath, JSON.stringify({ ...journal, state: "prepared" }, null, 2) + "\n"); + + // Because crash-prov in config.providers carries journal.importId in its note, apply must refuse! + await expect(runCcSwitchProviderImport({ mode: "apply", sourcePath: dbPath })).rejects.toThrow( + /active CC-Switch import journal already exists/, + ); + + // And restore must still roll back crash-prov and remove the journal. + const restored = await runCcSwitchProviderImport({ mode: "restore" }); + expect(restored).toEqual({ + mode: "restore", + restored: 1, + restoredNames: ["crash-prov"], + alreadyAbsent: 0, + retainedEdited: 0, + retainedNames: [], + }); + expect(loadConfig().providers["crash-prov"]).toBeUndefined(); + expect(existsSync(journalPath)).toBe(false); + }); + + test("refuses FIFO special files without hanging and detects TOCTOU source replacement", async () => { + if (process.platform !== "win32") { + const fifoPath = join(sourceDir, "pipe.db"); + const mk = Bun.spawnSync(["mkfifo", fifoPath]); + expect(mk.exitCode).toBe(0); + await expect(runCcSwitchProviderImport({ mode: "preview", sourcePath: fifoPath })).rejects.toThrow( + /regular file/, + ); + } + + const dbPath = createSqliteFixture([ + { + id: "toctou-prov", + app_type: "openai", + name: "Toctou", + settings_config: JSON.stringify({ baseUrl: "https://toctou.example.com/v1" }), + }, + ]); + const replacementPath = join(sourceDir, "other.db"); + writeFileSync(replacementPath, readFileSync(dbPath)); + + setCcSwitchSourceOpenHookForTests(target => { + writeFileSync(target + ".swap", readFileSync(replacementPath)); + const { renameSync } = require("node:fs"); + renameSync(target + ".swap", target); + }); + + await expect(runCcSwitchProviderImport({ mode: "preview", sourcePath: dbPath })).rejects.toThrow( + /changed while opening/, + ); + }); + + test("F1/F2: word-shaped canary in secret fields or 16-byte header never materializes, URL path grammar rejects non-API paths, and --apply prints before writing", async () => { + const sqlPath = join(sourceDir, "word-canary.db"); + const db = new Database(sqlPath, { create: true }); + try { + db.run(`CREATE TABLE providers ( + id TEXT NOT NULL, app_type TEXT NOT NULL, name TEXT NOT NULL, settings_config TEXT NOT NULL, + website_url TEXT, notes TEXT, meta TEXT NOT NULL DEFAULT '{}', sort_index INTEGER, PRIMARY KEY (id, app_type) + )`); + db.prepare("INSERT INTO providers (id, app_type, name, settings_config, website_url, notes, meta, sort_index) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)").run( + "dashscope-compat", + "openai", + WORD_CANARY, + JSON.stringify({ + baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", + model: "qwen3-coder-plus", + apiKey: WORD_CANARY, + api_key: WORD_CANARY, + env: { OPENAI_API_KEY: WORD_CANARY }, + auth: { bearer: WORD_CANARY }, + }), + `https://${WORD_CANARY}.example.com`, + WORD_CANARY, + JSON.stringify({ token: WORD_CANARY }), + 1, + ); + for (const [idx, badUrl] of [ + `https://api.example.com/${WORD_CANARY}`, + `https://api.example.com/v1/${WORD_CANARY}`, + "https://api.example.com/V1", + "https://api.example.com/a/b/c/d/v1", + "https://api.example.com/v1/%76ioletotter", + `https://api.example.com/v1?key=${WORD_CANARY}`, + ].entries()) { + db.prepare("INSERT INTO providers (id, app_type, name, settings_config, sort_index) VALUES (?1, ?2, ?3, ?4, ?5)").run( + `bad-url-${idx}`, + "openai", + WORD_CANARY, + JSON.stringify({ baseUrl: badUrl, apiKey: WORD_CANARY }), + idx + 2, + ); + } + } finally { + db.close(); + } + + // Compact JSON placing WORD_CANARY inside bytes 0..15 of the file header AND in secret fields, + // plus an invalid .db file starting with WORD_CANARY in bytes 0..15. + const headerJsonPath = join(sourceDir, "header-canary.json"); + const headerJsonString = JSON.stringify({ + [WORD_CANARY]: WORD_CANARY, + providers: { + "ok-codex": { + name: WORD_CANARY, + app_type: "codex", + apiKey: WORD_CANARY, + settings_config: { + auth: { OPENAI_API_KEY: WORD_CANARY }, + config: [ + `model = "gpt-5.6-sol" # ${WORD_CANARY}`, + `api_key = "${WORD_CANARY}"`, + `[model_providers.ok]`, + `base_url = "https://api.openai.com/v1" # ${WORD_CANARY}`, + `[other]`, + `base_url = "https://api.example.com/${WORD_CANARY}"`, + ].join("\n"), + }, + }, + "bad-json-url": { + app_type: "openai", + settings_config: { + baseUrl: `https://api.example.com/v1/${WORD_CANARY}`, + }, + }, + }, + }); + expect(headerJsonString.slice(0, 15)).toContain(WORD_CANARY); + writeFileSync(headerJsonPath, headerJsonString); + const fakeDbPath = join(sourceDir, "fake-header.db"); + writeFileSync(fakeDbPath, `${WORD_CANARY}0123456789abcdef`); + + const observedStrings: string[] = []; + const origDecode = TextDecoder.prototype.decode; + const origBufToString = Buffer.prototype.toString; + const origFromCharCode = String.fromCharCode; + const origFromCodePoint = String.fromCodePoint; + const decodeSpy = spyOn(TextDecoder.prototype, "decode").mockImplementation(function (this: TextDecoder, ...args: unknown[]) { + const s = origDecode.apply(this, args as [BufferSource?, TextDecodeOptions?]); + observedStrings.push(s); + return s; + }); + const bufStrSpy = spyOn(Buffer.prototype, "toString").mockImplementation(function (this: Buffer, ...args: unknown[]) { + const s = origBufToString.apply(this, args as [BufferEncoding?, number?, number?]); + observedStrings.push(s); + return s; + }); + const charCodeSpy = spyOn(String, "fromCharCode").mockImplementation((...codes: number[]) => { + const s = origFromCharCode(...codes); + observedStrings.push(s); + return s; + }); + const codePointSpy = spyOn(String, "fromCodePoint").mockImplementation((...codes: number[]) => { + const s = origFromCodePoint(...codes); + observedStrings.push(s); + return s; + }); + + const cliLines: string[] = []; + let printedBeforeDiskCommit = false; + const logSpy = spyOn(console, "log").mockImplementation(v => { cliLines.push(String(v)); }); + const errSpy = spyOn(console, "error").mockImplementation(v => { cliLines.push(String(v)); }); + try { + await expect(runCcSwitchProviderImport({ mode: "preview", sourcePath: fakeDbPath })).rejects.toThrow(/not a valid SQLite 3 file/); + const jsonPreview = await runCcSwitchProviderImport({ mode: "preview", sourcePath: headerJsonPath }); + expect(jsonPreview).toMatchObject({ + mode: "preview", + discovered: 2, + eligible: 1, + invalid: 1, + invalidReasons: { invalid_base_url: 1 }, + }); + expect(JSON.stringify(jsonPreview)).not.toContain(WORD_CANARY); + + // Direct --apply without prior preview must print imported id, model, and URL BEFORE writing config.json! + let callbackSawNoJournalOrConfig = false; + const callbackPrinted = await runCcSwitchProviderImport({ + mode: "apply", + sourcePath: sqlPath, + onBeforeApplyWrite: providers => { + expect(existsSync(join(targetHome, CC_SWITCH_IMPORT_JOURNAL_FILENAME))).toBe(false); + expect(loadConfig().providers["dashscope-compat"]).toBeUndefined(); + expect(providers).toEqual([{ + name: "dashscope-compat", + sourceId: "dashscope-compat", + appType: "openai", + adapter: "openai-chat", + baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", + defaultModel: "qwen3-coder-plus", + }]); + callbackSawNoJournalOrConfig = true; + }, + }); + expect(callbackSawNoJournalOrConfig).toBe(true); + expect(callbackPrinted.mode).toBe("apply"); + await runCcSwitchProviderImport({ mode: "restore" }); + + // Also verify CLI --apply --json prints pre-write summary to stderr BEFORE writing config.json, while keeping stdout pure JSON! + const stdoutOnly: string[] = []; + const stderrOnly: string[] = []; + logSpy.mockImplementation(v => { cliLines.push(String(v)); stdoutOnly.push(String(v)); }); + errSpy.mockImplementation(v => { cliLines.push(String(v)); stderrOnly.push(String(v)); }); + setPersistedConfigMutationBeforeCommitForTests(() => { + const currentOnDisk = loadConfig(); + expect(currentOnDisk.providers["dashscope-compat"]).toBeUndefined(); + expect(stdoutOnly).toHaveLength(0); + const printedSoFar = stderrOnly.join("\n"); + expect(printedSoFar).toContain("id=dashscope-compat"); + expect(printedSoFar).toContain("model=qwen3-coder-plus"); + expect(printedSoFar).toContain("url=https://dashscope.aliyuncs.com/compatible-mode/v1"); + printedBeforeDiskCommit = true; + }); + const exitCode = await cmdProviderCcSwitchImport(["--source", sqlPath, "--apply", "--json"]); + expect(exitCode).toBe(0); + expect(printedBeforeDiskCommit).toBe(true); + expect(JSON.parse(stdoutOnly[0]!)).toMatchObject({ mode: "apply", imported: 1, invalidReasons: { invalid_base_url: 6 } }); + } finally { + logSpy.mockRestore(); + errSpy.mockRestore(); + decodeSpy.mockRestore(); + bufStrSpy.mockRestore(); + charCodeSpy.mockRestore(); + codePointSpy.mockRestore(); + } + + for (const s of observedStrings) { + expect(s).not.toContain(WORD_CANARY); + } + expect(cliLines.join("\n")).not.toContain(WORD_CANARY); + expect(readFileSync(getConfigPath(), "utf8")).not.toContain(WORD_CANARY); + expect(readFileSync(join(targetHome, CC_SWITCH_IMPORT_JOURNAL_FILENAME), "utf8")).not.toContain(WORD_CANARY); + }); + + test("F3: refuses open WAL database holding uncheckpointed inserts and updates until checkpointed", async () => { + const walDbPath = join(sourceDir, "live-wal.db"); + const writer = new Database(walDbPath, { create: true }); + try { + writer.run("PRAGMA journal_mode = WAL;"); + writer.run("CREATE TABLE providers (id TEXT NOT NULL, app_type TEXT NOT NULL, name TEXT NOT NULL, settings_config TEXT NOT NULL, sort_index INTEGER, PRIMARY KEY (id, app_type))"); + writer.prepare("INSERT INTO providers VALUES (?1, ?2, ?3, ?4, ?5)").run( + "existing-prov", + "openai", + "Existing", + JSON.stringify({ baseUrl: "https://old.acme.example/v1" }), + 1, + ); + writer.run("PRAGMA wal_checkpoint(TRUNCATE);"); + + // Disable auto-checkpoint so subsequent UPDATE and INSERT stay exclusively in live-wal.db-wal. + writer.run("PRAGMA wal_autocheckpoint = 0;"); + writer.prepare("UPDATE providers SET settings_config = ?1 WHERE id = 'existing-prov'").run( + JSON.stringify({ baseUrl: "https://updated.acme.example/v1" }), + ); + writer.prepare("INSERT INTO providers VALUES (?1, ?2, ?3, ?4, ?5)").run( + "uncheckpointed-prov", + "claude", + "Uncheckpointed", + JSON.stringify({ env: { ANTHROPIC_BASE_URL: "https://claude-new.acme.example/v1" } }), + 2, + ); + expect(statSync(`${walDbPath}-wal`).size).toBeGreaterThan(0); + + // Original path (nlink === 1) is refused while live-wal.db-wal is non-empty. + expect(() => readSqliteRows(walDbPath)).toThrow(/uncheckpointed WAL changes.*close CC-Switch/i); + await expect(runCcSwitchProviderImport({ mode: "preview", sourcePath: walDbPath })).rejects.toThrow( + /uncheckpointed WAL changes.*close CC-Switch/i, + ); + await expect(runCcSwitchProviderImport({ mode: "apply", sourcePath: walDbPath })).rejects.toThrow( + /uncheckpointed WAL changes.*close CC-Switch/i, + ); + + // Hard-linked alias path has no sibling alias-live.db-wal; descriptor fstat nlink > 1 must refuse + // both alias and original fail-closed rather than silently reading stale pre-WAL bytes! + const aliasDbPath = join(sourceDir, "alias-live.db"); + linkSync(walDbPath, aliasDbPath); + try { + expect(existsSync(`${aliasDbPath}-wal`)).toBe(false); + expect(statSync(`${walDbPath}-wal`).size).toBeGreaterThan(0); + expect(() => readSqliteRows(aliasDbPath)).toThrow(/multiple hard links.*original CC-Switch database path/i); + await expect(runCcSwitchProviderImport({ mode: "preview", sourcePath: aliasDbPath })).rejects.toThrow( + /multiple hard links.*original CC-Switch database path/i, + ); + await expect(runCcSwitchProviderImport({ mode: "preview", sourcePath: walDbPath })).rejects.toThrow( + /multiple hard links.*original CC-Switch database path/i, + ); + const cliJsonOut: string[] = []; + const logSpy = spyOn(console, "log").mockImplementation(v => { cliJsonOut.push(String(v)); }); + try { + expect(await cmdProviderCcSwitchImport(["--source", aliasDbPath, "--json"])).toBe(1); + } finally { + logSpy.mockRestore(); + } + expect(cliJsonOut.join("\n")).not.toContain(aliasDbPath); + expect(JSON.parse(cliJsonOut[0]!)).toMatchObject({ error: "sqlite_hardlink_refused" }); + } finally { + writer.close(); + unlinkSync(aliasDbPath); + } + } finally { + try { writer.close(); } catch { /* already closed */ } + } + + // Reopen and checkpoint cleanly (simulating CC-Switch closing/checkpointing); retry now succeeds with updated data. + const checkpointer = new Database(walDbPath); + try { + checkpointer.run("PRAGMA wal_checkpoint(TRUNCATE);"); + } finally { + checkpointer.close(); + } + + const afterClose = await runCcSwitchProviderImport({ mode: "preview", sourcePath: walDbPath }); + expect(afterClose.mode).toBe("preview"); + if (afterClose.mode === "preview") { + expect(afterClose.eligible).toBe(2); + expect(afterClose.providers).toEqual([ + { + name: "existing-prov", + sourceId: "existing-prov", + appType: "openai", + adapter: "openai-chat", + baseUrl: "https://updated.acme.example/v1", + }, + { + name: "uncheckpointed-prov", + sourceId: "uncheckpointed-prov", + appType: "claude", + adapter: "anthropic", + baseUrl: "https://claude-new.acme.example/v1", + }, + ]); + } + }); +}); From 3ea89653f5727aea41948ddbd5e833f74afe3e36 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 29 Oct 2026 17:38:44 +0900 Subject: [PATCH 41/84] fix(gui): explain upstream request failures (#7936) * fix(gui): explain upstream request failures * fix(gui): suppress retries after lossy log normalization * fix(logs): preserve attempt normalization loss flag * docs(logs): record normalization loss guard * test(logs): cover persisted retry projection * docs(gui): explain request failure accounting * docs(gui): clarify settled retry accounting * docs(gui): align retry accounting wording * docs(gui): clarify settled accounting in Russian * fix(logs): persist normalization loss marker * Organize request log test layout * Preserve usage normalization loss metadata * Preserve normalization loss during log sanitization * Normalize request attempts at ingress * Preserve live request attempt records --- .../content/docs/fr/guides/web-dashboard.md | 2 + .../src/content/docs/guides/web-dashboard.md | 2 + .../content/docs/ja/guides/web-dashboard.md | 2 + .../content/docs/ko/guides/web-dashboard.md | 2 + .../content/docs/ru/guides/web-dashboard.md | 2 + .../content/docs/tr/guides/web-dashboard.md | 2 + .../docs/zh-cn/guides/web-dashboard.md | 2 + .../docs/zh-tw/guides/web-dashboard.md | 2 + gui/src/i18n/de.ts | 2 + gui/src/i18n/en.ts | 2 + gui/src/i18n/fr.ts | 2 + gui/src/i18n/ja.ts | 2 + gui/src/i18n/ko.ts | 2 + gui/src/i18n/pt.ts | 2 + gui/src/i18n/ru.ts | 2 + gui/src/i18n/tr.ts | 2 + gui/src/i18n/vi.ts | 2 + gui/src/i18n/zh-TW.ts | 2 + gui/src/i18n/zh.ts | 2 + gui/src/pages/Logs.tsx | 95 ++------ gui/src/pages/logs-failure-summary.ts | 103 +++++++++ gui/tests/logs-upstream-faults.test.tsx | 214 ++++++++++++++++++ scripts/test-layout/layout-usage.json | 4 + scripts/test-layout/layout.json | 5 +- src/server/management/shared.ts | 1 + src/server/request-log.ts | 13 +- src/usage/log.ts | 21 +- structure/dashboard-and-usage.md | 2 +- tests/fixtures/test-layout-expected.json | 2 +- tests/usage/request-log-normalization.test.ts | 109 +++++++++ tests/usage/request-outcome-agreement.test.ts | 55 ++++- 31 files changed, 566 insertions(+), 96 deletions(-) create mode 100644 gui/src/pages/logs-failure-summary.ts create mode 100644 gui/tests/logs-upstream-faults.test.tsx create mode 100644 scripts/test-layout/layout-usage.json create mode 100644 tests/usage/request-log-normalization.test.ts diff --git a/docs-site/src/content/docs/fr/guides/web-dashboard.md b/docs-site/src/content/docs/fr/guides/web-dashboard.md index 9b7cfe005b3..6b11e7cc80e 100644 --- a/docs-site/src/content/docs/fr/guides/web-dashboard.md +++ b/docs-site/src/content/docs/fr/guides/web-dashboard.md @@ -93,6 +93,8 @@ Les vues Utilisation, Tableau de bord, Fournisseurs, Catalogue des fournisseurs ### Filtrer les requêtes +Le détail d’une requête privilégie la cause d’échec amont enregistrée plutôt qu’un message générique d’erreur du proxy. Le nombre d’envois physiques est toujours affiché lorsqu’il est valide ; une comptabilité absente ou invalide affiche **Unavailable**, et non zéro. **Retries** compte les envois après le premier uniquement lorsqu’une seule tentative entièrement réglée et cohérente est enregistrée. Les tentatives multiples, les comptabilités non résolues ou incohérentes et les normalisations avec perte omettent les retries au lieu d’en inventer. Une cause persistante inconnue revient au titre fondé sur le statut, sans attribution brute. + Les filtres combinent interface, requêtes interceptées, fournisseur, modèle exact, statut, période, vitesse et identifiant de conversation dans le journal chargé. Les choix incluent les tentatives de repli ; les modèles ignorent la casse et les espaces externes, sans correspondance partielle. Un choix disparu revient à Tous. Les périodes de 15 minutes, une heure et un jour évoluent toutes les 30 secondes dans l’onglet Logs, même sans actualisation automatique. La vitesse mesure les jetons de sortie par seconde sur toute la durée : moins de 15, de 15 à moins de 50, ou au moins 50 ; les valeurs indisponibles sont exclues quand ce filtre est actif. Réussite : 2xx ; erreur : 4xx/5xx. diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 8759beef4e4..677bd9e46d4 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -154,6 +154,8 @@ timer. Requests already sent upstream retain their original credentials. ### Filtering request logs +Request details prefer a recognized upstream failure cause over generic proxy-error copy. Physical sends are always shown when valid; missing or invalid accounting shows **Unavailable**, not zero. **Retries** counts sends after the first only when one fully settled, consistent attempt is recorded. Multi-attempt, unresolved, inconsistent, or lossy-normalized records omit retries instead of inventing them. Unknown persisted causes use the status-based headline without raw attribution. + A proxy replay refusal keeps HTTP 429 and error code `upstream_reset_replay_refused`, but its request and attempt failure cause is `transport-ambiguous`, with resend permission `refused-ambiguous`: the upstream may already have processed the turn. A genuine provider diff --git a/docs-site/src/content/docs/ja/guides/web-dashboard.md b/docs-site/src/content/docs/ja/guides/web-dashboard.md index b45983256c2..eb8c7522c07 100644 --- a/docs-site/src/content/docs/ja/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ja/guides/web-dashboard.md @@ -70,6 +70,8 @@ bun run dev:gui ### リクエストログの絞り込み +リクエスト詳細では、一般的なプロキシエラーの文言よりも、記録されたアップストリームの失敗原因を優先して表示します。物理的なアップストリーム送信数は有効な場合に常に表示し、会計情報がない、または無効な場合は 0 ではなく **Unavailable** と表示します。**Retries** は、完全に精算され整合する単一の試行が記録されている場合に限り、最初の送信以降の追加送信回数として表示します。複数の試行、未解決・不整合な会計、または損失のある attempt 正規化では、再試行回数を推測せず非表示にします。未知の永続化された原因は、状態に基づく見出しに戻ります。 + Logsではサーフェス、インターセプトされたリクエスト、プロバイダー、完全なモデル名、ステータス、時間、速度、会話IDを組み合わせて、読み込み済みログを絞り込みます。選択肢にはフォールバック試行も含まれます。モデル名は大文字小文字と前後の空白を無視しますが、部分一致ではありません。ログから消えた選択肢は全件に戻ります。 時間は直近15分・1時間・1日で、Logsタブでは自動更新をオフにしても30秒ごとに更新します。速度はリクエスト全体の時間あたりの毎秒出力トークン数で、15未満、15以上50未満、50以上です。速度フィルター中は測定不能な行を除外します。成功は2xx、エラーは4xx/5xxです。 diff --git a/docs-site/src/content/docs/ko/guides/web-dashboard.md b/docs-site/src/content/docs/ko/guides/web-dashboard.md index d97002b856b..c34d9d2a70c 100644 --- a/docs-site/src/content/docs/ko/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ko/guides/web-dashboard.md @@ -70,6 +70,8 @@ Usage, Dashboard, 공급자 작업 화면·카탈로그, API 키 화면은 읽 ### 요청 로그 필터 +요청 상세 화면은 일반 프록시 오류 문구 대신 기록된 업스트림 실패 원인을 우선 표시합니다. 물리적 업스트림 전송 수는 유효할 때 항상 표시하며, 회계 정보가 없거나 유효하지 않으면 0이 아니라 **Unavailable**로 표시합니다. **Retries**는 완전히 정산되고 일관된 단일 시도가 기록된 경우에만 첫 전송 이후 추가 전송 횟수로 표시합니다. 여러 시도, 미해결·불일치 회계 또는 손실 있는 attempt 정규화에서는 재시도 횟수를 만들어 내지 않고 숨깁니다. 알 수 없는 영속 원인은 상태 기반 제목으로 돌아갑니다. + Logs에서는 클라이언트 종류, 가로챈 요청, 공급자, 정확한 모델명, 상태, 시간, 속도, 대화 ID 조건을 함께 적용할 수 있습니다. 현재 불러온 로그만 필터링하며, 공급자·모델 선택지에는 폴백 시도도 포함됩니다. 모델명은 대소문자와 앞뒤 공백을 diff --git a/docs-site/src/content/docs/ru/guides/web-dashboard.md b/docs-site/src/content/docs/ru/guides/web-dashboard.md index d2d9873dbbe..1d912d7f48e 100644 --- a/docs-site/src/content/docs/ru/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ru/guides/web-dashboard.md @@ -73,6 +73,8 @@ bun run dev:gui ### Фильтрация запросов +В деталях запроса распознанная причина сбоя upstream имеет приоритет над общим текстом об ошибке прокси. Число физических отправок всегда показывается, если оно корректно; при отсутствующем или некорректном учёте отображается **Unavailable**, а не ноль. **Retries** считает отправки после первой только при наличии одной попытки с полностью урегулированным и согласованным учётом. При нескольких попытках, незавершённом или противоречивом учёте и при нормализации с потерями retries не выводятся, вместо того чтобы выдумывать их. Неизвестные сохранённые причины возвращают заголовок по статусу без сырой атрибуции. + Фильтры объединяют источник, перехваченные запросы, провайдера, точную модель, статус, время, скорость и ID диалога в загруженном журнале. Варианты включают резервные попытки; модель сравнивается без учёта регистра и крайних пробелов, но не по подстроке. Исчезнувший вариант сбрасывается на все записи. Периоды 15 минут, час и сутки обновляются каждые 30 секунд на вкладке Logs даже при выключенном автообновлении. Скорость — выходные токены в секунду за полную длительность запроса: меньше 15, от 15 до менее 50, не менее 50; недоступные значения исключаются при активном фильтре скорости. Успех — 2xx, ошибки — 4xx/5xx. diff --git a/docs-site/src/content/docs/tr/guides/web-dashboard.md b/docs-site/src/content/docs/tr/guides/web-dashboard.md index e432e2f8cd6..fee8d7b1d78 100644 --- a/docs-site/src/content/docs/tr/guides/web-dashboard.md +++ b/docs-site/src/content/docs/tr/guides/web-dashboard.md @@ -94,6 +94,8 @@ Kullanım, panel, sağlayıcı çalışma alanı, sağlayıcı kataloğu ve API ### İstek günlüklerini filtreleme +İstek ayrıntıları, genel proxy hatası metni yerine kaydedilmiş upstream hata nedenini öncelikli gösterir. Fiziksel gönderim sayısı geçerliyse her zaman gösterilir; eksik veya geçersiz muhasebe bilgisi sıfır yerine **Unavailable** olarak görüntülenir. **Retries**, yalnızca muhasebesi tamamen kesinleşmiş ve tutarlı tek bir deneme kaydı bulunduğunda ilk gönderimden sonraki ek gönderim sayısını gösterir. Birden fazla deneme, çözümlenmemiş veya tutarsız muhasebe ve kayıplı attempt normalizasyonu, uydurma yeniden deneme sayısı yerine retries alanını gizler. Bilinmeyen kalıcı nedenler, ham atıf olmadan durum tabanlı başlığa döner. + Filtreler yüklü günlükte yüzey, yakalanan istekler, sağlayıcı, tam model adı, durum, zaman, hız ve konuşma kimliğini birleştirir. Seçenekler yedek denemeleri de içerir; model eşleşmesi büyük/küçük harfi ve dış boşlukları yok sayar, kısmi adları eşleştirmez. Kaybolan seçenek tüm kayıtlara döner. Son 15 dakika, saat ve gün pencereleri Logs sekmesinde otomatik yenileme kapalıyken de 30 saniyede bir güncellenir. Hız, tam istek süresindeki saniyelik çıktı jetonudur: 15 altı, 15 dahil 50 altı, en az 50; hız filtresi açıkken ölçülemeyenler dışlanır. Başarı 2xx, hata 4xx/5xx anlamındadır. diff --git a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md index 4776dd575eb..2c2cde7bd9f 100644 --- a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md @@ -70,6 +70,8 @@ bun run dev:gui ### 筛选请求日志 +请求详情优先显示已记录的上游失败原因,而不是通用的代理错误文案。有效的物理上游发送次数始终显示;缺失或无效的会计信息显示为 **Unavailable**,而不是 0。只有在记录到一次已完全结算且一致的尝试时,**Retries** 才显示首次发送后的额外发送次数。多次尝试、未解决或不一致的会计信息,以及有损失的 attempt 归一化,都不会编造重试次数。未知的持久化原因回退为基于状态的标题。 + Logs 可组合界面、被拦截请求、提供商、完整模型名、状态、时间、速度和会话 ID,筛选当前已加载的日志。选项包含回退尝试;模型匹配忽略大小写及首尾空格,但不做部分匹配。日志中消失的选项恢复为全部。 时间范围为最近 15 分钟、1 小时或 1 天;Logs 标签页每 30 秒更新一次,即使关闭自动刷新也会更新。速度按完整请求耗时计算每秒输出 token,分为小于 15、15 至小于 50、至少 50;启用速度筛选时排除无测量值的请求。成功为 2xx,错误为 4xx/5xx。 diff --git a/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md b/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md index 3c9340ef572..ea24d88142b 100644 --- a/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md @@ -79,6 +79,8 @@ GUI session 簽發到服務的頁面中,並在到期或代理重啟時靜默 ### 篩選請求日誌 +請求詳細資訊會優先顯示已記錄的上游失敗原因,而不是通用的代理錯誤文案。有效的上游實際傳送次數一律顯示;缺少或無效的記帳資訊則顯示 **Unavailable**,而非 0。只有在記錄到一次已完全結算且一致的嘗試時,**Retries** 才顯示首次傳送後的額外傳送次數。多次嘗試、未解決或不一致的記帳資訊,以及有損失的 attempt 正規化,都不會憑空產生重試次數。未知的持久化原因會回退為依狀態顯示的標題。 + Logs 可組合介面、被攔截請求、供應商、完整模型名稱、狀態、時間、速度和對話 ID,篩選目前已載入的日誌。選項包含回退嘗試;模型比對忽略大小寫及頭尾空白,但不做部分比對。日誌中消失的選項恢復為全部。 時間範圍為最近 15 分鐘、1 小時或 1 天;Logs 分頁每 30 秒更新一次,即使關閉自動重新整理也會更新。速度按完整請求耗時計算每秒輸出 token,分為小於 15、15 至小於 50、至少 50;啟用速度篩選時排除無測量值的請求。成功為 2xx,錯誤為 4xx/5xx。 diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index cdfa3f1531b..fffab7207a4 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1266,6 +1266,8 @@ export const de: Record = { "logs.detail.outcome.incomplete": "Unvollständig", "logs.detail.outcome.aborted": "Abgebrochen", "logs.detail.sends.label": "Upstream-Sendungen", + "logs.detail.sends.unavailable": "Nicht verfügbar", + "logs.detail.retries.label": "Wiederholungen", "logs.detail.sends.unresolved": "ungeklärt", "logs.detail.attempt.recovery.unknown": "Unbekannter Wiederherstellungsgrund", "logs.detail.cause.label": "Fehlerursache", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index a506d8c7596..d805772ff80 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1324,6 +1324,8 @@ export const en = { "logs.detail.outcome.incomplete": "Incomplete", "logs.detail.outcome.aborted": "Aborted", "logs.detail.sends.label": "Upstream sends", + "logs.detail.sends.unavailable": "Unavailable", + "logs.detail.retries.label": "Retries", "logs.detail.sends.unresolved": "unresolved", "logs.detail.attempt.recovery.unknown": "Unknown recovery reason", "logs.detail.cause.label": "Failure cause", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 40e1f436ae7..f3fbc99a28c 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1300,6 +1300,8 @@ export const fr: Record = { "logs.detail.outcome.incomplete": "Incomplet", "logs.detail.outcome.aborted": "Interrompu", "logs.detail.sends.label": "Envois en amont", + "logs.detail.sends.unavailable": "Indisponible", + "logs.detail.retries.label": "Nouvelles tentatives", "logs.detail.sends.unresolved": "non résolus", "logs.detail.attempt.recovery.oauth401": "Réauthentification OAuth", "logs.detail.attempt.recovery.key429": "Clé soumise à une limitation de débit (429)", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 7d74ccd8c92..a3b6940897f 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1207,6 +1207,8 @@ export const ja: Record = { "logs.detail.outcome.incomplete": "未完了", "logs.detail.outcome.aborted": "中断", "logs.detail.sends.label": "アップストリーム送信", + "logs.detail.sends.unavailable": "利用不可", + "logs.detail.retries.label": "再試行", "logs.detail.sends.unresolved": "未確定", "logs.detail.attempt.recovery.unknown": "不明なリカバリ理由", "logs.detail.cause.label": "失敗の原因", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index bc6d056aa59..a92e751b06b 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1305,6 +1305,8 @@ export const ko: Record = { "logs.detail.outcome.incomplete": "미완료", "logs.detail.outcome.aborted": "중단됨", "logs.detail.sends.label": "업스트림 전송", + "logs.detail.sends.unavailable": "사용 불가", + "logs.detail.retries.label": "재시도", "logs.detail.sends.unresolved": "미확인", "logs.detail.attempt.recovery.unknown": "알 수 없는 복구 사유", "logs.detail.cause.label": "실패 원인", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index 5b5a2278907..526567b7eba 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -1319,6 +1319,8 @@ export const pt: Record = { "logs.detail.outcome.incomplete": "Incompleta", "logs.detail.outcome.aborted": "Abortada", "logs.detail.sends.label": "Envios upstream", + "logs.detail.sends.unavailable": "Indisponível", + "logs.detail.retries.label": "Novas tentativas", "logs.detail.sends.unresolved": "não resolvido", "logs.detail.attempt.recovery.unknown": "Motivo de recuperação desconhecido", "logs.detail.cause.label": "Causa da falha", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index f9061168408..5835b18f727 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1283,6 +1283,8 @@ export const ru: Record = { "logs.detail.outcome.incomplete": "Не завершено", "logs.detail.outcome.aborted": "Прервано", "logs.detail.sends.label": "Отправки к провайдеру", + "logs.detail.sends.unavailable": "Недоступно", + "logs.detail.retries.label": "Повторные попытки", "logs.detail.sends.unresolved": "не подтверждено", "logs.detail.attempt.recovery.unknown": "Неизвестная причина восстановления", "logs.detail.cause.label": "Причина сбоя", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index e11bdc2d8d5..b673ee21a0f 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1310,6 +1310,8 @@ export const tr: Record = { "logs.detail.outcome.incomplete": "Tamamlanmadı", "logs.detail.outcome.aborted": "İptal edildi", "logs.detail.sends.label": "Yukarı akış gönderimleri", + "logs.detail.sends.unavailable": "Kullanılamıyor", + "logs.detail.retries.label": "Yeniden denemeler", "logs.detail.sends.unresolved": "çözülmemiş", "logs.detail.attempt.recovery.unknown": "Bilinmeyen kurtarma nedeni", "logs.detail.cause.label": "Hata nedeni", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 1d75e051d14..eed86f0d3d2 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -1304,6 +1304,8 @@ export const vi: Record = { "logs.detail.outcome.incomplete": "Chưa hoàn tất", "logs.detail.outcome.aborted": "Đã hủy", "logs.detail.sends.label": "Số lần gửi lên nhà cung cấp", + "logs.detail.sends.unavailable": "Không khả dụng", + "logs.detail.retries.label": "Lần thử lại", "logs.detail.sends.unresolved": "chưa xác định", "logs.detail.attempt.recovery.unknown": "Lý do khôi phục không rõ", "logs.detail.cause.label": "Nguyên nhân lỗi", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index ef74649ba6f..48b05bda9a5 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -3162,6 +3162,8 @@ export const zhTW: Record = { "logs.detail.outcome.incomplete": "未完成", "logs.detail.outcome.aborted": "已中止", "logs.detail.sends.label": "上游傳送次數", + "logs.detail.sends.unavailable": "無法使用", + "logs.detail.retries.label": "重試", "logs.detail.sends.unresolved": "未結算", "logs.detail.attempt.recovery.unknown": "未知的復原原因", "logs.detail.cause.label": "失敗原因", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 0658144cc0d..d04ea73e9b5 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1278,6 +1278,8 @@ export const zh: Record = { "logs.detail.outcome.incomplete": "未完成", "logs.detail.outcome.aborted": "已中止", "logs.detail.sends.label": "上游发送次数", + "logs.detail.sends.unavailable": "不可用", + "logs.detail.retries.label": "重试", "logs.detail.sends.unresolved": "未结算", "logs.detail.attempt.recovery.unknown": "未知的恢复原因", "logs.detail.cause.label": "失败原因", diff --git a/gui/src/pages/Logs.tsx b/gui/src/pages/Logs.tsx index a1f5858f394..439a3e07368 100644 --- a/gui/src/pages/Logs.tsx +++ b/gui/src/pages/Logs.tsx @@ -23,6 +23,7 @@ import { logsTabKeyDown, readTabFromHash, selectLogsTab } from "./logs-tab-keydo import { isModelRerouted, modelTitle, type ModelTitleTierOutcome } from "./logs-model-title"; import { speedLabel } from "./logs-speed-label"; import { decodeRateLabelKeys } from "./logs-decode-rate"; +import { failureAttributionLabels, failureSummary } from "./logs-failure-summary"; import "./logs-decode-rate.css"; import { formatEstimatedUsd, formatEstimatedUsdValue, summarizeEstimatedCosts } from "./logs-cost-format"; import { cacheSplit, isCursorUsageProvider, tokensTitle } from "./logs-token-title"; @@ -41,8 +42,6 @@ import type { } from "../../../src/usage/telemetry-contract"; import { classifyRequestOutcome, - requestPhysicalSends, - requestUnresolvedSends, type RequestOutcomeClass, } from "../../../src/usage/request-outcome"; @@ -208,6 +207,7 @@ export interface LogEntry extends LogFailureAttribution { totalTokens?: number; firstOutputMs?: number; attempts?: LogAttempt[]; + attemptNormalizationDropped?: boolean; displayMetrics?: LogDisplayMetrics; /** Bounded route-decision trace (RI-01); absent for pre-trace rows. */ routeDecision?: { @@ -395,51 +395,6 @@ const OUTCOME_KEYS = { aborted: "logs.detail.outcome.aborted", } as const satisfies Record; -/** - * i18n key for each shared failure cause, total by construction. - * - * The `satisfies` clause is the point. The recovery-kind catalog on this page drifted to nine of - * the durable thirteen and four real causes reached the operator as "Unknown recovery reason" -- - * an absence of a label rendered as an absence of a cause. A missing member here is a typecheck - * failure instead. - */ -const FAILURE_CAUSE_KEYS = { - "transport-unsent": "logs.detail.cause.transportUnsent", - "transport-ambiguous": "logs.detail.cause.transportAmbiguous", - "upstream-declined": "logs.detail.cause.upstreamDeclined", - "rate-limit": "logs.detail.cause.rateLimit", - "quota-exhausted": "logs.detail.cause.quotaExhausted", - "credential-rejected": "logs.detail.cause.credentialRejected", - "policy-refusal": "logs.detail.cause.policyRefusal", - "parameter-rejected": "logs.detail.cause.parameterRejected", - "ciphertext-refusal": "logs.detail.cause.ciphertextRefusal", - "payload-too-large": "logs.detail.cause.payloadTooLarge", - "payload-rejected": "logs.detail.cause.payloadRejected", - "upstream-fault": "logs.detail.cause.upstreamFault", - "empty-output": "logs.detail.cause.emptyOutput", - "client-cancelled": "logs.detail.cause.clientCancelled", - "local-refusal": "logs.detail.cause.localRefusal", -} as const satisfies Record; - -/** i18n key for each stage the caller's view of the exchange reached. */ -const FAILURE_STAGE_KEYS = { - "pre-header": "logs.detail.stage.preHeader", - "headers-only": "logs.detail.stage.headersOnly", - "protocol-prelude": "logs.detail.stage.protocolPrelude", - "semantic-output": "logs.detail.stage.semanticOutput", - "side-effect": "logs.detail.stage.sideEffect", - "terminal": "logs.detail.stage.terminal", -} as const satisfies Record; - -/** i18n key for each resend verdict; every refusal names which refusal it is. */ -const RESEND_PERMISSION_KEYS = { - "permitted": "logs.detail.resend.permitted", - "permitted-after-repair": "logs.detail.resend.permittedAfterRepair", - "refused-ambiguous": "logs.detail.resend.refusedAmbiguous", - "refused-committed": "logs.detail.resend.refusedCommitted", - "refused-futile": "logs.detail.resend.refusedFutile", -} as const satisfies Record; - /** * How this request ended, using the same classifier the Prometheus exporter uses. * @@ -451,27 +406,6 @@ function outcomeKey(entry: Pick= 200 && status < 300) return "var(--green)"; if (status >= 400) return "var(--red)"; @@ -1085,6 +1019,7 @@ function LogDetailDialog({ const decodeRateLabels = decodeRateLabelKeys(decodeRate?.kind === "value" ? decodeRate : {}); const reasoningWire = reasoningWireLabel(detail); const detailFailure = failureAttributionLabels(detail, t); + const summary = failureSummary(detail, detailInfo, t); const copyRequestId = async () => { if (!detail.requestId) return; @@ -1109,11 +1044,11 @@ function LogDetailDialog({

{detail.status} - {detailInfo && {detailInfo.label}} + {summary.label && {summary.label}}

- {detailInfo &&

{detailInfo.description}

} + {summary.description &&

{summary.description}

}

{t("logs.detail.section.basic")}

@@ -1136,16 +1071,16 @@ function LogDetailDialog({ {detailFailure.resend} )} - {detail.spend && ( - <> - {t("logs.detail.sends.label")} - - {requestPhysicalSends(detail.spend)} - {requestUnresolvedSends(detail.spend) > 0 - && ` (${t("logs.detail.sends.unresolved")}: ${requestUnresolvedSends(detail.spend)})`} - - - )} + {t("logs.detail.sends.label")} + + {summary.sends === undefined ? t("logs.detail.sends.unavailable") : summary.sends} + {summary.unresolved !== undefined && summary.unresolved > 0 + && ` (${t("logs.detail.sends.unresolved")}: ${summary.unresolved})`} + + {summary.retries !== undefined && <> + {t("logs.detail.retries.label")} + {summary.retries} + } {t("logs.col.request")} {detail.requestId ?? "\u2014"} diff --git a/gui/src/pages/logs-failure-summary.ts b/gui/src/pages/logs-failure-summary.ts new file mode 100644 index 00000000000..73f383693f3 --- /dev/null +++ b/gui/src/pages/logs-failure-summary.ts @@ -0,0 +1,103 @@ +import type { TFn } from "../i18n/shared"; +import type { RequestFailureCause, RequestFailureStage, ResendPermission, RequestSpendTotals } from "../../../src/usage/telemetry-contract"; + +interface LogFailureAttribution { + failureCause?: RequestFailureCause; + failureStage?: RequestFailureStage; + resendPermission?: ResendPermission; +} + +/** + * i18n key for each shared failure cause, total by construction. + * + * The `satisfies` clause is the point. The recovery-kind catalog on this page drifted to nine of + * the durable thirteen and four real causes reached the operator as "Unknown recovery reason" -- + * an absence of a label rendered as an absence of a cause. A missing member here is a typecheck + * failure instead. + */ +const FAILURE_CAUSE_KEYS = { + "transport-unsent": "logs.detail.cause.transportUnsent", + "transport-ambiguous": "logs.detail.cause.transportAmbiguous", + "upstream-declined": "logs.detail.cause.upstreamDeclined", + "rate-limit": "logs.detail.cause.rateLimit", + "quota-exhausted": "logs.detail.cause.quotaExhausted", + "credential-rejected": "logs.detail.cause.credentialRejected", + "policy-refusal": "logs.detail.cause.policyRefusal", + "parameter-rejected": "logs.detail.cause.parameterRejected", + "ciphertext-refusal": "logs.detail.cause.ciphertextRefusal", + "payload-too-large": "logs.detail.cause.payloadTooLarge", + "payload-rejected": "logs.detail.cause.payloadRejected", + "upstream-fault": "logs.detail.cause.upstreamFault", + "empty-output": "logs.detail.cause.emptyOutput", + "client-cancelled": "logs.detail.cause.clientCancelled", + "local-refusal": "logs.detail.cause.localRefusal", +} as const satisfies Record; + +/** i18n key for each stage the caller's view of the exchange reached. */ +const FAILURE_STAGE_KEYS = { + "pre-header": "logs.detail.stage.preHeader", + "headers-only": "logs.detail.stage.headersOnly", + "protocol-prelude": "logs.detail.stage.protocolPrelude", + "semantic-output": "logs.detail.stage.semanticOutput", + "side-effect": "logs.detail.stage.sideEffect", + "terminal": "logs.detail.stage.terminal", +} as const satisfies Record; + +/** i18n key for each resend verdict; every refusal names which refusal it is. */ +const RESEND_PERMISSION_KEYS = { + "permitted": "logs.detail.resend.permitted", + "permitted-after-repair": "logs.detail.resend.permittedAfterRepair", + "refused-ambiguous": "logs.detail.resend.refusedAmbiguous", + "refused-committed": "logs.detail.resend.refusedCommitted", + "refused-futile": "logs.detail.resend.refusedFutile", +} as const satisfies Record; + +/** + * Localized cause, stage and resend verdict for a row that carries them. + * + * A stale or hand-edited row can carry a value outside the roster, so each lookup falls back to + * the wire value rather than handing `t()` an undefined key. Showing the raw member is more + * useful than showing nothing, which is the mistake the recovery catalog made. + */ +export function failureAttributionLabels( + row: LogFailureAttribution, + t: TFn, +): { cause?: string; stage?: string; resend?: string } { + const causeKey = row.failureCause === undefined ? undefined : (Object.hasOwn(FAILURE_CAUSE_KEYS, row.failureCause) ? FAILURE_CAUSE_KEYS[row.failureCause] : undefined); + const stageKey = row.failureStage === undefined ? undefined : (Object.hasOwn(FAILURE_STAGE_KEYS, row.failureStage) ? FAILURE_STAGE_KEYS[row.failureStage] : undefined); + const resendKey = row.resendPermission === undefined ? undefined : (Object.hasOwn(RESEND_PERMISSION_KEYS, row.resendPermission) ? RESEND_PERMISSION_KEYS[row.resendPermission] : undefined); + return { + ...(row.failureCause ? { cause: causeKey ? t(causeKey) : row.failureCause } : {}), + ...(row.failureStage ? { stage: stageKey ? t(stageKey) : row.failureStage } : {}), + ...(row.resendPermission ? { resend: resendKey ? t(resendKey) : row.resendPermission } : {}), + }; +} + + +const nonnegativeInteger = (value: unknown): value is number => + typeof value === "number" && Number.isInteger(value) && value >= 0; + +export function failureSummary( + row: LogFailureAttribution & { spend?: RequestSpendTotals; attempts?: Array<{ sendCount: number }>; attemptNormalizationDropped?: boolean }, + fallback: { label: string; description: string } | null, + t: TFn, +): { label?: string; description?: string; sends?: number; unresolved?: number; retries?: number } { + const cause = row.failureCause; + const causeKey = cause && Object.hasOwn(FAILURE_CAUSE_KEYS, cause) ? FAILURE_CAUSE_KEYS[cause] : undefined; + const result: { label?: string; description?: string; sends?: number; unresolved?: number; retries?: number } = causeKey + ? { label: t(causeKey) } + : { label: fallback?.label, description: fallback?.description }; + const spend = row.spend; + if (!spend || !nonnegativeInteger(spend.sends) || !nonnegativeInteger(spend.settled) + || !nonnegativeInteger(spend.unresolved) || spend.settled > spend.sends + || (spend.reserved !== undefined && !nonnegativeInteger(spend.reserved))) return result; + result.sends = spend.sends; + result.unresolved = spend.unresolved; + if (spend.unresolved === 0 && spend.settled === spend.sends + && (spend.reserved === undefined || spend.reserved <= spend.settled) + && row.attemptNormalizationDropped !== true && Array.isArray(row.attempts) && row.attempts.length === 1 + && nonnegativeInteger(row.attempts[0]?.sendCount) && row.attempts[0].sendCount === spend.sends) { + result.retries = Math.max(0, spend.sends - 1); + } + return result; +} diff --git a/gui/tests/logs-upstream-faults.test.tsx b/gui/tests/logs-upstream-faults.test.tsx new file mode 100644 index 00000000000..76a2715d6f4 --- /dev/null +++ b/gui/tests/logs-upstream-faults.test.tsx @@ -0,0 +1,214 @@ +import { afterEach, beforeEach, expect, jest, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { LanguageProvider } from "../src/i18n/provider"; +import { clearClientResourceStoresForTests } from "../src/client-resource"; +import Logs from "../src/pages/Logs"; + +const globals = ["document", "window", "navigator", "localStorage", "sessionStorage", "IS_REACT_ACT_ENVIRONMENT", "ResizeObserver"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; +const originalFetch = globalThis.fetch; + +function installLayoutStubs(win: Window): void { + const proto = win.HTMLElement.prototype as unknown as HTMLElement; + Object.defineProperty(proto, "clientHeight", { configurable: true, get() { return 800; } }); + Object.defineProperty(proto, "clientWidth", { configurable: true, get() { return 1200; } }); + Object.defineProperty(proto, "offsetHeight", { configurable: true, get() { return 800; } }); + Object.defineProperty(proto, "offsetWidth", { configurable: true, get() { return 1200; } }); + Object.defineProperty(proto, "scrollHeight", { configurable: true, get() { return 800; } }); + Object.defineProperty(proto, "getBoundingClientRect", { + configurable: true, + value() { + return { + x: 0, y: 0, top: 0, left: 0, bottom: 800, right: 1200, width: 1200, height: 800, + toJSON() { return this; }, + }; + }, + }); + + class ResizeObserverStub { + #cb: ResizeObserverCallback; + constructor(cb: ResizeObserverCallback) { this.#cb = cb; } + observe(target: Element) { + this.#cb( + [{ + target, + contentRect: { + x: 0, y: 0, top: 0, left: 0, bottom: 800, right: 1200, width: 1200, height: 800, + toJSON() { return this; }, + }, + borderBoxSize: [], + contentBoxSize: [], + devicePixelContentBoxSize: [], + } as unknown as ResizeObserverEntry], + this as unknown as ResizeObserver, + ); + } + unobserve() {} + disconnect() {} + } + Object.defineProperty(globalThis, "ResizeObserver", { configurable: true, value: ResizeObserverStub }); + Object.defineProperty(win, "ResizeObserver", { configurable: true, value: ResizeObserverStub }); +} + +beforeEach(() => { + previousGlobals = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/#logs" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + sessionStorage: { configurable: true, value: testWindow.sessionStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + installLayoutStubs(testWindow); + jest.useFakeTimers({ now: 1_700_000_000_000 }); + // Logs reads through the shared resource layer now, and that cache is module-level: without + // this, one test's rows leak into the next one's cold mount and suppress its request. + clearClientResourceStoresForTests(); +}); + +afterEach(() => { + jest.useRealTimers(); + globalThis.fetch = originalFetch; + clearClientResourceStoresForTests(); + testWindow.close(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +async function mountLogs(apiBase = "http://localhost"): Promise<{ root: Root; container: HTMLElement }> { + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + let root!: Root; + await act(async () => { + root = createRoot(container); + root.render( + + + , + ); + }); + // Let virtualizer observe + measure after first paint. + await act(async () => { + jest.advanceTimersByTime(0); + await Promise.resolve(); + }); + return { root, container }; +} + + +function fixture(status: number, cause: string, sends: number): Record { + return { + requestId: "fault-row", timestamp: 1_700_000_000_000, provider: "openai", model: "fault-row", + status, durationMs: 100, usageStatus: "unreported", failureCause: cause, + spend: { sends, settled: sends, unresolved: 0 }, + attempts: [{ ordinal: 1, provider: "openai", model: "fault-row", adapter: "openai-chat", + status, durationMs: 100, sendCount: sends, recoveryKinds: [], usageStatus: "unreported" }], + }; +} +async function show(row: Record) { + globalThis.fetch = (async input => String(input).includes("/api/logs") + ? Response.json({ logs: [row], generatedAt: 1_700_000_000_000, timeZone: "UTC" }) + : new Response(null, { status: 404 })) as typeof fetch; + const mounted = await mountLogs(); + const button = mounted.container.querySelector(".log-detail-btn"); + expect(button).not.toBeNull(); + await act(async () => { button!.click(); }); + return mounted; +} +function value(label: string): string | undefined { + const grid = document.querySelector(".log-detail-grid")!; + const item = [...grid.children].find(node => node.textContent === label); + return item?.nextElementSibling?.textContent?.trim(); +} + +test.each([ + [429, "rate-limit", "Rate limited", 1, 0], + [500, "upstream-fault", "Upstream fault", 3, 2], + [503, "upstream-declined", "Upstream declined to start", 3, 2], +] as const)("%i displays the upstream cause and resolved physical sends", async (status, cause, headline, sends, retries) => { + const { root } = await show(fixture(status, cause, sends)); + try { + expect(document.querySelector("#log-detail-title")?.textContent).toContain(`${status}${headline}`); + expect(Boolean(document.querySelector(".modal-desc"))).toBe(false); + expect(value("Upstream sends")).toBe(String(sends)); + expect(value("Retries")).toBe(String(retries)); + } finally { await act(async () => { root.unmount(); }); } +}); + + +test.each([ + ["zero sends", { sends: 0, settled: 0, unresolved: 0 }, [0], "0", "0"], + ["multiple attempts", { sends: 3, settled: 3, unresolved: 0 }, [1, 2], "3", undefined], + ["unresolved", { sends: 3, settled: 2, unresolved: 1 }, [3], "3 (unresolved: 1)", undefined], + ["attempt mismatch", { sends: 3, settled: 3, unresolved: 0 }, [2], "3", undefined], + ["settled exceeds sends", { sends: 2, settled: 3, unresolved: 0 }, [2], "Unavailable", undefined], + ["invalid reserved", { sends: 3, settled: 3, unresolved: 0, reserved: -1 }, [3], "Unavailable", undefined], + ["null reserved", { sends: 3, settled: 3, unresolved: 0, reserved: null }, [3], "Unavailable", undefined], + ["invalid attempt count", { sends: 3, settled: 3, unresolved: 0 }, [-1], "3", undefined], + ["under-settled", { sends: 3, settled: 2, unresolved: 0 }, [3], "3", undefined], + ["over-reserved", { sends: 3, settled: 3, unresolved: 0, reserved: 4 }, [3], "3", undefined], + ["missing spend", undefined, [1], "Unavailable", undefined], + ["invalid sends", { sends: -1, settled: 0, unresolved: 0 }, [0], "Unavailable", undefined], + ["invalid unresolved", { sends: 1, settled: 1, unresolved: 0.5 }, [1], "Unavailable", undefined], + ["missing attempts", { sends: 1, settled: 1, unresolved: 0 }, [], "1", undefined], +] as const)("%s keeps send and retry accounting honest", async (_name, spend, counts, sends, retries) => { + const row = fixture(500, "upstream-fault", 3); + row.spend = spend; + const attempt = (row.attempts as Record[])[0]!; + row.attempts = counts.map((count, index) => ({ ...attempt, ordinal: index + 1, sendCount: count })); + const { root } = await show(row); + try { + expect(value("Upstream sends")).toBe(sends); + expect(value("Retries")).toBe(retries); + } finally { await act(async () => { root.unmount(); }); } +}); + +test("a lossy normalized attempt roster does not imply retries", async () => { + const row = fixture(500, "upstream-fault", 3); + row.attemptNormalizationDropped = true; + const { root } = await show(row); + try { + expect(value("Upstream sends")).toBe("3"); + expect(value("Retries")).toBeUndefined(); + } finally { await act(async () => { root.unmount(); }); } +}); + +test.each([ + ["local failure", "local-refusal", "Refused by this proxy", false], + ["unknown cause", "future-fault", "Proxy error", true], + ["inherited property cause", "constructor", "Proxy error", true], + ["legacy row", undefined, "Proxy error", true], +] as const)("%s uses the proper headline and attribution", async (_name, cause, headline, fallback) => { + const row = fixture(500, "upstream-fault", 0); + // Persisted normalization drops unknown causes before they reach the dashboard. + row.failureCause = cause === "future-fault" || cause === "constructor" ? undefined : cause; + if (cause === undefined) { delete row.spend; delete row.attempts; } + const { root } = await show(row); + try { + expect(document.querySelector("#log-detail-title")?.textContent).toContain(`500${headline}`); + expect(Boolean(document.querySelector(".modal-desc"))).toBe(fallback); + if (cause === "future-fault" || cause === "constructor") expect(value("Failure cause")).toBeUndefined(); + if (cause === undefined) expect(value("Upstream sends")).toBe("Unavailable"); + } finally { await act(async () => { root.unmount(); }); } +}); + + +test("multiple attempts preserve their recovery reason without inventing retries", async () => { + const row = fixture(429, "rate-limit", 2); + const attempt = (row.attempts as Record[])[0]!; + row.attempts = [{ ...attempt, sendCount: 1, recoveryKinds: ["rate-limit-429"] }, + { ...attempt, ordinal: 2, sendCount: 1 }]; + const { root } = await show(row); + try { + expect(document.querySelector(".log-detail-attempts")?.textContent).toContain("Rate-limited (429)"); + expect(value("Upstream sends")).toBe("2"); + expect(value("Retries")).toBeUndefined(); + } finally { await act(async () => { root.unmount(); }); } +}); diff --git a/scripts/test-layout/layout-usage.json b/scripts/test-layout/layout-usage.json new file mode 100644 index 00000000000..6638799267c --- /dev/null +++ b/scripts/test-layout/layout-usage.json @@ -0,0 +1,4 @@ +{ + "request-log.test.ts": "usage", + "request-log-normalization.test.ts": "usage" +} diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index dd2e8519dcf..551e1fa899a 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1,7 +1,7 @@ { "version": 1, "root": "tests", - "explicitShards": ["layout-clients.json", "layout-codex-collision.json", "layout-agent-assertion.json", "layout-chat.json", "layout-replay.json", "layout-adapters.json", "layout-jev.json", "layout-workbuddy.json", "layout-combos.json", "layout-credit-renewal.json", "layout-server-inbound.json", "layout-providers.json", "layout-responses.json", "layout-exec-boundary.json", "layout-persistent-dashboard.json", "layout-start-ownership.json", "layout-config.json", "layout-kiro-usage.json"], + "explicitShards": ["layout-clients.json", "layout-codex-collision.json", "layout-agent-assertion.json", "layout-chat.json", "layout-replay.json", "layout-adapters.json", "layout-jev.json", "layout-workbuddy.json", "layout-combos.json", "layout-credit-renewal.json", "layout-server-inbound.json", "layout-providers.json", "layout-responses.json", "layout-exec-boundary.json", "layout-persistent-dashboard.json", "layout-start-ownership.json", "layout-config.json", "layout-kiro-usage.json", "layout-usage.json"], "explicit": { "service-desktop-recovery.test.ts": "service", "catalog-omit-disabled-native.test.ts": "codex-integration", "provider-input-modalities.test.ts": "providers", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "codex-provider-routing.test.ts": "codex-integration", "config-native-reasoning-retention.test.ts": "config", "server-native-reasoning-retention-merge.test.ts": "server", "server-native-reasoning-retention.test.ts": "server", "responses-retained-reasoning-stream-recovery.test.ts": "responses", "openai-native-hosted-search-history.test.ts": "adapters/openai", "responses-native-compact-search-history.test.ts": "responses", "config-hosted-search-projection.test.ts": "config", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "cli-provider-list-active.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "bridge-terminal-refusal.test.ts": "adapters", "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", "combo-terminal-refusal.test.ts": "routing", "server-terminal-refusal-wiring.test.ts": "server", "incomplete-reason-normalization.test.ts": "responses", "advisor-retention.test.ts": "advisor", "responses-native-key-cooldown.test.ts": "responses", "responses-run-turn-key-readmission.test.ts": "responses", "qoder-spawn-readmission.test.ts": "adapters", "api-key-selection-cooldown.test.ts": "providers", "api-key-auto-pause-distribution.test.ts": "providers", "explicit-key-pause-oauth-boundary.test.ts": "providers", "server-chat-native-key-selection.test.ts": "server", "compact-key-selection.test.ts": "responses", "messages-native-key-selection.test.ts": "claude-integration", "coding-agent-windows-dispatch.test.ts": "adapters", "coding-agent-native-empty-argv.test.ts": "windows", "stop-restore-history-ownership.test.ts": "service", "cli-stop-refusal-transport.test.ts": "cli", @@ -1503,8 +1503,7 @@ "request-log-nonstream.test.ts": "usage", "request-log-protocol-trace.test.ts": "usage", "request-log-served-model.test.ts": "usage", "request-log-model-filter.test.ts": "usage", - "request-log-generation-window.test.ts": "usage", - "request-log.test.ts": "usage", "upstream-failure-diagnostics.test.ts": "usage", + "request-log-generation-window.test.ts": "usage", "upstream-failure-diagnostics.test.ts": "usage", "request-outcome-agreement.test.ts": "usage", "request-pacing.test.ts": "usage", "request-pacing-provenance.test.ts": "usage", "reserve-auth-context.test.ts": "codex-integration", diff --git a/src/server/management/shared.ts b/src/server/management/shared.ts index 17dc4d35633..75ba5aff01a 100644 --- a/src/server/management/shared.ts +++ b/src/server/management/shared.ts @@ -254,6 +254,7 @@ export function requestLogDto( ): Record { return { ...entry, + ...(entry.attemptNormalizationDropped === true ? { attemptNormalizationDropped: true } : {}), ...resendVerdict(entry), displayMetrics: { tokPerSecond: tokPerSecondResult(entry), diff --git a/src/server/request-log.ts b/src/server/request-log.ts index 18724f82a29..925bd5f2c54 100644 --- a/src/server/request-log.ts +++ b/src/server/request-log.ts @@ -47,6 +47,7 @@ import { normalizeClaudeCompatibilityUsageLog, normalizeRequestFailureAttribution, normalizeRequestSpend, + normalizedAttempts, readRecentUsageEntries, modelIdentityLogFields, recordObservedServedModel, usageForFinalLog, @@ -358,6 +359,8 @@ export interface RequestLogEntry extends UpstreamErrorDiagnostics { usage?: OcxUsage; totalTokens?: number; attempts?: PersistedUsageAttempt[]; + /** True when persistence normalization drops any attempt. */ + attemptNormalizationDropped?: boolean; /** * Upstream spend for the whole logical request: sends aggregated across attempts and combo * children, split into settled and unresolved, with the budget state and move reasons that @@ -514,6 +517,8 @@ export function requestLogEntryFromPersistedUsage(entry: PersistedUsageEntry): R ...(entry.usage ? { usage: entry.usage } : {}), ...(entry.totalTokens !== undefined ? { totalTokens: entry.totalTokens } : {}), ...(entry.attempts !== undefined ? { attempts: entry.attempts } : {}), + ...(entry.attemptNormalizationDropped === true + ? { attemptNormalizationDropped: true } : {}), ...(spend ? { spend } : {}), ...(isKnownCacheTelemetryProvenance(entry.cacheProvenance) ? { cacheProvenance: entry.cacheProvenance } @@ -620,12 +625,16 @@ export function addRequestLog(entry: RequestLogEntry) { const servedModel = modelIdentityLogFields(entry).servedModel; const claudeCompatibility = normalizeClaudeCompatibilityUsageLog(entry.claudeCompatibility); const jevDecision = normalizePersistedJevDecision(entry.jevDecision); + const attempts = normalizedAttempts(entry.attempts); + const attemptsDropped = Array.isArray(entry.attempts) && attempts.length < entry.attempts.length; const retained: RequestLogEntry = shadowCallRewrittenFrom === entry.shadowCallRewrittenFrom && servedModel === entry.servedModel && entry.claudeCompatibility === undefined && entry.jevDecision === undefined + && !attemptsDropped ? entry - : { ...entry, ...(shadowCallRewrittenFrom ? { shadowCallRewrittenFrom } : {}) }; + : { ...entry, ...(entry.attemptNormalizationDropped === true || attemptsDropped ? { attemptNormalizationDropped: true } : {}), + ...(shadowCallRewrittenFrom ? { shadowCallRewrittenFrom } : {}) }; if (!shadowCallRewrittenFrom && retained !== entry) delete retained.shadowCallRewrittenFrom; if (!servedModel && retained !== entry) { delete retained.servedModel; @@ -700,6 +709,8 @@ export function addRequestLog(entry: RequestLogEntry) { ...(entry.usage ? { usage: entry.usage } : {}), ...(entry.totalTokens !== undefined ? { totalTokens: entry.totalTokens } : {}), ...(entry.attempts !== undefined ? { attempts: entry.attempts } : {}), + ...(entry.attemptNormalizationDropped === true + ? { attemptNormalizationDropped: true } : {}), ...(entry.spend ? { spend: entry.spend } : {}), ...(isKnownCacheTelemetryProvenance(entry.cacheProvenance) ? { cacheProvenance: entry.cacheProvenance } diff --git a/src/usage/log.ts b/src/usage/log.ts index 13ecd2b38f8..51da4e7ee68 100644 --- a/src/usage/log.ts +++ b/src/usage/log.ts @@ -354,6 +354,8 @@ export interface PersistedUsageEntry { usage?: OcxUsage; totalTokens?: number; attempts?: PersistedUsageAttempt[]; + /** A persisted attempt roster lost at least one malformed entry during normalization. */ + attemptNormalizationDropped?: boolean; /** Aggregated upstream spend for this logical request; additive, older rows omit it. */ spend?: PersistedRequestSpend; /** Provenance of this row's cache detail; absent rows are reconstructed, never assumed observed. */ @@ -883,7 +885,7 @@ export function isValidReasoningWireValue( || (wireField === "reasoning.enabled" && typeof wireValue === "boolean"); } -function normalizedAttempts(raw: unknown): PersistedUsageAttempt[] { +export function normalizedAttempts(raw: unknown): PersistedUsageAttempt[] { if (!Array.isArray(raw)) return []; return raw.map(normalizeUsageAttempt) .filter((attempt): attempt is PersistedUsageAttempt => attempt !== null); @@ -998,7 +1000,7 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry { const jevDecision = normalizePersistedJevDecision(entry.jevDecision); const spend = normalizeRequestSpend(entry.spend); const protocolTrace = parseProtocolTraceV1(entry.protocolTrace); - return { + const normalized: PersistedUsageEntry = { requestId: entry.requestId, ...(isLogicalRequestId(entry.logicalRequestId) ? { logicalRequestId: entry.logicalRequestId } : {}), timestamp: entry.timestamp, @@ -1072,6 +1074,9 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry { ...(entry.usage ? { usage: normalizeUsageValue(entry.usage) } : {}), ...(typeof entry.totalTokens === "number" ? { totalTokens: entry.totalTokens } : {}), ...(Array.isArray(entry.attempts) ? { attempts } : {}), + ...(entry.attemptNormalizationDropped === true + && Object.prototype.propertyIsEnumerable.call(entry, "attemptNormalizationDropped") + ? { attemptNormalizationDropped: true } : {}), ...(spend ? { spend } : {}), ...(isKnownCacheTelemetryProvenance(entry.cacheProvenance) ? { cacheProvenance: entry.cacheProvenance } @@ -1094,6 +1099,12 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry { ...(protocolTrace ? { protocolTrace } : {}), ...normalizeRequestFailureAttribution(entry), }; + if (normalized.attemptNormalizationDropped !== true && (entry.attemptNormalizationDropped === true + || (Array.isArray(entry.attempts) && attempts.length < entry.attempts.length))) { + // Preserve the read-time loss signal without changing legacy ledger serialization. + Object.defineProperty(normalized, "attemptNormalizationDropped", { value: true, enumerable: false }); + } + return normalized; } // Bound hot-path filesystem hardening to once per second while ensuring an external mode @@ -1142,7 +1153,11 @@ export function setUsageLedgerAppendHook(hook: (() => void) | null): void { } export function appendUsageEntry(entry: PersistedUsageEntry): void { - const line = `${JSON.stringify(normalizeUsageEntry(entry))}\n`; + const normalized = normalizeUsageEntry(entry); + // Once invalid attempts are removed, persist their loss so future reads can still report it. + const line = `${JSON.stringify(normalized.attemptNormalizationDropped === true + ? { ...normalized, attemptNormalizationDropped: true } + : normalized)}\n`; const path = usageLogPath(); const now = Date.now(); const doAppend = (): void => { diff --git a/structure/dashboard-and-usage.md b/structure/dashboard-and-usage.md index 7cd592bf19a..084f60d1157 100644 --- a/structure/dashboard-and-usage.md +++ b/structure/dashboard-and-usage.md @@ -564,7 +564,7 @@ An absent upstream model stays absent; the tooltip retains all available model identities. Historical Codex `openai`, `chatgpt` and `openai-multi` main labels collapse for reporting; configured provider names ending in `-main` remain separate. Native Messages request and attempt `requestedEffort` labels follow the [native Messages logging contract](data-planes/protocol-paths.md#managed-native-messages). -Rows also carry the observed protocol path (`protocolTrace`), persisted in `usage.jsonl` and +The Logs request detail headline prefers a recognized request-level cause over the HTTP status label and omits the status description; unknown persisted causes are dropped and use the status fallback without raw attribution. Physical sends are unavailable for absent or invalid spend; retries appear only for one attempt with fully settled, consistent accounting and no flagged loss during attempt normalization, as sends after the first, floored at zero. Direct request-log ingress validates attempts with the persistence normalization rule, marks any loss, and retains the original live roster and its metadata. The ledger writer removes malformed attempts and persists the loss flag, so hydrated rosters can differ from live ones; both suppress retries when loss is marked. A loss detected while normalizing a legacy row is a non-enumerable in-memory flag, retained through re-normalization without changing JSON serialization; an explicitly persisted true flag stays enumerable. The persisted-to-request-log conversion and direct DTO projection preserve the flag. Other attempts and recovery reasons remain visible. Rows also carry the observed protocol path (`protocolTrace`), persisted in `usage.jsonl` and re-validated on read; the Logs list shows it as a text badge, the detail dialog as a section, and `src/server/request-log-filter.ts` owns the `/api/logs` query filters including `protocolMode`; its single-pass query applies provider, conversation, model, account, protocol mode and status before `tail`, then reports the pre-pagination count alongside the offset/limit page. `src/server/request-log-filter.ts` accepts the full provider/model label shown by `ocx logs` and bare model IDs, including IDs containing slashes. Qualified failover matches bind provider/model from the same attempt; matching occurs before the row cap and pagination. [Protocol Paths](data-planes/protocol-paths.md) owns its derivation. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d1c0c9f03c4..a4c1df1686d 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1205,7 +1205,7 @@ "request-log-estimate-cap.test.ts": "usage", "request-log-jev.test.ts": "usage", "request-log-nonstream.test.ts": "usage", "request-log-protocol-trace.test.ts": "usage", "request-log-served-model.test.ts": "usage", "request-log-model-filter.test.ts": "usage", "request-log-generation-window.test.ts": "usage", - "request-log.test.ts": "usage", "request-outcome-agreement.test.ts": "usage", + "request-log.test.ts": "usage", "request-log-normalization.test.ts": "usage", "request-outcome-agreement.test.ts": "usage", "upstream-failure-diagnostics.test.ts": "usage", "request-pacing.test.ts": "usage", "request-pacing-provenance.test.ts": "usage", "reserve-auth-context.test.ts": "codex-integration", "reserve-availability.test.ts": "codex-integration", "reserve-catalog-lifecycle.test.ts": "codex-integration", diff --git a/tests/usage/request-log-normalization.test.ts b/tests/usage/request-log-normalization.test.ts new file mode 100644 index 00000000000..4682e1acb22 --- /dev/null +++ b/tests/usage/request-log-normalization.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { addRequestLog, clearRequestLogsForTests, getRequestLogEntries, requestLogEntryFromPersistedUsage } from "../../src/server/request-log"; +import { requestLogDto } from "../../src/server/management/shared"; +import { normalizeUsageEntryForTest, readUsageEntries, resetUsageReadCacheForTests } from "../../src/usage/log"; +import { failureSummary } from "../../gui/src/pages/logs-failure-summary"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { log } from "../helpers/request-log-entry"; + +describe("request log metadata", () => { + test("direct ingress retains an already normalized row", () => { + const previousHome = process.env.OPENCODEX_HOME; + const home = mkdtempSync(join(tmpdir(), "ocx-valid-normalization-log-")); + process.env.OPENCODEX_HOME = home; + clearRequestLogsForTests(); + try { + const attempt = { ordinal: 1, provider: "openai", model: "model", adapter: "openai-chat", + status: 500, durationMs: 100, sendCount: 3, recoveryKinds: [], usageStatus: "unreported" as const, + liveOnlyMetadata: "retained" }; + const attempts = [attempt]; + const entry = log({ attempts }); + addRequestLog(entry); + expect(getRequestLogEntries()[0]).toBe(entry); + expect(getRequestLogEntries()[0]!.attempts).toBe(attempts); + expect(getRequestLogEntries()[0]!.attempts![0]).toBe(attempt); + } finally { + clearRequestLogsForTests(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(home); + } + }); + + test("sanitizing a normalized lossy row preserves its loss marker across surfaces", () => { + const attempt = { ordinal: 1, provider: "openai", model: "model", adapter: "openai-chat", + status: 500, durationMs: 100, sendCount: 3, recoveryKinds: [], usageStatus: "unreported" } as const; + const normalized = normalizeUsageEntryForTest(log({ requestId: "lossy-sanitized", status: 500, + spend: { sends: 3, settled: 3, unresolved: 0 }, + attempts: [attempt, { ...attempt, ordinal: 2, sendCount: 0, adapter: "" }] })); + expect(normalized.attempts).toHaveLength(1); + expect(normalized.attemptNormalizationDropped).toBe(true); + expect(Object.prototype.propertyIsEnumerable.call(normalized, "attemptNormalizationDropped")).toBe(false); + normalized.shadowCallRewrittenFrom = "model\nunsafe"; + const previousHome = process.env.OPENCODEX_HOME; + const home = mkdtempSync(join(tmpdir(), "ocx-lossy-sanitized-log-")); + process.env.OPENCODEX_HOME = home; + clearRequestLogsForTests(); + resetUsageReadCacheForTests(); + try { + addRequestLog(normalized); + const dto = requestLogDto(getRequestLogEntries()[0]!); + expect(dto.attemptNormalizationDropped).toBe(true); + expect(readUsageEntries()[0]?.attemptNormalizationDropped).toBe(true); + expect(failureSummary(dto, null, key => key).retries).toBeUndefined(); + } finally { + clearRequestLogsForTests(); + resetUsageReadCacheForTests(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(home); + } + }); + + for (const mixed of [false, true]) test(`direct ingress marks ${mixed ? "mixed" : "single partial"} attempts without changing the live roster`, () => { + const attempt = { ordinal: 1, provider: "openai", model: "model", adapter: "openai-chat", + status: 500, durationMs: 100, sendCount: 3, recoveryKinds: [], usageStatus: "unreported", + liveOnlyMetadata: "retained" } as const; + const previousHome = process.env.OPENCODEX_HOME; + const home = mkdtempSync(join(tmpdir(), "ocx-lossy-normalization-log-")); + process.env.OPENCODEX_HOME = home; + clearRequestLogsForTests(); + resetUsageReadCacheForTests(); + try { + const partial = { ordinal: 2, provider: "openai", model: "model", durationMs: 100, + usageStatus: "unreported", liveOnlyMetadata: "partial" }; + const attempts = mixed ? [attempt, partial] : [partial]; + addRequestLog(log({ requestId: "lossy-normalization", status: 500, + spend: { sends: 3, settled: 3, unresolved: 0 }, attempts: attempts as unknown as typeof attempt[] })); + const liveEntry = getRequestLogEntries()[0]!; + const persisted = readUsageEntries()[0]!; + const hydratedEntry = requestLogEntryFromPersistedUsage(persisted); + expect(liveEntry.attempts).toBe(attempts); + attempts.forEach((item, index) => expect(liveEntry.attempts![index]).toBe(item)); + expect(liveEntry.attempts).toEqual(attempts); + const { liveOnlyMetadata: _, ...persistedAttempt } = attempt; + for (const row of [persisted, hydratedEntry]) { + expect(row.attempts).toEqual(mixed ? [persistedAttempt] : []); + expect(row.attemptNormalizationDropped).toBe(true); + } + const live = requestLogDto(liveEntry); + const hydrated = requestLogDto(hydratedEntry); + expect(live.attempts).toHaveLength(attempts.length); + expect(hydrated.attempts).toHaveLength(mixed ? 1 : 0); + for (const row of [live, hydrated]) { + expect(row.attemptNormalizationDropped).toBe(true); + expect(failureSummary(row, null, key => key).retries).toBeUndefined(); + } + } finally { + clearRequestLogsForTests(); + resetUsageReadCacheForTests(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(home); + } + }); + +}); diff --git a/tests/usage/request-outcome-agreement.test.ts b/tests/usage/request-outcome-agreement.test.ts index 7b1b49dbf65..1067df8947d 100644 --- a/tests/usage/request-outcome-agreement.test.ts +++ b/tests/usage/request-outcome-agreement.test.ts @@ -18,6 +18,10 @@ import { createRequestMetricsOwner, } from "../../src/server/request-metrics"; import { ATTEMPT_RECOVERY_KIND_ROSTER } from "../../src/usage/telemetry-contract"; +import { normalizeUsageEntryForTest, type PersistedUsageEntry } from "../../src/usage/log"; +import { requestLogDto } from "../../src/server/management/shared"; +import { requestLogEntryFromPersistedUsage } from "../../src/server/request-log"; +import { failureSummary } from "../../gui/src/pages/logs-failure-summary"; /** * The durable ledger, the Prometheus exporter and the dashboard have to answer "how did this @@ -29,7 +33,7 @@ import { ATTEMPT_RECOVERY_KIND_ROSTER } from "../../src/usage/telemetry-contract * specifically about a 200 that must NOT read as success. */ -const LOCALES = ["en", "ko", "ja", "zh", "zh-TW", "de", "fr", "ru", "tr", "vi"] as const; +const LOCALES = ["en", "ko", "ja", "zh", "zh-TW", "de", "fr", "ru", "tr", "vi", "pt"] as const; function sampleValue(snapshot: string, series: string): number { const line = snapshot.split("\n").find(row => row.startsWith(series + " ")); @@ -103,6 +107,42 @@ describe("terminal classification is stated once", () => { }); describe("send totals agree across surfaces", () => { + test("lossy attempt normalization is exposed to the request log DTO", () => { + const attempt = { ordinal: 1, provider: "openai", model: "model", adapter: "openai-chat", + status: 500, durationMs: 100, sendCount: 3, recoveryKinds: [], usageStatus: "unreported" } as const; + const entry = { requestId: "lossy", timestamp: 1, provider: "openai", model: "model", + status: 500, durationMs: 100, usageStatus: "unreported", spend: { sends: 3, settled: 3, unresolved: 0 }, + attempts: [attempt, { ...attempt, ordinal: 2, sendCount: 0, adapter: "" }] } as PersistedUsageEntry; + const normalized = normalizeUsageEntryForTest(entry); + expect(normalized.attempts).toHaveLength(1); + const valid = normalizeUsageEntryForTest({ ...entry, attempts: [attempt] }); + expect(JSON.stringify(normalized)).toBe(JSON.stringify(valid)); + expect(JSON.stringify(normalizeUsageEntryForTest(normalized))).toBe(JSON.stringify(normalized)); + expect(Object.prototype.propertyIsEnumerable.call(normalized, "attemptNormalizationDropped")).toBe(false); + expect(normalized.attemptNormalizationDropped).toBe(true); + expect(requestLogEntryFromPersistedUsage(normalized).attemptNormalizationDropped).toBe(true); + expect(requestLogDto(normalized).attemptNormalizationDropped).toBe(true); + expect(failureSummary(requestLogDto(requestLogEntryFromPersistedUsage(normalized)), null, key => key).retries).toBeUndefined(); + expect(normalizeUsageEntryForTest(normalized).attemptNormalizationDropped).toBe(true); + expect(normalizeUsageEntryForTest({ ...entry, attempts: [attempt] }).attemptNormalizationDropped).toBeUndefined(); + expect(failureSummary(requestLogDto(requestLogEntryFromPersistedUsage(valid)), null, key => key).retries).toBe(2); + }); + + test("an explicitly persisted normalization-loss marker survives reads and serialization", () => { + const entry = { requestId: "marked", timestamp: 1, provider: "openai", model: "model", + status: 500, durationMs: 100, usageStatus: "unreported", attemptNormalizationDropped: true } as PersistedUsageEntry; + const normalized = normalizeUsageEntryForTest(JSON.parse(JSON.stringify(entry))); + expect(normalized.attemptNormalizationDropped).toBe(true); + expect(Object.prototype.propertyIsEnumerable.call(normalized, "attemptNormalizationDropped")).toBe(true); + expect(JSON.parse(JSON.stringify(normalized)).attemptNormalizationDropped).toBe(true); + expect(JSON.stringify(normalizeUsageEntryForTest(normalized))).toBe(JSON.stringify(normalized)); + }); + + test.each(["future-fault", "constructor"])("unknown persisted cause %s is dropped", cause => { + const entry = { requestId: "unknown", timestamp: 1, provider: "openai", model: "model", + status: 500, durationMs: 100, usageStatus: "unreported", failureCause: cause } as PersistedUsageEntry; + expect(normalizeUsageEntryForTest(entry).failureCause).toBeUndefined(); + }); /** * The budget charged four sends while the attempt rows account for three. Both numbers are * real and they answer different questions, so the surfaces have to agree about WHICH one the @@ -146,8 +186,8 @@ describe("send totals agree across surfaces", () => { test("the dashboard shows the send total and the unresolved remainder", () => { const page = readFileSync(repoPath("gui", "src", "pages", "Logs.tsx"), "utf8"); - expect(page).toContain("requestPhysicalSends"); - expect(page).toContain("requestUnresolvedSends"); + expect(page).toContain("summary.sends"); + expect(page).toContain("summary.unresolved"); }); }); @@ -197,9 +237,10 @@ describe("the dashboard recovery roster cannot drift from the durable one", () = expect(page).not.toContain('type AttemptRecoveryKind ='); }); - test("every label key the page names exists in all ten catalogs", () => { - const page = readFileSync(repoPath("gui", "src", "pages", "Logs.tsx"), "utf8"); - const keys = [...new Set([...page.matchAll(/"(logs\.detail\.(?:attempt\.recovery|outcome|sends|cause|stage|resend)\.[a-zA-Z0-9]+)"/g)] + test("every label key the page names exists in all eleven catalogs", () => { + const page = ["Logs.tsx", "logs-failure-summary.ts"] + .map(file => readFileSync(repoPath("gui", "src", "pages", file), "utf8")).join("\n"); + const keys = [...new Set([...page.matchAll(/"(logs\.detail\.(?:attempt\.recovery|outcome|sends|retries|cause|stage|resend)\.[a-zA-Z0-9]+)"/g)] .map(match => match[1]!))]; expect(keys.length).toBeGreaterThan(ATTEMPT_RECOVERY_KIND_ROSTER.length); const gaps: string[] = []; @@ -269,7 +310,7 @@ describe("the failure cause is derived once and reported everywhere", () => { }); test("every cause the recorder can derive has a dashboard label", () => { - const page = readFileSync(repoPath("gui", "src", "pages", "Logs.tsx"), "utf8"); + const page = readFileSync(repoPath("gui", "src", "pages", "logs-failure-summary.ts"), "utf8"); const block = page.slice(page.indexOf("const FAILURE_CAUSE_KEYS"), page.indexOf("} as const satisfies Record !block.includes(`"${cause}":`)); expect(missing).toEqual([]); From 8143cc0537d4c674b178d6743e1688a84a3edc36 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:10:10 +0900 Subject: [PATCH 42/84] build(deps): bump rustls from 0.23.45 to 0.23.46 in /desktop/src-tauri (#7942) * release: v2.33.0-preview.20260825 * release: v2.34.0-preview.20260827 * release: v2.36.0-preview.20260829 * fix(release): pass the bump job's permissions through the reusable-workflow call (#3262) Both v2.40.0 release dispatches (33615174183 preview, 33615177849 main) died at startup_failure: a workflow_call cannot grant its callee more than the calling job holds, and dev-version-bump.yml's job declares contents+pull- requests write. #3129 wired the call but never dispatched a release, so this is its first live run. The caller job now declares exactly the callee's two permissions; no other job in release.yml gains anything. Co-authored-by: jun (cherry picked from commit 7ce0ba51834740d7b4d5ec4793f6572d84624409) * release: set preview channel version 2.48.0-preview.20260908 * release: set main channel version 2.48.0 * chore(release): promote 2.55.0-preview.20260914 to preview Promotes the dev product snapshot 62f02223a0 to the preview train. The 2.55.0 line carries the #4546 cost-guard work: one send budget per logical request with a shared final-recovery reserve, zero-is-zero refusals with a typed error rather than a synthetic 502, compact and the Kiro inner retries admitted against that budget, a finite send ceiling per root workflow with an interactive reserve a fan-out cannot take, and a healthy detour promoted on transient-hold expiry instead of released cold. The previous preview tip 2.54.0-preview.20260914 is already tagged and published and is outranked by v2.54.0, so it could not be re-released; this is a new candidate rather than a re-cut. * chore(release): promote the verified 2.55.0 product tree to main Same product tree as preview 7bdd1b29b5 / 2.55.0-preview.20260914, which published successfully with its registry smoke green. Only package.json version differs. * ci(release): expose Linux bundler diagnostics for stable artifacts * fix(release): prepare stable platform bundles * fix(desktop): carry native sidecar packaging repair to main (#5532) * chore(ci): refresh main release verification (#5534) * fix(release): carry lipo argument fix to main (#5537) * fix(release): carry checksum and signature repair to main (#5546) * fix(release): carry Windows checksum record support to main (#5552) * fix(release): publish the GitHub release only after its verified assets attach (#5555) (#5558) GitHub freezes a release when it is published, so the attach step's upload came back HTTP 422 "Cannot upload assets to an immutable release". Every release from v2.55.0 to v2.60.0 therefore shipped with zero assets and the desktop updater had nothing to download. Create the release as a draft and flip it to published in attach-release, after the verified bundle is uploaded. Release notes still come from the validated notes file written at creation. * release: prepare 2.63.0 version metadata (#5612) * release: prepare 2.69.0 version metadata * fix(release): sign the packaged macOS keyring addons before notarization (#6271) * fix(release): sign the packaged macOS keyring addons before notarization Notarization rejected the 2.73.0 preview app: Resources/keyring/*.node, bundled since #6161, were unsigned or ad-hoc and had no secure timestamp, and Tauri does not sign files under Resources. Sign each darwin addon in place with the Developer ID identity, hardened runtime and timestamp after the certificate import and before tauri build, verify the result, and fail a real release that lacks signing material. * fix(release): match keyring signature fields without a pipe (cherry picked from commit 11782eedc3758d90c037244ab1cf36f17a286ccc) * chore(release): 2.74.0 * fix(deps): bump proxy-addr, fast-uri, ip-address and source-map-js for new advisories release.yml's dependency audit refused 2.79.0 on a critical advisory published after the candidate was cut: proxy-addr <2.0.8 is vulnerable to IP spoofing via IPv4-mapped IPv6 trust subnets (GHSA-jqcg-44mw-7w3h), reached through @modelcontextprotocol/sdk > express. gui/ reports source-map-js <1.2.2 (GHSA-68fv-2mgg-jv7q, vite > postcss). bun audit fix, lockfiles only, every bump inside its declared range: proxy-addr 2.0.7 -> 2.0.8, fast-uri 3.1.7 -> 3.1.8, ip-address 10.4.0 -> 10.7.1, and in gui/ source-map-js 1.2.1 -> 1.2.2. Both audits now report no vulnerabilities at --audit-level=high. * fix(deps): bump @modelcontextprotocol/sdk to 1.31.0 for GHSA-6qxp-vccf-f47h * fix(deps): clear high-severity audit findings blocking the 2.90.0 release (#7481) Raise @modelcontextprotocol/sdk to ^1.32.1 (GHSA-22jm-h49p-29qw, GHSA-46jr-4wx8-v453) and lock gui vite at 8.2.4 (GHSA-587h-7wmj-h5pm). release.yml runs bun run audit:high before publishing, and both advisories made it fail. * fix(deps): pin zod 4.6.6 and body-parser 2.4.0 for 2.92.0 (#7643) GHSA-mrhx-c7xc-m3w5 and GHSA-27r3-jxmh-p828 blocked the 2.92.0 publish. Leave the package version at 2.92.0 so Release can republish that version. Co-authored-by: Cursor Agent * fix(deps): bump gui happy-dom to 21.0.4 for GHSA-834v-8h66-3h79 (2.92.0) (#7645) Co-authored-by: lidge-jun <243035832+lidge-jun@users.noreply.github.com> * build(deps): bump rustls from 0.23.45 to 0.23.46 in /desktop/src-tauri Bumps [rustls](https://github.com/rustls/rustls) from 0.23.45 to 0.23.46. - [Release notes](https://github.com/rustls/rustls/releases) - [Changelog](https://github.com/rustls/rustls/blob/main/CHANGELOG.md) - [Commits](https://github.com/rustls/rustls/compare/v/0.23.45...v/0.23.46) --- updated-dependencies: - dependency-name: rustls dependency-version: 0.23.46 dependency-type: indirect ... Signed-off-by: dependabot[bot] --------- Signed-off-by: dependabot[bot] Co-authored-by: JUN Co-authored-by: jun Co-authored-by: jun Co-authored-by: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Co-authored-by: t Co-authored-by: JUN Co-authored-by: Cursor Agent Co-authored-by: lidge-jun Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Epinephrine --- desktop/src-tauri/Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index ff520a88d25..92ee4e1fbd9 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -3323,9 +3323,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "once_cell", "ring", From 88de3762fdabb7ef6038c2ea0313fb34b0f34c36 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 30 Oct 2026 11:16:11 +0000 Subject: [PATCH 43/84] fix(ci): distinguish install failures from owned deadlines (#7977) * fix(ci): distinguish install failures from owned deadlines * fix(ci): probe required timeout foreground support --- scripts/ci/bun-install-frozen.sh | 192 ++++++++++-- .../ci-bun-install-frozen.test.ts | 294 ++++++++++++++---- 2 files changed, 394 insertions(+), 92 deletions(-) diff --git a/scripts/ci/bun-install-frozen.sh b/scripts/ci/bun-install-frozen.sh index 0cc559d30da..e6d7bc3aec0 100755 --- a/scripts/ci/bun-install-frozen.sh +++ b/scripts/ci/bun-install-frozen.sh @@ -1,15 +1,71 @@ #!/usr/bin/env bash -# Frozen-lockfile install with a per-attempt deadline and one total budget. -# -# A registry or runner stall can leave `bun install` printing only its banner until the job's -# timeout cancels the run, which reads as a failed check unrelated to the change under test. -# Each attempt therefore runs under a process deadline, and only an attempt that the deadline -# killed is retried. Any other failure, such as a lockfile that no longer matches, fails at once. -# All directories share one total budget, so retries end well inside the shortest job timeout -# that calls this (5 minutes) and leave time for setup, the GUI build and the tests. -# -# Usage: bash scripts/ci/bun-install-frozen.sh [dir ...] (default: the current directory) -set -euo pipefail +# Frozen installs: one witnessed deadline, a shared budget, and no PID-directed cleanup. +# GNU timeout/gtimeout bounds a fixed reader, never the install's own exit status. +# Bootstrap FIFO names disappear before install code runs; this is not a sandbox against +# arbitrary same-user process inspection. Cleanup covers descendants that remain in the group. +set -eu + +own_group() { + local stat state ppid group rest + if [[ -r /proc/$$/stat ]]; then + IFS= read -r stat < /proc/$$/stat || return 1 + read -r state ppid group rest <<< "${stat##*) }" + else + read -r group <<< "$(ps -o pgid= -p "$$")" || return 1 + fi + [[ "$group" == "$$" ]] +} + +keeper() { + local boot="$1" timer="$2" grace="$3" dir="$4" deadline token completion rc + shift 4 + set +m + own_group || exit 70 # No signal 0 until this process is its group leader. + trap ':' TERM + # O_RDWR bootstrap avoids a blocked open if the other endpoint dies during setup. + exec 6<>"$boot/outcome" 8>"$boot/outcome" + exec 6>&- + exec 6<>"$boot/lifeline" 9<"$boot/lifeline" + exec 6>&- + printf 'ready %s\n' "$$" >&8 + IFS=' ' read -r -t 5 token deadline <&9 || exit 70 + [[ "$token" == go && "$deadline" =~ ^[1-9][0-9]*$ ]] || exit 70 + + # Only the parent owns a lifeline writer. Its death/cancellation is EOF, never a PID kill. + ( exec 8>&-; trap '' TERM; cat <&9 >/dev/null; kill -TERM 0; sleep 0.2; kill -KILL 0 ) >/dev/null 2>&1 & + exec 9<&- # Only the EOF helper needs the read endpoint now. + if ! cd "$dir"; then printf 'fail 66\n' >&8; kill -KILL 0; fi + exec 7>&1 + report_install() { + local status=0 + exec 8>&- + # The command gets neither a control descriptor nor the result pipe's writer. + "$@" >&7 7>&- || status=$? + printf '%s\n' "$status" + } + rc=0 + # The fixed reader exits only 0/70; install 124/137 are pipe data, not timer statuses. + # Process substitution does not wait for a stalled reporter when the reader times out. + completion=$("$timer" --foreground "${deadline}s" bash -c ' + IFS= read -r status || exit 70 + [[ "$status" =~ ^(0|[1-9][0-9]{0,2})$ && $status -le 255 ]] || exit 70 + printf "%s" "$status" + ' < <(report_install "$@") 7>&- 8>&-) || rc=$? + if [[ $rc -eq 0 ]]; then + printf 'result %s\n' "$completion" >&8 + elif [[ $rc -eq 124 ]]; then + printf 'deadline 124\n' >&8 + kill -TERM 0 + sleep "$grace" + else + printf 'fail 70\n' >&8 + fi + # This live process pins the group until its last action, including ordinary success. + kill -KILL 0 + exit 70 +} + +if [[ "${1:-}" == --ocx-install-keeper ]]; then shift; keeper "$@"; exit 70; fi readonly ATTEMPT_SECONDS="${BUN_INSTALL_ATTEMPT_SECONDS:-45}" readonly TOTAL_SECONDS="${BUN_INSTALL_TOTAL_SECONDS:-150}" @@ -17,42 +73,112 @@ readonly MIN_ATTEMPT_SECONDS="${BUN_INSTALL_MIN_ATTEMPT_SECONDS:-5}" readonly KILL_GRACE_SECONDS="${BUN_INSTALL_KILL_GRACE_SECONDS:-5}" readonly MAX_ATTEMPTS="${BUN_INSTALL_MAX_ATTEMPTS:-3}" readonly BUN_INSTALL_BIN="${BUN_INSTALL_BIN:-bun}" +readonly SCRIPT_PATH="${BASH_SOURCE[0]}" +for value in "$ATTEMPT_SECONDS" "$TOTAL_SECONDS" "$MIN_ATTEMPT_SECONDS" "$KILL_GRACE_SECONDS" "$MAX_ATTEMPTS"; do + [[ "$value" =~ ^[1-9][0-9]*$ && ${#value} -le 6 ]] || { echo '::error::Invalid install deadline configuration.'; exit 70; } +done +TIMEOUT_BIN="" +for candidate in timeout gtimeout; do + if command -v "$candidate" >/dev/null 2>&1 && "$candidate" --foreground 1s true >/dev/null 2>&1; then + TIMEOUT_BIN=$(command -v "$candidate"); break + fi +done +[[ -n "$TIMEOUT_BIN" ]] || { echo '::error::GNU timeout is required to bound bun install.' >&2; exit 69; } -if ! command -v timeout >/dev/null 2>&1 || ! timeout --signal=TERM --kill-after=1s 1s true >/dev/null 2>&1; then - echo "::error::GNU timeout is required to bound bun install." >&2 - exit 69 -fi +ATTEMPT_VERDICT=fail +ATTEMPT_STATUS=70 +run_attempt() { + local dir="$1" deadline="$2" boot keeper_pid keeper_status=70 tag="" value="" extra="" + local cancel_status=0 interrupted=no drained=no remaining started=no monitor=no + shift 2 + boot=$(mktemp -d "${TMPDIR:-/tmp}/ocx-bun-install.XXXXXX") || return 70 + mkfifo "$boot/outcome" "$boot/lifeline" || return 70 + exec 8<>"$boot/outcome" 9<>"$boot/lifeline" + trap 'cancel_status=143; interrupted=yes; exec 9>&-' TERM + trap 'cancel_status=130; interrupted=yes; exec 9>&-' INT + trap 'cancel_status=129; interrupted=yes; exec 9>&-' HUP + [[ $- == *m* ]] && monitor=yes + set -m + bash "$SCRIPT_PATH" --ocx-install-keeper "$boot" "$TIMEOUT_BIN" "$KILL_GRACE_SECONDS" "$dir" "$@" 8>&- 9>&- & + keeper_pid=$! + [[ "$monitor" == yes ]] || set +m + ATTEMPT_VERDICT=fail; ATTEMPT_STATUS=70 + if IFS=' ' read -r -t 5 tag value extra <&8 && [[ "$tag" == ready && "$value" == "$keeper_pid" && -z "$extra" ]]; then + # After the handshake, unlink every IPC name before permitting any install code. + if rm "$boot/outcome" "$boot/lifeline" 8>&- 9>&- && rmdir "$boot" 8>&- 9>&-; then + remaining=$(( TOTAL_SECONDS - SECONDS - KILL_GRACE_SECONDS - 2 )) + (( remaining < deadline )) && deadline=$remaining + if (( deadline >= MIN_ATTEMPT_SECONDS && cancel_status == 0 )); then + if printf 'go %s\n' "$deadline" >&9; then + started=yes + tag=""; value=""; extra="" + if IFS=' ' read -r -t "$(( deadline + KILL_GRACE_SECONDS ))" tag value extra <&8 && [[ -z "$extra" ]]; then + case "$tag:$value" in + result:*) + if [[ "$value" =~ ^(0|[1-9][0-9]{0,2})$ && $value -le 255 ]]; then + ATTEMPT_STATUS=$value + [[ $value -eq 0 ]] && ATTEMPT_VERDICT=ok + fi ;; + deadline:124) ATTEMPT_VERDICT=deadline; ATTEMPT_STATUS=124 ;; + fail:66) ATTEMPT_STATUS=66 ;; + esac + fi + fi + else + ATTEMPT_VERDICT=budget; ATTEMPT_STATUS=124 + fi + fi + fi + # A valid outcome keeps the lifeline open through the keeper's own complete grace cycle. + if [[ "$ATTEMPT_VERDICT" == fail && $ATTEMPT_STATUS -eq 70 ]] || [[ "$started" == no ]]; then exec 9>&-; fi + while :; do + interrupted=no + keeper_status=0; wait "$keeper_pid" 2>/dev/null || keeper_status=$? + [[ "$interrupted" == yes ]] || break + done + # EOF is not a drain proof. Reuse can cause a conservative failure, never a foreign kill. + for value in 1 2 3 4 5 6 7 8 9 10; do + if ! kill -0 -- "-$keeper_pid" 2>/dev/null; then drained=yes; break; fi + sleep 0.1 8>&- 9>&- + done + exec 9>&- 8>&- + trap - TERM INT HUP + if [[ -d "$boot" ]]; then + for value in outcome lifeline; do [[ ! -e "$boot/$value" ]] || rm "$boot/$value"; done + rmdir "$boot" + fi + if (( cancel_status > 0 )); then + ATTEMPT_VERDICT=cancel; ATTEMPT_STATUS=$cancel_status + elif [[ "$drained" != yes ]]; then + ATTEMPT_VERDICT=drain_fail; ATTEMPT_STATUS=70 + elif [[ "$started" == yes && $keeper_status -ne 137 ]]; then + ATTEMPT_VERDICT=fail; ATTEMPT_STATUS=70 + fi +} install_dir() { - local dir="$1" attempt status remaining deadline - # Never empty: macOS ships bash 3.2, where expanding an empty array under `set -u` aborts - # with "unbound variable". + local dir="$1" attempt deadline remaining + # Keep this array nonempty for Bash 3.2 with nounset. local -a install_cmd=("$BUN_INSTALL_BIN" install --frozen-lockfile) for ((attempt = 1; attempt <= MAX_ATTEMPTS; attempt++)); do - # The kill grace counts against the budget too, so the worst case stays at TOTAL_SECONDS. - remaining=$(( TOTAL_SECONDS - SECONDS - KILL_GRACE_SECONDS )) + remaining=$(( TOTAL_SECONDS - SECONDS - KILL_GRACE_SECONDS - 2 )) if (( remaining < MIN_ATTEMPT_SECONDS )); then echo "::error::bun install --frozen-lockfile in ${dir} has no time left in its ${TOTAL_SECONDS}s budget." return 124 fi deadline=$(( remaining < ATTEMPT_SECONDS ? remaining : ATTEMPT_SECONDS )) - status=0 - (cd "$dir" && timeout --signal=TERM --kill-after="${KILL_GRACE_SECONDS}s" "${deadline}s" \ - "${install_cmd[@]}") || status=$? - if [[ $status -eq 0 ]]; then return 0; fi - # 124: the deadline fired and the install exited on TERM; 137: it needed the KILL. - if [[ $status -ne 124 && $status -ne 137 ]]; then - echo "::error::bun install --frozen-lockfile failed in ${dir} (exit ${status}); not retrying." - return "$status" - fi - echo "::warning::bun install --frozen-lockfile in ${dir} exceeded ${deadline}s (attempt ${attempt}/${MAX_ATTEMPTS})." - # A killed install may have extracted bun but not finished its trusted postinstall. - # Plain install can then report success while leaving bin/bun.exe a placeholder. + run_attempt "$dir" "$deadline" "${install_cmd[@]}" + case "$ATTEMPT_VERDICT" in + ok) return 0 ;; + cancel) return "$ATTEMPT_STATUS" ;; + budget) echo "::error::bun install --frozen-lockfile in ${dir} has no time left in its ${TOTAL_SECONDS}s budget."; return 124 ;; + deadline) echo "::warning::bun install --frozen-lockfile in ${dir} exceeded ${deadline}s (attempt ${attempt}/${MAX_ATTEMPTS})." ;; + *) echo "::error::bun install --frozen-lockfile failed in ${dir} (exit ${ATTEMPT_STATUS}, ${ATTEMPT_VERDICT}); not retrying."; return "$ATTEMPT_STATUS" ;; + esac install_cmd=("$BUN_INSTALL_BIN" install --frozen-lockfile --force) done echo "::error::bun install --frozen-lockfile in ${dir} exceeded its deadline ${MAX_ATTEMPTS} times." return 124 } - if [[ $# -eq 0 ]]; then set -- .; fi for dir in "$@"; do install_dir "$dir"; done diff --git a/tests/ci-workflows/ci-bun-install-frozen.test.ts b/tests/ci-workflows/ci-bun-install-frozen.test.ts index bec3dd7292f..cb969eaf5ef 100644 --- a/tests/ci-workflows/ci-bun-install-frozen.test.ts +++ b/tests/ci-workflows/ci-bun-install-frozen.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { spawnSync } from "node:child_process"; -import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { spawn, spawnSync } from "node:child_process"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { repoPath } from "../helpers/repo-root"; @@ -39,53 +39,257 @@ describe("CI frozen installs are bounded", () => { }); }); -// The script targets the Linux CI runners and needs GNU timeout. +// These process-group assertions need POSIX; a Windows skip is not Linux coverage. describe.skipIf(process.platform === "win32")("bun-install-frozen.sh retry policy", () => { const roots: string[] = []; - afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); + const groups = (root: string) => existsSync(join(root, "groups")) + ? [...new Set(readFileSync(join(root, "groups"), "utf8").trim().split(/\s+/).filter((s) => /^[1-9]\d*$/.test(s)))] : []; + const drained = (root: string) => spawnSync("bash", ["-c", + 'for group; do if kill -0 -- "-$group" 2>/dev/null; then exit 1; fi; done', + "_", ...groups(root)], { timeout: 2_000 }).status === 0; + afterEach(() => { + for (const root of roots.splice(0)) { + // Never delete a fixture against a live attempt, or infer drain merely from pipe EOF. + if (!drained(root)) throw new Error("Attempt group remains; fixture retained: " + root); + rmSync(root, { recursive: true }); + } + }); - /** A fake bun whose Nth invocation behaves as listed: "hang", "ok" or an exit code. */ - function run(plan: string[], options: { dirs?: number; env?: Record } = {}) { - const root = mkdtempSync(join(tmpdir(), "ocx-bun-install-")); + function fixture(plan: string[], env: Record = {}) { + const root = mkdtempSync(join(tmpdir(), "ocx-bun-install-case-")); roots.push(root); - const count = join(root, "count"), fake = join(root, "fake-bun"); + mkdirSync(join(root, "tmp")); writeFileSync(join(root, "plan"), plan.join("\n") + "\n"); + const fake = join(root, "fake-bun"); writeFileSync(fake, [ "#!/usr/bin/env bash", - `n=$(( $(cat "${count}" 2>/dev/null || echo 0) + 1 )); echo "$n" > "${count}"`, - `step=$(sed -n "${"$"}{n}p" "${join(root, "plan")}")`, + 'if [[ -r /proc/$$/stat ]]; then', + ' IFS= read -r stat < /proc/$$/stat; rest="${stat##*) }"; read -r state parent group rest <<< "$rest"', + 'else read -r group <<< "$(ps -o pgid= -p "$$")"; fi', + 'printf "%s\\n" "$group" >> "$TEST_ROOT/groups"', + 'n=$(( $(cat "$TEST_ROOT/count" 2>/dev/null || echo 0) + 1 )); echo "$n" > "$TEST_ROOT/count"', + 'step=$(sed -n "$' + '{n}p" "$TEST_ROOT/plan")', 'echo "fake bun $* in $PWD"', - 'case "$step" in hang) exec sleep 30 ;; ok) exit 0 ;; *) exit "$step" ;; esac', + 'case "$step" in', + " hang) exec sleep 30 ;;", + " hang-ignore) trap '' TERM; exec sleep 30 ;;", + " term-marker) trap 'echo TERM > \"$TEST_ROOT/termed\"; exit 0' TERM; sleep 30 ;;", + " sigkill) kill -KILL $$ ;;", + " spawn-ignore) ( trap '' TERM; exec sleep 30 ) & exec sleep 30 ;;", + " spawn-leak) ( exec sleep 30 >/dev/null 2>&1 ) & exit 0 ;;", + ' forge-fd) for fd in 6 7 8 9; do if ( printf "result 0\\n" >&"$fd" ) 2>/dev/null; then echo "$fd" > "$TEST_ROOT/leaked"; exit 99; fi; done; exit 42 ;;', + ' metadata) mkdir "$TMPDIR/ocx-bun-install.forged"; mkdir "$TMPDIR/ocx-bun-install.forged/decision"; echo 0 > "$TMPDIR/ocx-bun-install.forged/result"; echo "deadline 0" > "$TMPDIR/ocx-bun-install.forged/witness"; exit 42 ;;', + ' kill-keeper) kill -KILL "$group"; exec sleep 30 ;;', + " ok) exit 0 ;;", + ' *) exit "$step" ;;', + "esac", ].join("\n")); chmodSync(fake, 0o755); - const dirs = Array.from({ length: options.dirs ?? 1 }, () => root); + return { + root, count: join(root, "count"), + env: { ...process.env, TEST_ROOT: root, TMPDIR: join(root, "tmp"), + BUN_INSTALL_BIN: fake, BUN_INSTALL_ATTEMPT_SECONDS: "1", + BUN_INSTALL_KILL_GRACE_SECONDS: "1", BUN_INSTALL_MIN_ATTEMPT_SECONDS: "1", ...env }, + }; + } + + function run(plan: string[], options: { dirs?: number; env?: Record } = {}) { + const f = fixture(plan, options.env); + const dirs = Array.from({ length: options.dirs ?? 1 }, (_, n) => { + const dir = join(f.root, "dir-" + n); mkdirSync(dir); return dir; + }); const started = Date.now(); - const result = spawnSync("bash", [script, ...dirs], { - encoding: "utf8", timeout: 30_000, - env: { ...process.env, BUN_INSTALL_BIN: fake, BUN_INSTALL_ATTEMPT_SECONDS: "1", BUN_INSTALL_KILL_GRACE_SECONDS: "1", - BUN_INSTALL_MIN_ATTEMPT_SECONDS: "1", ...options.env }, + const child = spawnSync("bash", [script, ...dirs], { + encoding: "utf8", timeout: 15_000, env: f.env, }); - return { status: result.status, out: result.stdout + result.stderr, elapsedMs: Date.now() - started, - calls: existsSync(count) ? Number(readFileSync(count, "utf8")) : 0 }; + expect(child.error, child.stdout + child.stderr).toBeUndefined(); + expect(drained(f.root), "owned group remains").toBe(true); + return { status: child.status, out: child.stdout + child.stderr, root: f.root, + elapsedMs: Date.now() - started, + calls: existsSync(f.count) ? Number(readFileSync(f.count, "utf8")) : 0 }; + } + + function timeoutCandidates(f: ReturnType, compatibleFallback: boolean) { + const bin = join(f.root, "timeout-bin"); + mkdirSync(bin); + for (const name of ["timeout", "gtimeout"]) { + writeFileSync(join(bin, name), [ + "#!/usr/bin/env bash", + 'if [[ "$1" == --signal=TERM ]]; then exit 0; fi', + 'if [[ "$1" == --foreground ]]; then', + ...(name === "gtimeout" && compatibleFallback + ? [' shift 2', ' exec "$@"'] + : [' exit 64']), + "fi", + "exit 64", + ].join("\n")); + chmodSync(join(bin, name), 0o755); + } + return { ...f.env, PATH: `${bin}:${f.env.PATH ?? ""}` }; } - test("an attempt killed by its deadline is retried and the next success passes", () => { - const result = run(["hang", "ok"]); - expect(result.status, result.out).toBe(0); - expect(result.calls).toBe(2); - expect(result.out).toContain("::warning::bun install --frozen-lockfile"); - expect(result.out).toContain("fake bun install --frozen-lockfile --force"); - expect(result.out.split("fake bun ")[1]).toStartWith("install --frozen-lockfile in "); + test("rejects candidates without foreground support before starting an install", () => { + const f = fixture(["ok"]); + const dir = join(f.root, "dir"); mkdirSync(dir); + const child = spawnSync("bash", [script, dir], { + encoding: "utf8", timeout: 10_000, env: timeoutCandidates(f, false), + }); + expect(child.error, child.stdout + child.stderr).toBeUndefined(); + expect(child.status, child.stdout + child.stderr).toBe(69); + expect(child.stdout + child.stderr).toContain("GNU timeout is required"); + expect(existsSync(f.count)).toBe(false); + expect(drained(f.root)).toBe(true); + }); + + test("falls back to gtimeout when timeout lacks foreground support", () => { + const f = fixture(["ok"]); + const dir = join(f.root, "dir"); mkdirSync(dir); + const child = spawnSync("bash", [script, dir], { + encoding: "utf8", timeout: 10_000, env: timeoutCandidates(f, true), + }); + expect(child.error, child.stdout + child.stderr).toBeUndefined(); + expect(child.status, child.stdout + child.stderr).toBe(0); + expect(existsSync(f.count)).toBe(true); + expect(readFileSync(f.count, "utf8")).toBe("1\n"); + expect(drained(f.root)).toBe(true); + }); + + test.each(["1", "124", "137", "sigkill"])("ordinary install failure %s never retries", (step) => { + const r = run([step, "ok"]); + expect(r.status, r.out).toBe(step === "sigkill" ? 137 : Number(step)); + expect(r.calls).toBe(1); + expect(r.out).toContain("not retrying"); + expect(r.out).not.toContain("::warning::"); }); - test("a genuine install failure after a timeout still fails without another retry", () => { - const result = run(["hang", "42", "ok"]); - expect(result.status, result.out).toBe(42); - expect(result.calls).toBe(2); - expect(result.out).toContain("::error::bun install --frozen-lockfile failed"); - expect(result.out).toContain("not retrying"); + test.each(["hang", "hang-ignore", "spawn-ignore"])("deadline retries %s after its full grace", (step) => { + const r = run([step, "ok"], { env: { BUN_INSTALL_KILL_GRACE_SECONDS: "2" } }); + expect(r.status, r.out).toBe(0); + expect(r.calls).toBe(2); + expect(r.elapsedMs).toBeGreaterThanOrEqual(2_800); + expect(r.elapsedMs).toBeLessThan(12_000); + expect(r.out).toContain("::warning::"); + expect(r.out).toContain("fake bun install --frozen-lockfile --force"); + expect(r.out.split("fake bun ")[1]).toStartWith("install --frozen-lockfile in "); + }, 15_000); + + test("TERM remains catchable and its full grace elapses before completion", () => { + const r = run(["term-marker"], { env: { + BUN_INSTALL_MAX_ATTEMPTS: "1", BUN_INSTALL_KILL_GRACE_SECONDS: "2", + } }); + expect(r.status, r.out).toBe(124); + expect(r.calls).toBe(1); + const marker = join(r.root, "termed"); + expect(existsSync(marker)).toBe(true); + expect(Date.now() - statSync(marker).mtimeMs).toBeGreaterThanOrEqual(1_800); + }, 10_000); + + test("a failure after a witnessed deadline stops further retries", () => { + const r = run(["hang", "42", "ok"]); + expect(r.status, r.out).toBe(42); + expect(r.calls).toBe(2); + expect(r.out).toContain("not retrying"); + }, 10_000); + + test("normal success drains descendants even when they closed output", () => { + const r = run(["spawn-leak"]); + expect(r.status, r.out).toBe(0); + expect(r.calls).toBe(1); + expect(r.elapsedMs).toBeLessThan(5_000); }); + test("the install inherits no result or lifeline descriptor", () => { + const r = run(["forge-fd", "ok"]); + expect(r.status, r.out).toBe(42); + expect(r.calls).toBe(1); + expect(existsSync(join(r.root, "leaked"))).toBe(false); + }); + + test("writable decision/result/witness files have no classification authority", () => { + const r = run(["metadata", "ok"]); + expect(r.status, r.out).toBe(42); + expect(r.calls).toBe(1); + expect(r.out).not.toContain("::warning::"); + expect(readFileSync(join(r.root, "tmp", "ocx-bun-install.forged", "result"), "utf8")).toBe("0\n"); + }); + + test("keeper death fails closed and leaves no attempt group", () => { + const r = run(["kill-keeper", "ok"]); + expect(r.status, r.out).toBe(70); + expect(r.calls).toBe(1); + expect(r.elapsedMs).toBeLessThan(8_000); + expect(r.out).not.toContain("::warning::"); + }, 10_000); + + test("three witnessed deadlines stop at max attempts", () => { + const r = run(["hang", "hang", "hang", "ok"]); + expect(r.status, r.out).toBe(124); + expect(r.calls).toBe(3); + expect(r.out).toContain("exceeded its deadline 3 times"); + }, 15_000); + + test("successful root and stalled GUI share the total budget, including cleanup", () => { + const r = run(["ok", "hang", "hang", "hang"], { dirs: 2, env: { + BUN_INSTALL_ATTEMPT_SECONDS: "3", BUN_INSTALL_TOTAL_SECONDS: "9", + } }); + expect(r.status, r.out).toBe(124); + expect(r.calls).toBeGreaterThanOrEqual(2); + expect(r.calls).toBeLessThan(4); + expect(r.elapsedMs).toBeLessThan(10_500); + expect(r.out).toContain("dir-1"); + expect(r.out).toContain("budget"); + }, 15_000); + + async function interrupt(signal: "SIGTERM" | "SIGINT" | "SIGHUP" | "SIGKILL", duringGrace = false) { + const f = fixture([duringGrace ? "term-marker" : "hang", "ok"], { + BUN_INSTALL_ATTEMPT_SECONDS: duringGrace ? "1" : "8", BUN_INSTALL_KILL_GRACE_SECONDS: "3", + }); + const child = spawn("bash", [script, f.root], { env: f.env }); + let out = ""; + child.stdout.on("data", (d) => { out += d; }); + child.stderr.on("data", (d) => { out += d; }); + let closed = false; + const completion = new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", (code, sig) => { + closed = true; + resolve(code ?? (sig === "SIGKILL" ? 137 : sig === "SIGTERM" ? 143 : -1)); + }); + }); + let timer: ReturnType | undefined; + try { + const readyBy = Date.now() + 5_000; + const ready = duringGrace ? join(f.root, "termed") : f.count; + while (!existsSync(ready) && !closed && Date.now() < readyBy) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + expect(existsSync(ready), out).toBe(true); + expect(closed, out).toBe(false); + const stopped = Date.now(); + child.kill(signal); + const status = await Promise.race([completion, new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("No parent exit/output EOF")), 5_000); + })]); + // SIGKILL cannot wait for its descendants, so observe the group separately. + while (!drained(f.root) && Date.now() - stopped < 4_000) { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + expect(status, out).toBe({ SIGTERM: 143, SIGINT: 130, SIGHUP: 129, SIGKILL: 137 }[signal]); + expect(drained(f.root), out).toBe(true); + expect(Number(readFileSync(f.count, "utf8"))).toBe(1); + expect(out).not.toContain("::warning::"); + } finally { + if (timer) clearTimeout(timer); + if (!closed && child.exitCode === null && child.signalCode === null) child.kill("SIGTERM"); + if (!closed) await completion; + } + } + + test.each(["SIGTERM", "SIGINT", "SIGHUP", "SIGKILL"] as const)("parent %s stops its attempt without a retry", + (signal) => interrupt(signal), 15_000); + test("parent cancellation outranks an already recorded deadline during its grace", + () => interrupt("SIGTERM", true), 15_000); + test("retry completes a trusted postinstall interrupted after package extraction", () => { const root = mkdtempSync(join(tmpdir(), "ocx-bun-postinstall-")); roots.push(root); @@ -125,32 +329,4 @@ describe.skipIf(process.platform === "win32")("bun-install-frozen.sh retry polic expect(readFileSync(join(root, "bun.lock"), "utf8")).toBe(lockBefore); }, 30_000); - test("an ordinary install failure such as a lockfile mismatch is not retried", () => { - const result = run(["1", "ok"]); - expect(result.status, result.out).toBe(1); - expect(result.calls).toBe(1); - expect(result.out).toContain("not retrying"); - }); - - test("three deadline kills fail with an error annotation", () => { - const result = run(["hang", "hang", "hang", "ok"]); - expect(result.status, result.out).toBe(124); - expect(result.calls).toBe(3); - expect(result.out).toContain("::error::bun install --frozen-lockfile in"); - }); - - test("every attempt runs the frozen-lockfile install", () => { - const result = run(["ok"]); - expect(result.status, result.out).toBe(0); - expect(result.out).toContain("fake bun install --frozen-lockfile"); - }); - - test("root and GUI stalls share one total budget instead of three attempts each", () => { - // Per-directory retries alone would allow 6 attempts of 2 s plus grace; the 4 s total ends after 2. - const result = run(Array(8).fill("hang"), { dirs: 2, env: { BUN_INSTALL_ATTEMPT_SECONDS: "2", BUN_INSTALL_TOTAL_SECONDS: "4" } }); - expect(result.status, result.out).toBe(124); - expect(result.calls).toBe(2); - expect(result.elapsedMs).toBeLessThan(7_000); - expect(result.out).toContain("budget"); - }); }); From 2c4e580e23f902af929f93f3294e13b687467308 Mon Sep 17 00:00:00 2001 From: Reed Date: Fri, 30 Oct 2026 12:16:48 +0100 Subject: [PATCH 44/84] test(xai): cover encrypted Responses tool continuation (#7975) * test(xai): cover encrypted Responses tool continuation * test(xai): assert immutability of the adapter request body --- .../xai/xai-responses-adjacency.test.ts | 58 +++++++++++++++++-- 1 file changed, 54 insertions(+), 4 deletions(-) diff --git a/tests/providers/xai/xai-responses-adjacency.test.ts b/tests/providers/xai/xai-responses-adjacency.test.ts index 6c8d03546b7..5539e241356 100644 --- a/tests/providers/xai/xai-responses-adjacency.test.ts +++ b/tests/providers/xai/xai-responses-adjacency.test.ts @@ -22,22 +22,72 @@ function xaiOauthResponses(overrides: Partial = {}): OcxProvi } function buildBody(provider: OcxProviderConfig, rawBody: Record): Record { + const requestBody = typeof rawBody.model === "string" ? rawBody : { model: MODEL, ...rawBody }; + const before = structuredClone(requestBody); const built = createResponsesPassthroughAdapter(provider).buildRequest({ - modelId: MODEL, + modelId: typeof requestBody.model === "string" ? requestBody.model : MODEL, context: { messages: [] }, stream: true, options: {}, previousResponseId: typeof rawBody.previous_response_id === "string" ? rawBody.previous_response_id : undefined, - _rawBody: { model: MODEL, ...rawBody }, + _rawBody: requestBody, } as Parameters["buildRequest"]>[0], { headers: new Headers(), }); + if (built instanceof Promise) throw new Error("Expected a synchronous Responses request builder"); + expect(requestBody).toEqual(before); return JSON.parse(String(built.body)) as Record; } describe("xAI Responses include compatibility", () => { + for (const baseUrl of ["https://api.x.ai/v1", "https://us.api.x.ai/v1", "https://cli-chat-proxy.grok.com/v1"]) { + test(`preserves Grok 4.7 encrypted tool continuation at ${baseUrl}`, () => { + const raw = { + model: "grok-4.7", + store: false, + reasoning: { effort: "xhigh" }, + include: ["output.encrypted_content", "reasoning.encrypted_content", "web_search_call.action.sources", "output.encrypted_content"], + tools: [{ type: "function", name: "read_fixture", parameters: { type: "object", properties: {} } }], + input: [ + { type: "reasoning", encrypted_content: "synthetic-native-ciphertext", summary: [] }, + { type: "function_call", call_id: "call_fixture", name: "read_fixture", arguments: "{}" }, + { type: "function_call_output", call_id: "call_fixture", output: "nonce=synthetic-fixture" }, + ], + }; + const before = structuredClone(raw); + const body = buildBody(xaiOauthResponses({ baseUrl }), raw); + + expect(body.include).toEqual(["reasoning.encrypted_content", "web_search_call.action.sources"]); + expect(body.model).toBe("grok-4.7"); + expect(body.reasoning).toEqual(raw.reasoning); + expect(body.store).toBe(false); + expect(body.tools).toEqual(raw.tools); + expect(body.input).toEqual(raw.input); + expect(raw).toEqual(before); + }); + } + + test("does not invent include values for absent, empty or non-array input", () => { + const provider = xaiOauthResponses(); + expect(buildBody(provider, { input: [] })).not.toHaveProperty("include"); + expect(buildBody(provider, { input: [], include: [] }).include).toEqual([]); + expect(buildBody(provider, { input: [], include: "reasoning.encrypted_content" }).include) + .toBe("reasoning.encrypted_content"); + }); + + for (const baseUrl of ["https://chatgpt.com/backend-api/codex", "https://gateway.example/v1", "https://api.x.ai.evil.example/v1", "https://api.x.ai:444/v1"]) { + test(`preserves include entries and caller input outside xAI at ${baseUrl}`, () => { + const raw = { include: ["output.encrypted_content", "reasoning.encrypted_content"], input: [] }; + const before = structuredClone(raw); + const body = buildBody(xaiOauthResponses({ baseUrl, authMode: "key" }), raw); + + expect(body.include).toEqual(raw.include); + expect(raw).toEqual(before); + }); + } + test("drops the include entry xAI rejects instead of ignoring", () => { const body = buildBody(xaiOauthResponses(), { include: ["output.encrypted_content", "reasoning.encrypted_content"], @@ -57,7 +107,7 @@ describe("xAI Responses include compatibility", () => { }); test("strips the rejected include entry for the US regional endpoint", () => { - const provider = xaiOauthResponses({ baseUrl: "https://us.api.x.ai/v1", authMode: "api-key" }); + const provider = xaiOauthResponses({ baseUrl: "https://us.api.x.ai/v1", authMode: "key" }); const body = buildBody(provider, { include: ["output.encrypted_content", "reasoning.encrypted_content"], input: [], }); @@ -70,7 +120,7 @@ describe("xAI Responses include compatibility", () => { const provider: OcxProviderConfig = { adapter: "openai-responses", baseUrl: "https://api.openai.com/v1", - authMode: "api-key", + authMode: "key", }; const body = buildBody(provider, { include: ["output.encrypted_content"], input: [] }); From 3a70dd4788db76701df546c8629c8d8e17c2680d Mon Sep 17 00:00:00 2001 From: Yu Zhang <34849476+AaronZ345@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:20:09 +0100 Subject: [PATCH 45/84] fix(volcengine): keep Kimi K3.1 Pro tool continuations valid (#7944) * fix(volcengine): preserve empty Kimi K3.1 Pro tool continuations * fix(volcengine): cover Kimi continuation contracts --------- Co-authored-by: zhangyu.34 --- .../src/content/docs/guides/providers.md | 4 +++ src/adapters/openai-chat/messages.ts | 13 +++++---- structure/providers/chat-compat.md | 11 ++++---- .../volcengine-ark-assistant-content.test.ts | 28 +++++++++++++++++-- 4 files changed, 44 insertions(+), 12 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 4959c8a019b..5a43f759fe2 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -1082,6 +1082,10 @@ unchanged and were not probed in this update. > models. Coding Plan defaults to `ark-code-latest`, while Agent Plan defaults to > `deepseek-v4-flash`. +On the pay-as-you-go Ark route, Kimi K3.1 Pro (`kimi-k3-1-pro`, including dated versions) +replays tool-only assistant turns with an empty string. Its endpoint rejects the legacy +empty-text array with HTTP 400; other Ark models keep their existing continuation format. + > **Volcengine Plan usage restriction:** Volcengine documents Coding Plan and Agent Plan quota as > valid only inside supported AI coding tools, and warns that using a plan key for general API > calls may suspend the subscription or ban the account. Routing Codex or Claude Code through diff --git a/src/adapters/openai-chat/messages.ts b/src/adapters/openai-chat/messages.ts index 7b45c4581cf..43963d54eb4 100644 --- a/src/adapters/openai-chat/messages.ts +++ b/src/adapters/openai-chat/messages.ts @@ -356,10 +356,10 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv type: "function", function: { name: namespacedToolName(tc.namespace, tc.name), arguments: JSON.stringify(tc.arguments) }, })); - if (!chatMsg.content) chatMsg.content = emptyAssistantContent(provider); + if (!chatMsg.content) chatMsg.content = emptyAssistantContent(provider, parsed.modelId); } if (hasReplayedReasoning && chatMsg.content === undefined && chatMsg.tool_calls === undefined) { - chatMsg.content = emptyAssistantContent(provider); + chatMsg.content = emptyAssistantContent(provider, parsed.modelId); } out.push(chatMsg); pendingToolCalls = wireToolCalls.map(({ tc, id }) => ({ id, name: namespacedToolName(tc.namespace, tc.name) })); @@ -413,7 +413,7 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv : { reasoning_content: orphanReasoning }; out.push({ role: "assistant", - content: emptyAssistantContent(provider), + content: emptyAssistantContent(provider, parsed.modelId), ...orphanReasoningFields, tool_calls: [{ id: toolCallId, @@ -448,8 +448,11 @@ export function safeToolName(name: string | undefined): string { return sanitized; } -export function emptyAssistantContent(provider: OcxProviderConfig): string | { type: "text"; text: string }[] { - return isVolcengineArkPaygChatTarget(provider) ? [{ type: "text", text: "" }] : ""; +export function emptyAssistantContent(provider: OcxProviderConfig, modelId = ""): string | { type: "text"; text: string }[] { + // Kimi K3.1 Pro rejects Ark's legacy empty-text array during tool continuations. + const upstreamModelId = provider.modelSuffixBracketStrip ? stripBracketedModelSuffix(modelId) : modelId; + const kimi31Pro = /^kimi-k3-1-pro(?:-\d{6})?$/.test(upstreamModelId); + return isVolcengineArkPaygChatTarget(provider) && !kimi31Pro ? [{ type: "text", text: "" }] : ""; } /** Reconstruct completed text-only thinking; never invent signatures or placeholder reasoning. */ diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index a675d58bba0..c9c33cb8278 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -396,11 +396,12 @@ ledger: `devlog/_fin/260709_parallel_tool_calls/`. ## Volcengine Ark assistant continuation shapes -The `openai-chat` adapter keeps Volcengine's pay-as-you-go Chat endpoint and Coding Plan endpoint -on separate empty-assistant contracts. The pay-as-you-go `/api/v3` route retains the structured -`[{ "type": "text", "text": "" }]` placeholder inferred for #796, while `/api/coding/v3` uses the -ordinary empty string accepted by its live tool-call continuation contract (#1571). Matching only -the shared Ark hostname is too broad because the two endpoint families reject opposite shapes. +The `openai-chat` adapter uses `""` for Kimi K3.1 Pro (`kimi-k3-1-pro`, including dated versions) +on Ark's pay-as-you-go `/api/v3` route: tool continuations reject the legacy empty-text array. +This covers ordinary, reasoning-only and synthesized orphan assistant turns. Other pay-as-you-go +Ark models retain the `[{ "type": "text", "text": "" }]` placeholder inferred for #796. +Coding Plan's `/api/coding/v3` uses the empty string accepted by its live continuation contract +(#1571); matching only the shared Ark hostname cannot identify the continuation format. > Decision record: [ADR-0063](../decisions/ADR-0063-volcengine-ark-assistant-continuation-shapes.md) diff --git a/tests/providers/volcengine-ark-assistant-content.test.ts b/tests/providers/volcengine-ark-assistant-content.test.ts index cb48dc2a19a..26426d6b549 100644 --- a/tests/providers/volcengine-ark-assistant-content.test.ts +++ b/tests/providers/volcengine-ark-assistant-content.test.ts @@ -34,9 +34,9 @@ interface ChatMsg { tool_call_id?: string; } -function wire(provider: OcxProviderConfig, messages: OcxMessage[]): ChatMsg[] { +function wire(provider: OcxProviderConfig, messages: OcxMessage[], modelId = "kimi-k3"): ChatMsg[] { const parsed: OcxParsedRequest = { - modelId: "kimi-k3", + modelId, context: { messages }, stream: false, options: {}, @@ -137,4 +137,28 @@ describe("Volcengine Ark empty assistant content (#796)", () => { genericReasoning.preserveReasoningContentModels = ["kimi-k3"]; expect(assistantsOf(wire(genericReasoning, reasoningHistory))[0].content).toBe(""); }); + + for (const modelId of ["kimi-k3-1-pro", "kimi-k3-1-pro-261024"]) { + test(`${modelId} replays tool, orphan and reasoning-only turns with string content`, () => { + const assistant = assistantsOf(wire(ark, history, modelId))[0]; + expect(assistant.content).toBe(""); + expect(assistant.tool_calls).toHaveLength(1); + expect(assistantsOf(wire(ark, history.slice(2), modelId))[0].content).toBe(""); + const reasoning: OcxMessage[] = [ + { role: "assistant", content: [{ type: "thinking", thinking: "deliberating" }], timestamp: 0 }, + ]; + const replay = assistantsOf(wire({ ...ark, preserveReasoningContentModels: [modelId] }, reasoning, modelId))[0]; + expect(replay.content).toBe(""); + expect(replay.reasoning_content).toBe("deliberating"); + expect(assistantsOf(wire(arkCodingPlan, history, modelId))[0].content).toBe(""); + expect(assistantsOf(wire(generic, history, modelId))[0].content).toBe(""); + }); + } + + test("Kimi matching follows the model ID sent upstream", () => { + const bracketed = "kimi-k3-1-pro-261024[1m]"; + expect(assistantsOf(wire({ ...ark, modelSuffixBracketStrip: true }, history, bracketed))[0].content).toBe(""); + expect(assistantsOf(wire(ark, history, bracketed))[0].content).toEqual([{ type: "text", text: "" }]); + expect(assistantsOf(wire(ark, history, "kimi-k3-1-pro-261024-extra"))[0].content).toEqual([{ type: "text", text: "" }]); + }); }); From 4998194755f3698005409a6e75408f4e80a7a81b Mon Sep 17 00:00:00 2001 From: ZeroDi Date: Fri, 30 Oct 2026 20:25:41 +0900 Subject: [PATCH 46/84] perf(responses): count snapshot text bytes incrementally (#7957) --- scripts/test-layout/layout.json | 1 + src/server/responses-snapshot-repair.ts | 22 ++++- structure/transports/byte-accounting.md | 6 ++ tests/fixtures/test-layout-expected.json | 1 + .../responses-snapshot-scaling.test.ts | 98 +++++++++++++++++++ 5 files changed, 124 insertions(+), 4 deletions(-) create mode 100644 tests/responses/responses-snapshot-scaling.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 551e1fa899a..7a7edd47f0a 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1594,6 +1594,7 @@ "responses-show-thinking-summary.test.ts": "responses", "responses-snapshot-repair-server.test.ts": "responses", "responses-snapshot-repair.test.ts": "responses", + "responses-snapshot-scaling.test.ts": "responses", "responses-sparse-terminal-tool-scope.test.ts": "responses", "responses-spend-ledger-wiring.test.ts": "responses", "responses-spend-contract-c.test.ts": "responses", diff --git a/src/server/responses-snapshot-repair.ts b/src/server/responses-snapshot-repair.ts index 389b8374900..1f3c2da3391 100644 --- a/src/server/responses-snapshot-repair.ts +++ b/src/server/responses-snapshot-repair.ts @@ -169,6 +169,9 @@ type OpenItem = { /** The gateway sent content_part.done for this item. */ partDone: boolean; text: string; + /** Exact UTF-8 size and boundary unit, without rescanning the accumulated text. */ + textBytes: number; + lastCodeUnit: number; item: Record; }; @@ -219,7 +222,7 @@ export function createResponsesSnapshotBlockRewrite( const closeOpenItem = (index: number): void => { const open = openItems.get(index); if (!open) return; - aggregateOpenTextBytes -= Buffer.byteLength(open.text, "utf8"); + aggregateOpenTextBytes -= open.textBytes; openItems.delete(index); }; @@ -313,6 +316,8 @@ export function createResponsesSnapshotBlockRewrite( textDone: false, partDone: false, text: "", + textBytes: 0, + lastCodeUnit: -1, item: event.item, }); } @@ -407,12 +412,19 @@ export function createResponsesSnapshotBlockRewrite( return [changed ? jsonBlock(nextEvent) : block]; } if (open && (itemId === undefined || itemId === open.itemId)) { - const deltaBytes = Buffer.byteLength(event.delta, "utf8"); + let deltaBytes = Buffer.byteLength(event.delta, "utf8"); + const first = event.delta.charCodeAt(0); + // Joining two isolated surrogate halves replaces two 3-byte replacements with 4 bytes. + if (open.lastCodeUnit >= 0xd800 && open.lastCodeUnit <= 0xdbff && first >= 0xdc00 && first <= 0xdfff) { + deltaBytes -= 2; + } + if (event.delta.length > 0) open.lastCodeUnit = event.delta.charCodeAt(event.delta.length - 1); open.text += event.delta; + open.textBytes += deltaBytes; aggregateOpenTextBytes += deltaBytes; // Unbounded text accumulation is a retention hole (#893 review): // overshoot per-item or aggregate caps and the stream goes fail-closed. - if (Buffer.byteLength(open.text, "utf8") > MAX_COMPLETED_OUTPUT_ITEM_SOURCE_BYTES + if (open.textBytes > MAX_COMPLETED_OUTPUT_ITEM_SOURCE_BYTES || aggregateOpenTextBytes > MAX_OPEN_ITEM_AGGREGATE_TEXT_BYTES) { taintAndRelease(); } @@ -437,9 +449,11 @@ export function createResponsesSnapshotBlockRewrite( if (typeof event.text === "string") { // Replacement must keep the aggregate honest and bounded, the // same as delta accumulation (#893 review). - const previousBytes = Buffer.byteLength(open.text, "utf8"); + const previousBytes = open.textBytes; const nextBytes = Buffer.byteLength(event.text, "utf8"); open.text = event.text; + open.textBytes = nextBytes; + open.lastCodeUnit = event.text.charCodeAt(event.text.length - 1); aggregateOpenTextBytes += nextBytes - previousBytes; if (nextBytes > MAX_COMPLETED_OUTPUT_ITEM_SOURCE_BYTES || aggregateOpenTextBytes > MAX_OPEN_ITEM_AGGREGATE_TEXT_BYTES) { diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index 4f0cba5eca5..ff55305299f 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -224,6 +224,12 @@ tool completion with rewritten arguments. `src/adapters/openai-responses.ts` counts new compaction fragments, including surrogate pairs formed across deltas, while retaining snapshot/done/delta precedence and existing terminal ownership. +The provider-opt-in tracker in `src/server/responses-snapshot-repair.ts` caches each open item's +UTF-8 text size and final code unit. It measures only incoming deltas, corrects split surrogate +pairs even across empty deltas, replaces the count for authoritative text snapshots, and refunds +the cached count on item closure. Per-item and aggregate open-text caps remain 8 MiB; crossing +either cap releases state and disables lifecycle reconstruction. `tests/responses/responses-snapshot-scaling.test.ts` +checks the linear measurement budget, Unicode boundaries, replacement, closure and cap refusal. Serialized request and buffered-response observations use byte counts without measurement arrays. The same rule applies to Anthropic, Google, and Chat response accounting; serialization itself is preserved where the existing metric is the serialized JSON size. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index a4c1df1686d..5826dcf9196 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1258,6 +1258,7 @@ "responses-send-budget-errors.test.ts": "responses", "responses-memory-models.test.ts": "responses", "responses-shadow-intercept.test.ts": "responses", "responses-show-thinking-summary.test.ts": "responses", "responses-snapshot-repair-server.test.ts": "responses", "responses-snapshot-repair.test.ts": "responses", + "responses-snapshot-scaling.test.ts": "responses", "responses-sparse-terminal-tool-scope.test.ts": "responses", "responses-spend-ledger-wiring.test.ts": "responses", "responses-spend-contract-c.test.ts": "responses", "responses-spend-anchor.test.ts": "responses", "responses-spend-capacity-guard.test.ts": "responses", "responses-spill-acl-recovery.test.ts": "responses", "responses-spill-admission-headroom.test.ts": "responses", diff --git a/tests/responses/responses-snapshot-scaling.test.ts b/tests/responses/responses-snapshot-scaling.test.ts new file mode 100644 index 00000000000..28880b47af1 --- /dev/null +++ b/tests/responses/responses-snapshot-scaling.test.ts @@ -0,0 +1,98 @@ +import { expect, spyOn, test } from "bun:test"; +import { createResponsesSnapshotBlockRewrite } from "../../src/server/responses-snapshot-repair"; +import { MAX_COMPLETED_OUTPUT_ITEM_SOURCE_BYTES as LIMIT } from "../../src/server/relay"; + +type Event = { type: string; delta?: string; text?: string; response?: { output?: Array<{ content: Array<{ text: string }> }> } }; + +function tracker() { + const rewrite = createResponsesSnapshotBlockRewrite(); + const send = (event: Record) => rewrite(`data: ${JSON.stringify(event)}`) + .map(block => JSON.parse(block.slice("data: ".length)) as Event); + const add = (index: number) => send({ type: "response.output_item.added", output_index: index, item: { type: "message", id: `m${index}` } }); + const delta = (index: number, text: string) => send({ type: "response.output_text.delta", output_index: index, item_id: `m${index}`, delta: text }); + const replace = (index: number, text: string) => send({ type: "response.output_text.done", output_index: index, item_id: `m${index}`, text }); + const finish = () => send({ type: "response.completed", response: { id: "r" } }); + return { rewrite, send, add, delta, replace, finish }; +} + +test("snapshot repair measures only new text instead of every accumulated prefix", () => { + const t = tracker(); + t.add(0); + const fragment = "x".repeat(256); + const fragments = 1024; + const original = Buffer.byteLength; + let measuredChars = 0; + let calls = 0; + const measure = spyOn(Buffer, "byteLength"); + measure.mockImplementation((value, encoding) => { + if (typeof value === "string") { measuredChars += value.length; calls++; } + return original(value, encoding); + }); + try { + for (let i = 0; i < fragments; i++) t.delta(0, fragment); + } finally { + measure.mockRestore(); + } + try { + expect(calls).toBeGreaterThanOrEqual(fragments); + expect(measuredChars).toBeLessThanOrEqual(fragment.length * fragments * 2); + expect(t.finish().find(event => event.type === "response.output_text.done")?.text).toBe(fragment.repeat(fragments)); + } finally { t.rewrite.dispose?.(); } +}); + +for (const highSource of ["delta", "snapshot"] as const) { + test(`snapshot repair counts a split surrogate pair at the byte cap (${highSource})`, () => { + const t = tracker(); + try { + t.add(0); + const prefix = "x".repeat(LIMIT - 4) + "\ud83d"; + if (highSource === "delta") t.delta(0, prefix); + else t.replace(0, prefix); + t.delta(0, ""); + t.delta(0, "\ude00"); // The assembled emoji is 4 bytes, not two 3-byte replacements. + t.replace(0, "ok"); + t.add(1); + const output = t.delta(1, "x".repeat(LIMIT - 2)); + expect(output.map(event => event.type)).toContain("response.content_part.added"); + t.replace(1, "done"); + const terminal = t.finish(); + const items = terminal.find(event => event.type === "response.completed")?.response?.output; + expect(items?.map(item => item.content[0]?.text)).toEqual(["ok", "done"]); + } finally { t.rewrite.dispose?.(); } + }); +} + +test("snapshot repair refunds closed text before admitting the next open item", () => { + const t = tracker(); + try { + t.add(0); + t.delta(0, "x".repeat(LIMIT)); + t.send({ type: "response.output_item.done", output_index: 0, item: { type: "message", id: "m0", content: [] } }); + t.add(1); + expect(t.delta(1, "x".repeat(LIMIT)).map(event => event.type)).toContain("response.content_part.added"); + } finally { t.rewrite.dispose?.(); } +}); + +test("snapshot repair accepts the exact aggregate cap and stops retaining above it", () => { + const t = tracker(); + try { + t.add(0); + t.add(1); + t.delta(0, "x".repeat(LIMIT - 3)); + expect(t.delta(1, "한").map(event => event.type)).toContain("response.content_part.added"); + const overflow = t.delta(1, "!"); + expect(overflow.at(-1)?.delta).toBe("!"); + expect(t.finish().map(event => event.type)).toEqual(["response.completed"]); + } finally { t.rewrite.dispose?.(); } +}); + +test("snapshot repair preserves empty, non-ASCII and unpaired-surrogate deltas", () => { + const t = tracker(); + const deltas = ["한", "\ud83d", "", "\ude00", "\ud83d", "x", "\ude00"]; + try { + t.add(0); + for (const delta of deltas) expect(t.delta(0, delta).at(-1)?.delta).toBe(delta); + const terminal = t.finish(); + expect(terminal.find(event => event.type === "response.output_text.done")?.text).toBe(deltas.join("")); + } finally { t.rewrite.dispose?.(); } +}); From 572467f21d82b30bc2070c48e8753438492ff97d Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 30 Oct 2026 11:29:15 +0000 Subject: [PATCH 47/84] perf(history): move integrity scans off the serving thread (#7972) Validate persisted indexes once per handle and every five minutes in a read-only worker while preserving automatic corruption repair. Bound read retries, keep operational failures nondestructive, and drain old scanners before replacing files. Cover warm reads, real B-tree corruption, analytics, routing evidence, ingestion and worker lifetime races with native Bun tests. Refs lidge-jun/opencodex#7966 --- .../ko/reference/configuration/routing.md | 21 + .../docs/reference/configuration/routing.md | 23 + scripts/test-layout/layout.json | 2 +- src/routing/analytics.ts | 8 +- src/routing/health.ts | 8 +- src/routing/history/build-file.ts | 6 + src/routing/history/indexer.ts | 189 +++++-- src/routing/history/ingest.ts | 2 +- src/routing/history/integrity.ts | 177 ++++++ src/routing/history/rebuild-job.ts | 26 +- src/routing/history/rebuild-protocol.ts | 3 +- src/routing/history/rebuild-worker.ts | 18 + src/routing/history/sqlite-errors.ts | 9 + structure/dashboard-and-usage.md | 12 +- tests/fixtures/test-layout-expected.json | 2 +- tests/usage/request-history-integrity.test.ts | 525 ++++++++++++++++++ 16 files changed, 976 insertions(+), 55 deletions(-) create mode 100644 src/routing/history/integrity.ts create mode 100644 src/routing/history/sqlite-errors.ts create mode 100644 tests/usage/request-history-integrity.test.ts diff --git a/docs-site/src/content/docs/ko/reference/configuration/routing.md b/docs-site/src/content/docs/ko/reference/configuration/routing.md index d19dce2f092..fed5f09dbfc 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/routing.md +++ b/docs-site/src/content/docs/ko/reference/configuration/routing.md @@ -121,6 +121,27 @@ CLI: `ocx route policy list`, `ocx route policy show `, `ocx route policy dr CLI: `ocx logs explain `, `ocx logs rebuild-index`, `ocx logs index-status`. +### 인덱스 검사와 복구 + +기존 기록 인덱스를 처음 열면 읽기 전용 워커에서 무결성을 검사합니다. +기록 조회는 이 초기 검사를 기다리며, 프록시의 요청 처리 스레드를 막지 않습니다. +동기식 라우팅 조회는 검사 중에 busy를 반환하므로 라우팅 프로필의 +`unknownEvidence.health` 정책이 적용됩니다. 작은 인덱스를 인라인으로 재구축한 경우에는 +성공 직후 사용할 수 있으며, 워커에서 재구축한 인덱스는 게시 전에 검사를 마칩니다. + +검사나 재구축에 성공한 뒤 최소 5분이 지나면 다음 조회가 백그라운드 무결성 검사 하나를 +시작합니다. 이미 검증된 인덱스는 검사 중에도 조회할 수 있습니다. 일반 갱신은 스키마, +원본 파일 식별자와 바이트 오프셋을 확인하지만 전체 동기식 검사를 반복하지 않으며, +조회가 없는 동안에는 타이머를 실행하지 않습니다. 데이터베이스 잠금이나 워커 실행 실패 +같은 일시적인 검사 오류는 인덱스를 삭제하지 않고, 30초가 지난 뒤의 조회에서 다시 시도합니다. +초기 검증을 마치지 못한 인덱스는 그동안 사용할 수 없습니다. + +손상이 탐지되면 `usage.jsonl`에서 자동으로 재구축하며, 영향을 받은 비동기 기록·분석 +조회는 복구 후 한 번 재시도합니다. 인덱스를 닫거나 재구축할 때에는 이전 검사 결과를 +폐기하고 검사 워커가 파일을 해제한 뒤 인덱스를 교체합니다. 검사와 복구는 원본 기록을 +다시 쓰지 않습니다. 초기 검사에는 인덱스 크기에 비례하는 시간이 걸리며, +이후 백그라운드 검사는 인덱스를 사용하는 동안에만 실행됩니다. + ## 마이그레이션 `routingProfiles`는 선택적 추가 설정입니다. 기존 설정 파일과 이전 `usage.jsonl` 행은 그대로 읽힙니다. 인덱스는 일회용이며 삭제 시 다음 쿼리에서 `usage.jsonl`로 자동 재구축됩니다. 자동 튜닝은 없습니다. diff --git a/docs-site/src/content/docs/reference/configuration/routing.md b/docs-site/src/content/docs/reference/configuration/routing.md index e518b009789..63a3a0eae1f 100644 --- a/docs-site/src/content/docs/reference/configuration/routing.md +++ b/docs-site/src/content/docs/reference/configuration/routing.md @@ -401,6 +401,29 @@ CLI: `ocx logs history`, `ocx logs get `, `ocx logs explain `, `ocx logs rebuild-index`, `ocx logs index-status`, `ocx route policy list | show | dry-run | evaluate`. +### Index validation and recovery + +An existing history index is checked in a read-only worker when first opened. +History queries wait for that initial check without blocking the proxy's serving +thread. Synchronous routing reads report busy during validation, so routing +profiles apply their `unknownEvidence.health` policy. A successful small inline +rebuild is usable immediately; a worker rebuild is checked before publication. + +After a successful check or rebuild, the next read at least five minutes later +starts one background integrity check. Reads from the verified index continue +during that check. Normal refreshes retain schema, source-identity and offset +validation without a full synchronous scan, and an idle index starts no timer. +Temporary check failures, such as a busy database or unavailable worker, leave +the index intact and retry on a subsequent read after a 30-second backoff. +An index that has not completed initial validation remains unavailable meanwhile. + +Detected corruption triggers automatic reconstruction from `usage.jsonl`, with +one retry for an affected asynchronous history or analytics read. Closing or +rebuilding the index discards old check results and waits for the scanner to +release the file before replacing it. Validation and recovery never rewrite the +canonical ledger. The initial check still takes time proportional to index size; +later background checks run only when the index is used. + ## Migration `routingProfiles` is optional and additive: existing config files load diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 7a7edd47f0a..efa3f4e689d 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1495,7 +1495,7 @@ "repo-hygiene.test.ts": "ci-workflows", "request-decompress.test.ts": "usage", "request-evidence.test.ts": "usage", - "request-history-index.test.ts": "usage", + "request-history-index.test.ts": "usage", "request-history-integrity.test.ts": "usage", "request-log-attempt-identity.test.ts": "usage", "request-log-conversation.test.ts": "usage", "request-log-estimate-cap.test.ts": "usage", diff --git a/src/routing/analytics.ts b/src/routing/analytics.ts index ab919d88433..220a65e70f5 100644 --- a/src/routing/analytics.ts +++ b/src/routing/analytics.ts @@ -13,7 +13,7 @@ import { normalizePersistedUsageRow, type PersistedUsageEntry } from "../usage/log"; import { computeEntryCost } from "../usage/summary"; -import { openRequestHistoryIndex, requestHistoryDb } from "./history/indexer"; +import { withRequestHistoryRead } from "./history/indexer"; export const ANALYTICS_MAX_ROWS = 50_000; /** Default row cap for the management API (full cap remains available via `limit`). */ @@ -168,8 +168,6 @@ export async function computeRoutingAnalytics( filters: RoutingAnalyticsFilters, options: { maxRows?: number } = {}, ): Promise { - await openRequestHistoryIndex(); - const handle = requestHistoryDb(); const maxRows = Math.min( Math.max(1, Math.trunc(options.maxRows ?? ANALYTICS_MAX_ROWS)), ANALYTICS_MAX_ROWS, @@ -189,7 +187,7 @@ export async function computeRoutingAnalytics( if (filters.to !== undefined) add("timestamp <= ?", filters.to); const whereSql = where.length > 0 ? ` WHERE ${where.join(" AND ")}` : ""; - const rows = handle.query( + const rows = await withRequestHistoryRead(handle => handle.query( `SELECT provider, model, api_key_id AS apiKeyId, profile_id AS profileId, profile_revision AS profileRevision, status, duration_ms AS durationMs, first_output_ms AS firstOutputMs, @@ -197,7 +195,7 @@ export async function computeRoutingAnalytics( usage_status AS usageStatus, usage_json AS usageJson, attempt_count AS attemptCount, fallback, row_json AS rowJson FROM requests${whereSql} ORDER BY timestamp DESC LIMIT ?`, - ).all(...values, maxRows + 1) as ScannedRow[]; + ).all(...values, maxRows + 1) as ScannedRow[]); const scanned = rows.slice(0, maxRows); const historyTruncated = rows.length > maxRows; diff --git a/src/routing/health.ts b/src/routing/health.ts index ceab2e1b9a1..af4b20f5e42 100644 --- a/src/routing/health.ts +++ b/src/routing/health.ts @@ -17,7 +17,7 @@ */ import type { OcxConfig } from "../types"; -import { openRequestHistoryIndexSync, requestHistoryDb } from "./history/indexer"; +import { openRequestHistoryIndexSync, reportRequestHistoryCorruption, requestHistoryDb } from "./history/indexer"; import { OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers"; import { getCodexAccountCooldownUntil, @@ -251,9 +251,10 @@ function computeHistoricalHealthEvidence( input: Pick, now: number, ): HistoricalHealthEvidence { + let handle: ReturnType | undefined; try { openRequestHistoryIndexSync(); - const handle = requestHistoryDb(); + handle = requestHistoryDb(); const where: string[] = ["provider = ?", "model = ?", "timestamp >= ?"]; const values: Array = [input.provider, input.model, now - HEALTH_WINDOW_MS]; if (input.accountRef) { @@ -341,7 +342,8 @@ function computeHistoricalHealthEvidence( out.recencyWeight = decayWeight(samples[0]!.timestamp, now); } return out; - } catch { + } catch (error) { + if (handle) reportRequestHistoryCorruption(error, handle); /* index unreadable: evidence stays unknown */ return {}; } diff --git a/src/routing/history/build-file.ts b/src/routing/history/build-file.ts index 772697e990e..de443b549e2 100644 --- a/src/routing/history/build-file.ts +++ b/src/routing/history/build-file.ts @@ -79,6 +79,12 @@ export function buildIndexFile(buildPath: string, reason: string, revision: Usag expected: revision, endOffset: Number(revision.size), }); + // Validate before publication while still off the serving thread. The + // published handle can then be trusted until its periodic worker check. + const check = build.query("PRAGMA quick_check").get() as { quick_check?: string } | undefined; + if (check?.quick_check !== "ok") { + throw Object.assign(new Error("rebuilt history index failed integrity check"), { code: "SQLITE_CORRUPT" }); + } // The serving thread opens the published file in WAL mode; switching here // keeps that first open from rewriting the database header. build.exec("PRAGMA journal_mode = WAL"); diff --git a/src/routing/history/indexer.ts b/src/routing/history/indexer.ts index 342e98fab4b..68413390ab4 100644 --- a/src/routing/history/indexer.ts +++ b/src/routing/history/indexer.ts @@ -3,7 +3,7 @@ * * `usage.jsonl` stays canonical; this module maintains a rebuildable SQLite * projection (ADR-1, ADR-8). On every open/query it verifies schema version, - * file identity, integrity, and byte offset, then appends whatever complete + * file identity and byte offset, then appends whatever complete * JSONL rows arrived since the last index. Missing/corrupt/stale index or a * replaced/truncated source triggers an automatic full rebuild; canonical * history is never touched. @@ -12,6 +12,8 @@ * runs on a worker thread (#6990): the worker writes a private build file and * this thread swaps it in once complete. Async readers wait for it; the * routing-time sync entry never waits and throws `RequestHistoryIndexBusyError`. + * Full integrity scans run on that worker at open and periodically while in + * use, never synchronously on the serving thread (#7966). */ import { Database } from "bun:sqlite"; @@ -48,6 +50,8 @@ import { } from "./ingest"; import { buildFilePath, removeBuildFiles } from "./build-file"; import { RebuildWorkerJob } from "./rebuild-job"; +import { RequestHistoryIntegrityMonitor, type RequestHistoryIntegrityOptions } from "./integrity"; +import { isHistoryIndexCorruption } from "./sqlite-errors"; export { REQUEST_HISTORY_INSERT_BATCH, @@ -138,9 +142,15 @@ class RebuildHandedOff { constructor(readonly rebuild: WorkerRebuild) {} } +/** An initial integrity check, or a scanner draining before index replacement. */ +class IntegrityHandedOff { + constructor(readonly promise: Promise) {} +} + let db: Database | null = null; let dbPath = ""; let openPromise: Promise | null = null; +let manualRebuild: Promise | null = null; let workerRebuild: WorkerRebuild | null = null; let workerFailure: { at: number; error: unknown } | null = null; let workerRebuildMinBytes = REQUEST_HISTORY_WORKER_REBUILD_MIN_BYTES; @@ -148,6 +158,33 @@ let maxAsyncRefreshBuilds = MAX_ASYNC_REFRESH_BUILDS; let buildSequence = 0; // Bumped by closeRequestHistoryIndex, so a rebuild finishing after a close is discarded. let indexLifetime = 0; +let integrity = new RequestHistoryIntegrityMonitor(); +let integrityDrain: Promise | null = null; +let corruptHandle: Database | null = null; +let validatedBuildPath: string | null = null; + +/** Test seam: replace integrity scheduling without leaving a scanner on an old file. */ +export function setRequestHistoryIntegrityOptionsForTests(options?: RequestHistoryIntegrityOptions): void { + closeRequestHistoryIndex(); + integrity = new RequestHistoryIntegrityMonitor(options); +} + +function stopIntegrityScan(): Promise | null { + const pending = integrity.quiesce(); + if (pending && !integrityDrain) { + const drained = pending.finally(() => { + if (integrityDrain === drained) integrityDrain = null; + }); + integrityDrain = drained; + } + return integrityDrain; +} + +/** Never delete/rename a database that a cancelled scanner may still hold on Windows. */ +function prepareIndexReplacement(): void { + const pending = stopIntegrityScan(); + if (pending) throw new IntegrityHandedOff(pending); +} /** Test seam: move the worker threshold; `undefined` restores the default. */ export function setRequestHistoryWorkerRebuildMinBytesForTests(bytes: number | undefined): void { @@ -210,15 +247,6 @@ export function sourceIdentityMatches(dbHandle: Database, revision: UsageLogRevi && storedBirthtimeMs === Number(revision.birthtimeMs); } -function isHealthy(dbHandle: Database): boolean { - try { - const row = dbHandle.query("PRAGMA quick_check").get() as { quick_check?: string } | undefined; - return row?.quick_check === "ok"; - } catch { - return false; - } -} - /** Remove the index file and its WAL side files. */ function removeIndexFiles(path: string): void { // Windows: a partially-opened handle from a failed `new Database` can hold @@ -238,6 +266,7 @@ function removeIndexFiles(path: string): void { } function destroyAndRecreate(path: string, reason: string): Database { + prepareIndexReplacement(); if (db) { try { db.close(); } catch { /* already closed */ } db = null; @@ -249,10 +278,13 @@ function destroyAndRecreate(path: string, reason: string): Database { resetAndCreateSchema(fresh); setMeta(fresh, HISTORY_META_KEYS.lastError, reason); db = fresh; + corruptHandle = null; + validatedBuildPath = null; return fresh; } function openIndexDb(): Database { + if (integrityDrain) throw new IntegrityHandedOff(integrityDrain); const path = indexDbPath(); if (db) return db; let handle: Database | undefined; @@ -270,45 +302,64 @@ function openIndexDb(): Database { setMeta(handle, HISTORY_META_KEYS.builtAtMs, Date.now()); setMeta(handle, HISTORY_META_KEYS.lastError, "created"); } - } catch { + } catch (error) { // A partially-opened handle on a corrupt file can hold the OS lock on // Windows; close it before the destructive recreate. if (handle) { try { handle.close(); } catch { /* already unusable */ } } + if (!isHistoryIndexCorruption(error)) throw error; handle = destroyAndRecreate(path, "unreadable database recreated"); } dbPath = path; db = handle; + if (validatedBuildPath === path) { + integrity.acceptRebuilt(handle, path); + validatedBuildPath = null; + } return handle; } function ensureSchemaAndIdentity(dbHandle: Database): "ready" | "rebuilt" { try { + if (corruptHandle === dbHandle) { + destroyAndRecreate(dbPath, "integrity check failed; index rebuilt"); + return "rebuilt"; + } const storedVersion = metaValue(dbHandle, HISTORY_META_KEYS.schemaVersion); if (storedVersion !== String(HISTORY_SCHEMA_VERSION)) { destroyAndRecreate(dbPath, `schema version ${storedVersion ?? "missing"} -> ${HISTORY_SCHEMA_VERSION}`); return "rebuilt"; } - if (!isHealthy(dbHandle)) { - destroyAndRecreate(dbPath, "integrity check failed; index rebuilt"); - return "rebuilt"; - } const revision = sourceIdentity(); if (!sourceIdentityMatches(dbHandle, revision)) { destroyAndRecreate(dbPath, "source identity changed; index rebuilt"); return "rebuilt"; } + const check = integrity.inspect(dbHandle, dbPath); + if (check.state === "pending") throw new IntegrityHandedOff(check.promise); + if (check.state === "unavailable") { + throw new RequestHistoryIndexBusyError("request-history integrity check unavailable"); + } + if (check.state === "corrupt") { + corruptHandle = dbHandle; + destroyAndRecreate(dbPath, "integrity check failed; index rebuilt"); + return "rebuilt"; + } return "ready"; - } catch { + } catch (error) { + if (!isHistoryIndexCorruption(error)) throw error; // A file that opens but is not actually SQLite (or is mid-corruption) // throws on the first statement; treat it as corrupt and rebuild. + corruptHandle = dbHandle; + integrity.markCorrupt(dbHandle); destroyAndRecreate(dbPath, "index unreadable; rebuilt"); return "rebuilt"; } } function fullRebuild(dbHandle: Database, reason: string): void { + prepareIndexReplacement(); resetAndCreateSchema(dbHandle); setMeta(dbHandle, HISTORY_META_KEYS.lastError, reason); const path = usageLogPath(); @@ -319,6 +370,8 @@ function fullRebuild(dbHandle: Database, reason: string): void { setMeta(dbHandle, HISTORY_META_KEYS.indexedOffset, 0); setMeta(dbHandle, HISTORY_META_KEYS.indexedRows, 0); setMeta(dbHandle, HISTORY_META_KEYS.builtAtMs, Date.now()); + integrity.acceptRebuilt(dbHandle, dbPath); + corruptHandle = null; return; } const inserted = ingestSourceTail(dbHandle, path, 0); @@ -326,6 +379,8 @@ function fullRebuild(dbHandle: Database, reason: string): void { setMeta(dbHandle, HISTORY_META_KEYS.indexedRows, inserted); setMeta(dbHandle, HISTORY_META_KEYS.indexedOffset, Math.max(current.indexedOffset, 0)); setMeta(dbHandle, HISTORY_META_KEYS.builtAtMs, Date.now()); + integrity.acceptRebuilt(dbHandle, dbPath); + corruptHandle = null; } /** @@ -372,12 +427,15 @@ function startWorkerRebuild(reason: string, revision: UsageLogRevision): WorkerR /** Swap a complete worker build in for the current index file. */ function installBuild(buildPath: string, indexPath: string): void { + prepareIndexReplacement(); if (db) { try { db.close(); } catch { /* already closed */ } db = null; } removeIndexFiles(indexPath); renameSync(buildPath, indexPath); + corruptHandle = null; + validatedBuildPath = indexPath; } /** @@ -389,6 +447,7 @@ type RefreshCaller = "routing" | "reader"; /** Full rebuild, on the worker when the ledger is large enough to stall the event loop. */ function rebuildFromLedger(handle: Database, reason: string, caller: RefreshCaller): void { + prepareIndexReplacement(); const revision = sourceIdentity(); if (revision !== null && Number(revision.size) >= workerRebuildMinBytes) { if (workerCoolingDown()) { @@ -405,7 +464,7 @@ function rebuildFromLedger(handle: Database, reason: string, caller: RefreshCall fullRebuild(handle, reason); } -function refreshLockedSync(caller: RefreshCaller): RequestHistoryIndexMeta { +function refreshOnceLockedSync(caller: RefreshCaller): RequestHistoryIndexMeta { if (workerRebuild) throw new RebuildHandedOff(workerRebuild); openIndexDb(); const state = ensureSchemaAndIdentity(db!); @@ -436,27 +495,55 @@ function refreshLockedSync(caller: RefreshCaller): RequestHistoryIndexMeta { return metaFor(handle); } +function refreshLockedSync(caller: RefreshCaller): RequestHistoryIndexMeta { + try { + return refreshOnceLockedSync(caller); + } catch (error) { + // Metadata reads and incremental writes can encounter damage even between + // scheduled checks. Operational failures (busy, locked, I/O, full) propagate. + if (!isHistoryIndexCorruption(error)) throw error; + if (db) { + corruptHandle = db; + integrity.markCorrupt(db); + } + const handle = destroyAndRecreate(dbPath, "SQLite corruption; index rebuilt"); + rebuildFromLedger(handle, "SQLite corruption; index rebuilt", caller); + return metaFor(handle); + } +} + /** * Synchronous refresh for routing-time evidence reads (RI-06+). Never waits * for a worker rebuild: while one runs (this call may start it), throws * `RequestHistoryIndexBusyError`, which health scoring reads as unknown evidence. */ export function openRequestHistoryIndexSync(): RequestHistoryIndexMeta { + if (manualRebuild) throw new RequestHistoryIndexBusyError(); try { return refreshLockedSync("routing"); } catch (error) { - if (error instanceof RebuildHandedOff) throw new RequestHistoryIndexBusyError(); + if (error instanceof RebuildHandedOff || error instanceof IntegrityHandedOff) throw new RequestHistoryIndexBusyError(); throw error; } } -async function refreshWaitingForWorker(): Promise { +async function refreshWaitingForWorker(fromManualRebuild = false): Promise { const lifetime = indexLifetime; for (let builds = 0; ; ) { + if (manualRebuild && !fromManualRebuild) { + await manualRebuild; + if (lifetime !== indexLifetime) throw new RequestHistoryIndexClosedError(); + continue; + } let handedOff: RebuildHandedOff; try { return refreshLockedSync("reader"); } catch (error) { + if (error instanceof IntegrityHandedOff) { + await error.promise; + if (lifetime !== indexLifetime) throw new RequestHistoryIndexClosedError(); + continue; + } if (!(error instanceof RebuildHandedOff)) throw error; handedOff = error; } @@ -480,15 +567,19 @@ async function refreshWaitingForWorker(): Promise { */ export function openRequestHistoryIndex(): Promise { if (!openPromise) { - openPromise = refreshWaitingForWorker().finally(() => { - openPromise = null; + const pending = refreshWaitingForWorker().finally(() => { + if (openPromise === pending) openPromise = null; }); + openPromise = pending; } return openPromise; } export function closeRequestHistoryIndex(): void { indexLifetime += 1; + stopIntegrityScan(); + corruptHandle = null; + validatedBuildPath = null; const rebuild = workerRebuild; workerRebuild = null; workerFailure = null; @@ -499,21 +590,36 @@ export function closeRequestHistoryIndex(): void { db = null; } openPromise = null; + manualRebuild = null; } /** Force a full rebuild from the canonical ledger (CLI / tests). */ -export async function rebuildRequestHistoryIndex(): Promise { +export function rebuildRequestHistoryIndex(): Promise { + if (!manualRebuild) { + const pending = rebuildManually(indexLifetime).finally(() => { + if (manualRebuild === pending) manualRebuild = null; + }); + manualRebuild = pending; + } + return manualRebuild; +} + +async function rebuildManually(lifetime: number): Promise { + const draining = stopIntegrityScan(); + if (draining) await draining; + if (lifetime !== indexLifetime) throw new RequestHistoryIndexClosedError(); while (workerRebuild) await workerRebuild.promise.catch(() => undefined); + if (lifetime !== indexLifetime) throw new RequestHistoryIndexClosedError(); const handle = openIndexDb(); const revision = sourceIdentity(); if (revision !== null && Number(revision.size) >= workerRebuildMinBytes) { workerFailure = null; const rebuild = startWorkerRebuild("manual rebuild requested", revision); if (rebuild) { - const lifetime = indexLifetime; try { await rebuild.promise; - return openRequestHistoryIndex(); + if (lifetime !== indexLifetime) throw new RequestHistoryIndexClosedError(); + return refreshWaitingForWorker(true); } catch (error) { // A close is final; any other worker failure falls back to the inline rebuild below. if (lifetime !== indexLifetime || error instanceof RequestHistoryIndexClosedError) throw error; @@ -575,6 +681,29 @@ function requireDb(): Database { return handle; } +/** Invalidate only the live generation, including errors from routing's synchronous reads. */ +export function reportRequestHistoryCorruption(error: unknown, handle: Database): boolean { + if (!isHistoryIndexCorruption(error)) return false; + if (db === handle) { + corruptHandle = handle; + integrity.markCorrupt(handle); + } + return true; +} + +/** Read-only operations may retry once after repair; ordinary SQLite failures propagate. */ +export async function withRequestHistoryRead(read: (handle: Database, meta: RequestHistoryIndexMeta) => T): Promise { + for (let attempt = 0; ; attempt++) { + const meta = await openRequestHistoryIndex(); + const handle = requireDb(); + try { + return read(handle, meta); + } catch (error) { + if (!reportRequestHistoryCorruption(error, handle) || attempt > 0) throw error; + } + } +} + function hydrateRow(row: HistoryQueryRow | undefined): PersistedUsageEntry | null { if (!row) return null; try { @@ -593,7 +722,6 @@ export async function queryRequestHistory( rawCursor: string | null | undefined, pageSize: number | undefined, ): Promise { - const meta = await openRequestHistoryIndex(); const cursor = decodeHistoryCursor(rawCursor); if (rawCursor !== null && rawCursor !== undefined && cursor === null) { throw new InvalidCursorError(); @@ -602,8 +730,9 @@ export async function queryRequestHistory( Math.max(1, Math.trunc(pageSize ?? REQUEST_HISTORY_DEFAULT_PAGE_SIZE)), REQUEST_HISTORY_MAX_PAGE_SIZE, ); - const handle = requireDb(); - const { rows, fetched } = queryRows(handle, filters, cursor, limit); + const { rows, fetched, meta } = await withRequestHistoryRead((handle, meta) => ({ + ...queryRows(handle, filters, cursor, limit), meta, + })); const hasMore = fetched > limit; const pageRows = rows.slice(0, limit); const entries: PersistedUsageEntry[] = []; @@ -620,11 +749,9 @@ export async function queryRequestHistory( } export async function requestHistoryRowById(requestId: string): Promise { - await openRequestHistoryIndex(); - const handle = requireDb(); - const row = handle.query("SELECT row_json FROM requests WHERE request_id = ?").get(requestId) as + const row = await withRequestHistoryRead(handle => handle.query("SELECT row_json FROM requests WHERE request_id = ?").get(requestId) as | { row_json: string } - | undefined; + | undefined); return hydrateRow(row); } diff --git a/src/routing/history/ingest.ts b/src/routing/history/ingest.ts index 16a98d726b9..429fe4dc974 100644 --- a/src/routing/history/ingest.ts +++ b/src/routing/history/ingest.ts @@ -25,7 +25,7 @@ export const REQUEST_HISTORY_MAX_RECORD_BYTES = 1024 * 1024; // One small observation per live database handle, never retained ledger bytes. An // unterminated tail cannot advance indexedOffset, but need not be reread until its -// exact file revision changes. The indexer's schema/identity/quick_check gates still run. +// exact file revision changes. Schema/identity gates and lifecycle integrity checks still apply. const tailObservations = new WeakMap; + +export interface RequestHistoryIntegrityOptions { + createJob?: () => IntegrityJob; + now?: () => number; + intervalMs?: number; + failureCooldownMs?: number; +} + +export type RequestHistoryIntegrityState = + | { state: "ready" } + | { state: "corrupt" } + | { state: "unavailable"; error: unknown } + // Never rejects, including on cancellation or check failure. Completion + // proves the scanner exited; inspect again to obtain its verdict or error. + | { state: "pending"; promise: Promise }; + +interface Generation { + handle: object; + path: string; + verified: boolean; + corrupt: boolean; + nextCheckAt: number; + failure: { error: unknown } | null; +} + +interface ActiveCheck { + generation: Generation; + cancelled: boolean; + cancel: () => void; + promise: Promise; +} + +type CheckOutcome = + | { kind: "checked"; healthy: boolean } + | { kind: "failed"; error: unknown } + | { kind: "cancelled" }; + +export class RequestHistoryIntegrityMonitor { + private readonly createJob: () => IntegrityJob; + private readonly now: () => number; + private readonly intervalMs: number; + private readonly failureCooldownMs: number; + private current: Generation | null = null; + private active: ActiveCheck | null = null; + + constructor(options: RequestHistoryIntegrityOptions = {}) { + this.createJob = options.createJob ?? (() => new RebuildWorkerJob()); + this.now = options.now ?? Date.now; + this.intervalMs = options.intervalMs ?? REQUEST_HISTORY_INTEGRITY_INTERVAL_MS; + this.failureCooldownMs = options.failureCooldownMs ?? REQUEST_HISTORY_INTEGRITY_FAILURE_COOLDOWN_MS; + } + + inspect(handle: object, path: string): RequestHistoryIntegrityState { + if (this.current?.handle !== handle || this.current.path !== path) { + this.quiesce(); + this.current = { handle, path, verified: false, corrupt: false, nextCheckAt: -Infinity, failure: null }; + } + const generation = this.current; + if (generation.corrupt) return { state: "corrupt" }; + if (this.active) { + if (this.active.generation === generation && !this.active.cancelled && generation.verified) { + return { state: "ready" }; + } + return { state: "pending", promise: this.active.promise }; + } + if (this.now() >= generation.nextCheckAt) { + const check = this.start(generation); + if (check && !generation.verified) return { state: "pending", promise: check.promise }; + } + if (generation.verified) return { state: "ready" }; + return { state: "unavailable", error: generation.failure?.error ?? new Error("history integrity check unavailable") }; + } + + /** A statement detected corruption; an in-flight healthy verdict cannot erase it. */ + markCorrupt(handle: object): void { + if (this.current?.handle === handle) this.current.corrupt = true; + } + + /** + * Accept a newly reconstructed projection after successful inline ingest or + * validation of a private worker build. Existing persisted databases must + * use inspect instead. The caller drains scans before rebuilding/swapping. + */ + acceptRebuilt(handle: object, path: string): void { + if (this.active) throw new Error("history integrity worker must exit before accepting a rebuilt index"); + this.current = { + handle, path, verified: true, corrupt: false, + nextCheckAt: this.now() + this.intervalMs, failure: null, + }; + } + + /** + * Invalidate and stop the current check. Null means the index can be replaced + * now; otherwise await the returned barrier before deleting or renaming it. + * The barrier survives reset/reopen and resolves only after actual worker + * exit, including Windows' OS-thread settling, not the bounded disposal wait. + */ + quiesce(): Promise | null { + this.current = null; + this.active?.cancel(); + return this.active?.promise ?? null; + } + + reset(): void { + this.quiesce(); + } + + private start(generation: Generation): ActiveCheck | null { + let job: IntegrityJob; + try { + job = this.createJob(); + } catch (error) { + this.recordFailure(generation, error); + return null; + } + let cancel!: () => void; + const cancelled = new Promise(resolve => { cancel = () => resolve({ kind: "cancelled" }); }); + const check: ActiveCheck = { + generation, + cancelled: false, + cancel: () => { check.cancelled = true; cancel(); }, + promise: Promise.resolve(), + }; + this.active = check; + check.promise = this.run(check, job, cancelled); + return check; + } + + private recordFailure(generation: Generation, error: unknown): void { + generation.failure = { error: error ?? new Error("history integrity check failed") }; + generation.nextCheckAt = this.now() + this.failureCooldownMs; + } + + private async run(check: ActiveCheck, job: IntegrityJob, cancelled: Promise): Promise { + const observed = Promise.resolve().then(async (): Promise => { + if (check.cancelled) return { kind: "cancelled" }; + try { + return { kind: "checked", healthy: await job.check(check.generation.path) }; + } catch (error) { + return { kind: "failed", error }; + } + }); + const outcome = await Promise.race([observed, cancelled]); + await new Promise(resolve => { + // afterExit may return at its disposal timeout, before native I/O lets + // the worker exit. Only its callback releases the file replacement gate. + void job.afterExit(resolve).catch(() => undefined); + }); + const generation = check.generation; + if (this.current === generation && !check.cancelled && !generation.corrupt) { + if (outcome.kind === "checked") { + generation.verified = outcome.healthy; + generation.corrupt = !outcome.healthy; + generation.failure = null; + generation.nextCheckAt = this.now() + this.intervalMs; + } else if (outcome.kind === "failed") { + this.recordFailure(generation, outcome.error); + } + } + if (this.active === check) this.active = null; + } +} diff --git a/src/routing/history/rebuild-job.ts b/src/routing/history/rebuild-job.ts index 18467701835..e98e2050aec 100644 --- a/src/routing/history/rebuild-job.ts +++ b/src/routing/history/rebuild-job.ts @@ -13,6 +13,7 @@ import { RequestHistoryIndexStaleError } from "./ingest"; import type { RebuildWorkerReply, RebuildWorkerRequest } from "./rebuild-protocol"; const CLOSE_TIMEOUT_MS = 5_000; +type WorkerSuccess = Exclude; function delay(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); @@ -34,7 +35,7 @@ function errorFromReply(reply: Extract): export class RebuildWorkerJob { private readonly worker: Worker; - private pending: { resolve: (rows: number) => void; reject: (error: unknown) => void } | null = null; + private pending: { resolve: (reply: WorkerSuccess) => void; reject: (error: unknown) => void } | null = null; private exited = false; private exitedCleanly = false; private disposal: Promise | null = null; @@ -49,8 +50,8 @@ export class RebuildWorkerJob { this.pending = null; if (!pending) return; const reply = event.data; - if (reply.type === "built") pending.resolve(reply.rows); - else pending.reject(errorFromReply(reply)); + if (reply.type === "error") pending.reject(errorFromReply(reply)); + else pending.resolve(reply); }); this.worker.addEventListener("error", (event: Event) => { this.fail(new Error((event as ErrorEvent).message || "request-history rebuild worker failed")); @@ -68,16 +69,29 @@ export class RebuildWorkerJob { pending?.reject(error); } - /** Build the complete private file; resolves with the ingested row count. */ - build(indexPath: string, buildPath: string, reason: string, revision: UsageLogRevision): Promise { + private send(request: RebuildWorkerRequest): Promise { if (this.exited || this.disposal) return Promise.reject(new Error("request-history rebuild worker is not running")); if (this.pending) return Promise.reject(new Error("request-history rebuild worker is busy")); return new Promise((resolve, reject) => { this.pending = { resolve, reject }; - this.worker.postMessage({ type: "build", indexPath, buildPath, reason, revision } satisfies RebuildWorkerRequest); + this.worker.postMessage(request); }); } + /** Build the complete private file; resolves with the ingested row count. */ + async build(indexPath: string, buildPath: string, reason: string, revision: UsageLogRevision): Promise { + const reply = await this.send({ type: "build", indexPath, buildPath, reason, revision }); + if (reply.type !== "built") throw new Error("unexpected request-history build reply"); + return reply.rows; + } + + /** Scan a read-only connection off-loop; operational errors are not corruption. */ + async check(indexPath: string): Promise { + const reply = await this.send({ type: "check", indexPath }); + if (reply.type !== "checked") throw new Error("unexpected request-history integrity reply"); + return reply.healthy; + } + /** Stop the thread; waits a bounded time for it to exit. */ dispose(): Promise { this.disposal ??= this.shutDown(); diff --git a/src/routing/history/rebuild-protocol.ts b/src/routing/history/rebuild-protocol.ts index eb5999d29a5..d0dde97af6e 100644 --- a/src/routing/history/rebuild-protocol.ts +++ b/src/routing/history/rebuild-protocol.ts @@ -8,8 +8,9 @@ export type RebuildWorkerRequest = { buildPath: string; reason: string; revision: UsageLogRevision; -}; +} | { type: "check"; indexPath: string }; export type RebuildWorkerReply = | { type: "built"; rows: number } + | { type: "checked"; healthy: boolean } | { type: "error"; name: string; message: string }; diff --git a/src/routing/history/rebuild-worker.ts b/src/routing/history/rebuild-worker.ts index 6ee44b081d8..a636ed4d93e 100644 --- a/src/routing/history/rebuild-worker.ts +++ b/src/routing/history/rebuild-worker.ts @@ -8,12 +8,30 @@ * closes it. The serving thread then swaps the finished file in. */ +import { Database } from "bun:sqlite"; import { buildIndexFile, removeAbandonedBuilds } from "./build-file"; +import { isHistoryIndexCorruption } from "./sqlite-errors"; import type { RebuildWorkerReply, RebuildWorkerRequest } from "./rebuild-protocol"; declare const self: Worker; function handle(request: RebuildWorkerRequest): RebuildWorkerReply { + if (request.type === "check") { + let handle: Database | undefined; + try { + handle = new Database(request.indexPath, { readonly: true }); + handle.exec("PRAGMA busy_timeout = 5000"); + const row = handle.query("PRAGMA quick_check").get() as { quick_check?: string } | undefined; + return { type: "checked", healthy: row?.quick_check === "ok" }; + } catch (error) { + if (isHistoryIndexCorruption(error)) return { type: "checked", healthy: false }; + throw error; + } finally { + // Release the live index before posting a verdict: the serving thread + // may immediately replace it, including on Windows. + handle?.close(); + } + } removeAbandonedBuilds(request.indexPath); const rows = buildIndexFile(request.buildPath, request.reason, request.revision); return { type: "built", rows }; diff --git a/src/routing/history/sqlite-errors.ts b/src/routing/history/sqlite-errors.ts new file mode 100644 index 00000000000..9e8101954d8 --- /dev/null +++ b/src/routing/history/sqlite-errors.ts @@ -0,0 +1,9 @@ +/** Only structural SQLite damage justifies discarding the derived index. */ +export function isHistoryIndexCorruption(error: unknown): boolean { + if (!error || typeof error !== "object") return false; + const { code, errno } = error as { code?: unknown; errno?: unknown }; + // Bun exposes extended result codes too; the low byte is the primary code. + if (typeof errno === "number" && ((errno & 0xff) === 11 || (errno & 0xff) === 26)) return true; + return typeof code === "string" + && /^(SQLITE_CORRUPT(?:_[A-Z]+)?|SQLITE_NOTADB)$/.test(code); +} diff --git a/structure/dashboard-and-usage.md b/structure/dashboard-and-usage.md index 084f60d1157..960bbdc128c 100644 --- a/structure/dashboard-and-usage.md +++ b/structure/dashboard-and-usage.md @@ -276,12 +276,12 @@ first two is the loss signal. Provider debug formats one ring line per finalized counts and writes no second record. `GET /api/usage?failures=1` groups failed rows by a versioned fingerprint over closed vocabularies only, rebuilt through the same cooperative scanner and inheriting its bounds, so deleting a ledger row removes it from the grouping. -`usageLedgerMaxBytes` is unset by default; when set, an append that crosses it publishes the newest -whole rows byte for byte through the shared atomic writer, refuses the rename unless the source is -the exact revision that was copied, and then discards the Logs ring, the retained aggregates and the -request-history index so no surface serves rows the ledger no longer has. That index (`src/routing/history/`) rebuilds a ledger of at least `REQUEST_HISTORY_WORKER_REBUILD_MIN_BYTES` (1 MiB) on a worker thread (#6990): `rebuild-worker.ts`, driven by `rebuild-job.ts`, ingests the ledger snapshot the refresh recorded into a private `.rebuild--` file through the same `ingest.ts` the inline path uses, switches it to WAL and closes it; the serving thread then closes the old index and renames the build over it. Async readers wait for the build; the routing-time sync entry never does and throws `RequestHistoryIndexBusyError`, which health scoring reads as unknown evidence. Health scoring (`src/routing/health.ts`) counts physical sends only: a request answered locally or refused before any send is no evidence for any target, and a row's terminal status and close reason score its last physical send, so a 200 stream cut mid-flight after a failover counts against the target that dropped it (`tests/server/health-scoring.test.ts`). A close discards the build, rejects waiting readers with `RequestHistoryIndexClosedError`, and removes the build files only after the worker thread exits. For 30 s after a worker build fails (other than on a replaced ledger), async readers and manual rebuilds rebuild inline instead, while routing-time reads keep reporting busy. -Ledger revisions in `src/usage/log.ts` capture device/inode identifiers from bigint descriptor stats as exact decimal strings; history metadata, ingestion, cooperative scans and retention compare them without narrowing to Numbers, preserving distinct NTFS identifiers above 2^53. An older index containing a rounded identifier fails the identity check and rebuilds once. `src/usage/summary.ts` turns that file into the `/api/usage` shape — totals, daily zero-filled -grid, model and provider breakdowns, and `measured / reported / unreported / unsupported / estimated` counts. Each scope also aggregates end-to-end output throughput (#6309): measured output tokens and wall-clock `durationMs` are summed over attempts reporting both, exposing `throughputTokensPerSec` — a token-sum-over-duration-sum, never a mean of per-request rates. `throughputSamples` counts qualifying attempts (legacy rows without attempts contribute one); nonpositive or nonfinite tokens/timing are excluded. The Usage summary and Models/Providers tables display the rate and sample basis, or unavailable when none qualify. +`usageLedgerMaxBytes` is unset by default; when set, an append that crosses it publishes the newest whole rows byte for byte through the shared atomic writer, refuses the rename unless the source is the exact revision that was copied, and then discards the Logs ring, the retained aggregates and the request-history index so no surface serves rows the ledger no longer has. +That index (`src/routing/history/`) rebuilds a ledger of at least `REQUEST_HISTORY_WORKER_REBUILD_MIN_BYTES` (1 MiB) on a worker thread (#6990): `rebuild-worker.ts`, driven by `rebuild-job.ts`, ingests the ledger snapshot the refresh recorded into a private `.rebuild--` file through the same `ingest.ts` the inline path uses, switches it to WAL and closes it; the serving thread then closes the old index and renames the build over it. Async readers wait for the build; the routing-time sync entry never does and throws `RequestHistoryIndexBusyError`, which health scoring reads as unknown evidence. Health scoring (`src/routing/health.ts`) counts physical sends only: a request answered locally or refused before any send is no evidence for any target, and a row's terminal status and close reason score its last physical send, so a 200 stream cut mid-flight after a failover counts against the target that dropped it (`tests/server/health-scoring.test.ts`). A close discards the build, rejects waiting readers with `RequestHistoryIndexClosedError`, and removes the build files only after the worker thread exits. For 30 s after a worker build fails (other than on a replaced ledger), async readers and manual rebuilds rebuild inline instead, while routing-time reads keep reporting busy. + +`src/routing/history/integrity.ts` validates an existing persisted index on first use with a read-only worker `PRAGMA quick_check` (#7966); serving refreshes retain schema/source/offset checks without a synchronous integrity scan. Async readers await initial validation; synchronous routing reports busy/unknown meanwhile. A successful fresh inline reconstruction is immediately usable, and a private worker build is integrity-checked before publication. After validation or reconstruction, the next refresh at least five minutes later starts one background check; verified reads remain available during it, and idle indexes start no timer. Operational check failures leave the index intact and delay retry for 30 s; an unverified index remains unavailable. A corrupt verdict or `SQLITE_CORRUPT`/`SQLITE_NOTADB` invalidates the live handle and enters the existing rebuild path; async history and analytics reads retry once after recovery. Close/rebuild discards stale verdicts and drains scanners through actual thread exit before index replacement. Neither validation nor recovery rewrites canonical `usage.jsonl`. `tests/usage/request-history-integrity.test.ts` covers scan scheduling, corruption recovery and lifecycle isolation. + +Ledger revisions in `src/usage/log.ts` capture device/inode identifiers from bigint descriptor stats as exact decimal strings; history metadata, ingestion, cooperative scans and retention compare them without narrowing to Numbers, preserving distinct NTFS identifiers above 2^53. An older index containing a rounded identifier fails the identity check and rebuilds once. `src/usage/summary.ts` turns that file into the `/api/usage` shape — totals, daily zero-filled grid, model and provider breakdowns, and `measured / reported / unreported / unsupported / estimated` counts. Each scope also aggregates end-to-end output throughput (#6309): measured output tokens and wall-clock `durationMs` are summed over attempts reporting both, exposing `throughputTokensPerSec` — a token-sum-over-duration-sum, never a mean of per-request rates. `throughputSamples` counts qualifying attempts (legacy rows without attempts contribute one); nonpositive or nonfinite tokens/timing are excluded. The Usage summary and Models/Providers tables display the rate and sample basis, or unavailable when none qualify. All three throughput displays use the same finite-number guard as their tooltip; malformed or nonfinite rates are unavailable rather than formatted. The management route scans the ledger from its beginning in fixed 1 MiB chunks on a cold rebuild, then retains compact numeric aggregate state and resumes at the last verified LF for diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 5826dcf9196..e01f098a460 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1200,7 +1200,7 @@ "fixture-teardown-helper.test.ts": "ci-workflows", "replay-refusal-parity.test.ts": "server", "fixture-windows-runtime-preload.test.ts": "ci-workflows", "repo-hygiene.test.ts": "ci-workflows", "request-decompress.test.ts": "usage", - "request-evidence.test.ts": "usage", "request-history-index.test.ts": "usage", + "request-evidence.test.ts": "usage", "request-history-index.test.ts": "usage", "request-history-integrity.test.ts": "usage", "request-log-attempt-identity.test.ts": "usage", "request-log-conversation.test.ts": "usage", "request-log-estimate-cap.test.ts": "usage", "request-log-jev.test.ts": "usage", "request-log-nonstream.test.ts": "usage", "request-log-protocol-trace.test.ts": "usage", diff --git a/tests/usage/request-history-integrity.test.ts b/tests/usage/request-history-integrity.test.ts new file mode 100644 index 00000000000..3c824e918db --- /dev/null +++ b/tests/usage/request-history-integrity.test.ts @@ -0,0 +1,525 @@ +/** Regression coverage for #7966: integrity scans never run on the serving thread. */ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { closeSync, mkdtempSync, openSync, readFileSync, renameSync, writeFileSync, writeSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + appendUsageEntry, + resetUsageReadCacheForTests, + usageLogPath, + type PersistedUsageEntry, +} from "../../src/usage/log"; +import { + closeRequestHistoryIndex, + openRequestHistoryIndex, + openRequestHistoryIndexSync, + queryRequestHistory, + rebuildRequestHistoryIndex, + requestHistoryDb, + requestHistoryIndexStatus, + requestHistoryRowById, + RequestHistoryIndexBusyError, + RequestHistoryIndexClosedError, + setRequestHistoryIntegrityOptionsForTests, + setRequestHistoryRebuildJobFactoryForTests, + setRequestHistoryWorkerRebuildMinBytesForTests, + withRequestHistoryRead, +} from "../../src/routing/history/indexer"; +import { HISTORY_META_KEYS, HISTORY_SCHEMA_VERSION } from "../../src/routing/history/schema"; +import { RequestHistoryIntegrityMonitor } from "../../src/routing/history/integrity"; +import { isHistoryIndexCorruption } from "../../src/routing/history/sqlite-errors"; +import { computeRoutingAnalytics } from "../../src/routing/analytics"; +import { clearHealthHistoryCacheForTests, healthEvidenceForCandidate } from "../../src/routing/health"; +import { flushConfigDirHardeningForTests } from "../../src/config/paths"; +import { flushWindowsSecretAclReapsBeforeRemoval } from "../../src/lib/windows-secret-acl"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +let testDir = ""; +let previousHome: string | undefined; + +function entry(requestId: string, timestamp: number): PersistedUsageEntry { + return { requestId, timestamp, provider: "a", model: "m1", status: 200, durationMs: 10, usageStatus: "reported" }; +} + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + testDir = mkdtempSync(join(tmpdir(), "ocx-integrity-")); + process.env.OPENCODEX_HOME = testDir; + resetUsageReadCacheForTests(); + clearHealthHistoryCacheForTests(); + closeRequestHistoryIndex(); + setRequestHistoryIntegrityOptionsForTests(undefined); +}); + +afterEach(async () => { + clearHealthHistoryCacheForTests(); + closeRequestHistoryIndex(); + setRequestHistoryIntegrityOptionsForTests(undefined); + setRequestHistoryRebuildJobFactoryForTests(undefined); + setRequestHistoryWorkerRebuildMinBytesForTests(undefined); + await flushConfigDirHardeningForTests(); + await flushWindowsSecretAclReapsBeforeRemoval(testDir); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(testDir); +}); + +async function existingIndex(): Promise { + for (let index = 0; index < 4; index++) appendUsageEntry(entry(`req-${index}`, 1000 + index)); + const meta = await rebuildRequestHistoryIndex(); + closeRequestHistoryIndex(); + return meta.dbPath; +} + +interface Check { + path: string; + resolve(healthy: boolean): void; + reject(error: unknown): void; +} + +/** A pending check is controlled by the test; release is immediate once it settles or is disposed. */ +function controlledChecks() { + const checks: Check[] = []; + let clock = 0; + setRequestHistoryIntegrityOptionsForTests({ + now: () => clock, + intervalMs: 1000, + failureCooldownMs: 100, + createJob: () => ({ + check: (path: string) => new Promise((resolve, reject) => { checks.push({ path, resolve, reject }); }), + dispose: async () => {}, + afterExit: async (cleanup: () => void) => { cleanup(); }, + }), + }); + return { checks, advance: (ms: number) => { clock += ms; } }; +} + +async function until(predicate: () => boolean): Promise { + for (let attempt = 0; attempt < 200 && !predicate(); attempt++) await Bun.sleep(0); + expect(predicate()).toBe(true); +} + +async function verifyInitial(checks: Check[]): Promise { + const ready = openRequestHistoryIndex(); + await until(() => checks.length === 1); + checks[0]!.resolve(true); + await ready; +} + +function corruptModelIndex(handle: Database, path: string): void { + const { rootpage } = handle.query("SELECT rootpage FROM sqlite_master WHERE name = 'idx_requests_model'").get() as { rootpage: number }; + const { page_size: pageSize } = handle.query("PRAGMA page_size").get() as { page_size: number }; + expect(rootpage).toBeGreaterThan(1); + const checkpoint = handle.query("PRAGMA wal_checkpoint(TRUNCATE)").get() as { busy: number }; + expect(checkpoint.busy).toBe(0); + // Keep SQLite's header, schema and request table intact, but invalidate a + // separate index B-tree that the default timestamp-ordered page does not visit. + const fd = openSync(path, "r+"); + try { writeSync(fd, Buffer.from([0]), 0, 1, (rootpage - 1) * pageSize); } + finally { closeSync(fd); } + handle.exec("PRAGMA shrink_memory"); +} + +describe("request-history integrity lifecycle (#7966)", () => { + test("real worker checks leave repeated page, row, status and routing reads free of full serving-thread scans", async () => { + await existingIndex(); + const query = spyOn(Database.prototype, "query"); + const prepare = spyOn(Database.prototype, "prepare"); + const exec = spyOn(Database.prototype, "exec"); + try { + for (let repeat = 0; repeat < 4; repeat++) { + const page = await queryRequestHistory({}, undefined, 1); + expect(page.rows.map(row => row.requestId)).toEqual(["req-3"]); + expect((await requestHistoryRowById("req-1"))?.requestId).toBe("req-1"); + expect((await requestHistoryIndexStatus()).indexedRows).toBe(4); + expect(openRequestHistoryIndexSync().indexedRows).toBe(4); + } + appendUsageEntry(entry("tail", 2000)); + expect((await queryRequestHistory({}, undefined, 1)).rows[0]?.requestId).toBe("tail"); + const fullChecks = [...query.mock.calls, ...prepare.mock.calls, ...exec.mock.calls] + .filter(([sql]) => /\bPRAGMA\s+(?:quick_check|integrity_check)\b/i.test(String(sql))); + expect(fullChecks).toEqual([]); + } finally { + query.mockRestore(); + prepare.mockRestore(); + exec.mockRestore(); + } + }); + + test("existing-index validation is single-flight and routing never waits for the first check", async () => { + const path = await existingIndex(); + const { checks } = controlledChecks(); + let finished = false; + const page = queryRequestHistory({}, undefined, 1).then(value => { finished = true; return value; }); + const row = requestHistoryRowById("req-1"); + const status = requestHistoryIndexStatus(); + await until(() => checks.length === 1); + expect(checks[0]!.path).toBe(path); + expect(finished).toBe(false); + expect(() => openRequestHistoryIndexSync()).toThrow(RequestHistoryIndexBusyError); + expect(checks).toHaveLength(1); + checks[0]!.resolve(true); + const [result, found, meta] = await Promise.all([page, row, status]); + expect(result.rows[0]?.requestId).toBe("req-3"); + expect(found?.requestId).toBe("req-1"); + expect(meta.indexedRows).toBe(4); + expect(openRequestHistoryIndexSync().indexedRows).toBe(4); + expect(checks).toHaveLength(1); + }); + + test("periodic checks are single-flight and do not delay a verified index's reads", async () => { + await existingIndex(); + const { checks, advance } = controlledChecks(); + await verifyInitial(checks); + advance(999); + await queryRequestHistory({}, undefined, 1); + expect(checks).toHaveLength(1); + advance(1); + expect((await queryRequestHistory({}, undefined, 1)).rows[0]?.requestId).toBe("req-3"); + await until(() => checks.length === 2); + for (let repeat = 0; repeat < 3; repeat++) { + expect((await requestHistoryIndexStatus()).indexedRows).toBe(4); + expect(openRequestHistoryIndexSync().indexedRows).toBe(4); + } + expect(checks).toHaveLength(2); + checks[1]!.resolve(true); + await Bun.sleep(0); + }); + + test("a transient initial check failure preserves the database and retries only after cooldown", async () => { + await existingIndex(); + const canonical = readFileSync(usageLogPath()); + const { checks, advance } = controlledChecks(); + const first = openRequestHistoryIndex(); + const failure = first.catch((error: unknown) => error); + await until(() => checks.length === 1); + const handle = requestHistoryDb(); + checks[0]!.reject(Object.assign(new Error("database is locked"), { code: "SQLITE_BUSY" })); + expect(await failure).toBeInstanceOf(RequestHistoryIndexBusyError); + for (let repeat = 0; repeat < 3; repeat++) { + await expect(openRequestHistoryIndex()).rejects.toBeInstanceOf(RequestHistoryIndexBusyError); + expect(() => openRequestHistoryIndexSync()).toThrow(RequestHistoryIndexBusyError); + } + expect(requestHistoryDb()).toBe(handle); + expect(checks).toHaveLength(1); + expect(handle.query("SELECT count(*) AS n FROM requests").get()).toEqual({ n: 4 }); + advance(100); + const retry = openRequestHistoryIndex(); + await until(() => checks.length === 2); + checks[1]!.resolve(true); + expect((await retry).indexedRows).toBe(4); + expect(requestHistoryDb()).toBe(handle); + expect(readFileSync(usageLogPath())).toEqual(canonical); + }); + + test("a background check failure leaves verified reads available without a worker retry storm", async () => { + await existingIndex(); + const { checks, advance } = controlledChecks(); + await verifyInitial(checks); + const handle = requestHistoryDb(); + advance(1000); + await openRequestHistoryIndex(); + await until(() => checks.length === 2); + checks[1]!.reject(new Error("worker exited")); + await Bun.sleep(0); + for (let repeat = 0; repeat < 3; repeat++) expect((await openRequestHistoryIndex()).indexedRows).toBe(4); + expect(checks).toHaveLength(2); + expect(requestHistoryDb()).toBe(handle); + advance(100); + await openRequestHistoryIndex(); + await until(() => checks.length === 3); + checks[2]!.resolve(true); + await Bun.sleep(0); + }); + + test("a late corrupt verdict from a closed lifetime cannot rebuild the reopened index", async () => { + await existingIndex(); + const { checks } = controlledChecks(); + const first = openRequestHistoryIndex(); + const closed = first.catch((error: unknown) => error); + await until(() => checks.length === 1); + closeRequestHistoryIndex(); + expect(await closed).toBeInstanceOf(RequestHistoryIndexClosedError); + const next = openRequestHistoryIndex(); + await until(() => checks.length === 2); + const reopened = requestHistoryDb(); + checks[0]!.resolve(false); + checks[1]!.resolve(true); + expect((await next).indexedRows).toBe(4); + expect(requestHistoryDb()).toBe(reopened); + expect((await queryRequestHistory({}, undefined, 10)).rows).toHaveLength(4); + expect(checks).toHaveLength(2); + }); + + test("a manual rebuild shares one flight and releases readers waiting on an initial scan", async () => { + await existingIndex(); + const { checks } = controlledChecks(); + const reader = openRequestHistoryIndex(); + await until(() => checks.length === 1); + setRequestHistoryWorkerRebuildMinBytesForTests(0); + const rebuild = rebuildRequestHistoryIndex(); + expect(rebuildRequestHistoryIndex()).toBe(rebuild); + expect(() => openRequestHistoryIndexSync()).toThrow(RequestHistoryIndexBusyError); + const [readMeta, rebuildMeta] = await Promise.all([reader, rebuild]); + expect(readMeta.indexedRows).toBe(4); + expect(rebuildMeta.indexedRows).toBe(4); + checks[0]!.resolve(false); + await Bun.sleep(0); + expect(openRequestHistoryIndexSync().indexedRows).toBe(4); + expect(checks).toHaveLength(1); + }); + + test("close prevents a manual rebuild waiting on an existing worker from reopening the index", async () => { + appendUsageEntry(entry("only", 1000)); + setRequestHistoryWorkerRebuildMinBytesForTests(0); + let builds = 0; + let rejectBuild: ((error: unknown) => void) | undefined; + setRequestHistoryRebuildJobFactoryForTests(() => ({ + build: () => new Promise((_resolve, reject) => { builds += 1; rejectBuild = reject; }), + dispose: async () => { rejectBuild?.(new Error("worker stopped")); }, + afterExit: async (cleanup: () => void) => { cleanup(); }, + })); + const reader = openRequestHistoryIndex().catch((error: unknown) => error); + await until(() => builds === 1); + const manual = rebuildRequestHistoryIndex().catch((error: unknown) => error); + closeRequestHistoryIndex(); + expect(await reader).toBeInstanceOf(RequestHistoryIndexClosedError); + expect(await manual).toBeInstanceOf(RequestHistoryIndexClosedError); + expect(builds).toBe(1); + expect(() => requestHistoryDb()).toThrow("not open"); + }); + + test("source replacement cancels a background scan and reprojects only the replacement ledger", async () => { + await existingIndex(); + const { checks, advance } = controlledChecks(); + await verifyInitial(checks); + advance(1000); + await openRequestHistoryIndex(); + await until(() => checks.length === 2); + const path = usageLogPath(); + const replacement = JSON.stringify(entry("replacement", 5000)) + "\n"; + writeFileSync(`${path}.next`, replacement); + renameSync(`${path}.next`, path); + const page = await queryRequestHistory({}, undefined, 10); + expect(page.rows.map(row => row.requestId)).toEqual(["replacement"]); + const rebuilt = requestHistoryDb(); + checks[1]!.resolve(false); + await Bun.sleep(0); + expect((await requestHistoryIndexStatus()).indexedRows).toBe(1); + expect(requestHistoryDb()).toBe(rebuilt); + expect(readFileSync(path, "utf8")).toBe(replacement); + expect(checks).toHaveLength(2); + }); + + test("a periodic corrupt verdict rebuilds the projection on the next read", async () => { + await existingIndex(); + const canonical = readFileSync(usageLogPath()); + const { checks, advance } = controlledChecks(); + await verifyInitial(checks); + const handle = requestHistoryDb(); + advance(1000); + await openRequestHistoryIndex(); + await until(() => checks.length === 2); + checks[1]!.resolve(false); + await Bun.sleep(0); + expect((await queryRequestHistory({}, undefined, 10)).rows).toHaveLength(4); + expect(requestHistoryDb()).not.toBe(handle); + expect(checks).toHaveLength(2); + expect(readFileSync(usageLogPath())).toEqual(canonical); + }); + + test("worker spawn failures observe cooldown and never delete an unverified index", async () => { + await existingIndex(); + let clock = 0; + let spawns = 0; + setRequestHistoryIntegrityOptionsForTests({ + now: () => clock, + failureCooldownMs: 100, + createJob: () => { spawns += 1; throw new Error("worker unavailable"); }, + }); + await expect(openRequestHistoryIndex()).rejects.toBeInstanceOf(RequestHistoryIndexBusyError); + const handle = requestHistoryDb(); + await expect(openRequestHistoryIndex()).rejects.toBeInstanceOf(RequestHistoryIndexBusyError); + expect(spawns).toBe(1); + clock = 100; + await expect(openRequestHistoryIndex()).rejects.toBeInstanceOf(RequestHistoryIndexBusyError); + expect(spawns).toBe(2); + expect(requestHistoryDb()).toBe(handle); + expect(handle.query("SELECT count(*) AS n FROM requests").get()).toEqual({ n: 4 }); + }); + + test("the real worker detects an unqueried damaged B-tree and rebuilds from untouched canonical rows", async () => { + const path = await existingIndex(); + const canonical = readFileSync(usageLogPath()); + const damaged = new Database(path); + try { + corruptModelIndex(damaged, path); + expect(damaged.query("SELECT value FROM schema_meta WHERE key = ?").get(HISTORY_META_KEYS.schemaVersion)) + .toEqual({ value: String(HISTORY_SCHEMA_VERSION) }); + expect(damaged.query("SELECT request_id FROM requests ORDER BY timestamp DESC, request_id DESC LIMIT 1").get()) + .toEqual({ request_id: "req-3" }); + expect(() => damaged.query("SELECT request_id FROM requests INDEXED BY idx_requests_model WHERE model = ?").all("m1")) + .toThrow(); + } finally { damaged.close(); } + setRequestHistoryWorkerRebuildMinBytesForTests(0); + const repaired = await queryRequestHistory({ model: "m1" }, undefined, 10); + expect(repaired.rows.map(row => row.requestId)).toEqual(["req-3", "req-2", "req-1", "req-0"]); + expect(repaired.meta.lastError).toMatch(/rebuilt|integrity|corrupt/i); + expect(readFileSync(usageLogPath())).toEqual(canonical); + }); + + test("corruption encountered by a live query rebuilds once even before the next periodic check", async () => { + const path = await existingIndex(); + await openRequestHistoryIndex(); + const original = requestHistoryDb(); + const canonical = readFileSync(usageLogPath()); + corruptModelIndex(original, path); + let reads = 0; + const rows = await withRequestHistoryRead(handle => { + reads += 1; + return handle.query("SELECT request_id FROM requests INDEXED BY idx_requests_model WHERE model = ? ORDER BY request_id").all("m1"); + }); + expect(reads).toBe(2); + expect(rows).toEqual([0, 1, 2, 3].map(index => ({ request_id: `req-${index}` }))); + expect(requestHistoryDb()).not.toBe(original); + expect(readFileSync(usageLogPath())).toEqual(canonical); + }); + + test("routing analytics recovers a corrupted model index through its read wrapper", async () => { + const path = await existingIndex(); + await openRequestHistoryIndex(); + const handle = requestHistoryDb(); + const canonical = readFileSync(usageLogPath()); + corruptModelIndex(handle, path); + const analytics = await computeRoutingAnalytics({ model: "m1" }); + expect(analytics.totalRequests).toBe(4); + expect(analytics.successRate).toBe(1); + expect(requestHistoryDb()).not.toBe(handle); + expect(readFileSync(usageLogPath())).toEqual(canonical); + }); + + test("routing health marks query corruption for repair and recovers current historical evidence", async () => { + const path = await existingIndex(); + const now = Date.now(); + appendUsageEntry(entry("recent", now)); + await openRequestHistoryIndex(); + const input = { provider: "a", model: "m1", now }; + expect(healthEvidenceForCandidate(input).sampleCount).toBe(1); + clearHealthHistoryCacheForTests(); + const handle = requestHistoryDb(); + const canonical = readFileSync(usageLogPath()); + corruptModelIndex(handle, path); + expect(healthEvidenceForCandidate(input)).toEqual({}); + expect((await openRequestHistoryIndex()).indexedRows).toBe(5); + expect(requestHistoryDb()).not.toBe(handle); + clearHealthHistoryCacheForTests(); + const repaired = healthEvidenceForCandidate(input); + expect(repaired.sampleCount).toBe(1); + expect(repaired.successRate).toBe(1); + expect(readFileSync(usageLogPath())).toEqual(canonical); + }); + + test("tail ingestion recovers corruption in a secondary index before inserting the appended row", async () => { + const path = await existingIndex(); + await openRequestHistoryIndex(); + const handle = requestHistoryDb(); + corruptModelIndex(handle, path); + appendUsageEntry(entry("after-corruption", 2000)); + const canonical = readFileSync(usageLogPath()); + const page = await queryRequestHistory({ model: "m1" }, undefined, 10); + expect(page.rows.map(row => row.requestId)).toEqual(["after-corruption", "req-3", "req-2", "req-1", "req-0"]); + expect(page.meta.indexedRows).toBe(5); + expect(requestHistoryDb()).not.toBe(handle); + expect(readFileSync(usageLogPath())).toEqual(canonical); + }); + + test("query retries are bounded and ordinary SQLite lock errors do not recreate the index", async () => { + await existingIndex(); + await openRequestHistoryIndex(); + const handle = requestHistoryDb(); + const busy = Object.assign(new Error("database is locked"), { code: "SQLITE_BUSY" }); + let reads = 0; + await expect(withRequestHistoryRead(() => { reads += 1; throw busy; })).rejects.toBe(busy); + expect(reads).toBe(1); + expect(requestHistoryDb()).toBe(handle); + const corrupt = Object.assign(new Error("database disk image is malformed"), { code: "SQLITE_CORRUPT" }); + reads = 0; + await expect(withRequestHistoryRead(() => { reads += 1; throw corrupt; })).rejects.toBe(corrupt); + expect(reads).toBe(2); + }); + + test("extended SQLite corruption codes are repairable while operational failures are not", () => { + for (const error of [ + { code: "SQLITE_CORRUPT" }, { code: "SQLITE_CORRUPT_INDEX" }, { code: "SQLITE_NOTADB" }, + { errno: 11 }, { errno: 267 }, { errno: 26 }, + ]) expect(isHistoryIndexCorruption(error)).toBe(true); + for (const error of [ + { code: "SQLITE_BUSY", errno: 5 }, { code: "SQLITE_LOCKED", errno: 6 }, + { code: "SQLITE_IOERR", errno: 10 }, { code: "SQLITE_FULL", errno: 13 }, + { code: "SQLITE_CANTOPEN", errno: 14 }, new Error("database disk image is malformed"), null, + ]) expect(isHistoryIndexCorruption(error)).toBe(false); + }); + + test("a cancelled check holds the replacement barrier until its actual exit callback", async () => { + const checks: Check[] = []; + const exits: Array<() => void> = []; + const monitor = new RequestHistoryIntegrityMonitor({ + createJob: () => ({ + check: (path: string) => new Promise((resolve, reject) => { checks.push({ path, resolve, reject }); }), + dispose: async () => {}, + // Simulate bounded disposal returning while the native thread is still alive. + afterExit: async (cleanup: () => void) => { exits.push(cleanup); }, + }), + }); + const first = monitor.inspect({}, "index.sqlite"); + if (first.state !== "pending") throw new Error("initial check must be pending"); + await until(() => checks.length === 1); + monitor.reset(); + const replacement = {}; + const blocked = monitor.inspect(replacement, "index.sqlite"); + expect(blocked.state).toBe("pending"); + await until(() => exits.length === 1); + let released = false; + void first.promise.then(() => { released = true; }); + checks[0]!.resolve(false); + await Bun.sleep(0); + expect(released).toBe(false); + expect(checks).toHaveLength(1); + exits[0]!(); + await first.promise; + const next = monitor.inspect(replacement, "index.sqlite"); + if (next.state !== "pending") throw new Error("replacement needs its own check"); + await until(() => checks.length === 2); + checks[1]!.resolve(true); + await until(() => exits.length === 2); + exits[1]!(); + await next.promise; + expect(monitor.inspect(replacement, "index.sqlite").state).toBe("ready"); + }); + + test("a delayed healthy check cannot clear corruption reported by a statement", async () => { + let clock = 0; + let finish: ((healthy: boolean) => void) | undefined; + let released = false; + const monitor = new RequestHistoryIntegrityMonitor({ + now: () => clock, + intervalMs: 100, + createJob: () => ({ + check: () => new Promise(resolve => { finish = resolve; }), + dispose: async () => {}, + afterExit: async (cleanup: () => void) => { released = true; cleanup(); }, + }), + }); + const handle = {}; + monitor.acceptRebuilt(handle, "index.sqlite"); + clock = 100; + expect(monitor.inspect(handle, "index.sqlite").state).toBe("ready"); + await until(() => finish !== undefined); + monitor.markCorrupt(handle); + finish!(true); + await until(() => released); + await Bun.sleep(0); + expect(monitor.inspect(handle, "index.sqlite").state).toBe("corrupt"); + }); +}); From 0cc5a5958d5435444a0185bf06e424843916716f Mon Sep 17 00:00:00 2001 From: Robin Bially <7304732+robin-bially@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:31:22 +0100 Subject: [PATCH 48/84] fix(workflow-budget): make per-task ceilings configurable from config and dashboard (#7960) * fix(workflow-budget): make per-task ceilings configurable from config and dashboard The root-workflow count ceilings (8 concurrent turns, 256 sends and 64 distinct children per 10-minute window) were module constants. An install that legitimately runs more parallel subagents had no way to raise them short of patching the package, and every hit surfaced as a local 429. Add an optional workflowBudget config block for the three count ceilings, bound to the live config and read per decision by HTTP admission and the pre-dispatch send ceiling. Absent block keeps today's policy object unchanged. Window, interactive reserve and tracked-root bound stay fixed. GET/PUT /api/workflow-budget/settings expose effective values, defaults and bounds; the Subagents page gets a panel to edit them. * docs(workflow-budget): record deferred live settings CLI --------- Co-authored-by: Robin Bially --- .../030_wfc_diff_plan.md | 7 + .../src/content/docs/guides/web-dashboard.md | 2 +- .../docs/reference/configuration/server.md | 24 +++ .../content/docs/reference/management-api.md | 1 + gui/src/components/WorkflowBudgetPanel.tsx | 178 ++++++++++++++++++ gui/src/i18n/de.ts | 16 ++ gui/src/i18n/en.ts | 16 ++ gui/src/i18n/fr.ts | 16 ++ gui/src/i18n/ja.ts | 16 ++ gui/src/i18n/ko.ts | 16 ++ gui/src/i18n/pt.ts | 16 ++ gui/src/i18n/ru.ts | 16 ++ gui/src/i18n/tr.ts | 16 ++ gui/src/i18n/vi.ts | 16 ++ gui/src/i18n/zh-TW.ts | 16 ++ gui/src/i18n/zh.ts | 16 ++ gui/src/pages/Subagents.tsx | 2 + gui/tests/workflow-budget-panel.test.tsx | 151 +++++++++++++++ scripts/test-layout/layout.json | 2 +- src/config/diagnostics.ts | 3 + src/config/load-degrade.ts | 2 + src/config/schema/config-schema.ts | 3 + src/config/schema/workflow-budget.ts | 43 +++++ src/lib/state-store-registrations.ts | 2 + src/lib/workflow-budget-config.ts | 39 ++++ src/server/management/route-registry.ts | 2 + .../management/workflow-budget-routes.ts | 73 ++++++- src/server/responses/request-send-budget.ts | 3 +- src/server/workflow-refusal.ts | 3 +- src/types/config.ts | 2 + structure/gui-and-management-api.md | 2 +- tests/cli/cli-headless-parity.test.ts | 1 + tests/config/config-workflow-budget.test.ts | 56 ++++++ tests/fixtures/test-layout-expected.json | 2 +- .../server-workflow-budget-settings.test.ts | 150 +++++++++++++++ 35 files changed, 920 insertions(+), 9 deletions(-) create mode 100644 gui/src/components/WorkflowBudgetPanel.tsx create mode 100644 gui/tests/workflow-budget-panel.test.tsx create mode 100644 src/config/schema/workflow-budget.ts create mode 100644 src/lib/workflow-budget-config.ts create mode 100644 tests/config/config-workflow-budget.test.ts create mode 100644 tests/server/server-workflow-budget-settings.test.ts diff --git a/devlog/_fin/260915_workflow_budget_window/030_wfc_diff_plan.md b/devlog/_fin/260915_workflow_budget_window/030_wfc_diff_plan.md index 7b4617b716a..b2d2c7b6905 100644 --- a/devlog/_fin/260915_workflow_budget_window/030_wfc_diff_plan.md +++ b/devlog/_fin/260915_workflow_budget_window/030_wfc_diff_plan.md @@ -96,6 +96,13 @@ declared `deferred-verb` in the route registry against this document, which is what keeps them out of the undeclared-route ratchet without pretending the gap does not exist. +The later configurable-ceilings change also defers the live CLI counterpart of +`PUT /api/workflow-budget/settings` against this document. Its management API +updates the running policy; local `ocx config set/unset workflowBudget...` +edits disk configuration and requires a proxy restart for live convergence. +`GET /api/workflow-budget/settings` is a `local-transport` exemption because +`ocx config get` can read the same configuration block locally. + ## Verification posture Local suite, typecheck, install and build: NOT RUN, by standing instruction. diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 677bd9e46d4..7426627eeab 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -131,7 +131,7 @@ is visible) and never forces an upstream refresh. | **Providers** | Add, edit, set the default (enabled providers only), enable/disable, and remove providers; manage OAuth account pools and API-key pools where supported. Removing the current default switches to the first remaining enabled provider when one exists; otherwise deletion is refused and the current default is kept. Provider Settings can disable live model discovery for endpoints with missing, slow, or oversized `/models` catalogs. For Claude (Anthropic) OAuth pools, each logged-in account shows its own 5-hour and weekly rate-limit bars (usage is per credential); a failed probe keeps the last-known bars and marks them unavailable until the next successful refresh. Refreshing quota skips paused OAuth accounts and disabled API keys; skipped rows without a cached reading remain unavailable but do not affect the refresh result. Refreshing reports failure when an unpaused OAuth account or enabled API key that supports quota reads has an unavailable reading. The Provider Overview shown when no provider is selected carries a **Refresh all quotas** control that forces one server-side re-read of every configured provider; a provider whose upstream probe fails keeps its last-good row, so the status line reports that the check completed rather than claiming every value is fresh, and each row's own age stays the per-provider freshness signal. | | **Add provider** | One search above the tabs reaches all four at once — Accounts, Free, Local, Paid. While a query is live the results are grouped by tab with a count each, and the selected tab stays put rather than jumping. Press ArrowDown in the search box to focus the first available result action, skipping disabled buttons; if no action is available, focus stays in the search box. Local runtimes (Ollama, vLLM, LM Studio, LiteLLM) have their own tab, and a long provider note clamps to two lines with the full text one click away. Claude Code CLI (subscription) sits under Paid with a Subscription CLI badge, and selecting it warns that it bills the signed-in Claude subscription through `claude -p` and never uses an API key (use `anthropic-apikey` for key billing). | | **Codex Auth** | Add ChatGPT/Codex pool accounts, select the next-session account, refresh 5h / weekly / 30d quotas, enable or disable quota auto-switch, set its 1–100% threshold, and configure transient-failure failover. | -| **Subagents** | Feature up to five bare native or namespaced routed models in the `spawn_agent` override list. | +| **Subagents** | Feature up to five bare native or namespaced routed models in the `spawn_agent` override list. The **Workflow limits for subagents** panel sets how many turns one task may run in parallel and how many model requests and distinct subagents it may use per 10-minute window. See [workflow limits](/reference/configuration/server/#workflow-limits). | | **Models** | Toggle native GPT and routed models, set provider allowlists and context caps, choose v1/base/v2, and configure the v2 thread limit. The page distinguishes a catalog saved on the hub, a catalog fetched by this client, and activation in a running client. A fetch timestamp does not prove it includes the latest hub save, and runtime activation is shown as unverified. Configured providers stay visible as zero-model groups when discovery is off or returns no rows. | | **Logs** | Auto-refresh recent requests with tokens, requested effort and (when available) effective outbound effort, resolved model, provider, status, request id, duration, and error details. The detail view includes the exact reasoning wire field when the adapter emits one. Filter by opaque conversation/session id (when the client sends one) to total tokens and estimated list-price cost for the currently loaded Logs ring. | | **Usage / Debug** | Inspect token-usage coverage and trends. The Usage page's Models table also breaks each model down into input tokens, output tokens, cache hits, cache writes, and cache hit rate; a dash means cache telemetry for that metric is unavailable. Or enable opt-in provider transport and usage-extraction diagnostics. | diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index bff693deae6..59aa34c7602 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -34,6 +34,7 @@ passthrough, including native WebSocket output, retain their existing stop behav | `codexNativeSteering?` | `boolean` | `false` | Experimental, native-only mid-turn steering on the Responses WebSocket endpoint. Requires `websockets: true`, a compatible upstream/client, and a pinned account/model/tool surface. Validated generation settings can change in explicit saved-result continuations. Does not enable translated models or HTTP fallback. See [native steering](/guides/codex-integration/#experimental-native-mid-turn-steering). | | `codexNativeInjection?` | `boolean` | `false` | Experimental saved function-result injection on compatible native multi-agent WebSocket turns. Requires `websockets: true`, explicit `multi_agent.enabled`, and an eligible provider. Separate from steering; no automatic tool rerun or recovery create. See [native injection](/guides/codex-integration/#experimental-native-function-result-injection). | | `nativeReasoningRetention?` | `{ modelSwitch?: boolean; accountSwitch?: boolean }` | both `false` | Permit reasoning ciphertext forwarding across a known model or account change on the same canonical ChatGPT backend. Account retention is experimental. See [Native reasoning retention](#native-reasoning-retention). | +| `workflowBudget?` | `{ maxConcurrentChildren?: number; maxPhysicalSends?: number; maxDistinctChildren?: number }` | `8` / `256` / `64` | Per-task fan-out ceilings: turns running at once, upstream sends per 10-minute window, and distinct subagent threads per window. Omitted fields keep the built-in value. See [Workflow limits](#workflow-limits). | | `corsAllowOrigins?` | `string[]` | `[]` | Additional exact origins allowed by CORS. Loopback origins are always allowed. Authority-based browser extension origins such as `chrome-extension://` are supported; `*` is not a wildcard. Firefox and Safari regenerate the extension UUID (per install / per browser launch), so update the entry when the origin changes. | | `apiKeys?` | `OcxApiKey[]` | `[]` | Generated `ocx_…` data-plane admission credentials on non-loopback binds. They do not authorize management APIs; management access uses the separate credential documented in the [management reference](/reference/management-api/). Dashboard-managed. | | `storageCleanupPolicy?` | `StorageCleanupPolicy` | disabled | Opt-in archived-session cleanup policy. Never enabled implicitly. | @@ -87,6 +88,29 @@ Retention means that OpenCodex may forward the carried ciphertext. It cannot con Use `GET /api/native-reasoning-retention` to read the resolved booleans and `PUT` with a partial boolean object to update them. `PUT` with JSON `null` restores both defaults. Invalid fields or unknown keys are rejected; a malformed file block disables retention with a warning. Dashboard/API changes apply to subsequent requests. Local `ocx config set/unset` edits require a restart to converge the running proxy. +## Workflow limits + +OpenCodex bounds how much one task may fan out. A task is the root conversation, identified by the client's `x-codex-parent-thread-id` header, together with every subagent it starts. Three count ceilings apply to each task, and requests over any of them are refused locally with HTTP 429 and an `x-opencodex-local-refusal` header naming the ceiling (`workflow_concurrency_exhausted`, `workflow_sends_exhausted` or `workflow_children_exhausted`); no provider is contacted. + +| Field | Default | Allowed | Meaning | +| --- | --- | --- | --- | +| `maxConcurrentChildren` | `8` | 2–64 | Turns one task may run at the same time. One slot is always reserved for the task's own conversation, so subagents may use one fewer. | +| `maxPhysicalSends` | `256` | 1–10000 | Upstream sends, retries included, one task may make inside the 10-minute window. | +| `maxDistinctChildren` | `64` | 1–1024 | Distinct subagent threads one task may start inside the same window. | + +The defaults suit most installs. Raise them when a workflow that legitimately runs many subagents in parallel keeps hitting a ceiling; lower them to stop a runaway fan-out sooner: + +```jsonc +{ + "workflowBudget": { + "maxConcurrentChildren": 16, + "maxPhysicalSends": 1024 + } +} +``` + +The **Subagents** page of the dashboard shows the effective ceilings and edits them. `GET /api/workflow-budget/settings` returns the effective values with their defaults and allowed ranges; `PUT` with a partial object updates them, and JSON `null` restores the defaults. Dashboard/API changes apply to the next request without a restart and never reset what a task has already used; `POST /api/workflow-budget/clear` remains the way to forgive one task. A malformed file block is ignored with a warning and the built-in ceilings stay in force. Local `ocx config set/unset` edits require a restart to converge the running proxy. The 10-minute window, the reserved interactive slot and the 512 tracked tasks are not configurable. + ## Upstream transport recovery While the canonical ChatGPT upstream WebSocket waits for the first Responses event after diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 7da2cada27a..07b78c54e08 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -271,6 +271,7 @@ See [Codex prompt layers](/guides/codex-prompt/) for the layer model and the key | `PUT /api/config` | Disabled full-config replacement guard | 405; use focused endpoints instead | | `GET, PUT /api/settings` | Read runtime/startup settings or update auto-start, stream mode, app-owned memory budget, and `codexAccountPickerEnabled` | 400 invalid, non-object, or empty update | | `GET, PUT /api/native-reasoning-retention` | Read resolved model/account reasoning-retention booleans; partial PUT preserves omitted fields, JSON `null` restores defaults. See [native reasoning retention](/reference/configuration/server/#native-reasoning-retention). | 400 unknown field, invalid type or non-object/non-null body | +| `GET, PUT /api/workflow-budget/settings` | Read the effective per-task workflow ceilings with defaults and allowed ranges; partial PUT preserves omitted fields, JSON `null` restores defaults. See [workflow limits](/reference/configuration/server/#workflow-limits). | 400 unknown field, non-integer or out-of-range value, or non-object/non-null body | | `GET /api/startup-health` | Read cached service/shim startup health | — | | `POST /api/startup-action` | Install or repair the service or Codex shim | 400 invalid action; 500 action failure | | `GET, POST /api/windows-tray` | Read Windows tray state or install/start/stop/uninstall it | 400 unsupported platform/action; 500 operation failure | diff --git a/gui/src/components/WorkflowBudgetPanel.tsx b/gui/src/components/WorkflowBudgetPanel.tsx new file mode 100644 index 00000000000..e8d7cb29e25 --- /dev/null +++ b/gui/src/components/WorkflowBudgetPanel.tsx @@ -0,0 +1,178 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useT } from "../i18n/shared"; +import { createBoundedFetch } from "../bounded-fetch"; +import { clampNumberDraft } from "../clamp-draft"; +import { requireJson } from "../pages/dashboard-shared"; +import { NumberStepper } from "./NumberStepper"; + +const KEYS = ["maxConcurrentChildren", "maxPhysicalSends", "maxDistinctChildren"] as const; +type Key = typeof KEYS[number]; +type Values = Record; +type Drafts = Record; +interface Settings { + values: Values; + defaults: Values; + bounds: Record; + windowMinutes: number; +} + +const LABEL_KEYS = { + maxConcurrentChildren: ["workflowBudget.maxConcurrentChildren", "workflowBudget.maxConcurrentChildrenHint"], + maxPhysicalSends: ["workflowBudget.maxPhysicalSends", "workflowBudget.maxPhysicalSendsHint"], + maxDistinctChildren: ["workflowBudget.maxDistinctChildren", "workflowBudget.maxDistinctChildrenHint"], +} as const; + +const isInt = (value: unknown): value is number => typeof value === "number" && Number.isInteger(value); + +function readSettings(payload: unknown): Settings { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) throw new Error("Invalid workflow budget settings"); + const value = payload as Record; + const defaults = value.defaults as Record | undefined; + const bounds = value.bounds as Record | undefined; + if (!defaults || !bounds || !isInt(value.windowMs)) throw new Error("Missing workflow budget settings"); + const settings = { values: {}, defaults: {}, bounds: {}, windowMinutes: Math.round(value.windowMs / 60_000) } as Settings; + for (const key of KEYS) { + const range = bounds[key]; + if (!isInt(value[key]) || !isInt(defaults[key]) || !range || !isInt(range.min) || !isInt(range.max)) { + throw new Error("Missing workflow budget settings"); + } + settings.values[key] = value[key]; + settings.defaults[key] = defaults[key]; + settings.bounds[key] = { min: range.min, max: range.max }; + } + return settings; +} + +const toDrafts = (values: Values): Drafts => + ({ maxConcurrentChildren: String(values.maxConcurrentChildren), maxPhysicalSends: String(values.maxPhysicalSends), maxDistinctChildren: String(values.maxDistinctChildren) }); + +function parseDraft(raw: string, range: { min: number; max: number }): number | undefined { + if (!/^\d+$/.test(raw.trim())) return undefined; + const value = Number(raw.trim()); + return value >= range.min && value <= range.max ? value : undefined; +} + +export default function WorkflowBudgetPanel({ apiBase }: { apiBase: string }) { + return ; +} + +function WorkflowBudgetControls({ apiBase }: { apiBase: string }) { + const t = useT(); + const [saved, setSaved] = useState(); + const [drafts, setDrafts] = useState({ maxConcurrentChildren: "", maxPhysicalSends: "", maxDistinctChildren: "" }); + const [busy, setBusy] = useState(false); + const [loadError, setLoadError] = useState(false); + const [feedback, setFeedback] = useState<"saved" | "failed" | null>(null); + const active = useRef(false); + const pending = useRef | null>(null); + const accept = useCallback((value: Settings) => { setSaved(value); setDrafts(toDrafts(value.values)); }, []); + const load = useCallback(async () => { + if (pending.current) return; + const request = createBoundedFetch(15_000); + pending.current = request; setLoadError(false); + try { + const response = await fetch(apiBase + "/api/workflow-budget/settings", { signal: request.signal }); + const value = readSettings(await requireJson(response)); + if (active.current && pending.current === request) accept(value); + } catch { + if (active.current && pending.current === request) setLoadError(true); + } finally { + request.clear(); + if (pending.current === request) pending.current = null; + } + }, [apiBase, accept]); + useEffect(() => { + active.current = true; + const timer = window.setTimeout(() => { void load(); }, 0); + return () => { + window.clearTimeout(timer); active.current = false; + pending.current?.controller.abort(); pending.current?.clear(); pending.current = null; + }; + }, [load]); + + const parsed = saved + ? Object.fromEntries(KEYS.map(key => [key, parseDraft(drafts[key], saved.bounds[key])])) as Record + : undefined; + const valid = parsed !== undefined && KEYS.every(key => parsed[key] !== undefined); + const changed = saved !== undefined && parsed !== undefined && KEYS.some(key => parsed[key] !== saved.values[key]); + const atDefaults = saved !== undefined && KEYS.every(key => saved.values[key] === saved.defaults[key]); + + const save = async (reset = false) => { + if (pending.current || saved === undefined || (!reset && (!valid || parsed === undefined))) return; + // Values equal to the defaults are written as a reset, so the install keeps following the + // built-in ceilings instead of pinning today's numbers. + const body = reset || KEYS.every(key => parsed?.[key] === saved.defaults[key]) + ? null + : Object.fromEntries(KEYS.map(key => [key, parsed?.[key]])); + const request = createBoundedFetch(15_000); + pending.current = request; setBusy(true); setFeedback(null); + try { + const response = await fetch(apiBase + "/api/workflow-budget/settings", { + method: "PUT", headers: { "Content-Type": "application/json" }, signal: request.signal, + body: JSON.stringify(body), + }); + const value = readSettings(await requireJson(response)); + if (active.current && pending.current === request) { accept(value); setFeedback("saved"); } + } catch { + if (active.current && pending.current === request) setFeedback("failed"); + } finally { + request.clear(); + if (active.current && pending.current === request) setBusy(false); + if (pending.current === request) pending.current = null; + } + }; + const disabled = busy || saved === undefined || loadError; + const edit = (key: Key, value: string) => { setDrafts(current => ({ ...current, [key]: value })); setFeedback(null); }; + + return
+
{t("workflowBudget.title")}
+

{t("workflowBudget.description", { minutes: saved?.windowMinutes ?? 10 })}

+
+ {KEYS.map(key => { + const [labelKey, hintKey] = LABEL_KEYS[key]; + const label = t(labelKey); + const range = saved?.bounds[key] ?? { min: 0, max: 0 }; + const invalid = saved !== undefined && parsed?.[key] === undefined; + const hintId = "workflow-budget-" + key + "-hint"; + return
+
+ {label} + + {t(hintKey)}{" "} + {saved && t("workflowBudget.range", { default: saved.defaults[key], min: range.min, max: range.max })} + + {invalid && {t("workflowBudget.invalid", { min: range.min, max: range.max })}} +
+ + edit(key, event.target.value)} + onKeyDown={event => { + if (event.key === "Enter" && !event.nativeEvent.isComposing && !disabled && changed && valid) { + event.preventDefault(); void save(); + } + }} + /> + edit(key, clampNumberDraft(drafts[key], 1, range.min, range.max))} + onDecrement={() => edit(key, clampNumberDraft(drafts[key], -1, range.min, range.max))} + /> + +
; + })} +
+ + +
+
+ {loadError &&
{t("workflowBudget.loadFailed")}
} + {feedback === "failed" &&
{t("workflowBudget.saveFailed")}
} + {feedback === "saved" &&
{t("workflowBudget.saved")}
} +
; +} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index fffab7207a4..affe092dcec 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -105,6 +105,22 @@ export const de: Record = { "nativeReasoningRetention.loadFailed": "Native Denkdateneinstellungen konnten nicht geladen werden.", "nativeReasoningRetention.saveFailed": "Native Denkdateneinstellungen konnten nicht gespeichert werden.", "nativeReasoningRetention.saved": "Native Denkdateneinstellungen gespeichert.", + "workflowBudget.title": "Workflow-Limits für Subagenten", + "workflowBudget.description": "Obergrenzen pro Aufgabe, also pro Unterhaltung samt der Subagenten, die sie startet, gezählt über ein gleitendes Fenster von {minutes} Minuten. Eine Anfrage über einer Grenze wird lokal mit 429 abgelehnt, bevor ein Anbieter kontaktiert wird. Änderungen gelten ab der nächsten Anfrage; bereits gezählte Nutzung bleibt erhalten.", + "workflowBudget.maxConcurrentChildren": "Parallele Turns", + "workflowBudget.maxConcurrentChildrenHint": "Turns, die eine Aufgabe gleichzeitig ausführen darf. Ein Platz bleibt immer für die eigene Unterhaltung reserviert, Subagenten bekommen also einen weniger.", + "workflowBudget.maxPhysicalSends": "Modellanfragen pro Fenster", + "workflowBudget.maxPhysicalSendsHint": "Anfragen an den Anbieter, Wiederholungen eingeschlossen, die eine Aufgabe im Fenster senden darf.", + "workflowBudget.maxDistinctChildren": "Verschiedene Subagenten pro Fenster", + "workflowBudget.maxDistinctChildrenHint": "Unterschiedliche Subagenten-Threads, die eine Aufgabe im Fenster starten darf.", + "workflowBudget.range": "Standard {default}, erlaubt {min}–{max}.", + "workflowBudget.invalid": "Bitte eine ganze Zahl von {min} bis {max} eingeben.", + "workflowBudget.increase": "{label} erhöhen", + "workflowBudget.decrease": "{label} verringern", + "workflowBudget.reset": "Standard wiederherstellen", + "workflowBudget.loadFailed": "Workflow-Limits konnten nicht geladen werden.", + "workflowBudget.saveFailed": "Workflow-Limits konnten nicht gespeichert werden.", + "workflowBudget.saved": "Workflow-Limits gespeichert.", ...desktopCompatibilityCopy("de"), "claude.tierDefaults.unset": "Nicht festgelegt (Claude-Einstellung beibehalten)", "claude.tierDefaults.recovery": "Anwendung gestoppt; automatische Wiederherstellung fehlgeschlagen. Prüfen Sie vor einem erneuten Versuch die Sicherung unten.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index d805772ff80..4f4cd4ffe0f 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -106,6 +106,22 @@ export const en = { "nativeReasoningRetention.loadFailed": "Could not load native reasoning settings.", "nativeReasoningRetention.saveFailed": "Could not save native reasoning settings.", "nativeReasoningRetention.saved": "Native reasoning settings saved.", + "workflowBudget.title": "Workflow limits for subagents", + "workflowBudget.description": "Ceilings per task, meaning one conversation and the subagents it starts, counted over a rolling {minutes}-minute window. A request over a ceiling is refused locally with a 429 before any provider is contacted. Changes apply to the next request; usage already counted is kept.", + "workflowBudget.maxConcurrentChildren": "Parallel turns", + "workflowBudget.maxConcurrentChildrenHint": "Turns one task may run at the same time. One slot is always kept for your own conversation, so subagents get one fewer.", + "workflowBudget.maxPhysicalSends": "Model requests per window", + "workflowBudget.maxPhysicalSendsHint": "Upstream requests, retries included, one task may send inside the window.", + "workflowBudget.maxDistinctChildren": "Different subagents per window", + "workflowBudget.maxDistinctChildrenHint": "Distinct subagent threads one task may start inside the window.", + "workflowBudget.range": "Default {default}, allowed {min}–{max}.", + "workflowBudget.invalid": "Enter a whole number from {min} to {max}.", + "workflowBudget.increase": "Increase {label}", + "workflowBudget.decrease": "Decrease {label}", + "workflowBudget.reset": "Restore defaults", + "workflowBudget.loadFailed": "Could not load workflow limits.", + "workflowBudget.saveFailed": "Could not save workflow limits.", + "workflowBudget.saved": "Workflow limits saved.", ...desktopCompatibilityCopy("en"), "claude.tierDefaults.unset": "Unset (keep existing Claude setting)", "claude.tierDefaults.recovery": "Apply stopped and automatic restore failed. Check the backup below before retrying.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index f3fbc99a28c..4b8cf58b1de 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -104,6 +104,22 @@ export const fr: Record = { "nativeReasoningRetention.loadFailed": "Impossible de charger les paramètres du raisonnement natif.", "nativeReasoningRetention.saveFailed": "Impossible de sauvegarder les paramètres du raisonnement natif.", "nativeReasoningRetention.saved": "Paramètres du raisonnement natif sauvegardés.", + "workflowBudget.title": "Limites de workflow des sous-agents", + "workflowBudget.description": "Plafonds par tâche, c'est-à-dire une conversation et les sous-agents qu'elle lance, comptés sur une fenêtre glissante de {minutes} minutes. Une requête au-delà d'un plafond est refusée localement avec un 429 avant tout contact avec un fournisseur. Les changements s'appliquent à la requête suivante ; l'usage déjà compté est conservé.", + "workflowBudget.maxConcurrentChildren": "Tours en parallèle", + "workflowBudget.maxConcurrentChildrenHint": "Tours qu'une tâche peut exécuter en même temps. Un emplacement reste toujours réservé à votre propre conversation, les sous-agents en ont donc un de moins.", + "workflowBudget.maxPhysicalSends": "Requêtes au modèle par fenêtre", + "workflowBudget.maxPhysicalSendsHint": "Requêtes vers le fournisseur, nouvelles tentatives comprises, qu'une tâche peut envoyer dans la fenêtre.", + "workflowBudget.maxDistinctChildren": "Sous-agents distincts par fenêtre", + "workflowBudget.maxDistinctChildrenHint": "Fils de sous-agents différents qu'une tâche peut lancer dans la fenêtre.", + "workflowBudget.range": "Par défaut {default}, autorisé {min}–{max}.", + "workflowBudget.invalid": "Saisissez un nombre entier de {min} à {max}.", + "workflowBudget.increase": "Augmenter : {label}", + "workflowBudget.decrease": "Diminuer : {label}", + "workflowBudget.reset": "Restaurer les valeurs par défaut", + "workflowBudget.loadFailed": "Impossible de charger les limites de workflow.", + "workflowBudget.saveFailed": "Impossible d'enregistrer les limites de workflow.", + "workflowBudget.saved": "Limites de workflow enregistrées.", ...desktopCompatibilityCopy("fr"), "claude.tierDefaults.unset": "Non défini (conserver le réglage Claude)", "claude.tierDefaults.recovery": "Application arrêtée et restauration automatique échouée. Vérifiez la sauvegarde ci-dessous avant de réessayer.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index a3b6940897f..4c99bb76162 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -104,6 +104,22 @@ export const ja: Record = { "nativeReasoningRetention.loadFailed": "ネイティブ推論の設定を読み込めませんでした。", "nativeReasoningRetention.saveFailed": "ネイティブ推論の設定を保存できませんでした。", "nativeReasoningRetention.saved": "ネイティブ推論の設定を保存しました。", + "workflowBudget.title": "サブエージェントのワークフロー上限", + "workflowBudget.description": "タスク(1つの会話とそこから起動されたサブエージェント)ごとの上限で、直近{minutes}分間のスライディングウィンドウで数えます。上限を超えたリクエストは、プロバイダーに接続する前にローカルで 429 として拒否されます。変更は次のリクエストから適用され、すでに数えられた使用量は保持されます。", + "workflowBudget.maxConcurrentChildren": "並列ターン数", + "workflowBudget.maxConcurrentChildrenHint": "1つのタスクが同時に実行できるターン数です。1枠は常に自分の会話用に確保されるため、サブエージェントが使えるのは1つ少なくなります。", + "workflowBudget.maxPhysicalSends": "ウィンドウあたりのモデルリクエスト数", + "workflowBudget.maxPhysicalSendsHint": "1つのタスクがウィンドウ内に送信できるプロバイダーへのリクエスト数(再試行を含む)です。", + "workflowBudget.maxDistinctChildren": "ウィンドウあたりの異なるサブエージェント数", + "workflowBudget.maxDistinctChildrenHint": "1つのタスクがウィンドウ内に起動できる異なるサブエージェントスレッドの数です。", + "workflowBudget.range": "既定値 {default}、範囲 {min}–{max}。", + "workflowBudget.invalid": "{min}~{max} の整数を入力してください。", + "workflowBudget.increase": "{label}を増やす", + "workflowBudget.decrease": "{label}を減らす", + "workflowBudget.reset": "既定値に戻す", + "workflowBudget.loadFailed": "ワークフロー上限を読み込めませんでした。", + "workflowBudget.saveFailed": "ワークフロー上限を保存できませんでした。", + "workflowBudget.saved": "ワークフロー上限を保存しました。", ...desktopCompatibilityCopy("ja"), "claude.tierDefaults.unset": "未指定(既存のClaude設定を維持)", "claude.tierDefaults.recovery": "適用が停止し、自動復元に失敗しました。再試行する前に以下のバックアップを確認してください。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index a92e751b06b..b073cfed72c 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -104,6 +104,22 @@ export const ko: Record = { "nativeReasoningRetention.loadFailed": "네이티브 추론 설정을 불러올 수 없습니다.", "nativeReasoningRetention.saveFailed": "네이티브 추론 설정을 저장할 수 없습니다.", "nativeReasoningRetention.saved": "네이티브 추론 설정을 저장했습니다.", + "workflowBudget.title": "서브에이전트 워크플로 한도", + "workflowBudget.description": "작업(하나의 대화와 그 대화가 시작한 서브에이전트)마다 적용되는 상한으로, 최근 {minutes}분의 이동 창 기준으로 집계합니다. 상한을 넘는 요청은 공급자에 연결하기 전에 로컬에서 429로 거부됩니다. 변경 사항은 다음 요청부터 적용되며, 이미 집계된 사용량은 유지됩니다.", + "workflowBudget.maxConcurrentChildren": "병렬 턴", + "workflowBudget.maxConcurrentChildrenHint": "하나의 작업이 동시에 실행할 수 있는 턴 수입니다. 한 자리는 항상 내 대화용으로 남겨 두므로 서브에이전트는 하나 적게 사용합니다.", + "workflowBudget.maxPhysicalSends": "창당 모델 요청 수", + "workflowBudget.maxPhysicalSendsHint": "하나의 작업이 창 안에서 보낼 수 있는 공급자 요청 수(재시도 포함)입니다.", + "workflowBudget.maxDistinctChildren": "창당 서로 다른 서브에이전트 수", + "workflowBudget.maxDistinctChildrenHint": "하나의 작업이 창 안에서 시작할 수 있는 서로 다른 서브에이전트 스레드 수입니다.", + "workflowBudget.range": "기본값 {default}, 허용 범위 {min}–{max}.", + "workflowBudget.invalid": "{min}~{max} 사이의 정수를 입력하세요.", + "workflowBudget.increase": "{label} 늘리기", + "workflowBudget.decrease": "{label} 줄이기", + "workflowBudget.reset": "기본값 복원", + "workflowBudget.loadFailed": "워크플로 한도를 불러오지 못했습니다.", + "workflowBudget.saveFailed": "워크플로 한도를 저장하지 못했습니다.", + "workflowBudget.saved": "워크플로 한도를 저장했습니다.", ...desktopCompatibilityCopy("ko"), "claude.tierDefaults.unset": "미지정 (기존 Claude 설정 유지)", "claude.tierDefaults.recovery": "적용이 중단되었고 자동 복원에 실패했어요. 다시 시도하기 전에 아래 백업을 확인하세요.", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index 526567b7eba..f170560e262 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -106,6 +106,22 @@ export const pt: Record = { "nativeReasoningRetention.loadFailed": "Não foi possível carregar as configurações de raciocínio nativo.", "nativeReasoningRetention.saveFailed": "Não foi possível salvar as configurações de raciocínio nativo.", "nativeReasoningRetention.saved": "Configurações de raciocínio nativo salvas.", + "workflowBudget.title": "Limites de fluxo de trabalho dos subagentes", + "workflowBudget.description": "Tetos por tarefa, ou seja, uma conversa e os subagentes que ela inicia, contados em uma janela deslizante de {minutes} minutos. Uma requisição acima de um teto é recusada localmente com 429 antes de qualquer provedor ser contatado. As alterações valem a partir da próxima requisição; o uso já contado é mantido.", + "workflowBudget.maxConcurrentChildren": "Turnos em paralelo", + "workflowBudget.maxConcurrentChildrenHint": "Turnos que uma tarefa pode executar ao mesmo tempo. Uma vaga fica sempre reservada para a sua própria conversa, então os subagentes recebem uma a menos.", + "workflowBudget.maxPhysicalSends": "Requisições ao modelo por janela", + "workflowBudget.maxPhysicalSendsHint": "Requisições ao provedor, incluindo novas tentativas, que uma tarefa pode enviar dentro da janela.", + "workflowBudget.maxDistinctChildren": "Subagentes diferentes por janela", + "workflowBudget.maxDistinctChildrenHint": "Threads de subagentes distintos que uma tarefa pode iniciar dentro da janela.", + "workflowBudget.range": "Padrão {default}, permitido {min}–{max}.", + "workflowBudget.invalid": "Informe um número inteiro de {min} a {max}.", + "workflowBudget.increase": "Aumentar {label}", + "workflowBudget.decrease": "Diminuir {label}", + "workflowBudget.reset": "Restaurar padrões", + "workflowBudget.loadFailed": "Não foi possível carregar os limites de fluxo de trabalho.", + "workflowBudget.saveFailed": "Não foi possível salvar os limites de fluxo de trabalho.", + "workflowBudget.saved": "Limites de fluxo de trabalho salvos.", ...desktopCompatibilityCopy("pt"), "claude.tierDefaults.unset": "Não definido (manter configuração Claude)", "claude.tierDefaults.recovery": "Aplicação interrompida; restauração automática falhou. Verifique o backup abaixo antes de tentar novamente.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 5835b18f727..7c7d951edde 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -104,6 +104,22 @@ export const ru: Record = { "nativeReasoningRetention.loadFailed": "Не удалось загрузить настройки нативных рассуждений.", "nativeReasoningRetention.saveFailed": "Не удалось сохранить настройки нативных рассуждений.", "nativeReasoningRetention.saved": "Настройки нативных рассуждений сохранены.", + "workflowBudget.title": "Лимиты рабочих процессов для субагентов", + "workflowBudget.description": "Ограничения на задачу, то есть на один диалог и запущенных им субагентов, считаются в скользящем окне длиной {minutes} минут. Запрос сверх ограничения отклоняется локально с кодом 429 ещё до обращения к провайдеру. Изменения действуют со следующего запроса; уже учтённое использование сохраняется.", + "workflowBudget.maxConcurrentChildren": "Параллельные ходы", + "workflowBudget.maxConcurrentChildrenHint": "Сколько ходов одна задача может выполнять одновременно. Одно место всегда остаётся за вашим собственным диалогом, поэтому субагентам доступно на одно меньше.", + "workflowBudget.maxPhysicalSends": "Запросов к модели за окно", + "workflowBudget.maxPhysicalSendsHint": "Запросы к провайдеру, включая повторные попытки, которые одна задача может отправить за окно.", + "workflowBudget.maxDistinctChildren": "Разных субагентов за окно", + "workflowBudget.maxDistinctChildrenHint": "Сколько разных потоков субагентов одна задача может запустить за окно.", + "workflowBudget.range": "По умолчанию {default}, допустимо {min}–{max}.", + "workflowBudget.invalid": "Введите целое число от {min} до {max}.", + "workflowBudget.increase": "Увеличить: {label}", + "workflowBudget.decrease": "Уменьшить: {label}", + "workflowBudget.reset": "Восстановить значения по умолчанию", + "workflowBudget.loadFailed": "Не удалось загрузить лимиты рабочих процессов.", + "workflowBudget.saveFailed": "Не удалось сохранить лимиты рабочих процессов.", + "workflowBudget.saved": "Лимиты рабочих процессов сохранены.", ...desktopCompatibilityCopy("ru"), "claude.tierDefaults.unset": "Не задано (сохранить настройку Claude)", "claude.tierDefaults.recovery": "Применение остановлено, автоматическое восстановление не удалось. Проверьте резервную копию ниже перед повтором.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index b673ee21a0f..ab1fd6ee3b1 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -105,6 +105,22 @@ export const tr: Record = { "nativeReasoningRetention.loadFailed": "Yerel akıl yürütme ayarları yüklenemedi.", "nativeReasoningRetention.saveFailed": "Yerel akıl yürütme ayarları kaydedilemedi.", "nativeReasoningRetention.saved": "Yerel akıl yürütme ayarları kaydedildi.", + "workflowBudget.title": "Alt ajanlar için iş akışı sınırları", + "workflowBudget.description": "Görev başına, yani bir konuşma ve başlattığı alt ajanlar için üst sınırlar; {minutes} dakikalık kayan bir pencerede sayılır. Bir sınırı aşan istek, herhangi bir sağlayıcıyla iletişime geçilmeden önce yerel olarak 429 ile reddedilir. Değişiklikler bir sonraki istekten itibaren geçerlidir; önceden sayılan kullanım korunur.", + "workflowBudget.maxConcurrentChildren": "Paralel turlar", + "workflowBudget.maxConcurrentChildrenHint": "Bir görevin aynı anda çalıştırabileceği tur sayısı. Bir yer her zaman kendi konuşmanız için ayrılır, bu yüzden alt ajanlar bir eksik alır.", + "workflowBudget.maxPhysicalSends": "Pencere başına model isteği", + "workflowBudget.maxPhysicalSendsHint": "Bir görevin pencere içinde gönderebileceği sağlayıcı istekleri, yeniden denemeler dahil.", + "workflowBudget.maxDistinctChildren": "Pencere başına farklı alt ajan", + "workflowBudget.maxDistinctChildrenHint": "Bir görevin pencere içinde başlatabileceği farklı alt ajan iş parçacığı sayısı.", + "workflowBudget.range": "Varsayılan {default}, izin verilen {min}–{max}.", + "workflowBudget.invalid": "{min} ile {max} arasında bir tam sayı girin.", + "workflowBudget.increase": "{label} artır", + "workflowBudget.decrease": "{label} azalt", + "workflowBudget.reset": "Varsayılanları geri yükle", + "workflowBudget.loadFailed": "İş akışı sınırları yüklenemedi.", + "workflowBudget.saveFailed": "İş akışı sınırları kaydedilemedi.", + "workflowBudget.saved": "İş akışı sınırları kaydedildi.", ...desktopCompatibilityCopy("tr"), "claude.tierDefaults.unset": "Belirtilmedi (mevcut Claude ayarını koru)", "claude.tierDefaults.recovery": "Uygulama durdu ve otomatik geri yükleme başarısız oldu. Yeniden denemeden önce aşağıdaki yedeği kontrol edin.", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index eed86f0d3d2..807a752f49e 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -106,6 +106,22 @@ export const vi: Record = { "nativeReasoningRetention.loadFailed": "Không thể tải cài đặt suy luận gốc.", "nativeReasoningRetention.saveFailed": "Không thể lưu cài đặt suy luận gốc.", "nativeReasoningRetention.saved": "Đã lưu cài đặt suy luận gốc.", + "workflowBudget.title": "Giới hạn quy trình cho tác tử phụ", + "workflowBudget.description": "Mức trần cho mỗi tác vụ, tức là một cuộc trò chuyện cùng các tác tử phụ nó khởi chạy, được đếm trong cửa sổ trượt {minutes} phút. Yêu cầu vượt mức trần bị từ chối cục bộ với mã 429 trước khi liên hệ bất kỳ nhà cung cấp nào. Thay đổi áp dụng từ yêu cầu tiếp theo; mức sử dụng đã đếm được giữ nguyên.", + "workflowBudget.maxConcurrentChildren": "Lượt chạy song song", + "workflowBudget.maxConcurrentChildrenHint": "Số lượt một tác vụ có thể chạy cùng lúc. Luôn giữ lại một chỗ cho cuộc trò chuyện của bạn, nên tác tử phụ được ít hơn một.", + "workflowBudget.maxPhysicalSends": "Yêu cầu tới mô hình mỗi cửa sổ", + "workflowBudget.maxPhysicalSendsHint": "Số yêu cầu tới nhà cung cấp, kể cả thử lại, mà một tác vụ có thể gửi trong cửa sổ.", + "workflowBudget.maxDistinctChildren": "Tác tử phụ khác nhau mỗi cửa sổ", + "workflowBudget.maxDistinctChildrenHint": "Số luồng tác tử phụ khác nhau mà một tác vụ có thể khởi chạy trong cửa sổ.", + "workflowBudget.range": "Mặc định {default}, cho phép {min}–{max}.", + "workflowBudget.invalid": "Nhập một số nguyên từ {min} đến {max}.", + "workflowBudget.increase": "Tăng {label}", + "workflowBudget.decrease": "Giảm {label}", + "workflowBudget.reset": "Khôi phục mặc định", + "workflowBudget.loadFailed": "Không thể tải giới hạn quy trình.", + "workflowBudget.saveFailed": "Không thể lưu giới hạn quy trình.", + "workflowBudget.saved": "Đã lưu giới hạn quy trình.", ...desktopCompatibilityCopy("vi"), "claude.tierDefaults.unset": "Chưa đặt (giữ cài đặt Claude hiện có)", "claude.tierDefaults.recovery": "Đã dừng áp dụng và khôi phục tự động thất bại. Kiểm tra bản sao lưu bên dưới trước khi thử lại.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 48b05bda9a5..63c1ac42d41 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -102,6 +102,22 @@ export const zhTW: Record = { "nativeReasoningRetention.loadFailed": "無法載入原生推理設定。", "nativeReasoningRetention.saveFailed": "無法儲存原生推理設定。", "nativeReasoningRetention.saved": "原生推理設定已儲存。", + "workflowBudget.title": "子代理工作流程限制", + "workflowBudget.description": "依任務(即一個對話及其啟動的子代理)設定的上限,在滾動的 {minutes} 分鐘視窗內計數。超過上限的請求會在聯絡任何供應商之前於本機以 429 拒絕。變更從下一個請求開始生效;已計入的用量會保留。", + "workflowBudget.maxConcurrentChildren": "平行輪次", + "workflowBudget.maxConcurrentChildrenHint": "一個任務可同時執行的輪次數。一律為你自己的對話保留一個名額,因此子代理少一個。", + "workflowBudget.maxPhysicalSends": "每個視窗的模型請求數", + "workflowBudget.maxPhysicalSendsHint": "一個任務在視窗內可向供應商傳送的請求數(含重試)。", + "workflowBudget.maxDistinctChildren": "每個視窗的不同子代理數", + "workflowBudget.maxDistinctChildrenHint": "一個任務在視窗內可啟動的不同子代理執行緒數。", + "workflowBudget.range": "預設 {default},允許 {min}–{max}。", + "workflowBudget.invalid": "請輸入 {min} 到 {max} 之間的整數。", + "workflowBudget.increase": "增加{label}", + "workflowBudget.decrease": "減少{label}", + "workflowBudget.reset": "還原預設值", + "workflowBudget.loadFailed": "無法載入工作流程限制。", + "workflowBudget.saveFailed": "無法儲存工作流程限制。", + "workflowBudget.saved": "工作流程限制已儲存。", ...desktopCompatibilityCopy("zh-TW"), "claude.tierDefaults.unset": "未設定(保留現有 Claude 設定)", "claude.tierDefaults.recovery": "套用已停止,自動還原失敗。重試前請檢查下方備份。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index d04ea73e9b5..005f5099ab1 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -104,6 +104,22 @@ export const zh: Record = { "nativeReasoningRetention.loadFailed": "无法加载原生推理设置。", "nativeReasoningRetention.saveFailed": "无法保存原生推理设置。", "nativeReasoningRetention.saved": "原生推理设置已保存。", + "workflowBudget.title": "子代理工作流限制", + "workflowBudget.description": "按任务(即一个对话及其启动的子代理)设置的上限,在滚动的 {minutes} 分钟窗口内计数。超过上限的请求会在联系任何提供商之前于本地以 429 拒绝。更改从下一个请求开始生效;已计入的用量会保留。", + "workflowBudget.maxConcurrentChildren": "并行轮次", + "workflowBudget.maxConcurrentChildrenHint": "一个任务可同时运行的轮次数。始终为你自己的对话保留一个名额,因此子代理少一个。", + "workflowBudget.maxPhysicalSends": "每个窗口的模型请求数", + "workflowBudget.maxPhysicalSendsHint": "一个任务在窗口内可向提供商发送的请求数(含重试)。", + "workflowBudget.maxDistinctChildren": "每个窗口的不同子代理数", + "workflowBudget.maxDistinctChildrenHint": "一个任务在窗口内可启动的不同子代理线程数。", + "workflowBudget.range": "默认 {default},允许 {min}–{max}。", + "workflowBudget.invalid": "请输入 {min} 到 {max} 之间的整数。", + "workflowBudget.increase": "增加{label}", + "workflowBudget.decrease": "减少{label}", + "workflowBudget.reset": "恢复默认值", + "workflowBudget.loadFailed": "无法加载工作流限制。", + "workflowBudget.saveFailed": "无法保存工作流限制。", + "workflowBudget.saved": "工作流限制已保存。", ...desktopCompatibilityCopy("zh"), "claude.tierDefaults.unset": "未设置(保留现有 Claude 设置)", "claude.tierDefaults.recovery": "应用已停止,自动恢复失败。重试前请检查下方备份。", diff --git a/gui/src/pages/Subagents.tsx b/gui/src/pages/Subagents.tsx index b56e6199702..d21200620ae 100644 --- a/gui/src/pages/Subagents.tsx +++ b/gui/src/pages/Subagents.tsx @@ -1,4 +1,5 @@ import SubagentForceControl from "../components/subagents-workspace/SubagentForceControl"; +import WorkflowBudgetPanel from "../components/WorkflowBudgetPanel"; import { useCallback, useEffect, useRef, useState } from "react"; import { readJsonOrThrow } from "../fetch-json"; import { Notice } from "../ui"; @@ -365,6 +366,7 @@ export default function Subagents({ apiBase }: { apiBase: string }) { )} + ; +let win: Window; +let root: Root | undefined; +let container: HTMLDivElement; +let setting: Record; +let failLoad: boolean; +let failSave: boolean; +let writes: unknown[]; +let urls: string[]; + +const payload = () => ({ ...setting, interactiveReserve: 1, windowMs: 600_000, defaults, bounds }); + +beforeEach(() => { + previous = Object.fromEntries(globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])); + win = new Window({ url: "http://localhost/" }); + for (const key of ["document", "window", "navigator", "localStorage", "sessionStorage", "HTMLElement"] as const) { + Object.defineProperty(globalThis, key, { configurable: true, value: key === "window" ? win : win[key] }); + } + Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true }); + win.localStorage.setItem("ocx-lang", "en"); + setting = { ...defaults }; + failLoad = false; failSave = false; writes = []; urls = []; + Object.defineProperty(globalThis, "fetch", { configurable: true, writable: true, value: async (input: unknown, init?: RequestInit) => { + urls.push(String(input)); + if (init?.method === "PUT") { + const body = JSON.parse(String(init.body)) as Record | null; + writes.push(body); + if (failSave) return Response.json({ error: "fixture failure" }, { status: 500 }); + setting = body === null ? { ...defaults } : { ...setting, ...body }; + } else if (failLoad) return Response.json({ error: "unavailable" }, { status: 503 }); + return Response.json(payload()); + } }); +}); + +afterEach(async () => { + if (root) await act(async () => { root!.unmount(); }); + root = undefined; win.close(); + for (const key of globals) { + if (previous[key]) Object.defineProperty(globalThis, key, previous[key]!); + else delete (globalThis as Record)[key]; + } +}); + +async function flush() { await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); }); } +async function render(base = "") { + container = win.document.createElement("div") as unknown as HTMLDivElement; + win.document.body.appendChild(container); + root = (await import("react-dom/client")).createRoot(container); + await act(async () => { root!.render(); }); + await flush(); +} +function button(label: string) { + return [...container.querySelectorAll("button")] + .find(value => value.textContent === label || value.getAttribute("aria-label") === label)!; +} +const input = (label: string) => container.querySelector('input[aria-label="' + label + '"]')!; +async function click(label: string) { await act(async () => { button(label).click(); }); } +async function enter(label: string, value: string) { + await act(async () => { + Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, "value")!.set!.call(input(label), value); + input(label).dispatchEvent(new win.Event("input", { bubbles: true })); + }); +} + +test("shows the effective ceilings and saves all three through the dedicated route", async () => { + await render("/proxy"); + expect(input(PARALLEL).value).toBe("8"); + expect(input(SENDS).value).toBe("256"); + expect(container.textContent).toContain("rolling 10-minute window"); + expect(container.textContent).toContain("Default 8, allowed 2–64."); + expect(button("Save").disabled).toBe(true); + expect(button("Restore defaults").disabled).toBe(true); + await click("Increase " + PARALLEL); await click("Increase " + PARALLEL); + await enter(SENDS, "1024"); + await click("Save"); + expect(writes).toEqual([{ maxConcurrentChildren: 10, maxPhysicalSends: 1_024, maxDistinctChildren: 64 }]); + expect(input(PARALLEL).value).toBe("10"); + expect(button("Save").disabled).toBe(true); + expect(container.querySelector('[role="status"]')?.textContent).toBe("Workflow limits saved."); + expect(urls.every(value => value === "/proxy/api/workflow-budget/settings")).toBe(true); +}); + +test("saving the defaults and restoring both write null so the install keeps following built-in ceilings", async () => { + setting = { ...defaults, maxConcurrentChildren: 12 }; + await render(); + expect(input(PARALLEL).value).toBe("12"); + await enter(PARALLEL, "8"); + await click("Save"); + expect(writes).toEqual([null]); + expect(button("Restore defaults").disabled).toBe(true); + await click("Increase " + PARALLEL); await click("Save"); + expect(writes.at(-1)).toEqual({ ...defaults, maxConcurrentChildren: 9 }); + await click("Restore defaults"); + expect(writes.at(-1)).toBeNull(); + expect(input(PARALLEL).value).toBe("8"); + expect(button("Restore defaults").disabled).toBe(true); +}); + +test("an out-of-range draft names the allowed range and blocks saving", async () => { + await render(); + await enter(PARALLEL, "1"); + expect(container.querySelector('[role="alert"]')?.textContent).toBe("Enter a whole number from 2 to 64."); + expect(input(PARALLEL).getAttribute("aria-invalid")).toBe("true"); + expect(button("Save").disabled).toBe(true); + await enter(PARALLEL, "65"); + expect(button("Save").disabled).toBe(true); + await click("Decrease " + PARALLEL); + expect(input(PARALLEL).value).toBe("64"); + expect(container.querySelector('[role="alert"]')).toBeNull(); + expect(button("Save").disabled).toBe(false); + expect(writes).toEqual([]); +}); + +test("load errors disable every control and retry reloads", async () => { + failLoad = true; await render(); + expect(button("Save").disabled).toBe(true); + expect(button("Restore defaults").disabled).toBe(true); + expect(button("Increase " + PARALLEL).disabled).toBe(true); + expect(container.querySelector('[role="alert"]')?.textContent).toContain("Could not load workflow limits."); + failLoad = false; setting = { ...defaults, maxPhysicalSends: 512 }; + await click("Retry"); + expect(input(SENDS).value).toBe("512"); + expect(button("Increase " + PARALLEL).disabled).toBe(false); +}); + +test("a failed save keeps the draft and does not claim success", async () => { + await render(); + await click("Increase " + PARALLEL); + failSave = true; await click("Save"); + expect(input(PARALLEL).value).toBe("9"); + expect(button("Save").disabled).toBe(false); + expect(container.querySelector('[role="alert"]')?.textContent).toContain("Could not save workflow limits."); + expect(container.querySelector('[role="status"]')).toBeNull(); +}); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index efa3f4e689d..b80fd41ad6a 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -2,7 +2,7 @@ "version": 1, "root": "tests", "explicitShards": ["layout-clients.json", "layout-codex-collision.json", "layout-agent-assertion.json", "layout-chat.json", "layout-replay.json", "layout-adapters.json", "layout-jev.json", "layout-workbuddy.json", "layout-combos.json", "layout-credit-renewal.json", "layout-server-inbound.json", "layout-providers.json", "layout-responses.json", "layout-exec-boundary.json", "layout-persistent-dashboard.json", "layout-start-ownership.json", "layout-config.json", "layout-kiro-usage.json", "layout-usage.json"], - "explicit": { "service-desktop-recovery.test.ts": "service", "catalog-omit-disabled-native.test.ts": "codex-integration", "provider-input-modalities.test.ts": "providers", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "codex-provider-routing.test.ts": "codex-integration", "config-native-reasoning-retention.test.ts": "config", "server-native-reasoning-retention-merge.test.ts": "server", "server-native-reasoning-retention.test.ts": "server", "responses-retained-reasoning-stream-recovery.test.ts": "responses", "openai-native-hosted-search-history.test.ts": "adapters/openai", "responses-native-compact-search-history.test.ts": "responses", "config-hosted-search-projection.test.ts": "config", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "cli-provider-list-active.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "bridge-terminal-refusal.test.ts": "adapters", "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", "combo-terminal-refusal.test.ts": "routing", "server-terminal-refusal-wiring.test.ts": "server", "incomplete-reason-normalization.test.ts": "responses", "advisor-retention.test.ts": "advisor", "responses-native-key-cooldown.test.ts": "responses", "responses-run-turn-key-readmission.test.ts": "responses", "qoder-spawn-readmission.test.ts": "adapters", "api-key-selection-cooldown.test.ts": "providers", "api-key-auto-pause-distribution.test.ts": "providers", "explicit-key-pause-oauth-boundary.test.ts": "providers", "server-chat-native-key-selection.test.ts": "server", "compact-key-selection.test.ts": "responses", "messages-native-key-selection.test.ts": "claude-integration", "coding-agent-windows-dispatch.test.ts": "adapters", "coding-agent-native-empty-argv.test.ts": "windows", + "explicit": { "service-desktop-recovery.test.ts": "service", "catalog-omit-disabled-native.test.ts": "codex-integration", "provider-input-modalities.test.ts": "providers", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "codex-provider-routing.test.ts": "codex-integration", "config-native-reasoning-retention.test.ts": "config", "server-native-reasoning-retention-merge.test.ts": "server", "server-native-reasoning-retention.test.ts": "server", "config-workflow-budget.test.ts": "config", "server-workflow-budget-settings.test.ts": "server", "responses-retained-reasoning-stream-recovery.test.ts": "responses", "openai-native-hosted-search-history.test.ts": "adapters/openai", "responses-native-compact-search-history.test.ts": "responses", "config-hosted-search-projection.test.ts": "config", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "cli-provider-list-active.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "bridge-terminal-refusal.test.ts": "adapters", "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", "combo-terminal-refusal.test.ts": "routing", "server-terminal-refusal-wiring.test.ts": "server", "incomplete-reason-normalization.test.ts": "responses", "advisor-retention.test.ts": "advisor", "responses-native-key-cooldown.test.ts": "responses", "responses-run-turn-key-readmission.test.ts": "responses", "qoder-spawn-readmission.test.ts": "adapters", "api-key-selection-cooldown.test.ts": "providers", "api-key-auto-pause-distribution.test.ts": "providers", "explicit-key-pause-oauth-boundary.test.ts": "providers", "server-chat-native-key-selection.test.ts": "server", "compact-key-selection.test.ts": "responses", "messages-native-key-selection.test.ts": "claude-integration", "coding-agent-windows-dispatch.test.ts": "adapters", "coding-agent-native-empty-argv.test.ts": "windows", "stop-restore-history-ownership.test.ts": "service", "cli-stop-refusal-transport.test.ts": "cli", "service-proxy-env.test.ts": "service", "openai-chat-standalone-bare-block.test.ts": "adapters/openai", diff --git a/src/config/diagnostics.ts b/src/config/diagnostics.ts index 883c929146d..1e58125e44c 100644 --- a/src/config/diagnostics.ts +++ b/src/config/diagnostics.ts @@ -8,6 +8,7 @@ import { join } from "node:path"; import * as z from "zod/v4"; import { compactionRecoveryConfigError } from "./schema/compaction-recovery"; import { nativeReasoningRetentionConfigError } from "./schema/native-reasoning-retention"; +import { WORKFLOW_BUDGET_SHAPE_HINT, workflowBudgetConfigError } from "./schema/workflow-budget"; import { desktopCompatibilityConfigError } from "./schema/desktop-compatibility"; import { blockedModelRedirectsError } from "./schema/blocked-model-redirects"; import { mainAccountHardLockFallbackError } from "./schema/main-account-hard-lock-fallback"; @@ -145,6 +146,7 @@ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): Conf warnings.push(...degradedListenerWarnings(rawParsed, normalized)); if (blockedModelRedirectsError(rawParsed)) warnings.push("blockedModelRedirects ignored: expected a map of nonempty model keys to nonempty string targets"); if (nativeReasoningRetentionConfigError(rawParsed)) warnings.push("nativeReasoningRetention ignored: expected only optional boolean modelSwitch and accountSwitch fields; both remain off"); + if (workflowBudgetConfigError(rawParsed)) warnings.push("workflowBudget ignored: " + WORKFLOW_BUDGET_SHAPE_HINT + "; the built-in ceilings remain"); if (mainAccountHardLockFallbackError(rawParsed)) warnings.push("codexMainAccountHardLockFallback ignored: expected a nonblank model selector and an optional declared reasoningEffort"); if (fallbackPolicyError(rawParsed)) warnings.push("fallback partly ignored: expected boolean model/provider switches, providers/routes/devices maps of them, and devices..localFallback; ignored leaves keep today's fallback behavior"); const quotaAutoRefreshWarning = degradedCodexQuotaAutoRefreshWarning(rawParsed, normalized); @@ -735,6 +737,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx // drop a refusal on the next whole-config save; the write boundary rejects it instead. ?? fallbackPolicyError(value) ?? nativeReasoningRetentionConfigError(value) + ?? workflowBudgetConfigError(value) ?? mainAccountHardLockFallbackError(value) ?? compactionRecoveryConfigError(value) ?? desktopCompatibilityConfigError(value) ?? configReasoningPinsConfigError(value) ?? blankHostnameError(value) diff --git a/src/config/load-degrade.ts b/src/config/load-degrade.ts index 342ca547722..4ad2bbd2554 100644 --- a/src/config/load-degrade.ts +++ b/src/config/load-degrade.ts @@ -4,6 +4,7 @@ import { chmodSync, existsSync } from "node:fs"; import { join } from "node:path"; import { compactionRecoveryConfigError } from "./schema/compaction-recovery"; import { nativeReasoningRetentionConfigError } from "./schema/native-reasoning-retention"; +import { workflowBudgetConfigError } from "./schema/workflow-budget"; import { desktopCompatibilityConfigError } from "./schema/desktop-compatibility"; import { blockedModelRedirectsError } from "./schema/blocked-model-redirects"; import { mainAccountHardLockFallbackError } from "./schema/main-account-hard-lock-fallback"; @@ -137,6 +138,7 @@ export function warnDegradedCompactionRouting(rawParsed: unknown, validated: Ocx */ export function warnDegradedTopLevelOptIns(rawParsed: unknown, validated: OcxConfig): void { if (nativeReasoningRetentionConfigError(rawParsed)) console.warn("⚠️ invalid nativeReasoningRetention disabled; model and account switch allowances remain off"); + if (workflowBudgetConfigError(rawParsed)) console.warn("⚠️ invalid workflowBudget ignored; the built-in workflow ceilings stay in force"); setCodexWsReuseAcrossTurns(validated.codexWsReuseAcrossTurns === true); const retentionWarning = degradedReasoningRetentionWarning(rawParsed, validated); if (retentionWarning) console.warn(retentionWarning); diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index 2bacde053c6..9d3557f0fc2 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -6,6 +6,7 @@ import { accountPauseStateSchema } from "./account-auto-pause"; import { anthropicAccountPoolSchema } from "./anthropic-account-pool"; import { compactionRecoverySchema } from "./compaction-recovery"; import { nativeReasoningRetentionSchema } from "./native-reasoning-retention"; +import { workflowBudgetSchema } from "./workflow-budget"; import { desktopCompatibilitySchema } from "./desktop-compatibility"; import { blockedModelRedirectsSchema } from "./blocked-model-redirects"; import { mainAccountHardLockFallbackSchema } from "./main-account-hard-lock-fallback"; @@ -221,6 +222,8 @@ export const configSchema = z.object({ // A bad hand edit disables only this block; writes validate the catch-free leaf first. reasoningRetention: reasoningRetentionSchema.optional().catch(undefined), nativeReasoningRetention: nativeReasoningRetentionSchema.optional().catch(undefined), + // A bad hand edit falls back to the built-in ceilings; it never turns the guard off. + workflowBudget: workflowBudgetSchema.optional().catch(undefined), desktopCompatibility: desktopCompatibilitySchema.optional().catch(undefined), // A hand-edited malformed phase disables only that phase instead of rejecting // providers/apiKeys, matching the load-time degradation notice; the management write diff --git a/src/config/schema/workflow-budget.ts b/src/config/schema/workflow-budget.ts new file mode 100644 index 00000000000..7b34fb214b9 --- /dev/null +++ b/src/config/schema/workflow-budget.ts @@ -0,0 +1,43 @@ +import * as z from "zod/v4"; +import type { OcxConfig } from "../../types"; + +/** + * Per-install bounds for the root-workflow count ceilings. + * + * The lower concurrency bound is two because one slot is always held back for the interactive + * turn; at one, every worker turn would be refused. The upper bounds keep each ceiling a + * ceiling: a value large enough to never fire is a way of switching the guard off, and that is + * not what this setting is for. + */ +export const WORKFLOW_BUDGET_BOUNDS = { + maxConcurrentChildren: { min: 2, max: 64 }, + maxPhysicalSends: { min: 1, max: 10_000 }, + maxDistinctChildren: { min: 1, max: 1_024 }, +} as const; + +const bounded = (key: keyof typeof WORKFLOW_BUDGET_BOUNDS) => + z.number().int().min(WORKFLOW_BUDGET_BOUNDS[key].min).max(WORKFLOW_BUDGET_BOUNDS[key].max).optional(); + +/** Omitted fields keep the built-in ceiling, so an absent block is today's behavior byte for byte. */ +export const workflowBudgetSchema = z.object({ + maxConcurrentChildren: bounded("maxConcurrentChildren"), + maxPhysicalSends: bounded("maxPhysicalSends"), + maxDistinctChildren: bounded("maxDistinctChildren"), +}).strict(); + +export type WorkflowBudgetConfig = z.infer; + +export const WORKFLOW_BUDGET_SHAPE_HINT = "accepts only optional integer maxConcurrentChildren (2-64), maxPhysicalSends (1-10000) and maxDistinctChildren (1-1024)"; + +export function workflowBudgetConfigError(value: unknown): string | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const budget = (value as Record).workflowBudget; + return budget === undefined || workflowBudgetSchema.safeParse(budget).success + ? null : "schema_invalid: workflowBudget: " + WORKFLOW_BUDGET_SHAPE_HINT; +} + +/** Malformed input resolves to no overrides, the same way a degraded disk block does. */ +export function readWorkflowBudgetConfig(config: Pick): WorkflowBudgetConfig { + const parsed = workflowBudgetSchema.safeParse(config.workflowBudget); + return parsed.success && parsed.data ? parsed.data : {}; +} diff --git a/src/lib/state-store-registrations.ts b/src/lib/state-store-registrations.ts index 2eb6488eb39..22973b92f58 100644 --- a/src/lib/state-store-registrations.ts +++ b/src/lib/state-store-registrations.ts @@ -11,6 +11,7 @@ import { reconcilePoolRotationState } from "../codex/pool-rotation"; import { reconcileCodexQuotaAccounts } from "../codex/quota"; import { reconcileQuotaRecovery, sweepExpiredQuotaRecovery } from "../codex/quota-401-recovery"; import { bindUpstreamRequestCompressionConfig } from "../server/responses/request-compression"; +import { bindWorkflowBudgetConfig } from "./workflow-budget-config"; import { listLiveCodexAccountIds, reconcileCodexRoutingHealth, @@ -82,6 +83,7 @@ export function setLiveStateStoreConfig(config: OcxConfig): void { // The one place the server's live config object is installed; detached configs (catalog sync) // never come through here, so the outbound compression opt-in follows the running server only. bindUpstreamRequestCompressionConfig(config); + bindWorkflowBudgetConfig(config); notifyLiveConfigObservers(config); } diff --git a/src/lib/workflow-budget-config.ts b/src/lib/workflow-budget-config.ts new file mode 100644 index 00000000000..26905568183 --- /dev/null +++ b/src/lib/workflow-budget-config.ts @@ -0,0 +1,39 @@ +/** + * The workflow-budget policy the running server enforces. + * + * The ceilings in workflow-budget.ts are module defaults, and a busy install that legitimately + * runs more parallel subagents than they allow had no way to say so short of patching the + * package. This binds the live config the same way the outbound compression opt-in does: the + * object is installed once, read per decision, and a dashboard write that mutates it in place + * takes effect on the next request without a restart. + * + * Only the three count ceilings move. The window, the interactive reserve and the tracked-root + * bound stay where they are: the window fixes each root's ring geometry when the root first + * appears, so changing it live would leave existing roots measured on a scale no caller reads. + */ +import { readWorkflowBudgetConfig } from "../config/schema/workflow-budget"; +import type { OcxConfig } from "../types"; +import { DEFAULT_WORKFLOW_BUDGET_POLICY, type WorkflowBudgetPolicy } from "./workflow-budget"; + +let boundConfig: Pick | null = null; + +export function bindWorkflowBudgetConfig(config: Pick | null): void { + boundConfig = config; +} + +export function resolveWorkflowBudgetPolicy(config: Pick): WorkflowBudgetPolicy { + const overrides = readWorkflowBudgetConfig(config); + return { + ...DEFAULT_WORKFLOW_BUDGET_POLICY, + ...(overrides.maxConcurrentChildren !== undefined ? { maxConcurrentChildren: overrides.maxConcurrentChildren } : {}), + ...(overrides.maxPhysicalSends !== undefined ? { maxPhysicalSends: overrides.maxPhysicalSends } : {}), + ...(overrides.maxDistinctChildren !== undefined ? { maxDistinctChildren: overrides.maxDistinctChildren } : {}), + }; +} + +/** Policy for the next admission or ceiling check; an unset block is the module default itself. */ +export function currentWorkflowBudgetPolicy(): WorkflowBudgetPolicy { + return boundConfig?.workflowBudget === undefined + ? DEFAULT_WORKFLOW_BUDGET_POLICY + : resolveWorkflowBudgetPolicy(boundConfig); +} diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index 4b10087659d..0c48f6e5973 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -396,6 +396,8 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "PATCH", path: "/api/protocols/settings", module: "server/management/protocol-routes", mutates: true }, { method: "GET", path: "/api/workflow-budget", module: "server/management/workflow-budget-routes", mutates: false, exempt: { reason: "deferred-verb", why: "Reading a root's live budget is owed a CLI verb -- an operator staring at a 429 is usually already in a terminal -- but the ledger is process memory with no local transport to read it through, so the verb has to be an HTTP call the CLI does not yet make.", owner: "260915_workflow_budget_window wfc", ownerDoc: "devlog/_fin/260915_workflow_budget_window/030_wfc_diff_plan.md" } }, { method: "POST", path: "/api/workflow-budget/clear", module: "server/management/workflow-budget-routes", mutates: true, exempt: { reason: "deferred-verb", why: "Clearing one root is owed the same verb as the read above and for the same reason. It is deliberately not shipped as a verb in this work-phase: the read comes first, because an operator who cannot see which ceiling fired has no basis for deciding to forgive it.", owner: "260915_workflow_budget_window wfc", ownerDoc: "devlog/_fin/260915_workflow_budget_window/030_wfc_diff_plan.md" } }, + { method: "GET", path: "/api/workflow-budget/settings", module: "server/management/workflow-budget-routes", mutates: false, exempt: { reason: "local-transport", why: "The CLI reads the same workflowBudget block through ocx config get; the route only adds the built-in defaults and bounds the dashboard draws." } }, + { method: "PUT", path: "/api/workflow-budget/settings", module: "server/management/workflow-budget-routes", mutates: true, exempt: { reason: "deferred-verb", why: "A dedicated live CLI verb for the ceilings is deferred with the other workflow-budget verbs; ocx config set/unset edits the same block on disk and the running proxy picks it up on restart.", owner: "260915_workflow_budget_window wfc", ownerDoc: "devlog/_fin/260915_workflow_budget_window/030_wfc_diff_plan.md" } }, // server/management/request-history-routes { method: "GET", path: "/api/request-history", module: "server/management/request-history-routes", mutates: false }, // server/management/routing-analytics-routes diff --git a/src/server/management/workflow-budget-routes.ts b/src/server/management/workflow-budget-routes.ts index 4cdebd4572f..d252db0b2ba 100644 --- a/src/server/management/workflow-budget-routes.ts +++ b/src/server/management/workflow-budget-routes.ts @@ -10,14 +10,27 @@ * chain runs, so these handlers add no auth code of their own. The GET spends no user identity. * The POST clears one root's windowed count ceilings; it does not spend identity, and the * underlying ledger leaves in-flight concurrency and the token spend record untouched. + * + * `/api/workflow-budget/settings` reads and writes the per-install count ceilings. A write + * mutates the live config object, which the request path reads per decision, so it applies to + * the next admission without a restart and never resets what a root has already used. */ import { jsonResponse } from "../auth-cors"; import type { OcxConfig } from "../../types"; import type { ManagementContext } from "./context"; import { readManagementJsonBodyOr } from "./body"; +import { captureConfigTopLevelRollback, deleteConfigTopLevelKey } from "../../config/rebase-provenance"; +import { ConfigWritePublishedError } from "../../config/persist-unlocked"; +import { + WORKFLOW_BUDGET_BOUNDS, + WORKFLOW_BUDGET_SHAPE_HINT, + workflowBudgetSchema, +} from "../../config/schema/workflow-budget"; +import { resolveWorkflowBudgetPolicy } from "../../lib/workflow-budget-config"; import { clearWorkflowBudgetForRoot, + DEFAULT_WORKFLOW_BUDGET_POLICY, listTrackedWorkflowRoots, listWorkflowBudgetEvents, workflowBudgetSnapshot, @@ -27,6 +40,54 @@ import { const DEFAULT_LIMIT = 20; const MAX_LIMIT = WORKFLOW_EVENT_CAPACITY; const MAX_ROOT_ID_LENGTH = 200; +const INVALID_BODY = Symbol("invalid-body"); + +/** Effective ceilings plus what the dashboard needs to draw them: defaults, bounds, the fixed window. */ +function workflowBudgetSettings(config: OcxConfig) { + const policy = resolveWorkflowBudgetPolicy(config); + return { + maxConcurrentChildren: policy.maxConcurrentChildren, + maxPhysicalSends: policy.maxPhysicalSends, + maxDistinctChildren: policy.maxDistinctChildren, + interactiveReserve: policy.interactiveReserve, + windowMs: DEFAULT_WORKFLOW_BUDGET_POLICY.windowMs, + defaults: { + maxConcurrentChildren: DEFAULT_WORKFLOW_BUDGET_POLICY.maxConcurrentChildren, + maxPhysicalSends: DEFAULT_WORKFLOW_BUDGET_POLICY.maxPhysicalSends, + maxDistinctChildren: DEFAULT_WORKFLOW_BUDGET_POLICY.maxDistinctChildren, + }, + bounds: WORKFLOW_BUDGET_BOUNDS, + }; +} + +/** Partial PUTs preserve omitted ceilings; JSON null removes the block and restores the defaults. */ +async function putWorkflowBudgetSettings(ctx: ManagementContext): Promise { + const { req, config } = ctx; + const body = await readManagementJsonBodyOr(req, INVALID_BODY); + const parsed = body === null ? null : workflowBudgetSchema.safeParse(body); + if (parsed !== null && !parsed.success) { + return jsonResponse( + { error: { code: "invalid_workflow_budget", message: "body must be null or an object that " + WORKFLOW_BUDGET_SHAPE_HINT } }, + 400, + req, + config, + ); + } + // Resolve the saver before changing the live object: no await may interleave mutation/save. + const persist = ctx.deps.saveConfigPreservingClaudeCode + ?? (await import("../../config")).saveConfigPreservingClaudeCode; + const restore = captureConfigTopLevelRollback(config, ["workflowBudget"]); + try { + if (parsed === null) deleteConfigTopLevelKey(config, "workflowBudget"); + else config.workflowBudget = { ...config.workflowBudget, ...parsed.data }; + persist(config); + } catch (error) { + // Published bytes remain authoritative even if later bookkeeping failed. + if (!(error instanceof ConfigWritePublishedError)) restore(); + throw error; + } + return jsonResponse(workflowBudgetSettings(config), 200, req, config); +} /** * A root id is an opaque caller-thread token, not a path. Without a length cap a client @@ -60,6 +121,12 @@ function parseLimitParam(rawLimit: string | null): { ok: true; limit: number } | export async function handleWorkflowBudgetRoutes(ctx: ManagementContext): Promise { const { url, req, config } = ctx; + if (url.pathname === "/api/workflow-budget/settings" && req.method === "GET") { + return jsonResponse(workflowBudgetSettings(config), 200, req, config); + } + if (url.pathname === "/api/workflow-budget/settings" && req.method === "PUT") return putWorkflowBudgetSettings(ctx); + const policy = resolveWorkflowBudgetPolicy(config); + if (url.pathname === "/api/workflow-budget") { if (req.method !== "GET") return null; @@ -78,7 +145,7 @@ export async function handleWorkflowBudgetRoutes(ctx: ManagementContext): Promis if (rawRoot !== null) { const rootId = parseRootId(rawRoot); if (rootId === null) return invalidRootResponse(req, config); - const snapshot = workflowBudgetSnapshot(rootId); + const snapshot = workflowBudgetSnapshot(rootId, policy); // An unknown id is a 200 with `root: null`, not a 404: the operator asked what this // process currently holds for that token, and "nothing" is a legitimate answer. POST // /clear is the opposite — claiming to forgive a ceiling that was never tracked would @@ -98,7 +165,7 @@ export async function handleWorkflowBudgetRoutes(ctx: ManagementContext): Promis return jsonResponse( { - roots: listTrackedWorkflowRoots(limit), + roots: listTrackedWorkflowRoots(limit, policy), events: listWorkflowBudgetEvents(limit), }, 200, @@ -117,7 +184,7 @@ export async function handleWorkflowBudgetRoutes(ctx: ManagementContext): Promis const rootId = parseRootId(rawRoot); if (rootId === null) return invalidRootResponse(req, config); - const before = clearWorkflowBudgetForRoot(rootId); + const before = clearWorkflowBudgetForRoot(rootId, policy); if (!before) { return jsonResponse( { error: { code: "unknown_root", message: "root is not currently tracked" } }, diff --git a/src/server/responses/request-send-budget.ts b/src/server/responses/request-send-budget.ts index 27f1c5ac18e..0417a6adcf5 100644 --- a/src/server/responses/request-send-budget.ts +++ b/src/server/responses/request-send-budget.ts @@ -6,6 +6,7 @@ import { workflowSendCeilingReached, workflowSpendCeilingReached, } from "../../lib/workflow-budget"; +import { currentWorkflowBudgetPolicy } from "../../lib/workflow-budget-config"; import { poolContinuityRefusalReason, workflowRefusalResponse } from "../workflow-refusal"; import type { AttemptRecoveryKind, AttemptRecoveryWithheld } from "../../usage/log"; import { noteAttemptRecoveryWithheld, noteAttemptSend } from "../request-log"; @@ -93,7 +94,7 @@ export function createResponsesSendBudget( // dropping the record to make room would hand the fan-out a fresh allowance, which is the // laundering this ceiling exists to stop. The client is told the task needs a new grant // rather than being given a synthetic upstream error. - if (workflowSendCeilingReached(workflowRootId)) { + if (workflowSendCeilingReached(workflowRootId, currentWorkflowBudgetPolicy())) { // A log context exists here, unlike at HTTP admission, so the row this request writes is // marked synthetic rather than reading as a request that vanished with zero sends. return workflowRefusalResponse("workflow-sends-exhausted", logCtx, undefined, workflowRootId); diff --git a/src/server/workflow-refusal.ts b/src/server/workflow-refusal.ts index 58dab886a53..61c7b943b8f 100644 --- a/src/server/workflow-refusal.ts +++ b/src/server/workflow-refusal.ts @@ -27,6 +27,7 @@ import { type WorkflowLane, type WorkflowSpendDenialDetail, } from "../lib/workflow-budget"; +import { currentWorkflowBudgetPolicy } from "../lib/workflow-budget-config"; /** * What a caller needs to hand over for the refusal to become a row on `/api/logs`. @@ -142,7 +143,7 @@ export function admitHttpWorkflowTurn(headers: Headers): WorkflowDecision | unde const lane: WorkflowLane = rootId !== undefined && threadId !== undefined && threadId !== rootId ? "worker" : "interactive"; - return admitWorkflowTurn(rootId, lane, undefined, threadId); + return admitWorkflowTurn(rootId, lane, currentWorkflowBudgetPolicy(), threadId); } /** diff --git a/src/types/config.ts b/src/types/config.ts index b3ca6bd575c..9a0e49e8aba 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -875,6 +875,8 @@ export interface OcxConfig { reasoningRetention?: { enabled?: boolean; maxContextPercent?: number; maxTokens?: number }; /** Canonical ChatGPT reasoning replay allowances; both default off. */ nativeReasoningRetention?: { modelSwitch?: boolean; accountSwitch?: boolean }; + /** Per-root fan-out ceilings (src/lib/workflow-budget.ts); omitted fields keep 8 / 256 / 64. */ + workflowBudget?: { maxConcurrentChildren?: number; maxPhysicalSends?: number; maxDistinctChildren?: number }; /** Explicit opt-in; resumes Observe only using an existing trusted certificate and endpoints. */ desktopCompatibility?: { startOnProxyStart: boolean }; /** diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 898df8af8d1..ef49522edeb 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -313,7 +313,7 @@ after the save, and a non-retryable skip (no managed catalog) is a clean save. C | Claude reset grants | `src/server/management/anthropic-reset-grant-routes.ts` — `GET /api/anthropic/reset-grants`, `POST /api/anthropic/reset-grants/consume` (lazy-loaded). Wire and fail-closed parsing live in `src/providers/anthropic-reset-grants.ts` (the Claude Code 2.1.278 `cedar_ember` contract, sent with `CLAUDE_CLI_USER_AGENT` from `src/providers/claude-cli-identity.ts`); the journal is `src/providers/anthropic-reset-grant-ledger.ts`: a cross-process `BEGIN IMMEDIATE` lock around every synchronous read-modify-write, a 90 s lease, the operation id reused as the upstream `request_id`, same-id retry only inside the vendor's ten-minute window, no settlement inferred from a re-read, and a fail-closed `500 journal_write_failed` when an answer cannot be recorded. Spending requires the `gui-session` principal. A journaled `reset`, including a same-id replay of that code, lifts that account cooldown only when it is a 429 quota refusal recorded before the operation was first journaled (the claim fence, `claimedAt`) and the journal's cleared windows cover every window the refusal named (both when it named none); it then drops the cached quota row and rereads usage with its own request rather than joining an older read. Model-family exclusions stay until the usage reread proves a family is no longer rejected. A failed reread leaves them in place. The HTTP result stays the journal answer if the reread fails. Other result codes do not admit. The dashboard owner is `gui/src/hooks/useAnthropicResetGrants.ts` with `gui/src/components/provider-workspace/AnthropicResetGrants.tsx` on the Anthropic OAuth rows of `ProviderAuthPanel`; after an unknown outcome the dialog only retries the same id. Design and audit record: [`../devlog/_fin/260923_claude_reset_grants/010_plan.md`](../devlog/_fin/260923_claude_reset_grants/010_plan.md). | | Combos | `src/server/management/combo-routes.ts` — `GET/PUT/DELETE /api/combos` own provider combination and failover definitions. `PUT` keeps a stored field the body omits (`cooldownMs`, `waitForCooldownMs`, `defaultEffortMode`, `reasoningEffortMode`, `imageInput`, `cooldownWaitPolicy`, per-target `lastResort`, JEV `decisionQuotaSignals`/`decisionQuotaTiers`/`sessionPin` on updates/rename); quota false/null clears sparsely and switching away removes quota policy under [the owning contract](providers/jev-decision.md#loaded-advisory-quota). Explicit values replace it and defaults are stored sparse. A combo validation 400 is `{ error, field? }`, where `field` is the top-level combo field of the first issue. | | Protocol paths | `src/server/management/protocol-routes.ts` (lazy-loaded) — `GET /api/protocols` returns the contract version, the resolved API surfaces and protocol settings, the policy revision and the feature vocabulary; with `?provider=` (one non-empty name of at most 200 characters without control characters, else 400 `invalid_provider`; an unconfigured name is 404 `unknown_provider`, neither echoing the name) it adds a `provider` block from `src/protocols/provider-summary.ts` ([Protocol Paths](data-planes/protocol-paths.md#provider-wire-summary)); `POST /api/protocols/plan` takes `{ model, inbound, features? }` (model at most 200 characters, at most 24 features, any other key refused with 400) and returns a `ProtocolPlanV1` with `basis: "preview"` from `src/protocols/plan-snapshot.ts`. Both are read-only, never log their input, and send nothing upstream. `PATCH /api/protocols/settings` takes `{ messagesEnabled?, unrepresentable?, rollout? }` (strict: unknown keys and wrong types are 400), validates and applies through `src/server/management/protocol-settings-patch.ts`, persists with `saveConfigPreservingClaudeCode`, restores the live config if the save fails (409 on lock contention, 500 otherwise), and answers with the fresh `GET /api/protocols` body; closing Messages also writes `claudeCode.enabled = false` through `commitClaudeCodeBlock` ([Protocol Paths](data-planes/protocol-paths.md#settings)). The CLI drives all three through `ocx api protocols`, `ocx api explain` and `ocx api policy` ([Protocol Paths](data-planes/protocol-paths.md#cli)); none carries a route-registry exemption. | -| Workflow budget | `src/server/management/workflow-budget-routes.ts` — `GET /api/workflow-budget` reads the tracked roots or one root, and `POST /api/workflow-budget/clear` clears exactly one. The clear moves the windowed send ring and the child map and nothing else: `active` belongs to turns still in flight, the spend ledger is a token budget an operator did not ask to forgive, and the lifetime send total survives so a clear cannot launder the record. A refusal event carries `spendScope` and `spendLimit` when a token ceiling fired, so the reason is readable without the config open beside it; no scope id is ever attached, because root ids are client thread headers and identity ids are credentials. Both are `deferred-verb` in the route registry — they are owed CLI verbs, and because the ledger is process memory there is no local projection the CLI could read instead. See [`../devlog/_fin/260915_workflow_budget_window/030_wfc_diff_plan.md`](../devlog/_fin/260915_workflow_budget_window/030_wfc_diff_plan.md). | +| Workflow budget | `src/server/management/workflow-budget-routes.ts` — `GET /api/workflow-budget` reads the tracked roots or one root, and `POST /api/workflow-budget/clear` clears exactly one. The clear moves the windowed send ring and the child map and nothing else: `active` belongs to turns still in flight, the spend ledger is a token budget an operator did not ask to forgive, and the lifetime send total survives so a clear cannot launder the record. A refusal event carries `spendScope` and `spendLimit` when a token ceiling fired, so the reason is readable without the config open beside it; no scope id is ever attached, because root ids are client thread headers and identity ids are credentials. Both are `deferred-verb` in the route registry — they are owed CLI verbs, and because the ledger is process memory there is no local projection the CLI could read instead. `GET`/`PUT /api/workflow-budget/settings` read and write the per-install count ceilings (`workflowBudget` in config: concurrent turns, sends and distinct children per window); the live config is bound in `src/lib/workflow-budget-config.ts` and read per decision by HTTP admission and the pre-dispatch send ceiling, so a write applies to the next request and never resets a root's usage. The window, interactive reserve and tracked-root bound stay fixed because a root's ring geometry is fixed when it first appears. The GET is `local-transport` (same block via `ocx config get`), the PUT `deferred-verb` with the other budget verbs. See [`../devlog/_fin/260915_workflow_budget_window/030_wfc_diff_plan.md`](../devlog/_fin/260915_workflow_budget_window/030_wfc_diff_plan.md). | | Codex accounts | `src/codex/auth-api/routes.ts` — `GET/POST/DELETE /api/codex-auth/accounts`, `PUT /api/codex-auth/accounts/alias`, `PUT /api/codex-auth/accounts/pause`, `PUT /api/codex-auth/accounts/pause-exhausted`, `POST /api/codex-auth/accounts/clear-cooldown`, `GET/PUT /api/codex-auth/active`, `PUT /api/codex-auth/auto-switch`, `PUT /api/codex-auth/pool-strategy`, `PUT /api/codex-auth/failover`, `GET /api/codex-auth/quota`, `GET /api/codex-auth/reset-credits` with `POST /api/codex-auth/reset-credits/consume`, and the login flow `POST /api/codex-auth/login`, `POST /api/codex-auth/login/code`, `POST /api/codex-auth/login/cancel`, `GET /api/codex-auth/login-status`. Per-account quota activation uses the existing `GET/PUT /api/settings` surface and `src/codex/quota-auto-refresh.ts`, keeping scheduled spending separate from credential/authentication mutation. Account ids are opaque handles and are serialized so the GUI can address an account; emails are masked and tokens are never serialized. New-account config commits add UI-managed selector bindings in the same config save; deletion deliberately retains existing bindings for fail-closed exact routing and re-add stability. Account mutations request catalog convergence only after config durability and expose only the boolean `catalogRefreshPending` completion projection. `showCodexCredits` is a default-off boolean in settings GET and successful PUT; partial PUT accepts a boolean and rolls back on persistence failure. See [credits exposure](providers/openai-accounts.md#display-only-codex-credits). | | Low-quota history | `src/server/management/low-quota-routes.ts` lazily serves authenticated `GET /api/codex-auth/low-quota-events` from the bounded ledger owned by the server handling the request. The response includes only that server’s account ids and notice/pause-save status; logs omit identifiers. The default notice records `logged`; a notice records `delivered` only after the injected or opted-in OS notifier sink succeeds, `failed` when it fails, and `cancelled` when its episode ends first, while a pause-save records `succeeded` after persistence succeeds. A timed-out in-flight save remains `pending` until it settles. Invalid limits return 400. The route itself emits no OS notification. | | Sidebar | `src/server/management/sidebar-routes.ts` — `GET/POST /api/github/star`, `GET /api/update/badge`, and `POST /api/update/desktop-snapshot`. The snapshot POST accepts the raw admin-token principal or the dedicated `local-desktop-snapshot-capability`; GUI sessions and requests carrying `Origin` cannot publish desktop state. A capability's bounded raw body is verified against its authenticated digest before JSON parsing or storage. Badge state is cosmetic and a failed poll degrades silently. | diff --git a/tests/cli/cli-headless-parity.test.ts b/tests/cli/cli-headless-parity.test.ts index ba4cdb8eb78..c304144e182 100644 --- a/tests/cli/cli-headless-parity.test.ts +++ b/tests/cli/cli-headless-parity.test.ts @@ -610,6 +610,7 @@ describe("headless GUI parity CLI", () => { ["/api/protocols", "ocx api protocols/explain/policy"], ["/api/settings", "ocx system"], ["/api/native-reasoning-retention", "(none — dedicated live CLI deferred; local policy via ocx config)"], + ["/api/workflow-budget/settings", "(none — dedicated live CLI deferred; local ceilings via ocx config)"], // Routing Intelligence (RI-04..RI-10): profiles + dry-run are mirrored by // `ocx route policy`. Analytics is GUI-first for now; the same request // history remains available through observe/index tooling. diff --git a/tests/config/config-workflow-budget.test.ts b/tests/config/config-workflow-budget.test.ts new file mode 100644 index 00000000000..fb4d0e98408 --- /dev/null +++ b/tests/config/config-workflow-budget.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import { getDefaultConfig, validateConfigCandidate } from "../../src/config"; +import { configDiagnosticsFromRaw } from "../../src/config/diagnostics"; +import { configSchema } from "../../src/config/schema/config-schema"; +import { warnDegradedTopLevelOptIns } from "../../src/config/load-degrade"; +import { resolveWorkflowBudgetPolicy } from "../../src/lib/workflow-budget-config"; +import { DEFAULT_WORKFLOW_BUDGET_POLICY } from "../../src/lib/workflow-budget"; + +const invalid = [null, [], true, "8", { maxConcurrentChildren: 1 }, { maxConcurrentChildren: 65 }, + { maxPhysicalSends: 0 }, { maxPhysicalSends: 2.5 }, { maxDistinctChildren: "64" }, + { maxDistinctChildren: 1_025 }, { windowMs: 60_000 }, { maxConcurrentChildren: 8, interactiveReserve: 0 }]; + +describe("workflow budget configuration", () => { + test("an absent block enforces the built-in policy itself", () => { + expect(getDefaultConfig().workflowBudget).toBeUndefined(); + expect(resolveWorkflowBudgetPolicy(getDefaultConfig())).toEqual(DEFAULT_WORKFLOW_BUDGET_POLICY); + }); + + test("valid partial blocks override only the named ceilings", () => { + const candidate = { ...getDefaultConfig(), workflowBudget: { maxConcurrentChildren: 16, maxPhysicalSends: 1_024 } }; + const parsed = validateConfigCandidate(candidate); + expect(parsed.ok).toBe(true); + if (!parsed.ok) return; + expect(parsed.config.workflowBudget).toEqual({ maxConcurrentChildren: 16, maxPhysicalSends: 1_024 }); + expect(resolveWorkflowBudgetPolicy(parsed.config)).toEqual({ + ...DEFAULT_WORKFLOW_BUDGET_POLICY, maxConcurrentChildren: 16, maxPhysicalSends: 1_024, + }); + }); + + test("invalid writes are refused while an invalid disk block falls back to the built-in ceilings", () => { + for (const budget of invalid) { + const candidate = { ...getDefaultConfig(), workflowBudget: budget }; + const result = validateConfigCandidate(candidate); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("workflowBudget"); + const parsed = configSchema.parse(candidate); + expect(parsed.workflowBudget).toBeUndefined(); + expect(parsed.providers).toEqual(candidate.providers); + expect(resolveWorkflowBudgetPolicy(parsed)).toEqual(DEFAULT_WORKFLOW_BUDGET_POLICY); + const diagnostics = configDiagnosticsFromRaw(JSON.stringify(candidate)); + expect(diagnostics.error).toBeNull(); + expect(diagnostics.warnings?.join(" ")).toContain("workflowBudget ignored"); + } + }); + + test("loading a malformed block warns instead of failing silently", () => { + const warning = spyOn(console, "warn").mockImplementation(() => {}); + try { + const raw = { ...getDefaultConfig(), workflowBudget: { maxConcurrentChildren: 0 } }; + warnDegradedTopLevelOptIns(raw, configSchema.parse(raw)); + expect(warning.mock.calls.some(call => String(call[0]).includes("invalid workflowBudget"))).toBe(true); + } finally { + warning.mockRestore(); + } + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index e01f098a460..3376f412bf1 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -88,7 +88,7 @@ "protocol-argon-thought-boundary.test.ts": "responses", "responses-auto-pause-replay-owner.test.ts": "responses", "responses-account-auto-pause.test.ts": "responses", "oauth-account-auto-pause.test.ts": "oauth", "responses-account-auto-pause-wire-owner.test.ts": "responses", "oauth-upsert-paused-api-key.test.ts": "oauth", "management-account-auto-pause.test.ts": "server", "cli-account-key-pause.test.ts": "cli", "oauth-account-auto-pause-revision.test.ts": "oauth", "config-account-auto-pause-revisions.test.ts": "config", "codex-provider-routing.test.ts": "codex-integration", - "claude-picker-trust-windows.test.ts": "claude-integration", "claude-picker-windows.test.ts": "claude-integration", "config-native-reasoning-retention.test.ts": "config", "server-native-reasoning-retention-merge.test.ts": "server", "server-native-reasoning-retention.test.ts": "server", "responses-retained-reasoning-stream-recovery.test.ts": "responses", + "claude-picker-trust-windows.test.ts": "claude-integration", "claude-picker-windows.test.ts": "claude-integration", "config-native-reasoning-retention.test.ts": "config", "server-native-reasoning-retention-merge.test.ts": "server", "server-native-reasoning-retention.test.ts": "server", "config-workflow-budget.test.ts": "config", "server-workflow-budget-settings.test.ts": "server", "responses-retained-reasoning-stream-recovery.test.ts": "responses", "advisor-retention.test.ts": "advisor", "responses-native-key-cooldown.test.ts": "responses", "responses-run-turn-key-readmission.test.ts": "responses", "qoder-spawn-readmission.test.ts": "adapters", "coding-agent-windows-dispatch.test.ts": "adapters", "coding-agent-native-empty-argv.test.ts": "windows", "api-key-selection-cooldown.test.ts": "providers", "api-key-auto-pause-distribution.test.ts": "providers", "explicit-key-pause-oauth-boundary.test.ts": "providers", "server-chat-native-key-selection.test.ts": "server", "compact-key-selection.test.ts": "responses", "messages-native-key-selection.test.ts": "claude-integration", diff --git a/tests/server/server-workflow-budget-settings.test.ts b/tests/server/server-workflow-budget-settings.test.ts new file mode 100644 index 00000000000..9ca61c44da5 --- /dev/null +++ b/tests/server/server-workflow-budget-settings.test.ts @@ -0,0 +1,150 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { getDefaultConfig } from "../../src/config"; +import { ConfigWritePublishedError } from "../../src/config/persist-unlocked"; +import { configRebaseDeletionKeys } from "../../src/config/rebase-provenance"; +import { handleManagementAPI } from "../../src/server/management-api"; +import { handleWorkflowBudgetRoutes } from "../../src/server/management/workflow-budget-routes"; +import type { ManagementContext } from "../../src/server/management/context"; +import { admitHttpWorkflowTurn } from "../../src/server/workflow-refusal"; +import { bindWorkflowBudgetConfig, currentWorkflowBudgetPolicy } from "../../src/lib/workflow-budget-config"; +import { + admitWorkflowTurn, + chargeWorkflowSends, + resetWorkflowBudgetsForTest, + workflowSendCeilingReached, + type WorkflowAdmission, +} from "../../src/lib/workflow-budget"; +import type { OcxConfig } from "../../src/types"; +import { ManagementRequest } from "../helpers/management-auth"; + +const defaults = { maxConcurrentChildren: 8, maxPhysicalSends: 256, maxDistinctChildren: 64 }; +const PATH = "/api/workflow-budget/settings"; + +function harness(saveError?: Error) { + const config = getDefaultConfig(); + const saved: OcxConfig[] = []; + async function call(method: string, body?: unknown, rawBody?: string, pathname = PATH) { + const url = new URL("http://localhost" + pathname); + return handleWorkflowBudgetRoutes({ + url, req: new Request(url, { method, ...(method === "GET" ? {} : { + headers: { "content-type": "application/json" }, body: rawBody ?? JSON.stringify(body), + }) }), + config, version: "test", deps: { saveConfigPreservingClaudeCode: (candidate: OcxConfig) => { + saved.push(structuredClone(candidate)); + if (saveError) throw saveError; + } }, + } as unknown as ManagementContext); + } + return { config, saved, call }; +} + +function workerHeaders(child: string): Headers { + return new Headers({ "x-codex-parent-thread-id": "root-live", "thread-id": child }); +} + +beforeEach(() => { resetWorkflowBudgetsForTest(); }); +afterEach(() => { bindWorkflowBudgetConfig(null); resetWorkflowBudgetsForTest(); }); + +describe("workflow budget settings route", () => { + test("management dispatch serves the effective ceilings with defaults, bounds and the fixed window", async () => { + const config = getDefaultConfig(); + const url = new URL("http://127.0.0.1:10100" + PATH); + const response = await handleManagementAPI(new ManagementRequest(url), url, config, {}); + expect(response?.status).toBe(200); + expect(await response!.json()).toEqual({ + ...defaults, interactiveReserve: 1, windowMs: 600_000, defaults, + bounds: { + maxConcurrentChildren: { min: 2, max: 64 }, + maxPhysicalSends: { min: 1, max: 10_000 }, + maxDistinctChildren: { min: 1, max: 1_024 }, + }, + }); + }); + + test("PUT preserves omitted ceilings, persists the block and null restores the defaults", async () => { + const h = harness(); + const original = structuredClone(h.config); + expect((await h.call("PUT", { maxConcurrentChildren: 16 }))?.status).toBe(200); + const second = await (await h.call("PUT", { maxPhysicalSends: 1_024 }))!.json() as Record; + expect(second).toMatchObject({ maxConcurrentChildren: 16, maxPhysicalSends: 1_024, maxDistinctChildren: 64 }); + expect(h.config.workflowBudget).toEqual({ maxConcurrentChildren: 16, maxPhysicalSends: 1_024 }); + const { workflowBudget: _budget, ...rest } = h.config; + expect(rest).toEqual(original); + const reset = await (await h.call("PUT", null))!.json() as Record; + expect(reset).toMatchObject(defaults); + expect(h.config.workflowBudget).toBeUndefined(); + expect(configRebaseDeletionKeys(h.config).has("workflowBudget")).toBe(true); + expect(h.saved).toHaveLength(3); + }); + + test("out-of-range, unknown and malformed bodies are refused before mutation or save", async () => { + const h = harness(); + h.config.workflowBudget = { maxConcurrentChildren: 12 }; + const original = structuredClone(h.config); + for (const body of [[], true, { maxConcurrentChildren: 1 }, { maxPhysicalSends: 10_001 }, + { maxDistinctChildren: 3.5 }, { windowMs: 1 }, { maxConcurrentChildren: "12" }]) { + expect((await h.call("PUT", body))?.status).toBe(400); + } + expect((await h.call("PUT", undefined, "{"))?.status).toBe(400); + expect(h.config).toEqual(original); + expect(h.saved).toHaveLength(0); + }); + + test("a failed save restores the previous block; a published write keeps the new one", async () => { + const failed = harness(new Error("fixture-save-failed")); + const previous = { maxConcurrentChildren: 12 }; + failed.config.workflowBudget = previous; + await expect(failed.call("PUT", { maxConcurrentChildren: 20 })).rejects.toThrow("fixture-save-failed"); + expect(failed.config.workflowBudget).toBe(previous); + await expect(failed.call("PUT", null)).rejects.toThrow("fixture-save-failed"); + expect(failed.config.workflowBudget).toBe(previous); + expect(configRebaseDeletionKeys(failed.config).has("workflowBudget")).toBe(false); + const published = harness(new ConfigWritePublishedError(new Error("fixture-bookkeeping-failed"))); + await expect(published.call("PUT", { maxConcurrentChildren: 20 })).rejects.toBeInstanceOf(ConfigWritePublishedError); + expect(published.config.workflowBudget).toEqual({ maxConcurrentChildren: 20 }); + }); + + test("unsupported methods fall through", async () => { + const h = harness(); + expect(await h.call("POST", {})).toBeNull(); + expect(h.saved).toHaveLength(0); + }); +}); + +describe("live workflow budget policy", () => { + test("a dashboard write raises the worker concurrency ceiling on the next admission", async () => { + const h = harness(); + bindWorkflowBudgetConfig(h.config); + const leases: WorkflowAdmission[] = []; + const admitWorker = (index: number) => { + const decision = admitHttpWorkflowTurn(workerHeaders("child-" + index)); + if (decision?.admitted) leases.push(decision.lease); + return decision; + }; + // Default: eight slots, one held for the interactive turn, so the eighth worker is refused. + for (let index = 0; index < 7; index += 1) expect(admitWorker(index)?.admitted).toBe(true); + expect(admitWorker(7)).toMatchObject({ admitted: false, reason: "workflow-concurrency-exhausted" }); + expect((await h.call("PUT", { maxConcurrentChildren: 10 }))?.status).toBe(200); + expect(admitWorker(7)?.admitted).toBe(true); + expect(admitWorker(8)?.admitted).toBe(true); + expect(admitWorker(9)).toMatchObject({ admitted: false, reason: "workflow-concurrency-exhausted" }); + for (const lease of leases) lease.release(); + }); + + test("the send and child ceilings follow the bound config and fall back after a reset", async () => { + const h = harness(); + bindWorkflowBudgetConfig(h.config); + expect((await h.call("PUT", { maxPhysicalSends: 3, maxDistinctChildren: 2 }))?.status).toBe(200); + const policy = currentWorkflowBudgetPolicy(); + for (const child of ["a", "b"]) { + const decision = admitWorkflowTurn("root-sends", "worker", policy, child); + expect(decision?.admitted).toBe(true); + if (decision?.admitted) decision.lease.release(); + } + expect(admitWorkflowTurn("root-sends", "worker", policy, "c")).toMatchObject({ admitted: false, reason: "workflow-children-exhausted" }); + chargeWorkflowSends("root-sends", 3); + expect(workflowSendCeilingReached("root-sends", currentWorkflowBudgetPolicy())).toBe(true); + expect((await h.call("PUT", null))?.status).toBe(200); + expect(workflowSendCeilingReached("root-sends", currentWorkflowBudgetPolicy())).toBe(false); + }); +}); From 899cc4fe4da796006dbd526039777499c67927c5 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 30 Oct 2026 20:38:58 +0900 Subject: [PATCH 49/84] docs(config): say when the workflow send ceiling is checked (#7987) maxPhysicalSends is compared when a request is admitted (workflowSendCeilingReached); sends are charged afterwards without a further check, so retries inside an admitted request and concurrent admissions can exceed it. The row added in #7960 read as a strict cap. --- docs-site/src/content/docs/reference/configuration/server.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 59aa34c7602..98636bbedba 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -95,7 +95,7 @@ OpenCodex bounds how much one task may fan out. A task is the root conversation, | Field | Default | Allowed | Meaning | | --- | --- | --- | --- | | `maxConcurrentChildren` | `8` | 2–64 | Turns one task may run at the same time. One slot is always reserved for the task's own conversation, so subagents may use one fewer. | -| `maxPhysicalSends` | `256` | 1–10000 | Upstream sends, retries included, one task may make inside the 10-minute window. | +| `maxPhysicalSends` | `256` | 1–10000 | Upstream sends, retries included, after which the task's new requests are refused for the rest of the 10-minute window. The count is checked when a request is admitted, so retries inside a request that was already admitted, and requests admitted concurrently, can take the total past the ceiling. | | `maxDistinctChildren` | `64` | 1–1024 | Distinct subagent threads one task may start inside the same window. | The defaults suit most installs. Raise them when a workflow that legitimately runs many subagents in parallel keeps hitting a ceiling; lower them to stop a runaway fan-out sooner: From 4fc870074e8030cd1e0a35a6a5c707f208e51b93 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 30 Oct 2026 20:39:03 +0900 Subject: [PATCH 50/84] fix(kiro): exclude runtime callbacks from retry snapshot (#7981) --- src/adapters/kiro/adapter.ts | 5 ++++- structure/providers/kiro.md | 1 + .../providers/kiro/kiro-single-final.test.ts | 22 +++++++++++++++---- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/adapters/kiro/adapter.ts b/src/adapters/kiro/adapter.ts index 11285f97573..b4d51979dd2 100644 --- a/src/adapters/kiro/adapter.ts +++ b/src/adapters/kiro/adapter.ts @@ -297,7 +297,10 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter toolNameMap = built.nameMap; conversationId = built.conversationId; completionMode = built.completionMode; - requestSnapshot = structuredClone(parsed); + // Runtime callbacks belong to the original request, not the retry payload. + requestSnapshot = structuredClone(Object.fromEntries( + Object.entries(parsed).filter(([, value]) => typeof value !== "function"), + )) as OcxParsedRequest; firstRequestBodyBytes = Buffer.byteLength(built.request.body); requestAbortSignal = incoming?.abortSignal; return built.request; diff --git a/structure/providers/kiro.md b/structure/providers/kiro.md index f324a6b1d0c..c17cb4ee260 100644 --- a/structure/providers/kiro.md +++ b/structure/providers/kiro.md @@ -159,6 +159,7 @@ budget until emitted, discarded, or cancelled; replay collectors are released af retry construction. Native `END_TURN` and `STOP_SEQUENCE` alone do not distinguish progress from an answer and therefore still require validation. Normal private completion and real tool calls need no completion retry. +The retry snapshots cloneable request fields without top-level runtime callbacks, leaving the original request intact. A turn can carry a real tool call and the private completion call together, in either order. The turn then continues through the real tools: the parser drops the completion call, ends the diff --git a/tests/providers/kiro/kiro-single-final.test.ts b/tests/providers/kiro/kiro-single-final.test.ts index a4bac94faed..12d09c28d45 100644 --- a/tests/providers/kiro/kiro-single-final.test.ts +++ b/tests/providers/kiro/kiro-single-final.test.ts @@ -38,17 +38,19 @@ function response(frames: Uint8Array[]): Response { } afterEach(resetKiroThrottleStateForTests); -async function run(first: Uint8Array[], retry: Uint8Array[], buffered = false) { +async function run(first: Uint8Array[], retry: Uint8Array[], buffered = false, input = structuredClone(parsed)) { const adapter = createKiroAdapter(provider); const budget = createTranslatorBudget(); const sends: number[] = []; const events: AdapterEvent[] = []; let visibleAtRetry: AdapterEvent[] = []; let physicalRequests = 0; + const bodies: string[] = []; try { - const request = await adapter.buildRequest(structuredClone(parsed)); + const request = await adapter.buildRequest(input); const upstream = await adapter.fetchResponse!(request, { - executor: (async () => { + executor: (async (_url, init) => { + bodies.push(String(init?.body)); if (++physicalRequests === 1) return response(first); visibleAtRetry = events.filter(event => event.type === "text_delta"); return response(retry); @@ -59,13 +61,25 @@ async function run(first: Uint8Array[], retry: Uint8Array[], buffered = false) { else for await (const event of adapter.parseStream(upstream, budget)) events.push(event); if (buffered) for (const event of events) releaseTranslatedEvent(event, budget); expect(budget.snapshot().currentBytes).toBe(0); - return { events, sends, physicalRequests, visibleAtRetry }; + return { events, sends, physicalRequests, visibleAtRetry, bodies }; } finally { budget.dispose(); } } describe("Kiro single final answer (#6270)", () => { + test("advisor guard survives request building and completion retry", async () => { + const guard: NonNullable = async function* () {}; + const input = { ...structuredClone(parsed), _advisorGuard: guard }; + const answer = "The workspace is ready."; + const { events, physicalRequests, bodies } = await run([text(answer)], [text(answer)], false, input); + expect(input._advisorGuard).toBe(guard); + expect(physicalRequests).toBe(2); + expect(bodies[1]).toContain("Inspect the workspace."); + expect(bodies[1]).toContain("claude-opus-5.5"); + expect(events.at(-1)).toMatchObject({ type: "done", endTurn: true }); + }); + for (const buffered of [false, true]) { test.each(["END_TURN", "STOP_SEQUENCE", undefined])( `plain text ending is held through validation (stop=%s, buffered=${buffered})`, From 853508eb629abe05fe290e84b0f6687d9682a7ad Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 30 Oct 2026 11:41:11 +0000 Subject: [PATCH 51/84] fix(windows): snapshot ACL failure diagnostics (#7970) * fix(windows): snapshot ACL failure diagnostics * docs(structure): record the single-read ACL diagnostic snapshot --------- Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> --- src/lib/windows-secret-acl.ts | 76 ++++--- structure/ops/service-and-sidecars.md | 2 +- .../windows-secret-acl-diagnostics.test.ts | 196 ++++++++++++++++++ 3 files changed, 244 insertions(+), 30 deletions(-) diff --git a/src/lib/windows-secret-acl.ts b/src/lib/windows-secret-acl.ts index 7b423974d9e..c7d39f38df7 100644 --- a/src/lib/windows-secret-acl.ts +++ b/src/lib/windows-secret-acl.ts @@ -1207,37 +1207,55 @@ function isTimeoutError(error: unknown): boolean { * which is optional and simply omitted when it is not an allowlisted code. */ function sanitizeAclFailure(cause: unknown): AclFailureDiagnostics | undefined { - if (!cause || typeof cause !== "object" || !("aclFailure" in cause)) return undefined; - const raw = (cause as { aclFailure?: unknown }).aclFailure; - if (!raw || typeof raw !== "object") return undefined; - const f = raw as Record; - if (typeof f.step !== "string" || !ICACLS_STEPS.has(f.step)) return undefined; - if ( - f.stage !== "deadline_before_spawn" && - f.stage !== "spawn_failed" && - f.stage !== "runner_rejected" && - f.stage !== "runner_watchdog" && - f.stage !== "child_result" - ) { - return undefined; - } - if (typeof f.elapsedMs !== "number" || !Number.isFinite(f.elapsedMs)) return undefined; - if (typeof f.budgetMs !== "number" || !Number.isFinite(f.budgetMs)) return undefined; - if (typeof f.success !== "boolean" || typeof f.timedOut !== "boolean") return undefined; - if (f.exitCode !== null && (typeof f.exitCode !== "number" || !Number.isFinite(f.exitCode))) { + // Every read off the untrusted cause — the `in` check, the `aclFailure` + // access and each field — may be a throwing accessor or a getter that + // returns a different value on the next read. Each field is therefore read + // exactly once into a local, and only that local is validated and copied; + // a read that throws drops the whole record rather than letting a raw + // hostile exception replace the sanitized required-mode error. + try { + if (!cause || typeof cause !== "object" || !("aclFailure" in cause)) return undefined; + const raw = (cause as { aclFailure?: unknown }).aclFailure; + if (!raw || typeof raw !== "object") return undefined; + const f = raw as Record; + const step = f.step; + if (typeof step !== "string" || !ICACLS_STEPS.has(step)) return undefined; + const stage = f.stage; + if ( + stage !== "deadline_before_spawn" && + stage !== "spawn_failed" && + stage !== "runner_rejected" && + stage !== "runner_watchdog" && + stage !== "child_result" + ) { + return undefined; + } + const elapsedMs = f.elapsedMs; + if (typeof elapsedMs !== "number" || !Number.isFinite(elapsedMs)) return undefined; + const budgetMs = f.budgetMs; + if (typeof budgetMs !== "number" || !Number.isFinite(budgetMs)) return undefined; + const success = f.success; + if (typeof success !== "boolean") return undefined; + const timedOut = f.timedOut; + if (typeof timedOut !== "boolean") return undefined; + const exitCode = f.exitCode; + if (exitCode !== null && (typeof exitCode !== "number" || !Number.isFinite(exitCode))) { + return undefined; + } + const spawnErrorCode = allowlistedSpawnErrorCode(f.spawnErrorCode); + return { + step: step as IcaclsStep, + stage, + elapsedMs, + budgetMs, + success, + exitCode: exitCode as number | null, + timedOut, + ...(spawnErrorCode !== undefined ? { spawnErrorCode } : {}), + }; + } catch { return undefined; } - const spawnErrorCode = allowlistedSpawnErrorCode(f.spawnErrorCode); - return { - step: f.step as IcaclsStep, - stage: f.stage, - elapsedMs: f.elapsedMs, - budgetMs: f.budgetMs, - success: f.success, - exitCode: f.exitCode as number | null, - timedOut: f.timedOut, - ...(spawnErrorCode !== undefined ? { spawnErrorCode } : {}), - }; } /** Preserve only the bounded machine-readable cause on a sanitized public error. */ diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 71781fd0a71..00955b3e463 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -122,7 +122,7 @@ enumeration twice made a measured 12.3-second fallback cost roughly 25 seconds b and every async ACL hardening flight and deadline-surviving `icacls.exe` runner under that home have settled. `src/config/paths.ts` owns the barrier: a timeout verdict alone does not make the home removable. The contract is exercised by `tests/server/server-stop-config-hardening.test.ts`. -`src/lib/windows-secret-acl.ts` retains removal ownership for an entire async harden, including principal lookup, successive ACL commands, retries and diagnostics, as well as tracking runners that outlive their deadlines. The exact-path timeout indicator keeps its existing meaning for atomic-file fallback. The Bun regression in `tests/windows/windows-secret-acl-removal-flight.test.ts` holds a normal command and verifies that removal remains blocked between successive commands. Required-mode failures keep their sanitized ETIMEDOUT/EICACLS semantics and additionally carry a bounded `aclFailure` record — fixed step and stage enums (`deadline_before_spawn`/`spawn_failed`/`runner_rejected`/`runner_watchdog`/`child_result`), measured elapsed and requested budget, success, exit code, and an allowlisted spawn code — copied field-by-field so no path, argv, SID, or child output crosses into the thrown error; `tests/windows/windows-secret-acl-diagnostics.test.ts` covers the boundary. Lifecycle acquisition opts into `verifyExistingRuntimeDirectory` before mutating its shared Windows runtime root. `src/lib/windows-owner-acl.ts` verifies a protected private DACL with the serving user's inheritable Full Control; an Administrators-owned runtime directory also qualifies when that group is enabled in the serving token, independently of its default owner. The ordinary private secret-file/pairing policy is unchanged. Verification is bound to unchanged device/inode/ctime and the original deadline, creates no hardening memo, and never falls through to the legacy name-only shortcut. Refusal retains required hardening and fail-closed errors. The root check does not certify descendants; the lifecycle database is hardened separately. Regression coverage is in `tests/windows/windows-lifecycle-root-acl.test.ts`. +`src/lib/windows-secret-acl.ts` retains removal ownership for an entire async harden, including principal lookup, successive ACL commands, retries and diagnostics, as well as tracking runners that outlive their deadlines. The exact-path timeout indicator keeps its existing meaning for atomic-file fallback. The Bun regression in `tests/windows/windows-secret-acl-removal-flight.test.ts` holds a normal command and verifies that removal remains blocked between successive commands. Required-mode failures keep their sanitized ETIMEDOUT/EICACLS semantics and additionally carry a bounded `aclFailure` record — fixed step and stage enums (`deadline_before_spawn`/`spawn_failed`/`runner_rejected`/`runner_watchdog`/`child_result`), measured elapsed and requested budget, success, exit code, and an allowlisted spawn code — copied field-by-field so no path, argv, SID, or child output crosses into the thrown error. Each field is read from the cause exactly once and that same primitive is both validated and copied, so a changing getter cannot pass validation with one value and publish another; a getter or Proxy trap that throws drops the whole record while the sanitized required-mode error is still thrown; `tests/windows/windows-secret-acl-diagnostics.test.ts` covers the boundary. Lifecycle acquisition opts into `verifyExistingRuntimeDirectory` before mutating its shared Windows runtime root. `src/lib/windows-owner-acl.ts` verifies a protected private DACL with the serving user's inheritable Full Control; an Administrators-owned runtime directory also qualifies when that group is enabled in the serving token, independently of its default owner. The ordinary private secret-file/pairing policy is unchanged. Verification is bound to unchanged device/inode/ctime and the original deadline, creates no hardening memo, and never falls through to the legacy name-only shortcut. Refusal retains required hardening and fail-closed errors. The root check does not certify descendants; the lifecycle database is hardened separately. Regression coverage is in `tests/windows/windows-lifecycle-root-acl.test.ts`. Removal ownership captures lexical and canonical path identities before each work item or child starts, including Windows short names and directory aliases; deadline survivors retain the same identities. A deleted temporary file is captured through its existing parent. Unreadable identity at capture stays conservatively matched until settlement. Removal compares the requested root against those retained identities, so deleting or retargeting an alias cannot release the original root early. The same Bun regression verifies aliases changed mid-flight still block removal. Matching covers captured and current alias destinations; a second retarget by a writer of the protected directory is out of scope. diff --git a/tests/windows/windows-secret-acl-diagnostics.test.ts b/tests/windows/windows-secret-acl-diagnostics.test.ts index c671ed9b1f7..6209b99f697 100644 --- a/tests/windows/windows-secret-acl-diagnostics.test.ts +++ b/tests/windows/windows-secret-acl-diagnostics.test.ts @@ -394,3 +394,199 @@ describe("aclFailure diagnostics on required harden errors", () => { expect(result.diagnostics).not.toContain(ownerSid); }); }); + +describe("aclFailure boundary against an untrusted runner-thrown cause", () => { + // A runner that throws instead of returning a result hands its raw error to + // the required-mode sanitizer. Every aclFailure field on that error is + // untrusted: a getter may return a different value on each read or throw, and + // a Proxy may trap `in` or `get`. The sanitizer must read each field once, + // validate and copy only that local value, and drop the whole record when any + // read throws — a hostile exception must never replace the sanitized error. + const throwingRunnerCause = (aclFailure: unknown): Error => + Object.assign(new Error("injected runner failure"), { code: "EICACLS", aclFailure }); + + test("a changing step getter cannot swap the validated value for a path at copy time", () => { + const filePath = secretFile(); + let stepReads = 0; + let exitCodeReads = 0; + const hostile = { + get step() { stepReads += 1; return stepReads <= 2 ? "/grant:r" : filePath; }, + stage: "child_result", + elapsedMs: 1, + budgetMs: 100, + success: false, + get exitCode() { exitCodeReads += 1; return exitCodeReads <= 3 ? 5 : `marker:${filePath}`; }, + timedOut: false, + }; + setIcaclsRunnerForTests(() => { throw throwingRunnerCause(hostile); }); + + const error = thrownAclError(() => hardenSecretPath(filePath, { required: true })); + + expect(error.code).toBe("EICACLS"); + // Validated values — never the later hostile reads — reach the record. + expect(error.aclFailure).toMatchObject({ step: "/grant:r", stage: "child_result", exitCode: 5 }); + expect(JSON.stringify(error.aclFailure)).not.toContain(filePath); + }); + + test("every copied field is read exactly once, keeping valid edge values", () => { + const filePath = secretFile(); + const reads: Record = {}; + const field = (name: string, value: T): PropertyDescriptor => ({ + enumerable: true, + get() { reads[name] = (reads[name] ?? 0) + 1; return value; }, + }); + const hostile = Object.defineProperties({}, { + step: field("step", "/grant:r"), + stage: field("stage", "child_result"), + elapsedMs: field("elapsedMs", -1), + budgetMs: field("budgetMs", 0), + success: field("success", false), + exitCode: field("exitCode", null), + timedOut: field("timedOut", false), + }); + setIcaclsRunnerForTests(() => { throw throwingRunnerCause(hostile); }); + + const error = thrownAclError(() => hardenSecretPath(filePath, { required: true })); + + expect(error.code).toBe("EICACLS"); + expect(error.aclFailure).toEqual({ + step: "/grant:r", + stage: "child_result", + elapsedMs: -1, + budgetMs: 0, + success: false, + exitCode: null, + timedOut: false, + }); + for (const name of ["step", "stage", "elapsedMs", "budgetMs", "success", "exitCode", "timedOut"]) { + expect(reads[name]).toBe(1); + } + }); + + test("an invalid first read drops the record even when a later read would pass", () => { + const filePath = secretFile(); + let stepReads = 0; + const hostile = { + get step() { stepReads += 1; return stepReads === 1 ? filePath : "/grant:r"; }, + stage: "child_result", + elapsedMs: 1, + budgetMs: 100, + success: false, + exitCode: 5, + timedOut: false, + }; + setIcaclsRunnerForTests(() => { throw throwingRunnerCause(hostile); }); + + const error = thrownAclError(() => hardenSecretPath(filePath, { required: true })); + + expect(error.code).toBe("EICACLS"); + expect(error.aclFailure).toBeUndefined(); + }); + + test("an invalid success field short-circuits before the timedOut getter runs", () => { + const filePath = secretFile(); + let timedOutReads = 0; + const hostile = { + step: "/grant:r", + stage: "child_result", + elapsedMs: 1, + budgetMs: 100, + success: "yes" as unknown as boolean, + get timedOut() { timedOutReads += 1; return false; }, + exitCode: 5, + }; + setIcaclsRunnerForTests(() => { throw throwingRunnerCause(hostile); }); + + const error = thrownAclError(() => hardenSecretPath(filePath, { required: true })); + + expect(error.code).toBe("EICACLS"); + expect(error.aclFailure).toBeUndefined(); + // Original guard read timedOut only after success passed; keep that order. + expect(timedOutReads).toBe(0); + }); + + test("a throwing aclFailure field getter drops the record but keeps the sanitized error", () => { + const filePath = secretFile(); + const hostile = Object.defineProperty({ + stage: "child_result", + elapsedMs: 1, + budgetMs: 100, + success: false, + exitCode: 5, + timedOut: false, + }, "step", { + enumerable: true, + get() { throw new Error(`getter-marker ${filePath}`); }, + }); + setIcaclsRunnerForTests(() => { throw throwingRunnerCause(hostile); }); + + const error = thrownAclError(() => hardenSecretPath(filePath, { required: true })); + + expect(error.code).toBe("EICACLS"); + expect(error.aclFailure).toBeUndefined(); + expect(error.message).not.toContain("getter-marker"); + expect(error.message).not.toContain(filePath); + }); + + test("proxy has/get traps on the untrusted cause drop the record without replacing the error", () => { + const wellFormed = { + step: "/grant:r", + stage: "child_result", + elapsedMs: 1, + budgetMs: 100, + success: false, + exitCode: 5, + timedOut: false, + }; + + // The record's own get trap throws when step is read. + const recordProxy = new Proxy({ ...wellFormed }, { + get(target, prop) { + if (prop === "step") throw new Error("record-get-marker"); + return (target as Record)[prop]; + }, + }); + const filePath1 = secretFile("secret-a.json"); + setIcaclsRunnerForTests(() => { throw throwingRunnerCause(recordProxy); }); + const first = thrownAclError(() => hardenSecretPath(filePath1, { required: true })); + expect(first.code).toBe("EICACLS"); + expect(first.aclFailure).toBeUndefined(); + expect(first.message).not.toContain("record-get-marker"); + + // The cause's own has trap throws only for the aclFailure key — a trap that + // also fires on the separate cause.code boundary is outside this fix. + const filePath2 = secretFile("secret-b.json"); + const causeProxy = new Proxy(throwingRunnerCause({ ...wellFormed }), { + has(target, prop) { + if (prop === "aclFailure") throw new Error(`cause-has-marker ${filePath2}`); + return prop in target; + }, + }); + setIcaclsRunnerForTests(() => { throw causeProxy; }); + const second = thrownAclError(() => hardenSecretPath(filePath2, { required: true })); + expect(second.code).toBe("EICACLS"); + expect(second.aclFailure).toBeUndefined(); + expect(second.message).not.toContain("cause-has-marker"); + expect(second.message).not.toContain(filePath2); + }); + + test("a well-formed plain record and its allowlisted spawn code survive verbatim", () => { + const filePath = secretFile(); + const clean = { + step: "/remove:g", + stage: "spawn_failed", + elapsedMs: 2, + budgetMs: 50, + success: false, + exitCode: null, + timedOut: false, + spawnErrorCode: "ENOENT", + }; + setIcaclsRunnerForTests(() => { throw throwingRunnerCause(clean); }); + + const error = thrownAclError(() => hardenSecretPath(filePath, { required: true })); + + expect(error.code).toBe("EICACLS"); + expect(error.aclFailure).toEqual({ ...clean }); + }); +}); From db7bdc5e3d233aed8425420e77fbd66fe20ec947 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 30 Oct 2026 11:41:25 +0000 Subject: [PATCH 52/84] fix(cli): explain tracked runtime stop refusals (#7973) * fix(cli): explain tracked runtime stop refusals * fix(cli): state only what the identity probe proves A cached identity mismatch shows only that the recorded process does not match the expected start command, not that the PID was reused by another process. --------- Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> --- src/cli/index.ts | 9 +- src/cli/stop-approval.ts | 16 +- src/config/process-state.ts | 28 ++- src/config/tracked-runtime-inspection.ts | 188 ++++++++++++++++++++ structure/config.md | 2 +- structure/runtime.md | 4 +- tests/cli/cli-stop-json.test.ts | 14 ++ tests/service/process-state.test.ts | 207 +++++++++++++++++++++++ 8 files changed, 452 insertions(+), 16 deletions(-) create mode 100644 src/config/tracked-runtime-inspection.ts diff --git a/src/cli/index.ts b/src/cli/index.ts index d1439754154..eff70dd94bb 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -69,6 +69,7 @@ import { writePid, writeRuntimePort, } from "../config/process-state"; +import { describeTrackedRuntimeTarget } from "../config/tracked-runtime-inspection"; import { claimPendingTeardown, isPendingTeardownAbandoned, @@ -1020,13 +1021,7 @@ async function handleStop(approval?: StopApproval) { try { return JSON.parse(lines[0]!) as ResolveJson; } catch { return null; } }, - () => { - const pid = readPid(); - const runtime = pid === null ? null : readRuntimePort(pid); - return pid && runtime?.port ? { - pid, port: runtime.port, hostname: runtime.hostname ?? "", - } : null; - }, + describeTrackedRuntimeTarget, () => inspectGuardedManagerTarget(approval.pid, approval.port), snapshot => handleStopUnlocked(snapshot), ); diff --git a/src/cli/stop-approval.ts b/src/cli/stop-approval.ts index 4b83fef6169..5a68879e8d5 100644 --- a/src/cli/stop-approval.ts +++ b/src/cli/stop-approval.ts @@ -20,6 +20,17 @@ export interface GuardedStopSnapshot { manager: Exclude; } +/** + * What the tracked-record check can report: the verified target, null when the + * caller cannot name a cause (the pre-diagnostics shape), or the fixed cause of + * the single check that failed. The cause is a fixed sentence fragment from the + * inspection probe -- it carries no path, command line, stderr, or token. + */ +export type TrackedRuntimeCheck = + | { pid: number; port: number; hostname: string } + | { untracked: string } + | null; + function sameGuardedManager( expected: GuardedStopSnapshot["manager"], current: GuardedManagerTarget, @@ -117,7 +128,7 @@ export function managerStillActive( export async function runApprovedStop( expected: StopApproval, read: () => Promise, - tracked: () => { pid: number; port: number; hostname: string } | null, + tracked: () => TrackedRuntimeCheck, managerTarget: () => GuardedManagerTarget, stop: (snapshot: GuardedStopSnapshot) => Promise, ): Promise { @@ -130,6 +141,9 @@ export async function runApprovedStop( const mismatch = stopApprovalMismatch(expected, now); if (mismatch !== null) return approvalChanged(mismatch); if (target === null) return approvalChanged("the tracked runtime record could not be verified"); + if ("untracked" in target) { + return approvalChanged(`the tracked runtime record could not be verified (${target.untracked})`); + } if (target.pid !== expected.pid || target.port !== expected.port || target.hostname !== expected.hostname) { return approvalChanged("the tracked runtime record changed"); diff --git a/src/config/process-state.ts b/src/config/process-state.ts index f61f714b8d9..4ac5191e57c 100644 --- a/src/config/process-state.ts +++ b/src/config/process-state.ts @@ -45,7 +45,7 @@ export type RuntimePortState = { siblingOfPort?: number; }; -function isValidRuntimePortState(value: unknown): value is RuntimePortState { +export function isValidRuntimePortState(value: unknown): value is RuntimePortState { if (!value || typeof value !== "object") return false; const state = value as Record; const hostnameOk = state.hostname === undefined || typeof state.hostname === "string"; @@ -201,14 +201,32 @@ export function ocxStartProcessCacheSizeForTests(): number { return ocxStartProcessCache.size; } -function isLikelyOcxStartProcess(pid: number): boolean { +/** + * One observation of whether a candidate PID is an `ocx start` process. + * + * `readPid()` and `verifyPidIdentity()` ask this on every short liveness poll, and the + * read-only tracked-record inspection in `tracked-runtime-inspection.ts` asks it once per + * guarded stop, so all of them share this single cache-backed answer rather than probe + * twice: a hit performs no native command at all, and a miss performs exactly one + * {@link readProcessCommandLine} probe. Only a definite answer is cached; an unreadable + * command line is reported as `"unverifiable"`, left uncached, and re-probed on the next + * ask. `"unverifiable"` is not evidence of a permission boundary, a foreign process, or a + * different account or elevation — only that the probe could not answer this time. + */ +export type OcxStartIdentityObservation = "match" | "mismatch" | "unverifiable"; + +export function inspectOcxStartProcessIdentity(pid: number): OcxStartIdentityObservation { const cached = ocxStartProcessCache.get(pid); - if (cached !== undefined) return cached; + if (cached !== undefined) return cached ? "match" : "mismatch"; const commandLine = readProcessCommandLine(pid); - if (commandLine === undefined) return false; + if (commandLine === undefined) return "unverifiable"; const ok = isOcxStartCommandLine(commandLine); ocxStartProcessCache.set(pid, ok); - return ok; + return ok ? "match" : "mismatch"; +} + +function isLikelyOcxStartProcess(pid: number): boolean { + return inspectOcxStartProcessIdentity(pid) === "match"; } export function readPid(): number | null { diff --git a/src/config/tracked-runtime-inspection.ts b/src/config/tracked-runtime-inspection.ts new file mode 100644 index 00000000000..6cc241e5f19 --- /dev/null +++ b/src/config/tracked-runtime-inspection.ts @@ -0,0 +1,188 @@ +/** + * Read-only classification of the tracked runtime record pair a guarded stop + * re-verifies before acting. + * + * "ocx stop --expect-*" asks "is the recorded runtime still the approved one", + * which collapses several observations into one boolean: a missing or invalid + * "ocx.pid", a dead, foreign or unverifiable recorded process, and a missing, + * invalid or PID-mismatched "runtime-port.json" all read as "not verified". + * The approval-changed refusal used to report only that umbrella fact, so a + * desktop log could not tell a stale record from a permission failure. + * + * This probe walks the same checks in the same order as "readPid()" followed + * by "readRuntimePort(pid)", but reports which one failed instead. Each state + * record is still read exactly once per call -- a guarded stop asks once, and + * caching the bytes would only carry a stale answer into a decision about a + * record that may have been rewritten. The process-identity question, though, + * is answered by the same cache-backed observation "readPid()" itself makes + * ("inspectOcxStartProcessIdentity"): a cached verdict serves this probe with + * no native command at all, and a miss performs the same single command-line + * probe readPid would, so this probe can never disagree with the admission + * readPid just granted or denied for the same PID. It performs no mutation, no + * signal beyond the same "process.kill(pid, 0)" liveness read, and no stop + * decision -- the refusal itself is unchanged; only its detail gains a cause. + * + * Two distinctions are deliberate: + * + * - "permission-denied" is reported only for a real EACCES/EPERM observed on a + * record read or the liveness signal. An "unverifiable" identity observation + * -- an unreadable process command line -- is not proof of a permission + * boundary, and no account or elevation cause is inferred from it. + * - No path, command line, stderr text, or token leaves this module: the refusal + * causes below are fixed strings and the detail built from them is a fixed + * sentence. + */ +import { readFileSync } from "node:fs"; +import { + getPidPath, + getRuntimePortPath, + inspectOcxStartProcessIdentity, + isValidRuntimePortState, + parsePidFile, +} from "./process-state"; +import type { OcxStartIdentityObservation } from "./process-state"; + +/** The single check that failed, as a stable machine-readable token. */ +export type TrackedRecordRefusal = + | "pid-record-absent" + | "pid-record-permission-denied" + | "pid-record-unreadable" + | "pid-record-invalid" + | "process-not-running" + | "process-identity-mismatch" + | "process-unverifiable" + | "runtime-record-absent" + | "runtime-record-permission-denied" + | "runtime-record-unreadable" + | "runtime-record-invalid" + | "runtime-record-pid-mismatch" + | "unverified"; + +export type TrackedRecordInspection = + | { status: "verified"; pid: number; port: number; hostname: string } + | { status: "refused"; reason: TrackedRecordRefusal }; + +/** + * Injectable read-only seams. Tests must supply fakes for "signal" and either + * "identity" or the process-state exec seams ("setProcessCommandLineExecForTests" + * and friends) so no real "process.kill" or native command probe runs; the + * defaults are exactly what the production caller uses, including the shared + * identity cache. + */ +export interface TrackedRecordProbeDeps { + /** Read a state record like "readFileSync(path, 'utf-8')"; throw with errno codes. */ + readFile?: (path: string) => string; + /** The "process.kill(pid, 0)" liveness read; throws like it (ESRCH/EPERM/...). */ + signal?: (pid: number) => void; + /** The shared start-identity observation; the default consults the real cache. */ + identity?: (pid: number) => OcxStartIdentityObservation; +} + +function readRecordFile( + readFile: (path: string) => string, + path: string, + absent: TrackedRecordRefusal, + denied: TrackedRecordRefusal, + unreadable: TrackedRecordRefusal, +): { ok: true; raw: string } | { ok: false; reason: TrackedRecordRefusal } { + try { + return { ok: true, raw: readFile(path) }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "EACCES" || code === "EPERM") return { ok: false, reason: denied }; + if (code === "ENOENT") return { ok: false, reason: absent }; + return { ok: false, reason: unreadable }; + } +} + +/** + * Classify why the tracked runtime record cannot back a guarded stop, or return + * the verified target when every check still holds. One read per record, in the + * same order the stop path already performs them. + */ +export function inspectTrackedRuntimeRecord( + deps: TrackedRecordProbeDeps = {}, +): TrackedRecordInspection { + const readFile = deps.readFile ?? ((path: string) => readFileSync(path, "utf-8")); + const signal = deps.signal ?? ((pid: number) => { process.kill(pid, 0); }); + const identity = deps.identity ?? inspectOcxStartProcessIdentity; + try { + const pidFile = readRecordFile( + readFile, getPidPath(), "pid-record-absent", "pid-record-permission-denied", "pid-record-unreadable", + ); + if (!pidFile.ok) return { status: "refused", reason: pidFile.reason }; + const pid = parsePidFile(pidFile.raw); + if (pid === null) return { status: "refused", reason: "pid-record-invalid" }; + try { + signal(pid); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ESRCH") return { status: "refused", reason: "process-not-running" }; + // EPERM still names a live process; identity is decided by the shared + // observation below, exactly as readPid decides it. Any other signal + // error can only be reported as unverifiable. + if (code !== "EPERM") return { status: "refused", reason: "process-unverifiable" }; + } + const observation = identity(pid); + if (observation === "unverifiable") { + return { status: "refused", reason: "process-unverifiable" }; + } + if (observation === "mismatch") { + return { status: "refused", reason: "process-identity-mismatch" }; + } + const runtimeFile = readRecordFile( + readFile, getRuntimePortPath(), + "runtime-record-absent", "runtime-record-permission-denied", "runtime-record-unreadable", + ); + if (!runtimeFile.ok) return { status: "refused", reason: runtimeFile.reason }; + let parsed: unknown; + try { + parsed = JSON.parse(runtimeFile.raw); + } catch { + return { status: "refused", reason: "runtime-record-invalid" }; + } + if (!isValidRuntimePortState(parsed)) { + return { status: "refused", reason: "runtime-record-invalid" }; + } + if (parsed.pid !== pid) { + return { status: "refused", reason: "runtime-record-pid-mismatch" }; + } + return { status: "verified", pid, port: parsed.port, hostname: parsed.hostname ?? "" }; + } catch { + return { status: "refused", reason: "unverified" }; + } +} + +/** + * Fixed refusal causes for the approval-changed detail. Every entry is a + * complete sentence fragment and contains no path, command line, stderr, or + * token, so the resulting detail is safe to surface in a caller's log or UI. + */ +const TRACKED_REFUSAL_CAUSES: Record = { + "pid-record-absent": "the recorded PID file is absent", + "pid-record-permission-denied": "reading the recorded PID file was denied", + "pid-record-unreadable": "the recorded PID file could not be read", + "pid-record-invalid": "the recorded PID file is not a valid record", + "process-not-running": "the recorded process is no longer running", + "process-identity-mismatch": "the recorded process does not match the expected start command", + "process-unverifiable": "the recorded process identity could not be verified", + "runtime-record-absent": "the tracked runtime record is absent", + "runtime-record-permission-denied": "reading the tracked runtime record was denied", + "runtime-record-unreadable": "the tracked runtime record could not be read", + "runtime-record-invalid": "the tracked runtime record is not a valid record", + "runtime-record-pid-mismatch": "the tracked runtime record names a different PID", + "unverified": "the tracked runtime record could not be re-verified", +}; + +/** + * The guarded stop's tracked() callback shape: the verified target, or the + * fixed cause of the single check that failed. Read-only; decides nothing. + */ +export function describeTrackedRuntimeTarget( + deps: TrackedRecordProbeDeps = {}, +): { pid: number; port: number; hostname: string } | { untracked: string } { + const inspection = inspectTrackedRuntimeRecord(deps); + return inspection.status === "verified" + ? { pid: inspection.pid, port: inspection.port, hostname: inspection.hostname } + : { untracked: TRACKED_REFUSAL_CAUSES[inspection.reason] }; +} diff --git a/structure/config.md b/structure/config.md index 4f330eebdd9..01d9447e520 100644 --- a/structure/config.md +++ b/structure/config.md @@ -68,7 +68,7 @@ silently move the active installation. Config reads in this leaf and `src/config It owns their byte-compatible writes, parsing, expected-PID filters, cheap liveness, full OCX command identity, and snapshot-guarded removal. `RuntimePortState.attestationSecret` and `siblingOfPort` (the live owner's port, written only by a sibling instance) remain optional, owner-only state and are validated before a record is returned. `src/config.ts` re-exports the same -symbols for compatibility, but new lifecycle-only callers import the process-state leaf directly. +symbols for compatibility, but new lifecycle-only callers import the process-state leaf directly. `src/config/tracked-runtime-inspection.ts` is the guarded stop's read-only companion probe: it re-runs the same record reads in the same order, answers the start-identity question through the same cache `readPid()` consults, and reports which single check failed as a fixed refusal cause, so an absent, denied, invalid or PID-mismatched record is told apart from a dead, foreign or unverifiable process, and it decides nothing and exposes no path, command line, stderr, or token. Replacing config and process-state writes use `src/config/atomic-write.ts`. Normal Windows writes opt into the destination's single ACL timeout recovery attempt; a consumed attempt re-arms after `TIMEOUT_MEMO_REARM_MS` in `src/lib/windows-secret-acl.ts`. Default writers track temporary paths per resolved destination across synchronous, streamed and asynchronous writes. A new write refuses immediately with sanitized `ETIMEDOUT` only while one of that destination's tracked paths has an icacls child retained past its caller's timeout belt and not yet reaped, including across re-arm windows. Healthy overlapping writes proceed, including during ACL hardening or a pause before rename. Refusal consumes no recovery attempt and leaves removal ownership intact; completing one write does not drop another write's retained-child tracking. Both cleanup paths leave that recovery unconsumed. Every admitted attempt still requires a successful harden before secret bytes or publication; non-timeout failures retain their existing fail-closed behavior. `tests/windows/windows-atomic-write-acl-rearm.test.ts` covers all five harden calls, the re-arm boundary, healthy overlap, retained child admission and cleanup. The leaf preserves the shared process-wide temp sequence, optional synchronous caller destination validation before every rename attempt, symlink target resolution, no-follow directory-entry replacement for diff --git a/structure/runtime.md b/structure/runtime.md index e40e2fc0ed0..ac5b871d6c6 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -59,7 +59,7 @@ The [CLI management contract](cli-management.md#cli-readiness-diagnostics) owns `ocx resolve` (`src/cli/resolve.ts`) is the machine surface a desktop shell asks instead of resolving the config home, the port, and liveness itself: the home comes from `src/config/paths.ts`, the effective port is the live listener's when the identity-checked `findLiveProxy` answers and the configured `config.port ?? 10100` otherwise, and the liveness verdict is that same module's output (pid, runtime-versus-config provenance, version, role). Config reads go through `readConfigDiagnostics`, not `loadConfig`: a missing file is defaults, but an invalid file exits 1 instead of being repaired to defaults, because a defaulted port is a guess the caller must refuse. Liveness is three-valued: when `findLiveProxy` returns null, resolve re-asks the endpoints with `endpointsToProve` and `everyEndpointProvenDown`, using the in-process `probeEndpointLiveness` from `src/server/proxy-liveness.ts`. Only unanimous definitive "dead" becomes `absent-proven`; a timeout or reset stays unknown, exits 1 and never authorises a start. The transient exclusive-bind fallback in `src/update/proxy-liveness-probe.mjs` belongs only to the npm/Bun updater. Resolve stays in-process because a compiled standalone binary cannot run a JavaScript probe with `execPath -e`. Discovery borrows `START_OWNERSHIP_LIVENESS`, the start path's ownership budget — the verdict feeds the shell's launch decision, so the cost of a false "nobody listening" is the duplicate proxy (#5004). The verb is in `skipsCodexShimAutoRestore`, so a read-only lookup never triggers a shim repair. Arguments are pre-parsed in `src/cli/root.ts` and exit 64 before any preflight side effect, the same ordering `ocx ready` obeys. `ocx-resolve/1` also reports `ownership` (`none`, `owned`, or `unknown`) and `takeover` (`supported` with protocol version, minimum CLI version and compatibility token, or `blocked` with a reason). A known liveness verdict still exits 0 when ownership is unknown, but takeover is blocked. An unreadable second service-state read blocks takeover instead of becoming an absent registration. `src/service/managing-cli.ts` observes the registered and selected PATH CLIs, including Windows PATHEXT order and file validation, before compatibility is offered. A `--version` probe is retried once on Windows — one transient failure must not read as an unobservable CLI — and a Windows `.CMD`/`.BAT` shim is invoked through a single verbatim-quoted `cmd /c` line, so a spaced executable path and spaced arguments both survive cmd's re-parse. `src/cli/route-policy.ts` distinguishes a missing profile (exit 4) from malformed `show` arguments (exit 2); an upstream failure propagates through the management error mapping. -`ocx stop --json` is a reporting layer over the unchanged stop path. `src/cli/index.ts` threads a `StopRunRecord` through the existing receipt, drain, respawn-verification and restore flow, and `src/cli/stop-report.ts` maps the recorded facts plus the signals that already pick the exit code into one versioned document (`schema: "ocx-stop/1"`). With `--json` the human lines print on stderr and stdout carries only that document; exit codes 0/1/79/80/81 cross the process boundary unchanged. The desktop's opt-in guarded stop adds exact approved PID, endpoint, home, CLI version and compatibility-token expectations; plain `ocx stop` retains its ordinary behavior. Under the ownership mutation lease, `src/cli/stop-approval.ts` re-resolves them and `src/service/guarded-manager-target.ts` binds any installed manager's process tree to the approved PID before a stop action. A pre-action mismatch returns `approval-changed` without stopping, and the summary carries `detail` naming the single guard fact that failed — live PID/port/hostname/CLI drift, an unverifiable ownership claim, a blocked takeover, or the compatibility fingerprint — so a transient re-probe can be told from genuine drift in the caller's log. Immediately before any manager stop or direct PID signal, the CLI rechecks the same manager identity or proven absence; a change also returns `approval-changed`. A non-OpenCodex process replacing the OS job in the remaining instant before the manager command is a residual same-user risk outside the ownership lease. The guarded path stops the bound manager, or the approved PID directly when managers are proven absent, then uses a five-second shared deadline to verify PID exit, port availability and definitive endpoint absence. Only after settlement does a read-only manager probe require launchd `not-loaded` in both domains or systemd `inactive` with `MainPID=0`; the state is checked again before a success summary. Timeout, active or unreadable manager state returns terminal `manager-still-active`, even if the endpoint briefly refuses. Windows managers bind through `src/service/windows-guarded-manager.ts`: a Task Scheduler backend must prove a recognized OpenCodex registration against the bounded current-user SID/name lookup, a running task, and a wrapper ancestor carrying this home's canonical paths — presence and running state are read root-folder-scoped because every `/tn` operation resolves a bare name there and nowhere else, so a same-named task inside a task folder is a different registration neither the probe nor the stop can reach; the registered definition itself is read only on the running path, and a state or registration read that cannot answer re-probes presence so a task deleted between probes — which can respawn nothing — reads absent while a merely unreadable one stays unknown; an unreadable current identity leaves the manager unknown; a WinSW backend must prove this installation's binary and SCM service PID ancestry; either marks `childNeedsSeparateStop`, so the approved PID receives its own graceful signal only after a successful manager stop because `schtasks /end` does not reliably cascade to the wrapper's child (#764); a failed or unknown manager stop leaves the child untouched. Post-stop Windows inactivity means the task not running, no surviving wrapper process, and no started WinSW — never mere registration presence; a running-state read that cannot answer re-probes presence the same way, so a task deleted between probes reports inactive while a persisting one stays unknown. Every Windows manager command a verdict or stop depends on — schtasks, sc.exe, WinSW verbs — runs under a bounded `execFileSync` timeout so a wedged Task Scheduler or Service Control Manager cannot hang the guarded stop; a killed command classifies through the same fail-closed paths a nonzero exit would (the interactive `install /p` password prompt is the one deliberate exception). A `wscript.exe`, `cscript.exe` or `cmd.exe` whose command line CIM cannot read makes the state unprovable only when it is tied to the proxy: pre-stop, as an ancestor of the approved PID; post-stop, as the former bound manager process or a descendant of it. Unrelated unreadable shells, which a non-admin query returns for other users' and elevated processes, are not evidence either way. An unprovable Windows manager still blocks guarded takeover. The token checks snapshot consistency, not whether a person approved the desktop prompt. `src/cli/stop-restore.ts` owns the shared Codex/Grok restoration and its history-only, history-deferred, and other failure categories. +`ocx stop --json` is a reporting layer over the unchanged stop path. `src/cli/index.ts` threads a `StopRunRecord` through the existing receipt, drain, respawn-verification and restore flow, and `src/cli/stop-report.ts` maps the recorded facts plus the signals that already pick the exit code into one versioned document (`schema: "ocx-stop/1"`). With `--json` the human lines print on stderr and stdout carries only that document; exit codes 0/1/79/80/81 cross the process boundary unchanged. The desktop's opt-in guarded stop adds exact approved PID, endpoint, home, CLI version and compatibility-token expectations; plain `ocx stop` retains its ordinary behavior. Under the ownership mutation lease, `src/cli/stop-approval.ts` re-resolves them and `src/service/guarded-manager-target.ts` binds any installed manager's process tree to the approved PID before a stop action. The same guard re-reads `ocx.pid` and `runtime-port.json` through `src/config/tracked-runtime-inspection.ts`, a single-pass read-only probe that walks the same checks `readPid()` and `readRuntimePort(pid)` perform and reports which one failed, answering the recorded process's start-identity question through the same cache `readPid()` fills so it cannot disagree with the admission it diagnoses. A pre-action mismatch returns `approval-changed` without stopping, and the summary carries `detail` naming the single guard fact that failed — live PID/port/hostname/CLI drift, an unverifiable ownership claim, a blocked takeover, the compatibility fingerprint, or the tracked-record cause — an absent, denied, unreadable or invalid record, a dead, foreign or unverifiable recorded process, or a record naming a different PID — so a transient re-probe can be told from genuine drift in the caller's log. The tracked-record causes are fixed strings: denied is reported only on an observed EACCES/EPERM, an unreadable command line stays unverifiable rather than attributed to an account or elevation, and no probed path, command line, stderr or token reaches the detail. Immediately before any manager stop or direct PID signal, the CLI rechecks the same manager identity or proven absence; a change also returns `approval-changed`. A non-OpenCodex process replacing the OS job in the remaining instant before the manager command is a residual same-user risk outside the ownership lease. The guarded path stops the bound manager, or the approved PID directly when managers are proven absent, then uses a five-second shared deadline to verify PID exit, port availability and definitive endpoint absence. Only after settlement does a read-only manager probe require launchd `not-loaded` in both domains or systemd `inactive` with `MainPID=0`; the state is checked again before a success summary. Timeout, active or unreadable manager state returns terminal `manager-still-active`, even if the endpoint briefly refuses. Windows managers bind through `src/service/windows-guarded-manager.ts`: a Task Scheduler backend must prove a recognized OpenCodex registration against the bounded current-user SID/name lookup, a running task, and a wrapper ancestor carrying this home's canonical paths — presence and running state are read root-folder-scoped because every `/tn` operation resolves a bare name there and nowhere else, so a same-named task inside a task folder is a different registration neither the probe nor the stop can reach; the registered definition itself is read only on the running path, and a state or registration read that cannot answer re-probes presence so a task deleted between probes — which can respawn nothing — reads absent while a merely unreadable one stays unknown; an unreadable current identity leaves the manager unknown; a WinSW backend must prove this installation's binary and SCM service PID ancestry; either marks `childNeedsSeparateStop`, so the approved PID receives its own graceful signal only after a successful manager stop because `schtasks /end` does not reliably cascade to the wrapper's child (#764); a failed or unknown manager stop leaves the child untouched. Post-stop Windows inactivity means the task not running, no surviving wrapper process, and no started WinSW — never mere registration presence; a running-state read that cannot answer re-probes presence the same way, so a task deleted between probes reports inactive while a persisting one stays unknown. Every Windows manager command a verdict or stop depends on — schtasks, sc.exe, WinSW verbs — runs under a bounded `execFileSync` timeout so a wedged Task Scheduler or Service Control Manager cannot hang the guarded stop; a killed command classifies through the same fail-closed paths a nonzero exit would (the interactive `install /p` password prompt is the one deliberate exception). A `wscript.exe`, `cscript.exe` or `cmd.exe` whose command line CIM cannot read makes the state unprovable only when it is tied to the proxy: pre-stop, as an ancestor of the approved PID; post-stop, as the former bound manager process or a descendant of it. Unrelated unreadable shells, which a non-admin query returns for other users' and elevated processes, are not evidence either way. An unprovable Windows manager still blocks guarded takeover. The token checks snapshot consistency, not whether a person approved the desktop prompt. `src/cli/stop-restore.ts` owns the shared Codex/Grok restoration and its history-only, history-deferred, and other failure categories. `ocx service status --json` (`src/service/status-json.ts`) is the passive machine form of the service report, read by peers such as GOAT's device inventory. `src/service/cli.ts` accepts `--json` for `status` only (`planServiceCommand` refuses it on every other verb before any installation probe) and answers it before `platformOps()` resolves, so no install, start, stop, repair or migration path is reachable; `skipsCodexShimAutoRestore` exempts `service status --json` so the CLI preflight repairs no Codex shim first, the collection runs inside `runPassiveRead` so the serving probe's config load neither hardens modes nor backs up a malformed file, and `--json` on any other verb is refused before the Desktop check and the Windows mutation lock; an unsupported platform or Docker gets a document instead of the text path's exit. The document reuses exactly the reads the text report makes — `diagnoseService()` (launchd `print`, systemd observation verbs, Task Scheduler `/query`, SCM/WinSW status) and, only when a registration exists, the 1.5-second loopback `confirmServiceServing` probe — plus the ownership-lease and Desktop login lines. `running` is the manager's verdict (the value `startup.serviceRunning` carries in `ocx status --json`); `serving` reports separately whether a proxy answers on the installed port. `backend` is `launchd`, `systemd`, `schtasks`, `winsw`, or `none` when nothing is registered. The systemd branch of `diagnoseService` derives through the pure `deriveSystemdServiceDiagnostic`, matching the launchd and Windows derivations. `tests/service/service-status-json.test.ts` snapshots each platform branch and records every subprocess entry point on darwin, linux and win32 against a read-only allowlist. `ocx status --json` carries an additive `install` verdict (`src/cli/status-install.ts`) mapped from the updater's own `detectInstallOwnership()`: `method` is `bun`, `npm`, `pnpm`, `mise`, `source`, `standalone` or `unknown` (detector failure), and `evidence.basis` names the package layout, the mise backend record (tool alias and backend only) or the standalone binary without printing a path (`tests/cli/status-install-method.test.ts`). @@ -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/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/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. | diff --git a/tests/cli/cli-stop-json.test.ts b/tests/cli/cli-stop-json.test.ts index 5a7168e55a2..5394f8256ac 100644 --- a/tests/cli/cli-stop-json.test.ts +++ b/tests/cli/cli-stop-json.test.ts @@ -464,6 +464,20 @@ describe("approval-bound stop", () => { () => null, BOUND_STOP_MANAGER, stop); expect(untracked.summary.detail).toBe("the tracked runtime record could not be verified"); + // The same umbrella refusal now names which tracked-record check failed, so a + // stale or missing record reads differently from an unreadable or foreign process. + const absentRecord = await runApprovedStop(STOP_APPROVAL, async () => STOP_RESOLVE, + () => ({ untracked: "the recorded PID file is absent" }), BOUND_STOP_MANAGER, stop); + expect(absentRecord.summary).toMatchObject({ outcome: "approval-changed", exitCode: 1, + detail: "the tracked runtime record could not be verified (the recorded PID file is absent)" }); + expect(absentRecord.summary.message).toContain("the recorded PID file is absent"); + + const foreignPid = await runApprovedStop(STOP_APPROVAL, async () => STOP_RESOLVE, + () => ({ untracked: "the recorded process does not match the expected start command" }), + BOUND_STOP_MANAGER, stop); + expect(foreignPid.summary.detail) + .toBe("the tracked runtime record could not be verified (the recorded process does not match the expected start command)"); + const managerGone = await runApprovedStop(STOP_APPROVAL, async () => STOP_RESOLVE, TRACKED_STOP_TARGET, () => ({ kind: "unknown" as const, reason: "CIM read failed" }), stop); expect(managerGone.summary.detail).toBe("the service manager could not be re-verified (CIM read failed)"); diff --git a/tests/service/process-state.test.ts b/tests/service/process-state.test.ts index 3ff6ac9ea01..73cfd51657a 100644 --- a/tests/service/process-state.test.ts +++ b/tests/service/process-state.test.ts @@ -18,9 +18,15 @@ import { setOcxStartProcessCacheForTests, setProcessCommandLineExecForTests, setProcessCommandLinePlatformForTests, + verifyPidIdentity, writePid, writeRuntimePort, } from "../../src/config/process-state"; +import { + describeTrackedRuntimeTarget, + inspectTrackedRuntimeRecord, +} from "../../src/config/tracked-runtime-inspection"; +import type { TrackedRecordProbeDeps } from "../../src/config/tracked-runtime-inspection"; import { markSiblingStart, resetSiblingStartForTests, siblingRuntimeField } from "../../src/codex/sibling-start"; import { setTrustedWindowsSystemDirectoryResolverForTests } from "../../src/lib/windows-elevation"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -308,3 +314,204 @@ describe("proxy process-state ownership", () => { } }); }); + +describe("tracked runtime record inspection", () => { + // Injected deps keep every probe input fake: no process.kill, no WMIC/PowerShell/ps, + // and no real stop ever runs. Record reads default to the real temp-home files. The + // cache-sharing cases below drive the same exec seams production uses -- with faked + // native answers and a real signal-0 read on this process only. + const errno = (code: string): NodeJS.ErrnoException => + Object.assign(new Error("fake " + code), { code }); + const liveOcx: TrackedRecordProbeDeps = { signal: () => {}, identity: () => "match" }; + const trackedPid = () => writeFileSync(getPidPath(), "4242", "utf-8"); + const trustedProbePaths = () => { + const trustedSystem32 = join(testDir, "trusted", "System32"); + const trustedPowerShell = join(trustedSystem32, "WindowsPowerShell", "v1.0", "powershell.exe"); + mkdirSync(dirname(trustedPowerShell), { recursive: true }); + writeFileSync(trustedPowerShell, "", { mode: 0o755 }); + setProcessCommandLinePlatformForTests("win32"); + setTrustedWindowsSystemDirectoryResolverForTests(() => trustedSystem32); + return { trustedWmic: join(trustedSystem32, "wbem", "WMIC.exe"), trustedPowerShell }; + }; + // The trusted-System32 seam also reroutes icacls resolution, so these records are + // written directly like the cases above -- the parity being tested is the shared + // identity cache, not the atomic writer. + const trackedSelf = (hostname: string) => { + writeFileSync(getPidPath(), String(process.pid), "utf-8"); + writeFileSync(getRuntimePortPath(), JSON.stringify({ pid: process.pid, port: 58195, hostname }), "utf-8"); + }; + + test("a verified record pair returns the tracked target", () => { + trackedPid(); + writeFileSync(getRuntimePortPath(), JSON.stringify({ pid: 4242, port: 58195, hostname: "0.0.0.0" }), "utf-8"); + expect(inspectTrackedRuntimeRecord(liveOcx)).toEqual( + { status: "verified", pid: 4242, port: 58195, hostname: "0.0.0.0" }); + expect(describeTrackedRuntimeTarget(liveOcx)).toEqual({ pid: 4242, port: 58195, hostname: "0.0.0.0" }); + }); + + test("an absent or invalid pid record is named before any process probe", () => { + const probes: string[] = []; + const deps: TrackedRecordProbeDeps = { + signal: () => { probes.push("signal"); }, + identity: () => { probes.push("identity"); return "match"; }, + }; + expect(inspectTrackedRuntimeRecord(deps)).toEqual( + { status: "refused", reason: "pid-record-absent" }); + expect(probes).toEqual([]); + + writeFileSync(getPidPath(), "not-a-pid", "utf-8"); + expect(inspectTrackedRuntimeRecord(deps)).toEqual( + { status: "refused", reason: "pid-record-invalid" }); + expect(probes).toEqual([]); + }); + + test("pid-record read errors separate real permission denials from other failures", () => { + const denied = (code: string) => inspectTrackedRuntimeRecord({ + readFile: () => { throw errno(code); }, + signal: () => {}, + identity: () => "match", + }); + expect(denied("EACCES")).toEqual({ status: "refused", reason: "pid-record-permission-denied" }); + expect(denied("EPERM")).toEqual({ status: "refused", reason: "pid-record-permission-denied" }); + expect(denied("EIO")).toEqual({ status: "refused", reason: "pid-record-unreadable" }); + }); + + test("a dead recorded pid reports not-running; other signal errors stay unverifiable", () => { + trackedPid(); + const withSignal = (signal: (pid: number) => void) => + inspectTrackedRuntimeRecord({ signal, identity: () => "match" }); + expect(withSignal(() => { throw errno("ESRCH"); })).toEqual( + { status: "refused", reason: "process-not-running" }); + expect(withSignal(() => { throw errno("EINVAL"); })).toEqual( + { status: "refused", reason: "process-unverifiable" }); + expect(withSignal(() => { throw new Error("signal probe failed"); })).toEqual( + { status: "refused", reason: "process-unverifiable" }); + }); + + test("an unverifiable identity observation is never permission evidence", () => { + trackedPid(); + writeFileSync(getRuntimePortPath(), JSON.stringify({ pid: 4242, port: 58195 }), "utf-8"); + // Whether the liveness signal was clean or EPERM, an identity that cannot be + // observed only means it could not be verified. The unelevated-desktop case + // behind #7771 is one possibility, not a proven cause. + for (const signal of [() => {}, () => { throw errno("EPERM"); }]) { + expect(inspectTrackedRuntimeRecord({ signal, identity: () => "unverifiable" })).toEqual( + { status: "refused", reason: "process-unverifiable" }); + } + // And a foreign process holding the recorded pid is a different fact entirely. + expect(inspectTrackedRuntimeRecord({ + signal: () => {}, + identity: () => "mismatch", + })).toEqual({ status: "refused", reason: "process-identity-mismatch" }); + }); + + test("runtime record failures distinguish absent, denied, invalid and pid-mismatch", () => { + trackedPid(); + const deps: TrackedRecordProbeDeps = { signal: () => {}, identity: () => "match" }; + expect(inspectTrackedRuntimeRecord(deps)).toEqual( + { status: "refused", reason: "runtime-record-absent" }); + + writeFileSync(getRuntimePortPath(), "{not json", "utf-8"); + expect(inspectTrackedRuntimeRecord(deps)).toEqual( + { status: "refused", reason: "runtime-record-invalid" }); + + writeFileSync(getRuntimePortPath(), JSON.stringify({ pid: 4242, port: 99999 }), "utf-8"); + expect(inspectTrackedRuntimeRecord(deps)).toEqual( + { status: "refused", reason: "runtime-record-invalid" }); + + writeFileSync(getRuntimePortPath(), JSON.stringify({ pid: 9999, port: 58195 }), "utf-8"); + expect(inspectTrackedRuntimeRecord(deps)).toEqual( + { status: "refused", reason: "runtime-record-pid-mismatch" }); + + expect(inspectTrackedRuntimeRecord({ + readFile: path => path.endsWith("ocx.pid") + ? "4242" + : (() => { throw errno("EACCES"); })(), + signal: () => {}, + identity: () => "match", + })).toEqual({ status: "refused", reason: "runtime-record-permission-denied" }); + }); + + test("refusal details are fixed sentences and never leak the observed error text", () => { + const marker = "t0ken-c0mmand-l1ne"; + const mismatched = describeTrackedRuntimeTarget({ + readFile: () => { throw Object.assign(new Error("open failed " + marker), { code: "EPERM" }); }, + signal: () => {}, + identity: () => "mismatch", + }); + expect(mismatched).toEqual({ untracked: "reading the recorded PID file was denied" }); + expect(JSON.stringify(mismatched)).not.toContain(marker); + + trackedPid(); + expect(describeTrackedRuntimeTarget(liveOcx)).toEqual( + { untracked: "the tracked runtime record is absent" }); + }); + + test("a cached start identity serves both readers after the native probe dies", () => { + const { trustedWmic } = trustedProbePaths(); + trackedSelf("0.0.0.0"); + const calls: string[] = []; + setProcessCommandLineExecForTests(executable => { + calls.push(executable); + if (executable === trustedWmic) return "CommandLine=ocx.exe start\r\n"; + throw new Error("unexpected process probe: " + executable); + }); + // The pidless healthz path fills the shared start-identity cache for this PID. + expect(verifyPidIdentity(process.pid)).toBe(process.pid); + expect(calls).toEqual([trustedWmic]); + // Every native probe is now dead: the cached verdict must still admit this + // record for both readers, and neither may ask again. + setProcessCommandLineExecForTests(executable => { + calls.push(executable); + throw new Error("native probe unavailable"); + }); + expect(readPid()).toBe(process.pid); + expect(readRuntimePort(process.pid)?.port).toBe(58195); + expect(describeTrackedRuntimeTarget()).toEqual({ pid: process.pid, port: 58195, hostname: "0.0.0.0" }); + expect(calls).toEqual([trustedWmic]); + }); + + test("a cached foreign verdict still refuses both readers while a fresh probe could answer", () => { + trustedProbePaths(); + trackedSelf("0.0.0.0"); + setOcxStartProcessCacheForTests([[process.pid, false]]); + const calls: string[] = []; + setProcessCommandLineExecForTests(executable => { + calls.push(executable); + return "CommandLine=ocx.exe start\r\n"; + }); + expect(readPid()).toBeNull(); + expect(describeTrackedRuntimeTarget()).toEqual( + { untracked: "the recorded process does not match the expected start command" }); + expect(calls).toEqual([]); + }); + + test("an unreadable identity answer is cached by neither reader and recovers together", () => { + const { trustedWmic, trustedPowerShell } = trustedProbePaths(); + trackedSelf("0.0.0.0"); + const calls: string[] = []; + setProcessCommandLineExecForTests(executable => { + calls.push(executable); + throw new Error("native probe unavailable"); + }); + expect(readPid()).toBeNull(); + expect(describeTrackedRuntimeTarget()).toEqual( + { untracked: "the recorded process identity could not be verified" }); + // Each reader asked the identical dead sequence: WMIC, then both CIM retries. + expect(calls).toEqual([ + trustedWmic, trustedPowerShell, trustedPowerShell, + trustedWmic, trustedPowerShell, trustedPowerShell, + ]); + expect(ocxStartProcessCacheSizeForTests()).toBe(0); + // Recovery: the next miss re-probes once and the shared answer then serves both. + calls.length = 0; + setProcessCommandLineExecForTests(executable => { + calls.push(executable); + if (executable === trustedWmic) return "CommandLine=ocx.exe start\r\n"; + throw new Error("unexpected process probe: " + executable); + }); + expect(readPid()).toBe(process.pid); + expect(describeTrackedRuntimeTarget()).toEqual({ pid: process.pid, port: 58195, hostname: "0.0.0.0" }); + expect(calls).toEqual([trustedWmic]); + }); +}); From d82d7ee739d7bcd2aa55d9e2ca2bbae669b11812 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 30 Oct 2026 20:42:02 +0900 Subject: [PATCH 53/84] fix(router): decode routed slugs via modelDisplayNames ids (#7979) * fix(router): decode routed slugs via modelDisplayNames ids * fix(router): use display names only as a last-resort slug decode * fix(router): keep concrete policy and combo targets exact * fix(router): keep retained ids ahead of display-name decode * docs(structure): record the modelDisplayNames slug-decode fallback * fix(router): try retained ids before display-name keys in the slug fallback An encoded slash-valued retainModels id now decodes ahead of a stale modelDisplayNames key, and the structure contract states the narrower guarantee that concrete policy, combo and redirect targets skip the display-name fallback. Co-authored-by: NoRain211 <170875756+NoRain211@users.noreply.github.com> * fix(router): let an ambiguous retained claim end the display-name fallback Co-authored-by: NoRain211 <170875756+NoRain211@users.noreply.github.com> * docs(providers): note the modelDisplayNames slug-decode fallback Co-authored-by: NoRain211 <170875756+NoRain211@users.noreply.github.com> --------- Co-authored-by: NoRain211 <170875756+NoRain211@users.noreply.github.com> --- .../ja/reference/configuration/providers.md | 2 +- .../ko/reference/configuration/providers.md | 2 +- .../docs/reference/configuration/providers.md | 2 +- .../ru/reference/configuration/providers.md | 2 +- .../reference/configuration/providers.md | 2 +- src/router.ts | 23 ++++- structure/runtime.md | 2 +- tests/codex-integration/slug-codec.test.ts | 90 +++++++++++++++++++ tests/routing/policy-execution.test.ts | 29 ++++++ 9 files changed, 146 insertions(+), 8 deletions(-) diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index 616cb1e164e..8705a8b7c64 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -118,7 +118,7 @@ account を削除しても mapping は保持され、同じ id を再追加す | `models?` | `string[]` | 初期/フォールバックモデル一覧。`liveModels: false` で `models` が空でなければ、その後に `retainModels` を追加します。`models` が空または省略されている場合は、設定済みの `defaultModel`、`retainModels` の順に初期一覧を作り、重複 ID は最初の出現だけを残します。 | | `liveModels?` | `boolean` |開始/同期時にライブ カタログをフェッチします (デフォルトは `true`)。カスタムプロバイダーは `${baseUrl}/models` を使用します。組み込みはレジストリ URL とフィルターを使用する場合があります。 | | `selectedModels?` | `string[]` |検出後のカタログ許可リスト。空でない場合は、それらの ID のみが公開されます。空または省略すると、検出されたすべてのモデルが公開されます。 | -| `modelDisplayNames?` | `Record` | このプロバイダーの正確なネイティブモデル ID をキーにした、永続的な表示専用ラベルです。大文字と小文字は区別されます。ラベルはプロバイダーカタログのメタデータより優先され、認証、アダプター、ルーティング、課金、上流リクエストには影響しません。マップは検出上限と同じ 2,000 件までです。 | +| `modelDisplayNames?` | `Record` | このプロバイダーの正確なネイティブモデル ID をキーにした、永続的な表示専用ラベルです。大文字と小文字は区別されます。ラベルはプロバイダーカタログのメタデータより優先され、認証、アダプター、ルーティング、課金、上流リクエストには影響しません。例外として、直接の `provider/model` セレクターが既知の ID、エイリアス、`retainModels` のいずれにも一致しない場合(ライブ一覧がまだ取得されていない間など)、エンコード形が一致するキーをネイティブ ID として送信します。マップは検出上限と同じ 2,000 件までです。 | | `contextWindow?` | `number` | アップストリームのメタデータが無い場合に使うプロバイダー全体のコンテキスト値。メタデータがある場合は上限として働き、より小さいライブ値をそのまま残します。Models ダッシュボードでは `providerContextCaps` とは別に設定します。 | | `modelContextWindows?` | `Record` | モデルごとのコンテキスト値および上限。`contextWindow` より優先され、ウィンドウが不明なら設定値を使い、より小さいライブメタデータがあればそちらが優先されます。 | | `inferContextWindow?` | `boolean` | カスタムプロバイダーのみ、既定は無効。`true` で欠けたウィンドウを同梱モデルメタデータから補完します。推定値であり、中継の容量を検証したものではありません。 | diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index b454ff2022d..0bb995ed1ef 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -118,7 +118,7 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | `models?` | `string[]` | 초기/폴백 모델 목록입니다. `liveModels: false`에서 `models`가 비어 있지 않으면 `models`, `retainModels` 순으로 구성합니다. `models`가 비어 있거나 생략되면 설정된 `defaultModel`, `retainModels` 순으로 구성하고, 중복 ID는 처음 나온 항목만 남깁니다. | | `liveModels?` | `boolean` | 시작 또는 동기화 시 라이브 카탈로그를 가져옵니다. 기본값은 `true`입니다. 사용자 지정 공급자는 `${baseUrl}/models`를 사용하고, 내장은 레지스트리 URL을 사용한 뒤 필터링할 수 있습니다. | | `selectedModels?` | `string[]` | 발견 후 카탈로그 허용 목록입니다. 값이 비어 있지 않으면 그 id만 노출하고, 비어 있거나 생략하면 발견된 모델을 모두 노출합니다. | -| `modelDisplayNames?` | `Record` | 이 공급자의 정확한 네이티브 모델 id를 키로 쓰는 영구 표시 전용 이름입니다. 키는 대소문자를 구분합니다. 이름은 공급자 카탈로그 메타데이터보다 우선하며 인증, 어댑터, 라우팅, 청구 또는 업스트림 요청을 바꾸지 않습니다. 맵은 발견 한도와 같은 최대 2,000개 항목을 가질 수 있습니다. | +| `modelDisplayNames?` | `Record` | 이 공급자의 정확한 네이티브 모델 id를 키로 쓰는 영구 표시 전용 이름입니다. 키는 대소문자를 구분합니다. 이름은 공급자 카탈로그 메타데이터보다 우선하며 인증, 어댑터, 라우팅, 청구 또는 업스트림 요청을 바꾸지 않습니다. 단, 직접 `provider/model` 선택자가 알려진 id, 별칭, `retainModels` 어디에도 맞지 않으면(예: 라이브 목록을 아직 받지 못한 동안) 인코딩 형태가 같은 키를 네이티브 id로 보냅니다. 맵은 발견 한도와 같은 최대 2,000개 항목을 가질 수 있습니다. | | `contextWindow?` | `number` | 업스트림 메타데이터가 없을 때 쓰이는 공급자 전반의 컨텍스트 값입니다. 메타데이터가 있으면 상한으로 동작해 더 작은 라이브 값을 그대로 둡니다. Models 대시보드에서 `providerContextCaps`와 별도로 설정합니다. | | `modelContextWindows?` | `Record` | 모델별 컨텍스트 값이자 상한입니다. `contextWindow`보다 우선하며, 창 크기를 알 수 없으면 설정값을 쓰고 더 작은 라이브 메타데이터가 있으면 그쪽을 따릅니다. | | `inferContextWindow?` | `boolean` | 사용자 지정 공급자 전용이며 기본값은 꺼짐입니다. `true`이면 빠진 창을 번들 모델 메타데이터로 채웁니다. 추정값이며 중계의 실제 용량을 검증하지 않습니다. | diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index f77855f4cc4..ae998f0f119 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -335,7 +335,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `liveModels?` | `boolean` | Fetch the live catalog on start/sync (default `true`). Custom providers use `${baseUrl}/models`; built-ins may use a registry URL and filter. | | `selectedModels?` | `string[]` | Catalog allowlist after discovery. Non-empty exposes only those ids; empty or omitted exposes all discovered models. | | `retainModels?` | `string[]` | Ids kept in the catalog even when live discovery omits them. They need not be repeated in `models`. Empty or omitted keeps today's behavior. | -| `modelDisplayNames?` | `Record` | Durable labels used only for display, keyed by this provider's exact upstream model id. Labels win over provider catalog metadata, survive discovery refreshes and provider edits, and never change authentication, adapter behavior, routing, billing, upstream request construction, the routed `provider/model` selector, or the upstream wire model. Keys are exact and case sensitive. Unknown model ids are kept so a temporarily missing model receives its label when it returns. The map accepts at most 2,000 entries, matching the discovery limit. | +| `modelDisplayNames?` | `Record` | Durable labels used only for display, keyed by this provider's exact upstream model id. Labels win over provider catalog metadata, survive discovery refreshes and provider edits, and never change authentication, adapter behavior, billing, upstream request construction, the routed `provider/model` selector, or the wire model of an id the provider already knows. One routing exception: when a direct `provider/model` selector matches no known id, alias or `retainModels` id (for example while a live roster is still cold), a key whose encoded form matches is sent as the native id. Keys are exact and case sensitive. Unknown model ids are kept so a temporarily missing model receives its label when it returns. The map accepts at most 2,000 entries, matching the discovery limit. | | `contextWindow?` | `number` | Provider-wide context fallback when upstream metadata is absent; otherwise a cap that retains smaller live metadata. Registry defaults cannot override a lower configured limit; explicit `modelContextWindows` entries take priority. The Models dashboard exposes this separately from `providerContextCaps`. | | `modelContextWindows?` | `Record` | Per-model context fallbacks/caps. These override `contextWindow`: an unknown window uses the configured value, while smaller live metadata remains authoritative. | | `inferContextWindow?` | `boolean` | Custom providers only, default off. `true` fills a missing window from bundled model-ID metadata ([details](#context-windows-on-custom-providers)); the value is an estimate, not verified relay capacity. | diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index f8ef70c7a55..af23015f1de 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -131,7 +131,7 @@ cross-route credential fallback не существует. Строки API GPT- | `models?` | `string[]` | Начальный список моделей или список для резервного режима. При `liveModels: false` за непустым `models` следует `retainModels`; если `models` пуст или отсутствует, сначала берётся настроенный `defaultModel`, затем `retainModels`. Для каждого ID сохраняется только первое вхождение. | | `liveModels?` | `boolean` | Получать live-каталог на start/sync (по умолчанию `true`). Custom-провайдеры используют `${baseUrl}/models`; built-in могут использовать registry URL и дополнительно фильтровать результат. | | `selectedModels?` | `string[]` | Allowlist каталога после discovery. Непустой список показывает только эти id; пустой или отсутствующий показывает всё, что было обнаружено. | -| `modelDisplayNames?` | `Record` | Постоянные display-only имена с точным нативным id модели этого провайдера в качестве ключа. Ключи чувствительны к регистру. Имена имеют приоритет над metadata каталога провайдера и не меняют аутентификацию, adapter, routing, billing или upstream-запросы. Карта содержит не более 2 000 записей, как и discovery. | +| `modelDisplayNames?` | `Record` | Постоянные display-only имена с точным нативным id модели этого провайдера в качестве ключа. Ключи чувствительны к регистру. Имена имеют приоритет над metadata каталога провайдера и не меняют аутентификацию, adapter, routing, billing или upstream-запросы. Исключение: если прямой селектор `provider/model` не совпадает ни с известным id, ни с alias, ни с `retainModels` (например, пока live-каталог ещё не загружен), ключ с совпадающей закодированной формой отправляется как нативный id. Карта содержит не более 2 000 записей, как и discovery. | | `contextWindow?` | `number` | Значение контекста для всего провайдера, применяемое когда upstream не отдаёт metadata; при наличии metadata работает как cap и сохраняет более маленькое live-значение. Панель Models настраивает его отдельно от `providerContextCaps`. | | `modelContextWindows?` | `Record` | Значения и cap'ы контекста по отдельным моделям. Перекрывают `contextWindow`: если окно неизвестно, берётся заданное значение, а более маленькая live-metadata остаётся авторитетной. | | `inferContextWindow?` | `boolean` | Только для пользовательских провайдеров, по умолчанию выключено. `true` заполняет отсутствующее окно из встроенных метаданных модели; это оценка, а не проверенная ёмкость посредника. | diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index 2703729ead1..e7b199e536d 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -120,7 +120,7 @@ selector,而不是分配一个新名称。 | `models?` | `string[]` | 初始/回退模型列表。`liveModels: false` 时,非空 `models` 后接 `retainModels`;若 `models` 为空或省略,则按已配置的 `defaultModel`、`retainModels` 顺序构建初始列表,重复 ID 仅保留首次出现的位置。 | | `liveModels?` | `boolean` | 启动/同步时获取实时目录(默认 `true`)。自定义提供者使用 `${baseUrl}/models`;内置项可能使用注册表 URL 并进行过滤。 | | `selectedModels?` | `string[]` | 发现之后的目录允许列表。非空时只暴露这些 id;为空或省略时则暴露全部发现到的模型。 | -| `modelDisplayNames?` | `Record` | 持久的仅显示名称,以此提供者的精确原生模型 id 为键。键区分大小写。名称优先于提供者目录元数据,并且不会改变身份验证、适配器、路由、计费或上游请求。该映射最多可包含 2,000 个条目,与发现上限相同。 | +| `modelDisplayNames?` | `Record` | 持久的仅显示名称,以此提供者的精确原生模型 id 为键。键区分大小写。名称优先于提供者目录元数据,并且不会改变身份验证、适配器、路由、计费或上游请求。例外:当直接的 `provider/model` 选择器不匹配任何已知 id、别名或 `retainModels`(例如实时目录尚未获取时),编码形式相同的键会作为原生 id 发送。该映射最多可包含 2,000 个条目,与发现上限相同。 | | `contextWindow?` | `number` | 上游缺少元数据时使用的提供者级上下文数值;有元数据时作为上限,保留更小的实时数值。Models 面板中与 `providerContextCaps` 分开设置。 | | `modelContextWindows?` | `Record` | 按模型设置的上下文数值与上限。优先于 `contextWindow`:窗口未知时采用所配置的数值,而更小的实时元数据仍然优先。 | | `inferContextWindow?` | `boolean` | 仅限自定义渠道,默认关闭。设为 `true` 时用内置模型元数据补全缺失窗口;这是估计值,不代表中转的实测容量。 | diff --git a/src/router.ts b/src/router.ts index d3939298e05..b9f4b85ae99 100644 --- a/src/router.ts +++ b/src/router.ts @@ -30,7 +30,7 @@ import { LEGACY_OPENAI_MULTI_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID, } from "./providers/openai-tiers"; -import { decodeRoutedModelIdOrThrow, encodeRoutedModelId } from "./providers/slug-codec"; +import { decodeRoutedModelId, decodeRoutedModelIdOrThrow, encodeRoutedModelId } from "./providers/slug-codec"; import { effectiveProviderAliasDecision, resolveModelAlias } from "./providers/default-aliases"; import { resolveBlockedModelRedirect } from "./lib/shadow-call"; import { workBuddyCatalogIdentity } from "./providers/workbuddy"; @@ -200,6 +200,20 @@ export function knownModelIdsForProvider( * (never a caller-supplied string), so the log carries no request content. */ const compactionFallbackWarnings = new Set(); +/** + * Last-resort decode for a direct provider-qualified slug the live roster cannot resolve yet. + * Retained ids win (an encoded slash-valued retained id included), then the operator-declared + * modelDisplayNames keys. Neither list feeds alias generation. + */ +function decodeDisplayNameFallback(prov: OcxProviderConfig, decoded: string): string { + const retained = prov.retainModels ?? []; + // Any retained claim, even an ambiguous one the codec refuses to guess, ends the fallback. + if (retained.some(id => id === decoded || encodeRoutedModelId(id) === decoded)) { + return decodeRoutedModelId(decoded, retained); + } + return decodeRoutedModelId(decoded, Object.keys(prov.modelDisplayNames ?? {})); +} + function warnCompactionDefaultProviderFallbackOnce(providerName: string): void { if (compactionFallbackWarnings.has(providerName)) return; compactionFallbackWarnings.add(providerName); @@ -583,7 +597,12 @@ function routeModelInternal( const decoded = decodeRoutedModelIdOrThrow(requestedModel, known); const nativeModel = known.includes(decoded) ? decoded - : resolveModelAlias(config, prov, known, requestedModel) ?? decoded; + : resolveModelAlias(config, prov, known, requestedModel) + // Last resort for direct requests while the live roster is cold: display-name keys are + // operator-declared native ids, consulted only here so they never claim aliases or shadow + // known or retained ids. Concrete policy/combo/redirect targets (bypassCombos) keep the + // exact model their evaluator selected, so evidence and eligibility describe what is sent. + ?? (bypassCombos ? decoded : decodeDisplayNameFallback(prov, decoded)); return routeResult( config, provName, diff --git a/structure/runtime.md b/structure/runtime.md index ac5b871d6c6..0af2d36768d 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -18,7 +18,7 @@ Release packaging, Docker, installed-artifact and service smoke jobs, desktop sh `src/router.ts` attaches one frozen `ResolvedModelPolicy` to every `RouteResult`. Fast observation, persistence and cost provenance follow the [response-tier authority contract](transports/responses.md#response-tier-observation-authority); outbound Fast policy is unchanged. Policy/combo route spreads retain that object. Every initial, fallback, and recovery route is recaptured for the request's original inbound protocol before route-dependent normalization, and all adapter rebuilds consume its recorded adapter. A translated Chat or Anthropic replay therefore cannot inherit a Responses-only default. Credential, account, quota, health, cooldown, and observed transport evidence remain late and cannot widen a captured static limit. -Virtual models are the sole model-identity transition: the ordinary and compact paths preserve the selected public id in diagnostics, rewrite `route.modelId` to the upstream wire id, and atomically replace `route.staticPolicy` before adapter or capability decisions continue. Model aliases are resolved before the route result is built, so their policy is already keyed by the native wire id. `src/router.ts` applies bare `blockedModelRedirects` keys after this resolution; ordinary targets substitute one upstream model id on the selected provider/account, including slash-valued targets, while only targets explicitly naming a different configured provider reroute. Qualified source keys take precedence only for those cross-provider targets. A cross-provider redirect sets `RouteResult.credentialDomainRewrite`, which Responses and Chat header scoping treat like combo/policy routes, so a caller credential for the source route is never forwarded. `src/cli/alias.ts` consumes the JSON output flag before choosing its default list action. `ocx alias --json` and both flag positions around an explicit action preserve the selected read or mutation. Live selector hints obey the [credential-scoped cache contract](catalog.md): a selection change cannot reuse the previous credential's roster to choose an alias target. Passive OAuth observation neither refreshes credentials nor repairs their storage. Cross-provider redirects require an enabled destination with a resolved key, usable stored OAuth account, or local/keyless auth (never source-caller forward auth), and share one visited set and five-edge budget across alias, provider, combo and policy resolution; every policy redirect must end at an eligible declared candidate, including same-provider substitutions and candidate fallback retries, and pinned account selectors reject provider changes. Decision traces name the final provider/model while policy candidates retain selection evidence and combos retain their route kind and redirect reason. +Virtual models are the sole model-identity transition: the ordinary and compact paths preserve the selected public id in diagnostics, rewrite `route.modelId` to the upstream wire id, and atomically replace `route.staticPolicy` before adapter or capability decisions continue. Model aliases are resolved before the route result is built, so their policy is already keyed by the native wire id. `src/router.ts` applies bare `blockedModelRedirects` keys after this resolution; ordinary targets substitute one upstream model id on the selected provider/account, including slash-valued targets, while only targets explicitly naming a different configured provider reroute. Qualified source keys take precedence only for those cross-provider targets. A cross-provider redirect sets `RouteResult.credentialDomainRewrite`, which Responses and Chat header scoping treat like combo/policy routes, so a caller credential for the source route is never forwarded. `src/cli/alias.ts` consumes the JSON output flag before choosing its default list action. `ocx alias --json` and both flag positions around an explicit action preserve the selected read or mutation. Live selector hints obey the [credential-scoped cache contract](catalog.md): a selection change cannot reuse the previous credential's roster to choose an alias target. Passive OAuth observation neither refreshes credentials nor repairs their storage. Cross-provider redirects require an enabled destination with a resolved key, usable stored OAuth account, or local/keyless auth (never source-caller forward auth), and share one visited set and five-edge budget across alias, provider, combo and policy resolution; every policy redirect must end at an eligible declared candidate, including same-provider substitutions and candidate fallback retries, and pinned account selectors reject provider changes. Decision traces name the final provider/model while policy candidates retain selection evidence and combos retain their route kind and redirect reason. An explicit provider-qualified slug decodes against `knownModelIdsForProvider` first, then model aliases. Only when both miss on a direct request does `src/router.ts` decode once more against the provider's `modelDisplayNames` keys, which are operator-declared native ids; this keeps a live-discovery provider such as Baseten routable while its roster cache is cold. The fallback never feeds `knownModelIdsForProvider`, so display-name keys claim no aliases and never shadow a configured id; `retainModels` ids (an encoded slash-valued one included) are tried before display-name keys, and concrete policy, combo and blocked-model redirect targets skip the display-name fallback (`tests/codex-integration/slug-codec.test.ts`, `tests/routing/policy-execution.test.ts`). Routed Meta Muse requests use the registry-owned [Muse effort and header contract](providers-and-adapters.md); `max` reaches the provider through the existing reasoning mapper. diff --git a/tests/codex-integration/slug-codec.test.ts b/tests/codex-integration/slug-codec.test.ts index 3a07001def8..1f521e3ebfc 100644 --- a/tests/codex-integration/slug-codec.test.ts +++ b/tests/codex-integration/slug-codec.test.ts @@ -301,6 +301,96 @@ describe("routeModel decode (proxy layer)", () => { expect(route.modelId).toBe("vendor/m-2"); }); + test("modelDisplayNames keys decode a provider-qualified slug with a cold live cache", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "other", + providers: { + other: { + adapter: "openai-chat", + baseUrl: "https://example.com/v1", + apiKey: "k", + modelDisplayNames: { "vendor/m-3": "Vendor M3" }, + }, + }, + }; + const route = routeModel(config, "other/vendor-m-3"); + expect(route.providerName).toBe("other"); + expect(route.modelId).toBe("vendor/m-3"); + }); + + test("a stale modelDisplayNames key does not shadow a configured id", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "other", + providers: { + other: { + adapter: "openai-chat", + baseUrl: "https://example.com/v1", + apiKey: "k", + models: ["a-b"], + modelDisplayNames: { "a/b": "Stale A/B" }, + }, + }, + }; + expect(routeModel(config, "other/a-b").modelId).toBe("a-b"); + }); + + test("a stale modelDisplayNames key does not shadow a retained id", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "other", + providers: { + other: { + adapter: "openai-chat", + baseUrl: "https://example.com/v1", + apiKey: "k", + retainModels: ["a-b"], + modelDisplayNames: { "a/b": "Stale A/B" }, + }, + }, + }; + expect(routeModel(config, "other/a-b").modelId).toBe("a-b"); + }); + + test("an encoded slash-valued retained id wins over a stale display-name key", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "other", + providers: { + other: { + adapter: "openai-chat", + baseUrl: "https://example.com/v1", + apiKey: "k", + retainModels: ["a/b"], + modelDisplayNames: { "a-b": "Stale A-B" }, + }, + }, + }; + expect(routeModel(config, "other/a-b").modelId).toBe("a/b"); + config.providers.other!.retainModels = ["x/y-z", "x-y/z"]; + config.providers.other!.modelDisplayNames = { "x/y-z": "Stale" }; + expect(routeModel(config, "other/x-y-z").modelId).toBe("x-y-z"); + }); + + test("a stale modelDisplayNames key does not break an existing default alias", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "other", + providers: { + other: { + adapter: "openai-chat", + baseUrl: "https://example.com/v1", + apiKey: "k", + defaultAliases: true, + models: ["vendor/claude-opus-5"], + modelDisplayNames: { "vendor/claude-opus-5-old": "Stale Opus" }, + }, + }, + }; + expect(routeModel(config, "other/opus").modelId).toBe("vendor/claude-opus-5"); + }); + test("knownModelIdsForProvider unions config, registry, and hint-map ids", () => { const ids = knownModelIdsForProvider("zenmux", zenmuxConfig().providers.zenmux!); expect(ids).toContain("moonshotai/kimi-k3-free"); diff --git a/tests/routing/policy-execution.test.ts b/tests/routing/policy-execution.test.ts index 96b6405a5b5..3508c5a2594 100644 --- a/tests/routing/policy-execution.test.ts +++ b/tests/routing/policy-execution.test.ts @@ -134,6 +134,35 @@ describe("policy execution (RI-05)", () => { expect(route.modelId).toBe("m1"); }); + test("policy and combo targets keep their exact model when a display name could re-decode it", () => { + const base = baseConfig(); + const config = baseConfig({ + providers: { + ...base.providers, + other: { + adapter: "openai-chat", + baseUrl: "https://other.example/v1", + apiKey: "ko", + modelDisplayNames: { "a/b": "Display-only A/B" }, + }, + }, + combos: { exactcombo: { strategy: "failover", targets: [{ provider: "other", model: "a-b" }] } }, + routingProfiles: { + exact: { + candidates: [{ provider: "other", model: "a-b" }], + unknownEvidence: { capability: "penalize", health: "penalize", quota: "penalize", cost: "penalize" }, + }, + }, + }); + expect(routeModel(config, "other/a-b").modelId).toBe("a/b"); + const policy = routeModel(config, "policy/exact"); + expect(policy.modelId).toBe("a-b"); + expect(policy.routeDecision!.selected.model).toBe("a-b"); + const combo = routeModel(config, "combo/exactcombo"); + expect(combo.modelId).toBe("a-b"); + expect(combo.combo!.target.model).toBe("a-b"); + }); + test("existing explicit, combo, native and default routes are unchanged", () => { const config = baseConfig(); expect(routeModel(config, "a/m1")).toMatchObject({ routeKind: "explicit-provider", providerName: "a", modelId: "m1" }); From 1e1620f3d9c2bee0c45591f3d074ae5f141fccea Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 30 Oct 2026 20:43:19 +0900 Subject: [PATCH 54/84] fix(responses): normalize Codex tool catalogs and include for Meta Muse (#7985) * fix(responses): normalize Codex tool catalogs and include for Meta Muse * test(muse): assert web search after public catalog projection * fix(meta): preserve catalog precedence and validate all input forms * fix(responses): skip validation of historical Muse catalogs Co-authored-by: nobrainer-tech <445466+nobrainer-tech@users.noreply.github.com> --------- Co-authored-by: nobrainer-tech Co-authored-by: nobrainer-tech <445466+nobrainer-tech@users.noreply.github.com> --- .../src/content/docs/reference/adapters.md | 21 ++ .../openai-responses/muse-tool-catalog.ts | 81 ++++++ src/adapters/openai-responses/passthrough.ts | 15 +- .../openai-responses/request-strips.ts | 27 +- src/server/responses/passthrough-dispatch.ts | 2 + structure/providers/opengateway.md | 2 + structure/providers/xai-grok.md | 2 +- structure/transports/response-citations.md | 2 + structure/transports/responses-citations.md | 2 + structure/transports/responses-wire-shapes.md | 4 +- .../muse-spark-web-search-compat.test.ts | 17 +- .../responses-muse-tool-choice.test.ts | 251 +++++++++++++++++- 12 files changed, 393 insertions(+), 33 deletions(-) create mode 100644 src/adapters/openai-responses/muse-tool-catalog.ts diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 82cf7d00436..0545fbbd8e6 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -260,6 +260,27 @@ blank strings and mixed encrypted/unknown parts are not partially converted. See [agent messages](/reference/configuration/providers/#routed-agent-messages) for the separate opt-in encrypted-task recovery behavior. +Meta Responses also receives Codex's current `additional_tools` declarations in the public +`tools` array. The catalog is captured before history normalization: replayed declarations +and historical tool-search discoveries cannot activate tools for the new turn. Current +namespace, custom-tool and long-name aliases retain the existing normalization and response +restoration. Current declarations keep their parameter schemas when search results repeat a tool. +Historical custom calls retain their existing function-call representation without granting +those old tools current authority. Nested function definitions become public declarations. +Malformed or unrepresentable namespace catalogs return HTTP 400 before inference, including +requests with string or omitted input, rather than being partially promoted. + +For Meta Responses at the exact `api.meta.ai` host, OpenCodex omits the unsupported +`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. + +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 +billed Meta API key. A subscription key can therefore return HTTP 403 through OpenCodex +even when the request shape is valid. See [Meta's subscription rules](https://dev.meta.ai/docs/muse-code/subscriptions). + For xAI Responses, `auto` or `none` tool selection is omitted when normalization leaves no tools in the request, including when cached-only search is removed. Valid forced function selections remain intact. Replayed custom tool calls with missing or invalid item ids receive stable ids diff --git a/src/adapters/openai-responses/muse-tool-catalog.ts b/src/adapters/openai-responses/muse-tool-catalog.ts new file mode 100644 index 00000000000..bf2811e00eb --- /dev/null +++ b/src/adapters/openai-responses/muse-tool-catalog.ts @@ -0,0 +1,81 @@ +import { isPlainObject } from "./internal"; +import { promoteClientLoadedTools } from "./tool-schema"; +import { rewriteRoutedCustomToolsForUpstream } from "../../responses/custom-tool-compat"; + +export class MuseToolCatalogCompatibilityError extends Error { + constructor() { + super("Malformed tool catalog for Meta Responses."); + this.name = "MuseToolCatalogCompatibilityError"; + } +} + +function isRepresentableName(name: unknown): name is string { + return typeof name === "string" && name.length > 0 + && [...name].every(character => character.charCodeAt(0) > 0x1f && character.charCodeAt(0) !== 0x7f); +} + +function normalizeCatalog(tools: unknown, inNamespace = false): Record[] { + if (!Array.isArray(tools)) throw new MuseToolCatalogCompatibilityError(); + return tools.flatMap(tool => { + if (!isPlainObject(tool) || typeof tool.type !== "string") throw new MuseToolCatalogCompatibilityError(); + if (inNamespace && tool.type !== "function" && tool.type !== "custom") throw new MuseToolCatalogCompatibilityError(); + if (tool.type === "function" && isPlainObject(tool.function)) { + const { function: definition, ...outer } = tool; + tool = { ...outer, ...definition, type: "function" }; + } + if (["namespace", "function", "custom"].includes(tool.type) + && !isRepresentableName(tool.name)) throw new MuseToolCatalogCompatibilityError(); + if (tool.type !== "namespace") return [tool]; + return [{ ...tool, tools: normalizeCatalog(tool.tools, true) }]; + }); +} + +function flattenBuiltinNamespace(tools: Record[]): Record[] { + const bareNames = new Set(tools.filter(tool => tool.type !== "namespace").map(tool => tool.name)); + return tools.flatMap(tool => { + if (tool.type !== "namespace" || tool.name !== "functions") return [tool]; + // Match namespace lowering: an explicit bare declaration owns this identity, + // even when it occurs later or in a different current declaration carrier. + return (tool.tools as Record[]).filter(child => !bareNames.has(child.name)) + .map(child => tool.defer_loading === undefined ? child : { defer_loading: tool.defer_loading, ...child }); + }); +} + +/** + * Meta rejects Codex's additional_tools input items. Capture the current catalog + * before history rewrites can shift the replay boundary or consume duplicate names. + * Historical messages and search results remain replay context, never declarations. + * The caller gates this projection on the actual Meta destination. + */ +export function normalizeMuseToolCatalog(body: unknown, replayPrefixLength = 0, supportsCustomTools?: boolean): unknown { + if (!isPlainObject(body)) return body; + // Namespace lowering drops malformed groups; reject them before any partial promotion. + const topLevelTools = body.tools === undefined ? undefined : normalizeCatalog(body.tools); + if (!Array.isArray(body.input)) return topLevelTools === undefined ? body : { ...body, tools: flattenBuiltinNamespace(topLevelTools) }; + const start = Number.isFinite(replayPrefixLength) + ? Math.min(body.input.length, Math.max(0, Math.trunc(replayPrefixLength))) : 0; + const normalizedInput = body.input.map((item, index) => { + if (index < start) return item; + if (isPlainObject(item) && (item.type === "additional_tools" + || (item.type === "tool_search_output" && item.tools !== undefined))) { + const tools = normalizeCatalog(item.tools); + return { ...item, tools: item.type === "tool_search_output" ? flattenBuiltinNamespace(tools) : tools }; + } + return item; + }); + const currentInput = normalizedInput.slice(start); + const declared = [...(topLevelTools ?? [])]; + for (const item of currentInput) { + if (isPlainObject(item) && item.type === "additional_tools" && Array.isArray(item.tools)) declared.push(...item.tools); + } + // All current declarations precede discoveries, regardless of their carrier. + const current = promoteClientLoadedTools({ ...body, tools: flattenBuiltinNamespace(declared), input: currentInput }); + const tools = isPlainObject(current) && Array.isArray(current.tools) ? current.tools : declared; + // Replay still needs the old declarations to lower custom calls and their outputs. + // Retain only that input projection, never the historical declaration/restore sets. + const replay = rewriteRoutedCustomToolsForUpstream({ ...body, input: normalizedInput }, supportsCustomTools).body; + const replayInput = isPlainObject(replay) && Array.isArray(replay.input) ? replay.input : normalizedInput; + const input = replayInput.filter(item => !isPlainObject(item) || item.type !== "additional_tools"); + const hasCatalog = topLevelTools !== undefined || tools.length > 0 || input.length !== body.input.length; + return { ...body, input, ...(hasCatalog ? { tools } : {}) }; +} diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index 256707dc813..9cb4657c64f 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -40,7 +40,7 @@ import { emittedFastWire, } from "../../providers/fastwire"; import { dropResponsesReasoningInputItems, mapRoutedResponsesReasoningEffort, normalizeConfiguredReasoningSummaryDelivery, sanitizeReasoningInputContent, stripDisabledReasoningSummaries, stripDisabledVerbosity, stripInternalReasoningEnabled, stripNoneReasoningSummary, stripUnsupportedReasoningSummaryDelivery } from "./reasoning"; -import { repairUrlCitationAnnotations, scrubOcxCompactionItems, stripXaiUnsupportedInclude, stripCanonicalOnlyInputItems, stripCanonicalOnlyToolFields, stripCanonicalOnlyTopLevelFields, stripInternalChatMessageMetadataPassthrough, stripInvalidItemIds, stripItemIdsWhenUnstored, stripProviderInputItemMetadata, stripRejectedSamplingParams } from "./request-strips"; +import { repairUrlCitationAnnotations, scrubOcxCompactionItems, stripUnsupportedOutputInclude, stripCanonicalOnlyInputItems, stripCanonicalOnlyToolFields, stripCanonicalOnlyTopLevelFields, stripInternalChatMessageMetadataPassthrough, stripInvalidItemIds, stripItemIdsWhenUnstored, stripProviderInputItemMetadata, stripRejectedSamplingParams } from "./request-strips"; import { notePlaintextReasoningDropped } from "./plaintext-reasoning-notice"; import { stripCanonicalForwardPromptCacheOptions, stripDeprecatedPromptCacheRetention } from "./prompt-cache"; import { isPlainObject } from "./internal"; @@ -53,6 +53,7 @@ import { normalizeImageGenClientTools, preferConfiguredHostedTools } from "./ima import { stripMuseSparkUnsupportedWebSearchFields, stripOpenAiOnlyWebSearchFields } from "./web-search"; 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"; @@ -178,6 +179,9 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): parsed._rawBody, forward || parsed._previousResponseInputExpanded === true, ); + if (isMetaAiResponsesDestination(url)) { + outBody = normalizeMuseToolCatalog(outBody, parsed._replayPrefixLen, provider.supportsResponsesCustomTools); + } if (!forward) outBody = normalizeRoutedAgentMessages(outBody, { allowStringContent: isXaiResponsesDestination(provider), }); @@ -308,7 +312,8 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): outBody = stripCanonicalOnlyTopLevelFields(outBody); outBody = stripCanonicalOnlyInputItems(outBody); } - outBody = promoteClientLoadedTools(outBody); + // Meta already captured discoveries from the original current-turn slice. + if (!isMetaAiResponsesDestination(url)) outBody = promoteClientLoadedTools(outBody); } if (!isCanonicalOpenAiForwardProvider(provider)) { const rewritten = rewriteRoutedCustomToolsForUpstream( @@ -338,10 +343,10 @@ 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 answers 400 for `output.encrypted_content` in `include` instead of ignoring it; + // xAI and Meta answer 400 for `output.encrypted_content` instead of ignoring it; // Codex Desktop asks for it on every turn (#7878). - if (isXaiResponsesDestination(provider)) { - outBody = stripXaiUnsupportedInclude(outBody); + if (isXaiResponsesDestination(provider) || isMetaAiResponsesDestination(url)) { + outBody = stripUnsupportedOutputInclude(outBody); } // xAI and explicitly classified compatible gateways reject these OpenAI web_search // extensions. Keep them for OpenAI API-key traffic and unclassified gateways. diff --git a/src/adapters/openai-responses/request-strips.ts b/src/adapters/openai-responses/request-strips.ts index f7e9e5f2580..82bf33520c3 100644 --- a/src/adapters/openai-responses/request-strips.ts +++ b/src/adapters/openai-responses/request-strips.ts @@ -8,24 +8,13 @@ import { activateDeferredTool } from "./tool-schema"; import { stripOpenAiOnlyWebSearchFields } from "./web-search"; /** - * Provider-opaque replay metadata is client history, not an OpenAI input-item field (#7111). - * The caller gates this on OpenAI-operated destinations; arbitrary gateways may accept it. - * Strip only the known extension, never an item-field allowlist that can erase newer wire fields - * or nested tool data. Copy on write so a later Google route can still replay its signature. - */ -/** Responses `include` entries xAI rejects with a 400 rather than ignoring (#7878). */ -const XAI_UNSUPPORTED_RESPONSES_INCLUDE = new Set(["output.encrypted_content"]); - -/** - * Drop `include` entries xAI's Responses API refuses (`Argument not supported: - * "output.encrypted_content" in "include" field`). Codex Desktop always asks for the - * OpenAI reasoning blob, so without this every xai/grok-4.7 turn sent through the - * Responses path fails, while the same model works on the openai-chat adapter. - * Copy on write; other destinations and other include entries are untouched. + * Codex requests output.encrypted_content, which xAI and Meta 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. */ -export function stripXaiUnsupportedInclude(body: unknown): unknown { +export function stripUnsupportedOutputInclude(body: unknown): unknown { if (!isPlainObject(body) || !Array.isArray(body.include)) return body; - const include = body.include.filter(item => !XAI_UNSUPPORTED_RESPONSES_INCLUDE.has(String(item))); + const include = body.include.filter(item => item !== "output.encrypted_content"); if (include.length === body.include.length) return body; if (include.length === 0) { const { include: _dropped, ...rest } = body; @@ -34,6 +23,12 @@ export function stripXaiUnsupportedInclude(body: unknown): unknown { return { ...body, include }; } +/** + * Provider-opaque replay metadata is client history, not an OpenAI input-item field (#7111). + * The caller gates this on OpenAI-operated destinations; arbitrary gateways may accept it. + * Strip only the known extension, never an item-field allowlist that can erase newer wire fields + * or nested tool data. Copy on write so a later Google route can still replay its signature. + */ export function stripProviderInputItemMetadata(body: unknown): unknown { if (!isPlainObject(body) || !Array.isArray(body.input)) return body; let input: unknown[] | undefined; diff --git a/src/server/responses/passthrough-dispatch.ts b/src/server/responses/passthrough-dispatch.ts index e253d5c9460..fdabdf1de4c 100644 --- a/src/server/responses/passthrough-dispatch.ts +++ b/src/server/responses/passthrough-dispatch.ts @@ -54,6 +54,7 @@ import { import { restoreRoutedCustomCalls, RoutedCustomToolCompatError } from "../../responses/custom-tool-compat"; import { XaiToolSchemaCompatibilityError } from "../../adapters/xai-tool-schema"; import { MuseToolChoiceCompatibilityError } from "../../adapters/openai-responses/muse-tool-choice"; +import { MuseToolCatalogCompatibilityError } from "../../adapters/openai-responses/muse-tool-catalog"; import { OpenGatewayInputCompatibilityError } from "../../adapters/openai-responses/opengateway-input"; import { formatErrorResponse } from "../../bridge"; import { redactSecretString } from "../../lib/redact"; @@ -389,6 +390,7 @@ export async function preparePassthroughExchange( || error instanceof XaiToolSchemaCompatibilityError || error instanceof RoutedCustomToolCompatError || error instanceof MuseToolChoiceCompatibilityError + || error instanceof MuseToolCatalogCompatibilityError || error instanceof OpenGatewayInputCompatibilityError ) { return formatErrorResponse(400, "invalid_request_error", redactSecretString(error.message)); diff --git a/structure/providers/opengateway.md b/structure/providers/opengateway.md index 12eeff8ec3c..6d87f19e0b9 100644 --- a/structure/providers/opengateway.md +++ b/structure/providers/opengateway.md @@ -39,3 +39,5 @@ keeps the existing stall-timeout policy. Rewritten responses preserve tool owner `tests/responses/opengateway-completion-receipts.test.ts` covers wire outcomes and resource release; `tests/responses/empty-completion-core.test.ts` and `tests/responses/empty-completion-hardening.test.ts` cover integration and replay barriers. Other destinations and translated retry defaults are unchanged. + +The separate [Responses include normalization](../transports/responses-wire-shapes.md) is gated to xAI and Meta; it does not change this projection or its destination boundary. Shared dispatch also maps Meta catalog validation failures to a client 400 before sending upstream. diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 5a767bd9c90..c44f44c9a42 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -41,7 +41,7 @@ Shared parsing and streaming follow the [request-copy](../transports/byte-accoun ## Responses request compatibility -`src/providers/xai-transport.ts` recognizes the exact HTTPS hosts `api.x.ai`, `us.api.x.ai` and `cli-chat-proxy.grok.com` on the standard port as xAI Responses destinations. The Responses passthrough's `stripXaiUnsupportedInclude` removes only the rejected `output.encrypted_content` include entry, omits `include` when nothing remains, and preserves other entries and other destinations. `tests/providers/xai/xai-responses-adjacency.test.ts` covers the global, US regional and non-xAI request bodies; `tests/providers/xai/xai-transport.test.ts` rejects lookalikes, HTTP and nonstandard ports. +`src/providers/xai-transport.ts` recognizes the exact HTTPS hosts `api.x.ai`, `us.api.x.ai` and `cli-chat-proxy.grok.com` on the standard port as xAI Responses destinations. The Responses passthrough's `stripUnsupportedOutputInclude` removes only the rejected `output.encrypted_content` include entry, omits `include` when nothing remains, and preserves other entries. The same exact-entry normalization applies to Meta at `api.meta.ai`; unrelated destinations remain unchanged. `tests/providers/xai/xai-responses-adjacency.test.ts` covers the global, US regional and non-xAI request bodies; `tests/providers/xai/xai-transport.test.ts` rejects lookalikes, HTTP and nonstandard ports. ### Reasoning-model sampling parameters diff --git a/structure/transports/response-citations.md b/structure/transports/response-citations.md index b23f0e7e750..e1e7d7be55b 100644 --- a/structure/transports/response-citations.md +++ b/structure/transports/response-citations.md @@ -20,3 +20,5 @@ missing-title error; it does not validate source URLs, establish actual citation spans or prove provider acceptance beyond the exercised serialization fixtures. The existing destination gate owns eligibility, and the test layout explicitly registers the regression beside other Responses tests. + +The separate [Responses include normalization](responses-wire-shapes.md) is gated to xAI and Meta; it does not change this projection or its destination boundary. diff --git a/structure/transports/responses-citations.md b/structure/transports/responses-citations.md index 1e7f465a312..e88fdc4d562 100644 --- a/structure/transports/responses-citations.md +++ b/structure/transports/responses-citations.md @@ -17,3 +17,5 @@ gateway preservation and both refresh directions; `tests/responses/responses-native-item-extensions-compact.test.ts` covers official API and ChatGPT compact repair, while `tests/responses/responses-url-citation-title-repair.test.ts` covers ordinary HTTP/WS projection, helper independence and input immutability. + +The separate [Responses include normalization](responses-wire-shapes.md) is gated to xAI and Meta; it does not change this projection or its destination boundary. diff --git a/structure/transports/responses-wire-shapes.md b/structure/transports/responses-wire-shapes.md index 2a5808baac7..a6150ee190f 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 and non-xAI destinations remain unchanged ([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; 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, @@ -221,7 +221,7 @@ the destination is the whole predicate, because Meta's refusal is a gateway sche Muse model it serves, its default `muse-spark-1.3` is not a Contributor id, and a missing model id still strips. Because the predicate is the host, a custom provider pointed at that exact URL gets the same strip. - +Meta's `api.meta.ai` route uses `src/adapters/openai-responses/muse-tool-catalog.ts` to capture current declarations before history rewrites or deduplication: current wrappers become `tools`, declarations retain their schemas over search discoveries, historical discovery stays replay data, and historical custom calls retain their conversion without live authority. Historical catalogs are not validated as current declarations. The reserved `functions` group and nested function definitions become public function declarations. Catalog validation also covers string/omitted input; unrepresentable namespace children and malformed current catalogs fail locally with HTTP 400 (`tests/responses/responses-muse-tool-choice.test.ts`). Direct Meta Muse / Meta Model Responses (`https://api.meta.ai/v1`) also rejects function tool names longer than 64 characters or containing characters outside `[a-zA-Z0-9_-]`. After namespace flattening, `src/responses/muse-tool-name-alias.ts` rewrites those identities on the `api.meta.ai` diff --git a/tests/providers/muse-spark-web-search-compat.test.ts b/tests/providers/muse-spark-web-search-compat.test.ts index f0e9f6c9b18..6b7a9d8319d 100644 --- a/tests/providers/muse-spark-web-search-compat.test.ts +++ b/tests/providers/muse-spark-web-search-compat.test.ts @@ -234,18 +234,19 @@ describe("#2617/#3378 Muse Spark web_search compatibility", () => { * same field drop, same preview preservation. */ test("direct Meta strips rejected web_search fields at both tool positions", () => { - const body = buildForProvider(META_PROVIDER, "muse-spark-1.3-contributor", { - tools: [webSearchTool()], - input: [{ type: "additional_tools", tools: [webSearchTool()] }], - }); - const tool = toolsOf(body)[0]!; - const item = (body.input as Array>)[0]!; - const nested = (item.tools as Array>)[0]!; - for (const declaration of [tool, nested]) { + for (const catalog of [ + { tools: [webSearchTool()] }, + { input: [{ type: "additional_tools", tools: [webSearchTool()] }] }, + ]) { + const body = buildForProvider(META_PROVIDER, "muse-spark-1.3-contributor", catalog); + // Meta receives either carrier in the public catalog after projection. + expect(toolsOf(body)).toHaveLength(1); + const declaration = toolsOf(body)[0]!; expect(declaration.type).toBe("web_search"); expect(declaration.search_context_size).toBe("medium"); expect(Object.hasOwn(declaration, "search_content_types")).toBe(false); expect(Object.hasOwn(declaration, "indexed_web_access")).toBe(false); + if (Array.isArray(body.input)) expect(body.input).toEqual([]); } }); diff --git a/tests/responses/responses-muse-tool-choice.test.ts b/tests/responses/responses-muse-tool-choice.test.ts index d5f7e95d6c1..277335ee1ef 100644 --- a/tests/responses/responses-muse-tool-choice.test.ts +++ b/tests/responses/responses-muse-tool-choice.test.ts @@ -20,9 +20,11 @@ afterEach(() => { releaseSpendHome = undefined; }); -function build(provider: OcxProviderConfig, rawBody: Record) { +function build(provider: OcxProviderConfig, rawBody: Record, compact = false, replayPrefixLength?: number) { return createAdapter(provider).buildRequest({ modelId: "muse-spark-1.3", + _compactionRequest: compact, + _replayPrefixLen: replayPrefixLength, context: { messages: [] }, stream: false, options: {}, @@ -193,3 +195,250 @@ describe("Meta Muse tool choice", () => { } }); }); + + +describe("Meta Muse Responses include compatibility", () => { + test("drops output encryption while preserving supported reasoning, other includes and caller input", () => { + for (const model of ["muse-spark-1.3", "muse-wonder-1.0", "muse-wonder-full-1.0"]) { + const raw = { model, include: ["reasoning.encrypted_content", "output.encrypted_content", "message.output_text.logprobs"], store: false }; + const original = structuredClone(raw); + const body = JSON.parse(build(meta, raw).body); + expect(body.include).toEqual(["reasoning.encrypted_content", "message.output_text.logprobs"]); + expect(body.model).toBe(model); + expect(body.store).toBe(false); + expect(raw).toEqual(original); + } + }); + + test("routed compaction preserves supported reasoning encryption", () => { + const raw = { include: ["reasoning.encrypted_content", "output.encrypted_content"], store: false }; + const body = JSON.parse(build(meta, raw, true).body); + expect(body.include).toEqual(["reasoning.encrypted_content"]); + expect(raw.include).toEqual(["reasoning.encrypted_content", "output.encrypted_content"]); + }); + + test("omits include when only unsupported output encryption was requested", () => { + expect(JSON.parse(build(meta, { include: ["output.encrypted_content"] }).body)).not.toHaveProperty("include"); + }); + + test("preserves unknown and malformed includes for upstream validation", () => { + for (const include of [undefined, null, "output.encrypted_content", { value: "output.encrypted_content" }, ["future.include"], [["output.encrypted_content"]]]) { + expect(JSON.parse(build(meta, { include }).body).include).toEqual(include); + } + }); + + test("does not infer Meta from a model name, path or lookalike hostname", () => { + for (const baseUrl of ["https://api.openai.com/v1", "https://api.meta.ai.example.test/v1", "https://example.test/api.meta.ai/v1"]) { + const include = ["reasoning.encrypted_content", "output.encrypted_content"]; + expect(JSON.parse(build({ ...meta, baseUrl }, { include }).body).include).toEqual(include); + } + }); +}); + + +describe("Meta Muse additional tool catalogs", () => { + const lookup = { type: "function", name: "lookup", parameters: { type: "object", properties: {} } }; + test("moves current namespace tools to the public catalog without changing messages or caller data", () => { + const message = { type: "message", role: "user", content: "continue" }; + const raw = { input: [{ type: "additional_tools", role: "developer", tools: [{ type: "namespace", name: "functions", tools: [lookup] }] }, message] }; + const original = structuredClone(raw); + const body = JSON.parse(build(meta, raw).body); + expect(body.input).toEqual([message]); + expect(body.tools).toEqual([lookup]); + expect(raw).toEqual(original); + }); + + test("removes replayed wrappers without authorizing their historical tools", () => { + const message = { type: "message", role: "user", content: "continue" }; + const raw = { tools: [], input: [{ type: "additional_tools", tools: [lookup] }, message] }; + const body = JSON.parse(build(meta, raw, false, 1).body); + expect(body.input).toEqual([message]); + expect(body.tools).toEqual([]); + }); + + test("ignores malformed historical catalogs while normalizing current declarations", () => { + for (const historical of [ + { type: "additional_tools" }, + { type: "additional_tools", tools: [{ type: "namespace", name: "old", tools: [{ type: "web_search" }] }] }, + ]) { + const definition = { name: "read", parameters: { type: "object", properties: {} } }; + const raw = { input: [historical, { type: "additional_tools", tools: [{ type: "function", function: definition }] }] }; + const original = structuredClone(raw); + const body = JSON.parse(build(meta, raw, false, 1).body); + expect(body.input).toEqual([]); + expect(body.tools).toEqual([{ type: "function", ...definition }]); + expect(raw).toEqual(original); + } + }); + + test("does not promote historical search discoveries or let removed history shift current declarations", () => { + const stale = { ...lookup, name: "stale" }; + const live = { ...lookup, name: "live" }; + const raw = { input: [ + { type: "configuration_update", config: {} }, + { type: "additional_tools", tools: [stale] }, + { type: "tool_search_output", call_id: "old_search", tools: [stale] }, + { type: "additional_tools", tools: [live] }, + { type: "tool_search_output", call_id: "new_search", tools: [{ ...lookup, name: "loaded" }] }, + ] }; + const body = JSON.parse(build(meta, raw, false, 3).body); + expect(body.tools.map((entry: { name: string }) => entry.name).sort()).toEqual(["live", "loaded"]); + expect(body.input.some((item: { type: string }) => item.type === "additional_tools")).toBe(false); + expect(JSON.stringify(body.input)).toContain("stale"); + }); + + test("a historical duplicate cannot consume the current declaration", () => { + const current = { ...lookup, description: "Current declaration" }; + const body = JSON.parse(build(meta, { input: [ + { type: "additional_tools", tools: [lookup] }, + { type: "additional_tools", tools: [current] }, + ] }, false, 1).body); + expect(body.tools).toEqual([current]); + }); + + test("rejects malformed catalogs before partial promotion", () => { + for (const raw of [ + { tools: null, input: [{ type: "additional_tools", tools: [lookup] }] }, + { input: [{ type: "additional_tools", tools: null }, { type: "additional_tools", tools: [lookup] }] }, + { input: [{ type: "additional_tools", tools: [lookup, { type: "namespace", name: "bad", tools: null }] }] }, + { input: [{ type: "additional_tools", tools: [lookup, null] }] }, + { input: [{ type: "tool_search_output", call_id: "s1", tools: [lookup, { type: "namespace", name: "bad", tools: null }] }] }, + ]) { + const original = structuredClone(raw); + expect(() => build(meta, raw)).toThrow("Malformed tool catalog for Meta Responses."); + expect(raw).toEqual(original); + } + }); + + test("current declarations keep their schema when search discovers the same tool", () => { + const declared = { ...lookup, defer_loading: true, parameters: { type: "object", properties: { project_id: { type: "string" } } } }; + const discovered = { ...lookup, parameters: { type: "object", properties: { admin_mode: { type: "boolean" } } } }; + for (const namespace of [undefined, "functions", "workspace"]) { + const catalog = namespace ? [{ type: "namespace", name: namespace, tools: [declared] }] : [declared]; + const discoveries = namespace === "workspace" ? [{ type: "namespace", name: namespace, tools: [discovered] }] : [discovered]; + for (const tools of [undefined, []]) { + const raw = { tools, input: [ + { type: "additional_tools", tools: catalog }, + { type: "tool_search_output", call_id: "s1", tools: discoveries }, + ] }; + const original = structuredClone(raw); + const body = JSON.parse(build(meta, raw).body); + expect(body.tools).toHaveLength(1); + expect(body.tools[0].parameters).toEqual(declared.parameters); + expect(body.tools[0]).not.toHaveProperty("defer_loading"); + expect(raw).toEqual(original); + } + } + }); + + test("unrepresentable namespace children fail without a partial catalog", () => { + for (const invalid of [ + { type: "namespace", name: "inner", tools: [lookup] }, + { ...lookup, name: "bad\u0000name" }, + { type: "web_search" }, + ]) { + const group = { type: "namespace", name: "outer", tools: [lookup, invalid] }; + for (const raw of [ + { tools: [group], input: [] }, + { input: [{ type: "additional_tools", tools: [group] }] }, + { input: [{ type: "tool_search_output", call_id: "s1", tools: [group] }] }, + ]) expect(() => build(meta, raw)).toThrow("Malformed tool catalog for Meta Responses."); + } + expect(() => build(meta, { tools: [{ type: "namespace", name: "bad\nname", tools: [lookup] }] })) + .toThrow("Malformed tool catalog for Meta Responses."); + }); + + test("explicit bare declarations win over reserved namespace duplicates in either carrier order", () => { + const bare = { ...lookup, parameters: { type: "object", properties: { project_id: { type: "string" } } } }; + const group = { type: "namespace", name: "functions", tools: [{ ...lookup, parameters: { type: "object", properties: { group_id: { type: "string" } } } }] }; + for (const raw of [ + { tools: [group], input: [{ type: "additional_tools", tools: [bare] }] }, + { tools: [bare], input: [{ type: "additional_tools", tools: [group] }] }, + { input: [{ type: "additional_tools", tools: [group] }, { type: "additional_tools", tools: [bare] }] }, + { input: [{ type: "additional_tools", tools: [bare] }, { type: "additional_tools", tools: [group] }] }, + { tools: [group, bare], input: "continue" }, + ]) { + const original = structuredClone(raw); + expect(JSON.parse(build(meta, raw).body).tools).toEqual([bare]); + expect(raw).toEqual(original); + } + }); + + test("validates top-level catalogs with string or omitted input", () => { + for (const input of ["continue", undefined]) { + expect(() => build(meta, { input, tools: [lookup, { type: "namespace", name: "bad", tools: null }] })) + .toThrow("Malformed tool catalog for Meta Responses."); + } + }); + + test("normalizes nested function definitions across input forms and catalog carriers", () => { + const definition = { name: "read", description: "Read a project", parameters: { type: "object", properties: { project_id: { type: "string" } } }, strict: true }; + const nested = { type: "function", function: definition }; + for (const raw of [ + { input: "continue", tools: [nested] }, + { input: undefined, tools: [nested] }, + { input: [], tools: [nested] }, + { input: [{ type: "additional_tools", tools: [nested] }] }, + { input: [{ type: "tool_search_output", call_id: "s1", tools: [nested] }] }, + ]) { + const original = structuredClone(raw); + expect(JSON.parse(build(meta, raw).body).tools).toEqual([{ type: "function", ...definition }]); + expect(raw).toEqual(original); + } + }); + + test("preserves historical custom-call conversion without declaring the old tool", () => { + for (const toolChoice of ["auto", "none"]) { + const raw = { tool_choice: toolChoice, input: [ + { type: "additional_tools", tools: [{ type: "custom", name: "old_exec", description: "Historical tool" }] }, + { type: "custom_tool_call", name: "old_exec", call_id: "c1", input: "historical input" }, + { type: "custom_tool_call_output", call_id: "c1", output: "historical output" }, + { type: "message", role: "user", content: "continue" }, + ] }; + const original = structuredClone(raw); + const request = build(meta, raw, false, 3); + const body = JSON.parse(request.body); + expect(body.tools).toEqual([]); + expect(body.input).toEqual([ + { type: "function_call", name: "old_exec", call_id: "c1", arguments: JSON.stringify({ input: "historical input" }) }, + { type: "function_call_output", call_id: "c1", output: "historical output" }, + original.input[3], + ]); + expect(request.convertedRoutedCustomToolNames?.has("old_exec")).toBe(false); + expect(raw).toEqual(original); + } + }); + + test("malformed nested catalogs return a client error without sending upstream", async () => { + const config = { port: 0, defaultProvider: "fixture", providers: { fixture: { ...meta, authMode: "key" } } } as OcxConfig; + const savedFetch = globalThis.fetch; + let sends = 0; + globalThis.fetch = (async () => { sends++; throw new Error("must not send"); }) as typeof fetch; + try { + releaseSpendHome ??= acquireOwnedSpendHome(); + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "fixture/muse-spark-1.3", input: [ + { type: "additional_tools", tools: [lookup, { type: "namespace", name: "bad", tools: null }] }, + { type: "message", role: "user", content: "continue" }, + ] }), + }), config, { model: "", provider: "" }); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ error: { type: "invalid_request_error", message: "Malformed tool catalog for Meta Responses." } }); + expect(sends).toBe(0); + } finally { globalThis.fetch = savedFetch; } + }); + + test("none clears declarations after promotion and long-name aliasing", () => { + const body = JSON.parse(build(meta, { input: [{ type: "additional_tools", tools: [tool] }], tool_choice: "none" }).body); + expect(body.tools).toEqual([]); + expect(body.input).toEqual([]); + }); + + test("leaves the private wrapper intact for unrelated destinations", () => { + const raw = { input: [{ type: "additional_tools", tools: [lookup] }] }; + for (const baseUrl of ["https://api.openai.com/v1", "https://api.meta.ai.example.test/v1"]) { + expect(JSON.parse(build({ ...meta, baseUrl }, raw).body).input).toEqual(raw.input); + } + }); +}); From ac0833bc55cce392d79d42fa2b952b2ccbe9d9b3 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 30 Oct 2026 20:45:22 +0900 Subject: [PATCH 55/84] fix(windows): bigint probe identity in runtime preflight; release CC-Switch F3 WAL handles (#7986) * fix(windows): compare runtime preflight directory identity as bigint NTFS file IDs exceed 2^53, where adjacent IDs are equal as JS numbers. The owned-probe check could then mistake a replacement directory for its own probe and remove it, and the replacement-root test failed on windows 2/11 of run 41021347487 with ino 10696049116378316. * test(cc-switch): finalize the F3 WAL writer statements before cleanup Unfinalized prepare().run() statements keep the SQLite db, -wal and -shm open past close(), so Windows refused to remove the temp tree (EBUSY on windows 10/11 of run 41021347487). --------- Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> --- src/lib/bun-runtime-preflight.ts | 17 +++++++------ tests/lib/bun-runtime-preflight.test.ts | 32 +++++++++++++++++++++--- tests/providers/cc-switch-import.test.ts | 11 +++++--- 3 files changed, 46 insertions(+), 14 deletions(-) diff --git a/src/lib/bun-runtime-preflight.ts b/src/lib/bun-runtime-preflight.ts index 19b9ca3ca09..3f3e7f6a68b 100644 --- a/src/lib/bun-runtime-preflight.ts +++ b/src/lib/bun-runtime-preflight.ts @@ -19,9 +19,10 @@ export class RuntimePreflightError extends Error { } } -type DirectoryIdentity = { dev: number; ino: number; isDirectory(): boolean; isSymbolicLink(): boolean }; +// bigint: NTFS file IDs exceed 2^53, where adjacent IDs are equal as numbers. +type DirectoryIdentity = { dev: bigint; ino: bigint; isDirectory(): boolean; isSymbolicLink(): boolean }; export interface RuntimePreflightFs { - lstatSync: (path: string) => DirectoryIdentity; + lstatSync: (path: string, options: { bigint: true }) => DirectoryIdentity; mkdirSync: (path: string, mode: number) => unknown; rmdirSync: (path: string) => void; readdirSync: (path: string) => readonly string[]; @@ -44,7 +45,7 @@ export interface RuntimePreflightDeps { const probeFs: RuntimePreflightFs = { lstatSync, mkdirSync, rmdirSync, readdirSync }; function sameDirectory(fs: RuntimePreflightFs, path: string, owned: DirectoryIdentity): boolean { - const current = fs.lstatSync(path); + const current = fs.lstatSync(path, { bigint: true }); return current.isDirectory() && !current.isSymbolicLink() && current.dev === owned.dev && current.ino === owned.ino; } @@ -59,7 +60,7 @@ function removeOwnedEmptyDirectory(fs: RuntimePreflightFs, path: string, owned: function prepareRoot(fs: RuntimePreflightFs, root: string, rootWasAbsent?: boolean): void { try { if (rootWasAbsent === undefined) { - try { fs.lstatSync(root); rootWasAbsent = false; } + try { fs.lstatSync(root, { bigint: true }); rootWasAbsent = false; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; rootWasAbsent = true; @@ -73,7 +74,7 @@ function prepareRoot(fs: RuntimePreflightFs, root: string, rootWasAbsent?: boole // Use a concurrently created root, subject to the directory check below. } } - const current = fs.lstatSync(root); + const current = fs.lstatSync(root, { bigint: true }); if (!current.isDirectory() || current.isSymbolicLink()) throw new RuntimePreflightError("create"); } catch { throw new RuntimePreflightError("create"); @@ -83,7 +84,7 @@ function prepareRoot(fs: RuntimePreflightFs, root: string, rootWasAbsent?: boole function probeInProcess(fs: RuntimePreflightFs, path: string): void { let owned: DirectoryIdentity | undefined; try { - try { fs.mkdirSync(path, 0o700); owned = fs.lstatSync(path); } + try { fs.mkdirSync(path, 0o700); owned = fs.lstatSync(path, { bigint: true }); } catch { throw new RuntimePreflightError("create"); } try { fs.rmdirSync(path); } catch { throw new RuntimePreflightError("remove"); } @@ -96,10 +97,10 @@ function probeChild(runtime: DurableBunRuntime, path: string, nonce: string, spa // The child emits only this invocation's nonce and a fixed result, never paths/errors. const script = `const fs = require("node:fs"); const path = ${JSON.stringify(path)}; let result = "create", owned; -try { fs.mkdirSync(path, 0o700); owned = fs.lstatSync(path); result = "remove"; +try { fs.mkdirSync(path, 0o700); owned = fs.lstatSync(path, { bigint: true }); result = "remove"; fs.rmdirSync(path); result = "ok"; } catch { /* result already names the failed step; details never leave the child */ } finally { - if (owned) { try { const now = fs.lstatSync(path); + if (owned) { try { const now = fs.lstatSync(path, { bigint: true }); if (now.isDirectory() && !now.isSymbolicLink() && now.dev === owned.dev && now.ino === owned.ino && fs.readdirSync(path).length === 0) fs.rmdirSync(path); } catch { /* leave any probe entry that cannot be proven ours */ } } diff --git a/tests/lib/bun-runtime-preflight.test.ts b/tests/lib/bun-runtime-preflight.test.ts index 353302af64d..0c7dea985ff 100644 --- a/tests/lib/bun-runtime-preflight.test.ts +++ b/tests/lib/bun-runtime-preflight.test.ts @@ -171,18 +171,44 @@ describe("selected Bun write preflight", () => { test("a replacement empty root with a different inode survives refusal", () => { const parent = temporary(); const root = join(parent, "replaced"); - let originalIno = 0; + let originalIno = 0n; refusal(() => assertSelectedRuntimeWritable(runtime, root, { ...windows, rootWasAbsent: true, spawnSync: (() => { - originalIno = lstatSync(root).ino; + originalIno = lstatSync(root, { bigint: true }).ino; renameSync(root, join(parent, "old")); mkdirSync(root); return { status: 1, stdout: "nonce:create" }; }) as typeof spawnSync, }), "create"); - expect(lstatSync(root).ino).not.toBe(originalIno); + // NTFS file IDs exceed 2^53; as numbers, adjacent IDs compare equal. + expect(lstatSync(root, { bigint: true }).ino).not.toBe(originalIno); expect(readdirSync(root)).toEqual([]); }); + test("a replacement probe whose file ID differs only above 2^53 is never removed", () => { + const root = temporary(); + const probe = join(root, ".ocx-runtime-probe-nonce"); + let replaced = false; + let removals = 0; + // Adjacent IDs above 2^53 collapse to one Number: the owned probe and its replacement. + const lstat = ((path: string, options?: { bigint?: boolean }) => { + const real = lstatSync(path, { bigint: true }); + const ino = path === probe ? (replaced ? 2n ** 53n : 2n ** 53n + 1n) : real.ino; + return { + dev: options?.bigint ? real.dev : Number(real.dev), ino: options?.bigint ? ino : Number(ino), + isDirectory: () => real.isDirectory(), isSymbolicLink: () => real.isSymbolicLink(), + }; + }) as RuntimePreflightFs["lstatSync"]; + refusal(() => assertSelectedRuntimeWritable(standalone, root, { + ...windows, fs: { ...fs, lstatSync: lstat, rmdirSync: path => { + if (removals++ > 0) { rmdirSync(path); return; } + renameSync(path, join(root, "owned-probe")); mkdirSync(path); replaced = true; + throw new Error("denied once"); + } }, + }), "remove"); + expect(existsSync(probe)).toBe(true); + expect(removals).toBe(1); + }); + test("refusal never inspects emptiness or deletes the config root", () => { const root = join(temporary(), "root"); const cleanup: string[] = []; diff --git a/tests/providers/cc-switch-import.test.ts b/tests/providers/cc-switch-import.test.ts index 185f1a7ab85..106475de1bd 100644 --- a/tests/providers/cc-switch-import.test.ts +++ b/tests/providers/cc-switch-import.test.ts @@ -1006,7 +1006,10 @@ describe("CC-Switch keyless provider import", () => { try { writer.run("PRAGMA journal_mode = WAL;"); writer.run("CREATE TABLE providers (id TEXT NOT NULL, app_type TEXT NOT NULL, name TEXT NOT NULL, settings_config TEXT NOT NULL, sort_index INTEGER, PRIMARY KEY (id, app_type))"); - writer.prepare("INSERT INTO providers VALUES (?1, ?2, ?3, ?4, ?5)").run( + // writer.run finalizes its statement; an unfinalized prepare() keeps the db, -wal and -shm + // open past close(), and Windows then refuses to delete the temp tree (EBUSY). + writer.run( + "INSERT INTO providers VALUES (?1, ?2, ?3, ?4, ?5)", "existing-prov", "openai", "Existing", @@ -1017,10 +1020,12 @@ describe("CC-Switch keyless provider import", () => { // Disable auto-checkpoint so subsequent UPDATE and INSERT stay exclusively in live-wal.db-wal. writer.run("PRAGMA wal_autocheckpoint = 0;"); - writer.prepare("UPDATE providers SET settings_config = ?1 WHERE id = 'existing-prov'").run( + writer.run( + "UPDATE providers SET settings_config = ?1 WHERE id = 'existing-prov'", JSON.stringify({ baseUrl: "https://updated.acme.example/v1" }), ); - writer.prepare("INSERT INTO providers VALUES (?1, ?2, ?3, ?4, ?5)").run( + writer.run( + "INSERT INTO providers VALUES (?1, ?2, ?3, ?4, ?5)", "uncheckpointed-prov", "claude", "Uncheckpointed", From 6218a9a63ba1ae7807c0eb94bec2a184d89e42f2 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 30 Oct 2026 20:46:37 +0900 Subject: [PATCH 56/84] fix(combo): skip Meta Muse targets for mandatory tool_choice (#7982) * fix(combo): skip Meta for mandatory tool choices * fix(combo): resolve effective wire before Meta eligibility --- docs-site/src/content/docs/guides/combos.md | 4 + .../responses/combo-required-tool-contract.ts | 27 ++++ src/server/responses/core-combo.ts | 30 +++- .../transports/responses-combo-boundaries.md | 7 + ...erver-combo-required-tool-contract.test.ts | 141 ++++++++++++++++++ 5 files changed, 208 insertions(+), 1 deletion(-) diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index 57dc9ccb196..a0b9de7c718 100644 --- a/docs-site/src/content/docs/guides/combos.md +++ b/docs-site/src/content/docs/guides/combos.md @@ -152,6 +152,10 @@ native routing and catalog identity on the next sync. ### Failover: ordered primary and backups +For Responses requests that require a tool, Meta Responses targets are skipped before sending +because Meta does not support mandatory `tool_choice`. An eligible later target receives the +original choice. If every target is incompatible for this reason, the combo reports the constraint. + `failover` selects the first eligible target in configuration order. A target is eligible when its provider exists, is enabled, is not cooling down, and can handle any special request constraint. Weights and `stickyLimit` do not affect this strategy. diff --git a/src/server/responses/combo-required-tool-contract.ts b/src/server/responses/combo-required-tool-contract.ts index 1e23e46873e..2f5e16a22d5 100644 --- a/src/server/responses/combo-required-tool-contract.ts +++ b/src/server/responses/combo-required-tool-contract.ts @@ -10,6 +10,33 @@ export type RequiredToolContract = | { kind: "any"; hostedCallTypes: ReadonlySet } | { kind: "named"; name: string }; +const allowedHostedSelectors = new Set([ + "web_search", "web_search_preview", "file_search", "computer_use_preview", "computer_use", + "code_interpreter", "image_generation", "image_gen", "tool_search", +]); + +/** Only definite, well-formed mandatory selectors justify excluding a destination. */ +export function hasMandatoryToolChoice(rawBody: unknown): boolean { + if (!rawBody || typeof rawBody !== "object" || Array.isArray(rawBody)) return false; + const choice = (rawBody as Record).tool_choice; + if (choice === "required") return true; + if (!choice || typeof choice !== "object" || Array.isArray(choice)) return false; + const selector = choice as Record; + if (selector.type === "function" || selector.type === "custom") { + return typeof selector.name === "string" && selector.name.trim().length > 0; + } + return selector.type === "allowed_tools" && selector.mode === "required" + && Array.isArray(selector.tools) && selector.tools.length > 0 + && selector.tools.every((tool: unknown) => { + if (!tool || typeof tool !== "object" || Array.isArray(tool)) return false; + const entry = tool as Record; + if (entry.name !== undefined && (typeof entry.name !== "string" || !entry.name.trim())) return false; + if (entry.type === "function" || entry.type === "custom") return typeof entry.name === "string"; + if (entry.type === "mcp") return typeof entry.server_label === "string" && !!entry.server_label.trim(); + return typeof entry.type === "string" && allowedHostedSelectors.has(entry.type); + }); +} + const hostedCalls = new Map([ ["web_search", "web_search_call"], ["web_search_preview", "web_search_call"], ["file_search", "file_search_call"], ["code_interpreter", "code_interpreter_call"], diff --git a/src/server/responses/core-combo.ts b/src/server/responses/core-combo.ts index 3b8d0fde10c..f2de582343d 100644 --- a/src/server/responses/core-combo.ts +++ b/src/server/responses/core-combo.ts @@ -1,4 +1,7 @@ import { effectiveAzureOutputBudget } from "../../adapters/azure"; +import { buildResponsesTransport } from "../../adapters/openai-responses/transport"; +import { isMetaAiResponsesDestination } from "../../responses/muse-tool-name-alias"; +import { hasMandatoryToolChoice } from "./combo-required-tool-contract"; import { isCodexAccountCooldownResponse } from "../../codex/account-cooldown-response"; import { resolveWireProtocolOverride } from "../adapter-resolve"; import { comboCooldownRetryAfterSeconds, isDefiniteContextOverflow, type CodexAccountModelRefusal } from "../../combos/failover"; @@ -56,7 +59,7 @@ import { previousResponseProviderState, } from "../../responses/state"; import { hasUnreadableEncryptedAgentTask } from "./encrypted-payload"; -import { routeConcreteModel, comboRouteDecisionTrace } from "../../router"; +import { captureRouteStaticPolicy, routeConcreteModel, comboRouteDecisionTrace } from "../../router"; import { memoryModelRouteReason } from "./memory-models"; import { baseProviderLabel, poolAccountProviderLabel } from "../../providers/label"; import { getAccountSet } from "../../oauth/store"; @@ -533,10 +536,29 @@ export async function executeComboResponses( const payloadEligible = (target: (typeof combo.targets)[number]): boolean => comboPayloadReadable || !unreadableEncryptedAgentTask || canDecryptUnreadableAgentTask(target); const meteredAllowed = admissionMayUseMeteredComboTargets(config, options.admission, combo); + const museToolEligible = (target: (typeof combo.targets)[number]): boolean => { + if (!hasMandatoryToolChoice(body)) return true; + try { + const route = routeConcreteModel(config, `${target.provider}/${target.model}`); + const inboundWire = options.inboundWire ?? "responses"; + const policy = captureRouteStaticPolicy(route.providerName, route.modelId, route.provider, + route.staticPolicy.effectiveAlias, inboundWire); + const provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire, policy); + return provider.adapter !== "openai-responses" + || !isMetaAiResponsesDestination(buildResponsesTransport(provider, { headers: req.headers }).url); + } catch { + return true; + } + }; + const onlyMuseToolIncompatible = (): boolean => { + const enabled = combo.targets.filter(target => config.providers[target.provider]?.disabled !== true); + return enabled.length > 0 && enabled.every(target => !museToolEligible(target)); + }; // Recovery can replace unreadable encrypted input, but cannot relax other admission checks. const recoveryEligible = (target: (typeof combo.targets)[number]): boolean => (meteredAllowed || !target.metered) && (combo.strategy !== "jev" || target.provider !== JEV_PROVIDER_ID) + && museToolEligible(target) && reasoningReplayEligible(target) && (protocolLanes?.pickable(target) ?? true); const targetEligible = (target: (typeof combo.targets)[number]): boolean => @@ -651,6 +673,9 @@ export async function executeComboResponses( config, { parentThreadId: inboundClientThreadId }, ); + if (!options.abortSignal?.aborted && onlyMuseToolIncompatible()) { + return comboUnavailableResponse(`No available targets for combo: ${comboId}; Meta Responses does not support mandatory tool_choice`); + } return options.abortSignal?.aborted ? clientCancelledResponse() : comboUnavailable(comboId, undefined, @@ -668,6 +693,9 @@ export async function executeComboResponses( const protocolRefusal = protocolLanes?.refusal(); if (protocolRefusal) return protocolRefusal; if (onlyReplayIncompatibleTargetsRemain()) return targetIncompatibleResponse(); + if (!options.abortSignal?.aborted && onlyMuseToolIncompatible()) { + return comboUnavailableResponse(`No available targets for combo: ${comboId}; Meta Responses does not support mandatory tool_choice`); + } return options.abortSignal?.aborted ? clientCancelledResponse() : comboUnavailable(comboId, undefined, comboExclusionReasons(config, comboId, targetEligible)); diff --git a/structure/transports/responses-combo-boundaries.md b/structure/transports/responses-combo-boundaries.md index a3a4d8f0e57..1c057789d0a 100644 --- a/structure/transports/responses-combo-boundaries.md +++ b/structure/transports/responses-combo-boundaries.md @@ -20,6 +20,13 @@ and `tests/responses/responses-combo-exhausted-error.test.ts`. ## Buffered required-tool contract +Before selection, `src/server/responses/core-combo.ts` excludes resolved Meta Responses destinations +for well-formed mandatory `tool_choice` selectors, including required allowed-tools lists. Normal and +recovery picks share the check, resolving the inbound wire's static model policy and actual Responses +transport URL; malformed and unknown selectors retain their ordinary error path. The next target +receives the original selector, and an all-Meta exhaustion names the constraint before any send. +Coverage: `tests/server/server-combo-required-tool-contract.test.ts`. + Before combo success bookkeeping, native delivery in `src/server/responses/passthrough-delivery.ts` uses `src/server/responses/combo-required-tool-contract.ts` to check successful native `openai-responses` JSON children with explicit required or named function/custom choices. diff --git a/tests/server/server-combo-required-tool-contract.test.ts b/tests/server/server-combo-required-tool-contract.test.ts index 517aa17de62..f25491239a2 100644 --- a/tests/server/server-combo-required-tool-contract.test.ts +++ b/tests/server/server-combo-required-tool-contract.test.ts @@ -21,6 +21,7 @@ import type { OcxConfig } from "../../src/types"; import { item as hostedImage } from "../fixtures/hosted-image-display"; import { createTestTranslatorBudget } from "../helpers/translator-budget"; import { deliverPassthroughResponse } from "../../src/server/responses/passthrough-delivery"; +import { encryptedInput } from "../helpers/agent-task-recovery"; /** * #7456: a Responses combo child that answers a `tool_choice`-required request with a buffered @@ -164,6 +165,146 @@ function requiredRequest(extra: Record = {}): Request { } describe("combo required-tool contract on buffered children (#7456)", () => { + for (const choice of ["required", { type: "function", name: "shell" }, + { type: "allowed_tools", mode: "required", tools: [{ type: "function", name: "shell" }] }]) { + test(`Meta mandatory tool choice selects the next target before send (#7964): ${JSON.stringify(choice)}`, async () => { + const bodies: unknown[] = []; + let metaSends = 0; + const b = serve(async request => { + bodies.push(await request.json()); + return Response.json(completedWithCall("m2", "shell")); + }); + const response = await handleResponsesWith(comboConfig({ + a: provider("openai-responses", "https://api.meta.ai/v1", "key-a", { fetch: (async () => { metaSends++; throw new Error("unexpected Meta send"); }) as typeof fetch }), + b: provider("openai-responses", baseUrl(b), "key-b"), + }), requiredRequest({ tool_choice: choice })); + expect(response.status).toBe(200); + expect(bodies).toHaveLength(1); + expect(metaSends).toBe(0); + expect(bodies[0]).toMatchObject({ tool_choice: choice }); + }); + } + for (const choice of ["auto", "none"]) { + test(`Meta accepts ${choice} without a hop`, async () => { + let sends = 0; + const meta = provider("openai-responses", "https://api.meta.ai/v1", "key-a", { + fetch: (async () => { sends++; return Response.json(completedNoCall("m1", "meta")); }) as typeof fetch, + }); + const response = await handleResponsesWith(comboConfig({ a: meta }), requiredRequest({ tool_choice: choice })); + expect(response.status).toBe(200); + expect(sends).toBe(1); + }); + } + test("malformed choice remains a local Meta 400", async () => { + let sends = 0; + const meta = provider("openai-responses", "https://api.meta.ai/v1", "key-a", { + fetch: (async () => { sends++; throw new Error("unexpected send"); }) as typeof fetch, + }); + const response = await handleResponsesWith(comboConfig({ a: meta }), requiredRequest({ tool_choice: { type: "function" } })); + expect(response.status).toBe(400); + expect(sends).toBe(0); + }); + test("#7982 chat configuration with Responses override skips Meta", async () => { + const bodies: unknown[] = []; + const next = serve(async request => { bodies.push(await request.json()); return Response.json(completedWithCall("m2", "shell")); }); + const response = await handleResponsesWith(comboConfig({ + a: provider("openai-chat", "https://api.meta.ai/v1", "key-a", { modelAdapters: { m1: "openai-responses" } }), + b: provider("openai-responses", baseUrl(next), "key-b"), + }), requiredRequest({ tool_choice: "required" })); + expect(response.status).toBe(200); + expect(bodies).toHaveLength(1); + expect(bodies[0]).toMatchObject({ tool_choice: "required" }); + }); + test("#7982 Responses configuration with Chat override sends to Meta Chat", async () => { + let sends = 0; + const response = await handleResponsesWith(comboConfig({ + a: provider("openai-responses", "https://api.meta.ai/v1", "key-a", { + modelAdapters: { m1: "openai-chat" }, + fetch: (async () => { sends++; return Response.json({ id: "chat-1", object: "chat.completion", model: "m1", choices: [{ index: 0, finish_reason: "tool_calls", message: { role: "assistant", content: null, tool_calls: [{ id: "call_a", type: "function", function: { name: "shell", arguments: "{}" } }] } }] }); }) as typeof fetch, + }), + }), requiredRequest({ tool_choice: "required" })); + expect(response.status).toBe(200); + await response.text(); + expect(sends).toBe(1); + }); + test("#7982 unknown and malformed hosted selectors do not exclude Meta", async () => { + const { hasMandatoryToolChoice } = await import("../../src/server/responses/combo-required-tool-contract"); + for (const entry of [{ type: "not_a_tool" }, { type: "web_search", name: 5 }, { type: "mcp" }, { type: "mcp", server_label: 5 }]) { + const choice = { type: "allowed_tools", mode: "required", tools: [entry] }; + expect(hasMandatoryToolChoice({ tool_choice: choice })).toBe(false); + const response = await handleResponsesWith(comboConfig({ a: provider("openai-responses", "https://api.meta.ai/v1", "key-a") }), requiredRequest({ tool_choice: choice })); + expect(response.status).toBe(400); + } + for (const entry of [{ type: "web_search" }, { type: "mcp", server_label: "docs" }]) { + expect(hasMandatoryToolChoice({ tool_choice: { type: "allowed_tools", mode: "required", tools: [entry] } })).toBe(true); + } + }); + test("#7982 encrypted recovery selection cannot pick a Meta target", async () => { + const response = await handleResponsesWith(comboConfig({ a: provider("openai-responses", "https://api.meta.ai/v1", "key-a") }), + requiredRequest({ input: encryptedInput(), tool_choice: "required" })); + expect(response.status).toBe(503); + expect(await response.text()).toContain("mandatory tool_choice"); + }); + for (const [extra, expectedUrl] of [ + [{ responsesPath: "/other/responses" }, "https://api.meta.ai/other/responses"], + [{ authMode: "forward" as const, responsesPath: "/ignored" }, "https://api.meta.ai/responses"], + ] as const) { + test(`#7982 resolved Meta URL excludes mandatory choice: ${JSON.stringify(extra)}`, async () => { + const response = await handleResponsesWith(comboConfig({ a: provider("openai-responses", "https://api.meta.ai", "key-a", extra) }), requiredRequest({ tool_choice: "required" })); + expect(response.status).toBe(503); + expect(await response.text()).toContain("mandatory tool_choice"); + let sentUrl = ""; + const auto = await handleResponsesWith(comboConfig({ a: provider("openai-responses", "https://api.meta.ai", "key-a", { + ...extra, fetch: (async input => { sentUrl = String(input); return Response.json(completedNoCall("m1", "meta")); }) as typeof fetch, + }) }), requiredRequest({ tool_choice: "auto" })); + expect(auto.status).toBe(200); + expect(sentUrl).toBe(expectedUrl); + }); + } + test("selector eligibility recognizes custom choices and abstains on malformed allowed lists", async () => { + const { hasMandatoryToolChoice } = await import("../../src/server/responses/combo-required-tool-contract"); + expect(hasMandatoryToolChoice({ tool_choice: { type: "custom", name: "shell" } })).toBe(true); + for (const choice of [ + { type: "function", name: "" }, + { type: "allowed_tools", mode: "required", tools: [] }, + { type: "allowed_tools", mode: "required", tools: [{ type: "function" }] }, + { type: "allowed_tools", mode: "required", tools: [{ type: 5 }] }, + { type: "allowed_tools", mode: "auto", tools: [{ type: "function", name: "shell" }] }, + ]) expect(hasMandatoryToolChoice({ tool_choice: choice })).toBe(false); + }); + test("all Meta targets report the mandatory choice constraint", async () => { + const meta = provider("openai-responses", "https://api.meta.ai/v1", "key-a"); + const response = await handleResponsesWith(comboConfig({ a: meta, b: meta }), requiredRequest({ tool_choice: "required" })); + expect(response.status).toBe(503); + expect(await response.text()).toContain("mandatory tool_choice"); + }); + test("a non-Meta first target remains eligible for mandatory tools", async () => { + let sends = 0; + const upstream = serve(() => { sends++; return Response.json(completedWithCall("m1", "shell")); }); + const response = await handleResponsesWith(comboConfig({ + a: provider("openai-responses", baseUrl(upstream), "key-a"), + b: provider("openai-responses", "https://api.meta.ai/v1", "key-b"), + }), requiredRequest({ tool_choice: "required" })); + expect(response.status).toBe(200); + expect(sends).toBe(1); + }); + test("streaming mandatory choice skips Meta before the first send", async () => { + const bodies: Record[] = []; + const upstream = serve(async request => { + bodies.push(await request.json() as Record); + const payload = completedWithCall("m2", "shell"); + return new Response(`data: ${JSON.stringify({ type: "response.completed", response: payload })}\n\n`, + { headers: { "content-type": "text/event-stream" } }); + }); + const response = await handleResponsesWith(comboConfig({ + a: provider("openai-responses", "https://api.meta.ai/v1", "key-a"), + b: provider("openai-responses", baseUrl(upstream), "key-b"), + }), requiredRequest({ stream: true, tool_choice: "required" })); + expect(response.status).toBe(200); + await response.text(); + expect(bodies).toHaveLength(1); + expect(bodies[0]!.tool_choice).toBe("required"); + }); test("a folded-SSE contract failure settles reported usage without completion effects", async () => { let effects = 0; let settled: unknown; From d42b9b1b231a6a5b1dadde9262dfb63736823488 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 30 Oct 2026 20:46:45 +0900 Subject: [PATCH 57/84] fix(sidecars): accept CR-delimited Responses events (#7989) Carries #7962. Co-authored-by: oocheol <86232509+oocheol@users.noreply.github.com> --- docs-site/src/content/docs/guides/sidecars.md | 2 +- .../src/content/docs/ko/guides/sidecars.md | 2 +- src/web-search/parse.ts | 3 +- structure/data-planes/search.md | 2 +- tests/web-search/web-search-eof.test.ts | 58 +++++++++++++++++++ 5 files changed, 63 insertions(+), 4 deletions(-) diff --git a/docs-site/src/content/docs/guides/sidecars.md b/docs-site/src/content/docs/guides/sidecars.md index 28b7955a74f..ed7f778add1 100644 --- a/docs-site/src/content/docs/guides/sidecars.md +++ b/docs-site/src/content/docs/guides/sidecars.md @@ -286,7 +286,7 @@ See the [Configuration reference](/reference/configuration/server/#sidecars) for Search responses with malformed optional fields retain valid answer text and citations. Invalid error-message values use a generic failure message. -The OpenAI search and vision sidecars read a final completion or failure event even when the server closes the body without a trailing newline. An unfinished response or one stopped by a size limit still reports an error. +The OpenAI search and vision sidecars accept CR, LF and CRLF line endings, including mixed endings and CRLF split across network chunks. They read a final completion or failure event even when the server closes the body without a trailing newline. An unfinished response or one stopped by a size limit still reports an error. When a search answer ends with a Sources list, an unmatched list item stays in the answer instead of being removed as a citation. A title followed by its URL is still consumed as one citation. Non-HTTP URI-like text inside inline code, such as `mode:fast`, stays in the answer; code-formatted HTTP(S) URLs are still citations. diff --git a/docs-site/src/content/docs/ko/guides/sidecars.md b/docs-site/src/content/docs/ko/guides/sidecars.md index 93701719528..1ad25a749b3 100644 --- a/docs-site/src/content/docs/ko/guides/sidecars.md +++ b/docs-site/src/content/docs/ko/guides/sidecars.md @@ -161,7 +161,7 @@ fingerprint 선례를 따르지만, 실제 계정과 작업량으로 충분히 s 검색 응답의 선택 필드 형식이 잘못되어도 유효한 답변과 출처는 유지합니다. 오류 메시지 값의 형식이 잘못되면 일반 실패 메시지를 표시합니다. -OpenAI 검색·비전 사이드카는 서버가 마지막 줄바꿈 없이 응답 본문을 닫아도 마지막 완료 또는 실패 이벤트를 읽습니다. 완료되지 않았거나 크기 제한으로 중단된 응답은 계속 오류로 처리합니다. +OpenAI 검색·비전 사이드카는 CR, LF, CRLF 줄바꿈과 이들이 섞인 응답을 처리하며, CRLF가 여러 네트워크 청크로 나뉘어도 이벤트를 읽습니다. 서버가 마지막 줄바꿈 없이 응답 본문을 닫아도 마지막 완료 또는 실패 이벤트를 읽습니다. 완료되지 않았거나 크기 제한으로 중단된 응답은 계속 오류로 처리합니다. 검색 답변의 Sources 목록에서 URL과 짝을 이루지 못한 글머리표는 출처로 삭제하지 않고 답변에 남깁니다. 제목 다음 줄에 해당 URL이 있으면 하나의 출처로 처리합니다. `mode:fast`처럼 인라인 코드 안의 비 HTTP URI 형태 텍스트는 답변에 남기며, 코드로 표시한 HTTP(S) URL은 출처로 처리합니다. diff --git a/src/web-search/parse.ts b/src/web-search/parse.ts index 699babde617..1420385b2a8 100644 --- a/src/web-search/parse.ts +++ b/src/web-search/parse.ts @@ -466,7 +466,8 @@ export async function parseSidecarSSE(response: Response): Promise { await expect(parseSidecarSSE(new Response(body))).rejects.toThrow("transport stopped"); }); }); + +describe("Responses sidecar SSE line endings", () => { + for (const [name, endings] of [ + ["LF", ["\n"]], + ["CRLF", ["\r\n"]], + ["CR", ["\r"]], + ["mixed", ["\r", "\n", "\r\n"]], + ] as const) { + test.each([["one chunk", false], ["single-byte chunks", true]] as const)(`${name} preserves deltas and citations (%s)`, async (_chunking, fragmented) => { + const lines = [ + ": heartbeat", + "event: response.output_text.delta", + 'data:{"type":"response.output_text.delta","delta":"검색 "}', + "", + 'data: {"type":"response.output_text.delta","delta":"😀"}', + "", + `data: ${JSON.stringify({ type: "response.output_text.annotation.added", annotation: { + type: "url_citation", url: "https://example.com/result", title: "Source", + } })}`, + "", + "data: [DONE]", + ]; + const wire = lines.map((line, index) => line + endings[index % endings.length]).join(""); + expect(await parseSidecarSSE(eofResponse(wire, fragmented))).toEqual({ + text: "검색 😀", sources: [{ url: "https://example.com/result", title: "Source" }], + }); + }); + } + + test("preserves a CR-delimited terminal failure and earlier partial text", async () => { + const wire = prefix.replaceAll("\n", "\r") + + 'data: {"type":"response.failed","error":{"message":"search stopped"}}\r\r'; + expect(await parseSidecarSSE(eofResponse(wire))).toEqual({ + text: "draft", sources: [], error: "search stopped", + }); + }); + + test("CR-delimited text without a terminal event remains incomplete", async () => { + expect(await parseSidecarSSE(eofResponse(prefix.replaceAll("\n", "\r")))).toEqual({ + text: "draft", sources: [], error: "sidecar stream ended before terminal event", + }); + }); + + test("a CR-delimited terminal event cancels without waiting for EOF", async () => { + let cancelled = false; + let sent = false; + const body = new ReadableStream({ + pull(controller) { + if (sent) throw new Error("read past terminal event"); + sent = true; + controller.enqueue(new TextEncoder().encode('data: {"type":"response.completed"}\r\r')); + }, + cancel() { cancelled = true; }, + }, { highWaterMark: 0 }); + expect(await parseSidecarSSE(new Response(body))).toEqual({ text: "", sources: [] }); + expect(cancelled).toBe(true); + }); +}); From d389699c96db753931a5f785f7a6a78c3ff94a1e Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 30 Oct 2026 20:59:19 +0900 Subject: [PATCH 58/84] fix(azure): omit unsupported Responses encrypted output include (#7988) Carries #7953. Closes #7952. Co-authored-by: x3M3x <98298256+x3M3x@users.noreply.github.com> --- .../src/content/docs/reference/adapters.md | 5 ++ src/adapters/openai-responses/passthrough.ts | 7 ++- .../openai-responses/request-strips.ts | 2 +- structure/transports/responses-wire-shapes.md | 2 +- tests/providers/azure-adapter.test.ts | 54 +++++++++++++++++++ 5 files changed, 66 insertions(+), 4 deletions(-) diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 0545fbbd8e6..8d6ee982c97 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -823,6 +823,11 @@ appended to the id instead. **Targets:** **Azure OpenAI**. Wraps `openai-responses` (so also `passthrough: true`). **Auth:** `key` via the `api-key` header (not Bearer). +Azure requests omit the unsupported output.encrypted_content entry from include, +while retaining reasoning.encrypted_content and all other entries. This also applies +to openai-responses configurations targeting a resource under .openai.azure.com. +When no entries remain, include is omitted. + - Delegates request building to the Responses passthrough, validates that `baseUrl` contains no unresolved template placeholder, and replaces `Authorization` with `api-key`. The configured URL targets Azure's v1 Responses API directly, so the adapter does not append `api-version`. diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index 9cb4657c64f..b76549f571e 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -343,9 +343,12 @@ 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 and Meta answer 400 for `output.encrypted_content` instead of ignoring it; + // xAI, Meta and Azure answer 400 for `output.encrypted_content` instead of ignoring it; // Codex Desktop asks for it on every turn (#7878). - if (isXaiResponsesDestination(provider) || isMetaAiResponsesDestination(url)) { + // 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) { 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 82bf33520c3..366d1ec34bc 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 and Meta reject with HTTP 400. + * Codex requests output.encrypted_content, which xAI, Meta and Azure 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/structure/transports/responses-wire-shapes.md b/structure/transports/responses-wire-shapes.md index a6150ee190f..a8b49f9dde0 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; 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; 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/providers/azure-adapter.test.ts b/tests/providers/azure-adapter.test.ts index 8414870a102..ee76dd27b8a 100644 --- a/tests/providers/azure-adapter.test.ts +++ b/tests/providers/azure-adapter.test.ts @@ -214,3 +214,57 @@ describe("Azure OpenAI adapter hardening", () => { } }); }); + +describe("Azure Responses include compatibility", () => { + test("removes the rejected include through the Azure adapter without mutating the caller", async () => { + const raw = { + model: "gpt-5.5", input: [], stream: true, + include: ["reasoning.encrypted_content", "output.encrypted_content", "message.output_text.logprobs"], + }; + const original = structuredClone(raw); + const request = await createAzureAdapter(provider()).buildRequest({ ...parsed, _rawBody: raw }); + expect(JSON.parse(request.body).include).toEqual([ + "reasoning.encrypted_content", "message.output_text.logprobs", + ]); + expect(raw).toEqual(original); + }); + + test("removes an include containing only the rejected entry", async () => { + const request = await createAzureAdapter(provider()).buildRequest({ + ...parsed, _rawBody: { ...parsed._rawBody, include: ["output.encrypted_content"] }, + }); + expect(JSON.parse(request.body)).not.toHaveProperty("include"); + }); + + test("normalizes Azure resource URLs configured with the plain Responses adapter", async () => { + const { createResponsesPassthroughAdapter } = await import("../../src/adapters/openai-responses"); + const adapter = withTestTranslatorBudget(createResponsesPassthroughAdapter({ + adapter: "openai-responses", + baseUrl: "https://example.openai.azure.com/openai/v1", + authMode: "key", + apiKey: "azure-test-key", + })); + const request = await adapter.buildRequest({ + ...parsed, + _rawBody: { ...parsed._rawBody, include: ["reasoning.encrypted_content", "output.encrypted_content"] }, + }, { headers: new Headers() }); + expect(JSON.parse(request.body).include).toEqual(["reasoning.encrypted_content"]); + }); + + test("preserves includes on unrelated and lookalike destinations", async () => { + const { createResponsesPassthroughAdapter } = await import("../../src/adapters/openai-responses"); + for (const baseUrl of [ + "https://api.openai.com/v1", + "https://gateway.example.test/v1", + "https://example.openai.azure.com.example.test/openai/v1", + ]) { + const adapter = withTestTranslatorBudget(createResponsesPassthroughAdapter({ + adapter: "openai-responses", baseUrl, authMode: "key", apiKey: "test-key", + })); + const request = await adapter.buildRequest({ + ...parsed, _rawBody: { ...parsed._rawBody, include: ["output.encrypted_content"] }, + }, { headers: new Headers() }); + expect(JSON.parse(request.body).include).toEqual(["output.encrypted_content"]); + } + }); +}); From a2ae4825350813b41d92abf2231924ed8950ef63 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 30 Oct 2026 12:01:31 +0000 Subject: [PATCH 59/84] fix(codex): validate CLI and desktop launcher execution boundaries (#7876) * fix(codex): validate CLI and desktop launcher execution boundaries * docs(cli): describe internal command validation contract * fix(chatgpt): retain discovered bundle root at every launch * fix(cli): validate renamed app bundles before Codex launch Check the canonical enclosing app bundle for every ocx codex handoff, including PATH aliases and recorded shim backings. Verify nested layouts against their outer root while preserving ordinary non-bundle launches and the Desktop launcher's strict recorded-root path. Add portable CLI revocation, renamed-bundle and alias regressions and document the launch policy in the shared contract and CLI references. Co-authored-by: Epinephrine * fix(codex): retain lexical app bundle containment * test(codex): cover nested bundle aliases and canonical handoff * docs(cli): clarify internal helper argument checks * fix(codex): keep the install hint when the selected CLI disappears Pre-spawn verification now resolves the executable, so a target that vanished after selection surfaced as a raw ENOENT instead of the install hint the spawn path prints. --------- Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> --- .../content/docs/guides/chatgpt-desktop.md | 14 +- .../content/docs/ko/reference/cli/agents.md | 2 + .../src/content/docs/reference/cli/agents.md | 2 + .../content/docs/tr/guides/chatgpt-desktop.md | 15 +- scripts/test-layout/layout-clients.json | 1 + scripts/test-layout/layout-exec-boundary.json | 4 + .../app-server-shim/bundle-trust-policy.ts | 5 + src/chatgpt/app-server-shim/launcher.ts | 46 ++++- src/cli/chatgpt-command.ts | 2 +- src/cli/codex-launcher.ts | 28 ++- src/cli/codex-shim-autorestore.ts | 2 + src/cli/internal-command.ts | 31 +++- src/codex/exec-boundary.ts | 31 +++- structure/cli-management.md | 2 +- structure/clients/chatgpt-desktop.md | 45 ++++- .../desktop-app-server-exec-boundary.test.ts | 76 ++++++++ .../desktop-app-server-shim-launcher.test.ts | 21 ++- .../exec-cli-bundle-containment.test.ts | 148 ++++++++++++++++ .../exec-cli-renamed-app-bundle.test.ts | 76 ++++++++ .../exec-launcher-boundary.test.ts | 143 +++++++++++++++ .../exec-renamed-app-bundle.test.ts | 164 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 5 + 22 files changed, 812 insertions(+), 51 deletions(-) create mode 100644 tests/clients/desktop-app-server-exec-boundary.test.ts create mode 100644 tests/codex-integration/exec-cli-bundle-containment.test.ts create mode 100644 tests/codex-integration/exec-cli-renamed-app-bundle.test.ts create mode 100644 tests/codex-integration/exec-launcher-boundary.test.ts create mode 100644 tests/codex-integration/exec-renamed-app-bundle.test.ts diff --git a/docs-site/src/content/docs/guides/chatgpt-desktop.md b/docs-site/src/content/docs/guides/chatgpt-desktop.md index 458acdfbedf..8f195bfa829 100644 --- a/docs-site/src/content/docs/guides/chatgpt-desktop.md +++ b/docs-site/src/content/docs/guides/chatgpt-desktop.md @@ -104,10 +104,11 @@ restart transaction. ## Failure behavior and known limits -When the platform is not macOS, the OpenCodex runtime is missing, or the filter -self-test fails, the launcher runs the original binary with untouched stdout. A -missing bundled app-server binary is the exception: there is nothing to fall back -to, so the launcher exits with an error (see below). +The launcher verifies the executable before every start, including Desktop respawns. +If stdout filtering is unavailable or its self-test fails, the verified executable +runs with untouched stdout. If executable verification fails or the OpenCodex +validator is unavailable, the launcher exits with an error without starting the +app-server. A missing bundled app-server binary also prevents startup (see below). A filter that passes the self-test and then dies mid-session closes the pipe. What the bundled app-server does after that has not been verified; it may get SIGPIPE or a write error and be respawned by Desktop through the same launcher. @@ -117,8 +118,9 @@ remaining stream to raw bytes. The experiment depends on the bundled binary path, the app honoring `CODEX_CLI_PATH`, and current RPC field shapes. Updates may change these. A moved -or removed OpenCodex installation fails the launcher preflight and runs the -original binary. Run `ocx chatgpt launch` again after relocating the installation. +or removed OpenCodex installation can leave the launcher unable to verify the +executable, so it refuses startup. Run `ocx chatgpt launch` again after relocating +the installation, or `ocx chatgpt restore` to remove the override. If an app update moves or removes the bundled app-server binary itself, the launcher cannot start it: it prints a message naming `ocx chatgpt launch` and `ocx chatgpt restore` on stderr and exits, and Desktop cannot start its diff --git a/docs-site/src/content/docs/ko/reference/cli/agents.md b/docs-site/src/content/docs/ko/reference/cli/agents.md index e6186944fb4..102cff039a0 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -193,6 +193,8 @@ family는 `opus`, `fable`, `sonnet`, `haiku`이며, 새 route는 `opus`에서 일치가 확인된(`matched`) autostart wrapper만 backing Codex를 직접 실행합니다. 검사 예외를 포함한 알 수 없는 상태는 검증된 bypass가 필요하며, 관리되는 Windows CMD wrapper는 현재 생성 템플릿 전체와 일치해야 `OCX_SHIM_BYPASS=1`로 실행합니다. 일반 npm 런처는 지원합니다. 검증할 수 없거나 오래된 wrapper는 종료 코드 1로 거부하며 Codex를 시작하지 않습니다. `ocx codex-shim install`로 갱신하세요. 이 실행은 shim을 수리하지 않으며, 내용 검사는 Windows backing 파일 출처나 동시 경로 교체를 보장하지 않습니다. +macOS에서는 도움말과 버전을 포함한 매 실행 시, 선택한 실행 파일의 경로나 정규 경로가 `.app` 번들 내부에 있으면 검증합니다. 이름을 바꾼 번들과 번들 내부를 가리키는 심볼릭 링크도 번들 및 실행 파일의 안전한 소유권, 권한, OpenAI 서명을 요구합니다. 선택한 번들 구성원은 실행 파일, 중간 디렉터리 또는 번들 전체의 심볼릭 링크를 거쳐도 해당 번들의 정규 `Contents/` 내부에 있어야 합니다. 검증 실패 시 Codex를 시작하지 않고 종료 코드 1을 반환합니다. npm 설치를 포함한 일반 번들 외부 실행 파일의 동작은 유지됩니다. + 런처 도움말은 `ocx help codex`, Codex 도움말은 `ocx codex --help`입니다. 이후 plain `codex`의 동작은 기존과 같습니다. ### `ocx opencode [opencode args...]` diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index fa0798d1e3d..bce042afced 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -994,6 +994,8 @@ A missing `OPENCODEX_API_AUTH_TOKEN` is read from this installation's `service-a Only a matched autostart wrapper launches its backing Codex directly. Unknown state, including an inspection exception, requires a verified bypass; Windows CMD managed wrappers must match the complete current generated template before the selected absolute command runs with `OCX_SHIM_BYPASS=1`. Ordinary npm launchers remain supported. Unverifiable or obsolete wrappers refuse with exit 1 and start no Codex; run `ocx codex-shim install` to refresh them. This launch does not repair shims. The content check does not establish Windows backing-file provenance or prevent concurrent pathname replacement. +On macOS, every launch, including help/version, verifies the selected executable when its selected path or canonical location is inside any `.app` bundle. Renamed bundles and symlinks into bundles require the same safe ownership, permissions and OpenAI signatures for the bundle and executable. A selected bundle member must stay inside that bundle's canonical `Contents/`, including through executable, directory or whole-bundle symlinks. Failed verification exits 1 without starting Codex. Ordinary non-bundle executables, including npm installations, retain their behavior. + Use `ocx help codex` for launcher help and `ocx codex --help` for Codex help. Launching plain `codex` later retains its existing behavior. ### `ocx opencode [opencode args...]` diff --git a/docs-site/src/content/docs/tr/guides/chatgpt-desktop.md b/docs-site/src/content/docs/tr/guides/chatgpt-desktop.md index 847b14d27f0..b3dd5372fc3 100644 --- a/docs-site/src/content/docs/tr/guides/chatgpt-desktop.md +++ b/docs-site/src/content/docs/tr/guides/chatgpt-desktop.md @@ -101,10 +101,12 @@ etkisiz bir yeniden başlatma işlemi garanti etmez. ## Hata davranışı ve bilinen sınırlar -Platform macOS değilse, OpenCodex çalışma zamanı eksikse veya filtre öz sınaması -başarısızsa başlatıcı özgün dosyayı stdout'u değiştirmeden çalıştırır. Paketlenmiş -app-server dosyasının eksikliği istisnadır: geri dönülecek dosya olmadığı için -başlatıcı hatayla çıkar. +Başlatıcı, Desktop'ın yeniden başlattığı işlemler dahil her çalıştırmadan önce +yürütülebilir dosyayı doğrular. Stdout filtresi kullanılamıyorsa veya öz sınaması +başarısızsa doğrulanmış dosya stdout değiştirilmeden çalışır. Dosya doğrulaması +başarısızsa ya da OpenCodex doğrulayıcısı kullanılamıyorsa başlatıcı app-server'ı +çalıştırmadan hatayla çıkar. Paketlenmiş app-server dosyasının eksikliği de +başlatmayı engeller. Öz sınamayı geçen filtre oturum ortasında çökerse boru kapanır. Paketlenmiş app-server'ın bundan sonra ne yaptığı doğrulanmamıştır; SIGPIPE/yazma hatası @@ -115,8 +117,9 @@ ham baytlara geçirir. Deney; paketlenmiş dosya yoluna, uygulamanın `CODEX_CLI_PATH` değerini kullanmasına ve güncel RPC alan biçimlerine bağlıdır. Güncellemeler bunları değiştirebilir. -OpenCodex taşınır veya kaldırılırsa başlatıcının ön kontrolü başarısız olur ve -özgün dosya çalışır. Kurulumu taşıdıktan sonra `ocx chatgpt launch` komutunu yeniden çalıştırın. +OpenCodex taşınır veya kaldırılırsa başlatıcı dosyayı doğrulayamayabilir ve +başlatmayı reddeder. Kurulumu taşıdıktan sonra `ocx chatgpt launch` komutunu yeniden +çalıştırın veya geçersiz kılmayı kaldırmak için `ocx chatgpt restore` kullanın. Uygulama güncellemesi paketlenmiş app-server dosyasını taşır veya kaldırırsa başlatıcı onu çalıştıramaz: stderr üzerinde `ocx chatgpt launch` ve diff --git a/scripts/test-layout/layout-clients.json b/scripts/test-layout/layout-clients.json index fa50b9af986..01a3abb4020 100644 --- a/scripts/test-layout/layout-clients.json +++ b/scripts/test-layout/layout-clients.json @@ -32,6 +32,7 @@ "droid-normalized-reasoning-defaults.test.ts": "clients", "desktop-app-server-shim.test.ts": "clients", "desktop-app-server-shim-launcher.test.ts": "clients", + "desktop-app-server-exec-boundary.test.ts": "clients", "desktop-bundle-trust-async.test.ts": "clients", "desktop-chatgpt-config.test.ts": "clients", "desktop-supervised-restart.test.ts": "clients", diff --git a/scripts/test-layout/layout-exec-boundary.json b/scripts/test-layout/layout-exec-boundary.json index e64ba5579f4..7ca28c34045 100644 --- a/scripts/test-layout/layout-exec-boundary.json +++ b/scripts/test-layout/layout-exec-boundary.json @@ -1,5 +1,9 @@ { "exec-trust-boundary.test.ts": "codex-integration", "exec-boundary-launch.test.ts": "codex-integration", + "exec-launcher-boundary.test.ts": "codex-integration", + "exec-cli-renamed-app-bundle.test.ts": "codex-integration", + "exec-cli-bundle-containment.test.ts": "codex-integration", + "exec-renamed-app-bundle.test.ts": "codex-integration", "exec-known-app-bundles.test.ts": "codex-integration" } diff --git a/src/chatgpt/app-server-shim/bundle-trust-policy.ts b/src/chatgpt/app-server-shim/bundle-trust-policy.ts index 4bb2c1b93a6..b105b1b28b1 100644 --- a/src/chatgpt/app-server-shim/bundle-trust-policy.ts +++ b/src/chatgpt/app-server-shim/bundle-trust-policy.ts @@ -28,6 +28,11 @@ export function bundleTrustStat(path: string): ReturnType; +/** The inline bundle root is code: protect its file and directory entry like the bundle. */ +export function untrustedLauncherFilesystemReason(path: string, deps: FilesystemTrustDeps, existing = true): string | null { + return (existing ? ownedSafely(deps, path, "file") : null) ?? untrustedAncestorReason(path, deps); +} + function ownedSafely(deps: FilesystemTrustDeps, path: string, kind: "file" | "directory"): string | null { const s = deps.stat(path); if (!s) return "is missing"; diff --git a/src/chatgpt/app-server-shim/launcher.ts b/src/chatgpt/app-server-shim/launcher.ts index 53142ddafbf..6cddfd97c0f 100644 --- a/src/chatgpt/app-server-shim/launcher.ts +++ b/src/chatgpt/app-server-shim/launcher.ts @@ -3,6 +3,8 @@ import { randomBytes } from "node:crypto"; import { join, posix } from "node:path"; import { getConfigDir } from "../../config/paths"; import { selfLaunchArgv } from "../../lib/self-launch-argv"; +import { defaultBundleTrustDeps } from "./bundle-trust"; +import { untrustedLauncherFilesystemReason, type BundleTrustDeps } from "./bundle-trust-policy"; export const CHATGPT_APP_CODEX_BINARY = "/Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex"; @@ -47,24 +49,41 @@ function shellQuote(value: string): string { } /** - * A failed precondition or a failed self-test runs the original binary with untouched stdout. + * A failed filter self-test runs the verified binary with untouched stdout. + * Executable verification is required on every invocation, including passthrough and respawns. * A filter that passes the self-test and then dies mid-session closes the pipe. * Expected (not yet validated against the bundled app-server): the server gets SIGPIPE or a * write error and Desktop respawns it through the same launcher. */ -export function buildChatgptShimLauncher(argv: readonly string[], real = CHATGPT_APP_CODEX_BINARY): string { +export function buildChatgptShimLauncher( + argv: readonly string[], + real = CHATGPT_APP_CODEX_BINARY, + validator: readonly string[] = [process.execPath, ...selfLaunchArgv(["internal", "codex-exec-target"])], + bundleRoot = /^(.*?\.app)\/Contents\//i.exec(real)?.[1], +): string { return `#!/bin/bash # opencodex (experimental): ChatGPT app-server stdout passes through the quota-gate filter. REAL=${shellQuote(real)} -FILTER=(${argv.map(shellQuote).join(" ")}) +${bundleRoot === undefined ? "" : `BUNDLE_ROOT=${shellQuote(bundleRoot)}\n`}FILTER=(${argv.map(shellQuote).join(" ")}) +VALIDATE=(${validator.map(shellQuote).join(" ")}) if [ ! -x "$REAL" ]; then echo "opencodex (experimental): the bundled app-server is no longer at $REAL; run 'ocx chatgpt launch' again or 'ocx chatgpt restore'." >&2 exit 127 fi +validate_real() { + local target + if [ ! -x "\${VALIDATE[0]}" ] || ! target=$("\${VALIDATE[@]}" ${bundleRoot === undefined ? "" : '--bundle-root "$BUNDLE_ROOT" '}-- "$REAL") || [ -z "$target" ]; then + echo "opencodex: app-server execution verification failed; run 'ocx chatgpt launch' again or 'ocx chatgpt restore'." >&2 + return 126 + fi + REAL="$target" +} if [ "$(uname -s)" = "Darwin" ] && [ -x "\${FILTER[0]}" ] \\ && "\${FILTER[@]}" --self-test >/dev/null 2>&1; then + validate_real || exit 126 exec "$REAL" "$@" > >(exec "\${FILTER[@]}") fi +validate_real || exit 126 exec "$REAL" "$@" `; } @@ -74,17 +93,32 @@ exec "$REAL" "$@" * launcher, so an existing symbolic link is replaced rather than followed and a respawn during * the write never runs a half-written script. */ -export function writeChatgptShimLauncher(configDir = getConfigDir(), real = CHATGPT_APP_CODEX_BINARY): string { +export function writeChatgptShimLauncher( + configDir = getConfigDir(), real = CHATGPT_APP_CODEX_BINARY, + bundleRoot = /^(.*?\.app)\/Contents\//i.exec(real)?.[1], + filesystem: Pick | undefined = process.platform === "darwin" ? defaultBundleTrustDeps() : undefined, +): string { + if (!bundleRoot || !posix.isAbsolute(bundleRoot) || !real.startsWith(bundleRoot + "/Contents/")) { + throw new Error("the app-server launcher requires its discovered bundle root"); + } mkdirSync(configDir, { recursive: true }); const path = chatgptShimLauncherPath(configDir); const argv = [process.execPath, ...selfLaunchArgv(["internal", "chatgpt-app-server-filter"])]; const temp = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`; + let created = false; + const assertFilesystem = (file: string, existing: boolean) => { + const reason = filesystem && untrustedLauncherFilesystemReason(file, filesystem, existing); + if (reason) throw new Error(`unsafe app-server launcher: ${reason}`); + }; try { - writeFileSync(temp, buildChatgptShimLauncher(argv, real), { mode: 0o755, flag: "wx" }); + assertFilesystem(temp, false); + writeFileSync(temp, buildChatgptShimLauncher(argv, real, undefined, bundleRoot), { mode: 0o755, flag: "wx" }); + created = true; chmodSync(temp, 0o755); + assertFilesystem(temp, true); renameSync(temp, path); } catch (error) { - try { unlinkSync(temp); } catch { /* the temp file was never created */ } + if (created) { try { unlinkSync(temp); } catch { /* preserve the publication error */ } } throw error; } return path; diff --git a/src/cli/chatgpt-command.ts b/src/cli/chatgpt-command.ts index 3006f44638c..0436b3033b1 100644 --- a/src/cli/chatgpt-command.ts +++ b/src/cli/chatgpt-command.ts @@ -164,7 +164,7 @@ CODEX_CLI_PATH launcher: ${app.shim === null ? "unknown" : app.shim ? "yes" : "n console.error("ChatGPT process inspection failed; no launcher was changed and no app was opened. Retry after resolving inspection."); return 1; } - if (binary) writeChatgptShimLauncher(undefined, binary); + if (binary) writeChatgptShimLauncher(undefined, binary, install.root); if (!(await quitApp(install, launcher))) { console.error("ChatGPT exit could not be confirmed; no new app was opened. Check the running app and retry."); return 1; diff --git a/src/cli/codex-launcher.ts b/src/cli/codex-launcher.ts index 98fc9f103ea..d8fce9489db 100644 --- a/src/cli/codex-launcher.ts +++ b/src/cli/codex-launcher.ts @@ -25,13 +25,13 @@ import { spawn } from "node:child_process"; import { loadConfig } from "../config"; import { getCodexRoutingKind, type CodexRoutingKind } from "../codex/inject"; import { inspectCodexShimBackingForCommand } from "../codex/shim"; +import { validatedCodexExecInvocationAsync, type CodexExecBoundaryAsyncDeps } from "../codex/exec-boundary"; import { CODEX_GLOBAL_OPTIONS_WITH_VALUE, CODEX_INTERNAL_COMMANDS, CODEX_SHIM_ENSURE_FAILED_DIAGNOSTIC } from "../codex/shim-templates"; import { withoutSiblingMarker } from "../codex/sibling-start"; import { withProcessRuntimeProvenance } from "../lib/bun-runtime"; import { tokenCollidesWithAdmin } from "../lib/admin-secrets"; import { selfLaunchArgv } from "../lib/self-launch-argv"; import { loadServiceTokenFromFile, serviceApiTokenFilePath } from "../lib/service-secrets"; -import { commandInvocation } from "../lib/win-exec"; import { findExecutableOnPath } from "../remote-control/workspace-executable"; import { findLiveProxy, type LiveProxy } from "../server/proxy-liveness"; import { codexCredentialDestinationIsTrusted, resolveCodexLaunchHome } from "./codex-launch-auth"; @@ -265,14 +265,26 @@ export function resolveCodexLaunchCommand( } } -/** Launch the selected Codex command with inherited IO and preserve its exit status. */ -function spawnCodex(args: readonly string[], env: CodexLaunchEnv): Promise { +export interface CodexSpawnDeps extends CodexLaunchCommandDeps { + execBoundary?: CodexExecBoundaryAsyncDeps; + spawn?: typeof spawn; +} + +/** Validate the final backing command immediately before each launch, including fast paths. */ +export async function spawnCodex(args: readonly string[], env: CodexLaunchEnv, deps: CodexSpawnDeps = {}): Promise { + let launch: CodexLaunchCommand, inv: Awaited>; + try { + launch = resolveCodexLaunchCommand(env, deps); + inv = await validatedCodexExecInvocationAsync(launch.command, args, { ...deps.execBoundary, verifyAnyAppBundle: true, env: launch.env as NodeJS.ProcessEnv }); + } catch (error) { + // A selected executable that vanished before verification is the same missing CLI + // the spawn-time ENOENT branch below reports, not a trust refusal. + const missing = error instanceof CodexNotFoundError || (error as NodeJS.ErrnoException | null)?.code === "ENOENT"; + console.error(missing ? CODEX_INSTALL_HINT : `❌ Cannot launch codex: ${error instanceof Error ? error.message : "executable verification failed"}`); + return 1; + } return new Promise(resolve => { - let launch: CodexLaunchCommand; - try { launch = resolveCodexLaunchCommand(env); } - catch (error) { console.error(error instanceof CodexNotFoundError ? CODEX_INSTALL_HINT : CODEX_UNVERIFIED_SHIM); resolve(1); return; } - const inv = commandInvocation(launch.command, args); - const child = spawn(inv.file, inv.args, { stdio: "inherit", env: launch.env as NodeJS.ProcessEnv, ...inv.options }); + const child = (deps.spawn ?? spawn)(inv.file, inv.args, { stdio: "inherit", env: launch.env as NodeJS.ProcessEnv, ...inv.options }); child.on("error", (err: NodeJS.ErrnoException) => { if (err.code === "ENOENT") { console.error(CODEX_INSTALL_HINT); diff --git a/src/cli/codex-shim-autorestore.ts b/src/cli/codex-shim-autorestore.ts index 86750714f3b..626bff50da9 100644 --- a/src/cli/codex-shim-autorestore.ts +++ b/src/cli/codex-shim-autorestore.ts @@ -21,6 +21,8 @@ export function skipsCodexShimAutoRestore(command: string | undefined, args: str if (command === "status" || command === "doctor") return true; // The launcher owns shim handoff after classifying routing, including native fast paths. if (command === "codex") return true; + // Exec admission only observes the selected target; it must never repair a shim first. + if (command === "internal" && args[1] === "codex-exec-target") return true; // `lab` is read-only inspection; it must not trigger shim side effects. if (command === "lab") return true; // Local messaging owns its helpers; even malformed usage must not repair/start anything. diff --git a/src/cli/internal-command.ts b/src/cli/internal-command.ts index 7799ceb52b2..d9d7c2ae9c7 100644 --- a/src/cli/internal-command.ts +++ b/src/cli/internal-command.ts @@ -16,15 +16,42 @@ * that same user, and a same-uid process could call `kill` directly. A token here would * protect nothing and would imply a boundary that does not exist. */ -const USAGE = "Usage: ocx internal desktop-restart-handoff --plan | chatgpt-app-server-filter [--self-test]"; +import type { CodexExecBoundaryAsyncDeps } from "../codex/exec-boundary"; + +const USAGE = "Usage: ocx internal desktop-restart-handoff --plan | chatgpt-app-server-filter [--self-test] | codex-exec-target [--bundle-root ] -- "; function optionValue(args: readonly string[], name: string): string | undefined { const index = args.indexOf(name); return index >= 0 ? args[index + 1] : undefined; } -export async function handleInternalCommand(args: readonly string[]): Promise { +/** + * Dispatch internal helpers with command-specific argument checks and exit codes. + * The executable-target helper writes one trusted path to stdout only after validation. + */ +export async function handleInternalCommand(args: readonly string[], execBoundary: CodexExecBoundaryAsyncDeps = {}): Promise { const sub = args[0]; + if (sub === "codex-exec-target") { + const bundled = args[1] === "--bundle-root"; + const root = bundled ? args[2] : undefined; + const separator = bundled ? 3 : 1; + const command = args[separator + 1]; + if (args.length !== separator + 2 || args[separator] !== "--" || !command || /[\r\n\0]/.test(command) + || (bundled && (!root || !root.startsWith("/") || /[\r\n\0]/.test(root)))) { + console.error(USAGE); + return 2; + } + try { + const { assertCodexExecTargetTrustedAsync } = await import("../codex/exec-boundary"); + const target = await assertCodexExecTargetTrustedAsync(command, bundled ? { ...execBoundary, expectedBundleRoot: root } : execBoundary); + if (!target || /[\r\n]/.test(target)) throw new Error("invalid executable path"); + console.log(target); + return 0; + } catch (error) { + console.error(`OpenCodex refused the app-server executable: ${error instanceof Error ? error.message : "verification failed"}`); + return 1; + } + } if (sub === "chatgpt-app-server-filter") { if (args.length > 2 || (args[1] !== undefined && args[1] !== "--self-test")) { console.error(USAGE); diff --git a/src/codex/exec-boundary.ts b/src/codex/exec-boundary.ts index 26f869fdf27..9db1b9291e8 100644 --- a/src/codex/exec-boundary.ts +++ b/src/codex/exec-boundary.ts @@ -20,8 +20,9 @@ import { * known-Codex-bundle classifier, and — when the lexical or canonical path sits * inside a ChatGPT.app or Codex.app bundle at any location — checked by the shared * signature/ownership policy before the same canonical target is returned for - * execution. Anything else, including binaries inside third-party app bundles, - * is passed through to the shared launcher unchanged. + * execution. CLI handoffs additionally classify any enclosing lexical or canonical .app, + * independent of bundle name or identifier. Other callers retain the known-app + * policy; non-bundle executables pass through to the shared launcher unchanged. * * The check is one-shot per invocation and deliberately not memoized: * directory-service group membership, signature state, and directory-entry @@ -53,6 +54,10 @@ export interface CodexExecBoundaryDeps { executable?: (path: string) => boolean; /** Canonicalizer; returns null when the path cannot be resolved. */ realpath?: (path: string) => string | null; + /** CLI handoffs verify any enclosing app, without trusting mutable bundle identity metadata. */ + verifyAnyAppBundle?: boolean; + /** Discovered Desktop bundle identity, retained by its generated launcher across respawns. */ + expectedBundleRoot?: string; macBundleTrustReason?: (bundleRoot: string, binary: string) => string | null; } @@ -113,22 +118,36 @@ function resolveDarwinExecPath(command: string, deps: CodexExecBoundaryDeps): { /** * Canonical (bundleRoot, binary) pair when the resolved path classifies into a - * known Codex desktop bundle, else null. The pair is always consistent: + * known Codex desktop bundle (or any enclosing app for CLI handoffs), else null. The pair is always consistent: * both halves are canonicalized together so a symlinked HOME or a PATH alias * cannot split the containment check, and a bundle member that cannot be * canonicalized refuses rather than escaping the known-bundle classification. */ function darwinBundleTarget(resolved: string, deps: CodexExecBoundaryDeps): { bundle: string; binary: string } | null { const realpath = deps.realpath ?? realpathOrNull; - const bundle = macCodexAppBundleFor(resolved, { env: deps.env, platform: deps.platform, realpath }); - if (!bundle) return null; + if (deps.expectedBundleRoot !== undefined && !posix.isAbsolute(deps.expectedBundleRoot)) { + throw new CodexExecTrustError("the recorded app bundle must be an absolute path"); + } const binary = realpath(resolved); + const enclosingApp = (path: string) => /^(.*?\/[^/]*\.app)(?:\/|$)/i.exec(path)?.[1]; + const lexicalBundle = deps.verifyAnyAppBundle && deps.expectedBundleRoot === undefined ? enclosingApp(resolved) : undefined; + // Verify the outer canonical app for nested layouts, but retain lexical containment. + const enclosing = deps.verifyAnyAppBundle && binary !== null ? enclosingApp(binary) : undefined; + const bundle = deps.expectedBundleRoot ?? enclosing ?? lexicalBundle ?? macCodexAppBundleFor(resolved, { env: deps.env, platform: deps.platform, realpath }); + if (!bundle) return null; if (binary === null) throw new CodexExecTrustError("cannot canonicalize the app-bundle exec target"); const canonicalBundle = realpath(bundle); if (canonicalBundle === null) throw new CodexExecTrustError("cannot canonicalize the app bundle"); if (!binary.startsWith(canonicalBundle + "/Contents/")) { throw new CodexExecTrustError("the app-bundle exec target is outside bundle Contents"); } + if (lexicalBundle && lexicalBundle !== bundle) { + const canonicalLexicalBundle = realpath(lexicalBundle); + if (canonicalLexicalBundle === null) throw new CodexExecTrustError("cannot canonicalize the app bundle"); + if (!binary.startsWith(canonicalLexicalBundle + "/Contents/")) { + throw new CodexExecTrustError("the app-bundle exec target is outside bundle Contents"); + } + } return { bundle: canonicalBundle, binary }; } @@ -144,6 +163,7 @@ export function validatedCodexExecTarget( deps: CodexExecBoundaryDeps = {}, ): ValidatedCodexExecTarget { if ((deps.platform ?? process.platform) !== "darwin") { + if (deps.expectedBundleRoot !== undefined) throw new CodexExecTrustError("recorded app bundles require macOS verification"); return { invocation: codexExecInvocation(command, args, deps.platform ?? process.platform, { env: deps.env, exists: deps.exists }), macAppBundle: false }; } const resolved = resolveDarwinExecPath(command, deps); @@ -164,6 +184,7 @@ export async function validatedCodexExecTargetAsync( ): Promise { deps.signal?.throwIfAborted(); if ((deps.platform ?? process.platform) !== "darwin") { + if (deps.expectedBundleRoot !== undefined) throw new CodexExecTrustError("recorded app bundles require macOS verification"); return { invocation: codexExecInvocation(command, args, deps.platform ?? process.platform, { env: deps.env, exists: deps.exists }), macAppBundle: false }; } const resolved = resolveDarwinExecPath(command, deps); diff --git a/structure/cli-management.md b/structure/cli-management.md index e601840594a..da5ae2d54f9 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -34,7 +34,7 @@ For fresh credentials, the launcher resolves a native literal `CODEX_HOME` once `src/cli/codex-launcher.ts` implements `ocx codex` without rewriting routing. Native/foreign routes pass through; local/unknown routes attempt startup and warn on failure. Fast paths skip startup/token loading. A matched wrapper uses its backing directly. Unknown state, including a throwing inspector, requires a separate bypass check via `src/cli/codex-launch-command.ts`; managed Windows CMD wrappers require complete generated-template equality before the selected absolute path executes with bypass. Obsolete, unreadable or ambiguous wrappers and lookup errors refuse launch without exception contents. A missing executable instead reports the Codex install hint, including on help/version fast paths. This content check does not establish Windows backing provenance or prevent concurrent replacement. `src/cli/codex-launch-auth.ts` separately requires a parsed managed transport block matching this installation's loopback listener or persisted local client connection; names/comments alone grant no token. Profiles, distinct project layers scanned through the filesystem root, destination-changing argv and environment overrides prevent automatic loading. An existing ancestor config is excluded only when its realpath matches the home config; a different file or failed identity resolution refuses. Token-file overrides and management-token collisions remain refused; user exports are preserved. No token enters argv or diagnostics. -The `codex` namespace forwards all help arguments unchanged; `ocx help codex` retains wrapper help. It skips root shim repair so the routing-aware launcher owns the handoff. +The `codex` namespace forwards all help arguments unchanged; `ocx help codex` retains wrapper help. It skips root shim repair so the routing-aware launcher owns the handoff. Every handoff, including help/version and passthrough, awaits the [execution trust contract](clients/chatgpt-desktop.md#codex-app-execution-trust) for the selected backing and spawns the returned invocation. On macOS, CLI handoffs verify any enclosing `.app` at the canonical target location, including renamed bundles and symlink targets; nested apps retain the outer bundle root. Fast paths bypass startup and credential loading, not executable validation. The hidden `internal codex-exec-target` helper returns a single verified path for the generated app-server launcher; its strict `--bundle-root` mode retains the discovered root independent of bundle name and refuses malformed roots without falling back to generic classification. It skips root shim auto-repair even on malformed arguments, and stays outside public capability discovery. Forwarded `-c`/`--config` and `-p`/`--profile` overrides make effective routing unverified for automatic service-token injection. They retain original argv and user-exported credentials but never load a new token from disk. Single-word prompts require `--` so they cannot select an unknown native subcommand. Missing system files also require a protected namespace: the closest existing directory must prevent another user from creating the missing layer, and its ancestors must prevent replacement. A normal Windows ProgramData directory that grants Users directory creation cannot authorize an absent OpenAI/Codex namespace; the launcher withholds credentials without creating or changing that directory. macOS opens each validated entry without following its final link (O_SYMLINK for the link entry itself), keeps dev/ino as bigint and requires descriptor dev/ino/type to match its original lstat, and reads its ACL with acl_get_fd_np. Only NULL with immediately captured ENOENT on this descriptor query means no extended ACL; open/stat/identity/close failures and every other NULL/error refuse. Every opened descriptor closes in finally. For a present ACL it validates and enumerates every entry through the documented -1/EINVAL terminator. Deny-only and read-only allow entries are accepted; any allow granting write/add-file, append/add-subdirectory, delete, delete-child, attribute, extended-attribute, security or ownership mutation refuses regardless of earlier denies. Unknown tags and FFI/enumeration failures refuse. Forced managed-preference checks remain mandatory. Pure policy and Darwin-gated native ACL coverage lives in `tests/codex-integration/codex-launcher-darwin-acl.test.ts`; modeled IO regressions for all three walks live in `tests/codex-integration/codex-launcher-darwin-identity.test.ts`. Tests model a protected empty machine namespace inside their private fixture; they do not modify real system configuration. Windows fixtures probe ancestor ownership before positive assertions; an unavailable protected location requires a proved ownership refusal and an explicit skip reason. Negative mutations and no-token/zero-read assertions execute even with unsafe ancestors; credential-file negatives bypass destination verification independently. Native checks use production defaults. `src/cli/root.ts`, `src/cli/help.ts`, `src/cli/help-navigation.ts` diff --git a/structure/clients/chatgpt-desktop.md b/structure/clients/chatgpt-desktop.md index b647bd7af5d..3038abdafd1 100644 --- a/structure/clients/chatgpt-desktop.md +++ b/structure/clients/chatgpt-desktop.md @@ -7,18 +7,23 @@ accepts only an optional boolean; malformed reads disable the leaf, while live writes reject malformed values and unknown fields. The launcher under the config directory re-enters the current CLI using -`process.execPath` and `selfLaunchArgv()`. Its hidden internal filter command stays +`process.execPath` and `selfLaunchArgv()`. Its hidden filter and executable-validation commands stay out of the public command registry and generated skill surface. Source installs include the CLI entry argument; compiled builds use only the executable and internal command arguments. -The launcher checks macOS, executable presence and a successful filter self-test, -then replaces itself with the bundled app-server using shell exec. Only stdout +The launcher uses macOS and a successful filter self-test to select stdout filtering. +Every invocation, including a Desktop respawn and the unfiltered branch, re-enters +`internal codex-exec-target --bundle-root -- ` immediately before shell exec. The helper applies +the execution trust contract below to the recorded discovered root, regardless of its name, +and returns the exact target to execute; the shell +does not use `uname` as execution authority. Only stdout passes through the filter. Stdin, stderr, process identity and the real server's exit status retain the direct app/server relationship. -When the platform is not macOS, the runtime is missing, or the filter self-test fails, -the launcher runs the original binary with untouched stdout. A missing bundled binary +When filtering is unavailable or its self-test fails, the launcher runs the validated +target with untouched stdout. An unavailable validator, failed trust check or empty +target exits 126 without executing the recorded binary; a missing bundled binary exits 127 instead (see below). A filter that passes the self-test and then exits mid-session closes the server's stdout pipe; the filter's passthrough mode limits this to an exit/crash case. @@ -59,6 +64,14 @@ OpenAI's. It writes the mode-0755 executable through an exclusive temp file and rename (never through a symbolic link), quits the bundle by id, waits for this user's instance to exit, then opens the same bundle path with the launcher in CODEX_CLI_PATH. The launcher itself exits 127 with a stderr hint when the recorded binary is gone. +The discovered root is a quoted literal in the launcher itself, with no metadata sidecar. +Before publication, the writer checks its parent/ancestor replacement permissions and the +new file's ownership, type and permissions using the shared POSIX filesystem policy. +An unsafe path refuses publication. The strict helper mode refuses a missing or empty root, +failed canonicalization or a target outside the recorded root's canonical `Contents/`. +It always verifies that root and binary, even if the bundle is renamed or its identifier changes. +Same-user processes, the installed CLI/runtime and the launcher code remain trusted; these +checks do not attest extended ACLs or make verification and exec an atomic operation. Restore uses the same trust policy for the bundle and its main app executable before quit or open. Both relaunch paths check ancestor ownership and POSIX replacement permissions through the filesystem root. Ancestors must be owned by this user or root; group/other write @@ -99,7 +112,23 @@ The name only routes to verification. `src/codex/exec-boundary.ts` requires a ca pair with the binary strictly inside that bundle's `Contents/`, then applies the same synchronous or asynchronous OpenAI signature and POSIX filesystem policy before returning an invocation. Canonicalization or verification failure refuses execution. This covers environment/configured -commands and catalog, prompt, login, refresh and remote-workspace consumers; ordinary CLI and -third-party app paths retain their existing policy. Conventional automatic discovery remains +commands and catalog, prompt, login, refresh and remote-workspace consumers. The +`src/cli/codex-launcher.ts` handoff validates the selected shim backing on routed, +passthrough and help/version fast paths, preserving the validated invocation's argv, +environment and Windows interpreter options. This CLI handoff additionally verifies every +enclosing `.app` at the lexical or canonical executable location, independent of its name or mutable +bundle identifier. A lexical app member must remain inside that root's canonical `Contents/`, +including through executable, directory or whole-bundle aliases. Nested apps use the outer canonical +bundle root so its signature is checked too; failed canonicalization refuses. Generated app-server launchers use the +hidden validator in `src/cli/internal-command.ts` on each execution; this command skips +root shim auto-repair, its stdout is a single path, and newline-bearing input or +canonical output is refused. Ordinary non-bundle CLI paths retain their existing policy; +third-party app paths retain their policy outside the stricter CLI handoff. Conventional automatic discovery remains limited to the two ChatGPT installation roots. Regression coverage is in -`tests/codex-integration/exec-known-app-bundles.test.ts`. +`tests/codex-integration/exec-known-app-bundles.test.ts`, +`tests/codex-integration/exec-launcher-boundary.test.ts`, +`tests/codex-integration/exec-cli-renamed-app-bundle.test.ts`, +`tests/codex-integration/exec-cli-bundle-containment.test.ts`, +`tests/codex-integration/exec-renamed-app-bundle.test.ts` and +`tests/clients/desktop-app-server-exec-boundary.test.ts`. Portable injected-verifier and +shell tests do not establish native macOS signature or real Desktop respawn acceptance. diff --git a/tests/clients/desktop-app-server-exec-boundary.test.ts b/tests/clients/desktop-app-server-exec-boundary.test.ts new file mode 100644 index 00000000000..fb604e111b1 --- /dev/null +++ b/tests/clients/desktop-app-server-exec-boundary.test.ts @@ -0,0 +1,76 @@ +import { expect, test } from "bun:test"; +import { chmodSync, existsSync, mkdtempSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { buildChatgptShimLauncher } from "../../src/chatgpt/app-server-shim/launcher"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const shellTest = process.platform !== "win32" && spawnSync("/bin/bash", ["-c", "exit 0"]).status === 0 ? test : test.skip; +const quote = (s: string) => `'${s.replaceAll("'", "'\\''")}'`; +function executable(dir: string, name: string, body: string) { + const path = join(dir, name); writeFileSync(path, `#!/bin/bash\n${body}\n`); chmodSync(path, 0o755); return path; +} +function fixture(run: (dir: string) => void) { + const dir = mkdtempSync(join(tmpdir(), "ocx-app-exec-")); + try { run(dir); } finally { removeTreeWithRetry(dir); } +} + +shellTest("each respawn validates and executes the returned target with original argv, stdin and PID", () => fixture(dir => { + const trace = join(dir, "trace"); + const recorded = executable(dir, "recorded.sh", "exit 99"); + const verified = executable(dir, "canonical with 'quote.sh", 'printf "PID:%s ARGS:%s\\n" "$$" "$*"\ncat\nexit 7'); + const validator = executable(dir, "validate", `echo validate >> ${quote(trace)}\nprintf '%s\\n' ${quote(verified)}`); + const launcher = executable(dir, "launcher", buildChatgptShimLauncher([join(dir, "missing-filter")], recorded, [validator])); + for (let i = 0; i < 2; i++) { + const out = spawnSync("/bin/bash", [launcher, "app-server", "arg with spaces"], { encoding: "utf8", input: "stdin\n", timeout: 5000 }); + expect(out.error).toBeUndefined(); expect(out.status).toBe(7); + expect(out.stdout).toBe(`PID:${out.pid} ARGS:app-server arg with spaces\nstdin\n`); + } + expect(readFileSync(trace, "utf8")).toBe("validate\nvalidate\n"); +})); + +shellTest("revocation after launcher creation blocks later execution even when uname says Linux", () => fixture(dir => { + const marker = join(dir, "spawned"), allow = join(dir, "allowed"); + const real = executable(dir, "real", `echo ran >> ${quote(marker)}`); + const validator = executable(dir, "validate", `[ -e ${quote(allow)} ] || exit 1\nprintf '%s\\n' "$2"`); + executable(dir, "uname", "echo Linux"); + const launcher = executable(dir, "launcher", buildChatgptShimLauncher([join(dir, "missing-filter")], real, [validator])); + const run = () => spawnSync("/bin/bash", [launcher], { encoding: "utf8", env: { ...process.env, PATH: `${dir}:${process.env.PATH}` }, timeout: 5000 }); + writeFileSync(allow, "allowed"); + expect(run().status).toBe(0); + expect(readFileSync(marker, "utf8")).toBe("ran\n"); + unlinkSync(allow); + const out = run(); + expect(out.status).toBe(126); expect(readFileSync(marker, "utf8")).toBe("ran\n"); + expect(out.stderr).toContain("execution verification failed"); +})); + +for (const status of [0, 1]) shellTest(`filter preflight ${status} cannot bypass the last executable check`, () => fixture(dir => { + const trace = join(dir, "trace"), marker = join(dir, "spawned"); + executable(dir, "uname", "echo Darwin"); + const real = executable(dir, "real", `echo ran >> ${quote(marker)}`); + const filter = executable(dir, "filter", `echo filter >> ${quote(trace)}\nexit ${status}`); + const validator = executable(dir, "validate", `echo validate >> ${quote(trace)}\nexit 1`); + const launcher = executable(dir, "launcher", buildChatgptShimLauncher([filter], real, [validator])); + const out = spawnSync("/bin/bash", [launcher], { encoding: "utf8", env: { ...process.env, PATH: `${dir}:${process.env.PATH}` }, timeout: 5000 }); + expect(out.status).toBe(126); expect(existsSync(marker)).toBe(false); + expect(readFileSync(trace, "utf8")).toBe("filter\nvalidate\n"); +})); + +shellTest("missing validator refuses instead of falling back to the unchecked target", () => fixture(dir => { + const marker = join(dir, "spawned"); + const real = executable(dir, "real", `echo ran >> ${quote(marker)}`); + const launcher = executable(dir, "launcher", buildChatgptShimLauncher([join(dir, "filter")], real, [join(dir, "missing-validator")])); + const out = spawnSync("/bin/bash", [launcher], { encoding: "utf8", timeout: 5000 }); + expect(out.status).toBe(126); expect(existsSync(marker)).toBe(false); +})); + +shellTest("an empty successful validator result does not authorize the recorded target", () => fixture(dir => { + const marker = join(dir, "spawned"); + const real = executable(dir, "real", `echo ran >> ${quote(marker)}`); + const validator = executable(dir, "validate", "exit 0"); + const launcher = executable(dir, "launcher", buildChatgptShimLauncher([join(dir, "filter")], real, [validator])); + const out = spawnSync("/bin/bash", [launcher], { encoding: "utf8", timeout: 5000 }); + expect(out.status).toBe(126); expect(existsSync(marker)).toBe(false); +})); diff --git a/tests/clients/desktop-app-server-shim-launcher.test.ts b/tests/clients/desktop-app-server-shim-launcher.test.ts index db53fc7fd0b..f26bd6354b1 100644 --- a/tests/clients/desktop-app-server-shim-launcher.test.ts +++ b/tests/clients/desktop-app-server-shim-launcher.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { chmodSync, lstatSync, mkdtempSync, readdirSync, readFileSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, lstatSync, mkdtempSync, readdirSync, readFileSync, realpathSync, statSync, symlinkSync, writeFileSync } from "node:fs"; import { OPENAI_TEAM_ID, untrustedChatgptBundleReason, type BundleTrustDeps } from "../../src/chatgpt/app-server-shim/bundle-trust"; import { tmpdir } from "node:os"; import { dirname, join, resolve, sep } from "node:path"; @@ -26,10 +26,13 @@ const executable = (dir: string, name: string, body: string) => { return path; }; function withDir(run: (dir: string) => void) { - const dir = mkdtempSync(join(tmpdir(), "ocx-shim-test-")); + const dir = realpathSync(mkdtempSync(join(tmpdir(), "ocx-shim-test-"))); try { run(dir); } finally { removeTreeWithRetry(dir); } } const internalArgs = ["internal", "chatgpt-app-server-filter"]; +const validatorArgv = [process.execPath, ...selfLaunchArgv(["internal", "codex-exec-target"], { + isStandaloneExecutable: false, sourceEntrypoint: repoPath("src/cli/index.ts"), +})]; describe("experimental app-server launcher", () => { test("source and compiled argv re-enter the CLI with shell-safe quoting", () => { @@ -37,8 +40,10 @@ describe("experimental app-server launcher", () => { const argv = ["/runtime with 'quote", ...selfLaunchArgv(internalArgs, { isStandaloneExecutable: standalone, sourceEntrypoint: "/source with space/index.ts", })]; - const launcher = buildChatgptShimLauncher(argv, "/real with space"); + const validator = [...argv.slice(0, -1), "codex-exec-target"]; + const launcher = buildChatgptShimLauncher(argv, "/real with space", validator); expect(launcher).toContain("'internal' 'chatgpt-app-server-filter'"); + expect(launcher).toContain("'internal' 'codex-exec-target'"); expect(launcher.includes("'/source with space/index.ts'")).toBe(!standalone); expect(launcher).toContain("'\\''"); expect(launcher).toContain('--self-test >/dev/null 2>&1'); @@ -103,13 +108,13 @@ describe("experimental app-server launcher", () => { executable(dir, "uname", 'echo Darwin'); return { ...process.env, PATH: `${dir}:${process.env.PATH ?? "/usr/bin:/bin"}` }; }; - for (const scenario of ["missing runtime", "failing self-test", "passing self-test"] as const) { + for (const scenario of ["missing filter runtime", "failing self-test", "passing self-test"] as const) { const scenarioTest = (scenario === "passing self-test" ? substitutionAvailable : bashAvailable) ? test : test.skip; scenarioTest(scenario, () => withDir(dir => { const real = executable(dir, "real with 'quote.sh", 'echo "PID:$$ ARGS:$*"\nread -r line\necho "STDIN:$line"\necho "to-stderr" >&2\nexit 7'); - const filter = scenario === "missing runtime" ? join(dir, "missing") : executable(dir, "filter.sh", + const filter = scenario === "missing filter runtime" ? join(dir, "missing") : executable(dir, "filter.sh", `if [ "$1" = --self-test ]; then exit ${scenario === "failing self-test" ? 1 : 0}; fi\nsed 's/STDIN/FILTERED/'`); - const launcher = executable(dir, "launcher.sh", buildChatgptShimLauncher([filter], real)); + const launcher = executable(dir, "launcher.sh", buildChatgptShimLauncher([filter], real, validatorArgv)); const out = spawnSync("/bin/bash", [launcher, "app-server", "--flag"], { encoding: "utf8", input: "hello\n", env: environment(dir), timeout: 5000, }); @@ -124,7 +129,7 @@ describe("experimental app-server launcher", () => { const argv = [process.execPath, ...selfLaunchArgv(internalArgs, { isStandaloneExecutable: false, sourceEntrypoint: repoPath("src/cli/index.ts"), })]; - const launcher = executable(dir, "launcher.sh", buildChatgptShimLauncher(argv, real)); + const launcher = executable(dir, "launcher.sh", buildChatgptShimLauncher(argv, real, validatorArgv)); const out = spawnSync("/bin/bash", [launcher, "app-server"], { encoding: "utf8", env: environment(dir), timeout: 10000 }); expect(out.status).toBe(0); const result = JSON.parse(out.stdout).result; @@ -135,7 +140,7 @@ describe("experimental app-server launcher", () => { executionTest("a filter exit after successful preflight closes the pipe and terminates a mock server", () => withDir(dir => { const real = executable(dir, "real.sh", 'trap "exit 23" PIPE\nwhile :; do printf "%s\\n" "mock server stdout" || exit 23; done'); const filter = executable(dir, "filter.sh", 'if [ "$1" = --self-test ]; then exit 0; fi\nexit 1'); - const launcher = executable(dir, "launcher.sh", buildChatgptShimLauncher([filter], real)); + const launcher = executable(dir, "launcher.sh", buildChatgptShimLauncher([filter], real, validatorArgv)); const out = spawnSync("/bin/bash", [launcher], { encoding: "utf8", env: environment(dir), timeout: 5000 }); expect(out.error).toBeUndefined(); expect(out.status).toBe(23); diff --git a/tests/codex-integration/exec-cli-bundle-containment.test.ts b/tests/codex-integration/exec-cli-bundle-containment.test.ts new file mode 100644 index 00000000000..65ecd399ef9 --- /dev/null +++ b/tests/codex-integration/exec-cli-bundle-containment.test.ts @@ -0,0 +1,148 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { EventEmitter } from "node:events"; +import type { spawn } from "node:child_process"; +import { spawnCodex, type CodexSpawnDeps } from "../../src/cli/codex-launcher"; +import { validatedCodexExecInvocation } from "../../src/codex/exec-boundary"; + +const root = "/opt/ChatGPT Beta.app"; +const member = `${root}/Contents/Resources/codex`; +type Link = readonly [string, string | null]; +let errors: ReturnType>; +beforeEach(() => { errors = spyOn(console, "error").mockImplementation(() => {}); }); +afterEach(() => { errors.mockRestore(); }); + +// Model realpath at its existing seam, including redirects of ancestor directories. +function harness(command: string, backing: boolean, links: readonly Link[], refusal: string | null = null) { + const checks: string[][] = [], spawns: string[] = []; + const deps: CodexSpawnDeps = { + findOnPath: () => backing ? "/owned/codex-wrapper" : command, + inspectShim: () => (backing + ? { status: "matched", selectedRole: "wrapper", backingPath: command } + : { status: "absent" }) as ReturnType>, + execBoundary: { + platform: "darwin", executable: () => true, + realpath: path => { + const link = links.find(([from]) => path === from || path.startsWith(from + "/")); + return link ? link[1] === null ? null : link[1] + path.slice(link[0].length) : path; + }, + macBundleTrustReasonAsync: async (bundle, binary) => { checks.push([bundle, binary]); return refusal; }, + }, + spawn: ((file: string) => { + spawns.push(file); + const child = new EventEmitter(); + queueMicrotask(() => child.emit("exit", 0, null)); + return child; + }) as typeof spawn, + }; + return { deps, checks, spawns }; +} + +const escapes: Array<{ name: string; links: Link[] }> = [ + { name: "executable symlink", links: [[member, "/outside/codex"]] }, + { name: "Resources symlink", links: [[`${root}/Contents/Resources`, "/outside"]] }, + { name: "Contents symlink", links: [[`${root}/Contents`, "/outside"]] }, + { name: "member in another signed app", links: [[member, "/opt/Other.app/Contents/Resources/codex"]] }, + { name: "prefix sibling", links: [[member, `${root}-sibling/Contents/Resources/codex`]] }, + { name: "outside Contents", links: [[member, `${root}/MacOS/codex`]] }, + { name: "unresolvable lexical root", links: [[member, "/opt/Other.app/Contents/Resources/codex"], [root, null]] }, + { name: "aliased root with an escaped member", links: [[member, "/outside/codex"], [root, "/real/Bundle"]] }, + { name: "aliased inner root escaped within the outer app", links: [ + [member, "/real/Outer.app/Contents/Resources/codex"], + [root, "/real/Outer.app/Contents/Resources/Inner.app"], + ] }, +]; + +for (const backing of [false, true]) for (const scenario of escapes) { + test(`CLI refuses lexical bundle escape before an accepting verifier: ${scenario.name}, backing=${backing}`, async () => { + const h = harness(member, backing, scenario.links); + expect(await spawnCodex(["--version"], {}, h.deps)).toBe(1); + expect(h.checks).toEqual([]); + expect(h.spawns).toEqual([]); + expect(errors.mock.calls.flat().join(" ")).toContain( + scenario.name === "unresolvable lexical root" ? "cannot canonicalize the app bundle" : "outside bundle Contents", + ); + }); +} + +for (const backing of [false, true]) for (const canonicalRoot of ["/real/Bundle", "/real/Renamed.APP"]) { + test(`CLI verifies a whole-bundle symlink: ${canonicalRoot}, backing=${backing}`, async () => { + const binary = `${canonicalRoot}/Contents/Resources/codex`; + const h = harness(member, backing, [[root, canonicalRoot]], "signature revoked"); + expect(await spawnCodex(["--help"], {}, h.deps)).toBe(1); + expect(h.checks).toEqual([[canonicalRoot, binary]]); + expect(h.spawns).toEqual([]); + const trusted = harness(member, backing, [[root, canonicalRoot]]); + expect(await spawnCodex(["--help"], {}, trusted.deps)).toBe(0); + expect(trusted.checks).toEqual([[canonicalRoot, binary]]); + expect(trusted.spawns).toEqual([binary]); + }); +} + +for (const backing of [false, true]) for (const from of [member, `${root}/Contents/Resources`]) { + test(`CLI validates an in-bundle member alias: ${from}, backing=${backing}`, async () => { + const target = from === member ? `${root}/Contents/MacOS/codex` : `${root}/Contents/MacOS`; + const links: Link[] = [[from, target]]; + const canonical = `${root}/Contents/MacOS/codex`; + const trusted = harness(member, backing, links); + expect(await spawnCodex(["--version"], {}, trusted.deps)).toBe(0); + expect(trusted.checks).toEqual([[root, canonical]]); + expect(trusted.spawns).toEqual([canonical]); + const rejected = harness(member, backing, links, "signature revoked"); + expect(await spawnCodex(["--version"], {}, rejected.deps)).toBe(1); + expect(rejected.checks).toEqual([[root, canonical]]); + expect(rejected.spawns).toEqual([]); + }); +} + +for (const suffix of ["app", "APP", "ApP"]) { + test(`CLI retains lexical .${suffix} classification when the canonical member escapes`, async () => { + const binary = `/opt/Unrelated.${suffix}/Contents/Resources/codex`; + const h = harness(binary, false, [[binary, "/outside/codex"]]); + expect(await spawnCodex(["app-server"], {}, h.deps)).toBe(1); + expect(h.checks).toEqual([]); + expect(h.spawns).toEqual([]); + }); +} + +for (const backing of [false, true]) { + test(`CLI spawns the captured canonical member after lexical alias replacement, backing=${backing}`, async () => { + const canonical = `${root}/Contents/MacOS/codex`; + const links: Link[] = [[member, canonical]]; + const h = harness(member, backing, links); + let entered!: () => void, release!: () => void; + const checking = new Promise(resolve => { entered = resolve; }); + const pending = new Promise(resolve => { release = resolve; }); + h.deps.execBoundary!.macBundleTrustReasonAsync = async (bundle, binary) => { + h.checks.push([bundle, binary]); entered(); + await pending; + return null; + }; + const launch = spawnCodex(["--help"], {}, h.deps); + await checking; + links[0] = [member, "/outside/replaced-codex"]; + release(); + expect(await launch).toBe(0); + expect(h.checks).toEqual([[root, canonical]]); + expect(h.spawns).toEqual([canonical]); + }); +} + +test("CLI checks the outer canonical app when the lexical bundle root is an inner app alias", async () => { + const outer = "/real/Outer.app"; + const inner = `${outer}/Contents/Resources/Inner.app`; + const binary = `${inner}/Contents/Resources/codex`; + const h = harness(member, true, [[root, inner]], "signature revoked"); + expect(await spawnCodex(["--help"], {}, h.deps)).toBe(1); + expect(h.checks).toEqual([[outer, binary]]); + expect(h.spawns).toEqual([]); +}); + +test("synchronous CLI validation applies the same lexical containment refusal", () => { + let checks = 0; + expect(() => validatedCodexExecInvocation(member, [], { + platform: "darwin", verifyAnyAppBundle: true, executable: () => true, + realpath: path => path === member ? "/outside/codex" : path, + macBundleTrustReason: () => { checks++; return null; }, + })).toThrow("outside bundle Contents"); + expect(checks).toBe(0); +}); diff --git a/tests/codex-integration/exec-cli-renamed-app-bundle.test.ts b/tests/codex-integration/exec-cli-renamed-app-bundle.test.ts new file mode 100644 index 00000000000..6e3db0ac335 --- /dev/null +++ b/tests/codex-integration/exec-cli-renamed-app-bundle.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { EventEmitter } from "node:events"; +import type { spawn } from "node:child_process"; +import { spawnCodex, type CodexSpawnDeps } from "../../src/cli/codex-launcher"; + +const root = "/opt/OpenAI/ChatGPT Beta.app"; +const binary = `${root}/Contents/Resources/codex`; +const alias = "/usr/local/bin/codex"; +let errors: ReturnType>; +beforeEach(() => { errors = spyOn(console, "error").mockImplementation(() => {}); }); +afterEach(() => { errors.mockRestore(); }); + +function harness(command: string, backing: boolean, target = binary) { + let allowed = true; + const checks: string[][] = [], spawns: string[] = []; + const deps: CodexSpawnDeps = { + findOnPath: () => backing ? "/owned/codex-wrapper" : command, + inspectShim: () => (backing + ? { status: "matched", selectedRole: "wrapper", backingPath: command } + : { status: "absent" }) as ReturnType>, + execBoundary: { + platform: "darwin", executable: () => true, + // Model a PATH symlink to the canonical member; no conventional bundle alias exists. + realpath: path => path === command ? target : path, + macBundleTrustReasonAsync: async (bundle, executable) => { + checks.push([bundle, executable]); + return allowed ? null : "signature revoked"; + }, + }, + spawn: ((file: string) => { + spawns.push(file); + const child = new EventEmitter(); + queueMicrotask(() => child.emit("exit", 0, null)); + return child; + }) as typeof spawn, + }; + return { deps, checks, spawns, revoke: () => { allowed = false; } }; +} + +for (const backing of [false, true]) for (const symlink of [false, true]) { + test(`CLI renamed bundle rechecks revocation: shim backing=${backing}, symlink=${symlink}`, async () => { + const h = harness(symlink ? alias : binary, backing); + expect(await spawnCodex(["--version"], {}, h.deps)).toBe(0); + h.revoke(); + expect(await spawnCodex(["--version"], {}, h.deps)).toBe(1); + expect(h.checks).toEqual([[root, binary], [root, binary]]); + expect(h.spawns).toEqual([binary]); + expect(errors.mock.calls.flat().join(" ")).toContain("signature revoked"); + }); +} + +test("CLI validates the outer renamed root for a nested app executable", async () => { + const nested = `${root}/Contents/Resources/CodexCLI.app/Contents/MacOS/codex`; + const h = harness(alias, true, nested); + h.revoke(); + expect(await spawnCodex(["app-server"], {}, h.deps)).toBe(1); + expect(h.checks).toEqual([[root, nested]]); + expect(h.spawns).toEqual([]); +}); + +test("CLI refuses an unresolved renamed bundle member before trust or spawn", async () => { + const h = harness(binary, false); + h.deps.execBoundary!.realpath = path => path === binary ? null : path; + expect(await spawnCodex(["--help"], {}, h.deps)).toBe(1); + expect(h.checks).toEqual([]); + expect(h.spawns).toEqual([]); +}); + +test("CLI ordinary executable aliases still bypass bundle verification", async () => { + const target = "/opt/npm/bin/codex"; + const h = harness(alias, true, target); + h.revoke(); + expect(await spawnCodex(["--help"], {}, h.deps)).toBe(0); + expect(h.checks).toEqual([]); + expect(h.spawns).toEqual([alias]); +}); diff --git a/tests/codex-integration/exec-launcher-boundary.test.ts b/tests/codex-integration/exec-launcher-boundary.test.ts new file mode 100644 index 00000000000..ac61f3d0b2f --- /dev/null +++ b/tests/codex-integration/exec-launcher-boundary.test.ts @@ -0,0 +1,143 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { EventEmitter } from "node:events"; +import type { spawn } from "node:child_process"; +import { spawnCodex, type CodexSpawnDeps } from "../../src/cli/codex-launcher"; +import { handleInternalCommand } from "../../src/cli/internal-command"; +import type { CodexExecBoundaryAsyncDeps } from "../../src/codex/exec-boundary"; +import { maybeAutoRestoreCodexShim, skipsCodexShimAutoRestore } from "../../src/cli/codex-shim-autorestore"; + +const bundle = "/Applications/ChatGPT.app"; +const binary = `${bundle}/Contents/Resources/codex`; +const alias = "/usr/local/bin/codex"; +let logs: ReturnType>; +let errors: ReturnType>; +beforeEach(() => { + logs = spyOn(console, "log").mockImplementation(() => {}); + errors = spyOn(console, "error").mockImplementation(() => {}); +}); +afterEach(() => { logs.mockRestore(); errors.mockRestore(); }); + +function harness(check: NonNullable) { + const calls: Array<{ file: string; args: string[]; options: Record }> = []; + const boundary: CodexExecBoundaryAsyncDeps = { + platform: "darwin", executable: () => true, + realpath: path => path === alias ? binary : path, + macBundleTrustReasonAsync: check, + }; + const deps: CodexSpawnDeps = { + findOnPath: () => "/owned/codex-wrapper", + inspectShim: () => ({ status: "matched", selectedRole: "wrapper", backingPath: alias }) as ReturnType>, + execBoundary: boundary, + spawn: ((file: string, args: string[], options: Record) => { + calls.push({ file, args, options }); + const child = new EventEmitter(); + queueMicrotask(() => child.emit("exit", 7, null)); + return child; + }) as typeof spawn, + }; + return { calls, boundary, deps }; +} + +test("the selected shim backing waits for trust, then spawns the canonical binary", async () => { + let finish!: (reason: string | null) => void; + let entered!: () => void; + const started = new Promise(resolve => { entered = resolve; }); + const trust = new Promise(resolve => { finish = resolve; }); + const h = harness(async (root, path) => { expect(root).toBe(bundle); expect(path).toBe(binary); entered(); return trust; }); + const env = { PATH: "/usr/bin:/bin", OCX_BOUNDARY_FIXTURE: "preserved" }; + const running = spawnCodex(["--version"], env, h.deps); + await started; + expect(h.calls).toHaveLength(0); + finish(null); + expect(await running).toBe(7); + expect(h.calls).toHaveLength(1); + expect(h.calls[0]).toEqual({ file: binary, args: ["--version"], options: { stdio: "inherit", env } }); +}); + +test("each invocation rechecks trust and a revoked signature starts no replacement", async () => { + let count = 0; + const h = harness(async () => ++count === 1 ? null : "signature changed"); + expect(await spawnCodex(["--help"], {}, h.deps)).toBe(7); + expect(await spawnCodex(["--help"], {}, h.deps)).toBe(1); + expect(count).toBe(2); + expect(h.calls).toHaveLength(1); + expect(errors.mock.calls.flat().join(" ")).toContain("signature changed"); +}); + +test("a failed or thrown trust check never reaches spawn", async () => { + for (const check of [async () => "foreign owner", async () => { throw new Error("inspection unavailable"); }]) { + const h = harness(check); + expect(await spawnCodex(["app-server"], {}, h.deps)).toBe(1); + expect(h.calls).toHaveLength(0); + } +}); + +test("a selected executable that disappears before verification keeps the install hint", async () => { + const h = harness(async () => { throw new Error("must not inspect a missing target"); }); + h.deps.findOnPath = () => "/usr/local/bin/codex"; + h.deps.inspectShim = () => ({ status: "absent" }) as ReturnType>; + h.deps.execBoundary = { ...h.boundary, executable: () => false, exists: () => false }; + expect(await spawnCodex(["--version"], {}, h.deps)).toBe(1); + expect(h.calls).toHaveLength(0); + expect(errors.mock.calls.flat().join(" ")).toContain("codex` CLI not found"); +}); + +test("non-bundle targets preserve arguments and do not invoke bundle verification", async () => { + const h = harness(async () => { throw new Error("must not inspect an ordinary CLI"); }); + h.deps.findOnPath = () => "/usr/bin/codex"; + h.deps.inspectShim = () => ({ status: "absent" }) as ReturnType>; + expect(await spawnCodex(["exec", "argument with spaces"], {}, h.deps)).toBe(7); + expect(h.calls[0].file).toBe("/usr/bin/codex"); + expect(h.calls[0].args).toEqual(["exec", "argument with spaces"]); +}); + +test("Windows CMD invocation keeps its interpreter options and launch environment", async () => { + const h = harness(async () => { throw new Error("not macOS"); }); + const command = "C:/test/codex.cmd"; + h.deps.findOnPath = () => command; + h.deps.inspectShim = () => ({ status: "absent" }) as ReturnType>; + h.deps.execBoundary = { platform: "win32", exists: () => true }; + const env = { ComSpec: "C:/Windows/System32/cmd.exe", PATH: "", PATHEXT: ".CMD;.EXE" }; + expect(await spawnCodex(["--version"], env, h.deps)).toBe(7); + expect(h.calls[0].file).toBe(env.ComSpec); + expect(h.calls[0].args.slice(0, 3)).toEqual(["/d", "/s", "/c"]); + expect(h.calls[0].options.windowsVerbatimArguments).toBe(true); +}); + +test("hidden validator emits only the trusted target and repeats the check", async () => { + let allowed = true, checks = 0; + const h = harness(async () => { checks++; return allowed ? null : "revoked"; }); + expect(await handleInternalCommand(["codex-exec-target", "--", alias], h.boundary)).toBe(0); + expect(logs.mock.calls).toEqual([[binary]]); + logs.mockClear(); allowed = false; + expect(await handleInternalCommand(["codex-exec-target", "--", alias], h.boundary)).toBe(1); + expect(logs.mock.calls).toHaveLength(0); + expect(checks).toBe(2); +}); + +test("hidden validator rejects malformed argv and line-oriented path ambiguity", async () => { + for (const args of [[], ["--"], ["--", ""], ["--", "a\nb"], ["--", "a\rb"], ["--", alias, "extra"]]) { + expect(await handleInternalCommand(["codex-exec-target", ...args])).toBe(2); + } + expect(logs.mock.calls).toHaveLength(0); +}); + +test("hidden validator refuses a canonical target containing a line break", async () => { + const h = harness(async () => null); + h.boundary.realpath = path => path === alias ? `${binary}\nextra` : path; + expect(await handleInternalCommand(["codex-exec-target", "--", alias], h.boundary)).toBe(1); + expect(logs.mock.calls).toHaveLength(0); +}); + +test("validator admission skips root shim repair even for malformed arguments", () => { + let reads = 0, restores = 0; + for (const suffix of [[], ["--", alias], ["--", alias, "extra"]]) { + maybeAutoRestoreCodexShim("internal", ["internal", "codex-exec-target", ...suffix], { + env: {}, warn: () => { throw new Error("unexpected repair warning"); }, + readConfig: () => { reads++; throw new Error("unexpected configuration read"); }, + restore: () => { restores++; throw new Error("unexpected shim repair"); }, + }); + } + expect(reads).toBe(0); expect(restores).toBe(0); + expect(skipsCodexShimAutoRestore("internal", ["internal", "chatgpt-app-server-filter"])).toBe(false); +}); diff --git a/tests/codex-integration/exec-renamed-app-bundle.test.ts b/tests/codex-integration/exec-renamed-app-bundle.test.ts new file mode 100644 index 00000000000..1225a317937 --- /dev/null +++ b/tests/codex-integration/exec-renamed-app-bundle.test.ts @@ -0,0 +1,164 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { handleInternalCommand } from "../../src/cli/internal-command"; +import { buildChatgptShimLauncher, writeChatgptShimLauncher, chatgptShimLauncherPath } from "../../src/chatgpt/app-server-shim/launcher"; +import { untrustedLauncherFilesystemReason, type BundleTrustDeps } from "../../src/chatgpt/app-server-shim/bundle-trust-policy"; +import { mkdtempSync, readFileSync, readdirSync, realpathSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { tmpdir } from "node:os"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const root = "/Volumes/Apps/ChatGPT Beta.app"; +const binary = `${root}/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex`; +let logs: ReturnType>; +let errors: ReturnType>; +beforeEach(() => { + logs = spyOn(console, "log").mockImplementation(() => {}); + errors = spyOn(console, "error").mockImplementation(() => {}); +}); +afterEach(() => { logs.mockRestore(); errors.mockRestore(); }); + +test("a recorded renamed bundle rechecks trust after revocation without a conventional alias", async () => { + let allowed = true; + const checked: string[][] = []; + const boundary = { + platform: "darwin" as const, executable: () => true, realpath: (path: string) => path, + expectedBundleRoot: root, + macBundleTrustReasonAsync: async (bundle: string, target: string) => { + checked.push([bundle, target]); + return allowed ? null : "signature revoked"; + }, + }; + expect(await handleInternalCommand(["codex-exec-target", "--", binary], boundary)).toBe(0); + expect(logs.mock.calls).toEqual([[binary]]); + logs.mockClear(); allowed = false; + expect(await handleInternalCommand(["codex-exec-target", "--", binary], boundary)).toBe(1); + expect(logs.mock.calls).toEqual([]); + expect(checked).toEqual([[root, binary], [root, binary]]); +}); + +test("launcher carries its outer renamed bundle root inline to the strict helper mode", () => { + const script = buildChatgptShimLauncher(["/filter"], binary, ["/ocx", "internal", "codex-exec-target"]); + expect(script).toContain(`BUNDLE_ROOT='${root}'`); + expect(script).toContain('--bundle-root "$BUNDLE_ROOT" -- "$REAL"'); +}); + +test("explicit bundle mode validates a renamed bundle and never accepts an empty root", async () => { + let checks = 0; + const boundary = { + platform: "darwin" as const, executable: () => true, realpath: (path: string) => path, + macBundleTrustReasonAsync: async (bundle: string, target: string) => { + checks++; expect(bundle).toBe(root); expect(target).toBe(binary); return null; + }, + }; + expect(await handleInternalCommand(["codex-exec-target", "--bundle-root", root, "--", binary], boundary)).toBe(0); + expect(checks).toBe(1); + for (const args of [ + ["--bundle-root", "", "--", binary], ["--bundle-root", root], + ["--bundle-root", root, "--", binary, "extra"], ["--bundle-root", `${root}\nextra`, "--", binary], + ]) expect(await handleInternalCommand(["codex-exec-target", ...args], boundary)).toBe(2); + expect(checks).toBe(1); +}); + +test("launcher helper argv rechecks the recorded root on a later respawn", async () => { + const args = ["codex-exec-target", "--bundle-root", root, "--", binary]; + let allowed = true, checks = 0; + const boundary = { + platform: "darwin" as const, executable: () => true, realpath: (path: string) => path, + macBundleTrustReasonAsync: async (bundle: string, target: string) => { + checks++; expect(bundle).toBe(root); expect(target).toBe(binary); + return allowed ? null : "signature revoked"; + }, + }; + expect(await handleInternalCommand(args, boundary)).toBe(0); + logs.mockClear(); allowed = false; + expect(await handleInternalCommand(args, boundary)).toBe(1); + expect(checks).toBe(2); expect(logs.mock.calls).toEqual([]); +}); + +for (const defect of ["missing root", "missing binary", "escape", "prefix sibling", "outside Contents"] as const) { + test(`recorded bundle refuses ${defect} before invoking trust`, async () => { + let checks = 0; + const boundary = { + platform: "darwin" as const, executable: () => true, + realpath: (path: string) => { + if (path === root && defect === "missing root" || path === binary && defect === "missing binary") return null; + if (path !== binary) return path; + return defect === "escape" ? "/opt/codex" : defect === "prefix sibling" ? `${root}-copy/Contents/codex` + : defect === "outside Contents" ? `${root}/codex` : path; + }, + macBundleTrustReasonAsync: async () => { checks++; return null; }, + }; + expect(await handleInternalCommand(["codex-exec-target", "--bundle-root", root, "--", binary], boundary)).toBe(1); + expect(checks).toBe(0); expect(logs.mock.calls).toEqual([]); + }); +} + +test("ordinary executable helper calls retain passthrough", async () => { + const target = "/opt/bin/codex"; + expect(await handleInternalCommand(["codex-exec-target", "--", target], { + platform: "darwin", executable: () => true, realpath: path => path, + macBundleTrustReasonAsync: async () => { throw new Error("ordinary CLI must not be checked"); }, + })).toBe(0); + expect(logs.mock.calls).toEqual([[target]]); +}); + +test("recorded bundle validation uses a consistent canonical root and target", async () => { + const canonicalRoot = "/private/Apps/ChatGPT Beta.app"; + const target = binary.replace(root, canonicalRoot); + expect(await handleInternalCommand(["codex-exec-target", "--bundle-root", root, "--", binary], { + platform: "darwin", executable: () => true, realpath: path => path.replace(root, canonicalRoot), + macBundleTrustReasonAsync: async (bundle, executable) => { + expect(bundle).toBe(canonicalRoot); expect(executable).toBe(target); return null; + }, + })).toBe(0); + expect(logs.mock.calls).toEqual([[target]]); +}); + +test("recorded bundle mode refuses on a host without macOS verification", async () => { + expect(await handleInternalCommand(["codex-exec-target", "--bundle-root", root, "--", binary], { platform: "linux" })).toBe(1); + expect(logs.mock.calls).toEqual([]); +}); + +for (const defect of ["none", "foreign file", "writable file", "link file", "foreign parent", "writable parent", "link parent"] as const) { + test(`inline root's launcher filesystem policy: ${defect}`, () => { + const path = "/owned/config/launcher.sh"; + const deps: Omit = { + uid: 501, + stat: entry => { + const bad = entry === (defect.endsWith("file") ? path : dirname(path)); + return { uid: bad && defect.startsWith("foreign") ? 502 : 501, + mode: bad && defect.startsWith("writable") ? 0o777 : 0o755, + isFile: entry === path, isDirectory: entry !== path, + isSymbolicLink: bad && defect.startsWith("link") }; + }, + }; + expect(untrustedLauncherFilesystemReason(path, deps) === null).toBe(defect === "none"); + }); +} + +for (const defect of ["none", "parent", "file"] as const) { + test(`writer protects recorded root before publication: ${defect}`, () => { + const dir = realpathSync(mkdtempSync(join(tmpdir(), "ocx-recorded-root-"))); + const launcher = chatgptShimLauncherPath(dir); + try { + writeFileSync(launcher, "original"); + const deps: Omit = { + uid: 501, + stat: entry => ({ uid: 501, + mode: defect === "parent" && entry === dir || defect === "file" && entry.endsWith(".tmp") ? 0o777 : 0o755, + isFile: entry.endsWith(".tmp"), isDirectory: !entry.endsWith(".tmp"), isSymbolicLink: false }), + }; + const write = () => writeChatgptShimLauncher(dir, binary, root, deps); + if (defect === "none") { + expect(write()).toBe(launcher); + const text = readFileSync(launcher, "utf8"); + expect(text).toContain(`BUNDLE_ROOT='${root}'`); + expect(text).toContain('--bundle-root "$BUNDLE_ROOT" -- "$REAL"'); + } else { + expect(write).toThrow("unsafe app-server launcher"); + expect(readFileSync(launcher, "utf8")).toBe("original"); + } + expect(readdirSync(dir)).toEqual(["chatgpt-codex-shim.sh"]); + } finally { removeTreeWithRetry(dir); } + }); +} diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 3376f412bf1..be2098b4109 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -613,6 +613,11 @@ "codex-exec-invocation.test.ts": "codex-integration", "exec-trust-boundary.test.ts": "codex-integration", "exec-boundary-launch.test.ts": "codex-integration", + "exec-launcher-boundary.test.ts": "codex-integration", + "exec-cli-renamed-app-bundle.test.ts": "codex-integration", + "exec-cli-bundle-containment.test.ts": "codex-integration", + "exec-renamed-app-bundle.test.ts": "codex-integration", + "desktop-app-server-exec-boundary.test.ts": "clients", "exec-known-app-bundles.test.ts": "codex-integration", "messaging-local-discovery.test.ts": "codex-integration", "messaging-local-cli.test.ts": "codex-integration", From eea1454782be464848b66e8a9483c453c2189a97 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 30 Oct 2026 12:10:47 +0000 Subject: [PATCH 60/84] fix(credentials): stop Windows ancestor-watch feedback loops (#7976) * fix(credentials): stop Windows ancestor-watch feedback loops * test(credentials): settle writes before hardlink replacement * fix(credentials): fold watcher names the way the volume resolves them A case-insensitive volume reports a notification under the writer's spelling. With same-identity ancestor ticks now ignored, an event for AUTH.JSON no longer had an incidental reconcile path. Compare target, ancestor and residue names case-insensitively with trailing dots and spaces removed, and always reconcile an 8.3 short name. * fix(credentials): identity-check ancestors whose names look like short names An ancestor such as OPENCODEX_HOME=C:\ocx~1 took the unconditional short-name reconcile, so the observer's own save could re-arm the loop. Only an unmatched alias is ambiguous. * fix(credentials): identity-check ancestors whose names look like write residue OPENCODEX_HOME=C:\config.json.ocx.home matched the residue pattern before the ancestor lookup, so the observer's own save re-armed the reconcile. Residue and short-name shapes now only decide names that match no target or ancestor. * fix(credentials): resolve unmatched short names to tracked ancestors by identity A Windows 8.3 alias such as OPENC~1 for a tracked ancestor took the unconditional short-name reconcile, so the observer's own save could re-arm the loop. A change that resolves to a tracked ancestor's recorded directory is that ancestor's tick; any other short name still reconciles. * fix(credentials): resolve unmatched short names against the install snapshot Windows may report either name of an entry, and a removed entry's short name cannot be looked up. An unmatched 8.3-shaped name now reconciles only when it aliases a target, when a target or ancestor of that parent lost its install identity, or when it renames a tracked ancestor; the observer's own metadata save under a short spelling is ignored. * docs(structure): state the transient short-name residual of the credential observer --------- Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> --- src/local-credentials/generation.ts | 8 +- src/local-credentials/inventory.ts | 140 ++++++++- structure/remote-link.md | 2 +- .../local-credential-generation.test.ts | 296 +++++++++++++++++- 4 files changed, 434 insertions(+), 12 deletions(-) diff --git a/src/local-credentials/generation.ts b/src/local-credentials/generation.ts index 0da2acb199c..1c370840f52 100644 --- a/src/local-credentials/generation.ts +++ b/src/local-credentials/generation.ts @@ -212,8 +212,12 @@ export function claimCredentialObservation(home: string, scan: (accounts: readon }); } /** - * Advance on every observed event and rescan; a failed scan leaves an empty vector, which never - * attests. It never waits for the fence: the runtime observer retries asynchronously instead. + * Advance on every observed coverage-relevant event and rescan; a failed scan leaves an empty + * vector, which never attests. It never waits for the fence: the runtime observer retries + * asynchronously instead. "Coverage-relevant" is the watcher's classification in inventory.ts: + * every event on a target name, credential-write residue, or ancestor structural change -- + * but not an ancestor directory's last-write tick from ordinary child churn, which reports no + * credential change and would re-arm the observer's own save forever. */ export function updateCredentialObservation(home: string, owner: string, scan: ((accounts: readonly string[]) => string) | null): void { withCredentialPublicationFence(home, () => { diff --git a/src/local-credentials/inventory.ts b/src/local-credentials/inventory.ts index 2f817b38811..c7349f1a175 100644 --- a/src/local-credentials/inventory.ts +++ b/src/local-credentials/inventory.ts @@ -40,6 +40,121 @@ function assertParents(path: string): void { if (p === parse(p).root) break; } } +/** Watch classification for one parent directory, per basenames seen there. */ +export type WatchEntry = { + /** + * Basenames that are coverage targets themselves: a file whose bytes are scanned or a + * directory whose whole listing is checked. Every event on these is coverage-relevant + * -- an in-place content change reconciles exactly like a rename, even when the scan + * vector ends up identical, because the advance is what proves the observation ran. + */ + names: Set; + /** + * Basenames watched only as ancestors of a deeper target, paired with the identity + * recorded at install. A "rename" always reconciles: a replacement is the signal these + * entries exist to catch. A "change" reconciles too unless the entry re-identifies as + * the same real directory -- on Windows a directory entry reports "change" whenever a + * child is added or removed, so this observer's own local-credentials.json save would + * otherwise re-arm the same reconcile forever. null means the ancestor could not be + * identified at install, and an unverifiable ancestor reconciles on any event. + */ + ancestors: Map; + /** + * Identity of each target name at install (watchEntryIdentity). An unmatched 8.3 short + * name is resolved against these: it reconciles when a target here changed or when the + * alias is a target. Absent means the entry is treated as unresolvable and reconciles. + */ + targetIdentities?: Map; +}; +const CREDENTIAL_TMP_NAME = /^(auth|config|codex-accounts)\.json\.ocx\./i; +/** + * Watcher names are compared the way a case-insensitive volume resolves them. Windows and + * default macOS volumes report a notification under the spelling the writer used, so a + * credential created as AUTH.JSON reaches the watcher under that name while the scan reads + * it as auth.json. Folding can only add reconciles on a case-sensitive volume, never drop one. + */ +function foldWatchName(name: string): string { + return name.toLowerCase().replace(/[. ]+$/, ""); +} +/** + * An 8.3 short name (AUTH~1.JSO, OPENC~1) cannot be mapped back to its long name by + * spelling; it is resolved by identity instead (see shortNameAction). + */ +const SHORT_NAME_ALIAS = /~\d/; +/** ":", "absent", or "unreadable" for one entry of a watched parent. */ +export function watchEntryIdentity(parent: string, child: string): string { + try { + const stat = lstatSync(join(parent, child), { bigint: true }); + return `${stat.dev}:${stat.ino}`; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "ENOENT" ? "absent" : "unreadable"; + } +} +/** + * Windows may report either name of an entry that has both, and a removed entry's short + * name can no longer be looked up. Such an event is coverage-relevant when a target or + * ancestor in this parent no longer has its install identity, or when the alias resolves to + * a target; a change that resolves to a tracked ancestor is that ancestor's tick. Anything + * else -- this observer's own metadata save under a short spelling -- changed no target. + */ +function shortNameAction(parent: string, entry: WatchEntry, event: string, name: string): WatchEventAction { + const recorded = entry.targetIdentities; + if (!recorded) return "reconcile"; + const resolved = watchEntryIdentity(parent, name); + if (resolved === "unreadable") return "reconcile"; + for (const [target, identity] of recorded) { + const now = watchEntryIdentity(parent, target); + if (now === "unreadable" || now !== identity) return "reconcile"; + if (resolved !== "absent" && resolved === now) return "reconcile"; + } + let aliasOfAncestor = false; + for (const [ancestorName, identity] of entry.ancestors) { + const now = ancestorIdentity(parent, ancestorName); + if (identity === null || now === null || now.dev !== identity.dev || now.ino !== identity.ino) return "reconcile"; + if (resolved === `${identity.dev}:${identity.ino}`) aliasOfAncestor = true; + } + return aliasOfAncestor && event !== "change" ? "reconcile" : "ignore"; +} +function foldedLookup(keys: Iterable, key: (item: T) => string, folded: string): T | undefined { + for (const item of keys) if (foldWatchName(key(item)) === folded) return item; + return undefined; +} +function ancestorIdentity(parent: string, child: string): { dev: bigint; ino: bigint } | null { + try { + const stat = lstatSync(join(parent, child), { bigint: true }); + return stat.isDirectory() && !stat.isSymbolicLink() ? { dev: stat.dev, ino: stat.ino } : null; + } catch { return null; } +} +type WatchEventAction = "fail" | "reconcile" | "ignore"; +/** + * The single (parent, event, name) decision behind the observation watchers, exported so + * tests can pin every branch without timing-dependent filesystem notifications. + */ +export function credentialWatchEventAction( + parent: string, entry: WatchEntry, event: string, name: string | null, +): WatchEventAction { + if (name === null) return "fail"; + const folded = foldWatchName(name); + if (foldedLookup(entry.names, n => n, folded) !== undefined) return "reconcile"; + const match = foldedLookup(entry.ancestors.entries(), ([n]) => n, folded); + // A known ancestor whose real name merely looks like write residue or a short name + // (C:\config.json.ocx.home, C:\ocx~1) goes through the identity check below. + if (match === undefined) { + if (CREDENTIAL_TMP_NAME.test(name)) return "reconcile"; + return SHORT_NAME_ALIAS.test(name) ? shortNameAction(parent, entry, event, name) : "ignore"; + } + const ancestor = match[1]; + // Renames (create/delete/move, including a same-identity rename) are structural and + // always reconcile; only "change" may be child-list noise from a same-identity dir. + if (event !== "change" || ancestor === null) return "reconcile"; + try { + const stat = lstatSync(join(parent, name), { bigint: true }); + if (stat.isDirectory() && !stat.isSymbolicLink() && stat.dev === ancestor.dev && stat.ino === ancestor.ino) { + return "ignore"; + } + } catch { /* an ancestor that cannot be re-identified cannot prove the tick is noise */ } + return "reconcile"; +} export function scanLocalCredentials(accounts: readonly string[] = []): { count: number; vector: string; targets: string[] } { const home = getConfigDir(); const codex = getCodexHome(); @@ -166,6 +281,10 @@ export function scanLocalCredentials(accounts: readonly string[] = []): { count: const profiles = countLocalNativeProfileSlots(vault, homeId); if (profiles.active && !main) throw new Error("Active native profile has no readable auth"); count += main + profiles.inactive; + // The whole listing is verified below, so the directory itself is a coverage target: + // an unknown create/delete must reach a watcher even when the child's own name is not. + targets.add(shared); + assertParents(shared); if (exists(shared)) { if (!lstatSync(shared).isDirectory() || lstatSync(shared).isSymbolicLink()) throw new Error("Unsafe native vault"); const expected = new Set([`${homeId}.vault.json`, `${homeId}.stages.json`]); @@ -223,23 +342,28 @@ export function startLocalCredentialObservation(): { stop(): void; reconcile(): } }; const install = (targets: string[]) => { - const watched = new Map>(); + const watched = new Map(); for (const target of targets) { + let leaf = true; for (let path = target; dirname(path) !== path; path = dirname(path)) { const parent = dirname(path); if (!exists(parent)) continue; assertParents(path); - const names = watched.get(parent) ?? new Set(); - names.add(path.slice(parent.length + (parent.endsWith("/") || parent.endsWith("\\") ? 0 : 1))); - watched.set(parent, names); + const entry = watched.get(parent) ?? { names: new Set(), ancestors: new Map(), targetIdentities: new Map() }; + const child = path.slice(parent.length + (parent.endsWith("/") || parent.endsWith("\\") ? 0 : 1)); + if (leaf) { entry.names.add(child); entry.targetIdentities?.set(child, watchEntryIdentity(parent, child)); } + else if (!entry.ancestors.has(child)) entry.ancestors.set(child, ancestorIdentity(parent, child)); + watched.set(parent, entry); + leaf = false; } } const next: FSWatcher[] = []; try { - for (const [parent, names] of watched) { - const watcher = watch(parent, { persistent: false }, (_event, name) => { - if (name === null) { fail(); return; } - if (names.has(String(name)) || /^(auth|config|codex-accounts)\.json\.ocx\./.test(String(name))) reconcile(); + for (const [parent, entry] of watched) { + const watcher = watch(parent, { persistent: false }, (event, name) => { + const action = credentialWatchEventAction(parent, entry, event, name === null ? null : String(name)); + if (action === "fail") fail(); + else if (action === "reconcile") reconcile(); }); watcher.on("error", fail); next.push(watcher); diff --git a/structure/remote-link.md b/structure/remote-link.md index f1b94aebbed..44f0dcb2004 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -119,7 +119,7 @@ The explicit `ocx link revoke --force --yes` branch in `src/cli/link.ts` sends o `ocx status --json` reports `localCredentials: {count, generation}` from `src/local-credentials/` on any runtime. `inventory.ts` counts this installation's stored provider credentials passively: OAuth accounts and the pre-multiauth backup, provider API-key slots including disabled entries and keychain, environment and GCP references, Codex pool records, the native Codex `auth.json` and inactive native profiles. It never counts link, management or incoming API keys, so a Child that holds only its link key reports zero, and accounts its Home serves are never local. A link-mode Child relays every model request to its Home and never dispatches to a provider itself, so a provider that needs no stored credential cannot serve a Child request and has nothing to count. An unreadable or unfamiliar source, a write residue, a pending native-profile transaction or a native Codex credential store other than `file` throws, and status omits the field instead of reporting zero. -`generation.ts` keeps `local-credentials.json` (epoch, sequence, pending tickets, touched keychain accounts and the observer's file-identity vector; never a credential value). Instrumented writers publish a ticket before credential bytes change and advance the sequence afterwards: `src/oauth/store.ts`, `src/codex/account-store.ts`, `src/config/persist-unlocked.ts`, `src/codex/main-account.ts`, the native-profile writers and the provider keychain factory in `src/providers/api-key-resolve.ts`. The token `lc1::` therefore changes on every add, replacement or removal, including one that ends at the same count. Every instrumented publication runs inside the credential-publication fence (`publication-fence.ts`), the OS lock of `local-credential-publication.sqlite`, separate from the config mutation lock: a writer holds it exclusively across its ticket, the write and the ticket's close, and fails if it cannot take it within ten seconds. Once coordination metadata exists, a publication whose ticket cannot be persisted (unreadable or unwritable metadata) aborts before it changes any credential bytes, so a completed write can never leave an earlier token attestable. Status holds it shared across both generation reads and the source scan, retries briefly while a publication holds it, and otherwise omits the field, so no snapshot can interleave with an instrumented write. Ticket and observer metadata are updated only under the fence, so contention on the config lock cannot drop a ticket. OS-store accounts are retained the same way: the observer seeds every `keychain:` and `gcp-sa:` account `config.json` references at start and on every reconcile, config persistence registers the references on both sides of a write before it lands (`references.ts`), and a keychain write or deletion registers its account before it is attempted. An account leaves the count only after a read proves it absent, so a secret whose config slot was removed while its deletion failed or never ran keeps counting. A proven absence is remembered in the metadata: a reference that still names that account is not registered again (it counts only as a configured slot while it stays in config), and only a new keychain write or deletion attempt for the account clears the mark. `restoreProviderKeyFromKeychain` in `src/providers/key-store.ts` saves the plaintext config before it deletes the OS entries; the other deletions remove secrets the config never referenced or run after the removal is committed. `runtime-observation.ts` gives `startServer` and `startClientRuntime` the same observer, which takes an OS lease (`observation-lease.ts`), watches the sources, rescans on every event and starts a new epoch at each runtime start; the client runtime releases it before a restart hands over to a replacement. Status attests only while that lease is held, no ticket is pending and the vector matches a fresh scan, so a stopped runtime, a lost watcher or an in-flight write omits the field. +`generation.ts` keeps `local-credentials.json` (epoch, sequence, pending tickets, touched keychain accounts and the observer's file-identity vector; never a credential value). Instrumented writers publish a ticket before credential bytes change and advance the sequence afterwards: `src/oauth/store.ts`, `src/codex/account-store.ts`, `src/config/persist-unlocked.ts`, `src/codex/main-account.ts`, the native-profile writers and the provider keychain factory in `src/providers/api-key-resolve.ts`. The token `lc1::` therefore changes on every add, replacement or removal, including one that ends at the same count. Every instrumented publication runs inside the credential-publication fence (`publication-fence.ts`), the OS lock of `local-credential-publication.sqlite`, separate from the config mutation lock: a writer holds it exclusively across its ticket, the write and the ticket's close, and fails if it cannot take it within ten seconds. Once coordination metadata exists, a publication whose ticket cannot be persisted (unreadable or unwritable metadata) aborts before it changes any credential bytes, so a completed write can never leave an earlier token attestable. Status holds it shared across both generation reads and the source scan, retries briefly while a publication holds it, and otherwise omits the field, so no snapshot can interleave with an instrumented write. Ticket and observer metadata are updated only under the fence, so contention on the config lock cannot drop a ticket. OS-store accounts are retained the same way: the observer seeds every `keychain:` and `gcp-sa:` account `config.json` references at start and on every reconcile, config persistence registers the references on both sides of a write before it lands (`references.ts`), and a keychain write or deletion registers its account before it is attempted. An account leaves the count only after a read proves it absent, so a secret whose config slot was removed while its deletion failed or never ran keeps counting. A proven absence is remembered in the metadata: a reference that still names that account is not registered again (it counts only as a configured slot while it stays in config), and only a new keychain write or deletion attempt for the account clears the mark. `restoreProviderKeyFromKeychain` in `src/providers/key-store.ts` saves the plaintext config before it deletes the OS entries; the other deletions remove secrets the config never referenced or run after the removal is committed. `runtime-observation.ts` gives `startServer` and `startClientRuntime` the same observer, which takes an OS lease (`observation-lease.ts`), watches the sources, rescans on every event and starts a new epoch at each runtime start; the client runtime releases it before a restart hands over to a replacement. Status attests only while that lease is held, no ticket is pending and the vector matches a fresh scan, so a stopped runtime, a lost watcher or an in-flight write omits the field. "Every event" above means every coverage-relevant event, classified per (watched parent, event, entry name) by `credentialWatchEventAction` in `inventory.ts`: an event on a scanned file, on a whole-listing-verified directory such as `.opencodex-native-main-profiles`, or on credential-write residue, and a `rename` on a tracked target or ancestor entry, always reconcile, while an ancestor directory's `change` is skipped only when the entry re-identifies as the same real directory recorded at install -- ordinary child churn, including this observer's own save, would otherwise re-arm the reconcile forever. Entry names are compared case-insensitively with trailing dots and spaces removed, as Windows and default macOS volumes resolve them, and a real ancestor whose name looks like a short name or like write residue keeps its identity check. A name that matches no target or ancestor but looks like an 8.3 short name (a tilde followed by a digit; Windows may report either name of an entry, and a removed entry's short name cannot be looked up) is resolved by identity against the install snapshot: it reconciles when the alias is a target, when any target or ancestor of that parent no longer has its install identity, or when it renames a tracked ancestor, and is otherwise ignored, so the observer's own save under a short spelling cannot re-arm the loop. The residual is transient: an add-then-remove of a target, or a rename-and-restore of an ancestor, that completes before its notifications are handled and whose notifications carry only short names that no longer resolve leaves the generation where it was. A differently spelled event on a credential path therefore never falls through as unrelated. A missing, replaced, symlinked or unidentifiable ancestor still reconciles, and a watcher error or a null event name fails the observation closed. The vault directory's target status means a child event inside it reaches the observer wherever the platform delivers one -- Windows reports the parent's last-write tick for child churn, while other platforms may deliver no event for the pair -- but the fail-closed part is platform-common: a fresh scan still throws on an unknown entry and status omits the attestation while it remains. There is no debounce, coalescing or scan-vector shortcut: every coverage-relevant event advances the generation even when the rescan is unchanged, because the advance is what proves the observation ran. A link-mode Child reports its current tunnel connection as `link.connectionGeneration` in a ready `/readyz`; `src/cli/status-link.ts` reads it from the live client runtime and omits it whenever that runtime does not answer ready with a valid value. Names have the form `:` and come from one process-wide sequence (`nextLinkConnectionGeneration` in `src/link/relay-auth.ts`), so a supervisor rebuilt in the same process, such as after a restart rollback, never repeats one. A Child-initiated link takes a new name from its supervisor on every transition into connected. For a Home-initiated link the Home supervisor names each ssh child it spawns, and its relay-auth answer carries that name with its own proof over the nonce, key and link ids (`linkConnectionProof` in `src/link/relay-auth.ts`), and the Child keeps the value from the latest authenticated answer (`src/client/link-connection.ts`). Every connect and every runtime restart changes it. An authenticated answer without a generation, or with one whose proof does not match, makes it unknown, so a Child that reconnects to an older Home never keeps reporting the previous connection. GOAT Ready requires client role, zero local credentials and equal generations before and after a completed Home inference; linked and healthy alone do not prove attribution. Coverage: `tests/cli/local-credentials.test.ts`, `tests/config/local-credential-generation.test.ts`, `tests/clients/link-connection-generation.test.ts`, `tests/clients/client-link-tunnel.test.ts`, `tests/clients/link-readiness.test.ts`. diff --git a/tests/config/local-credential-generation.test.ts b/tests/config/local-credential-generation.test.ts index 9eab3780211..135f669a358 100644 --- a/tests/config/local-credential-generation.test.ts +++ b/tests/config/local-credential-generation.test.ts @@ -1,7 +1,8 @@ import { expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; import { join } from "node:path"; -import { chmodSync, existsSync, readFileSync, unlinkSync } from "node:fs"; +import { chmodSync, existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { withLocalCredentialFixture } from "../helpers/local-credentials"; const accountStoreUrl = new URL("../../src/codex/account-store.ts", import.meta.url).href; @@ -294,3 +295,296 @@ for (const observedFirst of [true, false]) { }); }); } + +async function generationOf(inventory: typeof import("../../src/local-credentials/inventory")): Promise { + return (await inventory.collectLocalCredentials())?.generation; +} +async function waitForNewGeneration( + inventory: typeof import("../../src/local-credentials/inventory"), + previous: string | undefined, + timeoutMs = 4000, +): Promise { + const deadline = Date.now() + timeoutMs; + let next: string | undefined; + do { + next = await generationOf(inventory); + if (next !== undefined && next !== previous) return next; + if (Date.now() >= deadline) return next; + await new Promise(resolve => setTimeout(resolve, 40)); + } while (true); +} +async function expectStableGeneration( + inventory: typeof import("../../src/local-credentials/inventory"), + previous: string | undefined, + windowMs = 1500, +): Promise { + const deadline = Date.now() + windowMs; + while (Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, 60)); + expect(await generationOf(inventory)).toBe(previous); + } +} + +// Waits until the generation stays constant over one quiet window, tolerating +// residual notifications from the mutation that produced it. Throws rather than +// return an unsettled token, so a caller can never start the ABA on a baseline +// that was still racing events. +async function settleGeneration( + inventory: typeof import("../../src/local-credentials/inventory"), + quietMs = 500, + timeoutMs = 4000, +): Promise { + const deadline = Date.now() + timeoutMs; + let last = await generationOf(inventory); + let quietSince = Date.now(); + while (Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, 50)); + const next = await generationOf(inventory); + if (next !== last) { last = next; quietSince = Date.now(); continue; } + if (Date.now() - quietSince >= quietMs) return last; + } + throw new Error("credential generation never settled: notifications still arriving at timeout"); +} + +test("the watch seam classifies direct, ancestor, identity, and failure cases deterministically", async () => { + const inventory = await import("../../src/local-credentials/inventory"); + const dir = mkdtempSync(join(tmpdir(), "ocx-watch-seam-")); + try { + const ancestor = join(dir, "ancestor"); + const direct = join(dir, "direct"); + const foreign = join(dir, "foreign"); + mkdirSync(ancestor); mkdirSync(direct); mkdirSync(foreign); + const shortLooking = join(dir, "ocx~1"); + mkdirSync(shortLooking); + const residueLooking = join(dir, "config.json.ocx.home"); + mkdirSync(residueLooking); + writeFileSync(join(dir, "auth.json"), "{}"); + const same = lstatSync(ancestor, { bigint: true }); + const shortSame = lstatSync(shortLooking, { bigint: true }); + const residueSame = lstatSync(residueLooking, { bigint: true }); + const entry = { + names: new Set(["auth.json", "direct"]), + ancestors: new Map([ + ["ancestor", { dev: same.dev, ino: same.ino }], + ["ocx~1", { dev: shortSame.dev, ino: shortSame.ino }], + ["config.json.ocx.home", { dev: residueSame.dev, ino: residueSame.ino }], + ["unverifiable", null], + ]), + }; + const action = (event: string, name: string | null) => + inventory.credentialWatchEventAction(dir, entry, event, name); + + // A same-identity ancestor reporting only a last-write tick is ordinary child + // churn, however often it repeats: no reconcile, no save, no re-arm. + expect(action("change", "ancestor")).toBe("ignore"); + expect(action("change", "ancestor")).toBe("ignore"); + // A rename is structural even when the entry re-identifies the same afterwards. + expect(action("rename", "ancestor")).toBe("reconcile"); + // Direct names reconcile on any event, including a plain content change. + expect(action("change", "auth.json")).toBe("reconcile"); + expect(action("rename", "auth.json")).toBe("reconcile"); + expect(action("change", "direct")).toBe("reconcile"); + // A name carrying both roles stays direct: direct wins over ancestor. + const overlapping = { names: new Set(["ancestor"]), ancestors: entry.ancestors }; + expect(inventory.credentialWatchEventAction(dir, overlapping, "change", "ancestor")).toBe("reconcile"); + // Credential-write residue is always coverage-relevant. + expect(action("change", "config.json.ocx.7.tmp")).toBe("reconcile"); + // An ancestor that could not be identified at install reconciles on any event. + expect(action("change", "unverifiable")).toBe("reconcile"); + expect(action("rename", "unverifiable")).toBe("reconcile"); + // Unrelated names never reconcile. + expect(action("change", "foreign")).toBe("ignore"); + expect(action("rename", "unknown.tmp")).toBe("ignore"); + // A case-insensitive volume reports the writer's spelling: a differently cased or + // trailing-dot spelling of a target or its write residue must still reconcile rather + // than fall through as an unrelated name. + expect(action("rename", "AUTH.JSON")).toBe("reconcile"); + expect(action("change", "Auth.Json")).toBe("reconcile"); + expect(action("change", "auth.json.")).toBe("reconcile"); + expect(action("rename", "Direct")).toBe("reconcile"); + expect(action("rename", "CONFIG.JSON.OCX.7.tmp")).toBe("reconcile"); + // An entry without recorded target identities cannot resolve a short name: reconcile. + expect(action("rename", "AUTH~1.JSO")).toBe("reconcile"); + // A real ancestor whose name only looks like a short name (OPENCODEX_HOME=C:\ocx~1) is + // identity-checked like any other ancestor, so its own-save tick cannot re-arm the loop. + expect(action("change", "ocx~1")).toBe("ignore"); + expect(action("rename", "ocx~1")).toBe("reconcile"); + expect(action("change", "config.json.ocx.home")).toBe("ignore"); + expect(action("rename", "config.json.ocx.home")).toBe("reconcile"); + // A differently cased ancestor is still an ancestor: renames reconcile, and a change is + // ignored only when that spelling resolves to the recorded directory itself. + expect(action("rename", "ANCESTOR")).toBe("reconcile"); + const upper = (() => { try { return lstatSync(join(dir, "ANCESTOR"), { bigint: true }); } catch { return null; } })(); + const sameDir = upper !== null && upper.dev === same.dev && upper.ino === same.ino; + expect(action("change", "ANCESTOR")).toBe(sameDir ? "ignore" : "reconcile"); + // A different real directory at the same name: the original is kept aside so + // its inode stays allocated and the replacement provably has a new identity. + renameSync(ancestor, join(dir, "ancestor-aside")); + mkdirSync(ancestor); + expect(action("change", "ancestor")).toBe("reconcile"); + // Missing, and replaced by a link, cannot prove the tick is noise either. + rmSync(ancestor, { recursive: true, force: true }); + expect(action("change", "ancestor")).toBe("reconcile"); + symlinkSync(foreign, ancestor, "junction"); + expect(action("change", "ancestor")).toBe("reconcile"); + unlinkSync(ancestor); + rmSync(join(dir, "ancestor-aside"), { recursive: true, force: true }); + // A watcher overflow reports a null name and must fail closed. + expect(action("change", null)).toBe("fail"); + expect(action("rename", null)).toBe("fail"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("unmatched 8.3 short names resolve by identity against the install snapshot", async () => { + const inventory = await import("../../src/local-credentials/inventory"); + const dir = mkdtempSync(join(tmpdir(), "ocx-watch-short-")); + try { + // Literal tilde names stand in for the short spellings Windows may report for files. + mkdirSync(join(dir, "longancestor")); + writeFileSync(join(dir, "auth.json"), "{}"); + writeFileSync(join(dir, "localc~1.jso"), "{}"); + const ancestorStat = lstatSync(join(dir, "longancestor"), { bigint: true }); + const entry = { + names: new Set(["auth.json", "config.json"]), + ancestors: new Map([ + ["longancestor", { dev: ancestorStat.dev, ino: ancestorStat.ino }], + ]), + targetIdentities: new Map([ + ["auth.json", inventory.watchEntryIdentity(dir, "auth.json")], + ["config.json", inventory.watchEntryIdentity(dir, "config.json")], + ]), + }; + expect(entry.targetIdentities.get("config.json")).toBe("absent"); + const action = (event: string, name: string) => inventory.credentialWatchEventAction(dir, entry, event, name); + // The observer's own metadata save under a short spelling, live or already renamed + // away, changed no target or ancestor, so it must not re-arm the reconcile. + expect(action("change", "localc~1.jso")).toBe("ignore"); + expect(action("rename", "localc~2.tmp")).toBe("ignore"); + // A short alias of a tracked ancestor: its change is that ancestor's tick, a rename is + // structural. Whether LONGAN~1 resolves depends on the volume's 8.3 setting; either way + // the change cannot re-arm the loop. + const alias = (() => { try { return lstatSync(join(dir, "LONGAN~1"), { bigint: true }); } catch { return null; } })(); + const resolvesToAncestor = alias !== null && alias.dev === ancestorStat.dev && alias.ino === ancestorStat.ino; + expect(action("change", "LONGAN~1")).toBe("ignore"); + expect(action("rename", "LONGAN~1")).toBe(resolvesToAncestor ? "reconcile" : "ignore"); + // A short alias that resolves to a target is that target. + linkSync(join(dir, "auth.json"), join(dir, "authjs~1.jso")); + expect(action("change", "authjs~1.jso")).toBe("reconcile"); + unlinkSync(join(dir, "authjs~1.jso")); + // A removed or newly created target makes any unresolvable short name relevant. + writeFileSync(join(dir, "config.json"), "{}"); + expect(action("rename", "config~1.jso")).toBe("reconcile"); + unlinkSync(join(dir, "config.json")); + expect(action("rename", "config~1.jso")).toBe("ignore"); + unlinkSync(join(dir, "auth.json")); + expect(action("rename", "auth~1.jso")).toBe("reconcile"); + // A replaced ancestor does too. + writeFileSync(join(dir, "auth.json"), "{}"); + entry.targetIdentities.set("auth.json", inventory.watchEntryIdentity(dir, "auth.json")); + expect(action("rename", "other~1")).toBe("ignore"); + renameSync(join(dir, "longancestor"), join(dir, "aside")); + expect(action("change", "LONGAN~1")).toBe("reconcile"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("ordinary churn inside a watched ancestor does not re-arm the observer or advance the generation", async () => { + await withLocalCredentialFixture(async f => { + f.start(); + const before = await generationOf(f.inventory); + expect(before).toBeDefined(); + // Writes like runtime-port.json, journals, and this observer's own save tick the + // ancestor's last-write time on Windows; that tick must not re-arm a reconcile. + for (let i = 0; i < 6; i += 1) { + writeFileSync(join(f.home, `runtime-${i}.json`), "{}"); + if (i % 2 === 1) unlinkSync(join(f.home, `runtime-${i - 1}.json`)); + } + await expectStableGeneration(f.inventory, before); + // Suppression must not deafen the observer: a real target write still reconciles. + f.put(join(f.home, "config.json"), { model: "fixture" }); + const revived = await waitForNewGeneration(f.inventory, before); + expect(revived).toBeDefined(); + expect(revived).not.toBe(before); + }); +}); + +test("a same-identity rename of a watched ancestor still advances the generation", async () => { + await withLocalCredentialFixture(async f => { + f.start(); + const before = await generationOf(f.inventory); + expect(before).toBeDefined(); + const moved = `${f.codex}-moved`; + renameSync(f.codex, moved); + renameSync(moved, f.codex); + const after = await waitForNewGeneration(f.inventory, before); + expect(after).toBeDefined(); + expect(after).not.toBe(before); + }); +}); + +test("a direct target replacing itself with identical bytes still advances the generation", async () => { + await withLocalCredentialFixture(async f => { + f.start(); + const before = await generationOf(f.inventory); + expect(before).toBeDefined(); + const auth = join(f.codex, "auth.json"); + const shadow = join(f.codex, "auth-shadow.tmp"); + f.put(auth, { OPENAI_API_KEY: "fixture-native-key" }); + const writeObserved = await waitForNewGeneration(f.inventory, before); + expect(writeObserved).toBeDefined(); + const settled = await settleGeneration(f.inventory); + expect(settled).toBeDefined(); + // Rebind the directory entry to the same inode: the bytes stay identical, so + // only the observed event itself -- never content -- may advance coverage. + linkSync(auth, shadow); + unlinkSync(auth); + renameSync(shadow, auth); + const after = await waitForNewGeneration(f.inventory, settled); + expect(after).toBeDefined(); + expect(after).not.toBe(settled); + expect(readFileSync(auth, "utf8")).toBe(JSON.stringify({ OPENAI_API_KEY: "fixture-native-key" })); + }); +}); + +test("an unknown entry in the shared native-profile vault still reaches the observer", async () => { + await withLocalCredentialFixture(async f => { + const shared = join(f.codex, ".opencodex-native-main-profiles"); + // The vault listing itself is verified, so the directory must be a direct watch + // target; otherwise an unknown create/delete pair hides behind an ancestor tick. + expect(f.inventory.scanLocalCredentials().targets).toContain(shared); + f.start(); + const before = await generationOf(f.inventory); + expect(before).toBeDefined(); + mkdirSync(shared); + const created = await waitForNewGeneration(f.inventory, before); + expect(created).toBeDefined(); + // Let residual notifications from the mkdir drain: the ABA below must be + // measured against a settled token, not one still racing earlier events. + const settled = await settleGeneration(f.inventory); + expect(settled).toBeDefined(); + const vectorBefore = f.inventory.scanLocalCredentials().vector; + const unknown = join(shared, "unknown.json"); + writeFileSync(unknown, "{}"); + // Coverage fails closed while the entry is present: no attestation for an + // unverifiable vault listing. + expect(await f.inventory.collectLocalCredentials()).toBeUndefined(); + unlinkSync(unknown); + // The ABA pair leaves the vault listing -- and therefore the scan vector -- + // exactly as it was, so any advance can only come from observed events. + expect(f.inventory.scanLocalCredentials().vector).toBe(vectorBefore); + if (process.platform === "win32") { + // On Windows the pair ticks the vault's parent and is itself observed, so + // the previous token cannot be reused. Platforms that report no event for + // this pair cannot force the advance, so this is a win32-only check, not a + // platform-common verification. + const after = await waitForNewGeneration(f.inventory, settled); + expect(after).toBeDefined(); + expect(after).not.toBe(settled); + } else { + expect(await generationOf(f.inventory)).toBeDefined(); + } + }); +}); From 1eca7a32938507706321c10c8e9b7988fb5c525e Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 30 Oct 2026 21:20:05 +0900 Subject: [PATCH 61/84] fix(discovery): name the invalid model row and the static-list workaround (#7990) * fix(discovery): name the invalid model row and the static-list workaround Invalid upstream model rows still reject the whole discovery response, but the diagnostic now names the failing row index and reason without its contents. The catalog warning and the connection-test error explain that a new provider's selection stays pending and point at a static models list with liveModels: false. * fix(discovery): qualify the pending-selection hint for login connections * docs(structure): scope the invalid-row diagnostic to generic discovery --- src/codex/catalog/provider-models.ts | 3 +- src/providers/model-discovery-diagnostic.ts | 4 +++ src/providers/model-discovery.ts | 8 ++--- src/server/management/provider-routes.ts | 3 +- structure/providers-and-adapters.md | 2 +- .../anthropic2-discovery-ownership.test.ts | 12 ++++++++ .../provider-model-discovery-contract.test.ts | 29 ++++++++++++++++++- 7 files changed, 53 insertions(+), 8 deletions(-) create mode 100644 src/providers/model-discovery-diagnostic.ts diff --git a/src/codex/catalog/provider-models.ts b/src/codex/catalog/provider-models.ts index 04238755718..877e6b275b9 100644 --- a/src/codex/catalog/provider-models.ts +++ b/src/codex/catalog/provider-models.ts @@ -89,6 +89,7 @@ import { providerRedirectError, } from "../../lib/provider-outbound"; import { redactSecretString } from "../../lib/redact"; +import { invalidModelDiscoveryHint } from "../../providers/model-discovery-diagnostic"; import { extractProviderModelItems, isRegistryModelDiscoveryUrl, @@ -857,7 +858,7 @@ export async function fetchProviderModelsWithAuth( }; if (shouldLog) { console.warn( - `[opencodex] Provider model discovery for "${name}" ${diagnostic[extracted.reason]} [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`, + `[opencodex] Provider model discovery for "${name}" ${diagnostic[extracted.reason]} [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}]${extracted.reason === "invalid_shape" ? invalidModelDiscoveryHint("diagnostic" in extracted ? extracted.diagnostic : undefined) : "."}`, ); } return observed(models, "degraded"); diff --git a/src/providers/model-discovery-diagnostic.ts b/src/providers/model-discovery-diagnostic.ts new file mode 100644 index 00000000000..ea3623f0c84 --- /dev/null +++ b/src/providers/model-discovery-diagnostic.ts @@ -0,0 +1,4 @@ +/** Safe operator guidance for malformed upstream model lists. */ +export function invalidModelDiscoveryHint(diagnostic?: string): string { + return `${diagnostic ? ` (${diagnostic})` : ""}. A newly added API-key provider can keep its model selection pending while discovery fails; a static models list with liveModels: false avoids discovery for gateways publishing invalid rows.`; +} diff --git a/src/providers/model-discovery.ts b/src/providers/model-discovery.ts index c59c3df7e23..49065c4d74b 100644 --- a/src/providers/model-discovery.ts +++ b/src/providers/model-discovery.ts @@ -47,7 +47,7 @@ export type BoundedDiscoveryJsonResult = export type ProviderModelItemsResult = | { ok: true; items: ProviderModelsApiItem[]; rawCount: number } - | { ok: false; reason: "invalid_shape" | "too_many_models" }; + | { ok: false; reason: "invalid_shape" | "too_many_models"; diagnostic?: string }; export type ModelEnvelopeRowsResult = | { ok: true; rows: unknown[] } @@ -534,12 +534,12 @@ export function extractProviderModelItems( const preferred: ProviderModelsApiItem[] = []; const seen = new Set(); const idField = discovery.spec?.idField ?? "id"; - for (const raw of data) { + for (const [index, raw] of data.entries()) { if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { - return { ok: false, reason: "invalid_shape" }; + return { ok: false, reason: "invalid_shape", diagnostic: `row ${index} is not an object` }; } const id = (raw as Record)[idField]; - if (!isValidModelDiscoveryModelId(id)) return { ok: false, reason: "invalid_shape" }; + if (!isValidModelDiscoveryModelId(id)) return { ok: false, reason: "invalid_shape", diagnostic: `row ${index} has ${typeof id !== "string" ? "a non-string id" : !id.trim() ? "an empty id" : "an invalid id"}` }; const prefix = discovery.spec?.stripIdPrefix; let finalId = id; if (prefix && finalId.startsWith(prefix)) { diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 6e4826a959d..84d0e1752b5 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -63,6 +63,7 @@ import { import { shadowInterceptProviderDependency } from "./shadow-call-validation"; import { deriveProviderPresets, providerConfigSeed } from "../../providers/derive"; import { initializeProviderModelSelection } from "../../providers/initial-model-selection"; +import { invalidModelDiscoveryHint } from "../../providers/model-discovery-diagnostic"; import { effectiveGoogleMode, providerCodexAccountMode, providerMatchesRegistryTransport } from "../../providers/registry"; import { extractModelEnvelopeRows, @@ -1802,7 +1803,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { + const live = config(); + live.providers.vendor = { adapter: "openai-chat", authMode: "key", baseUrl: "https://vendor.example.test/v1", + apiKey: "synthetic-vendor-key", fetch: (async () => Response.json({ data: [{ id: "" }, { id: "valid" }] })) as typeof fetch } as OcxProviderConfig; + const result = await probe(live, "vendor"); + expect(result.ok).toBe(false); + expect(result.error).toContain("row 0 has an empty id"); + expect(result.error).toContain("pending"); + expect(result.error).toContain("liveModels: false"); + expect(result.error).toContain("models"); +}); diff --git a/tests/providers/provider-model-discovery-contract.test.ts b/tests/providers/provider-model-discovery-contract.test.ts index 3a7cf8aa303..6bd86b22c1c 100644 --- a/tests/providers/provider-model-discovery-contract.test.ts +++ b/tests/providers/provider-model-discovery-contract.test.ts @@ -647,10 +647,37 @@ describe("registry-owned provider model discovery", () => { }); for (const id of [" padded/model", "model\nwith-control"]) { expect(extractProviderModelItems({ data: [{ id }] }, discovery)) - .toEqual({ ok: false, reason: "invalid_shape" }); + .toEqual({ ok: false, reason: "invalid_shape", diagnostic: "row 0 has an invalid id" }); } }); + test("rejects an invalid row with a safe index and reason (#7910)", () => { + const discovery = resolveProviderModelDiscovery("unknown-provider", { + adapter: "openai-chat", baseUrl: "https://example.test/v1", + }); + expect(extractProviderModelItems({ data: [{ id: "", secret: "do-not-log" }, { id: "valid" }] }, discovery)) + .toEqual({ ok: false, reason: "invalid_shape", diagnostic: "row 0 has an empty id" }); + expect(extractProviderModelItems({ data: [{ id: "valid" }, null] }, discovery)) + .toEqual({ ok: false, reason: "invalid_shape", diagnostic: "row 1 is not an object" }); + expect(extractProviderModelItems({ data: [{ id: "valid" }, { id: 7 }] }, discovery)) + .toEqual({ ok: false, reason: "invalid_shape", diagnostic: "row 1 has a non-string id" }); + }); + + test("catalog warns safely and retains configured fallback for invalid rows (#7910)", async () => { + const warning = spyOn(console, "warn").mockImplementation(() => {}); + globalThis.fetch = (async () => Response.json({ data: [{ id: "", secret: "private-row-value" }, { id: "valid" }] })) as typeof fetch; + try { + const models = await gatherRoutedModels(togetherConfig({ models: ["fallback-model"], liveModels: true })); + expect(models.some(model => model.provider === "together" && model.id === "fallback-model")).toBe(true); + expect(models.some(model => model.provider === "together" && model.id === "valid")).toBe(false); + const message = warning.mock.calls.flat().join(" "); + expect(message).toContain("row 0 has an empty id"); + expect(message).toContain("pending"); + expect(message).toContain("liveModels: false"); + expect(message).not.toContain("private-row-value"); + } finally { warning.mockRestore(); } + }); + test("cloudflare-workers-ai resolves official search from the /ai/v1 base", () => { const url = resolveProviderModelDiscoveryUrl( "cloudflare-workers-ai", From 0c74947006604918a0821ba77463ce66cda095ba Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 30 Oct 2026 21:21:27 +0900 Subject: [PATCH 62/84] fix(codex): stop scoring an elapsed 5-hour window against a pool account (#7994) A known weekly or monthly score no longer counts a 5-hour reading whose valid reset has elapsed, so a higher-priority pool account is selected again without waiting for a WHAM re-probe. Missing or invalid resets keep the reading. Routing and the dashboard warning share hasElapsedShortWindow. Refs #7896. --- gui/src/codex-quota-utils.ts | 7 +- scripts/test-layout/layout.json | 2 +- src/codex/quota-types.ts | 5 + src/codex/routing/cooldown-math.ts | 5 +- structure/providers/openai-accounts.md | 2 +- .../codex-routing-elapsed-short-reset.test.ts | 92 +++++++++++++++++++ tests/fixtures/test-layout-expected.json | 2 +- .../gui/gui-codex-usage-score-parity.test.ts | 14 +++ 8 files changed, 121 insertions(+), 8 deletions(-) create mode 100644 tests/codex-integration/codex-routing-elapsed-short-reset.test.ts diff --git a/gui/src/codex-quota-utils.ts b/gui/src/codex-quota-utils.ts index 191f61ae866..0dcfcba023c 100644 --- a/gui/src/codex-quota-utils.ts +++ b/gui/src/codex-quota-utils.ts @@ -1,4 +1,4 @@ -import { isTerminalShortWindow } from "../../src/codex/quota-types"; +import { hasElapsedShortWindow, isTerminalShortWindow } from "../../src/codex/quota-types"; export interface AccountQuota { workbuddyCredits?: import("../../src/providers/workbuddy-credit-balance").WorkBuddyCreditBalance; @@ -66,7 +66,7 @@ export function normalizeQuotaForPlan(quota: AccountQuota | null, plan: string | * Evaluates governing quota windows based on the account's plan: * - For 30-day only plans (e.g. Free/Go), only the monthly window governs. * - For standard plans, weekly and monthly windows govern. - * - A known five-hour / short window refines a known governing long-window score. + * - A known five-hour / short window refines a known governing long-window score until its valid reset elapses. * - If no long window has been observed, an active terminal short burst (at 100%) acts as exhausted (100). * - Unknown or unprimed quota returns `null` so callers do not spuriously trigger threshold actions. */ @@ -104,6 +104,7 @@ export function computeCodexUsageScore( ? 100 : null; } - const values = finite(shortPercent) ? [...knownLong, shortPercent] : knownLong; + const values = finite(shortPercent) && !hasElapsedShortWindow(quota.fiveHourResetAt ?? quota.shortResetAt, now) + ? [...knownLong, shortPercent] : knownLong; return values.length ? Math.max(...values) : null; } diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index b80fd41ad6a..ef5c8df7fe2 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -705,7 +705,7 @@ "codex-restart-route.test.ts": "codex-integration", "codex-restore-app-rewrite.test.ts": "codex-integration", "codex-retained-root-serialization.test.ts": "codex-integration", - "codex-routing-cache-affinity-detour.test.ts": "codex-integration", + "codex-routing-cache-affinity-detour.test.ts": "codex-integration", "codex-routing-elapsed-short-reset.test.ts": "codex-integration", "codex-routing.test.ts": "codex-integration", "codex-runtime.test.ts": "codex-integration", "codex-runtime-macos-desktop.test.ts": "codex-integration", "codex-clamp-notice.test.ts": "codex-integration", "codex-runtime-wsl-desktop.test.ts": "codex-integration", diff --git a/src/codex/quota-types.ts b/src/codex/quota-types.ts index be1a0bb65c4..8a77c6d44fd 100644 --- a/src/codex/quota-types.ts +++ b/src/codex/quota-types.ts @@ -62,6 +62,11 @@ export function resetAtToMs(resetAt: number): number { return resetAt < RESET_AT_SECONDS_MAX ? resetAt * 1000 : resetAt; } +/** Only a valid reset at or before now proves a short-window reading has elapsed. */ +export function hasElapsedShortWindow(resetAt: number | undefined, now: number): boolean { + return typeof resetAt === "number" && Number.isFinite(resetAt) && resetAt > 0 && resetAtToMs(resetAt) <= now; +} + /** * A short-only reading that proves the account is blocked NOW. * diff --git a/src/codex/routing/cooldown-math.ts b/src/codex/routing/cooldown-math.ts index 515b4d9eb22..858d98b395f 100644 --- a/src/codex/routing/cooldown-math.ts +++ b/src/codex/routing/cooldown-math.ts @@ -3,7 +3,7 @@ import { CODEX_UNKNOWN_USAGE_SCORE, } from "../quota"; import { isThirtyDayOnlyCodexPlan } from "../plan"; -import { hasSpendableCodexCredits, isTerminalShortWindow, type CodexSpendableCredits } from "../quota-types"; +import { hasElapsedShortWindow, hasSpendableCodexCredits, isTerminalShortWindow, type CodexSpendableCredits } from "../quota-types"; import type { CodexQuotaScope } from "./health-store"; import type { TransientProbeGrant } from "./thread-affinity"; import type { MainQuotaDispatch } from "../main-account-cache"; @@ -136,7 +136,8 @@ export function computeCodexUsageScore(quota: { ? (allowCredits && hasSpendableCodexCredits(quota, now)) ? CODEX_EXHAUSTED_USAGE_PERCENT - 1 : CODEX_EXHAUSTED_USAGE_PERCENT : CODEX_UNKNOWN_USAGE_SCORE; } - const values = finite(quota.shortPercent) ? [...knownLong, quota.shortPercent] : knownLong; + const values = finite(quota.shortPercent) && !hasElapsedShortWindow(quota.shortResetAt, now) + ? [...knownLong, quota.shortPercent] : knownLong; const score = Math.max(...values); // Keep included-capacity accounts ahead of a paid overage account, but do not // classify the latter as exhausted solely from its subscription percentages. diff --git a/structure/providers/openai-accounts.md b/structure/providers/openai-accounts.md index 2b758db3ab3..4cb00c851b8 100644 --- a/structure/providers/openai-accounts.md +++ b/structure/providers/openai-accounts.md @@ -426,7 +426,7 @@ The Codex parser in `src/oauth/pool-kernel.ts` is reexported by the compatibilit The account-pool strategy control and `ocx account pool get openai strategy` summarize how the configured threshold applies to the active strategy. Manual-switch warnings use the routing usage -score. A reset-less terminal short window is current only when its `shortObservedAt` is not in the +score. A known long-window score excludes short usage when its valid reset has elapsed; absent or invalid resets retain it. A reset-less terminal short window is current only when its `shortObservedAt` is not in the future and is at most `TERMINAL_SHORT_WINDOW_FRESHNESS_MS` old; general `updatedAt` changes do not extend that observation. diff --git a/tests/codex-integration/codex-routing-elapsed-short-reset.test.ts b/tests/codex-integration/codex-routing-elapsed-short-reset.test.ts new file mode 100644 index 00000000000..b021b77f531 --- /dev/null +++ b/tests/codex-integration/codex-routing-elapsed-short-reset.test.ts @@ -0,0 +1,92 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { clearCodexUpstreamHealth, clearThreadAccountMap, resolveCodexAccountForThread } from "../../src/codex/routing"; +import { clearPoolRotationState } from "../../src/codex/pool-rotation"; +import { saveCodexAccountCredential } from "../../src/codex/account-store"; +import { clearAccountQuota, setAccountQuotaFromParsed, updateAccountQuota } from "../../src/codex/auth-api"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; + +// Lives beside codex-routing.test.ts, which sits at its file-size cap (#7896). +// A pool account whose 5-hour window filled while its weekly window was known kept +// scoring 100 after that window's reset passed, so a lower-priority account served +// every request until something happened to re-probe WHAM. + +const ICACLS_OK = { success: true, exitCode: 0, timedOut: false, stdout: "" }; +const NOW = 1_800_000_000_000; +let testDir = ""; +let previousOpencodexHome: string | undefined; +let previousCodexHome: string | undefined; + +function orderedConfig(): OcxConfig { + return { + providers: {}, + codexAccounts: [ + { id: "a", email: "a@test", isMain: false }, + { id: "b", email: "b@test", isMain: false }, + ], + activeCodexAccountId: "b", + codexAccountPriorities: { a: 1 }, + autoSwitchThreshold: 97, + } as OcxConfig; +} + +function saveTestCredential(id: string): void { + saveCodexAccountCredential(id, { + accessToken: "access-" + id, refreshToken: "refresh-" + id, + expiresAt: NOW + 5 * 60_000, chatgptAccountId: "acct-" + id, + }); +} + +describe("priority after an elapsed 5-hour reset (#7896)", () => { + beforeEach(() => { + previousOpencodexHome = process.env.OPENCODEX_HOME; + previousCodexHome = process.env.CODEX_HOME; + testDir = mkdtempSync(join(tmpdir(), "ocx-routing-elapsed-short-")); + setIcaclsRunnerForTests(() => ICACLS_OK); + setAsyncIcaclsRunnerForTests(async () => ICACLS_OK); + process.env.OPENCODEX_HOME = testDir; + process.env.CODEX_HOME = testDir; + clearThreadAccountMap(); + clearCodexUpstreamHealth(); + clearAccountQuota(); + clearPoolRotationState(); + saveTestCredential("a"); + saveTestCredential("b"); + }); + + afterEach(() => { + try { + clearAccountQuota(); + clearCodexUpstreamHealth(); + clearThreadAccountMap(); + clearPoolRotationState(); + } finally { + setIcaclsRunnerForTests(null); + setAsyncIcaclsRunnerForTests(null); + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + removeTreeWithRetry(testDir); + } + }); + + test("the higher tier returns once its known short reset has passed, without a fresh probe", () => { + const config = orderedConfig(); + setAccountQuotaFromParsed("a", { weeklyPercent: 20, shortPercent: 100, shortResetAt: NOW + 3_600_000 }); + updateAccountQuota("b", 10); + expect(resolveCodexAccountForThread(null, config, NOW)).toBe("b"); + expect(resolveCodexAccountForThread(null, config, NOW + 3_601_000)).toBe("a"); + }); + + test("a short reading without a reset still keeps the higher tier out", () => { + const config = orderedConfig(); + setAccountQuotaFromParsed("a", { weeklyPercent: 20, shortPercent: 100 }); + updateAccountQuota("b", 10); + expect(resolveCodexAccountForThread(null, config, NOW + 3_601_000)).toBe("b"); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index be2098b4109..e3ac7ff2a95 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -678,7 +678,7 @@ "codex-restart-contract-parity.test.ts": "codex-integration", "codex-restart-route.test.ts": "codex-integration", "codex-restore-app-rewrite.test.ts": "codex-integration", "codex-retained-root-serialization.test.ts": "codex-integration", - "codex-routing-cache-affinity-detour.test.ts": "codex-integration", "codex-routing.test.ts": "codex-integration", + "codex-routing-cache-affinity-detour.test.ts": "codex-integration", "codex-routing-elapsed-short-reset.test.ts": "codex-integration", "codex-routing.test.ts": "codex-integration", "codex-runtime.test.ts": "codex-integration", "codex-runtime-macos-desktop.test.ts": "codex-integration", "codex-runtime-wsl-desktop.test.ts": "codex-integration", "codex-clamp-notice.test.ts": "codex-integration", "codex-service-manager-probe-hardening.test.ts": "codex-integration", "codex-service-manager-probe.test.ts": "codex-integration", "codex-systemd-env-probe.test.ts": "codex-integration", diff --git a/tests/gui/gui-codex-usage-score-parity.test.ts b/tests/gui/gui-codex-usage-score-parity.test.ts index e72f550d9ab..7d1ee8a9a42 100644 --- a/tests/gui/gui-codex-usage-score-parity.test.ts +++ b/tests/gui/gui-codex-usage-score-parity.test.ts @@ -39,6 +39,20 @@ function expectSame(quota: AccountQuota, plan?: string | null): void { } describe("account-switch warning agrees with routing (#5045)", () => { + test("known long window ignores only valid elapsed short resets (#7896)", () => { + const future = NOW + 3_600_000; + const past = NOW - 1_000; + for (const [shortResetAt, expected] of [ + [future, 100], [future / 1000, 100], [past, 20], [past / 1000, 20], + [NOW, 20], [undefined, 100], [0, 100], [-1, 100], [NaN, 100], [Infinity, 100], + ] as const) { + const quota: AccountQuota = { weeklyPercent: 20, shortPercent: 100, shortResetAt, updatedAt: NOW }; + expect(guiScore(quota, null, NOW)).toBe(expected); + expect(routerScore(asRouterQuota(quota), null, NOW)).toBe(expected); + expect(guiScore({ weeklyPercent: 20, fiveHourPercent: 100, fiveHourResetAt: shortResetAt, updatedAt: NOW }, null, NOW)).toBe(expected); + } + }); + test("a terminal burst window reads the same in seconds and in milliseconds", () => { // Both units reach storage. Read as milliseconds, a seconds-form instant lands in 1970 and // every future reset looks elapsed — a check that passes its own test and does nothing. From b692a8a33399007baae29de60c6646da091497ea Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 30 Oct 2026 21:22:36 +0900 Subject: [PATCH 63/84] test(history): corrupt the index the health query plan actually reads (#7995) The routing-health integrity case corrupted idx_requests_model and expected the health query to hit it. The query can be served by the provider, model or timestamp index, and macOS's system SQLite picks a different one than Bun's bundled SQLite, so macOS 1/4 of lane=all run 41050393474 read healthy rows. Ask EXPLAIN QUERY PLAN which index this build uses and corrupt that one. Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> --- tests/usage/request-history-integrity.test.ts | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/tests/usage/request-history-integrity.test.ts b/tests/usage/request-history-integrity.test.ts index 3c824e918db..ac424842752 100644 --- a/tests/usage/request-history-integrity.test.ts +++ b/tests/usage/request-history-integrity.test.ts @@ -107,8 +107,8 @@ async function verifyInitial(checks: Check[]): Promise { await ready; } -function corruptModelIndex(handle: Database, path: string): void { - const { rootpage } = handle.query("SELECT rootpage FROM sqlite_master WHERE name = 'idx_requests_model'").get() as { rootpage: number }; +function corruptModelIndex(handle: Database, path: string, index = "idx_requests_model"): void { + const { rootpage } = handle.query("SELECT rootpage FROM sqlite_master WHERE name = ?").get(index) as { rootpage: number }; const { page_size: pageSize } = handle.query("PRAGMA page_size").get() as { page_size: number }; expect(rootpage).toBeGreaterThan(1); const checkpoint = handle.query("PRAGMA wal_checkpoint(TRUNCATE)").get() as { busy: number }; @@ -121,6 +121,22 @@ function corruptModelIndex(handle: Database, path: string): void { handle.exec("PRAGMA shrink_memory"); } +// Several indexes can serve the health query; which one SQLite's planner picks differs between +// builds (Bun bundles SQLite on Linux and Windows, macOS uses the system library). Corrupt the one +// this build actually reads instead of assuming the model index. +function healthQueryIndex(handle: Database): string { + // prepare + finalize: a query() statement stays cached on the handle and holds a table lock. + const statement = handle.prepare( + "EXPLAIN QUERY PLAN SELECT status FROM requests WHERE provider = ? AND model = ? AND timestamp >= ? ORDER BY timestamp DESC LIMIT ?", + ); + let plan: Array<{ detail: string }>; + try { plan = statement.all("a", "m1", 0, 1) as Array<{ detail: string }>; } + finally { statement.finalize(); } + const index = plan.map(row => /USING (?:COVERING )?INDEX (\w+)/.exec(row.detail)?.[1]).find(Boolean); + expect(index).toMatch(/^idx_requests_/); + return index!; +} + describe("request-history integrity lifecycle (#7966)", () => { test("real worker checks leave repeated page, row, status and routing reads free of full serving-thread scans", async () => { await existingIndex(); @@ -409,7 +425,7 @@ describe("request-history integrity lifecycle (#7966)", () => { clearHealthHistoryCacheForTests(); const handle = requestHistoryDb(); const canonical = readFileSync(usageLogPath()); - corruptModelIndex(handle, path); + corruptModelIndex(handle, path, healthQueryIndex(handle)); expect(healthEvidenceForCandidate(input)).toEqual({}); expect((await openRequestHistoryIndex()).indexedRows).toBe(5); expect(requestHistoryDb()).not.toBe(handle); From ba0556d001dbca25aaef46680a56fd3150c4b19c Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 30 Oct 2026 21:22:55 +0900 Subject: [PATCH 64/84] fix(codex): retire a pool account's carried 5-hour window when WHAM shows none (#7980) A complete, valid WHAM probe without a burst window now retires a pool account's carried 5-hour tuple, so a Plus to Pro upgrade no longer keeps the account at a stale limit. Retirement requires a live captured pool writer for the account, credential generation and history identity; partial or header observations, invalid or unparseable readings and an explicit allowed:false keep the tuple. Main behaviour is unchanged. Closes #7961. Co-authored-by: SupernovaTitanium <12254451+SupernovaTitanium@users.noreply.github.com> --- src/codex/auth-api/login-flow.ts | 4 +- src/codex/auth-api/pool-quota-probe.ts | 4 +- src/codex/quota.ts | 35 +++++- structure/dashboard-and-usage.md | 2 +- structure/providers/openai-tiers.md | 2 +- .../codex-credits-probes.test.ts | 113 +++++++++++++++++- .../main-quota-provenance.test.ts | 10 ++ 7 files changed, 160 insertions(+), 10 deletions(-) diff --git a/src/codex/auth-api/login-flow.ts b/src/codex/auth-api/login-flow.ts index 8b4547b8cc5..6cddd171904 100644 --- a/src/codex/auth-api/login-flow.ts +++ b/src/codex/auth-api/login-flow.ts @@ -1,6 +1,6 @@ import { withCodexAccountLogLabel } from "../account-label"; import { getCodexAccountCredential, markCodexAccountValidated, readCodexAccountRecord, saveCodexAccountCredential, CodexCredentialRefreshLockTimeoutError, CodexCredentialRefreshBusyError, CodexCredentialRefreshStaleError } from "../account-store"; -import { clearAccountQuota, isCodexQuotaExhausted, isValidWhamHistoryObservation, parseUsageQuota, setAccountQuotaFromParsed } from "../quota"; +import { clearAccountQuota, isCodexQuotaExhausted, parseUsageQuota, poolWhamPolicyObservation, setAccountQuotaFromParsed } from "../quota"; import type { StoredAccountQuota, WhamUsageResponse } from "../quota"; import { ConfigMutationLockError, withConfigMutationLockSync } from "../../config"; import { appendDefaultCodexAccountNamespace, codexAccountPickerEnabled } from "../account-namespaces"; @@ -278,7 +278,7 @@ export async function handleCodexAuthLoginStart(req: Request, config: OcxConfig, email = data.email?.trim() || email; plan = nonEmptyPlan(data.plan_type) ?? plan; quota = parseUsageQuota(data); - policyQuota = isValidWhamHistoryObservation(data) ? quota : null; + policyQuota = poolWhamPolicyObservation(data, quota); } } catch { /* wham fetch is non-blocking */ } // Reauth must refresh the same ChatGPT identity already bound to this pool slot. diff --git a/src/codex/auth-api/pool-quota-probe.ts b/src/codex/auth-api/pool-quota-probe.ts index abd6b2d5fe8..583fc24b6c6 100644 --- a/src/codex/auth-api/pool-quota-probe.ts +++ b/src/codex/auth-api/pool-quota-probe.ts @@ -3,7 +3,7 @@ import { fetchCodexUsage } from "../quota-query-backoff"; import type { CodexUsageOwner } from "../quota-query-backoff"; import { capturePoolQuotaWriter, getValidCodexToken, isCodexAccountGenerationLive, forceRefreshCodexPoolToken, markCodexAccountValidated, markCodexAccountValidationFailed, readCodexAccountRecord, isTerminalCodexPoolRefreshFailure, CodexCredentialGenerationConflictError, CodexCredentialRefreshLockTimeoutError, CodexCredentialRefreshBusyError, CodexCredentialRefreshStaleError, TokenRefreshError } from "../account-store"; import type { PoolQuotaWriter } from "../quota-types"; -import { isValidWhamHistoryObservation, getAccountQuota, isCompleteCodexQuotaRecoverySnapshot, parseUsageQuota, setAccountQuotaFromParsed } from "../quota"; +import { isValidWhamHistoryObservation, getAccountQuota, isCompleteCodexQuotaRecoverySnapshot, parseUsageQuota, poolWhamPolicyObservation, setAccountQuotaFromParsed } from "../quota"; import type { StoredAccountQuota, WhamUsageResponse } from "../quota"; import type { ManualResetRefreshLineage } from "../routing"; import { clearAccountNeedsReauth, markAccountNeedsReauth } from "../account-runtime-state"; @@ -377,7 +377,7 @@ export async function commitPoolQuotaResponse( return { quota: null, needsReauth: false, credentialGeneration: generation }; } const validPolicyObservation = isValidWhamHistoryObservation(data); - setAccountQuotaFromParsed(accountId, quota, writerGeneration, undefined, validPolicyObservation ? quota : null, + setAccountQuotaFromParsed(accountId, quota, writerGeneration, undefined, poolWhamPolicyObservation(data, quota), ctx.poolWriter && validPolicyObservation ? { writer: ctx.poolWriter, observedAt, source: "wham", raw: quota } : undefined, true); return { diff --git a/src/codex/quota.ts b/src/codex/quota.ts index fe2bbbc10ba..744e8127d7c 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -333,7 +333,10 @@ export function setAccountQuotaFromParsed( const mainDecisionGeneration = mainWriter && mainDecisionQuotaWriterGeneration(mainWriter); if (isMain && mainDecisionGeneration !== undefined && decisionRaw) publishCodexDecisionQuota(accountId, mainDecisionGeneration, decisionRaw, updatedAt, "codex-main", decisionObservationPartial); - const displayQuota: MainPolicyQuotaObservation = isMain && mainWriter && policyQuota?.shortWindowAbsent === true + // 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; const next = mergeAccountQuota(displayQuota, legacyExisting, updatedAt); @@ -934,7 +937,14 @@ export function parseMainPolicyUsageQuota(data: WhamUsageResponse): MainPolicyQu const windows = [data.rate_limit?.primary_window, data.rate_limit?.secondary_window, data.rate_limit?.tertiary_window]; if (windows.some(window => isInvalidPolicyUsagePercent(window?.used_percent))) return null; const quota = filterMainPolicyMonthlyQuota(parseUsageQuota(data), isThirtyDayOnlyCodexPlan(data.plan_type)); - const [primary, secondary, tertiary] = windows; + return quota ? withWhamShortWindowAbsence(data, quota) : null; +} + +/** Derive absence from complete WHAM topology without changing the display quota. */ +export function withWhamShortWindowAbsence( + data: WhamUsageResponse, quota: Omit, +): MainPolicyQuotaObservation { + const [primary, secondary, tertiary] = [data.rate_limit?.primary_window, data.rate_limit?.secondary_window, data.rate_limit?.tertiary_window]; // Primary and secondary must explicitly report their topology. WHAM also sends // two-window responses without tertiary; an explicit allowed verdict disambiguates those // from partial observations. A present tertiary still requires measured long/null evidence. @@ -942,7 +952,7 @@ export function parseMainPolicyUsageQuota(data: WhamUsageResponse): MainPolicyQu // carries a valid usage reading: a long window without used_percent leaves that // window's usage unknown, and unknown usage must never release a block. // Headers never supply this proof, and reset time alone still cannot release a block. - if (quota && (isMeasuredLongWindow(primary) + if ((isMeasuredLongWindow(primary) || (primary === null && isMeasuredLongWindow(secondary) && quota.weeklyPercent !== undefined)) && (secondary === null || isMeasuredLongWindow(secondary)) && (tertiary === null || isMeasuredLongWindow(tertiary) @@ -952,6 +962,25 @@ export function parseMainPolicyUsageQuota(data: WhamUsageResponse): MainPolicyQu return quota; } +/** + * Policy evidence for a stored pool account's WHAM read (pool probe or re-login): null when any + * reading is invalid. A complete long/null topology marks short-window absence (#7961) unless + * upstream explicitly denied the account, since a denial is never evidence that a block ended. + */ +export function poolWhamPolicyObservation( + data: WhamUsageResponse, quota: Omit | null, +): MainPolicyQuotaObservation | null { + if (!quota || !isValidWhamHistoryObservation(data)) return null; + // Retirement releases a block, so a present reading that does not parse is not "unknown". + const readings = [data.rate_limit?.primary_window, data.rate_limit?.secondary_window, data.rate_limit?.tertiary_window]; + for (const limit of Array.isArray(data.additional_rate_limits) ? data.additional_rate_limits : []) { + if (limit && typeof limit === "object") readings.push(limit.rate_limit?.primary_window, limit.rate_limit?.secondary_window); + } + 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); +} + function isMeasuredLongWindow(window: WhamUsageWindow | null | undefined): boolean { return isExplicitLongWindow(window) && normalizeUsagePercent(window?.used_percent) !== undefined; } diff --git a/structure/dashboard-and-usage.md b/structure/dashboard-and-usage.md index 960bbdc128c..17bc382a091 100644 --- a/structure/dashboard-and-usage.md +++ b/structure/dashboard-and-usage.md @@ -10,7 +10,7 @@ The dashboard is a local control surface, not a separate service. It should refl and catalog invariants documented in this folder rather than inventing parallel state. The sign-in form offers opt-in **Remember this device** for direct HTTPS dashboards or loopback, storing an HttpOnly device cookie plus a separate proof in this site's origin-scoped localStorage; neither is the admin token or works alone, cookies span ports of one hostname so the proof binds the dashboard origin (still prefer a dedicated hostname), and storage failure reports Remember unavailable. Both mobile and sidebar **Forget this device** actions use logout to revoke the record and derived sessions immediately after successful publication and remove the proof, retaining it on failure for retry (directory-entry crash durability is best effort); remembered sessions cannot approve GitHub stars, reset-credit consumption, prompt-layer edits, Meta Muse sign-in, desktop compatibility changes or quota validation, which require a fresh local or paired session, and session-change events update both surfaces under [GUI and management API](gui-and-management-api.md). Codex quota cards consume the display cache from `src/codex/quota.ts`. Partial refreshes remove -omitted short tuples after their reset deadline; validated main-window absence also removes +omitted short tuples after their reset deadline; validated main-window absence, or validated complete WHAM absence for a pool account (#7961), also removes future-reset short tuples. Main-account hard-lock evidence and reset-notification history follow separate retention rules in [OpenAI account modes](providers/openai-tiers.md#quota-cache-and-short-window-history). See [permanent refusal auto-pause](account-auto-pause.md). diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index a490d7db535..8f11ab67151 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. Coverage lives in +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), `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/tests/codex-integration/codex-credits-probes.test.ts b/tests/codex-integration/codex-credits-probes.test.ts index 71e7602d963..c45886cfe35 100644 --- a/tests/codex-integration/codex-credits-probes.test.ts +++ b/tests/codex-integration/codex-credits-probes.test.ts @@ -9,7 +9,7 @@ import { fetchMainAccountInfoSnapshot } from "../../src/codex/auth-api/main-acco import { commitPoolQuotaResponse } from "../../src/codex/auth-api/pool-quota-probe"; import { clearMainAccountInfoCache } from "../../src/codex/main-account-cache"; import { resetMainCodexAccountIdentityTrackingForTests } from "../../src/codex/account-lifecycle"; -import { clearAccountQuota } from "../../src/codex/quota"; +import { applyAccountQuotaFromUpstreamHeaders, clearAccountQuota, getAccountQuota, parseUsageQuota, poolWhamPolicyObservation, 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"; @@ -177,3 +177,114 @@ describe("pool credits publication", () => { expect(poolReads).toBe(1); }); }); + +describe("pool WHAM short-window retirement", () => { + const now = () => Math.floor(Date.now() / 1000); + const short = () => ({ used_percent: 100, limit_window_seconds: 18_000, reset_at: now() + 3600 }); + const weekly = () => ({ used_percent: 0, limit_window_seconds: 604_800, reset_at: now() + 604_800 }); + const observe = (primary: unknown, secondary: unknown, extra = {}) => Response.json({ + plan_type: "pro", rate_limit: { allowed: true, primary_window: primary, secondary_window: secondary }, ...extra, + }); + + test("complete weekly-only probe retires the carried short tuple", async () => { + savePool(); + const ctx = poolContext(); + await commitPoolQuotaResponse(observe(short(), weekly()), ctx); + const result = await commitPoolQuotaResponse(observe(weekly(), null), ctx); + expect(result.quota?.shortPercent).toBeUndefined(); + expect(result.quota?.shortResetAt).toBeUndefined(); + expect(result.quota?.weeklyPercent).toBe(0); + }); + + test("a Plus-style five-hour window remains present", async () => { + savePool(); + const ctx = poolContext(); + const result = await commitPoolQuotaResponse(observe(short(), weekly(), { plan_type: "plus" }), ctx); + expect(result.quota?.shortPercent).toBe(100); + expect(result.quota?.shortResetAt).toBeDefined(); + }); + + test("partial and invalid WHAM observations retain the carried tuple", async () => { + savePool(); + const ctx = poolContext(); + await commitPoolQuotaResponse(observe(short(), weekly()), ctx); + const partial = await commitPoolQuotaResponse(Response.json({ rate_limit: { primary_window: weekly() } }), ctx); + expect(partial.quota?.shortPercent).toBe(100); + const invalid = await commitPoolQuotaResponse(observe(weekly(), null, { + additional_rate_limits: [{ rate_limit: { primary_window: { used_percent: 101 } } }], + }), ctx); + expect(invalid.quota?.shortPercent).toBe(100); + }); + test("partial response headers do not retire a carried short window", async () => { + savePool(); + await commitPoolQuotaResponse(observe(short(), weekly()), poolContext()); + applyAccountQuotaFromUpstreamHeaders(account.id, new Headers({ "x-codex-primary-used-percent": "12" })); + expect(getAccountQuota(account.id)?.shortPercent).toBe(100); + }); + + test("monthly-only pool plans keep their monthly observation", async () => { + savePool(); + const ctx = poolContext(); + await commitPoolQuotaResponse(observe(short(), weekly()), ctx); + const monthly = { used_percent: 14, limit_window_seconds: 2_592_000, reset_at: now() + 2_592_000 }; + const result = await commitPoolQuotaResponse(observe(monthly, null, { plan_type: "go" }), ctx); + expect(result.quota?.shortPercent).toBeUndefined(); + expect(result.quota?.monthlyPercent).toBe(14); + }); + + test("a probe without a live writer for this account retains the carried tuple", async () => { + savePool(); + await commitPoolQuotaResponse(observe(short(), weekly()), poolContext()); + const missing = await commitPoolQuotaResponse(observe(weekly(), null), { ...poolContext(), poolWriter: undefined }); + expect(missing.quota?.shortPercent).toBe(100); + const live = poolContext().poolWriter!; + const stale = { ...live, historyIdentity: "00000000-0000-4000-8000-000000000000" }; + expect((await commitPoolQuotaResponse(observe(weekly(), null), { ...poolContext(), poolWriter: stale })).quota?.shortPercent).toBe(100); + const other = { ...live, accountId: "other-pool" }; + expect((await commitPoolQuotaResponse(observe(weekly(), null), { ...poolContext(), poolWriter: other })).quota?.shortPercent).toBe(100); + expect((await commitPoolQuotaResponse(observe(weekly(), null), poolContext())).quota?.shortPercent).toBeUndefined(); + }); + test("an explicit upstream denial never retires the carried tuple", async () => { + savePool(); + const ctx = poolContext(); + await commitPoolQuotaResponse(observe(short(), weekly()), ctx); + const denied = await commitPoolQuotaResponse(Response.json({ plan_type: "pro", rate_limit: { + allowed: false, primary_window: weekly(), secondary_window: null, tertiary_window: null, + } }), ctx); + expect(denied.quota?.shortPercent).toBe(100); + }); + + test("pool policy evidence marks absence only for a valid, allowed, complete topology", () => { + const evidence = (data: WhamUsageResponse) => poolWhamPolicyObservation(data, parseUsageQuota(data)); + const weeklyOnly = { rate_limit: { allowed: true, primary_window: weekly(), secondary_window: null } }; + expect(evidence(weeklyOnly)?.shortWindowAbsent).toBe(true); + expect(evidence({ rate_limit: { ...weeklyOnly.rate_limit, allowed: false, tertiary_window: null } })?.shortWindowAbsent).toBeUndefined(); + expect(evidence({ rate_limit: { allowed: true, primary_window: short(), secondary_window: weekly() } })?.shortWindowAbsent).toBeUndefined(); + expect(evidence({ rate_limit: { primary_window: weekly() } })?.shortWindowAbsent).toBeUndefined(); + expect(evidence({ ...weeklyOnly, additional_rate_limits: [{ rate_limit: { primary_window: { used_percent: 101 } } }] })).toBeNull(); + }); + + test("a malformed usage reading keeps the carried tuple and its reset", async () => { + savePool(); + const ctx = poolContext(); + const first = await commitPoolQuotaResponse(observe(short(), weekly()), ctx); + for (const used_percent of ["unreadable", true, {}]) { + const result = await commitPoolQuotaResponse(observe(weekly(), null, { + additional_rate_limits: [{ rate_limit: { primary_window: { used_percent, limit_window_seconds: 604_800 } } }], + }), ctx); + expect(result.quota?.shortPercent).toBe(100); + expect(result.quota?.shortResetAt).toBe(first.quota?.shortResetAt); + } + }); + + test("a writer captured before a credential rollover cannot retire the tuple", async () => { + savePool(); + await commitPoolQuotaResponse(observe(short(), weekly()), poolContext()); + const before = poolContext().poolWriter!; + savePool(); + const after = poolContext(); + expect(after.generation).not.toBe(before.credentialGeneration); + const replay = await commitPoolQuotaResponse(observe(weekly(), null), { ...after, poolWriter: before }); + expect(replay.quota?.shortPercent).toBe(100); + }); +}); diff --git a/tests/codex-integration/main-quota-provenance.test.ts b/tests/codex-integration/main-quota-provenance.test.ts index af5ae6339e2..5494cd2c29d 100644 --- a/tests/codex-integration/main-quota-provenance.test.ts +++ b/tests/codex-integration/main-quota-provenance.test.ts @@ -658,3 +658,13 @@ test("native main observations and oversized cache never become pool history", ( } }, padding: "x".repeat(4 * 1024 * 1024) })); expect(getAccountQuotaHistory("history-pool").observations).toEqual([]); }); + + +test("main short window is retained without a main writer even with WHAM absence evidence", () => { + const short = { shortPercent: 100, shortResetAt: Date.now() / 1000 + 3600 }; + setAccountQuotaFromParsed(MAIN, short); + const data: WhamUsageResponse = { rate_limit: { allowed: true, + primary_window: { used_percent: 0, limit_window_seconds: 604_800 }, secondary_window: null } }; + setAccountQuotaFromParsed(MAIN, parseUsageQuota(data), undefined, undefined, parseMainPolicyUsageQuota(data)); + expect(getAccountQuota(MAIN)?.shortPercent).toBe(100); +}); From 03d8bd0ce9dd707e1cfb0ac678b4a53f02f813d1 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 30 Oct 2026 21:24:34 +0900 Subject: [PATCH 65/84] fix(claude): keep web_search native when no sidecar backend can run (#7991) * fix(claude): preserve native search without sidecar * fix(claude): preserve search probe affinity and cancellation * fix(claude): stop the search probe when refresh wraps a cancellation --- src/server/claude-messages.ts | 77 +++++++++ src/server/messages-native-eligibility.ts | 11 +- structure/data-planes/protocol-paths.md | 8 +- .../claude-translated-server-tools.test.ts | 160 +++++++++++++++++- .../messages-native-bridge-policy.test.ts | 11 ++ 5 files changed, 250 insertions(+), 17 deletions(-) diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 2c26e6e6e32..a2d6b25c676 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -970,6 +970,83 @@ async function handleClaudeMessagesWithBudget( ...(settledRoute?.provider.authMode === "oauth" && settledInstance ? { oauthFailoverQuorum: anthropicRoutingFor(settledInstance).hasAnthropicFailoverQuorum() } : {}), }; + if (settledRoute && isRec(anthropicBody) + && nativeMessagesDeclineReason(settledRoute, anthropicBody, config, nativeSelector) === "bridge-only-policy" + && nativeMessagesDeclineReason(settledRoute, anthropicBody, config, { ...nativeSelector, webSearchBackendAvailable: false }) === undefined) { + const { parseRequest } = await import("../responses/parser"); + const { planWebSearch, shouldResolveOpenAiWebSearchSidecar } = await import("../web-search"); + const replayConfig = buildClaudeReplayConfig(config); + const parsed = parseRequest(internalBody); + let openAiSidecar: import("../providers/openai-sidecar").ResolvedOpenAiForwardSidecar | undefined; + if (shouldResolveOpenAiWebSearchSidecar(replayConfig, parsed, false)) { + const { listOpenAiForwardSidecarCandidates, captureExplicitOpenAiCallerAuth, resolveFirstUsableOpenAiSidecar } = await import("../providers/openai-sidecar"); + const candidates = listOpenAiForwardSidecarCandidates(replayConfig); + const headers = new Headers(); + for (const name of FORWARD_HEADERS) { + if (name === "authorization" || name === "chatgpt-account-id") continue; + const value = req.headers.get(name); + if (value) headers.set(name, value); + } + const { codexAccountSelectionForTurn, tryAdmitTurn } = await import("./lifecycle"); + // The probe owns its claim only until selection completes, not for the client turn. + const probeLease = logIds?.turnAdmissionLease && candidates.length ? tryAdmitTurn() : undefined; + const errors = await import("../codex/auth-context"); + try { + req.signal.throwIfAborted(); + if (logIds?.turnAdmissionLease && candidates.length && !probeLease) { + // No independent admission means availability is unknown; preserve the bridge. + nativeSelector.webSearchBackendAvailable = true; + } else { + if (candidates.some(candidate => candidate.accountMode === "direct") && probeLease) { + if (!tryClaimNativeMainProfileForTurn(probeLease)) { + nativeSelector.webSearchBackendAvailable = true; + } else { + const { getMainAccountToken } = await import("../codex/main-account"); + const token = getMainAccountToken(); + if (token) { + const auth = captureExplicitOpenAiCallerAuth(new Headers({ authorization: `Bearer ${token.accessToken}`, "chatgpt-account-id": token.chatgptAccountId }), replayConfig); + if (auth) { headers.set("authorization", auth.authorization); headers.set("chatgpt-account-id", auth.chatgptAccountId); } + } + } + } + openAiSidecar = await resolveFirstUsableOpenAiSidecar(candidates, headers, replayConfig, { + admission: logIds?.admission, codexAuthPolicy: config, signal: req.signal, + beginCodexAccountSelection: codexAccountSelectionForTurn(probeLease ?? undefined), + }); + req.signal.throwIfAborted(); + // The bridge can inject main auth into pool selection. Only a negative pool probe + // needs this extra read; an unavailable claim or present token leaves it uncertain. + if (!openAiSidecar && probeLease && candidates.some(candidate => candidate.accountMode === "pool")) { + if (!tryClaimNativeMainProfileForTurn(probeLease)) nativeSelector.webSearchBackendAvailable = true; + else { + const { getMainAccountToken } = await import("../codex/main-account"); + if (getMainAccountToken()) nativeSelector.webSearchBackendAvailable = true; + } + } + } + } catch (err) { + // Refresh wraps cancellation in CodexAuthContextError; a cancelled request must not continue. + if (req.signal.aborted) { + openAiSidecar?.releaseProbeLease?.(); + throw req.signal.reason ?? err; + } + if (!(err instanceof errors.CodexPoolAuthenticationError) && !(err instanceof errors.CodexAuthContextError) + && !(err instanceof errors.CodexAccountCooldownError) && !(err instanceof errors.CodexThreadAffinityExpiredError) + && !(err instanceof errors.CodexMainProfileDrainingError)) { + openAiSidecar?.releaseProbeLease?.(); + throw err; + } + if (probeLease && candidates.some(candidate => candidate.accountMode === "pool")) nativeSelector.webSearchBackendAvailable = true; + } finally { probeLease?.release(); } + } + try { + nativeSelector.webSearchBackendAvailable ??= !!planWebSearch(replayConfig, parsed, false, settledRoute.provider, settledRoute.modelId, openAiSidecar, { + admission: logIds?.admission, codexAuthPolicy: config, providerName: settledRoute.providerName, + }); + } finally { + openAiSidecar?.releaseProbeLease?.(); + } + } const nativeDecline = settledRoute && isRec(anthropicBody) ? nativeMessagesDeclineReason(settledRoute, anthropicBody, config, nativeSelector) : "rollout-disabled"; diff --git a/src/server/messages-native-eligibility.ts b/src/server/messages-native-eligibility.ts index bf878853d6b..79fc3fb56fe 100644 --- a/src/server/messages-native-eligibility.ts +++ b/src/server/messages-native-eligibility.ts @@ -2,8 +2,8 @@ * Whether a settled Messages route may take the managed native Messages lane (PF-08). * * PURE: reads the route, the body's structure and config, and nothing else. The Messages ingress, - * `count_tokens` and the protocol planner (`src/protocols/plan-snapshot.ts`) all ask here, so a - * preview, a count and the real send cannot disagree about the lane. + * `count_tokens` and the protocol planner (`src/protocols/plan-snapshot.ts`) all ask here; + * previews and counts remain conservative when runtime availability is unknown. */ import { anthropicBodyElidesBlockedSkill } from "../claude/inbound"; import { isClaudeWebSearchToolName } from "../claude/outbound"; @@ -48,6 +48,8 @@ export interface NativeMessagesSelector { * and judges OAuth from config alone. */ oauthFailoverQuorum?: boolean; + /** Runtime sidecar plan availability; absent for config-only callers. */ + webSearchBackendAvailable?: boolean; } type Rec = Record; @@ -59,8 +61,7 @@ function isRec(value: unknown): value is Rec { * Whether the bridge could hand this request to the web-search sidecar: a `web_search*` server * tool the tool choice does not exclude, with the sidecar not disabled in the Claude replay config * (`buildClaudeReplayConfig` merges `claudeCode.webSearchSidecar` over the global block). Whether - * a backend credential then exists is decided at dispatch and is not read here, so this errs - * toward the bridge, which is today's behavior for every such request. + * a backend credential exists is supplied separately by the sending ingress. */ function webSearchSidecarMayEngage(body: Readonly, config: OcxConfig): boolean { const sidecar = { ...config.webSearchSidecar, ...config.claudeCode?.webSearchSidecar }; @@ -89,7 +90,7 @@ function bridgeOnlyPolicyApplies( ): boolean { if (resolvePinnedEffort(route, selector.routeSelector, config) !== undefined) return true; if (anthropicBodyElidesBlockedSkill(body, selector.claudeCode ?? config.claudeCode)) return true; - return webSearchSidecarMayEngage(body, config); + return selector.webSearchBackendAvailable !== false && webSearchSidecarMayEngage(body, config); } /** diff --git a/structure/data-planes/protocol-paths.md b/structure/data-planes/protocol-paths.md index 44439672f09..277fbcc2d4b 100644 --- a/structure/data-planes/protocol-paths.md +++ b/structure/data-planes/protocol-paths.md @@ -373,10 +373,10 @@ it), and `bridge-only-policy` when operator policy that only the translated path engage: a pinned reasoning effort for the route (`resolvePinnedEffort`, read with the translated body's model id as the bridge reads it), a blocked-skill bundle the translator would elide (`anthropicBodyElidesBlockedSkill` in `src/claude/inbound.ts`), or a `web_search*` server tool the -web-search sidecar could serve (not excluded by `tool_choice`, sidecar not disabled in the Claude -replay config; backend credentials are decided at dispatch, so this errs toward the bridge). The -ingress, `count_tokens` and the planner all ask it. The planner can judge only the config-and-route -parts: skill elision and web search depend on body content no feature describes. +web-search sidecar could serve (not excluded by `tool_choice`, sidecar enabled). The cancellable ingress +probe uses the bridge's credential-free affinity headers, existing plan and authorized backend, with a +short-lived selection lease; uncertain admission keeps the bridge. Without a runnable plan search alone +does not decline native Messages. `count_tokens` and the planner lack runtime facts and remain conservative. With the switch on, a declined route re-marks its bridge entry with the decline reason (after any `effort-row` / `fast-row`), so the trace says why the bridge was taken; with it off nothing is diff --git a/tests/claude-integration/claude-translated-server-tools.test.ts b/tests/claude-integration/claude-translated-server-tools.test.ts index 9344f8d8ca8..267f534d2a0 100644 --- a/tests/claude-integration/claude-translated-server-tools.test.ts +++ b/tests/claude-integration/claude-translated-server-tools.test.ts @@ -1,10 +1,19 @@ -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createAnthropicAdapter } from "../../src/adapters/anthropic"; import { webSearchPairFromItem } from "../../src/claude/outbound"; import { handleClaudeMessages } from "../../src/server/claude-messages"; +import * as openAiSidecar from "../../src/providers/openai-sidecar"; +import * as mainAdmission from "../../src/codex/native-main-admission"; +import * as mainAccount from "../../src/codex/main-account"; +import { CodexAuthContextError, codexPoolAffinityKey } from "../../src/codex/auth-context"; +import { saveCodexAccountCredential } from "../../src/codex/account-store"; +import { updateAccountQuota, clearAccountQuota } from "../../src/codex/quota"; +import { bindThreadAffinity, CODEX_THREAD_AFFINITY_IDLE_TTL_MS } from "../../src/codex/routing/thread-affinity"; +import { clearThreadAccountMap, clearCodexUpstreamHealth } from "../../src/codex/routing"; +import { getNativeMainProfileRequestCount, tryAdmitTurn } from "../../src/server/lifecycle"; import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../../src/types"; import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -68,10 +77,11 @@ async function claudeTurn( frames: Record[], target: "anth" | "gpt" = "anth", messages: unknown[] = [{ role: "user", content: "What did Bun release? https://bun.sh/blog" }], - { stream = true, direct = false }: { stream?: boolean; direct?: boolean } = {}, + { stream = true, direct = false, native = false, searchBackend, searchTool = WEB_SEARCH, pool = false, directSearch = false, requestHeaders = {}, signal, onUpstream, lease }: { stream?: boolean; direct?: boolean; native?: boolean; searchBackend?: { backend: "exa"; exaApiKey: string }; searchTool?: typeof WEB_SEARCH & { allowed_domains?: string[] }; pool?: boolean; directSearch?: boolean; requestHeaders?: Record; signal?: AbortSignal; onUpstream?: () => void; lease?: ReturnType } = {}, ): Promise<{ wire: Record; clientSse: string }> { const wires: string[] = []; const fetch = (async (_input: unknown, init?: RequestInit) => { + onUpstream?.(); wires.push(String(init?.body)); if (target === "gpt") { return new Response(`data: ${JSON.stringify({ id: "c", object: "chat.completion.chunk", choices: [{ index: 0, delta: { content: "ok" }, finish_reason: "stop" }] })}\n\ndata: [DONE]\n\n`, @@ -81,12 +91,14 @@ async function claudeTurn( }) as typeof globalThis.fetch; const config = { port: 0, defaultProvider: "anth", + ...(pool ? { codexAccounts: [{ id: "fixture-a", email: "a@example.test", isMain: false }, { id: "fixture-b", email: "b@example.test", isMain: false }], activeCodexAccountId: "fixture-b" } : {}), claudeCode: { nativePassthrough: false }, - webSearchSidecar: { enabled: false }, - ...(direct ? { protocols: { rollout: { directEncoders: true } } } : {}), + ...(searchBackend ? { webSearchSidecar: searchBackend } : native ? {} : { webSearchSidecar: { enabled: false } }), + ...(direct || native ? { protocols: { rollout: { directEncoders: direct, managedMessagesNative: native } } } : {}), providers: { anth: { adapter: "anthropic", baseUrl, authMode: "key", apiKey: "sk-ant-fixture", models: [MODEL], fetch }, gpt: { adapter: "openai-chat", baseUrl: "https://gpt.invalid/v1", authMode: "key", apiKey: "sk-fixture", models: ["g"], fetch }, + ...(pool ? { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: directSearch ? "direct" : "pool" } } : {}), }, combos: { waterfall: { strategy: "failover", targets: target === "anth" ? [{ provider: "anth", model: MODEL }, { provider: "gpt", model: "g" }] @@ -94,13 +106,13 @@ async function claudeTurn( } as unknown as OcxConfig; const response = await handleClaudeMessages(new Request("http://localhost/v1/messages", { method: "POST", - headers: { "content-type": "application/json" }, + headers: { "content-type": "application/json", ...requestHeaders }, signal, body: JSON.stringify({ - model: direct ? `anth/${MODEL}` : "combo/waterfall", max_tokens: 64, stream, - tools: [{ name: "Read", description: "read", input_schema: { type: "object", properties: {} } }, WEB_SEARCH, WEB_FETCH], + model: direct || native ? `anth/${MODEL}` : "combo/waterfall", max_tokens: 64, stream, + tools: [{ name: "Read", description: "read", input_schema: { type: "object", properties: {} } }, searchTool, WEB_FETCH], messages, }), - }), config, { model: "", provider: "" }); + }), config, { model: "", provider: "" }, lease ? { requestId: "fixture", start: Date.now(), turnAdmissionLease: lease } : undefined); expect(response.status).toBe(200); const clientSse = await response.text(); expect(wires).toHaveLength(1); @@ -112,6 +124,138 @@ function clientFrames(text: string): Record[] { } describe("translated Claude lane carries typed web tools to Anthropic", () => { + for (const expired of [false, true]) { + test(`real pool resolves an ${expired ? "expired" : "available"} affinity binding`, async () => { + clearThreadAccountMap(); clearCodexUpstreamHealth(); clearAccountQuota(); + for (const id of ["fixture-a", "fixture-b"]) { + saveCodexAccountCredential(id, { accessToken: `access-${id}`, refreshToken: `refresh-${id}`, expiresAt: Date.now() + 3_600_000, chatgptAccountId: `account-${id}` }); + updateAccountQuota(id, 10, 10); + } + const requestHeaders = { "session-id": `real-${expired}`, "thread-id": "child", "x-codex-parent-thread-id": "parent" }; + const key = codexPoolAffinityKey(new Headers(requestHeaders))!; + bindThreadAffinity(key, "fixture-a", Date.now() - (expired ? CODEX_THREAD_AFFINITY_IDLE_TTL_MS + 1 : 0)); + const realResolver = openAiSidecar.resolveFirstUsableOpenAiSidecar; + let observed: string | undefined; + const resolver = spyOn(openAiSidecar, "resolveFirstUsableOpenAiSidecar").mockImplementation(async (...args) => { + const selected = await realResolver(...args); + if (!observed) observed = selected?.authContext.accountId ?? undefined; + return selected; + }); + const network = spyOn(globalThis, "fetch").mockImplementation(async () => { throw new Error("unexpected pool network request"); }); + try { + const { wire } = await claudeTurn("https://compat.example.com", OK_TURN, "anth", undefined, { native: true, pool: true, requestHeaders }); + expect(observed).toBe(expired ? undefined : "fixture-a"); + expect((wire.tools as Record[]).some(t => t.type === WEB_SEARCH.type)).toBe(expired); + expect(network).not.toHaveBeenCalled(); + } finally { network.mockRestore(); resolver.mockRestore(); clearThreadAccountMap(); clearCodexUpstreamHealth(); clearAccountQuota(); } + }); + } + for (const available of [true, false]) { + test(`pool probe uses the pinned affinity when ${available ? "available" : "unavailable"}`, async () => { + const requestHeaders = { "session-id": "fixture-session", "thread-id": "fixture-child", "x-codex-parent-thread-id": "fixture-parent", authorization: "Bearer client-secret" }; + const expectedHeaders = new Headers(requestHeaders); expectedHeaders.delete("authorization"); + const key = codexPoolAffinityKey(expectedHeaders); + const resolver = spyOn(openAiSidecar, "resolveFirstUsableOpenAiSidecar").mockImplementation(async (candidates, headers) => { + expect(codexPoolAffinityKey(headers)).toBe(key); + expect(headers.get("authorization")).toBeNull(); + return available ? { ...candidates[0]!, authContext: { kind: "main", accountId: null }, headers } : undefined; + }); + try { + const { wire } = await claudeTurn("https://compat.example.com", OK_TURN, "anth", undefined, { native: true, pool: true, requestHeaders }); + expect((wire.tools as Record[]).some(t => t.type === WEB_SEARCH.type)).toBe(!available); + } finally { resolver.mockRestore(); } + }); + } + + test("pool probe forwards cancellation to account resolution", async () => { + const controller = new AbortController(); + let seenSignal: AbortSignal | undefined; + const resolver = spyOn(openAiSidecar, "resolveFirstUsableOpenAiSidecar").mockImplementation(async (_candidates, _headers, _config, options) => { + seenSignal = options?.signal; + controller.abort(); + options?.signal?.throwIfAborted(); + return undefined; + }); + try { + await expect(claudeTurn("https://compat.example.com", OK_TURN, "anth", undefined, { native: true, pool: true, signal: controller.signal })).rejects.toThrow(); + expect(seenSignal?.aborted).toBe(true); + } finally { resolver.mockRestore(); } + }); + + test("cancellation wrapped by a credential refresh error still ends the request", async () => { + const controller = new AbortController(); + let sends = 0; + const resolver = spyOn(openAiSidecar, "resolveFirstUsableOpenAiSidecar").mockImplementation(async () => { + controller.abort(); + throw new CodexAuthContextError("fixture-account", controller.signal.reason); + }); + try { + await expect(claudeTurn("https://compat.example.com", OK_TURN, "anth", undefined, { native: true, pool: true, signal: controller.signal, onUpstream: () => { sends++; } })).rejects.toThrow(); + expect(sends).toBe(0); + } finally { resolver.mockRestore(); } + }); + + test("cancellation after account selection releases its probe lease", async () => { + const controller = new AbortController(); + let released = 0; + const resolver = spyOn(openAiSidecar, "resolveFirstUsableOpenAiSidecar").mockImplementation(async (candidates, headers) => { + controller.abort(); + return { ...candidates[0]!, authContext: { kind: "main", accountId: null }, headers, releaseProbeLease: () => { released++; } }; + }); + try { + await expect(claudeTurn("https://compat.example.com", OK_TURN, "anth", undefined, { native: true, pool: true, signal: controller.signal })).rejects.toThrow(); + expect(released).toBe(1); + } finally { resolver.mockRestore(); } + }); + + test("a pool probe releases its selected main claim when resolution ends", async () => { + const lease = tryAdmitTurn(); expect(lease).not.toBeNull(); + const resolver = spyOn(openAiSidecar, "resolveFirstUsableOpenAiSidecar").mockImplementation(async (_candidates, _headers, _config, options) => { + const selection = options?.beginCodexAccountSelection?.(); + expect(selection?.claimMainProfile()).toBe(true); + selection?.release(); + throw new Error("fixture probe ended"); + }); + const claim = spyOn(mainAdmission, "tryClaimNativeMainProfileForTurn"); + try { + await expect(claudeTurn("https://compat.example.com", OK_TURN, "anth", undefined, { native: true, pool: true, lease })).rejects.toThrow("fixture probe ended"); + expect(getNativeMainProfileRequestCount()).toBe(0); + expect(claim).not.toHaveBeenCalled(); + } finally { claim.mockRestore(); resolver.mockRestore(); lease?.release(); } + }); + + test("a direct probe releases its main claim after the token read", async () => { + const lease = tryAdmitTurn(); expect(lease).not.toBeNull(); + const token = spyOn(mainAccount, "getMainAccountToken").mockReturnValue(null); + try { + await claudeTurn("https://compat.example.com", OK_TURN, "anth", undefined, { native: true, pool: true, directSearch: true, lease, + onUpstream: () => expect(getNativeMainProfileRequestCount()).toBe(0) }); + expect(token).toHaveBeenCalledTimes(1); + } finally { token.mockRestore(); lease?.release(); } + }); + + test("a pool miss with possible main auth conservatively stays on the bridge", async () => { + const lease = tryAdmitTurn(); expect(lease).not.toBeNull(); + const resolver = spyOn(openAiSidecar, "resolveFirstUsableOpenAiSidecar").mockResolvedValue(undefined); + const token = spyOn(mainAccount, "getMainAccountToken").mockReturnValue({ accessToken: "fixture-token", chatgptAccountId: "fixture-account" }); + try { + const { wire } = await claudeTurn("https://compat.example.com", OK_TURN, "anth", undefined, { native: true, pool: true, lease }); + expect((wire.tools as Record[]).some(t => t.type === WEB_SEARCH.type)).toBe(false); + } finally { token.mockRestore(); resolver.mockRestore(); lease?.release(); } + }); + test("native compatible upstream receives search and returns its result without a sidecar", async () => { + const searchTool = { ...WEB_SEARCH, allowed_domains: ["bun.sh"] }; + const { wire, clientSse } = await claudeTurn("https://compat.example.com", SEARCH_TURN, "anth", undefined, { native: true, searchTool }); + expect(wire.tools).toContainEqual(searchTool); + expect(clientSse).toContain('"type":"web_search_tool_result"'); + expect(clientSse).toContain("Bun blog"); + }); + test("an available sidecar keeps the compatible route on the bridge", async () => { + const { wire } = await claudeTurn("https://compat.example.com", OK_TURN, "anth", undefined, + { native: true, searchBackend: { backend: "exa", exaApiKey: "fixture" } }); + expect(wire.tools).not.toContainEqual(WEB_SEARCH); + expect((wire.tools as Record[]).map(tool => tool.name)).toContain("web_search"); + }); test("api.anthropic.com receives web_search and web_fetch as declared", async () => { const { wire } = await claudeTurn("https://api.anthropic.com", OK_TURN); const tools = wire.tools as Record[]; diff --git a/tests/responses/messages-native-bridge-policy.test.ts b/tests/responses/messages-native-bridge-policy.test.ts index 61ab7c4815b..2604a94ed2f 100644 --- a/tests/responses/messages-native-bridge-policy.test.ts +++ b/tests/responses/messages-native-bridge-policy.test.ts @@ -112,6 +112,17 @@ describe("web-search sidecar", () => { .toBe("bridge-only-policy"); }); + test("runtime availability changes only the search policy", () => { + expect(nativeMessagesDeclineReason(route(), searchBody, config(), { webSearchBackendAvailable: false })).toBeUndefined(); + expect(nativeMessagesDeclineReason(route(), searchBody, config(), { webSearchBackendAvailable: true })).toBe("bridge-only-policy"); + expect(nativeMessagesDeclineReason(route(), searchBody, config({ protocols: { rollout: { managedMessagesNative: false } } }), + { webSearchBackendAvailable: false })).toBe("rollout-disabled"); + expect(nativeMessagesDeclineReason(route(), { ...searchBody, tool_choice: { type: "none" } }, config(), + { webSearchBackendAvailable: true })).toBeUndefined(); + expect(nativeMessagesDeclineReason(route({ pinnedReasoningEffort: "high" }), searchBody, config(), + { webSearchBackendAvailable: false })).toBe("bridge-only-policy"); + }); + test("a disabled sidecar or a tool choice that excludes search stays native", () => { const off = config({ claudeCode: { webSearchSidecar: { enabled: false } } } as Partial); expect(nativeMessagesDeclineReason(route(), searchBody, off)).toBeUndefined(); From 8063be23e47822240b45c0456f3243102d4a0d9d Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 30 Oct 2026 21:28:28 +0900 Subject: [PATCH 66/84] fix(providers): keep the quota report cache when pool readings are only restamped (#7992) The Pool aggregation part of the provider quota report cache key hashed each account's quota updatedAt, which every proxied Codex response restamps, so the cache never committed and a transient probe failure dropped the last-good row. The key now carries the fresh/stale verdict instead, plus shortObservedAt only for an exhausted reset-less short window whose blocking verdict depends on it. Closes #7956. --- src/providers/quota.ts | 4 +- src/providers/quota/report-cache.ts | 21 ++++- tests/providers/provider-quota.test.ts | 103 ++++++++++++++++++++++++- 3 files changed, 121 insertions(+), 7 deletions(-) diff --git a/src/providers/quota.ts b/src/providers/quota.ts index 06292acc709..4526b82a4d1 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -151,8 +151,8 @@ async function maybeFetchProviderQuota( * * No previous snapshot is passed. The observer keeps its own persisted last-seen map, * because `previous` here is bound only when `cache.key === key` and the key digest at - * cacheKeyWithAggregationState includes quota values and updatedAt — so it is empty exactly - * when a reset happened. + * cacheKeyWithAggregationState includes quota values and their freshness — so it is empty + * exactly when a reset happened. * * Account identity is resolved SYNCHRONOUSLY, before any await. Two reasons, both observed: * pool failover rewrites `activeAccountId` during request routing diff --git a/src/providers/quota/report-cache.ts b/src/providers/quota/report-cache.ts index f3fad7a635d..7350263d6cf 100644 --- a/src/providers/quota/report-cache.ts +++ b/src/providers/quota/report-cache.ts @@ -3,7 +3,7 @@ import { providerDecisionWindows } from "../quota-decision-publication"; import type { DecisionQuotaWindow } from "../quota-decision-snapshot"; import { createHash } from "node:crypto"; import { effectiveCodexAuthAccountId, listCodexAuthAccountsSnapshot } from "../../codex/auth-api"; -import { withoutRetiredCodexQuota, type StoredAccountQuota } from "../../codex/quota"; +import { CODEX_EXHAUSTED_USAGE_PERCENT, withoutRetiredCodexQuota, type StoredAccountQuota } from "../../codex/quota"; import { isMainAccountIdentityGenerationLive } from "../../codex/main-account-cache"; import { codexPlanKey } from "../../codex/plan"; import { resolveProviderApiKey } from "../key-store"; @@ -154,16 +154,28 @@ export function hasCodexPoolProvider(config: OcxConfig): boolean { )); } -function quotaSignatureValue(quota: CodexCapacityQuota | null): unknown { +/** + * Signature of one pool account's presentation state. Every proxied Codex response restamps the + * observation, so a raw `updatedAt` in the key stopped the report cache from ever committing + * while traffic flowed (#7956). The key carries the fresh/stale verdict aggregation derives + * from it instead; value changes and freshness transitions still miss the cache. A displayed + * window `updatedAt` can therefore lag a restamp by up to the report cache TTL. The exception is + * an exhausted short window without a reset: its `shortObservedAt` alone decides whether it + * still blocks, over a window as short as that TTL, so that clock stays in the key. + */ +function quotaSignatureValue(quota: CodexCapacityQuota | null, now: number): unknown { if (!quota) return null; + const resetless = !(typeof quota.fiveHourResetAt === "number" && quota.fiveHourResetAt > 0); + const observationDecides = resetless && (quota.fiveHourPercent ?? 0) >= CODEX_EXHAUSTED_USAGE_PERCENT; return { fiveHourPercent: quota.fiveHourPercent, fiveHourResetAt: quota.fiveHourResetAt, + ...(observationDecides ? { shortObservedAt: quota.shortObservedAt } : {}), weeklyPercent: quota.weeklyPercent, weeklyResetAt: quota.weeklyResetAt, monthlyPercent: quota.monthlyPercent, monthlyResetAt: quota.monthlyResetAt, - updatedAt: quota.updatedAt, + fresh: Number.isFinite(quota.updatedAt) && now - quota.updatedAt <= CODEX_CAPACITY_MAX_QUOTA_AGE_MS, customWindows: [...(quota.customWindows ?? [])] .map(window => ({ label: window.label, percent: window.percent, resetAt: window.resetAt })) .sort((a, b) => a.label.localeCompare(b.label)), @@ -204,13 +216,14 @@ export function cacheKeyWithAggregationState( try { const activeId = effectiveCodexAuthAccountId(config); const snapshot = await (prefetchedSnapshot ?? listCodexAuthAccountsSnapshot(config, false)); + const now = Date.now(); const rows = snapshot.accounts.map(account => ({ isMain: account.isMain, active: account.id === activeId, plan: codexPlanKey(account.plan) ?? null, paused: account.paused, needsReauth: account.needsReauth === true, - quota: quotaSignatureValue(providerQuotaFromCodexQuota(account.quota)), + quota: quotaSignatureValue(providerQuotaFromCodexQuota(account.quota), now), })); const canonicalRows = rows.map(row => JSON.stringify(row)).sort(); const digest = createHash("sha256").update(JSON.stringify(canonicalRows)).digest("hex").slice(0, 24); diff --git a/tests/providers/provider-quota.test.ts b/tests/providers/provider-quota.test.ts index 9de61147f58..43d5fb32023 100644 --- a/tests/providers/provider-quota.test.ts +++ b/tests/providers/provider-quota.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, setSystemTime, spyOn, test } from "bun:test"; import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { tmpdir } from "node:os"; @@ -21,6 +21,7 @@ import { setAntigravityAccountQuotaTransportForTests, setProviderQuotaBeforePublishForTests, } from "../../src/providers/quota"; +import { cacheKeyWithAggregationState } from "../../src/providers/quota/report-cache"; import type { OcxConfig } from "../../src/types"; import { clearComboTargetCooldowns, coolComboTarget, pickComboTarget, pickComboTargetWithWait } from "../../src/combos"; import { routedProviderConfig } from "../../src/router"; @@ -98,6 +99,7 @@ beforeEach(() => { }); afterEach(() => { + setSystemTime(); clearComboTargetCooldowns(); for (const key of proxyKeys) { if (originalProxyEnv[key] === undefined) delete process.env[key]; @@ -2428,6 +2430,105 @@ describe("fetchProviderQuotaReports", () => { expect((await fetchProviderQuotaReports(config)).reports[0]?.aggregation?.weekly?.usedPercent).toBeCloseTo((20 * 20 + 11) / 21, 8); }); + test("restamped pool observations keep the report cache and last-good rows (#7956)", async () => { + await saveCredential("anthropic", { access: "synthetic-claude-access", refresh: "synthetic-claude-refresh", expires: Date.now() + 3600_000 }); + saveCodexAccountCredential("added", { + accessToken: "added-access", refreshToken: "added-refresh", + expiresAt: Date.now() + 3600_000, chatgptAccountId: "added-chatgpt-id", + }); + const config = testConfig(); + config.providers = { openai: config.providers.openai, anthropic: config.providers.anthropic }; + config.codexAccounts = [{ id: "added", email: "a@example.test", plan: "pro", isMain: false }]; + let anthropicCalls = 0; + let anthropicDown = false; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + if (String(input) === "https://api.anthropic.com/api/oauth/usage") { + anthropicCalls += 1; + if (anthropicDown) return new Response("unavailable", { status: 503 }); + return Response.json({ five_hour: { utilization: 41, resets_at: new Date(Date.now() + 3600_000).toISOString() } }); + } + const added = (init?.headers as Record | undefined)?.["ChatGPT-Account-Id"] === "added-chatgpt-id"; + return Response.json({ plan_type: added ? "pro" : "plus", + rate_limit: { secondary_window: { used_percent: added ? 30 : 11, reset_at: 1_999_000_000 } } }); + }) as typeof fetch; + + await fetchProviderQuotaReports(config, true); + expect(anthropicCalls).toBe(1); + // A proxied Codex response re-observes unchanged values with a new timestamp. + setSystemTime(new Date(Date.now() + 1_000)); + updateAccountQuota("added", 30, 1_999_000_000); + const cached = await fetchProviderQuotaReports(config); + expect(anthropicCalls).toBe(1); + expect(cached.reports.map(report => report.provider).sort()).toEqual(["anthropic", "openai"]); + + // A transient Anthropic failure on the next probe keeps its bounded last-good row. + anthropicDown = true; + setSystemTime(new Date(Date.now() + 1_000)); + updateAccountQuota("added", 30, 1_999_000_000); + const refreshed = await fetchProviderQuotaReports(config, true); + expect(anthropicCalls).toBe(2); + expect(refreshed.reports.find(report => report.provider === "anthropic")?.quota.fiveHourPercent).toBe(41); + }); + + test("a restamp during an outstanding probe still lets that probe commit (#7956)", async () => { + await saveCredential("anthropic", { access: "synthetic-claude-access", refresh: "synthetic-claude-refresh", expires: Date.now() + 3600_000 }); + saveCodexAccountCredential("added", { + accessToken: "added-access", refreshToken: "added-refresh", + expiresAt: Date.now() + 3600_000, chatgptAccountId: "added-chatgpt-id", + }); + const config = testConfig(); + config.providers = { openai: config.providers.openai, anthropic: config.providers.anthropic }; + config.codexAccounts = [{ id: "added", email: "a@example.test", plan: "pro", isMain: false }]; + let anthropicCalls = 0; + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + let probing!: () => void; + const started = new Promise(resolve => { probing = resolve; }); + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + if (String(input) === "https://api.anthropic.com/api/oauth/usage") { + anthropicCalls += 1; + probing(); + await gate; + return Response.json({ five_hour: { utilization: 41, resets_at: new Date(Date.now() + 3600_000).toISOString() } }); + } + const added = (init?.headers as Record | undefined)?.["ChatGPT-Account-Id"] === "added-chatgpt-id"; + return Response.json({ plan_type: added ? "pro" : "plus", + rate_limit: { secondary_window: { used_percent: added ? 30 : 11, reset_at: 1_999_000_000 } } }); + }) as typeof fetch; + + const first = fetchProviderQuotaReports(config, true); + await started; + setSystemTime(new Date(Date.now() + 1_000)); + updateAccountQuota("added", 30, 1_999_000_000); + release(); + await first; + const next = await fetchProviderQuotaReports(config); + expect(anthropicCalls).toBe(1); + expect(next.reports.map(report => report.provider).sort()).toEqual(["anthropic", "openai"]); + }); + + test("only an exhausted reset-less short window keeps its observation clock in the key (#7956)", async () => { + const config = testConfig(); + config.providers = { openai: config.providers.openai }; + let quota: Partial = {}; + const poolSpy = spyOn(authApi, "listCodexAuthAccountsSnapshot").mockImplementation(async () => ({ + mainIdentityGeneration: captureMainAccountIdentityGeneration(), + accounts: [{ id: "__main__", isMain: true, paused: false, plan: "plus", quota }], + } as unknown as Awaited>)); + const keyFor = async (next: Partial) => { quota = next; return await cacheKeyWithAggregationState(config); }; + try { + const now = Date.now(); + const withReset = { weeklyPercent: 20, shortPercent: 100, shortResetAt: Math.floor(now / 1000) + 3600 }; + expect(await keyFor({ ...withReset, shortObservedAt: now, updatedAt: now })) + .toBe(await keyFor({ ...withReset, shortObservedAt: now + 5, updatedAt: now + 5 })); + const resetless = { weeklyPercent: 20, shortPercent: 100 }; + expect(await keyFor({ ...resetless, shortObservedAt: now, updatedAt: now })) + .not.toBe(await keyFor({ ...resetless, shortObservedAt: now + 5, updatedAt: now + 5 })); + } finally { + poolSpy.mockRestore(); + } + }); + test("ordinary fetch reflects runtime reauthentication state", async () => { saveCodexAccountCredential("added", { accessToken: "added-access", refreshToken: "added-refresh", From d9e0c057881200d32b0398c5a1bc38144c1da73e Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 30 Oct 2026 21:30:38 +0900 Subject: [PATCH 67/84] fix(responses): promote additional_tools to top-level tools for xAI and strict gateways (#7983) Carries #7637. Co-authored-by: Omar <13569609+omar391@users.noreply.github.com> --- .../fr/reference/configuration/providers.md | 4 +- .../ja/reference/configuration/providers.md | 4 +- .../ko/reference/configuration/providers.md | 4 +- .../docs/reference/configuration/keychain.md | 55 +++ .../docs/reference/configuration/providers.md | 60 +-- .../ru/reference/configuration/providers.md | 4 +- .../tr/reference/configuration/providers.md | 4 +- .../reference/configuration/providers.md | 4 +- .../reference/configuration/providers.md | 4 +- scripts/test-layout/layout-providers.json | 3 + .../openai-responses/muse-tool-catalog.ts | 10 +- src/adapters/openai-responses/passthrough.ts | 32 +- src/adapters/openai-responses/prompt-cache.ts | 4 +- .../openai-responses/request-strips.ts | 11 +- .../openai-responses/tool-output-recovery.ts | 29 +- src/adapters/openai-responses/tool-schema.ts | 28 +- src/adapters/opencode-go-additional-tools.ts | 45 +- src/adapters/routed-agent-messages.ts | 3 + src/adapters/xai-web-search.ts | 9 +- src/adapters/zed-additional-tools.ts | 14 +- src/adapters/zed.ts | 7 +- src/config/schema/leaf-validators.ts | 1 + src/providers/model-rename-fields.ts | 1 + src/responses/custom-tool-compat.ts | 3 +- src/responses/item-provenance.ts | 79 ++++ src/responses/namespace-tool-compat.ts | 24 +- src/server/auth-cors.ts | 1 + .../management/provider-overwrite-carry.ts | 10 + src/server/management/provider-routes.ts | 1 + src/server/responses-undeclared-tool-guard.ts | 4 + src/server/responses/passthrough-dispatch.ts | 7 +- src/types/provider.ts | 8 + structure/gui-and-management-api.md | 2 +- structure/transports/responses-wire-shapes.md | 6 +- structure/transports/responses.md | 33 +- tests/adapters/routed-agent-messages.test.ts | 12 + tests/fixtures/test-layout-expected.json | 3 + .../xai/xai-additional-tools.test.ts | 421 ++++++++++++++++++ .../xai/xai-loaded-tool-provenance.test.ts | 128 ++++++ .../xai/xai-replay-provenance.test.ts | 94 ++++ tests/providers/zed-hosted-provider.test.ts | 22 + .../openai-responses-passthrough.test.ts | 16 +- .../responses-muse-tool-choice.test.ts | 16 + .../management-provider-compat-carry.test.ts | 35 ++ .../server-xai-responses-streaming.test.ts | 5 +- 45 files changed, 1112 insertions(+), 158 deletions(-) create mode 100644 docs-site/src/content/docs/reference/configuration/keychain.md create mode 100644 src/responses/item-provenance.ts create mode 100644 tests/providers/xai/xai-additional-tools.test.ts create mode 100644 tests/providers/xai/xai-loaded-tool-provenance.test.ts create mode 100644 tests/providers/xai/xai-replay-provenance.test.ts diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index 85650d22e13..6b7439f0b0c 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -199,9 +199,9 @@ configuré sous [`claudeCode.authMode`](/fr/reference/configuration/server/#clau ### Ce qu'une sauvegarde de fournisseur conserve -`POST /api/providers` avec le nom d'un fournisseur existant remplace la ligne enregistrée par une ligne construite à partir de la requête. Le formulaire d'ajout/modification du tableau de bord ne peut pas envoyer tous les champs ; la sauvegarde conserve donc certains champs enregistrés que la requête omet. Dix d'entre eux décrivent le comportement d'un amont précis : `preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`. +`POST /api/providers` avec le nom d'un fournisseur existant remplace la ligne enregistrée par une ligne construite à partir de la requête. Le formulaire d'ajout/modification du tableau de bord ne peut pas envoyer tous les champs ; la sauvegarde conserve donc certains champs enregistrés que la requête omet. Onze d'entre eux décrivent le comportement d'un amont précis : `preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `promoteAdditionalTools`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`. -| Sauvegarde | Les dix réglages | `apiKeyPool` enregistré | +| Sauvegarde | Les onze réglages | `apiKeyPool` enregistré | | --- | --- | --- | | Même destination, champ omis | Valeur enregistrée conservée, y compris un `[]` ou un `false` explicite | Conservé | | Nouvelle destination, champ omis | Non conservée ; les valeurs par défaut du registre pour la nouvelle destination peuvent s'appliquer | Non conservé | diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index 8705a8b7c64..7ecffe3764a 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -189,9 +189,9 @@ API キープロバイダーは、リテラルキーまたは環境参照を保 ### プロバイダーの保存で保持されるもの -既存のプロバイダー名で `POST /api/providers` を送ると、保存済みの行はリクエストから組み立てた行で置き換えられます。ダッシュボードの追加・編集フォームはすべてのフィールドを送れないため、リクエストが省略した保存済みフィールドの一部は保存時に引き継がれます。そのうち次の 10 つは、特定のアップストリームの挙動を記録するものです:`preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`。 +既存のプロバイダー名で `POST /api/providers` を送ると、保存済みの行はリクエストから組み立てた行で置き換えられます。ダッシュボードの追加・編集フォームはすべてのフィールドを送れないため、リクエストが省略した保存済みフィールドの一部は保存時に引き継がれます。そのうち次の 11 つは、特定のアップストリームの挙動を記録するものです:`preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `promoteAdditionalTools`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`。 -| 保存 | 10 つの設定 | 保存済みの `apiKeyPool` | +| 保存 | 11 つの設定 | 保存済みの `apiKeyPool` | | --- | --- | --- | | 同じ宛先、フィールド省略 | 保存済みの値を保持(明示的な `[]` や `false` を含む) | 保持 | | 新しい宛先、フィールド省略 | 保持しない。新しい宛先のレジストリ既定値が適用されることがあります | 保持しない | diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index 0bb995ed1ef..6102a5442e6 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -189,9 +189,9 @@ API 키 공급자는 리터럴 키나 환경 참조를 둘 수 있습니다. OAu ### 프로바이더 저장이 유지하는 것 -기존 프로바이더 이름으로 `POST /api/providers`를 보내면 저장된 행이 요청으로 만든 행으로 바뀝니다. 대시보드의 추가/편집 폼은 모든 필드를 보낼 수 없으므로, 요청이 빠뜨린 저장 필드 일부는 저장할 때 이어서 유지됩니다. 그중 열 가지는 특정 업스트림의 동작을 기록한 설정입니다: `preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`. +기존 프로바이더 이름으로 `POST /api/providers`를 보내면 저장된 행이 요청으로 만든 행으로 바뀝니다. 대시보드의 추가/편집 폼은 모든 필드를 보낼 수 없으므로, 요청이 빠뜨린 저장 필드 일부는 저장할 때 이어서 유지됩니다. 그중 열한 가지는 특정 업스트림의 동작을 기록한 설정입니다: `preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `promoteAdditionalTools`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`. -| 저장 | 열 가지 설정 | 저장된 `apiKeyPool` | +| 저장 | 열한 가지 설정 | 저장된 `apiKeyPool` | | --- | --- | --- | | 같은 목적지, 필드 생략 | 저장된 값 유지(명시적인 `[]`나 `false` 포함) | 유지 | | 새 목적지, 필드 생략 | 유지하지 않음. 새 목적지의 레지스트리 기본값이 적용될 수 있음 | 유지하지 않음 | diff --git a/docs-site/src/content/docs/reference/configuration/keychain.md b/docs-site/src/content/docs/reference/configuration/keychain.md new file mode 100644 index 00000000000..c8a4e40d384 --- /dev/null +++ b/docs-site/src/content/docs/reference/configuration/keychain.md @@ -0,0 +1,55 @@ +--- +title: Storing keys in the OS keychain +description: Storing provider API keys and Vertex credentials in macOS Keychain, Windows Credential Manager, or Linux Secret Service. +--- + +For Vertex, the dedicated API-key pool input (`POST /api/providers/keys`) accepts complete GCP +`service_account` or `authorized_user` credential JSON after the provider has been created. +The provider create/edit form and `ocx provider add --api-key` refuse credential JSON before +saving: create the provider without a key, then add the JSON through its key pool. Paste the +JSON contents, not a Windows, Unix, or relative file path. Multiple +JSON objects separated by commas or whitespace are stored together as separate pool entries; +the first becomes active. Configuration contains only `gcp-sa:` references, never the +credential JSON. Mixed JSON/literal-key bulk pastes are rejected as a whole; add literal keys +separately. Storage failures leave the configuration unchanged and report a fixed error without +echoing credential material. Empty marker accounts are invalid and do not fall back to host credentials. +Paste one credential object or a sequence of objects, without an enclosing array or quoted string. +Malformed credential text is rejected. If a selected stored credential is removed or becomes unreadable, +Vertex refuses the request even when it previously cached an access token for that credential. +Removing a key or provider, or replacing a provider with `ocx provider add --force`, deletes its unreferenced GCP credentials after the configuration is +saved; a credential referenced by another provider is retained. If OS-store cleanup fails, a +warning requests manual inspection without undoing the committed configuration removal. +Ordinary keychain store/restore actions leave GCP markers and JSON in the OS store. Restoring +credential JSON through a `keychain:` alias is refused, and cleanup preserves accounts still +referenced through either marker spelling. +Using a `keychain:` alias containing GCP JSON as an API key also refuses the request; select its +`gcp-sa:` reference for Vertex authentication instead. +Literal API keys must be plain token text: JSON objects, arrays and quoted JSON strings are refused +in literal-key resolution and restore, including wrappers containing escaped credential text. +Credential writes and marker persistence share the cleanup transaction lock, so concurrent +removal cannot delete the stored JSON between read-back and the marker commit. + +By default a provider's `apiKey` and `apiKeyPool` sit in `config.json` (mode 0600, atomic writes). +If you would rather keep the key material out of the file, move it into the OS credential store: + +```bash +ocx provider keychain deepseek status # store: file | env | keychain, and whether the keychain answers +ocx provider keychain deepseek store # move active key + pool into the OS keychain +ocx provider keychain deepseek restore # bring the plaintext back and delete the keychain items +``` + +The same operations are `GET`/`POST /api/providers/keychain`. After `store`, `config.json` holds +`"apiKey": "keychain:deepseek"` (pool entries `keychain:deepseek/`) and the secret lives under the +`opencodex.provider-api-key.v1` service in macOS Keychain, Windows Credential Manager, or the Linux +Secret Service. Backups of `config.json` therefore carry references only. Key rotation and failover +keep working: pool entries compare by reference, so a rotation never writes plaintext back. + +Before touching the config, `store` writes and reads back every entry; if the keychain is unavailable +or the read-back does not match, it refuses with 503 and leaves the file as it was. At request time +a reference that cannot be read yields no credential and one warning per key — there is no plaintext +fallback, by design. + +When not to opt in: a proxy running as a headless service (systemd, launchd, Task Scheduler) or in a +container usually has no unlocked keychain session, so requests would fail closed. Use an +`${ENV_VAR}` reference in the service environment there instead. Env references are left untouched +by `store`. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index ae998f0f119..7ed86852041 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -326,7 +326,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `preserveResponsesMessageMetadata?` | `boolean` | Default off. Preserve private `internal_chat_message_metadata_passthrough` on input items for explicitly trusted launcher relays, including custom native v1 compact requests. This forwards private turn/session metadata to that destination; do not enable for ordinary gateways. | | `dropResponsesReasoningItems?` | `boolean` | Remove replayed Responses `reasoning` items from continuation input entirely, including custom native v1 compact requests, instead of forwarding them with blanked content. This is lossy: summaries, item ids, and `encrypted_content` go with them, so no reasoning item from the previous turn remains in the forwarded input. It does not remove `previous_response_id`, so an upstream holding its own turn state may still reach it. Enable only for an upstream that rejects reasoning replay outright — the built-in Volcengine Ark Coding Plan preset sets it because validated tool continuations answered `400 InvalidParameter` otherwise. Set it to `false` on that provider to forward reasoning items again. | | `disabled?` | `boolean` | Keep the provider on disk but exclude it from routing and model/catalog listings. | -| `apiKey?` | `string` | API key, an `${ENV_VAR}` / `$ENV_VAR` reference, or a `keychain:` reference written by `ocx provider keychain store`. References resolve at request time. See [Storing keys in the OS keychain](#storing-keys-in-the-os-keychain). | +| `apiKey?` | `string` | API key, an `${ENV_VAR}` / `$ENV_VAR` reference, or a `keychain:` reference written by `ocx provider keychain store`. References resolve at request time. See [Storing keys in the OS keychain](/reference/configuration/keychain/). | | `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Anthropic key header style. Defaults to native `x-api-key`; valid only for key-auth `anthropic` providers. | | `apiKeyPool?` | `ApiKeyPoolEntry[]` | Multi-key pool. `apiKey` mirrors the active entry; each item has `id`, `key`, optional `label`, and optional numeric `addedAt`. | | `apiKeyPoolStrategy?` | `"round-robin" \| "fill-first" \| "quota"` | How a warm key is chosen **before** the first attempt when the committed key is already cooling. Omitted keeps rotation reactive-only: the pool moves after a 429 or 401 and not before. `round-robin` takes the next key in the pool, `fill-first` keeps the first eligible one, and `quota` prefers the key with the most remaining headroom, falling back to `fill-first` order for a provider whose per-key quota is unknown. A healthy committed key is never overridden, so a manual key selection stands. | @@ -370,6 +370,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `xaiResponsesXSearch?` | `boolean` | Disabled by default. On an xAI Responses destination, append the provider-hosted `x_search` declaration only when a live `web_search` tool survives final request normalization. Existing declarations are not duplicated, caller `tool_choice`/`allowed_tools` selectors are never widened, and this is separate from the web-search sidecar's `search.xSearch` options. | | `modelPreferHostedTools?` | `Record` | Exact-model opt-in for non-forward Responses gateways that reserve a hosted-tool namespace. Currently accepts only `["image_generation"]`; a matching model must use the `openai-responses` wire and support that hosted tool. It removes colliding client `image_gen` declarations and rewrites their selectors to preserve caller tool choice. For OpenAI API virtual `-pro` models, the selected public ID is matched first and the resolved base wire-model ID is a fallback. `modelAdapters` resolves the public ID first, then the base ID; the second resolution determines the final wire. Other models retain normal alias behavior. | | `annotateEmptyToolOutputs?` | `boolean` | Replace a present-but-empty tool result with a short marker before it reaches the model, so a blank result is not read as a missing one. Applies to blank strings and text-only part arrays; image, file, and encrypted parts are never touched. Defaults to `true` for DeepSeek from the built-in registry and is otherwise unset. Set `false` to opt a provider out — an explicit `false` is preserved across later edits that omit the field. `PATCH /api/providers?name=` accepts `true`, `false`, or `null` to clear the override and return to registry-default behavior. | +| `promoteAdditionalTools?` | `boolean` | Whether to promote Codex Desktop's `additional_tools` input items into top-level `tools` and strip the wrapper from `input`. Enabled automatically for OpenCode Go and xAI (`api.x.ai` and `cli-chat-proxy.grok.com`) unless `authMode` is `"forward"`, where promotion needs an explicit `true`; other OpenAI-strict third-party gateways that return HTTP 422 for unknown item types in `input` can opt in here. `PATCH /api/providers?name=` accepts a boolean or `null` to clear it. | | `unsupportedHostedTools?` | `string[]` | Hosted tool declarations this Responses destination rejects, so they are stripped from `tools`, from client-loaded `additional_tools`, and from `tool_choice` instead of being forwarded and rejected upstream. Use it for an OpenAI-compatible gateway with a narrower capability set — one that accepts plain Responses requests and `function` tools but returns HTTP 400 for hosted `web_search` — so a text-only prompt is not failed by a capability it never needed. Accepts only hosted tool type names (`web_search`, `web_search_preview`, `file_search`, `computer_use_preview`, `computer_use`, `code_interpreter`, `image_generation`, `image_gen`, `mcp`, `tool_search`, `local_shell`, `x_search`); an unrecognized name is rejected rather than silently ignored. Spelling variants of one capability are aliased, so `["web_search"]` also denies `web_search_preview`. A provider cannot both deny a hosted tool here and prefer it in `modelPreferHostedTools`. Xiaomi MiMo Responses destinations (`xiaomimimo.com` and its subdomains, including `api.xiaomimimo.com` and `token-plan-cn.xiaomimimo.com`) automatically strip `web_search` and `web_search_preview` while preserving function tools; no declaration is needed for these hosts. This is independent of `supportsResponsesCustomTools`; set both for a gateway that also rejects native custom tools. `PATCH /api/providers?name=` accepts an array or `null` to clear it. | | `reasoningEffortMap?` | `Record` | Provider-wide wire aliases for reasoning labels. Map a label to `"__omit__"` to drop the reasoning field from the upstream request entirely: `reasoning_effort` on an OpenAI-compatible wire, and Ollama's native `think` field on the Ollama native adapter (#2356). | | `modelReasoningEffortMap?` | `Record>` | Per-model wire aliases for reasoning labels. Map a label to `"__omit__"` to drop the reasoning field from the upstream request entirely. | @@ -487,9 +488,9 @@ and `modelContextWindows` for that exact id when needed. Unknown ids are not gue ### What a provider save keeps -`POST /api/providers` with the name of an existing provider replaces the stored row with one built from the request. The dashboard's add/edit form cannot send every field, so the save keeps some stored fields the request omits. Ten of them record how one upstream behaves: `preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `retryOn429`, `transientRetryOn5xx` and `retryOnReset`. +`POST /api/providers` with the name of an existing provider replaces the stored row with one built from the request. The dashboard's add/edit form cannot send every field, so the save keeps some stored fields the request omits. Eleven of them record how one upstream behaves: `preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `promoteAdditionalTools`, `retryOn429`, `transientRetryOn5xx` and `retryOnReset`. -| Save | The ten settings | Stored `apiKeyPool` | +| Save | The eleven settings | Stored `apiKeyPool` | | --- | --- | --- | | Same destination, field omitted | Stored value kept, including an explicit `[]` or `false` | Kept | | New destination, field omitted | Not kept; registry defaults for the new destination may apply | Not kept | @@ -1589,60 +1590,11 @@ same mapping applies to a native `vercel/` selector: use the encoded `vercel-ai-gateway/vercel-` selector in OpenCodex and keep `vercel/` as the model key. -## Static model allowlists - ## Storing keys in the OS keychain -For Vertex, the dedicated API-key pool input (`POST /api/providers/keys`) accepts complete GCP -`service_account` or `authorized_user` credential JSON after the provider has been created. -The provider create/edit form and `ocx provider add --api-key` refuse credential JSON before -saving: create the provider without a key, then add the JSON through its key pool. Paste the -JSON contents, not a Windows, Unix, or relative file path. Multiple -JSON objects separated by commas or whitespace are stored together as separate pool entries; -the first becomes active. Configuration contains only `gcp-sa:` references, never the -credential JSON. Mixed JSON/literal-key bulk pastes are rejected as a whole; add literal keys -separately. Storage failures leave the configuration unchanged and report a fixed error without -echoing credential material. Empty marker accounts are invalid and do not fall back to host credentials. -Paste one credential object or a sequence of objects, without an enclosing array or quoted string. -Malformed credential text is rejected. If a selected stored credential is removed or becomes unreadable, -Vertex refuses the request even when it previously cached an access token for that credential. -Removing a key or provider, or replacing a provider with `ocx provider add --force`, deletes its unreferenced GCP credentials after the configuration is -saved; a credential referenced by another provider is retained. If OS-store cleanup fails, a -warning requests manual inspection without undoing the committed configuration removal. -Ordinary keychain store/restore actions leave GCP markers and JSON in the OS store. Restoring -credential JSON through a `keychain:` alias is refused, and cleanup preserves accounts still -referenced through either marker spelling. -Using a `keychain:` alias containing GCP JSON as an API key also refuses the request; select its -`gcp-sa:` reference for Vertex authentication instead. -Literal API keys must be plain token text: JSON objects, arrays and quoted JSON strings are refused -in literal-key resolution and restore, including wrappers containing escaped credential text. -Credential writes and marker persistence share the cleanup transaction lock, so concurrent -removal cannot delete the stored JSON between read-back and the marker commit. - -By default a provider's `apiKey` and `apiKeyPool` sit in `config.json` (mode 0600, atomic writes). -If you would rather keep the key material out of the file, move it into the OS credential store: - -```bash -ocx provider keychain deepseek status # store: file | env | keychain, and whether the keychain answers -ocx provider keychain deepseek store # move active key + pool into the OS keychain -ocx provider keychain deepseek restore # bring the plaintext back and delete the keychain items -``` +API keys and credential references can be stored in the OS credential store (macOS Keychain, Windows Credential Manager, Linux Secret Service) instead of `config.json`. See [Storing keys in the OS keychain](/reference/configuration/keychain/) for details on migration commands, storage rules, and headless-service caveats. -The same operations are `GET`/`POST /api/providers/keychain`. After `store`, `config.json` holds -`"apiKey": "keychain:deepseek"` (pool entries `keychain:deepseek/`) and the secret lives under the -`opencodex.provider-api-key.v1` service in macOS Keychain, Windows Credential Manager, or the Linux -Secret Service. Backups of `config.json` therefore carry references only. Key rotation and failover -keep working: pool entries compare by reference, so a rotation never writes plaintext back. - -Before touching the config, `store` writes and reads back every entry; if the keychain is unavailable -or the read-back does not match, it refuses with 503 and leaves the file as it was. At request time -a reference that cannot be read yields no credential and one warning per key — there is no plaintext -fallback, by design. - -When not to opt in: a proxy running as a headless service (systemd, launchd, Task Scheduler) or in a -container usually has no unlocked keychain session, so requests would fail closed. Use an -`${ENV_VAR}` reference in the service environment there instead. Env references are left untouched -by `store`. +## Static model allowlists The `zhipu-bigmodel-responses` preset seeds `glm-5.3`, `glm-5.3-flash` and `glm-5-turbo` with `liveModels: false` for `https://open.bigmodel.cn/api/v1`. Its static roster and diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index af23015f1de..527f8f4761b 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -205,9 +205,9 @@ Claude Code настраивается через ### Что сохраняется при сохранении провайдера -`POST /api/providers` с именем существующего провайдера заменяет сохранённую строку строкой, собранной из запроса. Форма добавления и редактирования в панели не может отправить все поля, поэтому сохранение переносит часть сохранённых полей, которые запрос опустил. Десять из них описывают поведение конкретного апстрима: `preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`. +`POST /api/providers` с именем существующего провайдера заменяет сохранённую строку строкой, собранной из запроса. Форма добавления и редактирования в панели не может отправить все поля, поэтому сохранение переносит часть сохранённых полей, которые запрос опустил. Одиннадцать из них описывают поведение конкретного апстрима: `preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `promoteAdditionalTools`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`. -| Сохранение | Десять настроек | Сохранённый `apiKeyPool` | +| Сохранение | Одиннадцать настроек | Сохранённый `apiKeyPool` | | --- | --- | --- | | Та же точка назначения, поле опущено | Сохранённое значение остаётся, включая явный `[]` или `false` | Остаётся | | Новая точка назначения, поле опущено | Не переносится; могут примениться значения реестра по умолчанию для новой точки | Не переносится | diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index c13c0c45116..12f2004ed8b 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -205,9 +205,9 @@ yapılandırılır. ### Bir sağlayıcı kaydı neleri korur -Var olan bir sağlayıcının adıyla gönderilen `POST /api/providers`, saklanan satırı istekten oluşturulan bir satırla değiştirir. Panodaki ekleme/düzenleme formu her alanı gönderemez; bu yüzden kayıt, isteğin atladığı bazı saklanan alanları korur. Bunlardan onu belirli bir üst kaynağın nasıl davrandığını kaydeder: `preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`. +Var olan bir sağlayıcının adıyla gönderilen `POST /api/providers`, saklanan satırı istekten oluşturulan bir satırla değiştirir. Panodaki ekleme/düzenleme formu her alanı gönderemez; bu yüzden kayıt, isteğin atladığı bazı saklanan alanları korur. Bunlardan on biri belirli bir üst kaynağın nasıl davrandığını kaydeder: `preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `promoteAdditionalTools`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`. -| Kayıt | On ayar | Saklanan `apiKeyPool` | +| Kayıt | On bir ayar | Saklanan `apiKeyPool` | | --- | --- | --- | | Aynı hedef, alan atlanmış | Saklanan değer korunur, açık bir `[]` veya `false` dahil | Korunur | | Yeni hedef, alan atlanmış | Korunmaz; yeni hedef için kayıt defteri varsayılanları uygulanabilir | Korunmaz | diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index e7b199e536d..bc201ce0b73 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -191,9 +191,9 @@ API key 提供者可以持有字面量 key,或环境引用。OAuth 提供者 ### 保存提供方时会保留什么 -用已有提供方的名称调用 `POST /api/providers`,会用根据请求构建的行替换已存储的行。仪表板的添加/编辑表单无法发送所有字段,因此保存时会保留请求省略的部分已存储字段。其中十个记录的是某个上游的行为:`preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`。 +用已有提供方的名称调用 `POST /api/providers`,会用根据请求构建的行替换已存储的行。仪表板的添加/编辑表单无法发送所有字段,因此保存时会保留请求省略的部分已存储字段。其中十一个记录的是某个上游的行为:`preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `promoteAdditionalTools`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`。 -| 保存 | 十项设置 | 已存储的 `apiKeyPool` | +| 保存 | 十一项设置 | 已存储的 `apiKeyPool` | | --- | --- | --- | | 目的地相同,字段省略 | 保留已存储的值,包括显式的 `[]` 或 `false` | 保留 | | 新目的地,字段省略 | 不保留;可能套用新目的地的注册表默认值 | 不保留 | diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index cc8b93b3735..5a430f66c07 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -162,9 +162,9 @@ API-key 供應商可持有字面值金鑰或環境參考。OAuth 供應商使用 ### 儲存供應商時會保留什麼 -以既有供應商的名稱呼叫 `POST /api/providers`,會以根據請求建立的列取代已儲存的列。儀表板的新增/編輯表單無法傳送所有欄位,因此儲存時會保留請求省略的部分已儲存欄位。其中十個記錄的是某個上游的行為:`preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`。 +以既有供應商的名稱呼叫 `POST /api/providers`,會以根據請求建立的列取代已儲存的列。儀表板的新增/編輯表單無法傳送所有欄位,因此儲存時會保留請求省略的部分已儲存欄位。其中十一個記錄的是某個上游的行為:`preserveReasoningContentModels`, `preserveResponsesReasoningContent`, `requiresReasoningPlaceholderModels`, `foldDeveloperRoleToSystem`, `singleLeadingSystemMessage`, `reasoningWireFormat`, `omitReasoningEffortWithToolsModels`, `promoteAdditionalTools`, `retryOn429`, `transientRetryOn5xx`, `retryOnReset`。 -| 儲存 | 十項設定 | 已儲存的 `apiKeyPool` | +| 儲存 | 十一項設定 | 已儲存的 `apiKeyPool` | | --- | --- | --- | | 目的地相同,欄位省略 | 保留已儲存的值,包括明確的 `[]` 或 `false` | 保留 | | 新目的地,欄位省略 | 不保留;可能套用新目的地的登錄檔預設值 | 不保留 | diff --git a/scripts/test-layout/layout-providers.json b/scripts/test-layout/layout-providers.json index e79f4b53fc7..cadb953254c 100644 --- a/scripts/test-layout/layout-providers.json +++ b/scripts/test-layout/layout-providers.json @@ -1,4 +1,7 @@ { + "xai-additional-tools.test.ts": "providers/xai", + "xai-replay-provenance.test.ts": "providers/xai", + "xai-loaded-tool-provenance.test.ts": "providers/xai", "free-directory-runcrate.test.ts": "providers", "cc-switch-import.test.ts": "providers", "kiro-completion-with-real-tools.test.ts": "providers/kiro" diff --git a/src/adapters/openai-responses/muse-tool-catalog.ts b/src/adapters/openai-responses/muse-tool-catalog.ts index bf2811e00eb..e0c92d70205 100644 --- a/src/adapters/openai-responses/muse-tool-catalog.ts +++ b/src/adapters/openai-responses/muse-tool-catalog.ts @@ -1,6 +1,7 @@ import { isPlainObject } from "./internal"; import { promoteClientLoadedTools } from "./tool-schema"; import { rewriteRoutedCustomToolsForUpstream } from "../../responses/custom-tool-compat"; +import { hasItemProvenance, isHistoricalItem } from "../../responses/item-provenance"; export class MuseToolCatalogCompatibilityError extends Error { constructor() { @@ -54,8 +55,13 @@ export function normalizeMuseToolCatalog(body: unknown, replayPrefixLength = 0, if (!Array.isArray(body.input)) return topLevelTools === undefined ? body : { ...body, tools: flattenBuiltinNamespace(topLevelTools) }; const start = Number.isFinite(replayPrefixLength) ? Math.min(body.input.length, Math.max(0, Math.trunc(replayPrefixLength))) : 0; + // Item provenance owns membership once present: an earlier rewrite (Zed lifting a replayed + // wrapper) may already have shortened the input below the numeric prefix. + const originalInput: unknown[] = body.input; + const tagged = originalInput.some(hasItemProvenance); + const isCurrent = (item: unknown, index: number) => tagged ? !isHistoricalItem(item) : index >= start; const normalizedInput = body.input.map((item, index) => { - if (index < start) return item; + if (!isCurrent(item, index)) return item; if (isPlainObject(item) && (item.type === "additional_tools" || (item.type === "tool_search_output" && item.tools !== undefined))) { const tools = normalizeCatalog(item.tools); @@ -63,7 +69,7 @@ export function normalizeMuseToolCatalog(body: unknown, replayPrefixLength = 0, } return item; }); - const currentInput = normalizedInput.slice(start); + const currentInput = normalizedInput.filter((_item, index) => isCurrent(originalInput[index], index)); const declared = [...(topLevelTools ?? [])]; for (const item of currentInput) { if (isPlainObject(item) && item.type === "additional_tools" && Array.isArray(item.tools)) declared.push(...item.tools); diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index b76549f571e..6f6ab2c07e2 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -3,7 +3,7 @@ import { normalizeRoutedAgentMessages } from "../routed-agent-messages"; import { frameCompactionHistory } from "../../responses/compaction-history-framing"; import { nameRoutedIdentity, repairIdentityInResponsesBody, stripRoutedIdentity } from "../identity"; import { stripBracketedModelSuffix } from "../openai-chat"; -import { normalizeOpenCodeGoAdditionalTools } from "../opencode-go-additional-tools"; +import { isOpenCodeGoResponsesUrl, isXaiResponsesUrl, promoteResponsesAdditionalTools } from "../opencode-go-additional-tools"; import { isXaiResponsesDestination } from "../../providers/xai-transport"; import { applyGithubCopilotContextTier } from "../../providers/github-copilot-context"; import { Buffer } from "node:buffer"; @@ -48,6 +48,7 @@ import { usageFromResponsesPayload } from "./usage"; import { normalizeToolSchemas, promoteClientLoadedTools, stripUnsupportedHostedTools } from "./tool-schema"; import { annotateEmptyResponsesToolOutputs, backfillWebSearchQueries, normalizeResponsesToolResultAdjacency, repairOrphanedInputItems, repairOversizedReplayCallIds, repairUnidentifiedToolOutputItems, restoreBridgedWebSearchCalls } from "./tool-output-recovery"; import { bridgeSearchReplayScope } from "../../responses/bridge-search-replay-cache"; +import { tagHistoricalInputItems } from "../../responses/item-provenance"; import { applyTierDecisionToResponsesBody, normalizeCanonicalForwardContinuationEnvelope, normalizeCanonicalForwardPromptEnvelope, stripCanonicalForwardSamplingParams, stripPreviousResponseId, stripStatefulResponsesParams, stripUnsupportedForwardParams } from "./canonical-forward"; import { normalizeImageGenClientTools, preferConfiguredHostedTools } from "./image-gen"; import { stripMuseSparkUnsupportedWebSearchFields, stripOpenAiOnlyWebSearchFields } from "./web-search"; @@ -175,8 +176,22 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): let plaintextV2AgentMessageAliasedToolNames: ReadonlySet | undefined; let convertedMuseToolNameAliases: Map | undefined; const unexpandedMiss = !!parsed.previousResponseId && parsed._previousResponseInputExpanded !== true; - let outBody = stripPreviousResponseId( - parsed._rawBody, + const replayPrefixLen = parsed._replayPrefixLen; + let outBody = parsed._rawBody; + // Clone and tag before any input rewrite can remove, replace, or reorder replay items. + if ( + typeof replayPrefixLen === "number" + && replayPrefixLen > 0 + && isPlainObject(outBody) + && Array.isArray(outBody.input) + ) { + outBody = { + ...outBody, + input: tagHistoricalInputItems(outBody.input, replayPrefixLen), + }; + } + outBody = stripPreviousResponseId( + outBody, forward || parsed._previousResponseInputExpanded === true, ); if (isMetaAiResponsesDestination(url)) { @@ -368,8 +383,15 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): // Last, so promoted namespace children are also cleared of Codex-private fields. outBody = stripCanonicalOnlyToolFields(outBody, provider.supportsOpenAiWebSearchToolFields === false); } - if (!forward) { - outBody = normalizeOpenCodeGoAdditionalTools(outBody, url, parsed._replayPrefixLen); + if ( + provider.promoteAdditionalTools === true + || (!forward && ( + isOpenCodeGoResponsesUrl(url) + || isXaiResponsesDestination(provider) + || isXaiResponsesUrl(url) + )) + ) { + outBody = promoteResponsesAdditionalTools(outBody); } // Same native-v2/portable predicate as the routedCompaction gate in handleResponses(): // neither forward auth nor native v1 endpoint support implies trigger support. The diff --git a/src/adapters/openai-responses/prompt-cache.ts b/src/adapters/openai-responses/prompt-cache.ts index dff927dd17f..a9b4b2a6e63 100644 --- a/src/adapters/openai-responses/prompt-cache.ts +++ b/src/adapters/openai-responses/prompt-cache.ts @@ -71,7 +71,9 @@ export function stripPromptCacheBreakpoints( if (!isPlainObject(value)) return { value, changed: false, complete: true }; let changed = Object.hasOwn(value, "prompt_cache_breakpoint"); - const next: Record = {}; + // Keep symbol provenance when rebuilding an input item or one of its nested fields. + const next: Record = { ...value }; + delete next.prompt_cache_breakpoint; for (const [key, entry] of Object.entries(value)) { if (key === "prompt_cache_breakpoint") continue; const rewritten = stripPromptCacheBreakpoints(entry, state, depth + 1); diff --git a/src/adapters/openai-responses/request-strips.ts b/src/adapters/openai-responses/request-strips.ts index 366d1ec34bc..9d64f6ed49e 100644 --- a/src/adapters/openai-responses/request-strips.ts +++ b/src/adapters/openai-responses/request-strips.ts @@ -287,12 +287,15 @@ const CANONICAL_ONLY_INPUT_ITEM_TYPES: ReadonlySet = new Set([ * {@link stripCanonicalOnlyTopLevelFields}; the canonical and OpenAI-operated destinations keep * the items verbatim and in order. Returns the input unchanged when none is present. */ +export function isCanonicalOnlyInputItem(item: unknown): boolean { + return isPlainObject(item) + && typeof item.type === "string" && CANONICAL_ONLY_INPUT_ITEM_TYPES.has(item.type); +} + export function stripCanonicalOnlyInputItems(body: unknown): unknown { if (!isPlainObject(body) || !Array.isArray(body.input)) return body; - const isPrivate = (item: unknown): boolean => isPlainObject(item) - && typeof item.type === "string" && CANONICAL_ONLY_INPUT_ITEM_TYPES.has(item.type); - if (!body.input.some(isPrivate)) return body; - return { ...body, input: body.input.filter(item => !isPrivate(item)) }; + if (!body.input.some(isCanonicalOnlyInputItem)) return body; + return { ...body, input: body.input.filter(item => !isCanonicalOnlyInputItem(item)) }; } /** diff --git a/src/adapters/openai-responses/tool-output-recovery.ts b/src/adapters/openai-responses/tool-output-recovery.ts index f2218ecb181..2605e1fdab7 100644 --- a/src/adapters/openai-responses/tool-output-recovery.ts +++ b/src/adapters/openai-responses/tool-output-recovery.ts @@ -3,6 +3,7 @@ import { EMPTY_TOOL_OUTPUT_ANNOTATION, isWhitespaceOnlyTextPartArray } from "../ import { isPlainObject } from "./internal"; import { peekBridgeSearchReplay } from "../../responses/bridge-search-replay-cache"; import { externalTaskInputResponsesContent } from "../../responses/task-input"; +import { copyItemProvenance } from "../../responses/item-provenance"; const MAX_RESPONSES_CALL_ID_LENGTH = 64; @@ -336,7 +337,7 @@ export function restoreBridgedWebSearchCalls(body: unknown, destinationScope: st if (memo && !occupiedCallIds.has(memo.callId)) { changed = true; occupiedCallIds.add(memo.callId); - restored.push({ + const fnCall: Record = { type: "function_call", ...(memo.sourceItemId ? { id: memo.sourceItemId } : {}), call_id: memo.callId, @@ -344,8 +345,16 @@ export function restoreBridgedWebSearchCalls(body: unknown, destinationScope: st // The bridge records the complete arguments text from the call's own done frame; the // empty-object fallback matches what a continuation leg would have sent. arguments: memo.argumentsText.length > 0 ? memo.argumentsText : "{}", - }); - restored.push({ type: "function_call_output", call_id: memo.callId, output: memo.output }); + }; + const fnOutput: Record = { + type: "function_call_output", + call_id: memo.callId, + output: memo.output, + }; + copyItemProvenance(item, fnCall); + copyItemProvenance(item, fnOutput); + restored.push(fnCall); + restored.push(fnOutput); continue; } } @@ -397,7 +406,7 @@ export function repairOrphanedInputItems( const taskInput = isFnOutput && !hasStringCallId ? externalTaskInputResponsesContent(item) : undefined; if (taskInput) { changed = true; - repaired.push({ type: "message", role: "user", content: taskInput }); + repaired.push(copyItemProvenance(item, { type: "message", role: "user", content: taskInput })); continue; } const callId = typeof item.call_id === "string" ? item.call_id : ""; @@ -409,11 +418,13 @@ export function repairOrphanedInputItems( const knownNullOutput = callId.length > 0 && item.output == null; if (repairOrphanOutputs && !paired && (knownNullOutput || usableOutput)) { changed = true; - repaired.push({ + const msg: Record = { type: "message", role: "user", content: orphanedToolOutputContent(item.output, callId), - }); + }; + copyItemProvenance(item, msg); + repaired.push(msg); continue; } } @@ -429,9 +440,11 @@ export function repairOrphanedInputItems( const name = typeof item.name === "string" && item.name.length > 0 ? item.name : callId; const text = `[ocx] no tool result was recorded for "${name}"; execution status unknown — do not treat this as success, failure, or user-provided input.`; syntheticKeys.add(`${isFnCall ? "function" : "custom"}:${callId}`); - pendingSyntheticOutputs.push(isFnCall + const synthetic: Record = isFnCall ? { type: "function_call_output", call_id: callId, output: text } - : { type: "custom_tool_call_output", call_id: callId, output: text }); + : { type: "custom_tool_call_output", call_id: callId, output: text }; + copyItemProvenance(item, synthetic); + pendingSyntheticOutputs.push(synthetic); } } continue; diff --git a/src/adapters/openai-responses/tool-schema.ts b/src/adapters/openai-responses/tool-schema.ts index 12d11093cdd..eb986999fb8 100644 --- a/src/adapters/openai-responses/tool-schema.ts +++ b/src/adapters/openai-responses/tool-schema.ts @@ -8,6 +8,7 @@ import { XaiToolSchemaCompatibilityError, } from "../xai-tool-schema"; import { isPlainObject } from "./internal"; +import { hasItemProvenance, isHistoricalItem } from "../../responses/item-provenance"; function normalizeFunctionToolSchema(tool: unknown, xaiTarget: boolean): unknown | undefined { if (!isPlainObject(tool) || tool.type !== "function") return tool; @@ -195,11 +196,19 @@ function mergeLoadedTools(declaredTools: unknown[], loadedTools: unknown[]): unk * serializing the raw request, so activate those returned definitions for upstreams that do not * implement the native deferred-loading handshake themselves. */ -export function promoteClientLoadedTools(body: unknown): unknown { +export function promoteClientLoadedTools(body: unknown, replayPrefixLength?: number): unknown { if (!isPlainObject(body) || !Array.isArray(body.input)) return body; - const loadedTools = body.input.flatMap(item => - isPlainObject(item) && item.type === "tool_search_output" && Array.isArray(item.tools) + const currentTurnStart = Number.isFinite(replayPrefixLength) + ? Math.max(0, Math.trunc(replayPrefixLength!)) + : 0; + const hasProvenance = body.input.some(hasItemProvenance); + const isCurrentItem = (item: unknown, index: number): boolean => + hasProvenance ? !isHistoricalItem(item) : index >= currentTurnStart; + + // Filter the source before declarations lose their item's provenance in a live catalog. + const loadedTools = body.input.flatMap((item, index) => + isCurrentItem(item, index) && isPlainObject(item) && item.type === "tool_search_output" && Array.isArray(item.tools) ? item.tools : [] ); @@ -210,17 +219,20 @@ export function promoteClientLoadedTools(body: unknown): unknown { return tools === body.tools ? body : { ...body, tools }; } - const additionalToolsIndex = body.input.findIndex(item => - isPlainObject(item) && item.type === "additional_tools" && Array.isArray(item.tools) + const currentAdditionalToolsIndex = body.input.findIndex((item, index) => + isCurrentItem(item, index) && isPlainObject(item) && item.type === "additional_tools" && Array.isArray(item.tools) ); - if (additionalToolsIndex < 0) return { ...body, tools: mergeLoadedTools([], loadedTools) }; - const additionalTools = body.input[additionalToolsIndex]; + if (currentAdditionalToolsIndex < 0) { + return { ...body, tools: mergeLoadedTools([], loadedTools) }; + } + + const additionalTools = body.input[currentAdditionalToolsIndex]; if (!isPlainObject(additionalTools) || !Array.isArray(additionalTools.tools)) return body; const tools = mergeLoadedTools(additionalTools.tools, loadedTools); if (tools === additionalTools.tools) return body; const input = [...body.input]; - input[additionalToolsIndex] = { ...additionalTools, tools }; + input[currentAdditionalToolsIndex] = { ...additionalTools, tools }; return { ...body, input }; } diff --git a/src/adapters/opencode-go-additional-tools.ts b/src/adapters/opencode-go-additional-tools.ts index ea41ec1aa17..c76b0ad8a00 100644 --- a/src/adapters/opencode-go-additional-tools.ts +++ b/src/adapters/opencode-go-additional-tools.ts @@ -1,3 +1,5 @@ +import { hasItemProvenance, isHistoricalItem } from "../responses/item-provenance"; + function isRecord(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } @@ -20,13 +22,32 @@ export function isOpenCodeGoResponsesUrl(responseUrl: string): boolean { && !destination.href.includes("?") && !destination.href.includes("#"); } -/** Console Go accepts public tools but rejects the private additional_tools input wrapper. */ -export function normalizeOpenCodeGoAdditionalTools( +const XAI_RESPONSES_HOSTS = new Set(["api.x.ai", "cli-chat-proxy.grok.com"]); + +/** + * True when the Responses target is an official xAI endpoint (public API or Grok CLI proxy). + */ +export function isXaiResponsesUrl(responseUrl: string): boolean { + let destination: URL; + try { + destination = new URL(responseUrl); + } catch { + return false; + } + return destination.protocol === "https:" + && XAI_RESPONSES_HOSTS.has(destination.hostname.toLowerCase()) + && (destination.port === "" || destination.port === "443"); +} + +/** + * Promotes client-loaded `additional_tools` input items into top-level `tools` and strips + * the wrapper from `input`. Required for gateways whose Rust/OpenAI-strict deserializer + * rejects unknown items in the `input` array. + */ +export function promoteResponsesAdditionalTools( body: unknown, - responseUrl: string, replayPrefixLength = 0, ): unknown { - if (!isOpenCodeGoResponsesUrl(responseUrl)) return body; if (!isRecord(body) || !Array.isArray(body.input)) return body; // Do not replace a malformed top-level catalog with a partial promoted one. if (body.tools !== undefined && !Array.isArray(body.tools)) return body; @@ -36,13 +57,15 @@ export function normalizeOpenCodeGoAdditionalTools( const currentTurnStart = Number.isFinite(replayPrefixLength) ? Math.min(body.input.length, Math.max(0, Math.trunc(replayPrefixLength))) : 0; + const hasProvenance = body.input.some(hasItemProvenance); let changed = false; for (const [index, item] of body.input.entries()) { if (isRecord(item) && item.type === "additional_tools" && Array.isArray(item.tools)) { changed = true; // Replayed wrappers are conversation history, not authority for the current request. - // Go cannot accept the wrapper itself, so remove it without promoting its catalog. - if (index < currentTurnStart) continue; + // Gateways cannot accept the wrapper itself, so remove it without promoting its catalog. + const isHistorical = hasProvenance ? isHistoricalItem(item) : (index < currentTurnStart); + if (isHistorical) continue; // Custom/search/namespace lowering already owns identity and deduplication. This pass // only moves declarations, including hosted tools that intentionally have no name. for (const tool of item.tools) promoted.push(tool); @@ -52,3 +75,13 @@ export function normalizeOpenCodeGoAdditionalTools( } return changed ? { ...body, input, tools: [...(body.tools ?? []), ...promoted] } : body; } + +/** Console Go accepts public tools but rejects the private additional_tools input wrapper. */ +export function normalizeOpenCodeGoAdditionalTools( + body: unknown, + responseUrl: string, + replayPrefixLength = 0, +): unknown { + if (!isOpenCodeGoResponsesUrl(responseUrl)) return body; + return promoteResponsesAdditionalTools(body, replayPrefixLength); +} diff --git a/src/adapters/routed-agent-messages.ts b/src/adapters/routed-agent-messages.ts index 2ca67162a72..e9c57d17cc6 100644 --- a/src/adapters/routed-agent-messages.ts +++ b/src/adapters/routed-agent-messages.ts @@ -1,3 +1,5 @@ +import { hasItemProvenance, isHistoricalItem, REPLAY_HISTORICAL_ITEM } from "../responses/item-provenance"; + /** * `agent_message` is Codex's private multi-agent input item: it exists only in the ChatGPT * Codex backend's schema. Codex replays every sub-agent reply in the history it sends, so @@ -36,6 +38,7 @@ export function normalizeRoutedAgentMessages( changed = true; return { type: "message", role: "user", + ...(hasItemProvenance(message) ? { [REPLAY_HISTORICAL_ITEM]: isHistoricalItem(message) } : {}), content: [ ...(Object.keys(identities).length ? [{ type: "input_text", text: `Agent message ${JSON.stringify(identities)}` }] : []), ...content, diff --git a/src/adapters/xai-web-search.ts b/src/adapters/xai-web-search.ts index 836eb0ca6df..d871234ae73 100644 --- a/src/adapters/xai-web-search.ts +++ b/src/adapters/xai-web-search.ts @@ -2,6 +2,7 @@ import type { OcxProviderConfig } from "../types"; import { debugProviderDiagnostic } from "../lib/debug"; import { isXaiResponsesDestination } from "../providers/xai-transport"; import { isOpenCodeGoResponsesUrl } from "./opencode-go-additional-tools"; +import { hasItemProvenance, isHistoricalItem } from "../responses/item-provenance"; const CODEX_WEB_SEARCH_TOOL = "web_search"; const CODEX_WEB_SEARCH_PREVIEW_TOOL = "web_search_preview"; @@ -190,8 +191,8 @@ export function normalizeXaiResponsesWebSearch( const xaiDestination = isXaiResponsesDestination(provider); if (!xaiDestination && !isOpenCodeGoGrokResponses(target?.modelId, target?.responseUrl)) return body; // On Go, a wrapper emptied here stays in place: Go promotion (opencode-go-additional-tools.ts) - // removes every wrapper before the wire and splits them into history and current turn by - // index against _replayPrefixLen, so deleting one would shift the current turn into history. + // removes every wrapper before the wire. Retain positions for callers with untagged input; + // transformed adapter input carries per-item historical/live provenance instead. const keepEmptiedWrappers = !xaiDestination; let next: Record = body; @@ -264,7 +265,9 @@ export function injectXaiResponsesXSearch( const input = Array.isArray(body.input) ? body.input : undefined; const inputStart = input ? currentInputStart(input.length, replayPrefixLength) : 0; - const currentInput = input?.slice(inputStart) ?? []; + const currentInput = input?.some(hasItemProvenance) + ? input.filter(item => !isHistoricalItem(item)) + : input?.slice(inputStart) ?? []; const currentXSearchDeclared = hasToolType(body.tools, XAI_SEARCH_TOOL) || currentInput.some(item => isPlainObject(item) diff --git a/src/adapters/zed-additional-tools.ts b/src/adapters/zed-additional-tools.ts index 73a18ca4533..10b52677e52 100644 --- a/src/adapters/zed-additional-tools.ts +++ b/src/adapters/zed-additional-tools.ts @@ -1,4 +1,5 @@ import { isPlainObject as isRecord } from "./openai-responses/internal"; +import { hasItemProvenance, isHistoricalItem, tagHistoricalInputItems } from "../responses/item-provenance"; function toolKey(tool: Record): string | undefined { if (typeof tool.type !== "string" || !tool.type) return undefined; @@ -8,8 +9,8 @@ function toolKey(tool: Record): string | undefined { /** * Zed's OpenAI Responses schema accepts message and call items, not Codex's private - * additional_tools wrapper. Drop every wrapper. Promote only wrappers at or after the - * replayed prefix: earlier ones are history, not the current turn's tool catalog. + * additional_tools wrapper. Drop every wrapper. Item provenance owns current-turn + * membership; a numeric prefix is accepted only for input not yet tagged. * A non-array tools field is left untouched so a malformed catalog is not replaced * by a partial promotion. */ @@ -17,16 +18,15 @@ export function liftZedAdditionalTools(providerRequest: Record, if (!Array.isArray(providerRequest.input)) return; if (providerRequest.tools !== undefined && !Array.isArray(providerRequest.tools)) return; - const currentTurnStart = Number.isFinite(replayPrefixLength) - ? Math.min(providerRequest.input.length, Math.max(0, Math.trunc(replayPrefixLength))) - : 0; + const taggedInput = providerRequest.input.some(hasItemProvenance) ? providerRequest.input + : tagHistoricalInputItems(providerRequest.input, Number.isFinite(replayPrefixLength) ? replayPrefixLength : 0); const input: unknown[] = []; const promoted: unknown[] = []; let changed = false; - for (const [index, item] of providerRequest.input.entries()) { + for (const item of taggedInput) { if (isRecord(item) && item.type === "additional_tools" && Array.isArray(item.tools)) { changed = true; - if (index >= currentTurnStart) promoted.push(...item.tools); + if (!isHistoricalItem(item)) promoted.push(...item.tools); continue; } input.push(item); diff --git a/src/adapters/zed.ts b/src/adapters/zed.ts index a9bb6104df4..e156a59748c 100644 --- a/src/adapters/zed.ts +++ b/src/adapters/zed.ts @@ -354,12 +354,7 @@ export function createZedAdapter(provider: OcxProviderConfig, cacheRetention?: A : item, ); } - if (zedProvider === "open_ai") { - // Codex carries this turn's client tools in a private additional_tools input item. - // Zed rejects that variant, so promote the current turn and drop every wrapper - // before the built-in tool filter runs. - liftZedAdditionalTools(providerRequest, parsed._replayPrefixLen ?? 0); - } + // OpenAI wrappers were promoted before delegation; serialized input has no provenance tags. if (zedProvider === "open_ai" && Array.isArray(providerRequest.tools)) { // Zed's Responses decoder supports function/custom tools, not Codex's built-in web_search. const supported = (tool: unknown) => isRecord(tool) && (tool.type === "function" || tool.type === "custom"); diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts index 245222ad08e..3eacf076d56 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -360,6 +360,7 @@ export const providerConfigSchema = z.object({ inferContextWindow: z.boolean().optional(), modelSupportsServiceTier: z.record(z.string().min(1), z.boolean()).optional(), modelSuppressSyntheticMax: z.record(z.string().min(1), z.boolean()).optional(), + promoteAdditionalTools: z.boolean().optional(), preserveResponsesReasoningContent: z.boolean().optional(), preserveResponsesInputItemIds: z.boolean().optional(), preserveResponsesMessageMetadata: z.boolean().optional(), diff --git a/src/providers/model-rename-fields.ts b/src/providers/model-rename-fields.ts index ea003cedccc..a97202d8bdc 100644 --- a/src/providers/model-rename-fields.ts +++ b/src/providers/model-rename-fields.ts @@ -29,6 +29,7 @@ export const PROVIDER_MODEL_RENAME_ROLES = { supportsServiceTier: "none", fastEnabled: "none", modelSupportsServiceTier: "record", + promoteAdditionalTools: "none", preserveResponsesReasoningContent: "none", preserveResponsesInputItemIds: "none", preserveResponsesMessageMetadata: "none", diff --git a/src/responses/custom-tool-compat.ts b/src/responses/custom-tool-compat.ts index 4b15bc9f9ff..30a894f06c2 100644 --- a/src/responses/custom-tool-compat.ts +++ b/src/responses/custom-tool-compat.ts @@ -232,7 +232,8 @@ function rewriteForUpstream( } let changed = false; - const next: Record = {}; + // Recursive tool lowering must retain the input item's historical/live symbol marker. + const next: Record = { ...value }; for (const [key, entry] of Object.entries(value)) { const rewritten = rewriteForUpstream(entry, names, callIds); next[key] = rewritten; diff --git a/src/responses/item-provenance.ts b/src/responses/item-provenance.ts new file mode 100644 index 00000000000..4cf5b7c365f --- /dev/null +++ b/src/responses/item-provenance.ts @@ -0,0 +1,79 @@ +export const REPLAY_HISTORICAL_ITEM = Symbol.for("ocx.responses.replayHistoricalItem"); + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Mark an input item object as belonging to the historical replay prefix. + * Uses an enumerable symbol so it is preserved across shallow object spreads (`{ ...item }`), + * but completely ignored by `JSON.stringify` and `Object.keys`. + */ +export function markHistoricalItem(item: T): T { + Object.defineProperty(item, REPLAY_HISTORICAL_ITEM, { + value: true, + enumerable: true, + configurable: true, + writable: true, + }); + return item; +} + +/** + * Check whether an input item originated in the historical replay. + */ +export function isHistoricalItem(item: unknown): boolean { + return ( + typeof item === "object" + && item !== null + && (item as Record)[REPLAY_HISTORICAL_ITEM] === true + ); +} + +/** A live marker survives even when a rewrite removes every historical item. */ +export function hasItemProvenance(item: unknown): boolean { + return typeof item === "object" && item !== null + && typeof (item as Record)[REPLAY_HISTORICAL_ITEM] === "boolean"; +} + +/** Replacement and synthesized items inherit the source's historical or live tag. */ +export function copyItemProvenance(source: unknown, target: T): T { + if (hasItemProvenance(source)) { + Object.assign(target, { [REPLAY_HISTORICAL_ITEM]: isHistoricalItem(source) }); + } + return target; +} + +/** + * Tag both historical and live items so an empty replay prefix remains distinguishable + * from an input that has never been tagged. Input that already carries provenance keeps it: + * an earlier rewrite (Zed lifting a replayed wrapper) may have shifted items past the + * numeric prefix, so re-tagging by index would mislabel current-turn items as history. + */ +export function tagHistoricalInputItems(input: unknown[], prefixLength: number): unknown[] { + if (!Array.isArray(input)) return input; + if (input.some(hasItemProvenance)) return input; + const prefix = Math.min(input.length, Math.max(0, Math.trunc(prefixLength))); + if (prefix <= 0) return input; + return input.map((item, index) => { + if (isPlainObject(item)) { + return { ...item, [REPLAY_HISTORICAL_ITEM]: index < prefix }; + } + return item; + }); +} + +/** + * Compute the current replay prefix length dynamically. + * If items carry provenance markers, the boundary is the index of the first non-historical item. + * Otherwise, falls back to the provided fallback prefix length. + */ +export function computeReplayPrefixLength(input: unknown[], fallback = 0): number { + if (!Array.isArray(input)) return fallback; + const hasProvenance = input.some(hasItemProvenance); + if (!hasProvenance) { + return Math.min(input.length, Math.max(0, Math.trunc(fallback))); + } + const firstLiveIndex = input.findIndex(item => !isHistoricalItem(item)); + return firstLiveIndex === -1 ? input.length : firstLiveIndex; +} diff --git a/src/responses/namespace-tool-compat.ts b/src/responses/namespace-tool-compat.ts index 24b26e8a2ef..0c24ac3e87e 100644 --- a/src/responses/namespace-tool-compat.ts +++ b/src/responses/namespace-tool-compat.ts @@ -1,6 +1,7 @@ import { dottedToolName, namespacedToolName } from "../types"; import { collectResponsesToolGroups } from "./tool-groups"; import { collectAmbiguousDottedAliases, dottedAliasIsUnambiguous } from "./tool-name-aliases"; +import { hasItemProvenance, isHistoricalItem } from "./item-provenance"; export interface RoutedNamespaceToolIdentity { namespace: string; @@ -344,13 +345,25 @@ function rewriteInputItem(item: unknown, plan: NamespaceRewritePlan, emitted: Se export function rewriteRoutedNamespaceToolsForUpstream( body: unknown, convertedCustomToolNames?: ReadonlySet, + replayPrefixLength?: number, ): { body: unknown; aliases: Map; } { if (!isPlainObject(body)) return { body, aliases: new Map() }; - const groups = collectResponsesToolGroups(body); + const currentTurnStart = Number.isFinite(replayPrefixLength) + ? Math.max(0, Math.trunc(replayPrefixLength!)) + : 0; + const hasProvenance = Array.isArray(body.input) && body.input.some(hasItemProvenance); + const isHistorical = (item: unknown, index: number) => + hasProvenance ? isHistoricalItem(item) : index < currentTurnStart; + const groups = collectResponsesToolGroups(Array.isArray(body.input) + ? { ...body, input: body.input.filter((item, index) => !isHistorical(item, index)) } + : body); const plan = buildRewritePlan(groups); + const historicalPlan = buildRewritePlan(collectResponsesToolGroups({ + input: Array.isArray(body.input) ? body.input.filter(isHistorical) : [], + })); // Deliberately not gated on the plan being non-empty: a turn whose catalog is absent can still // replay call items carrying a private `namespace`. @@ -360,8 +373,13 @@ export function rewriteRoutedNamespaceToolsForUpstream( let input = body.input; if (Array.isArray(body.input)) { let inputChanged = false; - const rewrittenInput = body.input.map(item => { - const next = rewriteInputItem(item, plan, emitted); + const historicalEmitted = new Set(); + const rewrittenInput = body.input.map((item, index) => { + const historicalCatalog = isHistorical(item, index) && isPlainObject(item) && item.type === "additional_tools"; + const itemEmitted = historicalCatalog + ? historicalEmitted + : emitted; + const next = rewriteInputItem(item, historicalCatalog ? historicalPlan : plan, itemEmitted); if (next !== item) inputChanged = true; return next; }); diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 4d124d4ac7e..11059690ed2 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -1089,6 +1089,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = { annotateEmptyToolOutputs: "editor", supportsServiceTier: "editor", modelSupportsServiceTier: "editor", + promoteAdditionalTools: "editor", preserveResponsesReasoningContent: "editor", preserveResponsesInputItemIds: "editor", preserveResponsesMessageMetadata: "editor", diff --git a/src/server/management/provider-overwrite-carry.ts b/src/server/management/provider-overwrite-carry.ts index 08f3ceff314..6b1a9b0d550 100644 --- a/src/server/management/provider-overwrite-carry.ts +++ b/src/server/management/provider-overwrite-carry.ts @@ -51,6 +51,7 @@ const RETRY_POLICY_VALIDATORS = { export const PROVIDER_COMPAT_CARRY_FIELDS = [ "preserveReasoningContentModels", "preserveResponsesReasoningContent", + "promoteAdditionalTools", "requiresReasoningPlaceholderModels", "foldDeveloperRoleToSystem", "singleLeadingSystemMessage", @@ -167,6 +168,8 @@ export function providerCompatFieldConfigError(provider: Record if (hideRawReasoning !== undefined && typeof hideRawReasoning !== "boolean") return "hideRawReasoning must be a boolean"; const preserveResponses = provider.preserveResponsesReasoningContent; if (preserveResponses !== undefined && typeof preserveResponses !== "boolean") return "preserveResponsesReasoningContent must be a boolean"; + const promoteAdditionalTools = provider.promoteAdditionalTools; + if (promoteAdditionalTools !== undefined && typeof promoteAdditionalTools !== "boolean") return "promoteAdditionalTools must be a boolean"; const wire = provider.reasoningWireFormat; if (wire !== undefined && !PROVIDER_REASONING_WIRE_FORMATS.includes(wire as never)) { return `reasoningWireFormat must be one of: ${PROVIDER_REASONING_WIRE_FORMATS.join(", ")}`; @@ -225,6 +228,13 @@ export function applyProviderCompatPatchFields( else return { error: "preserveResponsesReasoningContent must be a boolean" }; touched = true; } + if (Object.hasOwn(rawBody, "promoteAdditionalTools")) { + const value = rawBody.promoteAdditionalTools; + if (value === null) delete next.promoteAdditionalTools; + else if (typeof value === "boolean") next.promoteAdditionalTools = value; + else return { error: "promoteAdditionalTools must be a boolean" }; + touched = true; + } if (Object.hasOwn(rawBody, "inferContextWindow")) { const value = rawBody.inferContextWindow; if (value === null) delete next.inferContextWindow; diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 84d0e1752b5..75a833095ad 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -908,6 +908,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise 0, allowPrivateNetwork: p.allowPrivateNetwork === true, preserveResponsesReasoningContent: p.preserveResponsesReasoningContent, + promoteAdditionalTools: p.promoteAdditionalTools, liveModels: p.liveModels !== false, requestPacing: p.requestPacing, models: p.models ?? [], diff --git a/src/server/responses-undeclared-tool-guard.ts b/src/server/responses-undeclared-tool-guard.ts index d64a802b2ab..6a2784c7c68 100644 --- a/src/server/responses-undeclared-tool-guard.ts +++ b/src/server/responses-undeclared-tool-guard.ts @@ -1,4 +1,5 @@ import { UNDECLARED_TOOL_CALL_ERROR_CODE } from "../lib/errors"; +import { hasItemProvenance, isHistoricalItem } from "../responses/item-provenance"; import { collectAmbiguousDottedAliases, dottedAliasIsUnambiguous, @@ -136,6 +137,9 @@ export function currentTurnWireToolCatalogBody( replayPrefixLength: number | undefined, ): unknown { if (!isPlainObject(body) || !Array.isArray(body.input)) return body; + if (body.input.some(hasItemProvenance)) { + return { ...body, input: body.input.filter(item => !isHistoricalItem(item)) }; + } if (typeof replayPrefixLength !== "number" || !Number.isFinite(replayPrefixLength)) return body; const start = Math.min(body.input.length, Math.max(0, Math.trunc(replayPrefixLength))); if (start === 0) return body; diff --git a/src/server/responses/passthrough-dispatch.ts b/src/server/responses/passthrough-dispatch.ts index fdabdf1de4c..86d3afdcbea 100644 --- a/src/server/responses/passthrough-dispatch.ts +++ b/src/server/responses/passthrough-dispatch.ts @@ -482,10 +482,9 @@ export async function preparePassthroughExchange( for (const callType of collectProviderExecutedCallTypes(clientToolAuthorizationBody)) { providerExecutedCallTypes.add(callType); } - const currentOutboundCatalog = currentTurnWireToolCatalogBody( - outboundRequestBody, - replayedInputPrefixLength, - ); + // Serialization drops provenance, so never slice transformed input by the raw prefix. + // Current wrappers were captured above; adapter-injected hosted tools are top-level. + const currentOutboundCatalog = { tools: outboundRequestBody?.tools }; for (const callType of collectProviderExecutedCallTypes(currentOutboundCatalog)) { providerExecutedCallTypes.add(callType); } diff --git a/src/types/provider.ts b/src/types/provider.ts index 61c66de5bc5..c2358cc46d7 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -434,6 +434,14 @@ export interface OcxProviderConfig { fastEnabled?: boolean; /** Exact upstream model ids that override the provider-level service-tier capability. */ modelSupportsServiceTier?: Record; + /** + * Whether to promote Codex Desktop's `additional_tools` input items into top-level `tools` + * and strip the wrapper from `input`. Enabled automatically for OpenCode Go and xAI + * (`api.x.ai` and `cli-chat-proxy.grok.com`) except on forward-auth routes, which need an + * explicit `true`; other strict gateways that return HTTP 422 for unknown item types in + * `input` can opt in here. + */ + promoteAdditionalTools?: boolean; /** * Responses upstream whose native contract accepts plaintext reasoning replay * (DeepSeek documents reasoning items with plaintext content). When set, the diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index ef49522edeb..bbdb03ccf55 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -275,7 +275,7 @@ per-request first-party callback reads that live object; a failed write leaves i | Startup safety | `GET /api/startup-health` reports whether injected Codex routing is restart-safe, with secret-free service/shim diagnostics. `POST /api/startup-action` provides allowlisted one-click installation for the background service or launcher shim. On Windows a healthy script shim is CLI-only; Codex Desktop requires the background service for full protection. Optional Codex compatibility uses the [confirmed local runtime](clients/codex-desktop.md#optional-compatibility-runtime), [machine-local dashboard consent](clients/codex-desktop.md#dashboard-controls), and [revision-bound next-start preference with shared awaited teardown](clients/codex-desktop.md#proxy-startup-preference); saving that preference performs no runtime action. | | Windows tray | `GET/POST /api/windows-tray` controls an owned, per-user HKCU login tray. The tray delegates fixed actions to the CLI and is never a proxy supervisor or restart-protection signal. | | Updates | `GET /api/update/check`, `POST /api/update/run`, and `GET /api/update/status` own dashboard self-update state. A launched worker PID is persisted in `update-job.json`; dead PIDs recover immediately, while legacy active records without a PID recover only after ten minutes. Live PIDs remain exclusive regardless of record age. `GET /api/update/check` and `POST /api/update/run` await one per-channel asynchronous registry lookup and write successful results through to the package cache; run passes that result to the job starter. `GET /api/update/badge` only reads the cache and reports unknown after 40 hours, on missing cache, or on channel mismatch. The badge links to the update surface rather than gating other actions. `GET /api/update/badge?surface=desktop&session=` projects only that process-local Tauri session; missing or expired state is unknown and never falls back to the package cache. `POST /api/update/desktop-snapshot` is a 1 KiB bounded display-state mutation with no install permission. It accepts the existing admin-token principal or a dedicated single-use, ten-second snapshot capability bound to nonce, method, path, PID, port and the SHA-256 digest of the exact body bytes; that capability cannot authorize another route. | -| Providers | Create/update/delete ordinary provider configs and enrich registry metadata. A `POST /api/providers` overwrite of an existing name keeps the nine operator compatibility settings (`PROVIDER_COMPAT_CARRY_FIELDS` in `src/server/management/provider-overwrite-carry.ts`) and the stored key pool only while the destination (adapter, normalized base URL, auth mode when named) is unchanged. It keeps omitted `hideRawReasoning` on every overwrite of the same name, including destination moves, because it is a display policy; a submitted boolean wins. An omitted `inferContextWindow` likewise keeps the latest live same-name state after asynchronous validation, including `false` or a concurrent clear; a submitted boolean wins. It never merges the rest of the old row. `PATCH` is a field mask and keeps every field it does not name; `hideRawReasoning`, `preserveResponsesReasoningContent` and `inferContextWindow` accept a boolean or `null` to clear them, rejecting other types. `GET /api/providers` exposes configured `inferContextWindow` as an optional boolean without substituting a default; its [custom-only, default-off inference policy](catalog-vendor-context.md) is unchanged. Custom Responses provider forms expose `preserveResponsesReasoningContent` as a default-off plaintext replay switch; unrelated settings saves preserve omission and curated/OpenAI destinations keep their existing defaults. The reserved `openai` card exposes Pool(default)/Direct account mode; `openai-apikey` remains the separate API route. | +| Providers | Create/update/delete ordinary provider configs and enrich registry metadata. A `POST /api/providers` overwrite of an existing name keeps the ten operator compatibility settings (`PROVIDER_COMPAT_CARRY_FIELDS` in `src/server/management/provider-overwrite-carry.ts`) and the stored key pool only while the destination (adapter, normalized base URL, auth mode when named) is unchanged. It keeps omitted `hideRawReasoning` on every overwrite of the same name, including destination moves, because it is a display policy; a submitted boolean wins. An omitted `inferContextWindow` likewise keeps the latest live same-name state after asynchronous validation, including `false` or a concurrent clear; a submitted boolean wins. It never merges the rest of the old row. `PATCH` is a field mask and keeps every field it does not name; `hideRawReasoning`, `preserveResponsesReasoningContent`, `promoteAdditionalTools` and `inferContextWindow` accept a boolean or `null` to clear them, rejecting other types. `GET /api/providers` exposes configured `inferContextWindow` as an optional boolean without substituting a default; its [custom-only, default-off inference policy](catalog-vendor-context.md) is unchanged. Custom Responses provider forms expose `preserveResponsesReasoningContent` as a default-off plaintext replay switch; unrelated settings saves preserve omission and curated/OpenAI destinations keep their existing defaults. The reserved `openai` card exposes Pool(default)/Direct account mode; `openai-apikey` remains the separate API route. | | Models | Fetch routed model lists, disabled model visibility, and catalog-facing ids. New non-OAuth registration holds exposure until authoritative discovery; 20 or more distinct switch rows start OFF without disabling the provider. Pending rows cannot accept visibility changes. Ordinary model reads also apply the [new-arrival catalog contract](catalog.md#shared-catalog) before returning models. With a matching persisted configuration, discovery records the baseline, NEW badges, and automatic disables together; HTTP listing does not wait for a separate Codex sync. Inventory already drifted from disk uses a detached policy projection: arrivals remain disabled in management rows without changing live config or disk. | | OAuth | Login/status/logout for OAuth-backed providers, plus multiauth account management: `GET /api/oauth/accounts`, `PUT /api/oauth/accounts/active`, `PUT /api/oauth/accounts/alias`, `PUT /api/oauth/accounts/pause`, and `DELETE /api/oauth/accounts` list masked accounts per provider, switch the active one, edit its display-only alias, pause/resume a generic OAuth account, and remove one. Paused generic OAuth accounts are excluded from request selection, 429 failover, and proactive token refresh; pausing an active account selects the next usable account when available, and resuming an account restores an active selection if the current one remains paused. With no unpaused account, requests return 403 rather than a login error. Kiro account-list rows include the current automatic-selection projection and closed exclusion reason; an active singleton may still send. The login flow itself is `GET /api/oauth/providers`, `POST /api/oauth/login`, `POST /api/oauth/login/code`, `POST /api/oauth/login/cancel`, `POST /api/oauth/logout`, and `GET /api/oauth/status`; pool controls are `GET/PUT/PATCH /api/oauth/accounts/pool` and `POST /api/oauth/accounts/clear-cooldown`. Login accepts `addAccount: true` to force a fresh browser identity. Meta Muse login start and manual-code continuation require the server-resolved `gui-session` principal before credential acquisition or code submission (including reauth); see the [provider contract](providers-and-adapters.md). Device flows return a structured `deviceCode`; the GUI highlights and copies it before the user opens the verification page. Google Antigravity account rows in `gui/src/components/provider-workspace/ProviderAuthPanel.tsx` display the account-summary `plan` as a badge and include it in the account button’s accessible name. The value is the canonical upstream tier ID (for example `free-tier` or `gemini-pro`), preserved literally across locales rather than inferred as a product name. Absent plans render no badge; pause and reauthentication controls keep their existing behavior. | | Key providers | `GET /api/key-providers` exposes API-key provider presets for setup and dashboard flows, and `GET/POST/DELETE /api/keys` owns the proxy's own admission keys. Machine links live in `src/server/management/link-routes.ts`: dashboard sessions reach `GET /api/link/candidates`, `POST /api/link/probe`, `POST /api/link/confirm-host` and `POST /api/link/apply`, meaning a paired session or, on a standalone runtime, the current loopback-issued session on trusted loopback ingress; `POST /api/link/join` instead requires an operator-paired session because it durably redirects local client traffic, and also refuses a runtime that is not standalone (`409 standalone_required`) or not listening on its configured port (`409 join_port_mismatch`) before any SSH, then restarts this runtime as a client; `GET /api/link/status` reports `joinAvailable` and typed `joinDenied` causes only to GUI-session callers, distinguishing pairing, runtime-role and port refusals without changing the admin/CLI DTO, so the dashboard enables the Child role only when the join gates pass; `GET /api/link/status`, `POST /api/link/{id}/reconnect` and `DELETE /api/link/{id}` also accept the admin token on a trusted loopback ingress, and reconnect restarts a Home-initiated link's tunnel now; `POST /api/link/issue` accepts only that admin token. Tailscale-identity sessions are refused on every link route. See [Remote Link](remote-link.md). Multi-key pool per key-auth provider: `GET /api/providers/keys`, `POST /api/providers/keys`, `PUT /api/providers/keys/active`, `PUT /api/providers/keys/alias`, `DELETE /api/providers/keys` masked list, add (upsert + activate), switch, rename, and remove keys. `provider.apiKey` always mirrors the active pool entry so routing stays single-key. | diff --git a/structure/transports/responses-wire-shapes.md b/structure/transports/responses-wire-shapes.md index a8b49f9dde0..eced4076992 100644 --- a/structure/transports/responses-wire-shapes.md +++ b/structure/transports/responses-wire-shapes.md @@ -157,13 +157,13 @@ policy. Explicitly selected Codex Lite ordinary unreferenced hosted-history lowering follows [the hosted-search continuation boundary](../providers-and-adapters.md#hosted-search-continuation-binding); non-Lite/unknown requests, native compact and public API retain structural history; portable compaction keeps its existing trigger and default projection. Go's non-forward Responses request path moves valid `additional_tools` wrappers into top-level `tools` through `src/adapters/opencode-go-additional-tools.ts`. Placement runs after existing custom/search/namespace lowering and before code-mode, compaction and final hosted-tool pruning. -It does not recalculate wire identities or response aliases. The matcher reads the constructed +It reads per-item replay provenance without recalculating wire identities or response aliases. The matcher reads the constructed send URL, resolving it with URL semantics, and requires HTTPS `opencode.ai`, the standard port and exact `/zen/go/v1/responses`. Normal and endpoint-inclusive bases or split `responsesPath` configurations agree; a custom path resolving to Zen or elsewhere does not acquire Go placement. Credentials, query, fragment, foreign hosts and other resource paths are excluded. The existing URL constructor canonicalizes trailing base slashes before this check. Malformed wrappers remain unchanged and -the shared mixed-ciphertext agent-message gate remains fail-closed. +the shared mixed-ciphertext agent-message gate remains fail-closed. Responses passthrough clones and tags historical and current input items before any rewrite, including identity repair; symbol provenance survives message replacement, prompt-cache cleanup, filtering and tool lowering without entering serialized requests. Promotion uses provenance rather than positions in shortened input. Discovery-source filtering follows the [current-turn catalog contract](responses.md#responses-httpsse) before nested tool definitions enter a live catalog. The canonical `opencode-go` registry entry defaults to `statelessResponses: true` because Go rejects reasoning ciphertext combined with `previous_response_id` (#3838). Existing derive @@ -221,7 +221,7 @@ the destination is the whole predicate, because Meta's refusal is a gateway sche Muse model it serves, its default `muse-spark-1.3` is not a Contributor id, and a missing model id still strips. Because the predicate is the host, a custom provider pointed at that exact URL gets the same strip. -Meta's `api.meta.ai` route uses `src/adapters/openai-responses/muse-tool-catalog.ts` to capture current declarations before history rewrites or deduplication: current wrappers become `tools`, declarations retain their schemas over search discoveries, historical discovery stays replay data, and historical custom calls retain their conversion without live authority. Historical catalogs are not validated as current declarations. The reserved `functions` group and nested function definitions become public function declarations. Catalog validation also covers string/omitted input; unrepresentable namespace children and malformed current catalogs fail locally with HTTP 400 (`tests/responses/responses-muse-tool-choice.test.ts`). +Meta's `api.meta.ai` route uses `src/adapters/openai-responses/muse-tool-catalog.ts` to capture current declarations before history rewrites or deduplication: current wrappers become `tools`, declarations retain their schemas over search discoveries, historical discovery stays replay data, and historical custom calls retain their conversion without live authority. Historical catalogs are not validated as current declarations; once input items carry replay provenance tags, the tags decide membership instead of the numeric prefix, so an earlier rewrite that shortened the input (Zed lifting a replayed wrapper) cannot shift the boundary. The reserved `functions` group and nested function definitions become public function declarations. Catalog validation also covers string/omitted input; unrepresentable namespace children and malformed current catalogs fail locally with HTTP 400 (`tests/responses/responses-muse-tool-choice.test.ts`). Direct Meta Muse / Meta Model Responses (`https://api.meta.ai/v1`) also rejects function tool names longer than 64 characters or containing characters outside `[a-zA-Z0-9_-]`. After namespace flattening, `src/responses/muse-tool-name-alias.ts` rewrites those identities on the `api.meta.ai` diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 82585590f56..d67380d7ec7 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -203,27 +203,24 @@ when the shared xAI normalizer can preserve its meaning; an unsafe function is o letting one incompatible declaration reject the entire request before inference. This is scoped to `cli-chat-proxy.grok.com`: public `api.x.ai` keeps native root unions, as do unrelated Responses gateways. Both top-level `tools` and Responses Lite `additional_tools` pass through this policy. +Codex Desktop packages client tools inside an `additional_tools` input item; strict gateways rejecting non-ResponseItem input types—including official xAI endpoints (`api.x.ai`, `cli-chat-proxy.grok.com`), OpenCode Go, and custom providers opting in with `promoteAdditionalTools: true`—promote declarations to top-level `tools` and strip the wrapper from `input`. `src/responses/item-provenance.ts` tags historical and live input objects with JSON-invisible symbols before replay repair; historical/live tags survive replacements, synthesized outputs and bridge restoration; transformed-input consumers read each tag, never a recalculated prefix. The numeric replay prefix tags only input that carries no tag yet, so a rewrite that already tagged and then shortened the input (Zed lifting a replayed wrapper) keeps a current-turn `tool_search_output` current. +Historical wrappers retain call-rewriting context with separate rewrite plans and declaration sets. Only current-turn `tool_search_output` discoveries join a live wrapper, top-level tools or the top-level fallback; source provenance is checked before merging declarations. Historical discoveries remain replay context and grant no current hosted-tool authority. `tests/providers/xai/xai-loaded-tool-provenance.test.ts` covers these paths and stored two-turn continuations. -Only the ROOT rejects a union, so exclusivity is preserved by moving it down rather than widening -it: a root `oneOf` whose branches differ in one property becomes that property's `oneOf`, or its -`anyOf` when the branches are provably disjoint and the two keywords describe the same set. That -property is also promoted into `required`, because absent it matched every branch — which the root -`oneOf` rejects. Branches that are wholly identical validate nothing and have no faithful -flattening, so they omit the tool. The walk carries depth, node, and variant budgets, since nested -unions are combinatorial and a `$ref` diamond amplifies the same way without ever cycling; -exceeding a budget omits that one function rather than expanding until memory is gone. - -Omitting a function makes `tool_choice` the loose end. A selector naming a dropped tool would reach -Grok as a dangling reference, and relaxing it to `auto` is worse — the turn would quietly run -without the tool the caller required. So an `allowed_tools` list drops the omitted entries while any -remain, and a selection with nothing left to point at fails locally with the same 400 a tool catalog -this proxy cannot lower already returns. +Only the ROOT rejects a union, so exclusivity is preserved by moving it down rather than widening it: +a root `oneOf` whose branches differ in one property becomes that property's `oneOf`, or its `anyOf` +when the branches are provably disjoint and the two keywords describe the same set. That property is also +promoted into `required`, because absent it matched every branch — which the root `oneOf` rejects. +Branches that are wholly identical validate nothing and have no faithful flattening, so they omit the tool. +The walk carries depth, node, and variant budgets, since nested unions are combinatorial and a `$ref` +diamond amplifies the same way without ever cycling; exceeding a budget omits that one function. + +Omitting a function makes `tool_choice` the loose end: a selector naming a dropped tool would reach +Grok as a dangling reference, and relaxing to `auto` runs without the tool the caller required. +An `allowed_tools` list drops omitted entries while any remain, and an empty selection fails with 400. The same noncanonical boundary strips ChatGPT's private `external_web_access` bit from routed -`web_search` declarations. The public tool remains enabled and all other options remain intact; -canonical OpenAI forwarding preserves the bit. xAI's public Responses schema enables browsing by -the presence of `web_search` and rejects the private argument, so forwarding it made the first -post-namespace request fail with HTTP 400. +`web_search` declarations while keeping the public tool and other options intact (canonical OpenAI +forwarding preserves the bit; xAI rejects the private argument with HTTP 400). The option-aware `openai` provider uses `openai-responses` with `authMode: "forward"`. Pool mode resolves main plus added accounts through affinity/quota/cooldown ownership; Direct forwards only diff --git a/tests/adapters/routed-agent-messages.test.ts b/tests/adapters/routed-agent-messages.test.ts index 2a44d118622..4e247cd1615 100644 --- a/tests/adapters/routed-agent-messages.test.ts +++ b/tests/adapters/routed-agent-messages.test.ts @@ -1,12 +1,24 @@ import { expect, test } from "bun:test"; import { createResponsesPassthroughAdapter } from "../../src/adapters/openai-responses"; import { normalizeRoutedAgentMessages } from "../../src/adapters/routed-agent-messages"; +import { hasItemProvenance, isHistoricalItem, tagHistoricalInputItems } from "../../src/responses/item-provenance"; import { parseRequest } from "../../src/responses/parser"; import { routeModel } from "../../src/router"; import { createTranslatorBudget } from "../../src/lib/translator-budget"; import type { OcxProviderConfig } from "../../src/types"; const base: OcxProviderConfig = { adapter: "openai-responses", baseUrl: "https://opencode.ai/zen/go/v1", authMode: "key", apiKey: "synthetic-key" }; + +test("agent-message replacement carries historical and live provenance", () => { + const input = tagHistoricalInputItems([ + { type: "agent_message", content: [{ type: "input_text", text: "past" }] }, + { type: "agent_message", content: [{ type: "input_text", text: "current" }] }, + ], 1); + const result = normalizeRoutedAgentMessages({ input }) as { input: unknown[] }; + expect(result.input.map(hasItemProvenance)).toEqual([true, true]); + expect(result.input.map(isHistoricalItem)).toEqual([true, false]); + expect(JSON.stringify(result)).not.toContain("replayHistoricalItem"); +}); const body = () => ({ model: "muse-spark-1.3-contributor", input: [{ type: "agent_message", id: "amsg_test", author: "/root/reader", recipient: "/root/checker", content: [{ type: "input_text", text: "Exact assignment\nwith lines." }] }], stream: true }); test("Responses converts plaintext task and peer messages without mutating replay or losing routing identities", async () => { diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index e3ac7ff2a95..7b19eb71736 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1494,6 +1494,9 @@ "ws-replay-refusal-attribution.test.ts": "responses", "combo-replay-refusal-attribution.test.ts": "responses", "ws-upstream.test.ts": "responses", + "xai-additional-tools.test.ts": "providers/xai", + "xai-replay-provenance.test.ts": "providers/xai", + "xai-loaded-tool-provenance.test.ts": "providers/xai", "xai-client.test.ts": "images", "xai-empty-catalog-tool-choice.test.ts": "providers/xai", "xai-no-stop.test.ts": "providers/xai", diff --git a/tests/providers/xai/xai-additional-tools.test.ts b/tests/providers/xai/xai-additional-tools.test.ts new file mode 100644 index 00000000000..26f93c6d581 --- /dev/null +++ b/tests/providers/xai/xai-additional-tools.test.ts @@ -0,0 +1,421 @@ +import { providerConfigSchema } from "../../../src/config/schema/leaf-validators"; +import { describe, expect, test } from "bun:test"; +import { createResponsesPassthroughAdapter as createProductionAdapter } from "../../../src/adapters/openai-responses"; +import { + isXaiResponsesUrl, + promoteResponsesAdditionalTools, +} from "../../../src/adapters/opencode-go-additional-tools"; +import { withTestTranslatorBudget } from "../../helpers/translator-budget"; +import { CODEX_GPT5_IDENTITY_LINE, identifyRoutedModel } from "../../../src/adapters/identity"; +import { tagHistoricalInputItems } from "../../../src/responses/item-provenance"; +import { rewriteRoutedCustomToolsForUpstream } from "../../../src/responses/custom-tool-compat"; +import type { OcxProviderConfig } from "../../../src/types"; + +const lookup = { + type: "function" as const, + name: "lookup", + description: "finds records", + parameters: { type: "object", properties: { q: { type: "string" } } }, +}; + +function build(providerConfig: OcxProviderConfig, rawBody: Record, replayPrefixLen?: number) { + const adapter = withTestTranslatorBudget(createProductionAdapter(providerConfig)); + const request = adapter.buildRequest({ + modelId: "grok-4.7", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: rawBody, + ...(replayPrefixLen !== undefined ? { _replayPrefixLen: replayPrefixLen } : {}), + }); + return { request, body: JSON.parse(request.body) as Record }; +} + +describe("xAI Responses additional_tools promotion", () => { + test("custom-tool lowering carries historical and live provenance into promotion", () => { + const input = tagHistoricalInputItems([ + { type: "additional_tools", tools: [{ type: "custom", name: "retired", description: "past tool" }] }, + { type: "additional_tools", tools: [lookup] }, + ], 1); + const lowered = rewriteRoutedCustomToolsForUpstream({ input }, false); + const promoted = promoteResponsesAdditionalTools(lowered.body) as { tools: unknown[] }; + expect(promoted.tools).toEqual([lookup]); + }); + + test("custom-tool lowering cannot reactivate a historical wrapper", () => { + const { body } = build({ + adapter: "openai-responses", baseUrl: "https://api.x.ai/v1", supportsResponsesCustomTools: false, + }, { input: [ + { type: "additional_tools", tools: [{ type: "custom", name: "retired", description: "past tool" }] }, + { type: "additional_tools", tools: [lookup] }, + ] }, 1); + expect(body.tools).toEqual([lookup]); + expect(body.input).toEqual([]); + }); + + test("forward cache-marker cleanup cannot promote historical declarations", () => { + const retired = { ...lookup, name: "retired", prompt_cache_breakpoint: true }; + const raw = { input: [ + { type: "additional_tools", tools: [retired] }, + { type: "additional_tools", tools: [lookup] }, + ] }; + const before = structuredClone(raw); + const { body } = build({ + adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", promoteAdditionalTools: true, + }, raw, 1); + expect(body.tools).toEqual([lookup]); + expect(body.input).toEqual([]); + expect(raw).toEqual(before); + }); + + test("forward opt-in preserves live tools after removing the historical identity message", () => { + const provider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + promoteAdditionalTools: true, + }; + const live = { ...lookup, name: "live" }; + const raw = { + input: [ + { type: "message", role: "developer", content: identifyRoutedModel(CODEX_GPT5_IDENTITY_LINE, "old-model") }, + { type: "additional_tools", tools: [live] }, + ], + }; + const before = structuredClone(raw); + const adapter = withTestTranslatorBudget(createProductionAdapter(provider)); + const request = adapter.buildRequest({ + modelId: "gpt-5", context: { messages: [] }, stream: false, options: {}, + _rawBody: raw, _replayPrefixLen: 1, + }, { headers: new Headers({ authorization: "Bearer caller-fixture", "chatgpt-account-id": "caller-account" }) }); + const body = JSON.parse(request.body); + expect(body.tools).toEqual([live]); + expect(body.input).toEqual([]); + expect(request.url).toBe("https://chatgpt.com/backend-api/codex/responses"); + expect(new Headers(request.headers).get("authorization")).toBe("Bearer caller-fixture"); + expect(new Headers(request.headers).get("chatgpt-account-id")).toBe("caller-account"); + expect(raw).toEqual(before); + for (const item of raw.input) expect(Object.getOwnPropertySymbols(item)).toEqual([]); + }); + + test("isXaiResponsesUrl matches official xAI endpoints and rejects lookalikes", () => { + expect(isXaiResponsesUrl("https://api.x.ai/v1/responses")).toBe(true); + expect(isXaiResponsesUrl("https://api.x.ai:443/v1/responses")).toBe(true); + expect(isXaiResponsesUrl("https://cli-chat-proxy.grok.com/v1/responses")).toBe(true); + expect(isXaiResponsesUrl("https://cli-chat-proxy.grok.com:443/v1/responses")).toBe(true); + + expect(isXaiResponsesUrl("http://api.x.ai/v1/responses")).toBe(false); + expect(isXaiResponsesUrl("https://api.x.ai:8443/v1/responses")).toBe(false); + expect(isXaiResponsesUrl("https://api.x.ai.evil.test/v1/responses")).toBe(false); + expect(isXaiResponsesUrl("https://example.com/v1/responses")).toBe(false); + }); + + test("promoteResponsesAdditionalTools strips wrapper from input and moves tools to top-level", () => { + const raw = { + input: [ + { type: "message", role: "user", content: "hello" }, + { type: "additional_tools", tools: [lookup] }, + ], + }; + const result = promoteResponsesAdditionalTools(raw) as { input: unknown[]; tools: unknown[] }; + expect(result.input).toEqual([{ type: "message", role: "user", content: "hello" }]); + expect(result.tools).toEqual([lookup]); + }); + + test("promoteResponsesAdditionalTools respects replay prefix length", () => { + const historicalTool = { type: "function", name: "old_tool" }; + const raw = { + input: [ + { type: "additional_tools", tools: [historicalTool] }, + { type: "message", role: "user", content: "turn 1" }, + { type: "additional_tools", tools: [lookup] }, + ], + }; + // replayPrefixLength = 2 means index 0 and 1 are historical; only index 2 is current turn + const result = promoteResponsesAdditionalTools(raw, 2) as { input: unknown[]; tools: unknown[] }; + expect(result.input).toEqual([{ type: "message", role: "user", content: "turn 1" }]); + expect(result.tools).toEqual([lookup]); + }); + + test("xAI provider strips additional_tools and promotes declarations on buildRequest", () => { + const xaiProvider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + headers: { authorization: "Bearer xai-key" }, + }; + + const raw = { + model: "grok-4.7", + input: [ + { type: "message", role: "user", content: "test query" }, + { type: "additional_tools", tools: [lookup] }, + ], + }; + + const { body } = build(xaiProvider, raw); + expect(body.input).toEqual([{ type: "message", role: "user", content: "test query" }]); + expect(body.tools).toBeArray(); + expect((body.tools as any[]).some(t => t.name === "lookup")).toBe(true); + }); + + test("cli-chat-proxy.grok.com also promotes additional_tools on buildRequest", () => { + const grokCliProvider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://cli-chat-proxy.grok.com/v1", + headers: { authorization: "Bearer grok-cli-token" }, + }; + + const raw = { + model: "grok-4.7", + input: [ + { type: "message", role: "user", content: "test query" }, + { type: "additional_tools", tools: [lookup] }, + ], + }; + + const { body } = build(grokCliProvider, raw); + expect(body.input).toEqual([{ type: "message", role: "user", content: "test query" }]); + expect((body.tools as any[]).some(t => t.name === "lookup")).toBe(true); + }); + + test("custom provider with promoteAdditionalTools: true promotes on buildRequest", () => { + const customProvider = { + adapter: "openai-responses", + baseUrl: "https://custom-gateway.internal/v1", + promoteAdditionalTools: true, + headers: { authorization: "Bearer internal-token" }, + }; + + const raw = { + model: "custom-model", + input: [ + { type: "message", role: "user", content: "custom query" }, + { type: "additional_tools", tools: [lookup] }, + ], + }; + + const { body } = build(customProvider, raw); + expect(body.input).toEqual([{ type: "message", role: "user", content: "custom query" }]); + expect((body.tools as any[]).some(t => t.name === "lookup")).toBe(true); + }); + + test("providerConfigSchema validates promoteAdditionalTools as optional boolean", () => { + expect(providerConfigSchema.safeParse({ adapter: "openai-responses", baseUrl: "https://example.com/v1", promoteAdditionalTools: true }).success).toBe(true); + expect(providerConfigSchema.safeParse({ adapter: "openai-responses", baseUrl: "https://example.com/v1", promoteAdditionalTools: false }).success).toBe(true); + expect(providerConfigSchema.safeParse({ adapter: "openai-responses", baseUrl: "https://example.com/v1", promoteAdditionalTools: "true" }).success).toBe(false); + }); + + test("unapproved custom provider without flag leaves input untouched", () => { + const unapprovedProvider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://unapproved.internal/v1", + headers: { authorization: "Bearer token" }, + }; + + const raw = { + model: "custom-model", + input: [ + { type: "message", role: "user", content: "custom query" }, + { type: "additional_tools", tools: [lookup] }, + ], + }; + + const { body } = build(unapprovedProvider, raw); + expect(body.input).toEqual(raw.input); + }); + + test("custom provider with authMode: forward and promoteAdditionalTools: true promotes tools", () => { + const forwardOptInProvider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://forward-proxy.example.com/v1", + authMode: "forward", + promoteAdditionalTools: true, + }; + + const raw = { + model: "custom-model", + input: [ + { type: "message", role: "user", content: "forward query" }, + { type: "additional_tools", tools: [lookup] }, + ], + }; + + const { body } = build(forwardOptInProvider, raw); + expect(body.input).toEqual([{ type: "message", role: "user", content: "forward query" }]); + expect((body.tools as any[]).some(t => t.name === "lookup")).toBe(true); + }); + + test("custom provider with authMode: forward without flag leaves input untouched", () => { + const forwardNoOptInProvider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + authMode: "forward", + }; + + const raw = { + model: "custom-model", + input: [ + { type: "message", role: "user", content: "forward query" }, + { type: "additional_tools", tools: [lookup] }, + ], + }; + + const { body } = build(forwardNoOptInProvider, raw); + expect(body.input).toEqual(raw.input); + }); + + test("promoteResponsesAdditionalTools recalculates replay boundary when reasoning items are dropped", () => { + const xaiProvider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + dropResponsesReasoningItems: true, + headers: { authorization: "Bearer xai-key" }, + }; + + const raw = { + model: "grok-4.7", + input: [ + { type: "reasoning", content: "thinking..." }, + { type: "message", role: "user", content: "hello" }, + { type: "additional_tools", tools: [lookup] }, + ], + }; + + // Original prefix len is 2 (reasoning + user message). + // When reasoning is dropped, prefix len must adjust from 2 to 1. + // additional_tools moves from index 2 to index 1, which matches the new prefix boundary (>= 1), + // so its tools are promoted to top-level tools. + const { body } = build(xaiProvider, raw, 2); + expect(body.input).toEqual([{ type: "message", role: "user", content: "hello" }]); + expect(body.tools).toBeArray(); + expect((body.tools as any[]).some(t => t.name === "lookup")).toBe(true); + }); + + test("promoteResponsesAdditionalTools recalculates replay boundary when canonical-only items are dropped", () => { + const xaiProvider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + headers: { authorization: "Bearer xai-key" }, + }; + + const raw = { + model: "grok-4.7", + input: [ + { type: "configuration_update", reasoning: { effort: "high" } }, + { type: "message", role: "user", content: "hello" }, + { type: "additional_tools", tools: [lookup] }, + ], + }; + + // Original prefix len is 2 (configuration_update + user message). + // When configuration_update is dropped, prefix len must adjust from 2 to 1. + // additional_tools moves from index 2 to index 1, which matches the new prefix boundary (>= 1), + // so its tools are promoted to top-level tools. + const { body } = build(xaiProvider, raw, 2); + expect(body.input).toEqual([{ type: "message", role: "user", content: "hello" }]); + expect(body.tools).toBeArray(); + expect((body.tools as any[]).some(t => t.name === "lookup")).toBe(true); + }); + test.each(["configuration_update", "reasoning"])("Finding 3: removing the entire %s replay prefix preserves current tools", type => { + const provider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + dropResponsesReasoningItems: true, + }; + const { body } = build(provider, { + input: [{ type, content: "historical" }, { type: "additional_tools", tools: [lookup] }], + }, 1); + expect(body.tools).toEqual([lookup]); + }); + + test("Finding 1: historical declarations do not deduplicate current declarations before promotion", () => { + const xaiProvider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + headers: { authorization: "Bearer xai-key" }, + }; + const raw = { + model: "grok-4.7", + input: [ + { type: "additional_tools", tools: [lookup] }, + { type: "message", role: "user", content: "hello" }, + { type: "additional_tools", tools: [lookup] }, + ], + }; + const { body } = build(xaiProvider, raw, 2); + expect(body.tools).toBeArray(); + expect((body.tools as any[]).some(t => t.name === "lookup")).toBe(true); + }); + + test("Finding 1: historical namespaced declarations do not deduplicate current namespaced declarations before promotion", () => { + const xaiProvider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + headers: { authorization: "Bearer xai-key" }, + }; + const namespacedLookup = { + type: "namespace", + name: "functions", + tools: [lookup], + }; + const raw = { + model: "grok-4.7", + input: [ + { type: "additional_tools", tools: [namespacedLookup] }, + { type: "message", role: "user", content: "hello" }, + { type: "additional_tools", tools: [namespacedLookup] }, + ], + }; + const { body } = build(xaiProvider, raw, 2); + expect(body.tools).toBeArray(); + expect((body.tools as any[]).some(t => t.name === "lookup")).toBe(true); + }); + + test("Finding 2: newly loaded search tools survive when first additional_tools wrapper is historical", () => { + const xaiProvider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + headers: { authorization: "Bearer xai-key" }, + }; + const raw = { + model: "grok-4.7", + input: [ + { type: "additional_tools", tools: [{ type: "function", name: "old_tool" }] }, + { type: "tool_search_output", tools: [lookup] }, + ], + }; + const { body } = build(xaiProvider, raw, 1); + expect(body.tools).toBeArray(); + expect((body.tools as any[]).some(t => t.name === "lookup")).toBe(true); + }); + + test("Finding 1: a historical bare declaration does not suppress the live functions namespace", () => { + const { body } = build({ adapter: "openai-responses", baseUrl: "https://api.x.ai/v1" }, { + input: [ + { type: "additional_tools", tools: [lookup] }, + { type: "additional_tools", tools: [{ type: "namespace", name: "functions", tools: [lookup] }] }, + ], + }, 1); + expect(body.tools).toEqual([lookup]); + }); + + test("Finding 3: synthesizing missing historical tool output does not reactivate retired tools", () => { + const pWithPairing: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + requiresPairedResponsesToolResults: true, + headers: { authorization: "Bearer xai-key" }, + }; + const raw = { + model: "grok-4.7", + input: [ + { type: "function_call", call_id: "c1", name: "some_call" }, + { type: "additional_tools", tools: [{ type: "function", name: "retired_tool" }] }, + ], + }; + const { body } = build(pWithPairing, raw, 2); + const retired = (body.tools as any[] ?? []).find(t => t.name === "retired_tool"); + expect(retired).toBeUndefined(); + }); +}); diff --git a/tests/providers/xai/xai-loaded-tool-provenance.test.ts b/tests/providers/xai/xai-loaded-tool-provenance.test.ts new file mode 100644 index 00000000000..70586cacfc2 --- /dev/null +++ b/tests/providers/xai/xai-loaded-tool-provenance.test.ts @@ -0,0 +1,128 @@ +import { expect, test } from "bun:test"; +import { createResponsesPassthroughAdapter } from "../../../src/adapters/openai-responses"; +import { promoteClientLoadedTools } from "../../../src/adapters/openai-responses/tool-schema"; +import { tagHistoricalInputItems } from "../../../src/responses/item-provenance"; +import { handleResponses } from "../../../src/server/responses"; +import type { OcxConfig } from "../../../src/types"; +import { acquireOwnedSpendHome } from "../../helpers/owned-spend-home"; +import { withTestTranslatorBudget } from "../../helpers/translator-budget"; + +const live = { type: "function", name: "live", parameters: { type: "object", properties: {} } }; +const retired = { type: "web_search", name: "retired_search" }; +const current = { type: "web_search", name: "current_search" }; +const wrap = (tools: unknown[]) => ({ type: "additional_tools", tools }); +const discovery = (tools: unknown[]) => ({ type: "tool_search_output", call_id: "search", tools }); +const provider = { + adapter: "openai-responses", baseUrl: "https://api.x.ai/v1", authMode: "key", + apiKey: "fixture-key", xaiResponsesXSearch: true, +}; + +for (const catalog of ["wrapper", "top-level", "fallback"] as const) { + for (const historical of [true, false]) { + test(`${catalog}: ${historical ? "historical" : "current"} discoveries keep their authority boundary`, () => { + const input = [discovery([retired]), ...(catalog === "wrapper" ? [wrap([live])] : [])]; + const raw = { input, ...(catalog === "top-level" ? { tools: [live] } : {}) }; + const before = JSON.stringify(raw); + const body = JSON.parse(withTestTranslatorBudget(createResponsesPassthroughAdapter(provider)).buildRequest({ + modelId: "grok-4.7", context: { messages: [] }, stream: false, options: {}, + _rawBody: raw, _replayPrefixLen: historical ? 1 : 0, + }, { headers: new Headers() }).body); + const declared = catalog === "fallback" ? [] : [live]; + const active = catalog === "wrapper" ? [{ type: "x_search" }, ...declared, retired] + : [...declared, retired, { type: "x_search" }]; + expect(body.tools ?? []).toEqual(historical ? declared : active); + expect(JSON.stringify(raw)).toBe(before); + expect(input.every(item => Object.getOwnPropertySymbols(item).length === 0)).toBe(true); + }); + } + + test(`${catalog}: source tags override stale positions after reordering`, () => { + const [oldResult, liveResult, wrapper] = tagHistoricalInputItems([ + discovery([retired]), discovery([current]), wrap([live]), + ], 1); + const body = { + input: [liveResult, oldResult, ...(catalog === "wrapper" ? [wrapper] : [])], + ...(catalog === "top-level" ? { tools: [live] } : {}), + }; + const loaded = promoteClientLoadedTools(body, body.input.length) as { + tools?: unknown[]; input: Array<{ type: string; tools?: unknown[] }>; + }; + const declared = catalog === "fallback" ? [] : [live]; + expect(catalog === "wrapper" ? loaded.input.at(-1)?.tools : loaded.tools).toEqual([...declared, current]); + expect(body.input).toEqual([liveResult, oldResult, ...(catalog === "wrapper" ? [wrapper] : [])]); + }); + + test(`${catalog}: untagged callers use the original replay prefix for discoveries`, () => { + const body = { + input: [discovery([retired]), discovery([current]), ...(catalog === "wrapper" ? [wrap([live])] : [])], + ...(catalog === "top-level" ? { tools: [live] } : {}), + }; + const loaded = promoteClientLoadedTools(body, 1) as { + tools?: unknown[]; input: Array<{ type: string; tools?: unknown[] }>; + }; + expect(catalog === "wrapper" ? loaded.input.at(-1)?.tools : loaded.tools) + .toEqual([...(catalog === "fallback" ? [] : [live]), current]); + }); +} + +for (const catalog of ["wrapper", "top-level", "fallback"] as const) { + for (const source of ["historical", "current", "absent"] as const) { + test(`two-turn ${catalog}: ${source} discovery retains only current hosted declarations`, async () => { + const savedFetch = globalThis.fetch; + const releaseHome = acquireOwnedSpendHome(); + const responseId = `resp_loaded_provenance_${catalog}_${source}`; + const outbound: Array<{ tools?: Array> }> = []; + const hostedCall = { + type: "custom_tool_call", id: "ctc_provenance", call_id: "xs_call-round3", + name: "x_keyword_search", input: "{}", status: "completed", + }; + globalThis.fetch = (async (_input, init) => { + outbound.push(JSON.parse(String(init?.body))); + return Response.json({ + id: outbound.length === 1 ? responseId : `${responseId}_next`, status: "completed", + output: outbound.length === 1 + ? [{ type: "message", role: "assistant", content: [{ type: "output_text", text: "done" }] }] + : [hostedCall], + }); + }) as typeof fetch; + const config: OcxConfig = { port: 0, defaultProvider: "fixture", providers: { fixture: provider } }; + const post = (body: Record) => handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "fixture/grok-4.7", stream: false, ...body }), + }), config, { model: "", provider: "" }); + try { + const first = await post({ input: [ + wrap([{ type: "web_search" }]), ...(source === "historical" ? [discovery([retired])] : []), + { role: "user", content: "old search" }, + ] }); + expect(first.status).toBe(200); + await first.text(); + const second = await post({ + previous_response_id: responseId, + input: [ + ...(catalog === "wrapper" ? [wrap([live])] : []), + ...(source === "current" ? [discovery([current])] : []), + { role: "user", content: "continue" }, + ], + ...(catalog === "top-level" ? { tools: [live] } : {}), + }); + const result = await second.json() as { output?: unknown[]; error?: { code?: string } }; + expect(outbound).toHaveLength(2); + // An omitted catalog leaves call handling to the client; an explicit live catalog + // activates the server guard. Both paths must exclude historical outbound authority. + const accepted = source === "current" || catalog === "fallback"; + expect(second.status).toBe(accepted ? 200 : 502); + if (accepted) expect(result.output).toEqual([hostedCall]); + else expect(result.error?.code).toBe("undeclared_tool_call"); + const declared = catalog === "fallback" ? [] : [live]; + const active = catalog === "wrapper" ? [{ type: "x_search" }, ...declared, current] + : [...declared, current, { type: "x_search" }]; + expect(outbound[1]?.tools ?? []) + .toEqual(source === "current" ? active : declared); + } finally { + globalThis.fetch = savedFetch; + releaseHome(); + } + }); + } +} diff --git a/tests/providers/xai/xai-replay-provenance.test.ts b/tests/providers/xai/xai-replay-provenance.test.ts new file mode 100644 index 00000000000..fdcc0b619af --- /dev/null +++ b/tests/providers/xai/xai-replay-provenance.test.ts @@ -0,0 +1,94 @@ +import { expect, test } from "bun:test"; +import { createResponsesPassthroughAdapter } from "../../../src/adapters/openai-responses"; +import { repairOrphanedInputItems, restoreBridgedWebSearchCalls } from "../../../src/adapters/openai-responses/tool-output-recovery"; +import { injectXaiResponsesXSearch } from "../../../src/adapters/xai-web-search"; +import { liftZedAdditionalTools } from "../../../src/adapters/zed-additional-tools"; +import { currentTurnWireToolCatalogBody } from "../../../src/server/responses-undeclared-tool-guard"; +import { hasItemProvenance, isHistoricalItem, tagHistoricalInputItems } from "../../../src/responses/item-provenance"; +import { withTestTranslatorBudget } from "../../helpers/translator-budget"; +import { rememberBridgeSearchReplay } from "../../../src/responses/bridge-search-replay-cache"; + +const live = { type: "function", name: "live", parameters: { type: "object", properties: {} } }; +const wrap = (tools: unknown[]) => ({ type: "additional_tools", tools }); +const xai = { adapter: "openai-responses", baseUrl: "https://api.x.ai/v1", xaiResponsesXSearch: true }; +const task = { type: "function_call_output", id: "seed", name: "task", namespace: "agent", output: "old task" }; + +function build(seed: unknown) { + return JSON.parse(withTestTranslatorBudget(createResponsesPassthroughAdapter({ + ...xai, requiresPairedResponsesToolResults: true, + })).buildRequest({ + modelId: "grok-4.7", context: { messages: [] }, stream: false, options: {}, + _rawBody: { input: [seed, wrap([{ type: "web_search" }]), wrap([live])] }, _replayPrefixLen: 2, + }, { headers: new Headers() }).body); +} + +test("control: historical user seed does not reactivate historical hosted search", () => { + expect(build({ type: "message", role: "user", content: "old task" }).tools).toEqual([live]); +}); + +test("external task-input replacement must not reactivate historical hosted search", () => { + expect(build(task).tools).toEqual([live]); +}); + +for (const [name, seed, synthesize] of [ + ["external task", task, false], + ["orphan output", { type: "function_call_output", call_id: "old", output: "result" }, false], + ["synthetic output", { type: "function_call", call_id: "old", name: "lookup", arguments: "{}" }, true], +] as const) { + test(`${name} replacement retains both historical and live provenance`, () => { + for (const historical of [true, false]) { + const tagged = tagHistoricalInputItems([{ type: "message" }, seed], historical ? 2 : 1); + const body = repairOrphanedInputItems({ input: tagged }, false, synthesize) as { input: unknown[] }; + const replacement = body.input.at(-1); + expect(hasItemProvenance(replacement)).toBe(true); + expect(isHistoricalItem(replacement)).toBe(historical); + expect(Object.getOwnPropertySymbols(seed)).toEqual([]); + } + }); +} + +test("xAI injection ignores historical web search after a live item moves ahead of it", () => { + const [historical, current] = tagHistoricalInputItems([wrap([{ type: "web_search" }]), wrap([live])], 1); + const body = { input: [current, historical] }; + expect(injectXaiResponsesXSearch(body, xai, 0)).toBe(body); +}); + +test("bridge restoration retains historical and live provenance on both restored items", () => { + const scope = "xai-replay-provenance-fixture"; + rememberBridgeSearchReplay(scope, "ws_provenance", { + callId: "search_provenance", name: "web_search", argumentsText: "{}", output: "fixture result", + }); + for (const historical of [true, false]) { + const input = tagHistoricalInputItems([ + { type: "message" }, { type: "web_search_call", id: "ws_provenance" }, + ], historical ? 2 : 1); + const restored = restoreBridgedWebSearchCalls({ input }, scope) as { input: unknown[] }; + expect(restored.input).toHaveLength(3); + for (const item of restored.input.slice(1)) { + expect(hasItemProvenance(item)).toBe(true); + expect(isHistoricalItem(item)).toBe(historical); + } + } +}); + +test("historical x_search cannot suppress injection for a current web-search wrapper", () => { + const [historical, current] = tagHistoricalInputItems([ + wrap([{ type: "x_search" }]), wrap([{ type: "web_search" }]), + ], 1); + expect(injectXaiResponsesXSearch({ input: [current, historical] }, xai, 0)) + .toMatchObject({ tools: [{ type: "x_search" }] }); +}); + +test("Zed promotion reads provenance after input reordering", () => { + const [historical, current] = tagHistoricalInputItems([wrap([{ ...live, name: "retired" }]), wrap([live])], 1); + const body: Record = { input: [current, historical] }; + liftZedAdditionalTools(body, 0); + expect(body.tools).toEqual([live]); + expect(body.input).toEqual([]); +}); + +test("current-turn authorization reads provenance after input reordering", () => { + const [historical, current] = tagHistoricalInputItems([wrap([{ type: "x_search" }]), wrap([live])], 1); + expect(currentTurnWireToolCatalogBody({ input: [current, historical] }, 0)) + .toEqual({ input: [current] }); +}); diff --git a/tests/providers/zed-hosted-provider.test.ts b/tests/providers/zed-hosted-provider.test.ts index 2046c1d6670..a3763b2a8d4 100644 --- a/tests/providers/zed-hosted-provider.test.ts +++ b/tests/providers/zed-hosted-provider.test.ts @@ -446,6 +446,28 @@ describe("Zed OpenAI tool relay", () => { expect(rawBefore).toContain("reasoning"); }); + test("a current tool_search_output stays current after a replayed wrapper is lifted", async () => { + catalogFetch(); + const lookup = { type: "function", name: "lookup", parameters: { type: "object", properties: {} } }; + const parsed = parseRequest({ + model: "gpt-6.1-sol", + input: [ + { type: "additional_tools", tools: [{ type: "function", name: "old_tool", parameters: { type: "object", properties: {} } }] }, + { type: "tool_search_output", call_id: "search", status: "completed", execution: "client", tools: [lookup] }, + { type: "message", role: "user", content: [{ type: "input_text", text: "now" }] }, + ], + tools: [], + stream: true, + }); + parsed._zedAuthContext = { userId: "user-1" }; + parsed._replayPrefixLen = 1; + const adapter = withTestTranslatorBudget(createZedAdapter(provider)); + const request = await adapter.buildRequest(parsed); + const body = JSON.parse(request.body).provider_request; + expect(JSON.stringify(body.input)).not.toContain("old_tool"); + expect(body.tools.map((tool: { name?: string }) => tool.name)).toEqual(["lookup"]); + }); + async function relay(frames: unknown[]): Promise { catalogFetch(); const parsed = parseRequest({ model: "gpt-6.1-sol", input: "hello", stream: true }); diff --git a/tests/responses/openai-responses-passthrough.test.ts b/tests/responses/openai-responses-passthrough.test.ts index feee0328653..d5bbb818b3d 100644 --- a/tests/responses/openai-responses-passthrough.test.ts +++ b/tests/responses/openai-responses-passthrough.test.ts @@ -1579,7 +1579,8 @@ describe("OpenAI Responses passthrough sanitization", () => { for (const lite of [false, true]) { const body = build(lite); - const tools = lite ? body.input[0]?.tools : body.tools; + const tools = body.tools; + if (lite) expect(body.input).toEqual([]); expect(tools?.map(tool => tool.name)).toEqual([ "mcp__codex_app__safe_union", "mcp__codex_app__plain", @@ -2478,8 +2479,8 @@ describe("OpenAI Responses passthrough sanitization", () => { tool_choice: Record; }; - expect(body.tools).toBeUndefined(); - expect(body.input[0]?.tools).toEqual([{ type: "web_search" }]); + expect(body.tools).toEqual([{ type: "web_search" }]); + expect(body.input).toEqual([]); expect(body.tool_choice).toEqual({ type: "web_search" }); }); @@ -2641,10 +2642,7 @@ describe("OpenAI Responses passthrough sanitization", () => { tools: [{ type: "function", name: "read", defer_loading: true, parameters: {} }], }, ], - input: [{ - type: "additional_tools", - tools: [{ type: "function", name: "loose", defer_loading: true, parameters: {} }], - }], + input: [{ type: "additional_tools", tools: [{ type: "function", name: "loose", defer_loading: true, parameters: {} }] }], }, }, { headers: new Headers() }); const body = JSON.parse(request.body) as { @@ -2655,7 +2653,9 @@ describe("OpenAI Responses passthrough sanitization", () => { expect(body.tools[0]).toEqual({ type: "web_search" }); expect(body.tools[1]).toMatchObject({ type: "function", name: "workspace__read" }); expect(body.tools[1]).not.toHaveProperty("defer_loading"); - expect(body.input[0].tools[0]).not.toHaveProperty("defer_loading"); + expect(body.tools[2]).toMatchObject({ type: "function", name: "loose" }); + expect(body.tools[2]).not.toHaveProperty("defer_loading"); + expect(body.input).toEqual([]); }); test("preserves prompt_cache_key in the raw Responses passthrough body", () => { diff --git a/tests/responses/responses-muse-tool-choice.test.ts b/tests/responses/responses-muse-tool-choice.test.ts index 277335ee1ef..08683ea88a3 100644 --- a/tests/responses/responses-muse-tool-choice.test.ts +++ b/tests/responses/responses-muse-tool-choice.test.ts @@ -1,5 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { createResponsesPassthroughAdapter as createResponsesPassthroughAdapterProduction } from "../../src/adapters/openai-responses"; +import { normalizeMuseToolCatalog } from "../../src/adapters/openai-responses/muse-tool-catalog"; +import { tagHistoricalInputItems } from "../../src/responses/item-provenance"; import { handleResponses, handleResponsesCompact } from "../../src/server/responses"; import type { OcxConfig, OcxProviderConfig } from "../../src/types"; import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; @@ -442,3 +444,17 @@ describe("Meta Muse additional tool catalogs", () => { } }); }); + +test("Meta catalog follows item provenance once an earlier rewrite shortened the input", () => { + const schema = { type: "object", properties: {} }; + const tagged = tagHistoricalInputItems([ + { type: "additional_tools", tools: [{ type: "function", name: "old_tool", parameters: schema }] }, + { type: "message", role: "user", content: [{ type: "input_text", text: "earlier" }] }, + { type: "tool_search_output", call_id: "search", status: "completed", execution: "client", tools: [{ type: "function", name: "lookup", parameters: schema }] }, + { type: "message", role: "user", content: [{ type: "input_text", text: "now" }] }, + ], 2); + // Zed lifted the replayed wrapper before the delegate ran, so the numeric prefix of 2 is stale. + const body = normalizeMuseToolCatalog({ model: "muse", input: tagged.slice(1) }, 2) as { tools?: { name?: string }[]; input: unknown[] }; + expect(body.tools?.map(tool => tool.name)).toEqual(["lookup"]); + expect(JSON.stringify(body.input)).not.toContain("old_tool"); +}); diff --git a/tests/server/management-provider-compat-carry.test.ts b/tests/server/management-provider-compat-carry.test.ts index b8ce5a41b7d..5722e8211de 100644 --- a/tests/server/management-provider-compat-carry.test.ts +++ b/tests/server/management-provider-compat-carry.test.ts @@ -127,6 +127,22 @@ const CASES: Record = { return (JSON.parse(String(built.body)) as { input: Array<{ content: unknown }> }).input[0]!.content; }, }, + promoteAdditionalTools: { + seed: { promoteAdditionalTools: true }, + probe: provider => { + const parsed = parseRequest({ + model: MODEL, + input: [ + { type: "message", role: "user", content: "hi" }, + { type: "additional_tools", tools: [tool] }, + ], + }); + parsed.modelId = MODEL; + const built = withTestTranslatorBudget(createResponsesPassthroughAdapter({ ...provider, adapter: "openai-responses" })).buildRequest(parsed, { headers: new Headers() }); + const body = JSON.parse(String(built.body)) as { input: Array<{ type: string }>; tools?: unknown[] }; + return { inputTypes: body.input.map(i => i.type), hasTools: Array.isArray(body.tools) && body.tools.length > 0 }; + }, + }, foldDeveloperRoleToSystem: { seed: { foldDeveloperRoleToSystem: true }, probe: provider => (nativeBody(provider, { @@ -287,6 +303,25 @@ describe("an overwrite that moves the provider carries none of it", () => { }); describe("PATCH and POST validate the settings they name", () => { + test("PATCH writes and clears promoteAdditionalTools", async () => { + await withServer({ relay: { adapter: "openai-responses", baseUrl: BASE_URL } }, async url => { + const set = await send(url, "/api/providers?name=relay", "PATCH", { promoteAdditionalTools: true }); + expect(set.status).toBe(200); + expect(loadConfig().providers.relay?.promoteAdditionalTools).toBe(true); + + const disable = await send(url, "/api/providers?name=relay", "PATCH", { promoteAdditionalTools: false }); + expect(disable.status).toBe(200); + expect(loadConfig().providers.relay?.promoteAdditionalTools).toBe(false); + + const bad = await send(url, "/api/providers?name=relay", "PATCH", { promoteAdditionalTools: "yes" }); + expect(bad.status).toBe(400); + + const clear = await send(url, "/api/providers?name=relay", "PATCH", { promoteAdditionalTools: null }); + expect(clear.status).toBe(200); + expect(loadConfig().providers.relay).not.toHaveProperty("promoteAdditionalTools"); + }); + }); + test("PATCH writes and clears foldDeveloperRoleToSystem and reasoningWireFormat", async () => { await withServer({ relay: { adapter: "openai-chat", baseUrl: BASE_URL } }, async url => { const set = await send(url, "/api/providers?name=relay", "PATCH", { diff --git a/tests/server/server-xai-responses-streaming.test.ts b/tests/server/server-xai-responses-streaming.test.ts index 8a2298972c9..03adfc36dac 100644 --- a/tests/server/server-xai-responses-streaming.test.ts +++ b/tests/server/server-xai-responses-streaming.test.ts @@ -477,11 +477,12 @@ describe("xAI OAuth Responses streaming opt-in", () => { type: string; tools?: Array<{ type: string; name?: string }>; }> | undefined; - const outboundTools = outboundInput?.find(item => item.type === "additional_tools")?.tools; + expect(outboundInput?.find(item => item.type === "additional_tools")).toBeUndefined(); + const outboundTools = outboundBody?.tools as Array<{ type: string; name?: string }> | undefined; expect(outboundTools?.some(tool => tool.type === "namespace")).toBe(false); expect(outboundTools?.find(tool => tool.name === "exec")?.type).toBe("function"); expect(outboundTools?.find(tool => tool.name === "collaboration__spawn_agent")?.type).toBe("function"); - expect(outboundBody?.tools).toEqual([{ type: "web_search" }]); + expect(outboundTools?.find(tool => tool.type === "web_search")).toEqual({ type: "web_search" }); const payloads = clientText .split(/\r?\n/) From a022a67fd8152045268405145d73ef696ff5afdd Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 30 Oct 2026 21:51:01 +0900 Subject: [PATCH 68/84] fix(claude): prune generated agents when the Claude integration is turned off (#7993) * fix(claude): prune generated agents when the Claude integration is turned off The integrations toggle persisted claudeCode.enabled=false but left ~/.claude/agents/ocx-*.md in place, so a native Claude Code session kept loading proxy-only agents whose placeholder model resolves to Haiku. Reconcile the definitions after the toggle persists, and again on a repeated Off. Closes #7968 * fix(claude): restore the in-memory block when the toggle save fails Otherwise a retry on the same config read the unsaved Off, took the repeated-Off branch and pruned agents while the saved integration was still on. Docs now describe the prune as best effort. * fix(claude): keep a published toggle value when post-publication bookkeeping fails ConfigWritePublishedError means the new value is on disk; restoring the old block in memory would make a later toggle report the wrong state. * fix(claude): reconcile agents only when the toggle turns Claude off Turning it on ran a model discovery request against the upstream from a settings toggle; On keeps its launch and startup sync, and only the Off prune is needed for #7968. --------- Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> --- .../src/content/docs/guides/claude-code.md | 5 ++ .../management/native-integration-routes.ts | 22 +++++ structure/subagents.md | 9 ++ .../claude-first-party-agent-sync.test.ts | 86 +++++++++++++++++++ 4 files changed, 122 insertions(+) diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index bf0cf28d7e9..c0b83efa0ce 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -856,6 +856,11 @@ Proxy startup/ensure, `ocx claude`, and relevant dashboard saves sync your featu overwritten or pruned; your own agents are never touched. - Files are atomically synced per file (write + rename). - `enabled: false` or `injectAgents: false` prunes all verified-owned definitions. +- Turning Claude Code off with the dashboard's integration toggle also prunes them right away + (best effort), so a native `claude` session started afterwards stops seeing proxy-only agents. + A file that cannot be deleted stays, and while a sibling OpenCodex instance owns + `~/.claude/agents` nothing is touched; turning it off again retries and also removes + definitions an older version left behind. - Successful CLI first-party saves and persisted Desktop first-party setup also attempt best-effort roster sync, including repeated setup to repair missing definitions. Refused setup and rollback do not sync agents. Turning first-party off alone does not disable agent registration. diff --git a/src/server/management/native-integration-routes.ts b/src/server/management/native-integration-routes.ts index 8b75adec412..be01108fbf8 100644 --- a/src/server/management/native-integration-routes.ts +++ b/src/server/management/native-integration-routes.ts @@ -1,4 +1,5 @@ import { readConfigForAdmittedMutation } from "../../config/persisted-mutation"; +import { ConfigWritePublishedError } from "../../config/persist-unlocked"; import { getConfigDir as configRoot, withConfigDirHardeningSettled as settleConfigRoot } from "../../config/paths"; import { WindowsSecretAclSettleTimeoutError } from "../../lib/windows-secret-acl"; import { mirrorDesiredEnabledOntoSnapshot } from "./claude-desktop-config-writers"; @@ -949,6 +950,9 @@ export async function handleNativeIntegrationRoutes(ctx: ManagementContext): Pro const enabled = body.enabled; if (claudeCodeEnabled(config) === enabled) { + // Re-run the same reconcile on a repeated OFF: definitions left behind by an older + // version that never pruned on disable are removed the next time the user says off. + if (!enabled) await ctx.syncClaudeAgentDefsBestEffort?.(); return jsonResponse({ ok: true, clientId: "claude", changed: false, state: enabled ? "current" : "absent", @@ -963,6 +967,10 @@ export async function handleNativeIntegrationRoutes(ctx: ManagementContext): Pro * sentinel the next startServer would silently convert a user's Auto auth mode into * a sticky manual subscription — a failure that surfaces nowhere near this route. */ + // Kept so a failed save leaves the shared in-memory config matching disk: otherwise a + // retry on the same object would read the unsaved value, take the repeated-OFF branch + // and prune agents while the persisted integration is still on. + const previousBlock = config.claudeCode === undefined ? undefined : structuredClone(config.claudeCode); commitClaudeCodeBlock(config, { ...(config.claudeCode ?? {}), enabled }); /* @@ -973,6 +981,11 @@ export async function handleNativeIntegrationRoutes(ctx: ManagementContext): Pro try { persist(config); } catch (error) { + // A published write already put the new value on disk; only an unpublished one rolls back. + if (!(error instanceof ConfigWritePublishedError)) { + if (previousBlock === undefined) delete config.claudeCode; + else config.claudeCode = previousBlock; + } if (isConfigLockError(error)) { return isLockContention(error) ? refusal(409, "claude", "config_busy", @@ -983,6 +996,15 @@ export async function handleNativeIntegrationRoutes(ctx: ManagementContext): Pro throw error; } + /* + * The generated ~/.claude/agents/ocx-*.md definitions route through this proxy; a + * native Claude Code session that still loads them after OFF follows their + * placeholder model (issue #7968). Prune them now instead of waiting for a later + * launch or startup sync; the disabled reconcile removes only marker-owned regular + * files and needs no model discovery. ON keeps its existing launch/startup sync. + */ + if (!enabled) await ctx.syncClaudeAgentDefsBestEffort?.(); + let interceptReason = enabled && interceptStartRefusal(ctx) ? "intercept_start_forbidden" : null; if (enabled && !interceptReason) { const outcome = await ensureManagementClaudeIntercept(ctx); diff --git a/structure/subagents.md b/structure/subagents.md index 5435786b3ef..9db8055c568 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -470,6 +470,15 @@ and stores only `features.multi_agent_v2.multi_agent_mode_hint_text` in Codex TO `null` removes that key. The hint affects new native Codex sessions when their v2 surface is active, without changing reasoning effort or the proxy guidance switch. +The integrations toggle `PUT /api/native-integrations/claude` reconciles the generated `ocx-*` definitions +through `syncClaudeAgentDefsBestEffort` after it persists Off, so the marker-owned regular files are +pruned at once without model discovery (On keeps the launch and startup sync); a repeated Off reconciles again to remove +definitions an older version left behind (#7968). The reconcile is best effort: an unlink failure +leaves that file and the toggle still succeeds. A save that fails before publishing restores the in-memory block, so a +retry on the same config is a real transition; one that published first keeps the new value. User-authored and symlinked `ocx-*` files are never +touched, and a live sibling instance still owns the directory. Coverage: +`tests/claude-integration/claude-first-party-agent-sync.test.ts`. + Claude Code `ocx-*` agent definitions consume the same effective `claudeCode.blockedSkills` policy as inbound bundle elision. When the list is non-empty (default: `claude-api`), generated definitions whose marker-stripped model resolves to a routed id receive a preventive instruction not to invoke diff --git a/tests/claude-integration/claude-first-party-agent-sync.test.ts b/tests/claude-integration/claude-first-party-agent-sync.test.ts index fd2f2428b2c..25e538c00b3 100644 --- a/tests/claude-integration/claude-first-party-agent-sync.test.ts +++ b/tests/claude-integration/claude-first-party-agent-sync.test.ts @@ -178,3 +178,89 @@ test("Desktop saved:false partial success does not reconcile agent definitions", spy.mockRestore(); } }); + + +async function toggleClaude(enabled: boolean, save: (config: OcxConfig) => void = () => { persist(); }) { + const url = new URL("http://127.0.0.1:10100/api/native-integrations/claude"); + const response = await handleManagementAPI(new Request(url, { + method: "PUT", + headers: { Host: url.host, "Content-Type": "application/json" }, + body: JSON.stringify({ enabled }), + }), url, config, { + fetchAllModels: async () => [], + claudeAgentConfigDir: claudeDir, + saveConfigPreservingClaudeCode: save, + ensureClaudeIntercept: async () => ({ ok: true, state: { proxyPort: 10200, caCertPath: join(root, "ca.pem"), pickerProxyPort: null } }), + }, "admin-token", undefined, { trustedLoopback: true }); + expect(response).not.toBeNull(); + return { status: response!.status, body: await response!.json() as Record }; +} + +test("turning the Claude integration off prunes only marker-owned definitions (#7968)", async () => { + injectClaudeAgentDefs(config, {}, claudeDir); + expectRegistered(); + const user = seedExistingAgents()["ocx-user.md"]; + const result = await toggleClaude(false); + expect(result).toMatchObject({ status: 200, body: { ok: true, changed: true, desiredEnabled: false } }); + expect(agentFiles()).toEqual({ "ocx-user.md": user }); +}); + +test("a repeated off removes definitions an older version left behind", async () => { + config.claudeCode = { ...config.claudeCode, enabled: false }; + persist(); + // Left behind while disabled by a version that never pruned on disable. + writeFileSync(join(claudeDir, "agents", agentFile), "\nstale roster\n"); + const result = await toggleClaude(false); + expect(result).toMatchObject({ status: 200, body: { ok: true, changed: false } }); + expect(agentFiles()).toEqual({}); +}); + +test("turning the integration on fetches no models and leaves the roster to launch-time sync", async () => { + config.claudeCode = { ...config.claudeCode, enabled: false }; + persist(); + let fetched = 0; + const url = new URL("http://127.0.0.1:10100/api/native-integrations/claude"); + const response = await handleManagementAPI(new Request(url, { + method: "PUT", headers: { Host: url.host, "Content-Type": "application/json" }, body: JSON.stringify({ enabled: true }), + }), url, config, { + fetchAllModels: async () => { fetched++; return []; }, + claudeAgentConfigDir: claudeDir, + saveConfigPreservingClaudeCode: () => { persist(); }, + ensureClaudeIntercept: async () => ({ ok: true, state: { proxyPort: 10200, caCertPath: join(root, "ca.pem"), pickerProxyPort: null } }), + }, "admin-token", undefined, { trustedLoopback: true }); + expect(response?.status).toBe(200); + expect(fetched).toBe(0); +}); + +test("a failed save leaves the shared config on, so a retry neither prunes nor reports success", async () => { + injectClaudeAgentDefs(config, {}, claudeDir); + config.claudeCode = { ...config.claudeCode, enabled: true }; + persist(); + const before = agentFiles(); + const busy = Object.assign(new Error("config busy"), { + code: "CONFIG_MUTATION_LOCK_UNAVAILABLE", cause: { code: "SQLITE_BUSY" }, + }); + const failingSave = () => { throw busy; }; + const refused = await toggleClaude(false, failingSave); + expect(refused).toMatchObject({ status: 409, body: { reason: "config_busy" } }); + expect(config.claudeCode?.enabled).toBe(true); + expect(agentFiles()).toEqual(before); + // The same object again: still a real transition, not the repeated-OFF shortcut. + const retry = await toggleClaude(false); + expect(retry).toMatchObject({ status: 200, body: { changed: true } }); + expect(agentFiles()).toEqual({}); +}); + +test("a save that published before failing keeps the new value, so a later On is a real transition", async () => { + const { ConfigWritePublishedError } = await import("../../src/config/persist-unlocked"); + injectClaudeAgentDefs(config, {}, claudeDir); + config.claudeCode = { ...config.claudeCode, enabled: true }; + persist(); + const publishedThenFailed = () => { persist(); throw new ConfigWritePublishedError(new Error("bookkeeping")); }; + await toggleClaude(false, publishedThenFailed).catch(() => null); + expect(config.claudeCode?.enabled).toBe(false); + expect(JSON.parse(readFileSync(join(root, "config.json"), "utf8")).claudeCode.enabled).toBe(false); + const on = await toggleClaude(true); + expect(on).toMatchObject({ status: 200, body: { changed: true } }); + expect(config.claudeCode?.enabled).toBe(true); +}); From 227811fd70886f78a379ca478be84f2368fa3cb8 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 30 Oct 2026 21:57:04 +0900 Subject: [PATCH 69/84] fix(history): bound the integrity check wait (#7997) Treat a request-history integrity check that has not answered within 30 s as an operational failure: dispose the worker, apply the failure cooldown, and release waiting async readers with a bounded error, while index replacement still waits for the worker's actual exit. Closes #7984 --- src/routing/history/integrity.ts | 47 +++++-- structure/dashboard-and-usage.md | 2 +- tests/usage/request-history-integrity.test.ts | 117 ++++++++++++++++++ 3 files changed, 157 insertions(+), 9 deletions(-) diff --git a/src/routing/history/integrity.ts b/src/routing/history/integrity.ts index 29cf099f4be..9f8db625483 100644 --- a/src/routing/history/integrity.ts +++ b/src/routing/history/integrity.ts @@ -11,6 +11,7 @@ import { RebuildWorkerJob } from "./rebuild-job"; export const REQUEST_HISTORY_INTEGRITY_INTERVAL_MS = 5 * 60_000; export const REQUEST_HISTORY_INTEGRITY_FAILURE_COOLDOWN_MS = 30_000; +export const REQUEST_HISTORY_INTEGRITY_CHECK_TIMEOUT_MS = 30_000; type IntegrityJob = Pick; @@ -19,14 +20,15 @@ export interface RequestHistoryIntegrityOptions { now?: () => number; intervalMs?: number; failureCooldownMs?: number; + checkTimeoutMs?: number; + scheduleTimeout?: (callback: () => void, ms: number) => () => void; } export type RequestHistoryIntegrityState = | { state: "ready" } | { state: "corrupt" } | { state: "unavailable"; error: unknown } - // Never rejects, including on cancellation or check failure. Completion - // proves the scanner exited; inspect again to obtain its verdict or error. + // Never rejects. Inspect again for the verdict or operational failure. | { state: "pending"; promise: Promise }; interface Generation { @@ -43,11 +45,13 @@ interface ActiveCheck { cancelled: boolean; cancel: () => void; promise: Promise; + barrier: Promise; } type CheckOutcome = | { kind: "checked"; healthy: boolean } | { kind: "failed"; error: unknown } + | { kind: "timeout"; error: Error } | { kind: "cancelled" }; export class RequestHistoryIntegrityMonitor { @@ -55,14 +59,22 @@ export class RequestHistoryIntegrityMonitor { private readonly now: () => number; private readonly intervalMs: number; private readonly failureCooldownMs: number; + private readonly checkTimeoutMs: number; + private readonly scheduleTimeout: (callback: () => void, ms: number) => () => void; private current: Generation | null = null; private active: ActiveCheck | null = null; + private readonly draining = new Set(); constructor(options: RequestHistoryIntegrityOptions = {}) { this.createJob = options.createJob ?? (() => new RebuildWorkerJob()); this.now = options.now ?? Date.now; this.intervalMs = options.intervalMs ?? REQUEST_HISTORY_INTEGRITY_INTERVAL_MS; this.failureCooldownMs = options.failureCooldownMs ?? REQUEST_HISTORY_INTEGRITY_FAILURE_COOLDOWN_MS; + this.checkTimeoutMs = options.checkTimeoutMs ?? REQUEST_HISTORY_INTEGRITY_CHECK_TIMEOUT_MS; + this.scheduleTimeout = options.scheduleTimeout ?? ((callback, ms) => { + const timer = setTimeout(callback, ms); + return () => clearTimeout(timer); + }); } inspect(handle: object, path: string): RequestHistoryIntegrityState { @@ -72,6 +84,8 @@ export class RequestHistoryIntegrityMonitor { } const generation = this.current; if (generation.corrupt) return { state: "corrupt" }; + const old = [...this.draining].filter(check => check.generation !== generation); + if (old.length) return { state: "pending", promise: Promise.all(old.map(check => check.barrier)).then(() => {}) }; if (this.active) { if (this.active.generation === generation && !this.active.cancelled && generation.verified) { return { state: "ready" }; @@ -97,7 +111,7 @@ export class RequestHistoryIntegrityMonitor { * use inspect instead. The caller drains scans before rebuilding/swapping. */ acceptRebuilt(handle: object, path: string): void { - if (this.active) throw new Error("history integrity worker must exit before accepting a rebuilt index"); + if (this.draining.size) throw new Error("history integrity worker must exit before accepting a rebuilt index"); this.current = { handle, path, verified: true, corrupt: false, nextCheckAt: this.now() + this.intervalMs, failure: null, @@ -113,7 +127,7 @@ export class RequestHistoryIntegrityMonitor { quiesce(): Promise | null { this.current = null; this.active?.cancel(); - return this.active?.promise ?? null; + return this.draining.size ? Promise.all([...this.draining].map(check => check.barrier)).then(() => {}) : null; } reset(): void { @@ -130,14 +144,17 @@ export class RequestHistoryIntegrityMonitor { } let cancel!: () => void; const cancelled = new Promise(resolve => { cancel = () => resolve({ kind: "cancelled" }); }); + let release!: () => void; const check: ActiveCheck = { generation, cancelled: false, cancel: () => { check.cancelled = true; cancel(); }, - promise: Promise.resolve(), + promise: new Promise(resolve => { release = resolve; }), + barrier: Promise.resolve(), }; this.active = check; - check.promise = this.run(check, job, cancelled); + this.draining.add(check); + check.barrier = this.run(check, job, cancelled, release); return check; } @@ -146,7 +163,10 @@ export class RequestHistoryIntegrityMonitor { generation.nextCheckAt = this.now() + this.failureCooldownMs; } - private async run(check: ActiveCheck, job: IntegrityJob, cancelled: Promise): Promise { + private async run(check: ActiveCheck, job: IntegrityJob, cancelled: Promise, release: () => void): Promise { + let expire!: (outcome: CheckOutcome) => void; + const timeout = new Promise(resolve => { expire = resolve; }); + const clear = this.scheduleTimeout(() => expire({ kind: "timeout", error: new Error("request-history integrity check timed out") }), this.checkTimeoutMs); const observed = Promise.resolve().then(async (): Promise => { if (check.cancelled) return { kind: "cancelled" }; try { @@ -155,7 +175,16 @@ export class RequestHistoryIntegrityMonitor { return { kind: "failed", error }; } }); - const outcome = await Promise.race([observed, cancelled]); + const outcome = await Promise.race([observed, cancelled, timeout]); + clear(); + if (outcome.kind === "timeout") { + if (this.current === check.generation && !check.cancelled && !check.generation.corrupt) { + this.recordFailure(check.generation, outcome.error); + } + if (this.active === check) this.active = null; + void job.dispose().catch(() => undefined); + release(); + } await new Promise(resolve => { // afterExit may return at its disposal timeout, before native I/O lets // the worker exit. Only its callback releases the file replacement gate. @@ -173,5 +202,7 @@ export class RequestHistoryIntegrityMonitor { } } if (this.active === check) this.active = null; + this.draining.delete(check); + release(); } } diff --git a/structure/dashboard-and-usage.md b/structure/dashboard-and-usage.md index 17bc382a091..9e9b8b49b10 100644 --- a/structure/dashboard-and-usage.md +++ b/structure/dashboard-and-usage.md @@ -279,7 +279,7 @@ inheriting its bounds, so deleting a ledger row removes it from the grouping. `usageLedgerMaxBytes` is unset by default; when set, an append that crosses it publishes the newest whole rows byte for byte through the shared atomic writer, refuses the rename unless the source is the exact revision that was copied, and then discards the Logs ring, the retained aggregates and the request-history index so no surface serves rows the ledger no longer has. That index (`src/routing/history/`) rebuilds a ledger of at least `REQUEST_HISTORY_WORKER_REBUILD_MIN_BYTES` (1 MiB) on a worker thread (#6990): `rebuild-worker.ts`, driven by `rebuild-job.ts`, ingests the ledger snapshot the refresh recorded into a private `.rebuild--` file through the same `ingest.ts` the inline path uses, switches it to WAL and closes it; the serving thread then closes the old index and renames the build over it. Async readers wait for the build; the routing-time sync entry never does and throws `RequestHistoryIndexBusyError`, which health scoring reads as unknown evidence. Health scoring (`src/routing/health.ts`) counts physical sends only: a request answered locally or refused before any send is no evidence for any target, and a row's terminal status and close reason score its last physical send, so a 200 stream cut mid-flight after a failover counts against the target that dropped it (`tests/server/health-scoring.test.ts`). A close discards the build, rejects waiting readers with `RequestHistoryIndexClosedError`, and removes the build files only after the worker thread exits. For 30 s after a worker build fails (other than on a replaced ledger), async readers and manual rebuilds rebuild inline instead, while routing-time reads keep reporting busy. -`src/routing/history/integrity.ts` validates an existing persisted index on first use with a read-only worker `PRAGMA quick_check` (#7966); serving refreshes retain schema/source/offset checks without a synchronous integrity scan. Async readers await initial validation; synchronous routing reports busy/unknown meanwhile. A successful fresh inline reconstruction is immediately usable, and a private worker build is integrity-checked before publication. After validation or reconstruction, the next refresh at least five minutes later starts one background check; verified reads remain available during it, and idle indexes start no timer. Operational check failures leave the index intact and delay retry for 30 s; an unverified index remains unavailable. A corrupt verdict or `SQLITE_CORRUPT`/`SQLITE_NOTADB` invalidates the live handle and enters the existing rebuild path; async history and analytics reads retry once after recovery. Close/rebuild discards stale verdicts and drains scanners through actual thread exit before index replacement. Neither validation nor recovery rewrites canonical `usage.jsonl`. `tests/usage/request-history-integrity.test.ts` covers scan scheduling, corruption recovery and lifecycle isolation. +`src/routing/history/integrity.ts` validates an existing persisted index on first use with a read-only worker `PRAGMA quick_check` (#7966); serving refreshes retain schema/source/offset checks without a synchronous integrity scan. Async readers await initial validation; synchronous routing reports busy/unknown meanwhile. A successful fresh inline reconstruction is immediately usable, and a private worker build is integrity-checked before publication. After validation or reconstruction, the next refresh at least five minutes later starts one background check; verified reads remain available during it, and idle indexes start no timer. A check that has not answered in 30 s is stopped and reported as an operational failure; waiting readers are released without waiting for the worker's actual exit. Operational check failures leave the index intact and delay retry for 30 s; an unverified index remains unavailable. A corrupt verdict or `SQLITE_CORRUPT`/`SQLITE_NOTADB` invalidates the live handle and enters the existing rebuild path; async history and analytics reads retry once after recovery. Close/rebuild discards stale verdicts and drains all scanners through actual thread exit before index replacement, including timed-out scanners. Neither validation nor recovery rewrites canonical `usage.jsonl`. `tests/usage/request-history-integrity.test.ts` covers scan scheduling, corruption recovery and lifecycle isolation. Ledger revisions in `src/usage/log.ts` capture device/inode identifiers from bigint descriptor stats as exact decimal strings; history metadata, ingestion, cooperative scans and retention compare them without narrowing to Numbers, preserving distinct NTFS identifiers above 2^53. An older index containing a rounded identifier fails the identity check and rebuilds once. `src/usage/summary.ts` turns that file into the `/api/usage` shape — totals, daily zero-filled grid, model and provider breakdowns, and `measured / reported / unreported / unsupported / estimated` counts. Each scope also aggregates end-to-end output throughput (#6309): measured output tokens and wall-clock `durationMs` are summed over attempts reporting both, exposing `throughputTokensPerSec` — a token-sum-over-duration-sum, never a mean of per-request rates. `throughputSamples` counts qualifying attempts (legacy rows without attempts contribute one); nonpositive or nonfinite tokens/timing are excluded. The Usage summary and Models/Providers tables display the rate and sample basis, or unavailable when none qualify. All three throughput displays use the same finite-number guard as their tooltip; malformed or nonfinite rates are unavailable rather than formatted. diff --git a/tests/usage/request-history-integrity.test.ts b/tests/usage/request-history-integrity.test.ts index ac424842752..20f143bfc63 100644 --- a/tests/usage/request-history-integrity.test.ts +++ b/tests/usage/request-history-integrity.test.ts @@ -138,6 +138,123 @@ function healthQueryIndex(handle: Database): string { } describe("request-history integrity lifecycle (#7966)", () => { + test("a silent scanner times out for readers but retains the replacement barrier", async () => { + let clock = 0; + let fire: (() => void) | undefined; + const exits: Array<() => void> = []; + let jobs = 0; + let disposals = 0; + const monitor = new RequestHistoryIntegrityMonitor({ + now: () => clock, + failureCooldownMs: 100, + checkTimeoutMs: 50, + scheduleTimeout: (callback: () => void, ms: number) => { + expect(ms).toBe(50); + fire = callback; + return () => { fire = undefined; }; + }, + createJob: () => { + jobs++; + return { + check: () => new Promise(() => {}), + dispose: async () => { disposals++; }, + afterExit: async (cleanup: () => void) => { exits.push(cleanup); }, + }; + }, + }); + const handle = {}; + const first = monitor.inspect(handle, "index.sqlite"); + if (first.state !== "pending") throw new Error("initial check must be pending"); + expect(fire).toBeDefined(); + clock = 50; + fire!(); + await first.promise; + expect(disposals).toBe(1); + expect(monitor.inspect(handle, "index.sqlite").state).toBe("unavailable"); + expect(jobs).toBe(1); + clock = 150; + expect(monitor.inspect(handle, "index.sqlite").state).toBe("pending"); + expect(jobs).toBe(2); + const barrier = monitor.quiesce(); + expect(barrier).not.toBeNull(); + let released = false; + void barrier!.then(() => { released = true; }); + await Promise.resolve(); + expect(released).toBe(false); + exits[0]!(); + await Promise.resolve(); + expect(released).toBe(false); + // Let cancellation schedule the second worker's exit callback. + await Promise.resolve(); + exits[1]!(); + await barrier; + expect(released).toBe(true); + }, 1000); + + test("a verdict arriving after the timeout never changes the generation's state", async () => { + for (const lateVerdict of [true, false]) { + let clock = 0; + const fires: Array<() => void> = []; + const answers: Array<(healthy: boolean) => void> = []; + const exits: Array<() => void> = []; + const monitor = new RequestHistoryIntegrityMonitor({ + now: () => clock, + failureCooldownMs: 100, + checkTimeoutMs: 50, + scheduleTimeout: callback => { fires.push(callback); return () => {}; }, + createJob: () => ({ + check: () => new Promise(resolve => { answers.push(resolve); }), + dispose: async () => {}, + afterExit: async (cleanup: () => void) => { exits.push(cleanup); }, + }), + }); + const handle = {}; + const first = monitor.inspect(handle, "index.sqlite"); + if (first.state !== "pending") throw new Error("initial check must be pending"); + await Promise.resolve(); + fires[0]!(); + await first.promise; + expect(monitor.inspect(handle, "index.sqlite").state).toBe("unavailable"); + // The timed-out worker answers late, then exits. + answers[0]!(lateVerdict); + exits[0]!(); + for (let turn = 0; turn < 5; turn++) await Promise.resolve(); + expect(monitor.inspect(handle, "index.sqlite").state).toBe("unavailable"); + // After the cooldown a fresh check decides, and only its verdict counts. + clock = 150; + const fresh = monitor.inspect(handle, "index.sqlite"); + if (fresh.state !== "pending") throw new Error("fresh check must be pending"); + await Promise.resolve(); + answers[1]!(true); + for (let turn = 0; turn < 5 && !exits[1]; turn++) await Promise.resolve(); + exits[1]!(); + await fresh.promise; + expect(monitor.inspect(handle, "index.sqlite").state).toBe("ready"); + } + }, 1000); + + test("shared async readers leave a silent initial integrity check with a bounded error", async () => { + await existingIndex(); + let fire!: () => void; + let started!: () => void; + let exit!: () => void; + const scheduled = new Promise(resolve => { started = resolve; }); + setRequestHistoryIntegrityOptionsForTests({ + scheduleTimeout: callback => { fire = callback; started(); return () => {}; }, + createJob: () => ({ + check: () => new Promise(() => {}), + dispose: async () => {}, + afterExit: async cleanup => { exit = cleanup; }, + }), + }); + const first = openRequestHistoryIndex(); + const second = openRequestHistoryIndex(); + const errors = Promise.all([first.catch(error => error), second.catch(error => error)]); + await scheduled; + fire(); + expect((await errors).every(error => error instanceof RequestHistoryIndexBusyError)).toBe(true); + exit(); + }, 5000); test("real worker checks leave repeated page, row, status and routing reads free of full serving-thread scans", async () => { await existingIndex(); const query = spyOn(Database.prototype, "query"); From 9922f8159a9e5327fcc4ead930c309faa57755ec Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 30 Oct 2026 22:04:32 +0900 Subject: [PATCH 70/84] test(history): corrupt the health index through a cold connection (#8000) The routing-health case read the index before corrupting it and relied on PRAGMA shrink_memory to evict the cached pages. macOS's system SQLite kept them, so the read after corruption was served from cache (macOS 2/4 of lane=all run 41056253802, after #7995). Reopen the index after the pre-check so the corruption lands on a connection that has not cached it, like the other corruption cases. Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> --- tests/usage/request-history-integrity.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/usage/request-history-integrity.test.ts b/tests/usage/request-history-integrity.test.ts index 20f143bfc63..4716919edb5 100644 --- a/tests/usage/request-history-integrity.test.ts +++ b/tests/usage/request-history-integrity.test.ts @@ -540,6 +540,11 @@ describe("request-history integrity lifecycle (#7966)", () => { const input = { provider: "a", model: "m1", now }; expect(healthEvidenceForCandidate(input).sampleCount).toBe(1); clearHealthHistoryCacheForTests(); + // The read above left this index's pages in the connection's page cache, and PRAGMA + // shrink_memory does not evict them on every SQLite build (macOS links the system library). + // Corrupt through a fresh connection, as the other corruption cases do. + closeRequestHistoryIndex(); + await openRequestHistoryIndex(); const handle = requestHistoryDb(); const canonical = readFileSync(usageLogPath()); corruptModelIndex(handle, path, healthQueryIndex(handle)); From cb483626c8f6af0c0120642179d3336e42b68151 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 30 Oct 2026 13:10:16 +0000 Subject: [PATCH 71/84] fix(codex): await recovery ACL checks in async parent admission (#7931) * fix(codex): await recovery ACL checks in async parent admission Hold an exact generation across fresh Windows recovery inspection and revalidate pinned identity after awaits. Migrate asynchronous parent callers with a root-bound synchronous commit scope and keep stale-result checks on both admission success and denial. Preserve synchronous compatibility exports and document native validation and remaining synchronous publication boundaries. Follow-up for #7872 item 2; not a completion claim. * test(cli): align async recovery consumers and assert awaited preflight Fix stale mocks, spies and source oracles behind the 11 failures in run 40797643817, plus five reproduced failures in related consumers. Preserve refusal, ordering, timeout and handover assertions. Add three held-promise preflight ordering regressions. Tests: 280 passed across all seven affected test files; typecheck, structure, privacy and layout/size gates passed. No production source changes. * test: keep async parent registration mergeable Relocate the existing async parent test mapping in the registry and independent golden table without changing either mapping or the four-shard execution plan. Preserve the layout size cap. * fix(cli): revalidate ocx v2 state once the awaited admission enters Recovery admission can now wait on ACL checks. Read the native feature state and the OpenCodex config inside the admitted callback, recheck the hybrid-pin guard there, and save that fresh config, so a change made meanwhile is neither overruled nor overwritten. Also restores the executable mode of src/cli/index.ts. * fix(cli): save ocx v2 config changes inside the admitted callback The admission is released before the awaiting command resumes, so a save made after the callback could overwrite a competing write with the read copy. --------- Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> --- scripts/test-layout/layout.json | 2 +- src/cli/account-main.ts | 5 +- src/cli/codex-shim-autorestore.ts | 18 +- src/cli/dispatch.ts | 12 +- src/cli/doctor.ts | 10 +- src/cli/index.ts | 4 +- src/cli/init.ts | 4 +- src/cli/restart-scope.ts | 4 +- src/cli/root.ts | 12 +- src/cli/v2.ts | 88 ++++-- src/client/machine-api.ts | 4 +- src/codex/app-server-restart-service.ts | 4 +- src/codex/cli-update-job-store-windows.ts | 126 ++++++-- src/codex/cli-update-job-worker.ts | 14 +- src/codex/cli-update-recovery.ts | 62 +++- src/codex/cli-update-selection-gate.ts | 78 ++++- src/codex/desktop-app-restart.ts | 12 +- src/codex/desktop-app/handoff.ts | 2 +- src/codex/runtime.ts | 46 ++- src/codex/shim.ts | 24 +- src/remote-control/workspace-codex-runtime.ts | 4 +- .../management/agent-settings-routes.ts | 24 +- structure/cli-management.md | 2 +- structure/codex-cli-update.md | 26 +- structure/config.md | 2 +- structure/remote-workspace.md | 2 +- structure/runtime.md | 2 +- ...cli-capability-operation-workflows.test.ts | 2 +- tests/cli/cli-head.test.ts | 32 +- tests/cli/cli-help-recovery.test.ts | 2 +- tests/cli/cli-ready.test.ts | 21 +- tests/cli/cli-sync-expect-link.test.ts | 4 +- tests/cli/cli-version-skew.test.ts | 2 +- .../codex-cli-update-parent-async.test.ts | 297 ++++++++++++++++++ .../codex-cli-update-windows-pending.test.ts | 33 +- .../multi-agent-keep-native-v1.test.ts | 88 +++++- tests/fixtures/parent-stop-runner.ts | 2 +- tests/fixtures/test-layout-expected.json | 1 + 38 files changed, 931 insertions(+), 146 deletions(-) create mode 100644 tests/codex-integration/codex-cli-update-parent-async.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index ef5c8df7fe2..2442a618c9c 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -604,7 +604,7 @@ "codex-cli-installation-targets.test.ts": "codex-integration", "codex-cli-update-launcher-policy.test.ts": "codex-integration", "codex-cli-update-integrity.test.ts": "codex-integration", - "codex-cli-update-plan.test.ts": "codex-integration", + "codex-cli-update-plan.test.ts": "codex-integration", "codex-cli-update-parent-async.test.ts": "codex-integration", "codex-cli-update-zero-effect.test.ts": "codex-integration", "codex-cli-windows-installation-files.test.ts": "codex-integration", "codex-forward-access-programs-writer.test.ts": "codex-integration", diff --git a/src/cli/account-main.ts b/src/cli/account-main.ts index 08978824f10..70b2dd2cc57 100644 --- a/src/cli/account-main.ts +++ b/src/cli/account-main.ts @@ -3,6 +3,7 @@ import { redactSecretArgs } from "./secret-args"; import { codexExecInvocation } from "../codex/exec-invocation"; import { validatedCodexExecInvocationAsync, type CodexExecBoundaryAsyncDeps } from "../codex/exec-boundary"; import { resolveAndPersistCodexRuntime, type ResolveCodexRuntimeDeps } from "../codex/runtime"; +import { withCodexCliSelectionParentWriteAsync } from "../codex/cli-update-selection-gate"; import type { ResolveDeps, SpawnInvocation } from "../lib/win-exec"; import { apiError, @@ -62,7 +63,7 @@ export async function nativeMainCodexLoginValidatedInvocation( deps: NativeMainCodexLoginValidatedDeps = {}, ): Promise { const env = deps.env ?? process.env; - const command = resolveAndPersistCodexRuntime({ + const command = (await withCodexCliSelectionParentWriteAsync(() => resolveAndPersistCodexRuntime({ env, platform, existsSync: deps.existsSync, @@ -70,7 +71,7 @@ export async function nativeMainCodexLoginValidatedInvocation( configDir: deps.configDir, readFileSync: deps.readFileSync, discoverAlternatives: false, - }).runtime.command || "codex"; + }), { configDir: deps.configDir, signal: deps.signal })).runtime.command || "codex"; return validatedCodexExecInvocationAsync(command, ["login"], { env, platform, diff --git a/src/cli/codex-shim-autorestore.ts b/src/cli/codex-shim-autorestore.ts index 626bff50da9..8ea964ac3c7 100644 --- a/src/cli/codex-shim-autorestore.ts +++ b/src/cli/codex-shim-autorestore.ts @@ -1,4 +1,4 @@ -import { autoRestoreCodexShim } from "../codex/shim"; +import { autoRestoreCodexShim, autoRestoreCodexShimAsync } from "../codex/shim"; import { codexShimAutoRestoreEnabled, readConfigDiagnostics } from "../config"; export interface CodexShimAutoRestoreCliDeps { @@ -77,3 +77,19 @@ export function runCodexShimAutoRestore(deps: CodexShimAutoRestoreCliDeps = DEFA ); } } + +export async function maybeAutoRestoreCodexShimAsync(command: string | undefined, args: string[]): Promise { + if (!skipsCodexShimAutoRestore(command, args)) await runCodexShimAutoRestoreAsync(); +} + +export async function runCodexShimAutoRestoreAsync(): Promise { + try { + const result = await autoRestoreCodexShimAsync({ + enabled: () => codexShimAutoRestoreEnabled(DEFAULT_DEPS.readConfig().config, DEFAULT_DEPS.env), + }); + if (result.status === "restored") DEFAULT_DEPS.warn(`⚠️ ${result.message} (automatic repair after Codex update)`); + else if ((result.status === "deferred" || result.status === "ineligible") && result.message) DEFAULT_DEPS.warn(`⚠️ ${result.message}`); + } catch (error) { + DEFAULT_DEPS.warn(`⚠️ Codex shim auto-restore failed; continuing without it: ${error instanceof Error ? error.message : String(error)}. Run 'ocx codex-shim install' after the Codex update finishes.`); + } +} diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 6f9809c9916..d4d3f794166 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -435,8 +435,8 @@ const commandRunners: Record = { if (expectation) { // The root preflight skipped the shim repair for a pinned sync; the identity held, so // run it now, as an unpinned sync would have. - const { runCodexShimAutoRestore } = await import("./codex-shim-autorestore"); - runCodexShimAutoRestore(); + const { runCodexShimAutoRestoreAsync } = await import("./codex-shim-autorestore"); + await runCodexShimAutoRestoreAsync(); } // The connection the sync ran under, read inside its lock; the read above may be stale. const link = result.connection.transport === "link"; @@ -731,10 +731,10 @@ const commandRunners: Record = { return Number(process.exitCode ?? 0); }, "codex-shim": async deps => { - const { codexShimStatus, diagnoseCodexShim, installCodexShim, uninstallCodexShim } = await import("../codex/shim"); + const { codexShimStatus, diagnoseCodexShim, installCodexShimAsync, uninstallCodexShimAsync } = await import("../codex/shim"); switch (deps.args[1]) { case "install": { - const r = installCodexShim(); + const r = await installCodexShimAsync(); const { healthy, runnable, active, summary } = diagnoseCodexShim(); const success = !r.refused && (runnable ?? healthy); const { collectCodexShimReadinessWarnings } = await import("./codex-shim-readiness"); @@ -764,7 +764,7 @@ const commandRunners: Record = { } case "uninstall": case "remove": { - const r = uninstallCodexShim(); + const r = await uninstallCodexShimAsync(); console.log(r.removed ? `✅ ${r.message}` : `⚠️ ${r.message}`); break; } @@ -858,7 +858,7 @@ const commandRunners: Record = { }, ready: async deps => { // Fail-closed impossible-state guard: readyArgs is populated by the - // preparse block in src/cli/root.ts before maybeAutoRestoreCodexShim, so + // preparse block in src/cli/root.ts before maybeAutoRestoreCodexShimAsync, so // reaching here without it means dispatch diverged. Refuse with code 64 // and perform NO I/O (no discovery/probe). process.exit is `never`, // narrowing below. diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 2753a7362f6..ff33b7ab209 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -70,8 +70,8 @@ import { effortClampAppliesToRuntime, liveRemovedEfforts, loadLastEffortClamp, - persistCodexRuntime, - resolveAndPersistCodexRuntime, + persistCodexRuntimeAsync, + resolveAndPersistCodexRuntimeAsync, resolveCodexRuntime, } from "../codex/runtime"; import { CODEX_REAUTH_ACTION, collectOAuthHealthEntriesForCli, MASKED_ACCOUNT_FALLBACK, type OAuthHealthEntry } from "../oauth/health"; @@ -1266,8 +1266,8 @@ export async function runDoctor(args: string[] = []): Promise { const resolved = resolveCodexRuntime(); if (!resolved.newerAvailable) { console.log("No newer Codex runtime found; keeping current selection."); - const current = resolveAndPersistCodexRuntime(); - console.log(`Selected: ${displayCodexRuntimePath(current.runtime.command)} (${current.runtime.version ?? "unknown"})`); + const current = await resolveAndPersistCodexRuntimeAsync(); + console.log(`Selected: ${displayCodexRuntimePath(current?.runtime.command ?? "codex")} (${current?.runtime.version ?? "unknown"})`); return; } if (resolved.runtime.source === "environment") { @@ -1276,7 +1276,7 @@ export async function runDoctor(args: string[] = []): Promise { console.log("Then run ocx sync."); return; } - persistCodexRuntime({ + await persistCodexRuntimeAsync({ command: resolved.newerAvailable.command, version: resolved.newerAvailable.version, source: "configured", diff --git a/src/cli/index.ts b/src/cli/index.ts index eff70dd94bb..52cc993a5fe 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1674,8 +1674,8 @@ async function handleUninstall() { }); try { - const { uninstallCodexShim } = await import("../codex/shim"); - const r = uninstallCodexShim(); + const { uninstallCodexShimAsync } = await import("../codex/shim"); + const r = await uninstallCodexShimAsync(); console.log(r.removed ? "✅ Codex autostart shim removed" : "- Codex autostart shim removed: not installed"); } catch (err) { failures.push("Codex autostart shim removed"); diff --git a/src/cli/init.ts b/src/cli/init.ts index 83a69cff06f..b8866eccb3e 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -257,9 +257,9 @@ export async function runInit(): Promise { prompt.throwIfCancelled(); if (shimAnswer.trim().toLowerCase() !== "n") { try { - const { installCodexShim } = await import("../codex/shim"); + const { installCodexShimAsync } = await import("../codex/shim"); prompt.throwIfCancelled(); - const result = installCodexShim(); + const result = await installCodexShimAsync(); console.log(result.installed ? `✅ ${result.message}` : `⚠️ ${result.message}`); } catch (err) { if (err instanceof InitCancelledError) throw err; diff --git a/src/cli/restart-scope.ts b/src/cli/restart-scope.ts index 58b5264606e..1574520c192 100644 --- a/src/cli/restart-scope.ts +++ b/src/cli/restart-scope.ts @@ -104,9 +104,9 @@ export async function handleDesktopAppRestart( log: Pick, io: DesktopAppRestartIo = {}, ): Promise { - const { restartCodexDesktopApp } = await import("../codex/desktop-app-restart"); + const { restartCodexDesktopAppAsync } = await import("../codex/desktop-app-restart"); const { startDesktopRestartHandoff } = await import("../codex/desktop-app/handoff"); - const result = restartCodexDesktopApp({ + const result = await restartCodexDesktopAppAsync({ ...io, // The CLI is the one caller whose exit is exactly the signal the helper waits for, // so it is the one caller allowed to hand off. The management service is not (it diff --git a/src/cli/root.ts b/src/cli/root.ts index f34a1cc22ab..2668b1fb8a8 100644 --- a/src/cli/root.ts +++ b/src/cli/root.ts @@ -13,7 +13,7 @@ import { parseReadyArgs, type ReadyArgs } from "./ready"; import { parseResolveArgs, type ResolveArgs } from "./resolve"; import { parseStopApproval } from "./stop-approval"; import { parseSyncExpectation, SYNC_EXPECTATION_USAGE_EXIT } from "./sync-expectation"; -import { maybeAutoRestoreCodexShim } from "./codex-shim-autorestore"; +import { maybeAutoRestoreCodexShimAsync } from "./codex-shim-autorestore"; import { findCommand } from "./registry"; import { printUnknownCommand } from "./help-recovery"; import { noteCredentialArgv, redactSecretArgs } from "./secret-args"; @@ -64,7 +64,7 @@ export function parseCliHead(argv: string[]): CliHead { }; } // P1: pre-parse `ocx ready` and reject invalid arguments with exit 64 BEFORE - // maybeAutoRestoreCodexShim (or any discovery/probe/filesystem-capable global + // maybeAutoRestoreCodexShimAsync (or any discovery/probe/filesystem-capable global // preflight) runs. `ready --help` / `help ready` already exited above, so this // only sees ready args without a help flag. Valid args are stashed so the // switch dispatch can call runReady without a second parse. @@ -74,7 +74,7 @@ export function parseCliHead(argv: string[]): CliHead { return { kind: "ready", command, args, readyArgs: parsed.args }; } // Same ordering contract as `ready`: `ocx resolve` rejects any argument with exit - // 64 BEFORE maybeAutoRestoreCodexShim (or any other preflight with side effects) runs. + // 64 BEFORE maybeAutoRestoreCodexShimAsync (or any other preflight with side effects) runs. if (command === "resolve") { const parsed = parseResolveArgs(args.slice(1)); if (!parsed.ok) return { kind: "resolve", command, args, resolveArgs: undefined }; @@ -115,7 +115,7 @@ export async function runCli(argv: string[]): Promise { console.error(" Default wait timeout is 45 seconds."); process.exit(64); } - maybeAutoRestoreCodexShim(head.command, head.args); + await maybeAutoRestoreCodexShimAsync(head.command, head.args); return head; } case "resolve": { @@ -128,7 +128,7 @@ export async function runCli(argv: string[]): Promise { console.error(" and the identity-checked liveness verdict."); process.exit(64); } - maybeAutoRestoreCodexShim(head.command, head.args); + await maybeAutoRestoreCodexShimAsync(head.command, head.args); return head; } case "command": @@ -154,7 +154,7 @@ export async function runCli(argv: string[]): Promise { } } await maybeNoticeVersionSkew(head.command, head.args); - maybeAutoRestoreCodexShim(head.command, head.args); + await maybeAutoRestoreCodexShimAsync(head.command, head.args); return head; } } diff --git a/src/cli/v2.ts b/src/cli/v2.ts index 116cc1be0dc..734cccd3023 100644 --- a/src/cli/v2.ts +++ b/src/cli/v2.ts @@ -21,6 +21,7 @@ import type { ResolveDeps, SpawnInvocation } from "../lib/win-exec"; import { validatedCodexExecInvocation } from "../codex/exec-boundary"; import { deleteConfigTopLevelKey, loadConfig, saveConfig } from "../config"; import { resolveAndPersistCodexRuntime, type ResolveCodexRuntimeDeps } from "../codex/runtime"; +import { withCodexCliSelectionParentWriteAsync } from "../codex/cli-update-selection-gate"; export interface V2CliDeps { execFile?: (file: string, args: string[], options?: SpawnInvocation["options"] & { env?: NodeJS.ProcessEnv }) => void; @@ -79,6 +80,11 @@ export function codexFeaturesInvocation( }); } +export async function codexFeaturesInvocationAsync(action: "enable" | "disable", feature = "multi_agent_v2", + platform: NodeJS.Platform = process.platform, deps: CodexFeaturesInvocationDeps = {}): Promise { + return withCodexCliSelectionParentWriteAsync(() => codexFeaturesInvocation(action, feature, platform, deps), { configDir: deps.configDir }); +} + /** * Run `codex features ` synchronously - the management API * fallback when no deps toggle is injected. Shares the invocation builder and @@ -195,45 +201,77 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: () return emit(true, []); } if (parsed.verb === "mode-hint") { - const result = setMultiAgentModeHintText(parsed.value); + const result = parsed.value === null ? setMultiAgentModeHintText(null) + : await withCodexCliSelectionParentWriteAsync(() => setMultiAgentModeHintText(parsed.value)); if (!result.ok) return emit(false, ["Unable to write the native mode hint. Check Codex support and ownership, then read ocx v2 status."]); changed = result.changed; return emit(true, [result.changed ? "multi_agent_mode_hint_text saved — applies to new sessions." : "multi_agent_mode_hint_text already set — nothing to do."]); } if (parsed.verb === "threads") { - const enabled = (deps.isEnabled ?? isMultiAgentV2Enabled)(); - const result = transitionMultiAgentV2(enabled, (next, env, validate) => runCodexFeatures(next ? "enable" : "disable", deps, env, validate), { threadLimit: parsed.value }); + // Admission can wait on recovery ACL checks; the feature state is read once admitted, in + // the same synchronous turn as the transition, so a change made meanwhile is not undone. + let enabled = false; + const result = await withCodexCliSelectionParentWriteAsync(() => { + enabled = (deps.isEnabled ?? isMultiAgentV2Enabled)(); + return transitionMultiAgentV2(enabled, (next, env, validate) => runCodexFeatures(next ? "enable" : "disable", deps, env, validate), { threadLimit: parsed.value }); + }); if (!result.ok) return emit(false, ["Unable to update the native thread limit. Read ocx v2 status before retrying."]); changed = result.changed; return emit(true, [changed ? `max_threads = ${parsed.value} (${enabled ? "v2" : "v1"}) — applies to new sessions.` : `max_threads already ${parsed.value} — nothing to do.`]); } - const config = loadConfig(); + // Every awaited admission below re-reads, decides and saves the config inside its + // synchronous callback, so a setting changed while admission waited is neither + // overruled by a stale decision nor overwritten by a stale save. + let config = loadConfig(); let successLines: string[]; let unchangedKeep: boolean | undefined; if (parsed.verb === "mode") { - changed = (config.multiAgentMode ?? "default") !== parsed.value; + const persistMode = (): boolean => { + const before = config.multiAgentMode ?? "default"; + if (parsed.value === "default") deleteConfigTopLevelKey(config, "multiAgentMode"); + else config.multiAgentMode = parsed.value; + saveConfig(config); + return before !== parsed.value; + }; if (parsed.value !== "default") { - const target = parsed.value === "v2" && config.keepNativeChatGptOnV1 !== true; - const transition = transitionMultiAgentV2(target, (next, env, validate) => runCodexFeatures(next ? "enable" : "disable", deps, env, validate)); + let modeChanged = false; + const transition = await withCodexCliSelectionParentWriteAsync(() => { + config = loadConfig(); + const target = parsed.value === "v2" && config.keepNativeChatGptOnV1 !== true; + const result = transitionMultiAgentV2(target, (next, env, validate) => runCodexFeatures(next ? "enable" : "disable", deps, env, validate)); + if (result.ok) modeChanged = persistMode(); + return result; + }); if (!transition.ok) { changed = false; return emit(false, ["Native mode transition failed. Read ocx v2 status before retrying."]); } - changed ||= transition.changed; + changed = modeChanged || transition.changed; + } else { + changed = persistMode(); } - if (parsed.value === "default") deleteConfigTopLevelKey(config, "multiAgentMode"); - else config.multiAgentMode = parsed.value; - saveConfig(config); successLines = [multiAgentModeLine(parsed.value, config.keepNativeChatGptOnV1 === true), "Applies to NEW sessions; running sessions keep their pinned multi-agent version."]; } else if (parsed.verb === "keep-native-v1") { - unchangedKeep = (config.keepNativeChatGptOnV1 === true) === parsed.value; - changed = !unchangedKeep; + const persistKeep = (): void => { + unchangedKeep = (config.keepNativeChatGptOnV1 === true) === parsed.value; + if (parsed.value) config.keepNativeChatGptOnV1 = true; + else deleteConfigTopLevelKey(config, "keepNativeChatGptOnV1"); + saveConfig(config); + }; if (parsed.value && requiresGlobalV2Disabled(config.multiAgentMode, true)) { - const transition = transitionMultiAgentV2(false, (next, env, validate) => runCodexFeatures(next ? "enable" : "disable", deps, env, validate)); + const transition = await withCodexCliSelectionParentWriteAsync(() => { + config = loadConfig(); + // Revalidated once admitted: mode may have left v2 while admission waited. + const result = requiresGlobalV2Disabled(config.multiAgentMode, true) + ? transitionMultiAgentV2(false, (next, env, validate) => runCodexFeatures(next ? "enable" : "disable", deps, env, validate)) + : { ok: true as const, changed: false }; + if (result.ok) persistKeep(); + return result; + }); if (!transition.ok) { changed = false; return emit(false, ["Native hybrid-mode transition failed. Read ocx v2 status before retrying."]); } - changed ||= transition.changed; + changed = !unchangedKeep || transition.changed; + } else { + persistKeep(); + changed = !unchangedKeep; } - if (parsed.value) config.keepNativeChatGptOnV1 = true; - else deleteConfigTopLevelKey(config, "keepNativeChatGptOnV1"); - saveConfig(config); successLines = [parsed.value ? "keep_native_chatgpt_on_v1: ON — ChatGPT-native rows stay v1 when mode is v2 (new sessions)." : "keep_native_chatgpt_on_v1: OFF — ChatGPT-native rows follow v1/base/v2 (new sessions)."]; @@ -242,7 +280,19 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: () if (want && requiresGlobalV2Disabled(config.multiAgentMode, config.keepNativeChatGptOnV1 === true)) { return emit(false, ["v2 on: incompatible with keep-native-v1 while mode is v2 — Codex's global multi_agent_v2 overrides the native v1 catalog pin. Run 'ocx v2 keep-native-v1 off' first."]); } - const transition = transitionMultiAgentV2(want, (next, env, validate) => runCodexFeatures(next ? "enable" : "disable", deps, env, validate)); + let incompatible = false; + const transition = await withCodexCliSelectionParentWriteAsync(() => { + // Revalidated once admitted: keep-native-v1 or mode v2 may have been set meanwhile. + const current = loadConfig(); + if (want && requiresGlobalV2Disabled(current.multiAgentMode, current.keepNativeChatGptOnV1 === true)) { + incompatible = true; + return { ok: true as const, changed: false }; + } + return transitionMultiAgentV2(want, (next, env, validate) => runCodexFeatures(next ? "enable" : "disable", deps, env, validate)); + }); + if (incompatible) { + return emit(false, ["v2 on: incompatible with keep-native-v1 while mode is v2 — Codex's global multi_agent_v2 overrides the native v1 catalog pin. Run 'ocx v2 keep-native-v1 off' first."]); + } if (!transition.ok) return emit(false, ["Native feature transition failed. Read ocx v2 status before retrying."]); changed = transition.changed; if (!changed) return emit(true, [`multi_agent_v2 already ${want ? "ON" : "OFF"} — nothing to do.`]); diff --git a/src/client/machine-api.ts b/src/client/machine-api.ts index 5aeb11efc56..12997a8e6f5 100644 --- a/src/client/machine-api.ts +++ b/src/client/machine-api.ts @@ -1,5 +1,5 @@ import { journalOwner } from "../codex/journal"; -import { diagnoseCodexShim, installCodexShim, uninstallCodexShim } from "../codex/shim"; +import { diagnoseCodexShim, installCodexShimAsync, uninstallCodexShimAsync } from "../codex/shim"; import { readManagementJsonBody } from "../server/management/body"; import type { OcxClientConnectionConfig } from "../types"; import { disconnectClient, syncConnectedClient } from "./connect"; @@ -130,7 +130,7 @@ export async function handleMachineApi( return Response.json({ error: "action must be install, repair, or uninstall" }, { status: 400 }); } try { - const result = input.action === "uninstall" ? uninstallCodexShim() : installCodexShim(); + const result = input.action === "uninstall" ? await uninstallCodexShimAsync() : await installCodexShimAsync(); return Response.json({ success: true, action: input.action, result, shim: diagnoseCodexShim() }); } catch (error) { return Response.json({ success: false, error: error instanceof Error ? error.message : "shim action failed" }, { status: 409 }); diff --git a/src/codex/app-server-restart-service.ts b/src/codex/app-server-restart-service.ts index a84715351b1..da1776e12f1 100644 --- a/src/codex/app-server-restart-service.ts +++ b/src/codex/app-server-restart-service.ts @@ -37,8 +37,8 @@ import { getServerListenPort } from "../server/lifecycle"; import type { DesktopPickerState } from "./desktop-app/picker-freshness"; async function defaultRestartDesktopApp(): Promise { - const { restartCodexDesktopApp } = await import("./desktop-app-restart"); - const outcome = restartCodexDesktopApp({ allowHandoff: false }); + const { restartCodexDesktopAppAsync } = await import("./desktop-app-restart"); + const outcome = await restartCodexDesktopAppAsync({ allowHandoff: false }); return { attempted: outcome.attempted, stopped: outcome.stopped, diff --git a/src/codex/cli-update-job-store-windows.ts b/src/codex/cli-update-job-store-windows.ts index 9444bff57f2..d7b6e31b333 100644 --- a/src/codex/cli-update-job-store-windows.ts +++ b/src/codex/cli-update-job-store-windows.ts @@ -686,83 +686,155 @@ export interface WindowsCliRecoveryIo { withProofHandle(name: string, run: (handle: bigint) => T): T; write(name: string, pendingName: string, body: string, adoptPending?: boolean): void; } +export interface WindowsCliRecoveryAsyncIo { + readonly dir: string; + names(): string[]; + read(name: string): Promise; + write(name: string, pendingName: string, body: string, adoptPending?: boolean): Promise; +} +type RecoveryStoreStep = { kind: "acl"; entries: readonly WindowsAclEntry[] } + | { kind: "harden"; path: string; directory: boolean }; +type RecoveryStoreSteps = Generator; +function runRecoveryStoreSteps(steps: RecoveryStoreSteps): T { + let step = steps.next(); + while (!step.done) { + try { + const effect = step.value; + if (effect.kind === "acl") { + if (!verifyWindowsEntriesAcl(aclTargets(effect.entries), ACL_VERIFY_TIMEOUT_MS)) throw unsafe(); + } else if (effect.directory) hardenSecretDir(toNamespacedPath(effect.path), { required: true }); + else hardenSecretPath(toNamespacedPath(effect.path), { required: true }); + } catch (error) { step = steps.throw(error); continue; } + step = steps.next(); + } + return step.value; +} +async function runRecoveryStoreStepsAsync(steps: RecoveryStoreSteps): Promise { + let step = steps.next(); + while (!step.done) { + try { + const effect = step.value; + if (effect.kind === "acl") await verifyAcl(effect.entries); + else if (effect.directory) await hardenSecretDirAsync(toNamespacedPath(effect.path), { required: true }); + else await hardenSecretPathAsync(toNamespacedPath(effect.path), { required: true }); + } catch (error) { step = steps.throw(error); continue; } + step = steps.next(); + } + return step.value; +} +function openRecoveryContext(config: string): Ctx { + if (process.platform !== "win32" || process.arch !== "x64") throw unsafe(); + assertNotRealHomeUnderTest(config); + const kernel = recoveryFfi.dlopen("kernel32.dll", KERNEL_SYMBOLS); + try { return { ffi: recoveryFfi, kernel, nt: recoveryFfi.dlopen("ntdll.dll", NT_SYMBOLS), pins: new PinSet() }; } + catch (error) { kernel.close(); throw error; } +} /** The shared recovery protocol uses the ledger's native ancestry, no-reparse, * identity, private-DACL and rename primitives at synchronous gate boundaries. * Restart publication may write an existing pinned store without creating it. */ export function withWindowsCliRecoveryStore(config: string, create: boolean, run: (io: WindowsCliRecoveryIo) => T, publish = false): T { - if (process.platform !== "win32" || process.arch !== "x64") throw unsafe(); - assertNotRealHomeUnderTest(config); - const kernel = recoveryFfi.dlopen("kernel32.dll", KERNEL_SYMBOLS); - const nt = recoveryFfi.dlopen("ntdll.dll", NT_SYMBOLS); - const ctx: Ctx = { ffi: recoveryFfi, kernel, nt, pins: new PinSet() }; - const verify = (entries: readonly WindowsAclEntry[]) => { - if (!verifyWindowsEntriesAcl(aclTargets(entries), ACL_VERIFY_TIMEOUT_MS)) throw unsafe(); - }; + const ctx = openRecoveryContext(config); try { + const io = runRecoveryStoreSteps(recoveryStoreSteps(ctx, config, create, publish)); + return run({ dir: io.dir, names: io.names, + read: name => runRecoveryStoreSteps(io.read(name)), + withProofHandle: (name, use) => runRecoveryStoreSteps(io.withProofHandle(name, use)), + write: (name, pending, body, adopt) => runRecoveryStoreSteps(io.write(name, pending, body, adopt)), + }); + } finally { ctx.pins.closeAll(ctx.kernel); ctx.nt.close(); ctx.kernel.close(); } +} + +/** Parent admission awaits the same native protocol; pins outlive every ACL read. */ +export async function withWindowsCliRecoveryStoreAsync(config: string, create: boolean, + run: (io: WindowsCliRecoveryAsyncIo) => Promise, publish = false): Promise { + const ctx = openRecoveryContext(config); + try { + const io = await runRecoveryStoreStepsAsync(recoveryStoreSteps(ctx, config, create, publish)); + return await run({ dir: io.dir, names: io.names, + read: name => runRecoveryStoreStepsAsync(io.read(name)), + write: (name, pending, body, adopt) => runRecoveryStoreStepsAsync(io.write(name, pending, body, adopt)), + }); + } finally { ctx.pins.closeAll(ctx.kernel); ctx.nt.close(); ctx.kernel.close(); } +} + +function* recoveryStoreSteps(ctx: Ctx, config: string, create: boolean, publish: boolean) { + const kernel = ctx.kernel; const chain = pinChain(ctx, config, ACCESS_DIR_PIN); - verify(chain.map((entry, i) => ({ path: entry.path, directory: true, - policy: i >= chain.length - 2 ? "write-private" : "trusted-ancestry" }))); + const checkChain = () => { + for (const entry of chain) if (!sameIdentity(entry.inspection, inspect(ctx, entry.handle, true))) throw unsafe(); + }; + yield { kind: "acl", entries: chain.map((entry, i) => ({ path: entry.path, directory: true, + policy: i >= chain.length - 2 ? "write-private" : "trusted-ancestry" })) } satisfies RecoveryStoreStep; + checkChain(); const configPin = chain.at(-1); if (!configPin) throw unsafe(); const dir = join(config, "codex-cli-update-recovery"); try { - if (create) { mkdirSync(dir); hardenSecretDir(toNamespacedPath(dir), { required: true }); } + if (create) { mkdirSync(dir); yield { kind: "harden", path: dir, directory: true } satisfies RecoveryStoreStep; } } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; } const directoryPin = relOpen(ctx, configPin.handle, "codex-cli-update-recovery", true, create || publish ? ACCESS_DIR_PIN : ACCESS_READ, SHARE_DIR); - inspect(ctx, directoryPin, true); assertCanonicalPath(ctx, directoryPin, dir); - verify([{ path: dir, directory: true, policy: "private" }]); + const directoryIdentity = inspect(ctx, directoryPin, true); assertCanonicalPath(ctx, directoryPin, dir); + const checkDirectory = () => { + checkChain(); + if (!sameIdentity(directoryIdentity, inspect(ctx, directoryPin, true))) throw unsafe(); + }; + yield { kind: "acl", entries: [{ path: dir, directory: true, policy: "private" }] } satisfies RecoveryStoreStep; + checkDirectory(); if (create) flushDir(ctx, configPin.handle); - const leaf = (name: string, access = ACCESS_READ, sharing = SHARE_LEAF_VERIFY) => { + const leaf = function* (name: string, access = ACCESS_READ, sharing = SHARE_LEAF_VERIFY): RecoveryStoreSteps<{ handle: bigint; stat: StoreInspection }> { if (!/^[0-9a-f-]{36}(?:\.(?:terminal|proof))?(?:\.pending|\.json|\.terminal|\.proof)$/.test(name)) throw unsafe(); const handle = relOpen(ctx, directoryPin, name, false, access, sharing); const stat = inspect(ctx, handle, false); if (stat.links !== 1 || stat.size > 1024) throw unsafe(); - verify([{ path: join(dir, name), directory: false, policy: "private" }]); + yield { kind: "acl", entries: [{ path: join(dir, name), directory: false, policy: "private" }] }; + checkDirectory(); + const current = inspect(ctx, handle, false); + if (!sameIdentity(stat, current) || current.links !== 1 || current.size !== stat.size) throw unsafe(); return { handle, stat }; }; - return run({ dir, + return { dir, names: () => readdirSync(dir), - read(name) { - const file = leaf(name); + *read(name: string): RecoveryStoreSteps { + const file = yield* leaf(name); try { return readAll(ctx, file.handle, file.stat.size).toString("utf8"); } finally { ctx.pins.release(file.handle, kernel); } }, - withProofHandle(name, use) { + *withProofHandle(name: string, use: (handle: bigint) => T): RecoveryStoreSteps { if (!/^[0-9a-f-]{36}\.proof$/.test(name)) throw unsafe(); - const file = leaf(name, 0x40100080, 7); + const file = yield* leaf(name, 0x40100080, 7); try { return use(file.handle); } finally { ctx.pins.release(file.handle, kernel); } }, - write(name, pendingName, body, adoptPending = false) { + *write(name: string, pendingName: string, body: string, adoptPending = false): RecoveryStoreSteps { if (!(create || publish) || Buffer.byteLength(body) > 1024 || !/^[0-9a-f-]{36}(?:\.terminal|\.proof)?$/.test(name.replace(/\.json$/, "")) || pendingName !== name.replace(/\.json$/, "") + ".pending") throw unsafe(); // Recovery preserves the only durable child identity until atomic rename. const temp = join(dir, pendingName), destination = join(dir, name); if (!adoptPending) { try { - const old = leaf(pendingName, ACCESS_LEAF, SHARE_LEAF); + const old = yield* leaf(pendingName, ACCESS_LEAF, SHARE_LEAF); markDelete(ctx, old.handle); ctx.pins.release(old.handle, kernel); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } const fd = openSync(temp, "wx", 0o600); try { writeFileSync(fd, body); fsyncSync(fd); } finally { closeSync(fd); } } - hardenSecretPath(toNamespacedPath(temp), { required: true }); - const file = leaf(pendingName, adoptPending ? ACCESS_LEAF | 0x40000000 : ACCESS_LEAF, SHARE_LEAF); + yield { kind: "harden", path: temp, directory: false }; + checkDirectory(); + const file = yield* leaf(pendingName, adoptPending ? ACCESS_LEAF | 0x40000000 : ACCESS_LEAF, SHARE_LEAF); try { if (!readAll(ctx, file.handle, file.stat.size).equals(Buffer.from(body))) throw unsafe(); if (adoptPending) flushDir(ctx, file.handle); renamePinned(ctx, file.handle, destination); // The held adopted pin denies other writers; this reader must share // its write/flush access as well as DELETE through identity readback. - const published = leaf(name, ACCESS_READ, adoptPending ? SHARE_LEAF_VERIFY | 0x2 : SHARE_LEAF_VERIFY); + const published = yield* leaf(name, ACCESS_READ, adoptPending ? SHARE_LEAF_VERIFY | 0x2 : SHARE_LEAF_VERIFY); try { if (!sameIdentity(file.stat, published.stat)) throw unsafe(); } finally { ctx.pins.release(published.handle, kernel); } flushDir(ctx, directoryPin); } finally { ctx.pins.release(file.handle, kernel); forgetEphemeralSecretPath(toNamespacedPath(temp)); } }, - }); - } finally { ctx.pins.closeAll(kernel); nt.close(); kernel.close(); } + }; } export interface WindowsJobStoreIo { diff --git a/src/codex/cli-update-job-worker.ts b/src/codex/cli-update-job-worker.ts index 0dc08e8b2b0..c61aa759523 100644 --- a/src/codex/cli-update-job-worker.ts +++ b/src/codex/cli-update-job-worker.ts @@ -34,9 +34,7 @@ import { trustedNodeLauncherContext } from "../cli/launcher-context"; import { spawnWorker } from "../lib/worker-embed"; import { recoverClosedCodexCliUpdateWorker } from "./cli-update-worker-recovery"; import { - CODEX_CLI_UPDATE_GATE_FREE, - CODEX_CLI_UPDATE_GATE_PARENT_WRITE, - CODEX_CLI_UPDATE_GATE_WORKER_COMMIT, + codexCliSelectionGateState, cancelCodexCliUpdateFence, codexCliUpdateSelectionGate, createCodexCliUpdateFence, @@ -108,13 +106,8 @@ export interface CodexCliUpdateWorkerDiagnostics { } export function codexCliUpdateWorkerDiagnostics(): CodexCliUpdateWorkerDiagnostics { - const raw = Atomics.load(new Int32Array(codexCliUpdateSelectionGate()), 0); - const gate = raw === CODEX_CLI_UPDATE_GATE_FREE ? "free" - : raw === CODEX_CLI_UPDATE_GATE_PARENT_WRITE ? "parent_write" - : raw >= CODEX_CLI_UPDATE_GATE_WORKER_COMMIT ? "worker_commit" - : "unrecognized"; return { - gate, + gate: codexCliSelectionGateState(), liveJobs: [...liveJobs.entries()].map(([jobId, record]) => ({ jobId, settled: record.settled(), workerClosed: record.workerClosed(), })), @@ -188,8 +181,7 @@ function trackUnjoined(worker: Worker, alreadyClosed: boolean): void { * recovery releases admission only after matching terminal proof. */ function warnIfCommitGateHeld(): void { - if (Atomics.load(new Int32Array(codexCliUpdateSelectionGate()), 0) - < CODEX_CLI_UPDATE_GATE_WORKER_COMMIT) return; + if (codexCliSelectionGateState() !== "worker_commit") return; console.warn( "[opencodex] a Codex CLI update worker closed while the shared selection gate still reads" + " worker_commit; admission awaits owner-scoped terminal evidence — inspect" diff --git a/src/codex/cli-update-recovery.ts b/src/codex/cli-update-recovery.ts index c605a31cb66..5f240a90682 100644 --- a/src/codex/cli-update-recovery.ts +++ b/src/codex/cli-update-recovery.ts @@ -3,7 +3,7 @@ import { closeSync, constants, fstatSync, fsyncSync, ftruncateSync, lstatSync, m import { dirname, join } from "node:path"; import { getConfigDir } from "../config/paths"; import { assertTrustedCodexCliAncestry } from "./cli-update-paths"; -import { withWindowsCliRecoveryStore } from "./cli-update-job-store-windows"; +import { withWindowsCliRecoveryStore, withWindowsCliRecoveryStoreAsync } from "./cli-update-job-store-windows"; import { windowsCodexCliChildGone } from "./windows-cli-recovery"; export interface CodexCliUpdateNativeChildIdentity { jobName: string; creationTime: string } @@ -169,19 +169,64 @@ export function codexCliUpdateRecoveryBlocksAdmission(configDir = getConfigDir() } catch { return true; } // Existing evidence that disappears/is unreadable stays fenced. } +/** Every attempt observes fresh records; this never caches an ACL/admission verdict. */ +export async function codexCliUpdateRecoveryBlocksAdmissionAsync(configDir = getConfigDir()): Promise { + if (process.platform !== "win32") return codexCliUpdateRecoveryBlocksAdmission(configDir); + try { lstatSync(join(configDir, "codex-cli-update-recovery")); } + catch (error) { return (error as NodeJS.ErrnoException).code !== "ENOENT"; } + try { + const canonical = realpathSync.native(configDir); + return await withWindowsCliRecoveryStoreAsync(canonical, false, async io => { + const steps = inspectRecoveryFilesSteps(io.names(), true); + let step = steps.next(); + while (!step.done) { + const effect = step.value; + if (effect.kind === "publish") { + await io.write(effect.record.token + ".json", effect.record.token + ".pending", JSON.stringify(effect.record), true); + step = steps.next(undefined); + } else { + const record = JSON.parse(await io.read(effect.token + (effect.suffix ?? ".json"))) as RecoveryRecord; + if (!validRecord(record, effect.token)) throw new Error("invalid_recovery_owner"); + step = steps.next(record); + } + } + return step.value; + }, true); + } catch { return true; } +} + +type RecoveryInspectionStep = { kind: "read"; token: string; suffix?: string } + | { kind: "publish"; record: RecoveryRecord }; +type RecoveryInspectionSteps = Generator; +function* childGoneSteps(record: RecoveryRecord): RecoveryInspectionSteps { + if (process.platform !== "win32") return childGone(record); + if (record.pgid === null || !record.windows?.jobName || !record.windows.creationTime) return false; + const proof = (yield { kind: "read", token: record.token, suffix: ".proof" })!; + return childGone(record, () => proof); +} function inspectRecoveryFiles(files: string[], readRecord: (token: string, suffix?: string) => RecoveryRecord, publishChild?: (record: RecoveryRecord) => void): boolean { + const steps = inspectRecoveryFilesSteps(files, !!publishChild); + let step = steps.next(); + while (!step.done) { + const effect = step.value; + if (effect.kind === "publish") { publishChild!(effect.record); step = steps.next(undefined); } + else step = steps.next(readRecord(effect.token, effect.suffix)); + } + return step.value; +} +function* inspectRecoveryFilesSteps(files: string[], publishChild: boolean): RecoveryInspectionSteps { if (new Set(files.map(file => file.slice(0, 36))).size > 256) return true; const terminal = new Set(); const active = new Map(); for (const file of files.filter(file => file.endsWith(".terminal"))) { - const record = readRecord(file.slice(0, -9), ".terminal"); + const record = (yield { kind: "read", token: file.slice(0, -9), suffix: ".terminal" })!; if (record.phase !== "terminal") return true; terminal.add(record.token); } for (const file of files.filter(file => file.endsWith(".json"))) { if (terminal.has(file.slice(0, -5))) continue; - const record = readRecord(file.slice(0, -5)); + const record = (yield { kind: "read", token: file.slice(0, -5) })!; if (record.phase === "terminal") { terminal.add(record.token); continue; } active.set(record.token, record); } @@ -202,7 +247,7 @@ function inspectRecoveryFiles(files: string[], readRecord: (token: string, suffi if (terminal.has(token)) continue; // Published terminal authority shadows late writes. const suffix = file.slice(token.length); if (suffix !== ".pending" && suffix !== ".terminal.pending") return true; - const record = readRecord(token, suffix); + const record = (yield { kind: "read", token, suffix })!; const previous = active.get(token) ?? reconciled.get(token); if (process.platform === "win32" && suffix === ".pending" && record.phase === "child") { const armed = active.get(token); @@ -211,8 +256,8 @@ function inspectRecoveryFiles(files: string[], readRecord: (token: string, suffi || armed.windows.creationTime !== null || armed.windows.jobName !== record.windows?.jobName || record.pgid === null || record.pgid > 0xffffffff || !record.windows?.creationTime || BigInt(record.windows.creationTime) > 0xffffffffffffffffn - || !processGone(record.parentPid) || !childGone(record, token => readRecord(token, ".proof"))) return true; - publishChild(record); // Existing protected flush/ACL/rename path; any failure fences. + || !processGone(record.parentPid) || !(yield* childGoneSteps(record)) || !processGone(record.parentPid)) return true; + yield { kind: "publish", record }; // Existing protected flush/ACL/rename path; any failure fences. reconciled.set(token, record); continue; } // A complete cleanup write can survive a crash before rename. Recognize @@ -221,7 +266,8 @@ function inspectRecoveryFiles(files: string[], readRecord: (token: string, suffi if (record.phase !== "terminal" || record.pgid !== null || !processGone(record.parentPid) || (previous && previous.parentPid !== record.parentPid)) return true; if (previous?.phase === "child" - && !childGone(previous, token => readRecord(token, ".proof"))) return true; + && !(yield* childGoneSteps(previous))) return true; + if (!processGone(record.parentPid)) return true; reconciled.set(token, record); } for (const record of active.values()) { @@ -229,7 +275,7 @@ function inspectRecoveryFiles(files: string[], readRecord: (token: string, suffi // Re-probe on every admission attempt, including after a restart. A live // or inaccessible group stays fenced until the whole group proves absent. if (record.phase === "child" && record.pgid !== null - && processGone(record.parentPid) && childGone(record, token => readRecord(token, ".proof"))) continue; + && processGone(record.parentPid) && (yield* childGoneSteps(record)) && processGone(record.parentPid)) continue; return true; } return false; diff --git a/src/codex/cli-update-selection-gate.ts b/src/codex/cli-update-selection-gate.ts index 63e799a4d4f..a17c933728a 100644 --- a/src/codex/cli-update-selection-gate.ts +++ b/src/codex/cli-update-selection-gate.ts @@ -30,8 +30,10 @@ * stays the documented residual it always was. */ -import { codexCliUpdateRecoveryBlocksAdmission } from "./cli-update-recovery"; +import { codexCliUpdateRecoveryBlocksAdmission, codexCliUpdateRecoveryBlocksAdmissionAsync } from "./cli-update-recovery"; import type { CodexCliUpdateNativeChildIdentity, CodexCliUpdateNativeRecoveryProof } from "./cli-update-recovery"; +import { getConfigDir } from "../config/paths"; +import { resolve } from "node:path"; export const CODEX_CLI_UPDATE_FENCE_PREPARING = 0; export const CODEX_CLI_UPDATE_FENCE_CANCELLED = 1; @@ -53,6 +55,10 @@ export class CodexCliSelectionMutationBusyError extends Error { } let selectionGate: SharedArrayBuffer | null = null; +let asyncParentOwner: { buffer: SharedArrayBuffer; owner: number } | null = null; +// This scope exists only during one synchronous commit turn. It is never kept +// across an await, so unrelated parent requests cannot borrow its admission. +let parentCommitScope: { buffer: SharedArrayBuffer; owner: number; configDir: string } | null = null; /** One process-wide holder and monotonic generation counter shared across realms. */ export function codexCliUpdateSelectionGate(): SharedArrayBuffer { @@ -60,9 +66,19 @@ export function codexCliUpdateSelectionGate(): SharedArrayBuffer { return selectionGate; } +/** Diagnostic classification does not grant or release gate ownership. */ +export function codexCliSelectionGateState(): "free" | "parent_write" | "worker_commit" | "unrecognized" { + const buffer = codexCliUpdateSelectionGate(), owner = Atomics.load(new Int32Array(buffer), 0); + if (owner === CODEX_CLI_UPDATE_GATE_FREE) return "free"; + if (owner === CODEX_CLI_UPDATE_GATE_PARENT_WRITE + || (asyncParentOwner?.buffer === buffer && asyncParentOwner.owner === owner)) return "parent_write"; + return owner >= CODEX_CLI_UPDATE_GATE_WORKER_COMMIT ? "worker_commit" : "unrecognized"; +} + /** Test seam: drop the cell so each case starts from FREE on a fresh buffer. */ export function resetCodexCliSelectionGatesForTests(): void { selectionGate = null; + asyncParentOwner = null; } /** Per-job fence: decision, current child pgid (0 = unpublished), terminal proof. */ @@ -112,8 +128,13 @@ export interface CodexCliSelectionParentWrite { * paths. entered === false means a commit window (or another writer) is in * flight and the caller must surface busy immediately, never wait. */ -export function beginCodexCliSelectionParentWrite(): CodexCliSelectionParentWrite { - if (codexCliUpdateRecoveryBlocksAdmission()) return Object.freeze({ entered: false, exit() {} }); +export function beginCodexCliSelectionParentWrite(configDir = getConfigDir()): CodexCliSelectionParentWrite { + if (parentCommitScope) { + const entered = resolve(configDir) === parentCommitScope.configDir + && Atomics.load(new Int32Array(parentCommitScope.buffer), 0) === parentCommitScope.owner; + return Object.freeze({ entered, exit() {} }); + } + if (codexCliUpdateRecoveryBlocksAdmission(configDir)) return Object.freeze({ entered: false, exit() {} }); const view = new Int32Array(codexCliUpdateSelectionGate()); if (Atomics.compareExchange(view, 0, CODEX_CLI_UPDATE_GATE_FREE, CODEX_CLI_UPDATE_GATE_PARENT_WRITE) !== CODEX_CLI_UPDATE_GATE_FREE) { @@ -125,11 +146,60 @@ export function beginCodexCliSelectionParentWrite(): CodexCliSelectionParentWrit exit() { if (!held) return; held = false; - Atomics.store(view, 0, CODEX_CLI_UPDATE_GATE_FREE); + Atomics.compareExchange(view, 0, CODEX_CLI_UPDATE_GATE_PARENT_WRITE, CODEX_CLI_UPDATE_GATE_FREE); + }, + }); +} + +type SynchronousParentCommit = () => T & (T extends PromiseLike ? never : unknown); +export interface CodexCliSelectionAsyncParentWrite extends CodexCliSelectionParentWrite { + /** Run existing synchronous writers under this exact fresh admission. Never await inside. */ + commit(run: SynchronousParentCommit): T; +} +interface ParentAdmissionOptions { + configDir?: string; + signal?: AbortSignal; + /** Test seam; production always uses the fresh async recovery protocol. */ + recoveryBlocksAdmission?: (configDir?: string) => Promise; +} +/** Reserve before inspection, keep the generation across awaits, and release only that owner. */ +export async function beginCodexCliSelectionParentWriteAsync(options: ParentAdmissionOptions = {}): Promise { + const refused = Object.freeze({ entered: false, exit() {}, commit(_run: SynchronousParentCommit): T { throw new CodexCliSelectionMutationBusyError(); } }); + if (options.signal?.aborted) return refused; + const configDir = resolve(options.configDir ?? getConfigDir()); + const buffer = codexCliUpdateSelectionGate(), view = new Int32Array(buffer); + const owner = allocateCodexCliUpdateGateOwner(buffer); + if (Atomics.compareExchange(view, 0, CODEX_CLI_UPDATE_GATE_FREE, owner) !== CODEX_CLI_UPDATE_GATE_FREE) return refused; + const parent = { buffer, owner }; + asyncParentOwner = parent; + let held = true; + const exit = () => { + if (!held) return; + held = false; + releaseCodexCliUpdateGateOwner(buffer, owner); + if (asyncParentOwner === parent) asyncParentOwner = null; + }; + try { + if (await (options.recoveryBlocksAdmission ?? codexCliUpdateRecoveryBlocksAdmissionAsync)(configDir) + || options.signal?.aborted || Atomics.load(view, 0) !== owner) { exit(); return refused; } + } catch { exit(); return refused; } + return Object.freeze({ entered: true, exit, + commit(run: SynchronousParentCommit): T { + if (!held || options.signal?.aborted || Atomics.load(view, 0) !== owner) throw new CodexCliSelectionMutationBusyError(); + const previous = parentCommitScope; + parentCommitScope = { buffer, owner, configDir }; + try { return run(); } finally { parentCommitScope = previous; } }, }); } +/** The callback is synchronous: config critical sections retain their original no-await contract. */ +export async function withCodexCliSelectionParentWriteAsync(run: SynchronousParentCommit, options: ParentAdmissionOptions = {}): Promise { + const admission = await beginCodexCliSelectionParentWriteAsync(options); + if (!admission.entered) throw new CodexCliSelectionMutationBusyError(); + try { return admission.commit(run); } finally { admission.exit(); } +} + /** * The worker's installation window. tryEnter covers the uncontended same-turn * case; waitEnter is the bounded retry for a held parent write. commit() is diff --git a/src/codex/desktop-app-restart.ts b/src/codex/desktop-app-restart.ts index 638be90b91e..e6909c7420f 100644 --- a/src/codex/desktop-app-restart.ts +++ b/src/codex/desktop-app-restart.ts @@ -41,7 +41,7 @@ import { darwinDesktopAppAdapter, darwinDefaultExec } from "./desktop-app/darwin import { linuxDesktopAppAdapter, linuxDefaultExec } from "./desktop-app/linux"; import { windowsDesktopAppAdapter, windowsDefaultExec } from "./desktop-app/windows"; import { isTestHomeGuardArmed } from "../lib/test-home-guard"; -import { beginCodexCliSelectionParentWrite } from "./cli-update-selection-gate"; +import { beginCodexCliSelectionParentWrite, withCodexCliSelectionParentWriteAsync, CodexCliSelectionMutationBusyError } from "./cli-update-selection-gate"; export type { DesktopAppExecOptions } from "./desktop-app/types"; @@ -388,3 +388,13 @@ export function restartCodexDesktopApp(io: DesktopAppRestartIo = {}): DesktopApp if (lockHeld && !handedOff) releaseDesktopRestartLock(io.lock); } } + +export async function restartCodexDesktopAppAsync(io: DesktopAppRestartIo = {}): Promise { + try { return await withCodexCliSelectionParentWriteAsync(() => restartCodexDesktopApp(io)); } + catch (error) { + if (error instanceof CodexCliSelectionMutationBusyError) return { + attempted: false, stopped: [], surviving: [], relaunch: "skipped", reason: "selection_mutation_busy", + }; + throw error; + } +} diff --git a/src/codex/desktop-app/handoff.ts b/src/codex/desktop-app/handoff.ts index 3baf6d83b43..121b220d226 100644 --- a/src/codex/desktop-app/handoff.ts +++ b/src/codex/desktop-app/handoff.ts @@ -284,7 +284,7 @@ export async function runDesktopRestartHandoff( try { const restart = io.restart ? io.restart(false) - : (await import("../desktop-app-restart")).restartCodexDesktopApp({ + : await (await import("../desktop-app-restart")).restartCodexDesktopAppAsync({ allowHandoff: false, lock: io.lock, }); diff --git a/src/codex/runtime.ts b/src/codex/runtime.ts index d0e9d1538ef..8de8bed5e17 100644 --- a/src/codex/runtime.ts +++ b/src/codex/runtime.ts @@ -13,6 +13,7 @@ import { import { macCodexAppBundleFor, macCodexAppCommands } from "./mac-app-bundle"; import { beginCodexCliSelectionParentWrite, + withCodexCliSelectionParentWriteAsync, CodexCliSelectionMutationBusyError, } from "./cli-update-selection-gate"; import { resolveCodexHomeDir } from "./home"; @@ -406,7 +407,7 @@ export function persistCodexRuntime( // window, so a held gate is an immediate busy rather than a wait. Checked // BEFORE the mkdir and the resolve-cache invalidation so a busy refusal // changes nothing at all. - const selectionWrite = beginCodexCliSelectionParentWrite(); + const selectionWrite = beginCodexCliSelectionParentWrite(deps.configDir); if (!selectionWrite.entered) throw new CodexCliSelectionMutationBusyError(); try { const configDir = deps.configDir ?? getConfigDir(); @@ -438,7 +439,7 @@ export function clearPersistedCodexRuntime(deps: ResolveCodexRuntimeDeps = {}): // Same gate as persistCodexRuntime, checked before the cache invalidation // and outside the swallowing catch: a busy refusal must stay an error, not // become a silent "already removed". - const selectionWrite = beginCodexCliSelectionParentWrite(); + const selectionWrite = beginCodexCliSelectionParentWrite(deps.configDir); if (!selectionWrite.entered) throw new CodexCliSelectionMutationBusyError(); try { const configDir = deps.configDir ?? getConfigDir(); @@ -461,6 +462,14 @@ export function clearPersistedCodexRuntime(deps: ResolveCodexRuntimeDeps = {}): } } +export async function persistCodexRuntimeAsync(runtime: ResolvedCodexRuntime, deps: ResolveCodexRuntimeDeps = {}, + origin: CodexRuntimePinOrigin = "pinned"): Promise { + await withCodexCliSelectionParentWriteAsync(() => persistCodexRuntime(runtime, deps, origin), deps); +} +export async function clearPersistedCodexRuntimeAsync(deps: ResolveCodexRuntimeDeps = {}): Promise { + await withCodexCliSelectionParentWriteAsync(() => clearPersistedCodexRuntime(deps), deps); +} + type VersionProbeResult = { ok: true; version: string | null; macAppBundle?: boolean } | { ok: false; reason: string }; /** Checks that need no exec; a non-null answer settles the candidate without running `--version`. */ @@ -1415,7 +1424,38 @@ export async function resolveAndPersistCodexRuntimeAsync( if (deps.signal?.aborted || !isCurrent() || observation.epoch === null || observation.epoch !== resolveCacheEpoch || observation.inputs !== codexRuntimeSelectionIdentity(deps)) return null; if (deps.signal?.aborted) return null; - return cloneAndDeepFreeze(persistResolvedCodexRuntime(observation.result, deps)); + if (resolvedRuntimeMutation(observation.result, deps) === null) return cloneAndDeepFreeze(observation.result); + try { + return await withCodexCliSelectionParentWriteAsync(() => { + // Admission can await Windows ACL subprocesses. Re-prove all selection + // inputs after that await, in the same turn as the persisted mutation. + if (deps.signal?.aborted || !isCurrent() || observation.epoch !== resolveCacheEpoch + || observation.inputs !== codexRuntimeSelectionIdentity(deps)) return null; + return cloneAndDeepFreeze(persistResolvedCodexRuntime(observation.result, deps)); + }, deps); + } catch (error) { + if (deps.signal?.aborted) return null; + if (error instanceof CodexCliSelectionMutationBusyError) { + // A recovery denial also followed an await. Never return the obsolete + // selection as a usable result merely because no commit callback ran. + if (!isCurrent() || observation.epoch !== resolveCacheEpoch + || observation.inputs !== codexRuntimeSelectionIdentity(deps)) return null; + if (observation.result.runtime.source !== "fallback") { + return cloneAndDeepFreeze({ ...observation.result, persistError: error.message }); + } + } + throw error; + } +} + +function resolvedRuntimeMutation(result: ResolveCodexRuntimeResult, deps: ResolveCodexRuntimeDeps): "persist" | "clear" | null { + const persisted = loadPersistedCodexRuntime(deps); + const unchanged = persisted !== null && persisted.command === result.runtime.command + && persisted.source === result.runtime.source && (persisted.selectedVersion ?? null) === (result.runtime.version ?? null); + if (result.runtime.command && result.runtime.source !== "fallback" && !unchanged) return "persist"; + if (result.runtime.source === "fallback" && persisted?.command && result.failures.some(failure => + failure.command.trim() === persisted.command.trim() && failure.reason === PATH_MISSING_REASON)) return "clear"; + return null; } function persistResolvedCodexRuntime( diff --git a/src/codex/shim.ts b/src/codex/shim.ts index 4bac2ed2cae..99b525e2beb 100644 --- a/src/codex/shim.ts +++ b/src/codex/shim.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { beginCodexCliSelectionParentWrite } from "./cli-update-selection-gate"; +import { beginCodexCliSelectionParentWrite, withCodexCliSelectionParentWriteAsync, CodexCliSelectionMutationBusyError } from "./cli-update-selection-gate"; import { existsSync, lstatSync, @@ -984,6 +984,28 @@ export function installCodexShim(deps: RuntimePreflightDeps = {}): { installed: finally { gate.exit(); } } +export async function installCodexShimAsync(deps: RuntimePreflightDeps = {}): Promise> { + try { return await withCodexCliSelectionParentWriteAsync(() => installCodexShim(deps)); } + catch (error) { + if (error instanceof CodexCliSelectionMutationBusyError) return { installed: false, refused: true, message: error.message }; + throw error; + } +} +export async function autoRestoreCodexShimAsync(options: Parameters[0]): Promise { + try { return await withCodexCliSelectionParentWriteAsync(() => autoRestoreCodexShim(options)); } + catch (error) { + if (error instanceof CodexCliSelectionMutationBusyError) return { status: "deferred", message: error.message }; + throw error; + } +} +export async function uninstallCodexShimAsync(): Promise> { + try { return await withCodexCliSelectionParentWriteAsync(uninstallCodexShim); } + catch (error) { + if (error instanceof CodexCliSelectionMutationBusyError) return { removed: false, message: error.message }; + throw error; + } +} + export function autoRestoreCodexShim(options: Parameters[0]): CodexShimAutoRestoreResult { if (process.platform !== "win32") return autoRestoreUnixOverlay(options); const gate = beginCodexCliSelectionParentWrite(); diff --git a/src/remote-control/workspace-codex-runtime.ts b/src/remote-control/workspace-codex-runtime.ts index 9962477bc93..df2bd7bb3c9 100644 --- a/src/remote-control/workspace-codex-runtime.ts +++ b/src/remote-control/workspace-codex-runtime.ts @@ -7,7 +7,7 @@ import { type CodexExecBoundaryAsyncDeps, } from "../codex/exec-boundary"; import { - beginCodexCliSelectionParentWrite, + beginCodexCliSelectionParentWriteAsync, CodexCliSelectionMutationBusyError, } from "../codex/cli-update-selection-gate"; import { remoteWorkspaceThreadStartParams } from "./workspace-coordinator"; @@ -371,7 +371,7 @@ export class CodexRemoteWorkspaceRuntimeFactory implements RemoteWorkspaceRuntim // installer termination/readback; a new session must not appear inside that // window. Busy is the same explicit pre-spawn failure path as a spawn // throw, so the bridge and isolation are reclaimed identically. - const selectionWrite = beginCodexCliSelectionParentWrite(); + const selectionWrite = await beginCodexCliSelectionParentWriteAsync(); if (!selectionWrite.entered) { // Both teardown steps are attempted before the refusal leaves: the // ordered runner keeps a rejected bridge stop from skipping isolation diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index db957a73e72..85a80da579b 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -1,4 +1,6 @@ import { prepareClaudeCodePatch } from "./claude-code-settings-patch"; +import { beginCodexCliSelectionParentWriteAsync, withCodexCliSelectionParentWriteAsync, + CodexCliSelectionMutationBusyError, type CodexCliSelectionAsyncParentWrite } from "../../codex/cli-update-selection-gate"; import { readConfigForAdmittedMutation } from "../../config/persisted-mutation"; import { mirrorDesiredEnabledOntoSnapshot, persistDesktopModeField, persistDesktopProfileField } from "./claude-desktop-config-writers"; import { getConfigDir as configRoot, withConfigDirHardeningSettled as settleConfigRoot } from "../../config/paths"; @@ -205,6 +207,7 @@ export async function injectionModelView(config: OcxConfig, routed: readonly Cat export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise { try { return await handleAgentSettingsRoutesImpl(ctx); } catch (error) { + if (error instanceof CodexCliSelectionMutationBusyError) return jsonResponse({ error: error.message, retryable: true }, 503); if (!(error instanceof WindowsSecretAclSettleTimeoutError)) throw error; return jsonResponse({ error: "Could not save settings", code: "write_failed", diagnostic: { stage: "ca", cause: "ETIMEDOUT" } }, 500); } @@ -402,10 +405,8 @@ async function handleAgentSettingsRoutesImpl(ctx: ManagementContext): Promise { + if (wantsFlag || wantsMode || wantsThreads || wantsKeepNative || (wantsModeHintText && body.multiAgentModeHintText !== null)) { + selectionWrite = await beginCodexCliSelectionParentWriteAsync({ signal: req.signal }); + if (!selectionWrite.entered) return jsonResponse({ error: "codex_cli_selection_busy", retryable: true }, 503); + } + const commit = () => { const currentPlan = readModePlan(); if (currentPlan instanceof Response) return currentPlan; const { effectiveMode } = currentPlan; @@ -497,9 +503,11 @@ async function handleAgentSettingsRoutesImpl(ctx: ManagementContext): Promise selectionWrite ? selectionWrite.commit(commit) : commit()); if (writeRefusal) return writeRefusal; } finally { + selectionWrite?.exit(); if (heldWriteLock) releaseConfigWriteLock(heldWriteLock); } // Derived from fresh post-write readers (readConfigText is uncached): upstream @@ -558,9 +566,9 @@ async function handleAgentSettingsRoutesImpl(ctx: ManagementContext): Promise runCodexFeaturesCommand(action, DEFAULT_MODE_REQUEST_USER_INPUT_FEATURE_KEY, env, validate, invocation); } catch (error) { return jsonResponse({ error: `default_mode_request_user_input toggle failed: ${error instanceof Error ? error.message : String(error)}` }, 502); diff --git a/structure/cli-management.md b/structure/cli-management.md index da5ae2d54f9..cd1294ef63c 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -165,7 +165,7 @@ Remote Link enrollment is shared with the dashboard through `src/server/manageme An explicit `sync --restart-codex` (or app-server-only scope) also reloads after a committed configuration-byte change, even when no catalog/cache was written. Without an explicit restart scope, configuration-only changes do not restart clients. Existing catalog/cache-write restart behavior remains unchanged; success alone is not proof of changed configuration. -`ocx sync --expect-link ` and `--expect-hub-key ` (#7694) pin the sync to one connection. `src/cli/sync-expectation.ts` parses them, first in `src/cli/root.ts` before any preflight (malformed, repeated or doubled flags exit 2), and renders a refusal as exit 5 with a final stderr JSON line `{phase, reason}`. `skipsCodexShimAutoRestore` exempts a pinned sync from the root shim repair, which the runner performs (`runCodexShimAutoRestore`) only after the identity check passed. With an expectation, the `sync` runner in `src/cli/dispatch.ts` ignores its own unlocked state read and always enters `syncConnectedClient`, which checks the expectation (`src/client/sync-expectation.ts`) inside its first lifecycle/config-locked block, under the apply lock, before the token check, any request or any write. A refusal never reaches `syncModelsToCodex`, local proxy discovery or integration refreshes. The runner takes link-vs-hub wording and restart ownership from `result.connection`, the connection the sync ran under; `/api/machine/sync` strips that field from its response. If a lock the check needs is held before the check ran (`link_apply_busy` from `withLinkApply`, because a link connect keeps the apply lock across its network reads, `client_lifecycle_busy`, or `CONFIG_MUTATION_LOCK_UNAVAILABLE` caused by SQLite busy/locked), the pinned sync is refused as `pending_operation`; the same contention after the check, or without an expectation, stays an ordinary failure. A connection that changes after the check fails at the existing compare-and-swap (`client_connection_changed`, exit 1). Coverage: `tests/cli/cli-sync-expect-link.test.ts`. +`ocx sync --expect-link ` and `--expect-hub-key ` (#7694) pin the sync to one connection. `src/cli/sync-expectation.ts` parses them, first in `src/cli/root.ts` before any preflight (malformed, repeated or doubled flags exit 2), and renders a refusal as exit 5 with a final stderr JSON line `{phase, reason}`. `skipsCodexShimAutoRestore` exempts a pinned sync from the root shim repair, which the runner awaits (`runCodexShimAutoRestoreAsync`) only after the identity check passed. With an expectation, the `sync` runner in `src/cli/dispatch.ts` ignores its own unlocked state read and always enters `syncConnectedClient`, which checks the expectation (`src/client/sync-expectation.ts`) inside its first lifecycle/config-locked block, under the apply lock, before the token check, any request or any write. A refusal never reaches `syncModelsToCodex`, local proxy discovery or integration refreshes. The runner takes link-vs-hub wording and restart ownership from `result.connection`, the connection the sync ran under; `/api/machine/sync` strips that field from its response. If a lock the check needs is held before the check ran (`link_apply_busy` from `withLinkApply`, because a link connect keeps the apply lock across its network reads, `client_lifecycle_busy`, or `CONFIG_MUTATION_LOCK_UNAVAILABLE` caused by SQLite busy/locked), the pinned sync is refused as `pending_operation`; the same contention after the check, or without an expectation, stays an ordinary failure. A connection that changes after the check fails at the existing compare-and-swap (`client_connection_changed`, exit 1). Coverage: `tests/cli/cli-sync-expect-link.test.ts`. ## Custom Codex provider approval diff --git a/structure/codex-cli-update.md b/structure/codex-cli-update.md index f84c105d140..310df791db4 100644 --- a/structure/codex-cli-update.md +++ b/structure/codex-cli-update.md @@ -368,7 +368,7 @@ synchronous worker section, signal-based cancellation alone is not equivalent an worker path's only channel. The worker's final window is also serialized against the parent's own selection mutations through one -process-wide gate cell. The gate covers exactly seven parent operations: `persistCodexRuntime` and +process-wide gate cell. The gate covers seven parent mutation boundaries: `persistCodexRuntime` and `clearPersistedCodexRuntime` in `src/codex/runtime.ts` (busy refusal — the `codex_cli_selection_busy` error — before mkdir, cache invalidation or the swallowing unlink catch), the three public shim boundaries (`src/codex/shim-overlay.ts` on POSIX, `src/codex/shim.ts` on Windows; install, auto-restore, uninstall — @@ -378,7 +378,7 @@ lock and journal mutation), the remote-workspace app-server `Bun.spawn` in cleanup a spawn throw runs, so a rejected bridge stop cannot skip isolation removal), and `src/codex/desktop-app-restart.ts` (busy returns the new `selection_mutation_busy` skipped reason before the restart lock, package discovery, any app quit and the handoff, so a queued relaunch — and -its own lock-acquisition failure — cannot slip inside the window). Writers never wait: the commit +its own lock-acquisition failure — cannot slip inside the window). Gate contention refuses immediately: the commit window spans the final process scan through terminal installer evidence and readback. Windows directory observations retain bigint device/inode identities, including the second namespace check after asynchronous ACL/file observations. Windows also @@ -400,6 +400,26 @@ not bound time held. A synchronous parent writer must not park the event loop behind it, so a held gate is an immediate busy. The gate is one cell for the whole process rather than per-configDir because alias, junction and case spellings of the same target must not split the exclusion; a writer on a different configDir can also see busy. +Async parent callers reserve a unique gate owner before checking recovery and await Windows ACL +reads and required recovery publication without blocking the parent on PowerShell. The native sync +and async drivers share the same pinned ancestry, private-DACL, content and publication protocol; +the async driver rechecks held identities after each ACL/hardening await. Recovery verdicts are never +cached. A denied, failed or cancelled check releases only its own generation after that check settles. +The lease's synchronous commit scope admits nested compatibility writers only for the inspected +resolved config root and the still-held owner. It ends before queued work can run. Existing sync +exports and Worker arming retain synchronous checks; a direct sync call outside that scope does not +inherit an earlier async verdict. Ordinary atomic secret-file publication and native feature commands +retain their synchronous leaf contracts; the nonblocking guarantee covers recovery admission. CLI, +machine API, restart and Remote Workspace callers use the async +entry points. Runtime publication rechecks selection inputs, cache epoch, cancellation and caller +currentness after recovery. Codex feature-setting callers acquire admission before their synchronous +transition; management keeps the lease across config-root hardening, then rechecks its mode plan and +commits in the existing no-await callback. Refusal precedes combined setting writes and returns a +retryable `codex_cli_selection_busy` response. Diagnostics classify a held async parent by its exact +buffer and owner, so it cannot appear as a retained Worker. Portable gate/caller regressions and +Windows-native async ACL regressions live in `tests/codex-integration/codex-cli-update-parent-async.test.ts`; +`tests/codex-integration/codex-cli-update-windows-pending.test.ts` covers async pending-child publication +and flush, hardening, rename and readback refusal under the same protocol. The gate's holder word carries a unique generation; release compares that exact owner, so an obsolete observer cannot clear a later window. An installer/verifier with unconfirmed terminal state retains the gate as well as lease/staging. `src/codex/cli-update-worker-recovery.ts` observes worker close: a fence @@ -462,7 +482,7 @@ operator inspection. PID reuse can delay recovery but cannot prove absence. The ledger keeps its existing ambiguous result; fence recovery neither retries installation nor converts it to success. Abandoned installation-prefix leases and staging after process death are not reclaimed by this admission recovery. Old jobs lacking recovery records retain their existing unknown-state projection. -Ordinary config writes, account operations, model requests, existing session interrupt/close and +Unrelated config writes, account operations, model requests, existing session interrupt/close and arbitrary subprocesses are not gated. The gate claims nothing about processes the parent does not own: an external installer, a handoff helper's delayed descendants, or an OS-level snapshot race stays the documented residual it always was. diff --git a/structure/config.md b/structure/config.md index 01d9447e520..d5203ed40cb 100644 --- a/structure/config.md +++ b/structure/config.md @@ -62,7 +62,7 @@ test seam and the mutation types remain re-exported through `src/config.ts`. Suc `src/config/paths.ts` owns `OPENCODEX_HOME` expansion and resolution, the config directory and `config.json` path. A relative home is resolved once per distinct raw environment value, so a later working-directory change cannot silently move the active installation. Config reads in this leaf and `src/config/load-degrade.ts` use NTFS ACLs on Windows and private POSIX modes elsewhere. Optional directory hardening follows directory symlinks/junctions but skips non-directory or unstatable targets before starting chmod or ACL work. Windows reads omit `chmod` before ACL hardening so repeated loads preserve successful object/freshness observations; external metadata changes still invalidate them. Directory flights keep their existing flush/reap lifecycle, and file/required-write failure policy is unchanged. `tests/windows/windows-config-read-acl-memo.test.ts` covers repeated loads, non-directory and directory-link behavior, external invalidation and POSIX permissions. -`withConfigDirHardeningSettled` in `src/config/paths.ts` admits synchronous mutation after the last unrelated await. Its registry includes the optional config flight, ancestors and retained timeout children; one existing ACL deadline bounds admission, including newly overlapping work. Expiry throws sanitized `WindowsSecretAclSettleTimeoutError` (`ETIMEDOUT`) without running the writer or dropping child cleanup ownership. The final empty-registry check and callback share one turn, with no await in the callback. Intercept/picker CA, token, cleanup, profile and snapshot phases re-enter after trust/discovery awaits. Config saves in `src/server/management/agent-settings-routes.ts` and `src/server/management/config-routes.ts`, Desktop intent/mode commits in `src/server/management/native-integration-routes.ts`, and gateway publication/cleanup in those routes and `src/cli/ensure-desired-integrations.ts` use the same admission. `src/cli/claude-desktop.ts` admits local profile/mode commits and the complete remote-store transaction again after hub download. Lifecycle/config locks are acquired inside admission; refresh re-read, mode/freshness checks, profile and marker publication share one callback. Live setting edits and their save/publication share the admitted synchronous callback; a refused admission leaves both live and durable state untouched. Ordered post-commit phases preserve applied-state warnings or settled compensation, including mode/fingerprint marker refusal and retained CLI intent after incomplete rollback. Synchronous leaf APIs retain their contracts. `tests/claude-integration/claude-config-root-writer-barrier.test.ts` holds parent flights and drives refusal deadlines without sleeps. Lexical/canonical aliases and uncertain identities drain conservatively; removal keeps its separate at-or-below scope and actual-exit requirement. Async local config commands (`src/cli/config-command.ts`, `src/cli/effort.ts`, `src/cli/models.ts`, `src/cli/provider.ts`) admit complete synchronous read/validate/save phases through `withConfigDirHardeningSettled`. Preliminary model selection and client-connection diagnostics use passive observations that cannot publish recovery copies. Final authority and recovery publication are re-read inside admission without starting optional hardening. `src/cli/config-mutation.ts` refuses invalid or unreadable authority, including array/non-object roots rejected before defaults repair, with `CliUsageError`; full-config commands retain fresh-install initialization when the file is absent, while set/unset retain the persisted mutation owner's missing-file refusal. Custom-model removal revalidates the confirmed row by identity and contents while retaining current siblings. Import reads its input and checks its target inside admission. Provider-add credential cleanup shares the synchronous post-save phase and retains its existing post-commit warning. Timeout leaves the mutation database and config untouched and follows existing CLI failure handling. `tests/windows/windows-config-root-async-writers.test.ts` covers held parent flights, deadline refusal, recovery copies, invalid authority and concurrent edits. +`withConfigDirHardeningSettled` in `src/config/paths.ts` admits synchronous mutation after the last unrelated await. Its registry includes the optional config flight, ancestors and retained timeout children; one existing ACL deadline bounds admission, including newly overlapping work. Expiry throws sanitized `WindowsSecretAclSettleTimeoutError` (`ETIMEDOUT`) without running the writer or dropping child cleanup ownership. The final empty-registry check and callback share one turn, with no await in the callback. Intercept/picker CA, token, cleanup, profile and snapshot phases re-enter after trust/discovery awaits. Config saves in `src/server/management/agent-settings-routes.ts` and `src/server/management/config-routes.ts`, Desktop intent/mode commits in `src/server/management/native-integration-routes.ts`, and gateway publication/cleanup in those routes and `src/cli/ensure-desired-integrations.ts` use the same admission. `src/cli/claude-desktop.ts` admits local profile/mode commits and the complete remote-store transaction again after hub download. Lifecycle/config locks are acquired inside admission; refresh re-read, mode/freshness checks, profile and marker publication share one callback. Live setting edits and their save/publication share the admitted synchronous callback; a refused admission leaves both live and durable state untouched. Ordered post-commit phases preserve applied-state warnings or settled compensation, including mode/fingerprint marker refusal and retained CLI intent after incomplete rollback. Synchronous leaf APIs retain their contracts. `tests/claude-integration/claude-config-root-writer-barrier.test.ts` holds parent flights and drives refusal deadlines without sleeps. Lexical/canonical aliases and uncertain identities drain conservatively; removal keeps its separate at-or-below scope and actual-exit requirement. Async local config commands (`src/cli/config-command.ts`, `src/cli/effort.ts`, `src/cli/models.ts`, `src/cli/provider.ts`) admit complete synchronous read/validate/save phases through `withConfigDirHardeningSettled`. Preliminary model selection and client-connection diagnostics use passive observations that cannot publish recovery copies. Final authority and recovery publication are re-read inside admission without starting optional hardening. `src/cli/config-mutation.ts` refuses invalid or unreadable authority, including array/non-object roots rejected before defaults repair, with `CliUsageError`; full-config commands retain fresh-install initialization when the file is absent, while set/unset retain the persisted mutation owner's missing-file refusal. Custom-model removal revalidates the confirmed row by identity and contents while retaining current siblings. Import reads its input and checks its target inside admission. Provider-add credential cleanup shares the synchronous post-save phase and retains its existing post-commit warning. Timeout leaves the mutation database and config untouched and follows existing CLI failure handling. `tests/windows/windows-config-root-async-writers.test.ts` covers held parent flights, deadline refusal, recovery copies, invalid authority and concurrent edits. Codex multi-agent management changes that can resolve a runtime additionally await fresh [selection admission](codex-cli-update.md#worker-realm-and-the-selection-commit-window) before entering this barrier, keep that owner across hardening, and re-evaluate the mode plan inside the synchronous callback; neither ACL wait is inserted into the callback, and refusal leaves the combined mutation untouched. `src/config/process-state.ts` derives `ocx.pid` and `runtime-port.json` from that resolved directory. It owns their byte-compatible writes, parsing, expected-PID filters, cheap liveness, full OCX command diff --git a/structure/remote-workspace.md b/structure/remote-workspace.md index 6ed532f9cb4..d4afd8400e6 100644 --- a/structure/remote-workspace.md +++ b/structure/remote-workspace.md @@ -59,7 +59,7 @@ A track that holds a nested split reserves that subtree's minimum width (`minLay side-by-side splits never scroll inside a pane column; an oversized tree scrolls the pane surface once. Hub runtime admission counts pending create/resume starts as well as live handles against global and per-device limits; every outcome releases its reservation. Stop and shutdown reclaim late resumed handles before clearing ownership. Coordinator result size limits normalize both response text and success, so bridge and MCP callers receive consistent errors. -`src/remote-control/workspace-codex-runtime.ts` takes the shared Codex-CLI-update selection gate before execution-boundary trust validation and holds it across the asynchronous check through its app-server `Bun.spawn`. macOS executes the canonical checked target; validation or spawn failure releases the gate before bridge/isolation cleanup, attempting both cleanup steps while preserving the original error. While a cli-update worker holds admission through installer termination and readback the launch refuses with `codex_cli_selection_busy` after the same cleanup. See the [update commit window](codex-cli-update.md#worker-realm-and-the-selection-commit-window). +`src/remote-control/workspace-codex-runtime.ts` reserves the shared Codex-CLI-update selection gate before awaiting fresh recovery admission and execution-boundary trust validation, and holds it through its app-server `Bun.spawn`. Windows recovery awaits its native ACL reads under that same owner generation. macOS executes the canonical checked target; validation or spawn failure releases the gate before bridge/isolation cleanup, attempting both cleanup steps while preserving the original error. While a cli-update worker holds admission through installer termination and readback the launch refuses with `codex_cli_selection_busy` after the same cleanup. See the [update commit window](codex-cli-update.md#worker-realm-and-the-selection-commit-window). Prompt HTTP admission returns 202 with the existing session/event cursor; tracked operations publish terminal status for polling and attach a rejection observer immediately. Reconnect/resume cannot publish ready while a turn is active. The dashboard prefers newer local event cursors over stale polling, favors authoritative polling on ties, retains unconfirmed draft text with an uncertainty notice, and never retries prompt POSTs automatically. diff --git a/structure/runtime.md b/structure/runtime.md index 0af2d36768d..24fd3ad5035 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -1,6 +1,6 @@ # Runtime -The minute sweep checks persisted activation deadlines locally; only missing deadlines trigger metadata discovery. See the [quota activation contract](providers/openai-tiers.md#public-provider-contract). Codex control-plane ids are defined in the dependency-free `src/codex/control-plane-models.ts`; `src/router.ts` routes these exact ids unchanged to canonical OpenAI, retaining the existing error when no OpenAI provider is enabled. They do not widen the public native roster. Catalog requests and scheduled source refreshes follow the [nonblocking catalog observation contract](catalog.md#shared-catalog). Async runtime persistence rejects observed selection/epoch changes and returns detached frozen results; signal cancellation makes the persistence wrapper return null without writing, while raw async resolver rejections and exceptions without signal cancellation retain their rejection behavior; bounded exec rejects promptly and releases output readers on deadline or abort, independently awaits actual child exit, and escalates SIGTERM to SIGKILL after the shared 2-second grace (Windows termination is forceful). Known Codex.app and ChatGPT.app commands at any location or alias follow the [execution trust contract](clients/chatgpt-desktop.md#codex-app-execution-trust); automatic discovery also requires matching bundle identity; the existing ChatGPT bundle trust policy (safe ownership, permissions, non-symlink paths, and OpenAI signatures for both the bundle and the exact executable) is enforced at every execution boundary in src/codex/exec-boundary.ts before the resolved canonical target is spawned, not once at selection. Version probes retain that execution's bundle classification for selection, so a bare PATH alias of an app cannot supersede a discovered persisted runtime; selection does not resolve PATH again after the probe. Stored and displayed command spelling remains unchanged, and this observation grants no trust to a later execution. Claude overflow normalization examines at most 64 Ki characters, extracts stated context/request counts in separate forward scans, retains the bounded rewritten prefix, and leaves other error classifications intact; the managed native Messages SSE route follows the same contract (`tests/claude-integration/claude-prompt-too-long.test.ts`). Optional native desktop observation follows the [Codex Desktop startup gate and awaited teardown](clients/codex-desktop.md#proxy-startup-preference). Claude Code tier defaults are projected by `src/claude/model-default-settings.ts` into user settings only after explicit preview/apply, independently of launcher environment injection; see the [management contract](gui-and-management-api.md). The explicit projection pairs selected aliases with readable labels from `src/claude/context-windows.ts`, preserving custom labels and clearing only a label matching its current alias when an explicitly selected tier becomes a non-alias; unset tiers retain both settings model IDs and labels. No startup or periodic sync writes these defaults. +The minute sweep checks persisted activation deadlines locally; only missing deadlines trigger metadata discovery. See the [quota activation contract](providers/openai-tiers.md#public-provider-contract). Codex control-plane ids are defined in the dependency-free `src/codex/control-plane-models.ts`; `src/router.ts` routes these exact ids unchanged to canonical OpenAI, retaining the existing error when no OpenAI provider is enabled. They do not widen the public native roster. Catalog requests and scheduled source refreshes follow the [nonblocking catalog observation contract](catalog.md#shared-catalog). Async runtime persistence awaits the fresh [selection admission](codex-cli-update.md#worker-realm-and-the-selection-commit-window), rechecks observed selection/epoch and caller-currentness changes after that await, and returns detached frozen results; signal cancellation makes the persistence wrapper return null without writing, while raw async resolver rejections and exceptions without signal cancellation retain their rejection behavior; bounded exec rejects promptly and releases output readers on deadline or abort, independently awaits actual child exit, and escalates SIGTERM to SIGKILL after the shared 2-second grace (Windows termination is forceful). Known Codex.app and ChatGPT.app commands at any location or alias follow the [execution trust contract](clients/chatgpt-desktop.md#codex-app-execution-trust); automatic discovery also requires matching bundle identity; the existing ChatGPT bundle trust policy (safe ownership, permissions, non-symlink paths, and OpenAI signatures for both the bundle and the exact executable) is enforced at every execution boundary in src/codex/exec-boundary.ts before the resolved canonical target is spawned, not once at selection. Version probes retain that execution's bundle classification for selection, so a bare PATH alias of an app cannot supersede a discovered persisted runtime; selection does not resolve PATH again after the probe. Stored and displayed command spelling remains unchanged, and this observation grants no trust to a later execution. Claude overflow normalization examines at most 64 Ki characters, extracts stated context/request counts in separate forward scans, retains the bounded rewritten prefix, and leaves other error classifications intact; the managed native Messages SSE route follows the same contract (`tests/claude-integration/claude-prompt-too-long.test.ts`). Optional native desktop observation follows the [Codex Desktop startup gate and awaited teardown](clients/codex-desktop.md#proxy-startup-preference). Claude Code tier defaults are projected by `src/claude/model-default-settings.ts` into user settings only after explicit preview/apply, independently of launcher environment injection; see the [management contract](gui-and-management-api.md). The explicit projection pairs selected aliases with readable labels from `src/claude/context-windows.ts`, preserving custom labels and clearing only a label matching its current alias when an explicitly selected tier becomes a non-alias; unset tiers retain both settings model IDs and labels. No startup or periodic sync writes these defaults. ## Bun runtime and test runner diff --git a/tests/cli/cli-capability-operation-workflows.test.ts b/tests/cli/cli-capability-operation-workflows.test.ts index f877bdfcdd1..4e05a037e56 100644 --- a/tests/cli/cli-capability-operation-workflows.test.ts +++ b/tests/cli/cli-capability-operation-workflows.test.ts @@ -248,7 +248,7 @@ describe("operation capability workflows use existing handlers", () => { expect(tray).toContain('args.includes("--no-start") && sub !== "install"'); expect(tray).toContain('process.platform !== "win32"'); const dispatch = readFileSync(repoPath("src/cli/dispatch.ts"), "utf8"); - expect(dispatch).toContain('case "install": {\n const r = installCodexShim();'); + expect(dispatch).toContain('case "install": {\n const r = await installCodexShimAsync();'); }); }); diff --git a/tests/cli/cli-head.test.ts b/tests/cli/cli-head.test.ts index 52875ffedd6..feb1b1d1c91 100644 --- a/tests/cli/cli-head.test.ts +++ b/tests/cli/cli-head.test.ts @@ -184,7 +184,7 @@ describe("parseCliHead (pure CLI head, Phase 1)", () => { describe("uninstall argument validation", () => { test("uninstall and remove reject trailing arguments before shim preflight", async () => { - const preflight = spyOn(autorestore, "maybeAutoRestoreCodexShim").mockImplementation(() => {}); + const preflight = spyOn(autorestore, "maybeAutoRestoreCodexShimAsync").mockResolvedValue(undefined); const error = spyOn(console, "error").mockImplementation(() => {}); const exit = spyOn(process, "exit").mockImplementation((() => { throw new Error("usage-exit"); }) as never); try { @@ -201,7 +201,7 @@ describe("uninstall argument validation", () => { }); test("uninstall help bypasses preflight and bare commands retain it", async () => { - const preflight = spyOn(autorestore, "maybeAutoRestoreCodexShim").mockImplementation(() => {}); + const preflight = spyOn(autorestore, "maybeAutoRestoreCodexShimAsync").mockResolvedValue(undefined); const output = spyOn(console, "log").mockImplementation(() => {}); const exit = spyOn(process, "exit").mockImplementation((() => { throw new Error("help-exit"); }) as never); try { @@ -219,3 +219,31 @@ describe("uninstall argument validation", () => { } finally { preflight.mockRestore(); output.mockRestore(); exit.mockRestore(); } }); }); + +test.each([ + ["ready", "ready"], ["resolve", "resolve"], ["uninstall", "command"], +] as const)("the %s head awaits async shim preflight before becoming dispatchable", async (command, kind) => { + let entered!: () => void, release!: () => void; + const started = new Promise(resolve => { entered = resolve; }); + const held = new Promise(resolve => { release = resolve; }); + const preflight = spyOn(autorestore, "maybeAutoRestoreCodexShimAsync").mockImplementation(async () => { + entered(); + await held; + }); + let dispatchable = false; + const pending = runCli([command]).then(head => { dispatchable = true; return head; }); + try { + await Promise.race([started, pending.then(() => { throw new Error("head returned without entering preflight"); })]); + // Reach another event-loop turn while the preflight promise is unresolved. + await new Promise(resolve => setTimeout(resolve, 0)); + expect(preflight).toHaveBeenCalledTimes(1); + expect(preflight).toHaveBeenCalledWith(command, [command]); + expect(dispatchable).toBe(false); + release(); + expect(await pending).toMatchObject({ kind, command, args: [command] }); + expect(dispatchable).toBe(true); + } finally { + release(); + try { await pending; } finally { preflight.mockRestore(); } + } +}); diff --git a/tests/cli/cli-help-recovery.test.ts b/tests/cli/cli-help-recovery.test.ts index 63d0f9d758a..80bec0d4ca1 100644 --- a/tests/cli/cli-help-recovery.test.ts +++ b/tests/cli/cli-help-recovery.test.ts @@ -191,7 +191,7 @@ describe("CLI help recovery", () => { const calls = []; let reportCalls = false; mock.module(${JSON.stringify(repoPath("src", "cli", "codex-shim-autorestore.ts"))}, () => ({ - maybeAutoRestoreCodexShim: command => { calls.push(command); if (reportCalls) console.log("preflight called"); }, + maybeAutoRestoreCodexShimAsync: async command => { calls.push(command); if (reportCalls) console.log("preflight called"); }, })); // The lifecycle skew notice probes the configured (here: default) port. A developer machine // with a live proxy there would answer, so keep this admission test independent of the host. diff --git a/tests/cli/cli-ready.test.ts b/tests/cli/cli-ready.test.ts index c2d6ab69421..0c739b47020 100644 --- a/tests/cli/cli-ready.test.ts +++ b/tests/cli/cli-ready.test.ts @@ -687,31 +687,32 @@ describe("handleStart readinessGate wiring (source-level)", () => { }); }); -// ── P1: ready pre-parse before maybeAutoRestoreCodexShim (source-level) ──────── +// ── P1: ready pre-parse before maybeAutoRestoreCodexShimAsync (source-level) ──────── // `ocx ready` must reject invalid arguments with exit 64 BEFORE the global -// maybeAutoRestoreCodexShim preflight (or any discovery/probe/filesystem-capable +// maybeAutoRestoreCodexShimAsync preflight (or any discovery/probe/filesystem-capable // step) runs. These source-level guards pin that ordering and the single-parse // contract so a future edit cannot silently move parsing back into handleReady // or after auto-restore. The head block lives in src/cli/root.ts (Phase 1 of the // CLI deepening); the dispatch switch stays in src/cli/index.ts. No // subprocess/network/HOME is used. -describe("ready pre-parse before maybeAutoRestoreCodexShim (source-level, P1)", () => { +describe("ready pre-parse before maybeAutoRestoreCodexShimAsync (source-level, P1)", () => { const rootSource = readFileSync(repoPath("src/cli/root.ts"), "utf8"); const cliSource = readFileSync(repoPath("src/cli/index.ts"), "utf8"); - test("ready pre-parse call runs BEFORE maybeAutoRestoreCodexShim", () => { + test("ready pre-parse call runs BEFORE maybeAutoRestoreCodexShimAsync", () => { const preparseIdx = rootSource.indexOf("parseReadyArgs(args.slice(1))"); expect(preparseIdx, "pre-parse must call parseReadyArgs(args.slice(1))").toBeGreaterThanOrEqual(0); - const autoIdx = rootSource.indexOf("maybeAutoRestoreCodexShim(head.command, head.args)"); - expect(autoIdx, "maybeAutoRestoreCodexShim must be called in runCli").toBeGreaterThanOrEqual(0); - expect(preparseIdx, "ready pre-parse must precede maybeAutoRestoreCodexShim").toBeLessThan(autoIdx); + const autoIdx = rootSource.indexOf("await maybeAutoRestoreCodexShimAsync(head.command, head.args)"); + expect(autoIdx, "maybeAutoRestoreCodexShimAsync must be called in runCli").toBeGreaterThanOrEqual(0); + expect(preparseIdx, "ready pre-parse must precede maybeAutoRestoreCodexShimAsync").toBeLessThan(autoIdx); }); test("invalid ready exits 64 inside the pre-parse block, before auto-restore", () => { // parseCliHead (pure) returns readyArgs: undefined for invalid args; the // fail-closed runCli guard then exits 64 before any shim/discovery side // effect can run. - const autoIdx = rootSource.indexOf("maybeAutoRestoreCodexShim(head.command, head.args)"); + const autoIdx = rootSource.indexOf("await maybeAutoRestoreCodexShimAsync(head.command, head.args)"); + expect(autoIdx, "the awaited async preflight must exist before slicing its preceding guards").toBeGreaterThanOrEqual(0); const beforeAuto = rootSource.slice(0, autoIdx); expect(beforeAuto).toContain('command === "ready"'); expect(beforeAuto).toContain("parseReadyArgs(args.slice(1))"); @@ -742,7 +743,7 @@ describe("ready pre-parse before maybeAutoRestoreCodexShim (source-level, P1)", expect(body).not.toContain("process.exit"); }); - test("valid ready dispatch reaches handleReady AFTER maybeAutoRestoreCodexShim, with fail-closed guard", () => { + test("valid ready dispatch reaches handleReady AFTER maybeAutoRestoreCodexShimAsync, with fail-closed guard", () => { // Ordering: index.ts awaits runCli (which runs parseCliHead and the shim // preflight inside root.ts) BEFORE dispatch.ts runs the ready runner. const runCliIdx = cliSource.indexOf("await runCli(process.argv.slice(2))"); @@ -750,7 +751,7 @@ describe("ready pre-parse before maybeAutoRestoreCodexShim (source-level, P1)", const dispatchIdx = cliSource.indexOf("process.exit(await dispatchCommand(head"); expect(dispatchIdx, "index.ts must exit via dispatchCommand").toBeGreaterThanOrEqual(0); expect(runCliIdx).toBeLessThan(dispatchIdx); - expect(rootSource).toContain("maybeAutoRestoreCodexShim(head.command, head.args)"); + expect(rootSource).toContain("await maybeAutoRestoreCodexShimAsync(head.command, head.args)"); // The ready runner lives in dispatch.ts (keyed "ready:"); slice its body // up to the next runner key, not a fixed width. const dispatchSource = readFileSync(repoPath("src/cli/dispatch.ts"), "utf8"); diff --git a/tests/cli/cli-sync-expect-link.test.ts b/tests/cli/cli-sync-expect-link.test.ts index e6c1fc4b502..0cf79030200 100644 --- a/tests/cli/cli-sync-expect-link.test.ts +++ b/tests/cli/cli-sync-expect-link.test.ts @@ -202,7 +202,7 @@ describe("ocx sync --expect-link / --expect-hub-key", () => { // Never reach a real restart: on an unfixed tree a stray restart flag would end the Codex // app-servers and desktop app of whoever runs this file. const restarts = spyOn(restartScope, "handleRestartScopeAfterWrite").mockImplementation(async () => ({})); - const shimRepairs = spyOn(shimAutoRestore, "runCodexShimAutoRestore").mockImplementation(() => {}); + const shimRepairs = spyOn(shimAutoRestore, "runCodexShimAutoRestoreAsync").mockResolvedValue(undefined); const error = spyOn(console, "error").mockImplementation((...parts: unknown[]) => { errors.push(parts.map(String).join(" ")); }); const log = spyOn(console, "log").mockImplementation(() => {}); try { @@ -432,7 +432,7 @@ describe("the CLI root leaves a pinned sync to its own check", () => { throw new Error("process.exit"); }) as typeof process.exit); const error = spyOn(console, "error").mockImplementation(() => {}); - const preflight = spyOn(shimAutoRestore, "maybeAutoRestoreCodexShim"); + const preflight = spyOn(shimAutoRestore, "maybeAutoRestoreCodexShimAsync"); try { await expect(runCli(["sync", "--expect-link", "not-a-link-id"])).rejects.toThrow("process.exit"); expect(exits).toEqual([2]); diff --git a/tests/cli/cli-version-skew.test.ts b/tests/cli/cli-version-skew.test.ts index a0e2e7a0032..74a40c6a390 100644 --- a/tests/cli/cli-version-skew.test.ts +++ b/tests/cli/cli-version-skew.test.ts @@ -199,7 +199,7 @@ function freshNotice(scenario: NoticeScenario = {}) { maybeNoticeVersionSkew: (cmd,argv) => { events.push("hook"); return actual(cmd,argv,io); }, })); mock.module(${JSON.stringify(repoPath("src", "cli", "codex-shim-autorestore.ts"))}, () => ({ - maybeAutoRestoreCodexShim: () => events.push("shim"), + maybeAutoRestoreCodexShimAsync: async () => { events.push("shim"); }, })); const { runCli } = await import(${JSON.stringify(repoPath("src", "cli", "root.ts"))}); await runCli(args); events.push("dispatch"); diff --git a/tests/codex-integration/codex-cli-update-parent-async.test.ts b/tests/codex-integration/codex-cli-update-parent-async.test.ts new file mode 100644 index 00000000000..ed0f23a69b0 --- /dev/null +++ b/tests/codex-integration/codex-cli-update-parent-async.test.ts @@ -0,0 +1,297 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import * as gate from "../../src/codex/cli-update-selection-gate"; +import * as recovery from "../../src/codex/cli-update-recovery"; +import * as runtime from "../../src/codex/runtime"; +import { codexCliUpdateWorkerDiagnostics } from "../../src/codex/cli-update-job-worker"; +import { handleManagementAPI } from "../../src/server/management-api"; +import type { OcxConfig } from "../../src/types"; +import { hardenSecretDirAsync, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; +import { setWindowsOwnerAclAsyncRunnerForTests, setWindowsOwnerAclRunnerForTests } from "../../src/lib/windows-owner-acl"; +import { createCodexCliRecoveryTestHome } from "../helpers/codex-cli-recovery-home"; +import { trustWindowsTempFixtureAncestry } from "../helpers/windows-temp-ancestry"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { catalogConvergenceFactory } from "../helpers/catalog-convergence"; + +let home: string; +let saved: Record; +const spies: Array<{ mockRestore(): void }> = []; +beforeEach(() => { + home = createCodexCliRecoveryTestHome(); + saved = Object.fromEntries(["OPENCODEX_HOME", "CODEX_CLI_PATH", "CODEX_HOME"].map(key => [key, process.env[key]])); + process.env.OPENCODEX_HOME = home; + delete process.env.CODEX_CLI_PATH; + gate.resetCodexCliSelectionGatesForTests(); + runtime.clearCodexRuntimeResolveCache(); +}); +afterEach(() => { + for (const spy of spies.splice(0)) spy.mockRestore(); + setWindowsOwnerAclAsyncRunnerForTests(null); + setWindowsOwnerAclRunnerForTests(null); + setIcaclsRunnerForTests(null); + gate.resetCodexCliSelectionGatesForTests(); + runtime.clearCodexRuntimeResolveCache(); + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; else process.env[key] = value; + } + removeTreeWithRetry(home); +}); +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} +const free = () => Atomics.load(new Int32Array(gate.codexCliUpdateSelectionGate()), 0) === gate.CODEX_CLI_UPDATE_GATE_FREE; + +test("parent reserves before awaited recovery, yields, and excludes both another parent and a Worker", async () => { + const proof = deferred(); + let probes = 0; + const pending = gate.beginCodexCliSelectionParentWriteAsync({ recoveryBlocksAdmission: () => { probes++; return proof.promise; } }); + const worker = gate.createWorkerCommitWindow({ gate: gate.codexCliUpdateSelectionGate(), fence: gate.createCodexCliUpdateFence() }); + let timerProgress = false; + await new Promise(resolve => setTimeout(() => { timerProgress = true; resolve(); }, 0)); + expect(timerProgress).toBe(true); + expect(free()).toBe(false); + expect(codexCliUpdateWorkerDiagnostics().gate).toBe("parent_write"); + expect(worker.tryEnter()).toBe(false); + const second = await gate.beginCodexCliSelectionParentWriteAsync({ recoveryBlocksAdmission: async () => { probes++; return false; } }); + expect(second.entered).toBe(false); + expect(probes).toBe(1); + expect(gate.beginCodexCliSelectionParentWrite().entered).toBe(false); + proof.resolve(false); + const admitted = await pending; + try { + expect(admitted.entered).toBe(true); + expect(gate.beginCodexCliSelectionParentWrite().entered).toBe(false); + admitted.commit(() => { + const nested = gate.beginCodexCliSelectionParentWrite(); + expect(nested.entered).toBe(true); + nested.exit(); + expect(free()).toBe(false); + expect(gate.beginCodexCliSelectionParentWrite(join(home, "different-home")).entered).toBe(false); + }); + } finally { admitted.exit(); } + expect(worker.tryEnter()).toBe(true); + expect(codexCliUpdateWorkerDiagnostics().gate).toBe("worker_commit"); + worker.exit(); + expect(free()).toBe(true); +}); + +for (const outcome of ["denied", "thrown"] as const) test(`recovery ${outcome} leaves no mutation and allows a fresh admission`, async () => { + let writes = 0; + const pending = gate.withCodexCliSelectionParentWriteAsync(() => { writes++; }, { + recoveryBlocksAdmission: async () => { if (outcome === "thrown") throw new Error("fixture"); return true; }, + }); + await expect(pending).rejects.toThrow("codex_cli_selection_busy"); + expect(writes).toBe(0); + expect(free()).toBe(true); + await gate.withCodexCliSelectionParentWriteAsync(() => { writes++; }, { recoveryBlocksAdmission: async () => false }); + expect(writes).toBe(1); +}); + +test("aborting a pending check cannot free its gate before the check settles or admit a late allow", async () => { + const proof = deferred(), controller = new AbortController(); + const pending = gate.beginCodexCliSelectionParentWriteAsync({ signal: controller.signal, recoveryBlocksAdmission: () => proof.promise }); + controller.abort(); + expect(free()).toBe(false); + expect((await gate.beginCodexCliSelectionParentWriteAsync()).entered).toBe(false); + proof.resolve(false); + const refused = await pending; + expect(refused.entered).toBe(false); + expect(() => refused.commit(() => { throw new Error("must not run"); })).toThrow("codex_cli_selection_busy"); + expect(free()).toBe(true); +}); + +test("stale async refusal and repeated exit never release a later Worker generation", async () => { + const proof = deferred(), buffer = gate.codexCliUpdateSelectionGate(); + const pending = gate.beginCodexCliSelectionParentWriteAsync({ recoveryBlocksAdmission: () => proof.promise }); + const old = Atomics.load(new Int32Array(buffer), 0); + gate.releaseCodexCliUpdateGateOwner(buffer, old); + const worker = gate.createWorkerCommitWindow({ gate: buffer, fence: gate.createCodexCliUpdateFence() }); + expect(worker.tryEnter()).toBe(true); + const current = Atomics.load(new Int32Array(buffer), 0); + expect(current).not.toBe(old); + proof.resolve(false); + const stale = await pending; + expect(stale.entered).toBe(false); + stale.exit(); stale.exit(); + expect(Atomics.load(new Int32Array(buffer), 0)).toBe(current); + expect(codexCliUpdateWorkerDiagnostics().gate).toBe("worker_commit"); + worker.exit(); +}); + +test("commit authority does not reach queued work and its exception releases through the async wrapper", async () => { + let queued!: Promise; + const admitted = await gate.beginCodexCliSelectionParentWriteAsync({ recoveryBlocksAdmission: async () => false }); + try { + admitted.commit(() => { + queued = Promise.resolve().then(() => gate.beginCodexCliSelectionParentWrite().entered); + expect(gate.beginCodexCliSelectionParentWrite().entered).toBe(true); + }); + expect(await queued).toBe(false); + } finally { admitted.exit(); } + await expect(gate.withCodexCliSelectionParentWriteAsync(() => { throw new Error("commit failed"); }, + { recoveryBlocksAdmission: async () => false })).rejects.toThrow("commit failed"); + expect(free()).toBe(true); +}); + +for (const verdict of ["allow", "deny", "throw"] as const) +for (const change of ["input", "epoch", "cancel", "currentness"] as const) test(`runtime publication rechecks ${change} after async admission ${verdict}`, async () => { + const command = join(home, "codex.exe"); + writeFileSync(command, "fixture", { mode: 0o700 }); + process.env.CODEX_CLI_PATH = command; + const controller = new AbortController(); + const deps = { discoverAlternatives: false, signal: controller.signal }; + runtime.setCodexRuntimeResolveCacheForTests({ runtime: { command, source: "environment", version: "0.160.0" }, failures: [] }, { discoverAlternatives: false }); + const entered = deferred(), proof = deferred(); + spies.push(spyOn(recovery, "codexCliUpdateRecoveryBlocksAdmissionAsync").mockImplementation(() => { entered.resolve(); return proof.promise; })); + let current = true; + const pending = runtime.resolveAndPersistCodexRuntimeAsync(deps, () => current); + await entered.promise; + if (change === "input") process.env.CODEX_CLI_PATH = join(home, "other.exe"); + if (change === "epoch") runtime.clearCodexRuntimeResolveCache(); + if (change === "cancel") controller.abort(); + if (change === "currentness") current = false; + if (verdict === "throw") proof.reject(new Error("fixture recovery failure")); + else proof.resolve(verdict === "deny"); + expect(await pending).toBeNull(); + expect(existsSync(join(home, "codex-runtime.json"))).toBe(false); + expect(free()).toBe(true); +}); + +test("async runtime persistence owns the requested home, not an unrelated default home", async () => { + // POSIX admission legitimately delegates bounded filesystem checks to the + // sync checker. Isolate the admitted callback here; native tests cover ACL IO. + spies.push(spyOn(recovery, "codexCliUpdateRecoveryBlocksAdmissionAsync").mockResolvedValue(false)); + const reentry = spyOn(recovery, "codexCliUpdateRecoveryBlocksAdmission").mockImplementation(() => { throw new Error("sync recovery re-entry"); }); + spies.push(reentry); + const target = join(home, "selected"), record = { command: "codex", version: "0.160.0", source: "configured" as const }; + await runtime.persistCodexRuntimeAsync(record, { configDir: target }); + expect(JSON.parse(readFileSync(join(target, "codex-runtime.json"), "utf8")).command).toBe("codex"); + expect(existsSync(join(home, "codex-runtime.json"))).toBe(false); + await runtime.clearPersistedCodexRuntimeAsync({ configDir: target }); + expect(existsSync(join(target, "codex-runtime.json"))).toBe(false); + expect(reentry).not.toHaveBeenCalled(); +}); + +test("the async resolver publishes its selection without re-entering synchronous recovery", async () => { + const command = join(home, "codex.exe"); + writeFileSync(command, "fixture", { mode: 0o700 }); + process.env.CODEX_CLI_PATH = command; + runtime.setCodexRuntimeResolveCacheForTests({ runtime: { command, source: "environment", version: "0.160.0" }, failures: [] }, { discoverAlternatives: false }); + spies.push(spyOn(recovery, "codexCliUpdateRecoveryBlocksAdmissionAsync").mockResolvedValue(false)); + const reentry = spyOn(recovery, "codexCliUpdateRecoveryBlocksAdmission").mockImplementation(() => { throw new Error("sync recovery re-entry"); }); + spies.push(reentry); + const result = await runtime.resolveAndPersistCodexRuntimeAsync({ discoverAlternatives: false }); + expect(result?.persistError).toBeUndefined(); + expect(JSON.parse(readFileSync(join(home, "codex-runtime.json"), "utf8")).command).toBe(command); + expect(reentry).not.toHaveBeenCalled(); + expect(free()).toBe(true); +}); + +for (const outcome of ["allow", "deny", "plan-changed", "cancel"] as const) test(`management combined write waits for recovery and handles ${outcome} before mutation`, async () => { + process.env.CODEX_HOME = join(home, "codex"); + mkdirSync(process.env.CODEX_HOME); + const path = join(process.env.CODEX_HOME, "config.toml"); + const original = "[agents]\nmax_threads = 2\n"; + writeFileSync(path, original); + const config = { providers: [] } as unknown as OcxConfig; + const entered = deferred(), proof = deferred(), controller = new AbortController(); + let toggles = 0, syncProbes = 0; + spies.push(spyOn(recovery, "codexCliUpdateRecoveryBlocksAdmissionAsync").mockImplementation(() => { entered.resolve(); return proof.promise; })); + spies.push(spyOn(recovery, "codexCliUpdateRecoveryBlocksAdmission").mockImplementation(() => { syncProbes++; throw new Error("synchronous recovery must not re-enter"); })); + const req = new Request("http://localhost/api/v2", { method: "PUT", signal: controller.signal, + headers: { "host": "localhost", "content-type": "application/json" }, body: JSON.stringify({ enabled: true, multiAgentMode: "v2", agentsEnabled: false }) }); + const pending = handleManagementAPI(req, new URL(req.url), config, { + toggleCodexMultiAgentV2: enabled => { + toggles++; + // The real default toggle resolves/persists a runtime synchronously. This + // injected command exercises that nested writer without spawning a CLI. + runtime.persistCodexRuntime({ command: "fixture", source: "configured", version: null }); + writeFileSync(path, readFileSync(path, "utf8").replace(/^enabled\s*=\s*(?:true|false)$/m, `enabled = ${enabled}`)); + }, createManagementConvergeCodex: catalogConvergenceFactory(), + }); + try { + await Promise.race([entered.promise, pending.then(async response => { + throw new Error(`request settled before recovery: ${response?.status} ${await response?.text()}`); + })]); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(free()).toBe(false); + expect(toggles).toBe(0); + expect(readFileSync(path, "utf8")).toBe(original); + expect(existsSync(join(home, "codex-runtime.json"))).toBe(false); + expect(config.multiAgentMode).toBeUndefined(); + if (outcome === "plan-changed") config.keepNativeChatGptOnV1 = true; + if (outcome === "cancel") controller.abort(); + proof.resolve(outcome === "deny"); + const response = await pending; + expect(response?.status).toBe(outcome === "allow" ? 200 : outcome === "plan-changed" ? 400 : 503); + expect(syncProbes).toBe(0); + expect(free()).toBe(true); + if (outcome === "allow") { + expect(toggles).toBe(1); + expect(config.multiAgentMode).toBe("v2"); + expect(readFileSync(path, "utf8")).toContain("enabled = false"); + expect(existsSync(join(home, "codex-runtime.json"))).toBe(true); + } else { + expect(toggles).toBe(0); + expect(config.multiAgentMode).toBeUndefined(); + expect(readFileSync(path, "utf8")).toBe(original); + expect(existsSync(join(home, "codex-runtime.json"))).toBe(false); + } + } finally { proof.resolve(true); await pending; } +}); + +const native = process.platform === "win32" && process.arch === "x64" ? test : test.skip; +async function nativeTerminalStore() { + await hardenSecretDirAsync(home, { required: true }); + trustWindowsTempFixtureAncestry(home); + const config = join(home, "config"); + mkdirSync(config); + await hardenSecretDirAsync(config, { required: true }); + const owner = recovery.createCodexCliUpdateRecovery(crypto.randomUUID(), config); + owner.arm(); owner.terminal(); + return config; +} +native("native recovery uses async ACL subprocesses while timers advance; the commit never re-enters synchronous recovery", async () => { + const configDir = await nativeTerminalStore(); + let syncCalls = 0, syncHardenCalls = 0, ticks = 0; + setWindowsOwnerAclRunnerForTests(() => { syncCalls++; throw new Error("sync ACL forbidden during recovery"); }); + setIcaclsRunnerForTests(() => { syncHardenCalls++; throw new Error("sync hardening forbidden during recovery"); }); + const timer = setInterval(() => { ticks++; }, 1); + let admitted: gate.CodexCliSelectionAsyncParentWrite | undefined; + try { + admitted = await gate.beginCodexCliSelectionParentWriteAsync({ configDir }); + expect(admitted.entered).toBe(true); + expect(ticks).toBeGreaterThan(0); + expect(syncCalls).toBe(0); + expect(syncHardenCalls).toBe(0); + // Ordinary atomic secret-file publication retains its synchronous contract. + // Only recovery admission is asynchronous; the nested writer must reuse + // this root/generation's admission without re-running recovery. + setWindowsOwnerAclRunnerForTests(null); + setIcaclsRunnerForTests(null); + const reentry = spyOn(recovery, "codexCliUpdateRecoveryBlocksAdmission").mockImplementation(() => { throw new Error("sync recovery re-entry"); }); + spies.push(reentry); + admitted.commit(() => runtime.persistCodexRuntime({ command: "fixture", version: null, source: "configured" }, { configDir })); + expect(existsSync(join(configDir, "codex-runtime.json"))).toBe(true); + expect(reentry).not.toHaveBeenCalled(); + } finally { clearInterval(timer); admitted?.exit(); } +}, 30_000); + +native("native async ACL denial preserves recovery bytes, releases pins, and refuses publication", async () => { + const configDir = await nativeTerminalStore(); + const dir = join(configDir, "codex-cli-update-recovery"); + const snapshot = () => readdirSync(dir).sort().map(name => [name, readFileSync(join(dir, name), "utf8")]); + const before = snapshot(); + setWindowsOwnerAclAsyncRunnerForTests(async () => ({ success: false, timedOut: true, stdout: "" })); + await expect(runtime.persistCodexRuntimeAsync({ command: "fixture", version: null, source: "configured" }, { configDir })) + .rejects.toThrow("codex_cli_selection_busy"); + expect(existsSync(join(configDir, "codex-runtime.json"))).toBe(false); + expect(snapshot()).toEqual(before); + expect(free()).toBe(true); + setWindowsOwnerAclAsyncRunnerForTests(null); + expect(await recovery.codexCliUpdateRecoveryBlocksAdmissionAsync(configDir)).toBe(false); +}, 30_000); diff --git a/tests/codex-integration/codex-cli-update-windows-pending.test.ts b/tests/codex-integration/codex-cli-update-windows-pending.test.ts index 8e783c50996..860284cf5e1 100644 --- a/tests/codex-integration/codex-cli-update-windows-pending.test.ts +++ b/tests/codex-integration/codex-cli-update-windows-pending.test.ts @@ -5,7 +5,8 @@ import * as ffi from "bun:ffi"; import * as acl from "../../src/lib/windows-secret-acl"; import { hardenSecretDirAsync, hardenSecretPath } from "../../src/lib/windows-secret-acl"; import { withWindowsCliRecoveryStore } from "../../src/codex/cli-update-job-store-windows"; -import { codexCliUpdateRecoveryBlocksAdmission } from "../../src/codex/cli-update-recovery"; +import { codexCliUpdateRecoveryBlocksAdmission, codexCliUpdateRecoveryBlocksAdmissionAsync } from "../../src/codex/cli-update-recovery"; +import { setWindowsOwnerAclRunnerForTests } from "../../src/lib/windows-owner-acl"; import { startWindowsCliChild } from "../../src/codex/windows-cli-child"; import * as lifetime from "../../src/codex/windows-cli-recovery"; import { createCodexCliTestRoot } from "../helpers/codex-cli-fixture-root"; @@ -67,6 +68,27 @@ windows("restart publishes a complete pending child only after terminal native p expect(statSync(join(home, "codex-cli-update-recovery", token + ".json")).ino).toBe(pendingIdentity); expect(codexCliUpdateRecoveryBlocksAdmission(home)).toBe(false); }, 30000); +windows("async restart adopts the same pending inode without synchronous ACL reads or hardening", async () => { + const { armed, child, proof } = records(); seed(armed, child, proof); + const pendingIdentity = statSync(join(home, "codex-cli-update-recovery", token + ".pending"), { bigint: true }).ino; + let syncReads = 0, syncHardens = 0, ticks = 0; + setWindowsOwnerAclRunnerForTests(() => { syncReads++; throw new Error("sync recovery ACL read"); }); + acl.setIcaclsRunnerForTests(() => { syncHardens++; throw new Error("sync recovery ACL hardening"); }); + const timer = setInterval(() => { ticks++; }, 1); + try { + expect(await codexCliUpdateRecoveryBlocksAdmissionAsync(home)).toBe(false); + expect(ticks).toBeGreaterThan(0); + expect(syncReads).toBe(0); + expect(syncHardens).toBe(0); + } finally { + clearInterval(timer); + setWindowsOwnerAclRunnerForTests(null); + acl.setIcaclsRunnerForTests(null); + } + expect(existsSync(join(home, "codex-cli-update-recovery", token + ".pending"))).toBe(false); + expect(withWindowsCliRecoveryStore(home, false, io => JSON.parse(io.read(token + ".json")))).toEqual(child); + expect(statSync(join(home, "codex-cli-update-recovery", token + ".json"), { bigint: true }).ino).toBe(pendingIdentity); +}, 30000); const negatives = ["token", "job", "parent", "parent-alive", "armed-child", "armed-identity", "missing-child-pid", "missing-child-time", "proof-missing", "proof-malformed", "proof-armed", "proof-token", "proof-job", "proof-parent", "proof-pid", "pending-malformed", "pending-armed", "native-unknown", "native-throws", "parent-denied", @@ -101,10 +123,10 @@ windows.each(negatives)("pending child stays fenced without matching authority: try { expect(codexCliUpdateRecoveryBlocksAdmission(home)).toBe(true); expect(snapshot()).toEqual(before); } finally { probe?.mockRestore(); parentProbe?.mockRestore(); } }, 30000); -windows.each(["file-flush", "harden", "rename", "readback", "flush"] as const)("reconciliation publication failure fences: %s", stage => { +for (const asynchronous of [false, true]) windows.each(["file-flush", "harden", "rename", "readback", "flush"] as const)(`${asynchronous ? "async" : "sync"} reconciliation publication failure fences: %s`, async stage => { const { armed, child, proof } = records(); seed(armed, child, proof); let injected = 0, renamed = false; - const harden = stage === "harden" ? spyOn(acl, "hardenSecretPath").mockImplementation(() => { injected++; throw new Error("harden_failed"); }) : undefined; + const harden = stage === "harden" ? spyOn(acl, asynchronous ? "hardenSecretPathAsync" : "hardenSecretPath").mockImplementation(() => { injected++; throw new Error("harden_failed"); }) : undefined; const realOpen = ffi.dlopen; const open = stage !== "harden" ? spyOn(ffi, "dlopen").mockImplementation(((...args: Parameters) => { const lib = realOpen(...args); @@ -123,7 +145,10 @@ windows.each(["file-flush", "harden", "rename", "readback", "flush"] as const)(" }, } }; }) as typeof ffi.dlopen) : undefined; - try { expect(codexCliUpdateRecoveryBlocksAdmission(home)).toBe(true); expect(injected).toBeGreaterThan(0); } + try { + expect(asynchronous ? await codexCliUpdateRecoveryBlocksAdmissionAsync(home) : codexCliUpdateRecoveryBlocksAdmission(home)).toBe(true); + expect(injected).toBeGreaterThan(0); + } finally { harden?.mockRestore(); open?.mockRestore(); } // Failure may leave either armed+pending or an already renamed child. Neither // path fabricates terminal proof or changes the persisted child identity. diff --git a/tests/codex-integration/multi-agent-keep-native-v1.test.ts b/tests/codex-integration/multi-agent-keep-native-v1.test.ts index 4b1abceabee..7c985cdd9a9 100644 --- a/tests/codex-integration/multi-agent-keep-native-v1.test.ts +++ b/tests/codex-integration/multi-agent-keep-native-v1.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, spyOn, test } from "bun:test"; import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -10,6 +10,8 @@ import { import { CODEX_NATIVE_ALIAS_CATALOG_KIND } from "../../src/codex/catalog/kinds"; import { buildCatalogEntriesFromObservedState } from "../../src/codex/catalog/sync"; import { cmdV2 } from "../../src/cli/v2"; +import * as recovery from "../../src/codex/cli-update-recovery"; +import * as gate from "../../src/codex/cli-update-selection-gate"; import { loadConfig, saveConfig } from "../../src/config"; import { MULTI_AGENT_SURFACE_ADVISORY_VERSION, SUBAGENT_SURFACE_GUIDE_URL } from "../../src/config/multi-agent-surface"; import { isMultiAgentV2Enabled } from "../../src/codex/features"; @@ -530,3 +532,87 @@ describe("/api/v2 sub-agent surface advisory", () => { expect(config.multiAgentSurfaceAdvisoryVersion).toBeUndefined(); }); }); + +describe("ocx v2 revalidates state once the awaited admission enters", () => { + const fixtureSync = async () => ({ status: "applied" as const, ok: true, added: 0, catalogPath: null, catalogExists: true, catalogWritten: false, cacheSynced: false, message: "fixture" }); + function holdAdmission() { + let entered!: () => void, release!: (blocked: boolean) => void; + const started = new Promise(resolve => { entered = resolve; }); + const proof = new Promise(resolve => { release = resolve; }); + const spy = spyOn(recovery, "codexCliUpdateRecoveryBlocksAdmissionAsync").mockImplementation(() => { entered(); return proof; }); + return { started, release: () => release(false), restore: () => spy.mockRestore() }; + } + + test("v2 on refuses when the hybrid pin was enabled while admission waited", async () => { + isolateHomes(); + saveConfig({ ...loadConfig(), multiAgentMode: "v2" }); + const codexConfig = join(process.env.CODEX_HOME!, "config.toml"); + writeFileSync(codexConfig, "[features.multi_agent_v2]\nenabled = false\n"); + const hold = holdAdmission(); + const { errors, log } = captureLog(); + let toggles = 0; + try { + const running = cmdV2(["on"], { execFile: () => { toggles++; }, sync: async () => { throw new Error("must not sync"); }, log }); + await hold.started; + saveConfig({ ...loadConfig(), keepNativeChatGptOnV1: true }); + hold.release(); + expect(await running).toBe(1); + } finally { hold.restore(); } + expect(toggles).toBe(0); + expect(isMultiAgentV2Enabled(codexConfig)).toBe(false); + expect(errors.join("\n")).toContain("global multi_agent_v2 overrides the native v1 catalog pin"); + }); + + test("threads keeps a feature state changed while admission waited", async () => { + isolateHomes(); + const codexConfig = join(process.env.CODEX_HOME!, "config.toml"); + writeFileSync(codexConfig, "[features.multi_agent_v2]\nenabled = false\n"); + const hold = holdAdmission(); + const actions: string[] = []; + try { + const running = cmdV2(["threads", "4"], { + featuresInvocation: action => ({ file: "fixture-codex", args: ["features", action, "multi_agent_v2"], options: {} }), + execFile: (_file, args) => { actions.push(featureActionOf(args)); }, + log: captureLog().log, + }); + await hold.started; + writeFileSync(codexConfig, "[features.multi_agent_v2]\nenabled = true\n"); + hold.release(); + expect(await running).toBe(0); + } finally { hold.restore(); } + expect(actions).toEqual([]); + expect(isMultiAgentV2Enabled(codexConfig)).toBe(true); + }); + + test("mode saves the config read once admitted, keeping a setting saved meanwhile", async () => { + isolateHomes(); + writeFileSync(join(process.env.CODEX_HOME!, "config.toml"), "[features.multi_agent_v2]\nenabled = false\n"); + const hold = holdAdmission(); + try { + const running = cmdV2(["mode", "v1"], { execFile: () => {}, sync: fixtureSync, log: captureLog().log }); + await hold.started; + saveConfig({ ...loadConfig(), keepNativeChatGptOnV1: true }); + hold.release(); + expect(await running).toBe(0); + } finally { hold.restore(); } + expect(loadConfig().multiAgentMode).toBe("v1"); + expect(loadConfig().keepNativeChatGptOnV1).toBe(true); + }); + + test("mode saves inside admission, so a save landing after the callback is kept", async () => { + isolateHomes(); + writeFileSync(join(process.env.CODEX_HOME!, "config.toml"), "[features.multi_agent_v2]\nenabled = false\n"); + const real = gate.withCodexCliSelectionParentWriteAsync; + const spy = spyOn(gate, "withCodexCliSelectionParentWriteAsync").mockImplementation(async (run, options) => { + const value = await real(run, options); + // A competing writer between the admitted callback and this command's continuation. + saveConfig({ ...loadConfig(), keepNativeChatGptOnV1: true }); + return value; + }); + try { + expect(await cmdV2(["mode", "v1"], { execFile: () => {}, sync: fixtureSync, log: captureLog().log })).toBe(0); + } finally { spy.mockRestore(); } + expect(loadConfig().multiAgentMode).toBe("v1"); + expect(loadConfig().keepNativeChatGptOnV1).toBe(true); + }); +}); diff --git a/tests/fixtures/parent-stop-runner.ts b/tests/fixtures/parent-stop-runner.ts index 7a1f9dd48b3..db621f4b751 100644 --- a/tests/fixtures/parent-stop-runner.ts +++ b/tests/fixtures/parent-stop-runner.ts @@ -227,7 +227,7 @@ mock.module("../../src/server/system-env", () => ({ ...systemEnv, revertSystemEn const portReclaim = await import("../../src/server/port-reclaim"); mock.module("../../src/server/port-reclaim", () => ({ ...portReclaim, reclaimListenPort: async () => {} })); // Only shim preflight is unrelated to this stop contract; parsing and dispatch stay real. -mock.module("../../src/cli/codex-shim-autorestore", () => ({ maybeAutoRestoreCodexShim() {} })); +mock.module("../../src/cli/codex-shim-autorestore", () => ({ async maybeAutoRestoreCodexShimAsync() {} })); globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { const url = String(input); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 7b19eb71736..69b0093f255 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -74,6 +74,7 @@ "codex-cli-update-admission.test.ts": "codex-integration", "codex-cli-update-recovery.test.ts": "codex-integration", "codex-cli-update-worker.test.ts": "codex-integration", + "codex-cli-update-parent-async.test.ts": "codex-integration", "responses-replay-metadata-principal.test.ts": "responses", "responses-combo-replay-principal.test.ts": "responses", "responses-translated-replay-principal.test.ts": "responses", From 236a886f77c95c8d1191c339b845d5a84b7272e3 Mon Sep 17 00:00:00 2001 From: LilMGenius Date: Fri, 30 Oct 2026 22:11:48 +0900 Subject: [PATCH 72/84] feat(codex): tier role auto-assign candidates by measured capability (#7624) Tier Codex role auto-assign candidates by measured capability in the role's task domain, using the operator's own Artificial Analysis key, with configured tiers and price rank as fallback. The Hugging Face LMArena parquet source and the hyparquet dependency were removed before merge. The Artificial Analysis key joins configured-credential coverage. Co-authored-by: LilMGenius <97161055+LilMGenius@users.noreply.github.com> --- .../src/content/docs/guides/integrations.md | 33 +++- .../content/docs/guides/sub-agent-surface.md | 3 +- .../docs/reference/configuration/agents.md | 3 +- .../test-layout/layout-codex-collision.json | 4 + src/codex/role-auto-assign.ts | 76 ++++++--- src/codex/role-capability.ts | 160 ++++++++++++++++++ src/codex/role-sizing.ts | 30 +++- src/config/schema/config-schema.ts | 8 + src/server/configured-credentials.ts | 5 + .../management/codex-agent-role-routes.ts | 1 + .../management/codex-role-auto-assign.ts | 56 +++++- src/server/management/context.ts | 1 + src/types/config.ts | 5 + structure/config.md | 2 +- structure/subagents.md | 45 ++--- tests/fixtures/test-layout-expected.json | 4 + tests/routing/codex-role-auto-assign.test.ts | 66 +++++++- .../codex-role-capability-consent.test.ts | 59 +++++++ .../codex-role-capability-redirects.test.ts | 30 ++++ .../routing/codex-role-measured-ties.test.ts | 41 +++++ tests/routing/codex-role-sizing.test.ts | 21 ++- .../codex-role-auto-assign-routes.test.ts | 88 ++++++++++ .../codex-role-configured-credentials.test.ts | 55 ++++++ 23 files changed, 727 insertions(+), 69 deletions(-) create mode 100644 src/codex/role-capability.ts create mode 100644 tests/routing/codex-role-capability-consent.test.ts create mode 100644 tests/routing/codex-role-capability-redirects.test.ts create mode 100644 tests/routing/codex-role-measured-ties.test.ts create mode 100644 tests/server/codex-role-configured-credentials.test.ts diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index d76818a097c..ae6a4775100 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -987,15 +987,40 @@ exists only while LazyCodex is detected. Without it the dashboard shows neither, Auto-assign proposes a model for every role at once. opencodex asks your default Codex model (the root `model` in Codex `config.toml`) one question: for each role, given its -description and the start of its instructions, which capability tier (fast, standard or frontier) -and how much reasoning (glance, measured, thorough or exhaustive) does it need? That model never -picks a model. opencodex then picks the cheapest model from your picker list that reaches the tier: +description and the start of its instructions, which capability tier (fast, standard or frontier), +how much reasoning (glance, measured, thorough or exhaustive) and which task domain (coding, agentic, +reasoning, writing, knowledge or general) does it need? That model never picks a model. opencodex +then picks the cheapest model from your picker list that reaches the tier: - Models listed under `codexRoleTiers` in the opencodex config (`{ "fast": [...], "standard": [...], "frontier": [...] }`) have that tier. +- When two or more of the other models have a published score in the role's domain, those models are + tiered by distinct score values, without requiring a price; equal scores share a tier, and unscored models are left out. Scores come from + [Artificial Analysis](https://artificialanalysis.ai) when you add your own API key + (below). The response's `capability` block names the source it used. - Other models with a known price are ranked by price and split evenly across the three tiers. With only one or two priced models, the dearest is frontier and the other, if any, is standard. -- Models with no price and no listed tier are never proposed. List them to include them. +- When tiering falls back to price, models with no price and no listed tier are not proposed. List them to include them. + +To use Artificial Analysis, create a free API key at [artificialanalysis.ai](https://artificialanalysis.ai/api-reference) +and set it as a literal, an `${ENV}` reference or a keychain reference: + +```json +{ + "codexRoleCapability": { + "artificialAnalysis": { "apiKey": "${ARTIFICIAL_ANALYSIS_API_KEY}" } + } +} +``` + +opencodex reads it once per day, which stays inside the free tier's daily request limit. Its terms allow +using the data for your own decisions and forbid passing it on, so auto-assign keeps the scores +internal: the response carries the tier each model got and the attribution, without the scores. If the +key is absent or the read fails, auto-assign uses configured tiers and prices; `capability.error` reports +why and the source attribution fields are null. A retired `huggingFace` setting is ignored. + +Artificial Analysis refuses HTTP redirects and limits each response body to 16 MiB with a 30-second +request timeout. Credentials are sent only to its fixed API endpoint. Each proposal shows the model, the tier, the reasoning effort, a one-line reason, and what would move it up or down. A role the model could not size clearly is shown as not sized, with the reason, and diff --git a/docs-site/src/content/docs/guides/sub-agent-surface.md b/docs-site/src/content/docs/guides/sub-agent-surface.md index a7bd6c543eb..eab0c0000b8 100644 --- a/docs-site/src/content/docs/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/guides/sub-agent-surface.md @@ -151,7 +151,8 @@ description of the work Codex usually hands off, sizes it with one call to your and proposes the cheapest sufficient model and an effort from the list the picker offers, with the reasoning and the signals that would move it up or down. Nothing changes until you choose **Use this**, which saves exactly like picking the model and effort by hand. Tiers come from -`codexRoleTiers` or price, as in [role auto-assign](/guides/integrations/#auto-assign). +`codexRoleTiers` or price, as in [role auto-assign](/guides/integrations/#auto-assign), without the +measured scores role auto-assign can read from configured sources. `multiAgentGuidanceEnabled` defaults to on and is the master switch for opencodex-authored guidance on both surfaces. Turning it off suppresses both the v2 designation block and v1 proactive text. diff --git a/docs-site/src/content/docs/reference/configuration/agents.md b/docs-site/src/content/docs/reference/configuration/agents.md index 1b6791280cd..d2474b94f1c 100644 --- a/docs-site/src/content/docs/reference/configuration/agents.md +++ b/docs-site/src/content/docs/reference/configuration/agents.md @@ -56,7 +56,8 @@ still depends on upstream support for your account. | `subagentModelFallback?` | `string[]` | `[]` | Priority-ordered global fallback models for spawned child turns. | | `subagentModelFallbackByModel?` | `Record` | `{}` | Per-primary-model fallback chains, keyed by the requested primary model id. This is the supported home for per-role fallback metadata; `model_fallback` inside Codex agent TOML makes Codex 0.146+ skip the role (#1190). | | `subagentModelFallbackPollMs?` | `number` | `60000` | Availability-probe cache interval. Values below 1000 ms fall back to the default. | -| `codexRoleTiers?` | `{ fast?, standard?, frontier?: string[] }` | — | Capability tier of named models for [role auto-assign](/guides/integrations/#auto-assign). Listed models take that tier; other models are tiered by price, and unpriced unlisted models are never proposed. | +| `codexRoleTiers?` | `{ fast?, standard?, frontier?: string[] }` | — | Capability tier of named models for [role auto-assign](/guides/integrations/#auto-assign). Listed models take that tier; other models are tiered by measured score in the role's domain when two or more have one, without requiring prices. Equal scores share a tier. Otherwise price tiering excludes unpriced, unlisted models. | +| `codexRoleCapability?` | `{ artificialAnalysis?: { apiKey?: string } }` | No measured source | Measured-score source for [role auto-assign](/guides/integrations/#auto-assign). Artificial Analysis uses your API key (literal, `${ENV}` or keychain reference) once per day; scores stay out of the response. Missing or unavailable data falls back to configured tiers and prices. A retired `huggingFace` key is ignored. | | `effortCap?` | `string` | — | Hard ceiling for qualifying v2 main turns and marked spawned-child turns. Accepts `low` through `ultra`. | | `subagentEffortCap?` | `string` | — | Additional ceiling for spawned-child turns only. When both caps apply, the lower wins. | | `plaintextV2AgentMessages?` | `boolean` | — (unset) | Experimental opt-in. It runs only when explicitly set to `true` and asks eligible native ChatGPT v2 parents to emit `spawn_agent`, `send_message`, and `followup_task` message arguments as plaintext. See [Plaintext v2 agent messages](#plaintext-v2-agent-messages). | diff --git a/scripts/test-layout/layout-codex-collision.json b/scripts/test-layout/layout-codex-collision.json index d93e0f3748a..05cd39368be 100644 --- a/scripts/test-layout/layout-codex-collision.json +++ b/scripts/test-layout/layout-codex-collision.json @@ -1,4 +1,8 @@ { + "codex-role-configured-credentials.test.ts": "server", + "codex-role-capability-consent.test.ts": "routing", + "codex-role-capability-redirects.test.ts": "routing", + "codex-role-measured-ties.test.ts": "routing", "messaging-remote-port-reservation.test.ts": "codex-integration", "messaging-remote-forward-failure.test.ts": "codex-integration", "codex-cli-windows-child.test.ts": "codex-integration", diff --git a/src/codex/role-auto-assign.ts b/src/codex/role-auto-assign.ts index f2a29cea535..8fde0b66b2f 100644 --- a/src/codex/role-auto-assign.ts +++ b/src/codex/role-auto-assign.ts @@ -4,19 +4,23 @@ * * Tier of a candidate model, first rule that applies: * 1. codexRoleTiers in the opencodex config lists it under fast, standard or frontier. - * 2. It has a known price. Priced candidates are ranked by input plus output USD per 1M tokens. + * 2. At least two candidates have a measured score in the role's task domain. Measured candidates + * are ranked by that score and split across the tiers the same way price splits them below; + * unmeasured ones are left out rather than placed among measured ones by price. + * 3. It has a known price. Priced candidates are ranked by input plus output USD per 1M tokens. * Three or more split evenly across the three tiers by rank, cheapest in fast. Fewer than three * cannot fill every tier, so the ranking is anchored at the top: the dearest is frontier, * because it is the strongest model on offer, and each cheaper one sits one tier lower. One * priced model is frontier; two are standard and frontier. The tier left empty is fast, so a * fast role steps up to the cheapest model and a standard role keeps it instead of jumping to * the dearest, which keeps every role on the cheapest sufficient model on offer. - * 3. Otherwise it is unclassified and never proposed. + * 4. Otherwise it is unclassified and never proposed. * * A role gets the cheapest sufficient model: the lowest tier at or above what it was sized for, - * then the lowest price, then the model the role already uses when it is one of the tied models, - * then the picker's own order. The picker lists native models first, so without the current-model - * step a role on a routed model moved to a native one priced and tiered the same. + * then the lowest price, then the higher measured score, then the model the role already uses when + * it is one of the tied models, then the picker's own order. The picker lists native models first, + * so without the current-model step a role on a routed model moved to a native one priced and + * tiered the same. * * Effort binds to positions on the chosen model's ladder: glance is the floor, measured is the * model's default or the middle rung, exhaustive is the ceiling, and thorough is the rung above @@ -25,7 +29,7 @@ * above wins because thorough asks for more than the everyday setting. Nothing resolves outside * the ladder. */ -import { SIZING_TIERS, type RoleSizingOutcome, type SizingEffort, type SizingTier } from "./role-sizing"; +import { SIZING_TIERS, type RoleSizingOutcome, type SizingDomain, type SizingEffort, type SizingTier } from "./role-sizing"; import { CODEX_REASONING_LEVELS } from "../reasoning-effort"; export type RoleTierMapping = Partial>; @@ -40,7 +44,9 @@ export interface RoleModelCandidate { export interface ClassifiedCandidate extends RoleModelCandidate { readonly tier: SizingTier | null; - readonly tierSource: "mapping" | "price" | null; + readonly tierSource: "mapping" | "measured" | "price" | null; + /** The measured score the tier came from, or null when it came from elsewhere. */ + readonly score: number | null; } const LADDER_ORDER = ["none", "minimal", ...CODEX_REASONING_LEVELS.map(level => level.effort)]; @@ -66,33 +72,45 @@ export function mapEffortToLevel(intent: SizingEffort, efforts: readonly string[ } } +/** Split items ranked weakest first across the tiers; fewer than three are anchored at the top. */ +function tierByRank(ranked: readonly T[]): Map { + const top = SIZING_TIERS.length - 1; + return new Map(ranked.map((item, rank) => [item, SIZING_TIERS[ranked.length < SIZING_TIERS.length + ? top - (ranked.length - 1 - rank) + : Math.round((rank * top) / (ranked.length - 1))]!])); +} + export function classifyRoleModelCandidates( candidates: readonly RoleModelCandidate[], mapping: RoleTierMapping = {}, + measured: ReadonlyMap = new Map(), ): ClassifiedCandidate[] { const mapped = new Map(); for (const tier of SIZING_TIERS) { for (const model of mapping[tier] ?? []) if (!mapped.has(model)) mapped.set(model, tier); } - const priced = candidates - .map((candidate, order) => ({ candidate, order })) - .filter(({ candidate }) => !mapped.has(candidate.model) && candidate.unitPrice !== null) - .sort((a, b) => a.candidate.unitPrice! - b.candidate.unitPrice! || a.order - b.order); - const byPrice = new Map(); - priced.forEach(({ candidate }, rank) => { - const top = SIZING_TIERS.length - 1; - const index = priced.length < SIZING_TIERS.length - ? top - (priced.length - 1 - rank) - : Math.round((rank * top) / (priced.length - 1)); - byPrice.set(candidate.model, SIZING_TIERS[index]!); - }); + const unmapped = candidates.filter(candidate => !mapped.has(candidate.model)); + const scored = unmapped.filter(candidate => measured.has(candidate.model)); + const scoreTiers = tierByRank([...new Set(scored.map(candidate => measured.get(candidate.model)!))].sort((a, b) => a - b)); + const byScore = scored.length >= 2 + ? new Map(scored.map(candidate => [candidate.model, scoreTiers.get(measured.get(candidate.model)!)!] as const)) + : new Map(); + const byPrice = byScore.size > 0 + ? new Map() + : tierByRank(unmapped + .map((candidate, order) => ({ candidate, order })) + .filter(({ candidate }) => candidate.unitPrice !== null) + .sort((a, b) => a.candidate.unitPrice! - b.candidate.unitPrice! || a.order - b.order) + .map(({ candidate }) => candidate.model)); return candidates.map(candidate => { const fromMapping = mapped.get(candidate.model); - if (fromMapping) return { ...candidate, tier: fromMapping, tierSource: "mapping" as const }; + if (fromMapping) return { ...candidate, tier: fromMapping, tierSource: "mapping" as const, score: null }; + const fromScore = byScore.get(candidate.model); + if (fromScore) return { ...candidate, tier: fromScore, tierSource: "measured" as const, score: measured.get(candidate.model)! }; const fromPrice = byPrice.get(candidate.model); return fromPrice - ? { ...candidate, tier: fromPrice, tierSource: "price" as const } - : { ...candidate, tier: null, tierSource: null }; + ? { ...candidate, tier: fromPrice, tierSource: "price" as const, score: null } + : { ...candidate, tier: null, tierSource: null, score: null }; }); } @@ -107,6 +125,7 @@ export function cheapestSufficientCandidate( .filter(entry => entry.rank >= need) .sort((a, b) => a.rank - b.rank || (a.candidate.unitPrice ?? Infinity) - (b.candidate.unitPrice ?? Infinity) + || (b.candidate.score ?? -Infinity) - (a.candidate.score ?? -Infinity) || Number(b.candidate.model === current) - Number(a.candidate.model === current) || a.order - b.order); return ranked[0]?.candidate ?? null; @@ -123,10 +142,13 @@ export type RoleProposal = RoleProposalInput & ( readonly status: "proposed" | "unassigned"; readonly tier: SizingTier; readonly effortIntent: SizingEffort; + readonly domain: SizingDomain; readonly rationale: string; readonly moveUpIf: string; readonly moveDownIf: string; readonly proposedModel: string | null; + /** Where the picked model's tier came from: a user mapping, a measured score, or price rank. */ + readonly tierSource: ClassifiedCandidate["tierSource"]; /** The sized effort on the picked model's ladder; null only when that model has no ladder. */ readonly proposedEffort: string | null; readonly reason: string | null; @@ -138,23 +160,25 @@ export function buildRoleProposals( roles: readonly RoleProposalInput[], sizing: ReadonlyMap, classified: readonly ClassifiedCandidate[], + classifiedByDomain: ReadonlyMap = new Map(), ): RoleProposal[] { return roles.map(role => { const outcome = sizing.get(role.role) ?? { unsized: "the role was not sized" }; if ("unsized" in outcome) return { ...role, status: "unsized", reason: outcome.unsized }; - const { tier, effort, rationale, moveUpIf, moveDownIf } = outcome.sizing; - const pick = cheapestSufficientCandidate(classified, tier, role.model); - const sized = { ...role, tier, effortIntent: effort, rationale, moveUpIf, moveDownIf }; + const { tier, effort, domain, rationale, moveUpIf, moveDownIf } = outcome.sizing; + const pick = cheapestSufficientCandidate(classifiedByDomain.get(domain) ?? classified, tier, role.model); + const sized = { ...role, tier, effortIntent: effort, domain, rationale, moveUpIf, moveDownIf }; if (!pick) { return { ...sized, status: "unassigned", proposedModel: null, + tierSource: null, proposedEffort: null, reason: `no available model is classified ${tier} or above`, }; } const proposedEffort = mapEffortToLevel(effort, pick.efforts, pick.defaultEffort); - return { ...sized, status: "proposed", proposedModel: pick.model, proposedEffort, reason: null }; + return { ...sized, status: "proposed", proposedModel: pick.model, tierSource: pick.tierSource, proposedEffort, reason: null }; }); } diff --git a/src/codex/role-capability.ts b/src/codex/role-capability.ts new file mode 100644 index 00000000000..1edb0d1ded0 --- /dev/null +++ b/src/codex/role-capability.ts @@ -0,0 +1,160 @@ +/** + * Measured capability per task domain, so role auto-assign can tier candidates by what they score + * instead of what they cost. Artificial Analysis is read live when configured: + * + * - Artificial Analysis (https://artificialanalysis.ai/api-reference), when the user configures + * their own API key. Its terms allow that user's internal use with attribution and forbid + * redistributing the raw data, so its scores never leave this process. + * + * Each domain maps to one published index. The source names a model by display name + * with one row per reasoning setting ("Claude Opus 5.5 (High)"); capabilityModelKey drops the + * setting and folds the rest to a slug, which matches a catalog id once its provider prefix is + * gone ("cpa/claude-opus-5-5"). The best setting's score stands for the model, since a role can + * be given that setting. A candidate with no row is unmeasured, never guessed. + */ +import { resolveProviderApiKey } from "../providers/api-key-resolve"; +import type { OcxConfig } from "../types"; +import type { SizingDomain } from "./role-sizing"; + +export interface CapabilitySource { + readonly name: string; + readonly url: string; + readonly license: string; + /** Whether the terms let a response carry the raw scores. */ + readonly redistributable: boolean; +} + +export const ARTIFICIAL_ANALYSIS_SOURCE: CapabilitySource = { + name: "Artificial Analysis", + url: "https://artificialanalysis.ai", + license: "Artificial Analysis API terms: internal use, attribution required, no redistribution", + redistributable: false, +}; + +const ARTIFICIAL_ANALYSIS_URL = "https://artificialanalysis.ai/api/v2/data/llms/models"; + +/** Per domain, the indexes to read in order; the first one the response carries is used. */ +const INTELLIGENCE_INDEX = "artificial_analysis_intelligence_index"; +const ARTIFICIAL_ANALYSIS_INDEXES: Record = { + coding: ["artificial_analysis_coding_index", INTELLIGENCE_INDEX], + agentic: ["artificial_analysis_agentic_index", INTELLIGENCE_INDEX], + reasoning: ["artificial_analysis_math_index", INTELLIGENCE_INDEX], + writing: [INTELLIGENCE_INDEX], + knowledge: [INTELLIGENCE_INDEX], + general: [INTELLIGENCE_INDEX], +}; + +const FETCH_TIMEOUT_MS = 30_000; +/** Each published table is under 1 MB today; 16 MB leaves room to grow and still bounds memory. */ +export const MAX_TABLE_BYTES = 16 * 1024 * 1024; + +export interface CapabilityTable { + readonly source: CapabilitySource | null; + readonly publishedAt: string | null; + /** Per domain: model key to its best published score. */ + readonly domains: Partial>>; + /** Why a configured source was passed over for this one. */ + readonly fallbackReason?: string; +} + +export type LoadCapability = (config: OcxConfig) => Promise; + +export function capabilityModelKey(name: string): string { + return name.toLowerCase().replace(/\([^)]*\)/g, " ").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); +} + +export function candidateModelKey(slug: string): string { + return capabilityModelKey(slug.slice(slug.lastIndexOf("/") + 1)); +} + +function keepBest(scores: Map, key: string, value: number) { + if (key) scores.set(key, Math.max(value, scores.get(key) ?? -Infinity)); +} + +export function capabilityTableFromArtificialAnalysis(body: unknown): CapabilityTable { + const rows = (body as { data?: unknown } | null)?.data; + if (!Array.isArray(rows)) throw new Error("Artificial Analysis returned no model list"); + const domains: Partial>> = {}; + for (const [domain, indexes] of Object.entries(ARTIFICIAL_ANALYSIS_INDEXES) as [SizingDomain, readonly string[]][]) { + for (const index of indexes) { + const scores = new Map(); + for (const row of rows as Record[]) { + const value = (row?.evaluations as Record | undefined)?.[index]; + if (typeof value !== "number" || !Number.isFinite(value)) continue; + if (typeof row.name === "string") keepBest(scores, capabilityModelKey(row.name), value); + if (typeof row.slug === "string") keepBest(scores, capabilityModelKey(row.slug), value); + } + if (scores.size > 0) { + domains[domain] = scores; + break; + } + } + } + return { source: ARTIFICIAL_ANALYSIS_SOURCE, publishedAt: null, domains }; +} + +/** The body's bytes, refused once they pass maxBytes, whatever the transport declared. */ +export async function readBoundedBytes(response: Response, maxBytes: number): Promise { + const chunks: Uint8Array[] = []; + let length = 0; + const reader = response.body?.getReader(); + if (!reader) return new ArrayBuffer(0); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (length + value.byteLength > maxBytes) { + await reader.cancel(); + throw new Error("response exceeded byte bound"); + } + chunks.push(value); + length += value.byteLength; + } + const out = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.byteLength; + } + return out.buffer; +} + +export async function fetchArtificialAnalysisTable(apiKey: string, fetchImpl: typeof fetch = fetch): Promise { + const response = await fetchImpl(ARTIFICIAL_ANALYSIS_URL, { + redirect: "error", + headers: { "x-api-key": apiKey }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const bytes = await readBoundedBytes(response, MAX_TABLE_BYTES); + return capabilityTableFromArtificialAnalysis(JSON.parse(new TextDecoder().decode(bytes))); +} + +/** One fetch per period key; a failed fetch is forgotten so the next request retries. */ +function periodCache() { + let slot: { key: string; table: Promise } | null = null; + return (key: string, fetchTable: () => Promise): Promise => { + if (slot?.key === key) return slot.table; + const table = fetchTable(); + slot = { key, table }; + table.catch(() => { if (slot?.table === table) slot = null; }); + return table; + }; +} + +const artificialAnalysisCache = periodCache(); + +/** + * Artificial Analysis when the user's key resolves, read once per UTC day to stay inside the + * free tier's daily request quota. Absent or unavailable scores leave price ranking in charge. + */ +export const loadCapabilityTable: LoadCapability = async config => { + const now = new Date().toISOString(); + const unavailable = (fallbackReason: string): CapabilityTable => ({ source: null, publishedAt: null, domains: {}, fallbackReason }); + const apiKey = resolveProviderApiKey(config.codexRoleCapability?.artificialAnalysis?.apiKey)?.trim(); + if (!apiKey) return unavailable("benchmark data was unavailable: no Artificial Analysis key is configured"); + try { + return await artificialAnalysisCache(`${now.slice(0, 10)} ${apiKey}`, () => fetchArtificialAnalysisTable(apiKey)); + } catch { + return unavailable("Artificial Analysis could not be read"); + } +}; diff --git a/src/codex/role-sizing.ts b/src/codex/role-sizing.ts index 2d1a42ffa94..a8e9b384e79 100644 --- a/src/codex/role-sizing.ts +++ b/src/codex/role-sizing.ts @@ -43,6 +43,10 @@ export type SizingTier = typeof SIZING_TIERS[number]; export const SIZING_EFFORTS = ["glance", "measured", "thorough", "exhaustive"] as const; export type SizingEffort = typeof SIZING_EFFORTS[number]; +/** Task domains, the kind of capability a tier is measured in. A label, never a model or a vendor. */ +export const SIZING_DOMAINS = ["coding", "agentic", "reasoning", "writing", "knowledge", "general"] as const; +export type SizingDomain = typeof SIZING_DOMAINS[number]; + const MAX_FIELD_CHARS = 400; export const ROLE_SIZING_SYSTEM_PROMPT = [ @@ -66,6 +70,15 @@ export const ROLE_SIZING_SYSTEM_PROMPT = [ "or a strong model needs only a quick call. Effort buys deliberation, never capability, and more", "effort is not more correct.", "", + "Task domain names the kind of capability the tier is measured in. It sizes nothing; it tells the", + "mapping which measured strength matters, because models of one price class differ by domain:", + "- coding: writing, changing, or reviewing code in a repository.", + "- agentic: long multi-step tool use, where the work is completing a run of actions without derailing.", + "- reasoning: math, logic, planning, or analysis whose answer is derived rather than looked up.", + "- writing: prose meant for a reader: documents, messages, copy.", + "- knowledge: research and factual recall across sources.", + "- general: none of the above dominates. Pick it rather than guess.", + "", "Assess risk and complexity once for both dials:", "- file, module, or ownership boundary crossing;", "- reversibility and blast radius;", @@ -86,8 +99,8 @@ export const ROLE_SIZING_SYSTEM_PROMPT = [ "- Use only the neutral words above. Do not name concrete model products, vendors, versions, or vendor effort levels anywhere in the answer.", "", "Answer with one JSON object and nothing else, no prose and no code fence:", - '{"roles":{"":{"tier":"fast|standard|frontier","effort":"glance|measured|thorough|exhaustive","rationale":"","move_up_if":"","move_down_if":""}}}', - "Include every role you were given, keyed by its exact name, with exactly those five fields.", + '{"roles":{"":{"tier":"fast|standard|frontier","effort":"glance|measured|thorough|exhaustive","domain":"coding|agentic|reasoning|writing|knowledge|general","rationale":"","move_up_if":"","move_down_if":""}}}', + "Include every role you were given, keyed by its exact name, with exactly those six fields.", ].join("\n"); /** @@ -105,6 +118,7 @@ export const DELEGATED_WORK_SIZING_SYSTEM_PROMPT = [ export interface RoleSizing { readonly tier: SizingTier; readonly effort: SizingEffort; + readonly domain: SizingDomain; readonly rationale: string; readonly moveUpIf: string; readonly moveDownIf: string; @@ -167,13 +181,16 @@ function validateEntry(entry: unknown): RoleSizingOutcome { const fields = entry as Record; if (!isMember(SIZING_TIERS, fields.tier)) return { unsized: "the sizing answer has no valid tier" }; if (!isMember(SIZING_EFFORTS, fields.effort)) return { unsized: "the sizing answer has no valid effort" }; + // A missing or unknown domain still sizes the role on the overall scale: the domain only picks + // which measured capability ranks the candidates, and "general" is the rubric's own no-signal answer. + const domain = isMember(SIZING_DOMAINS, fields.domain) ? fields.domain : "general"; const rationale = oneLine(fields.rationale); const moveUpIf = oneLine(fields.move_up_if); const moveDownIf = oneLine(fields.move_down_if); if (rationale === null || moveUpIf === null || moveDownIf === null) { return { unsized: "the sizing answer is missing a rationale or a move trigger" }; } - return { sizing: { tier: fields.tier, effort: fields.effort, rationale, moveUpIf, moveDownIf } }; + return { sizing: { tier: fields.tier, effort: fields.effort, domain, rationale, moveUpIf, moveDownIf } }; } const CODE_FENCE = /^\x60{3}(?:json)?[ \t]*\r?\n([\s\S]*?)\r?\n[ \t]*\x60{3}$/i; @@ -183,9 +200,10 @@ const CODE_FENCE = /^\x60{3}(?:json)?[ \t]*\r?\n([\s\S]*?)\r?\n[ \t]*\x60{3}$/i; * * The answer must be one JSON object of the shape the rubric asks for. Two leniencies, both for * content the mapping never reads: a single surrounding code fence, which models add out of habit, - * and keys beyond the five fields in a role's entry, which are ignored. Every one of the five must - * still be present and valid. Anything else wrong marks the affected role, or every role when the - * whole answer is unusable, as unsized with the reason. + * and keys beyond the six fields in a role's entry, which are ignored. Tier, effort, rationale and + * both move triggers must still be present and valid; a missing domain falls back to "general". + * Anything else wrong marks the affected role, or every role when the whole answer is unusable, + * as unsized with the reason. */ export function parseRoleSizingResponse(text: string, roles: readonly string[]): Map { const out = new Map(); diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index 9d3557f0fc2..b57c3efa537 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -324,6 +324,14 @@ export const configSchema = z.object({ standard: z.array(z.string().trim().min(1)).optional(), frontier: z.array(z.string().trim().min(1)).optional(), }).strict().optional().catch(undefined), + // Discard the retired source while preserving an otherwise valid Artificial Analysis key. + codexRoleCapability: z.preprocess(value => { + if (!value || typeof value !== "object" || Array.isArray(value)) return value; + const { huggingFace: _retired, ...rest } = value as Record; + return rest; + }, z.object({ + artificialAnalysis: z.object({ apiKey: z.string().trim().min(1).optional() }).strict().optional(), + }).strict().optional().catch(undefined)), codexShimAutoRestore: z.boolean().optional(), codexDesktopAuthless: z.boolean().optional().catch(undefined), codexClientCompaction: z.boolean().optional().catch(undefined), diff --git a/src/server/configured-credentials.ts b/src/server/configured-credentials.ts index f1143938446..2b44fe778aa 100644 --- a/src/server/configured-credentials.ts +++ b/src/server/configured-credentials.ts @@ -163,6 +163,11 @@ function build(configRead: StoreRead, authRead: StoreRead, signature: string, no || (config.value.providers !== undefined && !isRecord(config.value.providers)))) complete = false; if (authSig !== "absent" && auth.ok && !isRecord(auth.value)) complete = false; if (isRecord(config.value) && isRecord(config.value.providers)) walkProvider(config.value.providers, 0); + if (isRecord(config.value) && isRecord(config.value.codexRoleCapability) + && isRecord(config.value.codexRoleCapability.artificialAnalysis)) { + const key = config.value.codexRoleCapability.artificialAnalysis.apiKey; + addReference(typeof key === "string" ? key.trim() : key); + } if (isRecord(auth.value)) walkAuth(auth.value, 0); const names = [...envNames].sort(); diff --git a/src/server/management/codex-agent-role-routes.ts b/src/server/management/codex-agent-role-routes.ts index d9d55614dce..d8e80d911da 100644 --- a/src/server/management/codex-agent-role-routes.ts +++ b/src/server/management/codex-agent-role-routes.ts @@ -82,6 +82,7 @@ export async function handleCodexAgentRoleRoutes(ctx: ManagementContext): Promis ...(typeof body?.model === "string" ? { sizingModel: body.model } : {}), fetchAllModels: deps.fetchAllModels ?? fetchAllModels, ...(deps.completeCodexRoleSizing ? { completeRoleSizing: deps.completeCodexRoleSizing } : {}), + ...(deps.loadCodexRoleCapability ? { loadCapability: deps.loadCodexRoleCapability } : {}), }), 200, req, config); } catch (error) { if (error instanceof NoSizingModelError) { diff --git a/src/server/management/codex-role-auto-assign.ts b/src/server/management/codex-role-auto-assign.ts index f9f2381ec4f..c951fabfc2b 100644 --- a/src/server/management/codex-role-auto-assign.ts +++ b/src/server/management/codex-role-auto-assign.ts @@ -9,6 +9,8 @@ import { join } from "node:path"; import type { OcxConfig } from "../../types"; import type { CatalogModel } from "../../codex/catalog"; import type { RoleModelCandidate } from "../../codex/role-auto-assign"; +import type { LoadCapability } from "../../codex/role-capability"; +import type { SizingDomain } from "../../codex/role-sizing"; export interface RoleSizingCall { readonly model: string; @@ -118,11 +120,13 @@ export async function proposeCodexRoleModels(options: { sizingModel?: string; fetchAllModels: (config: OcxConfig) => Promise; completeRoleSizing?: CompleteRoleSizing; + loadCapability?: LoadCapability; }) { - const [roles, sizing, assign, { readConfiguredDefaultModel }] = await Promise.all([ + const [roles, sizing, assign, capability, { readConfiguredDefaultModel }] = await Promise.all([ import("../../codex/agent-role-models"), import("../../codex/role-sizing"), import("../../codex/role-auto-assign"), + import("../../codex/role-capability"), import("../../codex/catalog/parsing"), ]); const sizingModel = resolveSizingModel(options.sizingModel, readConfiguredDefaultModel); @@ -159,15 +163,55 @@ export async function proposeCodexRoleModels(options: { } } - const classified = assign.classifyRoleModelCandidates( - await roleModelCandidates(options.config, await options.fetchAllModels(options.config)), - options.config.codexRoleTiers, - ); + const candidates = await roleModelCandidates(options.config, await options.fetchAllModels(options.config)); + const classified = assign.classifyRoleModelCandidates(candidates, options.config.codexRoleTiers); + // Measured capability is a better tier signal than price, and its absence is not an error: a + // failed read leaves every role on price rank and says so. + const domains = new Set([...outcomes.values()].flatMap(outcome => "sizing" in outcome ? [outcome.sizing.domain] : [])); + let table: import("../../codex/role-capability").CapabilityTable | null = null; + let capabilityError: string | null = null; + if (domains.size > 0) { + try { + table = await (options.loadCapability ?? capability.loadCapabilityTable)(options.config); + } catch { + capabilityError = "the measured capability source could not be read"; + } + } + const classifiedByDomain = new Map>(); + for (const domain of domains) { + const scores = table?.domains[domain]; + if (!scores) continue; + const measured = new Map(candidates.flatMap(candidate => { + const score = scores.get(capability.candidateModelKey(candidate.model)); + return score === undefined ? [] : [[candidate.model, score] as const]; + })); + classifiedByDomain.set(domain, assign.classifyRoleModelCandidates(candidates, options.config.codexRoleTiers, measured)); + } return { sizingModel, sizingError, - proposals: assign.buildRoleProposals(roleRows, outcomes, classified), + proposals: assign.buildRoleProposals(roleRows, outcomes, classified, classifiedByDomain), candidates: classified.map(({ model, tier, tierSource, unitPrice }) => ({ model, tier, tierSource, unitPrice })), + capability: capabilityReport(table?.source ?? null, table, capabilityError, classifiedByDomain), + }; +} + +/** The attribution and per-domain tiers; raw scores only from a source whose terms allow it. */ +function capabilityReport( + source: import("../../codex/role-capability").CapabilitySource | null, + table: import("../../codex/role-capability").CapabilityTable | null, + error: string | null, + classifiedByDomain: ReadonlyMap>, +) { + return { + name: source?.name ?? null, + url: source?.url ?? null, + license: source?.license ?? null, + publishedAt: table?.publishedAt ?? null, + error: error ?? table?.fallbackReason ?? null, + domains: Object.fromEntries([...classifiedByDomain].map(([domain, list]) => [domain, + list.filter(candidate => candidate.tierSource === "measured") + .map(({ model, tier, score }) => (source?.redistributable ? { model, tier, score } : { model, tier }))])), }; } diff --git a/src/server/management/context.ts b/src/server/management/context.ts index b12ec76f27b..f25685d28e6 100644 --- a/src/server/management/context.ts +++ b/src/server/management/context.ts @@ -91,6 +91,7 @@ export interface ManagementApiDeps { fetchAllModels?: (config: OcxConfig) => Promise; /** Codex role auto-assign's one sizing model call; route tests answer it without a provider. */ completeCodexRoleSizing?: import("./codex-role-auto-assign").CompleteRoleSizing; + loadCodexRoleCapability?: import("../../codex/role-capability").LoadCapability; /** * Writer seam for the Grok toggle: lets a test place the file in any state * between the pre-write recheck and the write itself (the r8 post-inspection diff --git a/src/types/config.ts b/src/types/config.ts index 9a0e49e8aba..10fcdcff171 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -770,6 +770,11 @@ export interface OcxConfig { * classification. Models no list names and no price covers are never proposed. */ codexRoleTiers?: { fast?: string[]; standard?: string[]; frontier?: string[] }; + /** + * Measured-capability source for Codex role auto-assign. With an Artificial Analysis API key + * (a literal, ${ENV} or keychain reference) its indexes inform measured tiers. + */ + codexRoleCapability?: { artificialAnalysis?: { apiKey?: string } }; /** * TTL (ms) for cached sub-agent model availability probes. Default 60_000. */ diff --git a/structure/config.md b/structure/config.md index d5203ed40cb..7f6345e6529 100644 --- a/structure/config.md +++ b/structure/config.md @@ -1,5 +1,5 @@ # Config Surface -See [local timeout policy and OpenCode export](transports/timeouts.md) and default-off [`completeOnMaxOutputTokens`](transports/max-output-completion.md). Pasted GCP JSON persists only as `gcp-sa:` references in one pool transaction in `src/providers/api-keys.ts`; post-commit deletion follows [Vertex credential markers](providers/google.md#vertex-credential-markers). See [LM Studio Chat instruction configuration](providers/lm-studio-chat.md). +See [local timeout policy and OpenCode export](transports/timeouts.md) and default-off [`completeOnMaxOutputTokens`](transports/max-output-completion.md). Pasted GCP JSON persists only as `gcp-sa:` references in one pool transaction in `src/providers/api-keys.ts`; post-commit deletion follows [Vertex credential markers](providers/google.md#vertex-credential-markers). See [LM Studio Chat instruction configuration](providers/lm-studio-chat.md). `codexRoleCapability` in `src/config/schema/config-schema.ts` and `src/types/config.ts` follows the [capability source policy](subagents.md#role-model-auto-assign): only Artificial Analysis supplies measured scores, and the retired `huggingFace` key is ignored. `src/config/schema/leaf-validators.ts` accepts only boolean `apiKeys[].allowMeteredComboTargets`; malformed entries are dropped rather than promoted. `src/types/config.ts` and combo validation define optional boolean target `metered`, normalized false when omitted, and optional per-combo `allowMeteredTargets` (default false, literal boolean validation shared by config schema and management API). Explicit consent is required under the [combo runtime contract](runtime.md#request-local-target-compatibility). Model-price overrides follow the [pricing contract](pricing.md). Quota activation reuses the existing next-reset fields without adding a polling configuration key. See the [quota activation contract](providers/openai-tiers.md#public-provider-contract). `src/types/config.ts` defines `anthropicAccountPool.routes` as ordered model rules. `src/oauth/anthropic-model-routes.ts` validates bounded names, full case-sensitive globs and stored account IDs; `src/config/diagnostics.ts` rejects malformed candidate writes. Load retains a malformed hand edit so unrelated providers survive, while the enabled Anthropic selector refuses it locally before dispatch. Saved rules remain inert when the pool is disabled; affinity is process-local. Request logs use only the rule’s 1-based `route:#` position, never its configured name. `src/cli/hub-disable.ts` removes interdependent hub/loopback/role/hostname settings through `mutatePersistedConfig` and `validateConfigCandidate` in one commit. Top-level deletion intent uses `src/config/rebase-provenance.ts` and authoritative disk provenance in `src/config/live-reconcile.ts` to preserve removed hub bindings through later live saves, `src/config/live-topology.ts` keeps an independent active-topology baseline and merges desired next-start settings into a separate persistence projection, retaining actual socket identity and live hub/client topology during guarded saves and OAuth disk adoption. Client connections and runtimeRole remain one topology unit through connect/disconnect, including fresh disk-only connections. Disk additions/value updates and deletions all survive repeated saves without changing active topology. `src/config/api-key-reconcile.ts` merges key rows by stable ID under the existing save lock; either side's revocation defeats stale edits while disjoint names and rotations merge; unchanged live key membership/order adopts persisted ordering. `tests/config/hub-disable-live-save.test.ts` exercises disable followed by live key creation, rename, rotation, unrelated settings and repeated saves. Saved API keys have no immutable enrollment marker; explicit exact-ID revocation preserves all unselected keys. The WorkBuddy AI account row pins its adapter and origin under the [WorkBuddy contract](providers/workbuddy.md). JEV `decisionQuotaSignals` and partial `decisionQuotaTiers` use the shared validator and sparse preserve/clear/strategy-switch contract in [loaded advisory quota](providers/jev-decision.md#loaded-advisory-quota); the opt-in JEV `sessionPin` follows the same contract under [session pin](providers/jev-decision.md#session-pin). Native reasoning retention follows [its optional configuration contract](transports/native-reasoning-retention.md#configuration-and-management). Native function-result injection follows [the separate opt-in control contract](transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. diff --git a/structure/subagents.md b/structure/subagents.md index 9db8055c568..abfb582c73f 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -356,26 +356,33 @@ the same refusal as the PUT. Applying a proposal is the ordinary is not sent. The call goes through the proxy's own `/v1/chat/completions` via `postLocalChatCompletion` (`src/lib/local-chat-completion.ts`, shared with the routed vision describer), on the root `model` of Codex `config.toml` unless the caller names one. An answer that is not the strict JSON - shape, a missing role, or a field outside the vocabulary leaves that role **unsized** with the reason; - nothing is guessed. Keys beyond the five answer fields are ignored, and all five stay required. The + shape, a missing role, or a tier or effort outside the vocabulary leaves that role **unsized** with the reason; + a missing or unknown domain defaults to general. Keys beyond the six answer fields are ignored; the other five stay required. The sizing model never names a model. -- **Mapping is deterministic code.** `src/codex/role-auto-assign.ts` draws candidates from - `subagentSelectableModels` (the same list the role picker renders) and tiers them: `codexRoleTiers` in the - opencodex config first, then price rank (input plus output per 1M tokens). Three or more priced models - split evenly across the three tiers. Fewer are anchored at the top, the dearest frontier and each cheaper - one a tier lower, so one is frontier and two are standard and frontier; fast is the tier left empty, - which keeps a standard role on the cheaper model. Unpriced, unmapped models are never proposed. A role - gets the lowest sufficient tier, then the lowest price, then the model it already uses when that model is - in the tie, then picker order. Effort binds to ladder positions of the chosen - model (floor, default or middle, the rung above, ceiling), collapsing inside the range; it is proposed - for every sized role whose chosen model has a non-empty ladder. Apply and `--apply` insert or - replace `model_reasoning_effort` through the same span-preserving editor as `model`, located by - the same TOML-aware scan. An empty ladder gives `proposedEffort: null`, meaning no proposal; - Apply omits `effort` and preserves the existing pin. A null proposal never means a null PUT. A routed - model's ladder is its row's - `reasoningEfforts`; a row that declares none takes the ladder the written Codex catalog shows for it, an - explicit empty list stays empty, and a row without its own default takes the catalog's default when that - rung is on the ladder. +- **Mapping is deterministic code.** `src/codex/role-auto-assign.ts` draws candidates from `subagentSelectableModels` (the + same list the role picker renders) and tiers them: `codexRoleTiers` in the opencodex config first, then measured score in + the role's task domain, then price rank (input plus output per 1M tokens). The sizing answer carries a neutral `domain` + (coding, agentic, reasoning, writing, knowledge, general; missing means general), and `src/codex/role-capability.ts` reads + that domain's scores from Artificial Analysis once per UTC day when `codexRoleCapability.artificialAnalysis.apiKey` + resolves; the source refuses redirects and keeps a 30-second timeout and a 16 MiB body bound. With no key or on failure it + keeps configured tiers and price signals, reports the reason through `capability.error`, and returns null source + attribution. The `capability` block attributes the source; Artificial Analysis terms forbid redistribution, so its scores + are dropped from that block. Mapped models keep their mapped tier either way. With two or more measured unmapped + candidates, those are tiered by distinct score values (equal scores share a tier) without requiring prices, and unmeasured + unmapped ones are not proposed; otherwise price rank decides. `capability.error` is set only when no key is configured or + the source cannot be read; a source that answered but matched fewer than two candidates leaves it null, and a + proposal picked by price rank carries `tierSource: "price"`. Three or more priced models split evenly across the three + tiers. Fewer are anchored at the top, the dearest frontier and each cheaper one a tier lower, so one is frontier and two + are standard and frontier; fast is the tier left empty, which keeps a standard role on the cheaper model. Price tiering + excludes unpriced, unmapped models. A role gets the lowest sufficient tier, then the lowest price, then the higher + measured score, then the model it already uses when that model is in the tie, then picker order. Effort binds to ladder + positions of the chosen model (floor, default or middle, the rung above, ceiling), collapsing inside the range; it is + proposed for every sized role whose chosen model has a non-empty ladder. Apply and `--apply` insert or replace + `model_reasoning_effort` through the same span-preserving editor as `model`, located by the same TOML-aware scan. An empty + ladder gives `proposedEffort: null`, meaning no proposal; Apply omits `effort` and preserves the existing pin. A null + proposal never means a null PUT. A routed model's ladder is its row's `reasoningEfforts`; a row that declares none takes + the ladder the written Codex catalog shows for it, an explicit empty list stays empty, and a row without its own default + takes the catalog's default when that rung is on the ladder. The sizing call is injectable (`completeCodexRoleSizing` on the management deps), so route tests answer it without a provider. Sibling instances refuse the preview too: it sits under the refused diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 69b0093f255..7939d5f9243 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,5 @@ { "lmstudio-system-policy.test.ts": "config", "openai-chat-single-system.test.ts": "adapters/openai", "update-reclaim-authorizer.test.ts": "update", "update-capability-policy.test.ts": "update", "update-service-home.test.ts": "update", "update-service-home-policy.test.ts": "update", "update-home-recovery.test.ts": "update", "update-recovery-authority.test.ts": "update", "update-recovery-boundaries.test.ts": "update", "update-home-launcher.test.ts": "update", "update-home-worker.test.ts": "update", "update-mutation-boundaries.test.ts": "update", "update-mutation-guard.test.ts": "update", "update-guarded-ops.test.ts": "update", "update-effect-authority.test.ts": "update", "update-windows-wrapper-authority.test.ts": "update", "update-mutation-scanner.test.ts": "update", "refreshed-upstream-deadlines.test.ts": "responses", "local-upstream-deadlines.test.ts": "responses", "service-desktop-recovery.test.ts": "service", "provider-input-modalities.test.ts": "providers", "turn-membership.test.ts": "responses", "turn-membership-ingress.test.ts": "responses", "workbuddy-quota.test.ts": "providers", "workbuddy-login.test.ts": "oauth", "workbuddy-adapter.test.ts": "adapters", "workbuddy-provider.test.ts": "providers", "link-tunnel-history.test.ts": "clients", "link-readiness.test.ts": "clients", "link-diagnostics.test.ts": "clients", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "responses-phase-inference.test.ts": "responses", + "codex-role-configured-credentials.test.ts": "server", "advisor-caller-policy.test.ts": "advisor", "advisor-caller-policy-sidecar-authority.test.ts": "advisor", "advisor-caller-policy-vision.test.ts": "advisor", @@ -1379,6 +1380,9 @@ "subagent-model-fallback-api.test.ts": "routing", "subagent-model-fallback.test.ts": "routing", "codex-agent-role-models.test.ts": "routing", "codex-role-sizing.test.ts": "routing", "codex-role-auto-assign.test.ts": "routing", "omo-role-models.test.ts": "clients", + "codex-role-capability-consent.test.ts": "routing", + "codex-role-capability-redirects.test.ts": "routing", + "codex-role-measured-ties.test.ts": "routing", "lazycodex-detection.test.ts": "clients", "codex-agent-role-routes.test.ts": "server", "codex-role-history-recovery.test.ts": "server", "codex-role-auto-assign-routes.test.ts": "server", "local-chat-completion.test.ts": "lib", diff --git a/tests/routing/codex-role-auto-assign.test.ts b/tests/routing/codex-role-auto-assign.test.ts index c4157b832e9..341a193d2e4 100644 --- a/tests/routing/codex-role-auto-assign.test.ts +++ b/tests/routing/codex-role-auto-assign.test.ts @@ -6,6 +6,7 @@ import { mapEffortToLevel, type RoleModelCandidate, } from "../../src/codex/role-auto-assign"; +import { candidateModelKey, capabilityTableFromArtificialAnalysis, readBoundedBytes } from "../../src/codex/role-capability"; import type { RoleSizingOutcome } from "../../src/codex/role-sizing"; const FULL = ["low", "medium", "high", "xhigh"]; @@ -73,6 +74,23 @@ describe("classifyRoleModelCandidates", () => { expect(cheapestSufficientCandidate(classified, "standard")!.model).toBe("cheap"); expect(cheapestSufficientCandidate(classified, "frontier")!.model).toBe("dear"); }); + + test("measured scores tier the candidates and leave unmeasured ones out", () => { + const classified = classifyRoleModelCandidates( + [candidate("cheap-strong", 1), candidate("dear-weak", 9), candidate("mid", 5), candidate("unmeasured", 2)], + {}, + new Map([["cheap-strong", 1500], ["dear-weak", 1400], ["mid", 1450]]), + ); + expect(classified.map(c => [c.model, c.tier, c.tierSource])).toEqual([ + ["cheap-strong", "frontier", "measured"], ["dear-weak", "fast", "measured"], ["mid", "standard", "measured"], ["unmeasured", null, null], + ]); + expect(cheapestSufficientCandidate(classified, "frontier")!.model).toBe("cheap-strong"); + }); + + test("one measured candidate is not a ranking, so price decides", () => { + const classified = classifyRoleModelCandidates([candidate("a", 1), candidate("b", 9)], {}, new Map([["a", 1500]])); + expect(classified.map(c => [c.model, c.tier, c.tierSource])).toEqual([["a", "standard", "price"], ["b", "frontier", "price"]]); + }); }); describe("cheapestSufficientCandidate", () => { @@ -100,7 +118,7 @@ describe("cheapestSufficientCandidate", () => { describe("buildRoleProposals", () => { const sized = (tier: "fast" | "standard" | "frontier", effort: "glance" | "exhaustive"): RoleSizingOutcome => ({ - sizing: { tier, effort, rationale: "r", moveUpIf: "u", moveDownIf: "d" }, + sizing: { tier, effort, domain: "general", rationale: "r", moveUpIf: "u", moveDownIf: "d" }, }); test("proposes an effort for every sized role, set or not, and keeps unsized reasons", () => { @@ -137,6 +155,25 @@ describe("buildRoleProposals", () => { expect(proposals.map(p => "proposedModel" in p ? p.proposedModel : null)).toEqual(["routed-mid", "native-mid", "routed-top"]); }); + test("ranks a role by its domain's scores and breaks an equal price by the higher score", () => { + const pool = [candidate("native-low", 0.6), candidate("routed-low", 0.6), candidate("native-mid", 12), candidate("routed-mid", 12), candidate("routed-top", 24), candidate("native-top", 60)]; + const coding = classifyRoleModelCandidates(pool, {}, new Map([ + ["native-low", 1400], ["routed-low", 1410], ["native-mid", 1480], ["routed-mid", 1490], ["native-top", 1500], ["routed-top", 1545], + ])); + const outcome = (tier: "fast" | "standard" | "frontier", domain: "coding" | "writing"): RoleSizingOutcome => ({ + sizing: { tier, effort: "glance", domain, rationale: "r", moveUpIf: "u", moveDownIf: "d" }, + }); + const proposals = buildRoleProposals( + [{ role: "low", model: null, effort: null }, { role: "mid", model: "native-mid", effort: null }, { role: "top", model: null, effort: null }, { role: "prose", model: null, effort: null }], + new Map([["low", outcome("fast", "coding")], ["mid", outcome("standard", "coding")], ["top", outcome("frontier", "coding")], ["prose", outcome("frontier", "writing")]]), + classifyRoleModelCandidates(pool), + new Map([["coding", coding]]), + ); + expect(proposals.map(p => "proposedModel" in p ? [p.proposedModel, p.tierSource] : null)).toEqual([ + ["routed-low", "measured"], ["routed-mid", "measured"], ["routed-top", "measured"], ["routed-top", "price"], + ]); + }); + test("reports a role no candidate can serve as unassigned", () => { const [proposal] = buildRoleProposals( [{ role: "architect", model: null, effort: null }], @@ -146,3 +183,30 @@ describe("buildRoleProposals", () => { expect(proposal).toMatchObject({ status: "unassigned", proposedModel: null, reason: "no available model is classified frontier or above" }); }); }); + +describe("capability response bounds", () => { + test("reads a leaderboard table up to the byte bound and refuses one past it", async () => { + const body = () => new Response(new ReadableStream({ + start(controller) { controller.enqueue(new Uint8Array(6)); controller.enqueue(new Uint8Array(6)); controller.close(); }, + })); + expect((await readBoundedBytes(body(), 12)).byteLength).toBe(12); + await expect(readBoundedBytes(body(), 11)).rejects.toThrow("response exceeded byte bound"); + }); +}); + +describe("capabilityTableFromArtificialAnalysis", () => { + test("reads each domain's index, falls back to the intelligence index, and matches catalog slugs", () => { + const table = capabilityTableFromArtificialAnalysis({ + status: 200, + data: [ + { name: "Claude Opus 5.5 (Reasoning)", slug: "claude-opus-5-5-reasoning", evaluations: { artificial_analysis_intelligence_index: 71, artificial_analysis_coding_index: 66 } }, + { name: "Claude Opus 5.5", slug: "claude-opus-5-5", evaluations: { artificial_analysis_intelligence_index: 64, artificial_analysis_coding_index: 58 } }, + { name: "GPT-6.1 Sol", slug: "gpt-6-1-sol", evaluations: { artificial_analysis_intelligence_index: 73, artificial_analysis_coding_index: 61 } }, + ], + }); + expect(table.source?.redistributable).toBe(false); + expect([candidateModelKey("cpa/claude-opus-5-5"), candidateModelKey("gpt-6.1-sol")].map(key => table.domains.coding!.get(key))).toEqual([66, 61]); + expect(table.domains.agentic!.get(candidateModelKey("gpt-6.1-sol"))).toBe(73); + expect(() => capabilityTableFromArtificialAnalysis({ error: "API key is required" })).toThrow(); + }); +}); diff --git a/tests/routing/codex-role-capability-consent.test.ts b/tests/routing/codex-role-capability-consent.test.ts new file mode 100644 index 00000000000..ca3de7b84f6 --- /dev/null +++ b/tests/routing/codex-role-capability-consent.test.ts @@ -0,0 +1,59 @@ +import { afterEach, expect, test } from "bun:test"; +import { configSchema } from "../../src/config/schema/config-schema"; +import { getDefaultConfig, loadConfig } from "../../src/config"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadCapabilityTable } from "../../src/codex/role-capability"; +import type { OcxConfig } from "../../src/types"; + +const originalFetch = globalThis.fetch; +afterEach(() => { globalThis.fetch = originalFetch; }); +const config = (value: unknown) => ({ codexRoleCapability: configSchema.shape.codexRoleCapability.parse(value) }) as OcxConfig; + +test("only Artificial Analysis is requested and missing keys produce unattributed fallback", async () => { + const requests: string[] = []; + globalThis.fetch = Object.assign(async (input: string | URL | Request) => { + requests.push(String(input)); + return Response.json({ data: [{ name: "Fixture model", evaluations: { artificial_analysis_coding_index: 42 } }] }); + }, { preconnect: originalFetch.preconnect }) as typeof fetch; + const empty = await loadCapabilityTable(config(undefined)); + expect(empty).toMatchObject({ source: null, domains: {}, fallbackReason: "benchmark data was unavailable: no Artificial Analysis key is configured" }); + expect(requests).toEqual([]); + const table = await loadCapabilityTable(config({ artificialAnalysis: { apiKey: "aa-consent-fixture" } })); + expect(requests).toEqual(["https://artificialanalysis.ai/api/v2/data/llms/models"]); + expect(table.source?.name).toBe("Artificial Analysis"); + expect(table.domains.coding?.get("fixture-model")).toBe(42); +}); + +test("retired source key is ignored without dropping the Artificial Analysis key", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-aa-legacy-")); + const old = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = dir; + try { + writeFileSync(join(dir, "config.json"), JSON.stringify({ ...getDefaultConfig(), codexRoleCapability: { artificialAnalysis: { apiKey: " aa-legacy-fixture " }, huggingFace: { enabled: true } } })); + expect(loadConfig().codexRoleCapability).toEqual({ artificialAnalysis: { apiKey: "aa-legacy-fixture" } }); + } finally { + if (old === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = old; + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("any other malformed capability block is dropped whole and reported as no configured key", async () => { + // Same convention as the other strict optional blocks: a malformed block is never partially + // trusted. The proposal then reports the missing key instead of reading with a guessed one. + const parse = (value: unknown) => configSchema.shape.codexRoleCapability.parse(value); + expect(parse({ artificialAnalysis: { apiKey: "aa-fixture" }, unknownSource: { enabled: true } })).toBeUndefined(); + expect(parse({ artificialAnalysis: { apiKey: "aa-fixture", extra: 1 } })).toBeUndefined(); + expect(parse({ artificialAnalysis: { apiKey: 42 } })).toBeUndefined(); + expect(parse({ artificialAnalysis: { apiKey: " " } })).toBeUndefined(); + expect(parse("aa-fixture")).toBeUndefined(); + const requests: string[] = []; + globalThis.fetch = Object.assign(async (input: string | URL | Request) => { + requests.push(String(input)); + return Response.json({ data: [] }); + }, { preconnect: originalFetch.preconnect }) as typeof fetch; + const table = await loadCapabilityTable(config({ artificialAnalysis: { apiKey: "aa-fixture" }, unknownSource: {} })); + expect(table).toMatchObject({ source: null, fallbackReason: "benchmark data was unavailable: no Artificial Analysis key is configured" }); + expect(requests).toEqual([]); +}); diff --git a/tests/routing/codex-role-capability-redirects.test.ts b/tests/routing/codex-role-capability-redirects.test.ts new file mode 100644 index 00000000000..55fdd7d2243 --- /dev/null +++ b/tests/routing/codex-role-capability-redirects.test.ts @@ -0,0 +1,30 @@ +import { afterEach, expect, test } from "bun:test"; +import { fetchArtificialAnalysisTable, loadCapabilityTable } from "../../src/codex/role-capability"; +import type { OcxConfig } from "../../src/types"; + +const nativeFetch = globalThis.fetch; +const analysisUrl = "https://artificialanalysis.ai/api/v2/data/llms/models"; +afterEach(() => { globalThis.fetch = nativeFetch; }); + +for (const status of [302, 307]) { + test(`Artificial Analysis HTTP ${status} is refused without forwarding the key`, async () => { + const received: (string | null)[] = []; + const initial: (string | null)[] = []; + const sink = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch(request) { received.push(request.headers.get("x-api-key")); return new Response(); } }); + const first = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch(request) { initial.push(request.headers.get("x-api-key")); return new Response(null, { status, headers: { location: `${sink.url}target` } }); } }); + const requests: string[] = []; + const transport = Object.assign((input: string | URL | Request, init?: RequestInit) => { + requests.push(String(input)); + return nativeFetch(`${first.url}models`, init); + }, { preconnect: nativeFetch.preconnect }) as typeof fetch; + try { + await expect(fetchArtificialAnalysisTable("aa-redirect-fixture", transport)).rejects.toThrow(); + globalThis.fetch = transport; + const result = await loadCapabilityTable({ codexRoleCapability: { artificialAnalysis: { apiKey: `aa-redirect-${status}` } } } as OcxConfig); + expect(result).toMatchObject({ source: null, domains: {}, fallbackReason: "Artificial Analysis could not be read" }); + expect(requests).toEqual([analysisUrl, analysisUrl]); + expect(initial).toEqual(["aa-redirect-fixture", `aa-redirect-${status}`]); + expect(received).toEqual([]); + } finally { await first.stop(true); await sink.stop(true); } + }); +} diff --git a/tests/routing/codex-role-measured-ties.test.ts b/tests/routing/codex-role-measured-ties.test.ts new file mode 100644 index 00000000000..bc07f407e2e --- /dev/null +++ b/tests/routing/codex-role-measured-ties.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test"; +import { cheapestSufficientCandidate, classifyRoleModelCandidates, type RoleModelCandidate } from "../../src/codex/role-auto-assign"; + +const candidate = (model: string, unitPrice: number | null): RoleModelCandidate => ({ model, unitPrice, efforts: [] }); + +describe("measured role tiers", () => { + test("equal scores share a tier regardless of provider order", () => { + const pool = [candidate("native/model-a", 1), candidate("routed/model-a", 5), candidate("model-b", 9)]; + const scores = new Map([["native/model-a", 1500], ["routed/model-a", 1500], ["model-b", 1550]]); + for (const candidates of [pool, [...pool].reverse()]) { + const classified = classifyRoleModelCandidates(candidates, {}, scores); + expect(classified.find(row => row.model === "native/model-a")?.tier).toBe("standard"); + expect(classified.find(row => row.model === "routed/model-a")?.tier).toBe("standard"); + expect(classified.find(row => row.model === "model-b")?.tier).toBe("frontier"); + expect(cheapestSufficientCandidate(classified, "standard")?.model).toBe("native/model-a"); + } + }); + + test("all equal measured scores share the top-anchored tier", () => { + const classified = classifyRoleModelCandidates([candidate("a", 1), candidate("b", 2)], {}, new Map([["a", 50], ["b", 50]])); + expect(classified.map(row => [row.tier, row.tierSource])).toEqual([["frontier", "measured"], ["frontier", "measured"]]); + }); + + test("one measured candidate still falls back to price and explicit mappings still win", () => { + const classified = classifyRoleModelCandidates( + [candidate("a", 1), candidate("b", 9), candidate("mapped", null)], + { fast: ["mapped"] }, new Map([["a", 50], ["mapped", 100]]), + ); + expect(classified.map(row => [row.tier, row.tierSource])).toEqual([ + ["standard", "price"], ["frontier", "price"], ["fast", "mapping"], + ]); + }); + + test("measured tiering can propose unpriced models but price tiering cannot", () => { + const pool = [candidate("a", null), candidate("b", null)]; + const measured = classifyRoleModelCandidates(pool, {}, new Map([["a", 50], ["b", 100]])); + expect(cheapestSufficientCandidate(measured, "standard")?.model).toBe("a"); + expect(cheapestSufficientCandidate(measured, "frontier")?.model).toBe("b"); + expect(cheapestSufficientCandidate(classifyRoleModelCandidates(pool), "fast")).toBeNull(); + }); +}); diff --git a/tests/routing/codex-role-sizing.test.ts b/tests/routing/codex-role-sizing.test.ts index 9edd8888341..59c13bc013d 100644 --- a/tests/routing/codex-role-sizing.test.ts +++ b/tests/routing/codex-role-sizing.test.ts @@ -3,6 +3,7 @@ import { DELEGATED_WORK_SIZING_SYSTEM_PROMPT, ROLE_INSTRUCTIONS_EXCERPT_CHARS, ROLE_SIZING_SYSTEM_PROMPT, + SIZING_DOMAINS, SIZING_EFFORTS, SIZING_TIERS, buildRoleSizingUserMessage, @@ -19,15 +20,15 @@ const GOOD = { }; describe("role sizing rubric", () => { - test("names every tier and effort and asks for JSON without model names", () => { - for (const word of [...SIZING_TIERS, ...SIZING_EFFORTS]) expect(ROLE_SIZING_SYSTEM_PROMPT).toContain(word); + test("names every tier, effort and domain and asks for JSON without model names", () => { + for (const word of [...SIZING_TIERS, ...SIZING_EFFORTS, ...SIZING_DOMAINS]) expect(ROLE_SIZING_SYSTEM_PROMPT).toContain(word); expect(ROLE_SIZING_SYSTEM_PROMPT).toContain("Do not name concrete model products"); expect(ROLE_SIZING_SYSTEM_PROMPT).toContain('"move_down_if"'); }); test("the role rubric keeps its exact bytes, and one-shot work gets the rubric plus an addendum", () => { const digest = new Bun.CryptoHasher("sha256").update(ROLE_SIZING_SYSTEM_PROMPT).digest("hex"); - expect(digest).toBe("5b611bbea1629e217d9d3ef77aeb02a331d99b1aed1d7d0074082ac7a1c11ff4"); + expect(digest).toBe("c43da51d3b6acebaeff086993de9694c9b86a11f01bdaf4dd8c6629af71b0709"); expect(DELEGATED_WORK_SIZING_SYSTEM_PROMPT.startsWith(`${ROLE_SIZING_SYSTEM_PROMPT}\n\n`)).toBe(true); expect(DELEGATED_WORK_SIZING_SYSTEM_PROMPT).toContain("one-shot"); }); @@ -62,6 +63,7 @@ describe("parseRoleSizingResponse", () => { sizing: { tier: "standard", effort: "measured", + domain: "general", rationale: GOOD.rationale, moveUpIf: GOOD.move_up_if, moveDownIf: GOOD.move_down_if, @@ -70,6 +72,16 @@ describe("parseRoleSizingResponse", () => { } }); + test("reads the domain and sizes a role whose domain is missing or unknown on the general scale", () => { + const out = parseRoleSizingResponse(JSON.stringify({ + roles: { coder: { ...GOOD, domain: "coding" }, odd: { ...GOOD, domain: "vibes" }, bare: GOOD }, + }), ["coder", "odd", "bare"]); + expect(["coder", "odd", "bare"].map(role => { + const outcome = out.get(role)!; + return "sizing" in outcome ? outcome.sizing.domain : outcome.unsized; + })).toEqual(["coding", "general", "general"]); + }); + test("marks every role unsized when the answer is not the JSON shape", () => { for (const text of ["Sure! explorer is standard.", "[]", '{"explorer":{}}', 'Here: {"roles":{}}']) { const out = parseRoleSizingResponse(text, ["explorer", "worker"]); @@ -93,7 +105,7 @@ describe("parseRoleSizingResponse", () => { expect(out.get("missing")).toEqual({ unsized: "the sizing model did not size this role" }); }); - test("ignores keys beyond the five it reads and keeps them out of the sizing", () => { + test("ignores keys beyond the six it reads and keeps them out of the sizing", () => { const out = parseRoleSizingResponse(JSON.stringify({ roles: { explorer: { ...GOOD, confidence: 0.8, notes: ["cheap"] } }, }), ["explorer"]); @@ -101,6 +113,7 @@ describe("parseRoleSizingResponse", () => { sizing: { tier: "standard", effort: "measured", + domain: "general", rationale: GOOD.rationale, moveUpIf: GOOD.move_up_if, moveDownIf: GOOD.move_down_if, diff --git a/tests/server/codex-role-auto-assign-routes.test.ts b/tests/server/codex-role-auto-assign-routes.test.ts index 2c4c816b534..fd8e1656c94 100644 --- a/tests/server/codex-role-auto-assign-routes.test.ts +++ b/tests/server/codex-role-auto-assign-routes.test.ts @@ -3,6 +3,7 @@ import { mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from import { tmpdir } from "node:os"; import { join } from "node:path"; import { listCatalogNativeSlugs, type CatalogModel } from "../../src/codex/catalog"; +import { ARTIFICIAL_ANALYSIS_SOURCE, type LoadCapability } from "../../src/codex/role-capability"; import { ROLE_SIZING_SYSTEM_PROMPT } from "../../src/codex/role-sizing"; import { handleManagementAPI } from "../../src/server/management-api"; import type { RoleSizingCall } from "../../src/server/management/codex-role-auto-assign"; @@ -70,6 +71,7 @@ const deps = { calls.push(call); return answer; }, + loadCodexRoleCapability: (async () => ({ source: null, publishedAt: null, domains: {} })) as LoadCapability, }; async function call(path: string, init?: RequestInit) { @@ -84,6 +86,57 @@ function snapshot(): Record { } describe("POST /api/codex-agent-roles/auto-assign", () => { + test("Artificial Analysis success produces measured tiers without exposing scores", async () => { + const originalFetch = globalThis.fetch; + const urls: string[] = []; + globalThis.fetch = Object.assign(async (input: string | URL | globalThis.Request) => { + urls.push(String(input)); + return Response.json({ data: [ + { name: "small", evaluations: { artificial_analysis_coding_index: 66 } }, + { name: "big", evaluations: { artificial_analysis_coding_index: 58 } }, + ] }); + }, { preconnect: originalFetch.preconnect }) as typeof fetch; + answer = { text: JSON.stringify({ roles: { explorer: { ...sized("frontier", "glance"), domain: "coding" } } }) }; + try { + const response = await handleManagementAPI( + new Request("http://localhost/api/codex-agent-roles/auto-assign", { method: "POST", body: "{}" }), + new URL("http://localhost/api/codex-agent-roles/auto-assign"), + { ...config, codexRoleTiers: undefined, codexRoleCapability: { artificialAnalysis: { apiKey: "aa-success-route-fixture" } } }, + { ...deps, loadCodexRoleCapability: undefined }, + ); + const body = await response!.json(); + expect(urls).toEqual(["https://artificialanalysis.ai/api/v2/data/llms/models"]); + expect(body.capability.name).toBe("Artificial Analysis"); + expect(body.capability.domains.coding).toEqual([{ model: "stub/small", tier: expect.any(String) }, { model: "stub/big", tier: expect.any(String) }]); + expect(body.proposals.find((row: { role: string }) => row.role === "explorer")).toMatchObject({ tierSource: "measured", proposedModel: "stub/small" }); + } finally { globalThis.fetch = originalFetch; } + }); + test.each([false, true])("disabled benchmarks retain proposals and report unavailability (Artificial Analysis configured: %s)", async withAnalysis => { + const originalFetch = globalThis.fetch; + const requests: string[] = []; + globalThis.fetch = Object.assign(async (input: string | URL | globalThis.Request) => { + requests.push(String(input)); + return new Response("fixture failure", { status: 401 }); + }, { preconnect: originalFetch.preconnect }) as typeof fetch; + try { + const response = await handleManagementAPI( + new Request("http://localhost/api/codex-agent-roles/auto-assign", { method: "POST", body: "{}" }), + new URL("http://localhost/api/codex-agent-roles/auto-assign"), + { ...config, codexRoleCapability: withAnalysis ? { artificialAnalysis: { apiKey: "route-consent-fixture" } } : undefined }, + { ...deps, loadCodexRoleCapability: undefined }, + ); + expect(response!.status).toBe(200); + const body = await response!.json(); + expect(requests).toEqual(withAnalysis ? ["https://artificialanalysis.ai/api/v2/data/llms/models"] : []); + expect(body.capability.error).toBe(withAnalysis ? "Artificial Analysis could not be read" : "benchmark data was unavailable: no Artificial Analysis key is configured"); + expect(body.capability).toMatchObject({ name: null, url: null, license: null, publishedAt: null }); + expect(body.capability.domains).toEqual({}); + expect(body.proposals.find((row: { role: string }) => row.role === "explorer")).toMatchObject({ status: "proposed", proposedModel: "stub/small" }); + } finally { + globalThis.fetch = originalFetch; + } + }); + test("proposes a model and effort per role through one sizing call and writes nothing", async () => { const before = snapshot(); const result = await call("/api/codex-agent-roles/auto-assign", { method: "POST", body: "{}" }); @@ -101,6 +154,41 @@ describe("POST /api/codex-agent-roles/auto-assign", () => { expect(byRole.bare.status).toBe("unsized"); }); + test("tiers a role by its domain's measured scores, and an unreadable source leaves price rank in charge", async () => { + const pricedConfig = { ...config, codexRoleTiers: undefined } as unknown as OcxConfig; + answer = { text: JSON.stringify({ roles: { explorer: { ...sized("frontier", "glance"), domain: "coding" } } }) }; + const post = async (loadCodexRoleCapability: typeof deps.loadCodexRoleCapability) => { + const response = await handleManagementAPI( + new Request("http://localhost/api/codex-agent-roles/auto-assign", { method: "POST", body: "{}" }), + new URL("http://localhost/api/codex-agent-roles/auto-assign"), + pricedConfig, + { ...deps, loadCodexRoleCapability }, + ); + return await response!.json() as Record; + }; + const measured = await post(async () => ({ + source: ARTIFICIAL_ANALYSIS_SOURCE, + publishedAt: null, + domains: { coding: new Map([["small", 1550], ["big", 1450]]) }, + })); + const explorer = (measured.proposals as any[]).find(p => p.role === "explorer"); + expect(explorer).toMatchObject({ domain: "coding", proposedModel: "stub/small", tierSource: "measured" }); + expect(measured.capability).toMatchObject({ name: "Artificial Analysis", publishedAt: null, error: null }); + expect(measured.capability.domains.coding[0].score).toBeUndefined(); + const licensed = await post(async () => ({ + source: ARTIFICIAL_ANALYSIS_SOURCE, + publishedAt: null, + domains: { coding: new Map([["small", 66], ["big", 58]]) }, + })); + expect(licensed.capability).toMatchObject({ name: "Artificial Analysis", url: "https://artificialanalysis.ai" }); + expect(licensed.capability.domains.coding[0]).toEqual({ model: "stub/small", tier: expect.any(String) }); + expect(licensed.capability.redistributable).toBeUndefined(); + const failed = await post(async () => { throw new Error("offline at /home/secret"); }); + expect(failed.capability.error).toBe("the measured capability source could not be read"); + expect(JSON.stringify(failed)).not.toContain("/home/secret"); + expect((failed.proposals as any[]).find(p => p.role === "explorer").tierSource).not.toBe("measured"); + }); + test("a routed model that declares no levels takes the ladder and default Codex shows for it", async () => { const levels = ["low", "medium", "high", "xhigh"].map(effort => ({ effort, description: effort })); writeFileSync(join(root, "codex", "opencodex-catalog.json"), JSON.stringify({ diff --git a/tests/server/codex-role-configured-credentials.test.ts b/tests/server/codex-role-configured-credentials.test.ts new file mode 100644 index 00000000000..643208119d2 --- /dev/null +++ b/tests/server/codex-role-configured-credentials.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { configuredCredentials } from "../../src/server/configured-credentials"; +import { safeConfigDTO } from "../../src/server/auth-cors"; +import { handleManagementAPI } from "../../src/server/management-api"; +import { ManagementRequest } from "../helpers/management-auth"; +import { redactSecrets } from "../../src/lib/redact"; +import { diagnosticValueAllowed } from "../../src/server/request-log-terminal-status"; +import { setProviderKeychainEntryFactoryForTests } from "../../src/providers/api-key-resolve"; +import type { OcxConfig } from "../../src/types"; + +test("Artificial Analysis credentials are protected and refreshed", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-aa-credential-")); + const oldHome = process.env.OPENCODEX_HOME; + const oldKey = process.env.OCX_TEST_AA_KEY; + process.env.OPENCODEX_HOME = dir; + const write = (apiKey: string) => writeFileSync(join(dir, "config.json"), JSON.stringify({ providers: {}, codexRoleCapability: { artificialAnalysis: { apiKey } } })); + try { + write("aa-literal-secret-value"); + expect(configuredCredentials().matches("aa-literal-secret-value")).toBe(true); + process.env.OCX_TEST_AA_KEY = "aa-env-secret-one"; + write(" $OCX_TEST_AA_KEY "); + expect(configuredCredentials().matches("aa-env-secret-one")).toBe(true); + process.env.OCX_TEST_AA_KEY = "aa-env-secret-two"; + expect(configuredCredentials().matches("aa-env-secret-two")).toBe(true); + expect(configuredCredentials().matches("aa-env-secret-one")).toBe(false); + expect(diagnosticValueAllowed("harmless_diagnostic")).toBe(true); + expect(diagnosticValueAllowed("aa-env-secret-two")).toBe(false); + setProviderKeychainEntryFactoryForTests(() => ({ getPassword: () => "aa-keychain-secret", setPassword: () => {}, deletePassword: () => true })); + write(" keychain:aa-present "); + expect(configuredCredentials().matches("aa-keychain-secret")).toBe(true); + setProviderKeychainEntryFactoryForTests(() => ({ getPassword: () => null, setPassword: () => {}, deletePassword: () => true })); + write(" keychain:aa-missing "); + expect(configuredCredentials().complete).toBe(false); + const config = { providers: {}, codexRoleCapability: { artificialAnalysis: { apiKey: "aa-literal-secret-value" } } }; + expect(JSON.stringify(safeConfigDTO(config as OcxConfig))).not.toContain("aa-literal-secret-value"); + expect(JSON.stringify(redactSecrets(config))).not.toContain("aa-literal-secret-value"); + } finally { + setProviderKeychainEntryFactoryForTests(null); + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = oldHome; + if (oldKey === undefined) delete process.env.OCX_TEST_AA_KEY; else process.env.OCX_TEST_AA_KEY = oldKey; + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("GET /api/config does not expose the Artificial Analysis key", async () => { + const secret = "aa-management-fixture-secret"; + const config = { port: 10100, defaultProvider: "openai", providers: {}, codexRoleCapability: { artificialAnalysis: { apiKey: secret } } } as OcxConfig; + const url = new URL("http://localhost/api/config"); + const response = await handleManagementAPI(new ManagementRequest(url), url, config); + expect(response?.status).toBe(200); + expect(await response!.text()).not.toContain(secret); +}); From 3bda3e5fe074d67c1ac20f7380d32f43675d93e3 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 30 Oct 2026 22:12:09 +0900 Subject: [PATCH 73/84] test(codex): assert the cold keyring refusal kind and report its cause (#8001) The refused scenarios only required some error, so a setup failure before the policy read was indistinguishable from the refusal (windows 10/11 of lane=all run 41056253802: reads 0, error unknown). Each scenario now names its expected refusal and the child reports message, cause and code. Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> --- .../main-quota-provenance.test.ts | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/tests/codex-integration/main-quota-provenance.test.ts b/tests/codex-integration/main-quota-provenance.test.ts index 5494cd2c29d..11db5da67f4 100644 --- a/tests/codex-integration/main-quota-provenance.test.ts +++ b/tests/codex-integration/main-quota-provenance.test.ts @@ -351,13 +351,13 @@ describe("main policy quota durability and lifecycle", () => { for (const scenario of [ { name: "verified replacement", account: "fixture-main-b", tag: "known", selected: true }, - { name: "same account", account: "fixture-main-a", tag: "known", selected: false }, - { name: "untagged quota", account: "fixture-main-b", tag: "missing", selected: false }, - { name: "invalid quota tag", account: "fixture-main-b", tag: "invalid", selected: false }, - { name: "unknown RPC identity", account: "", tag: "known", selected: false }, - { name: "manually paused", account: "fixture-main-b", tag: "known", selected: false, paused: true }, + { name: "same account", account: "fixture-main-a", tag: "known", selected: false, refusal: "CodexMainAccountHardLockError" }, + { name: "untagged quota", account: "fixture-main-b", tag: "missing", selected: false, refusal: "CodexPoolAuthenticationError" }, + { name: "invalid quota tag", account: "fixture-main-b", tag: "invalid", selected: false, refusal: "CodexPoolAuthenticationError" }, + { name: "unknown RPC identity", account: "", tag: "known", selected: false, refusal: "CodexPoolAuthenticationError" }, + { name: "manually paused", account: "fixture-main-b", tag: "known", selected: false, paused: true, refusal: "CodexPoolAuthenticationError" }, { name: "startup file replacement", account: "fixture-main-b", tag: "known", selected: true, source: "file" }, - { name: "startup same-account file", account: "fixture-main-a", tag: "known", selected: false, source: "file" }, + { name: "startup same-account file", account: "fixture-main-a", tag: "known", selected: false, source: "file", refusal: "CodexMainAccountHardLockError" }, ]) { test(`cold keyring Pool preserves quota unless replacement is proven: ${scenario.name}`, () => { const writer = writerFor("fixture-main-a"); @@ -387,7 +387,7 @@ describe("main policy quota durability and lifecycle", () => { pausedCodexAccountIds: ${JSON.stringify(scenario.paused ? [MAIN] : [])}, providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" } } }; let selected = false; - let failure; + let failure, failureDetail; try { const result = await resolveCodexAuthContext(new Headers(), config, "pool", { nativeMainRefreshDependencies: { codexAuthStatus: async () => { reads++; return { authMethod: "chatgpt", authToken: token, requiresOpenaiAuth: true }; } }, @@ -395,8 +395,11 @@ describe("main policy quota durability and lifecycle", () => { beginCodexAccountSelection: () => ({ mainProfileDraining: false, claimMainProfile: () => true, release() {} }), }); selected = result.kind === "main-pool" && result.chatgptAccountId === account; - } catch (error) { failure = error.name; } - console.log(JSON.stringify({ fixture: "cold-keyring-quota", before, selected, failure, reads, initialized, + } catch (error) { + failure = error.name; + failureDetail = { message: String(error.message).slice(0, 200), cause: error.cause?.name, code: error.cause?.code ?? error.code }; + } + console.log(JSON.stringify({ fixture: "cold-keyring-quota", before, selected, failure, failureDetail, reads, initialized, legacy: getAccountQuota("__main__"), policy: getMainPolicyQuota(), stored: getAccountQuota("fixture-stored"), paused: config.pausedCodexAccountIds })); `; @@ -416,8 +419,11 @@ describe("main policy quota durability and lifecycle", () => { expect(result.legacy).toBeNull(); expect(result.policy).toBeNull(); } else { - expect(result.failure).toBeDefined(); - expect(result.reads).toBe(scenario.paused || scenario.source === "file" ? 0 : 1); + // The refusal must be the policy decision itself; any earlier setup failure (with no read) + // fails here with its message and cause instead of passing as a refusal. + expect({ failure: result.failure, reads: result.reads, detail: result.failureDetail }).toMatchObject({ + failure: scenario.refusal, reads: scenario.paused || scenario.source === "file" ? 0 : 1, + }); expect(result.legacy.weeklyPercent).toBe(100); if (scenario.account === "fixture-main-a") expect(result.policy.weeklyPercent).toBe(100); } From b53b5d26e22a8284fd7d46035432422d6643b05d Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 30 Oct 2026 22:13:52 +0900 Subject: [PATCH 74/84] test(cc-switch): finalize the F1/F2 fixture statements before cleanup (#8002) Like F3 (#7986), F1/F2 inserted with unfinalized prepare().run() statements, which keep the SQLite file open past close() on Windows and make the temp-tree cleanup fail with EBUSY. Use db.run(sql, ...params). Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> --- tests/providers/cc-switch-import.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/providers/cc-switch-import.test.ts b/tests/providers/cc-switch-import.test.ts index 106475de1bd..a4608177aba 100644 --- a/tests/providers/cc-switch-import.test.ts +++ b/tests/providers/cc-switch-import.test.ts @@ -827,7 +827,9 @@ describe("CC-Switch keyless provider import", () => { id TEXT NOT NULL, app_type TEXT NOT NULL, name TEXT NOT NULL, settings_config TEXT NOT NULL, website_url TEXT, notes TEXT, meta TEXT NOT NULL DEFAULT '{}', sort_index INTEGER, PRIMARY KEY (id, app_type) )`); - db.prepare("INSERT INTO providers (id, app_type, name, settings_config, website_url, notes, meta, sort_index) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)").run( + // db.run finalizes its statement; see F3 below for why an unfinalized prepare() breaks cleanup. + db.run( + "INSERT INTO providers (id, app_type, name, settings_config, website_url, notes, meta, sort_index) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", "dashscope-compat", "openai", WORD_CANARY, @@ -852,7 +854,8 @@ describe("CC-Switch keyless provider import", () => { "https://api.example.com/v1/%76ioletotter", `https://api.example.com/v1?key=${WORD_CANARY}`, ].entries()) { - db.prepare("INSERT INTO providers (id, app_type, name, settings_config, sort_index) VALUES (?1, ?2, ?3, ?4, ?5)").run( + db.run( + "INSERT INTO providers (id, app_type, name, settings_config, sort_index) VALUES (?1, ?2, ?3, ?4, ?5)", `bad-url-${idx}`, "openai", WORD_CANARY, From 261131d3726d95b4ad3335cf416254a868963e08 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 30 Oct 2026 22:14:40 +0900 Subject: [PATCH 75/84] fix(update): refuse dashboard restart under a systemd unit OpenCodex does not own (#7996) * fix(update): refuse dashboard restart under a systemd unit OpenCodex does not own Refuse before package mutation when the worker cannot leave the service cgroup or the proxy is not run by OpenCodex's own user unit, and settle a dead worker's job on status reads. Part of #7967. * fix(update): refuse unsupervised dashboard updates with or without restart The update stops the proxy before replacing the package, so restart:false was not a safe fallback. Detect a systemd service from its cgroup too, accept a delegated child cgroup of the own unit, write the settled status from the compared record, and add hard spawn barriers to the refusal test. * fix(update): report a dead worker's job as failed on status without writing it A status poll that wrote the settled record could overwrite a job another process started between its read and its rename. The poll now returns a failed view; the next run's existing stale recovery settles the persisted record. * fix(update): judge the innermost systemd unit and refuse only an update that would run A desktop terminal runs in a scope below user@.service, which the component check took for a service, and the CI runner's own service preempted the source-checkout refusal. Read the innermost unit of the systemd hierarchy, and consult the supervisor only when an update is available. * fix(update): let the observed cgroup win over an inherited INVOCATION_ID A shell moved into its own systemd-run scope keeps INVOCATION_ID; only a missing cgroup observation falls back to the variable. * docs(structure): state that the observed cgroup unit decides before INVOCATION_ID --------- Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> --- .../content/docs/reference/management-api.md | 2 +- src/server/management/config-routes.ts | 19 +++-- src/server/management/context.ts | 2 + src/update/job.ts | 20 ++++++ src/update/worker-launch.ts | 72 ++++++++++++++++++- structure/gui-and-management-api.md | 2 +- structure/ops/service-and-sidecars.md | 2 +- tests/server/update-async-routes.test.ts | 72 ++++++++++++++++++- tests/update/update-worker-launch.test.ts | 50 ++++++++++++- 9 files changed, 230 insertions(+), 11 deletions(-) diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 07b78c54e08..a85f352c5cb 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -278,7 +278,7 @@ See [Codex prompt layers](/guides/codex-prompt/) for the layer model and the key | `GET /api/diagnostics/project-config` | Read cached project configuration warnings | — | | `POST /api/sync` | Sync the current model catalog into Codex | 500 failed sync | | `GET /api/update/check` | Asynchronously check the `latest` or `preview` package channel and refresh the package cache on success | 400 invalid tag | -| `POST /api/update/run` | Asynchronously check a fresh package version, then start an update job, optionally followed by restart | 400 invalid body; job-specific conflict/error status | +| `POST /api/update/run` | Asynchronously check a fresh package version, then start an update job, optionally followed by restart | 400 invalid body; 409 `update_restart_unsupervised` when the proxy runs inside a systemd unit other than OpenCodex's own user service, or the worker cannot leave the service cgroup (with or without restart, since the update stops the proxy first); job-specific conflict/error status | | `GET /api/update/status` | Poll an update job by id | 404 unknown job | | `GET, PUT /api/sidecar-settings` | Read or update web-search and vision sidecar model/backend settings | 400 invalid shape, backend, or limit | | `GET, PUT /api/shadow-call-settings` | Read or update shadow-call interception settings, including `sourceModels`; GET also returns the runtime-owned `sourceModelOptions` inventory | 400 invalid shape or value, including an empty, duplicate, whitespace-bearing, or provider-qualified source prefix, or more than 32 prefixes / 128 characters per prefix; source-list failures carry `code: shadow_call_source_invalid`, target conflicts `code: shadow_call_target_invalid` | diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 144ff32fb38..3283a2409ce 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -972,11 +972,22 @@ async function handleConfigRoutesImpl(ctx: ManagementContext): Promise checked, spawnWorkerFn: (jobId, runChannel, runRestart) => spawnGuiUpdateWorker(jobId, runChannel, runRestart, { resolveSystemdRun: () => systemdRun }), @@ -990,8 +1001,8 @@ async function handleConfigRoutesImpl(ctx: ManagementContext): Promise Promise; + /** Test seam for the dashboard restart supervisor check (src/update/worker-launch.ts). */ + dashboardRestartRefusal?: typeof import("../../update/worker-launch").dashboardRestartRefusal; remoteWorkspaceHub?: RemoteWorkspaceHubApi; remoteWorkspaceSessions?: RemoteWorkspaceSessionsApi; /** The listener retains and awaits teardown only after this optional subsystem activates. */ diff --git a/src/update/job.ts b/src/update/job.ts index d138bd56485..b5926fceb40 100644 --- a/src/update/job.ts +++ b/src/update/job.ts @@ -527,6 +527,26 @@ export function staleActiveUpdateJobReason( return null; } +/** + * Status view of an active record whose worker is gone (#7967). A worker killed with its service + * cgroup never writes a terminal state, so the persisted record stays `running`. The view reports + * it as `failed` with the reason so the dashboard stops waiting, without writing: the persisted + * record is settled only by the next run's existing stale recovery, which holds the start path, + * so a status poll can never overwrite a job another process started meanwhile. + */ +export function readUpdateJobForStatus( + jobId?: string | null, + now = Date.now(), + isAlive: (pid: number) => boolean = isProcessAlive, +): UpdateJobState | null { + const job = readUpdateJob(jobId); + if (!job) return null; + const reason = staleActiveUpdateJobReason(job, now, isAlive); + if (!reason) return job; + const line = `Update worker stopped before finishing: ${reason}.`; + return { ...job, status: "failed", error: line, exitCode: null, log: [...job.log, line] }; +} + /** * Spawn the GUI update worker without inheriting the proxy's LISTEN socket. * diff --git a/src/update/worker-launch.ts b/src/update/worker-launch.ts index 331e9431da0..24dd7d04b9f 100644 --- a/src/update/worker-launch.ts +++ b/src/update/worker-launch.ts @@ -1,5 +1,5 @@ import { probeSpawn, probeSpawnSync } from "./mutation-guard.mjs"; -import { accessSync, constants, realpathSync, statSync } from "node:fs"; +import { accessSync, constants, readFileSync, realpathSync, statSync } from "node:fs"; import { dirname, isAbsolute } from "node:path"; /** @@ -171,6 +171,76 @@ export function resetSystemdRunProbeForTests(): void { systemdRunProbePending = undefined; } +export interface DashboardRestartContext { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + /** The resolved scope launcher, or undefined when none is usable. */ + systemdRun: string | undefined; + /** The OpenCodex-managed user unit (`TASK` in src/service/state.ts) without `.service`. */ + unitName: string; + readCgroup?: () => string; +} + +function cgroupPaths(cgroup: string): string[] { + const rows = cgroup.split("\n").map(line => line.split(":")).filter(parts => parts.length >= 3); + // cgroup v2 ("0::") or the v1 systemd hierarchy name the unit; controller lines are noise. + const unified = rows.filter(parts => parts[1] === "" || parts[1] === "name=systemd"); + return (unified.length > 0 ? unified : rows).map(parts => parts.slice(2).join(":").trim()).filter(Boolean); +} + +/** The innermost systemd unit named by a cgroup path: its last `.service` or `.scope` component. */ +function innermostUnit(path: string): { unit: string; index: number; parts: string[] } | null { + const parts = path.split("/"); + for (let index = parts.length - 1; index >= 0; index--) { + const part = parts[index]!; + if (part.endsWith(".service") || part.endsWith(".scope")) return { unit: part, index, parts }; + } + return null; +} + +/** + * Why a dashboard update must not run against this proxy, or null when it may (#7967). + * + * A dashboard update stops the proxy before it replaces the package, with or without a requested + * restart, and the worker survives that stop only from its own transient scope. Only OpenCodex's + * own user unit is restarted by the updater. A proxy inside any systemd service -- detected from + * `INVOCATION_ID` or from a `.service` cgroup, so a unit that unsets the variable is still seen + * -- is therefore refused unless the scope launcher works and the cgroup places it in + * `.service` under a per-user manager (a delegated child cgroup below it is fine). A + * custom system unit with `User=` would lose the worker with its cgroup, and the restart would aim + * at an unrelated, possibly inactive user unit. Outside systemd nothing changes. + */ +export function dashboardRestartRefusal(context: DashboardRestartContext): string | null { + const platform = context.platform ?? process.platform; + const env = context.env ?? process.env; + if (platform !== "linux") return null; + let cgroup: string | null; + try { cgroup = (context.readCgroup ?? (() => readFileSync("/proc/self/cgroup", "utf8")))(); } + catch { cgroup = null; } + const paths = cgroup === null ? [] : cgroupPaths(cgroup); + // The innermost unit decides: a terminal or app scope below the per-user manager + // (user@.service/app.slice/vte-spawn-1.scope) is not a service; a system or user service is. + const units: ReturnType[] = paths.map(innermostUnit); + const observed = units.filter((found): found is NonNullable => found !== null); + // The observed cgroup wins over an inherited INVOCATION_ID (a shell moved into its own scope + // keeps the variable); without an observation the variable alone fails closed. + const inService = observed.length > 0 + ? observed.some(found => found.unit.endsWith(".service") && !found.unit.startsWith("user@")) + : Boolean(env.INVOCATION_ID); + if (!inService) return null; + const manual = "Stop the service with its own manager, update the package from a shell (for example `ocx update`), then start the service again."; + if (!context.systemdRun || !env.INVOCATION_ID) { + return `Dashboard update is unavailable: this proxy runs inside a systemd service, and the update worker cannot leave the service's cgroup (no usable \`systemd-run --user --scope\`), so stopping the proxy for the update would stop the update too. ${manual}`; + } + const ownUnit = `${context.unitName}.service`; + const managedByOwnUnit = units.some((found): boolean => found !== null && found.unit === ownUnit + && found.parts.slice(0, found.index).some((part: string) => part.startsWith("user@") && part.endsWith(".service"))); + if (!managedByOwnUnit) { + return `Dashboard update is unavailable: this proxy runs under a systemd unit that is not OpenCodex's own \`${context.unitName}.service\` user unit, so the updater can neither keep its worker alive nor restart that unit safely. ${manual}`; + } + return null; +} + export function guiUpdateWorkerCommand( execPath: string, args: readonly string[], diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index bbdb03ccf55..0cafa760adf 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -274,7 +274,7 @@ per-request first-party callback reads that live object; a failed write leaves i | Config/settings | Reasoning retention follows the [panel and settings contract](dashboard-and-usage.md). Read safe config/settings views; mutate supported settings only. Full `PUT /api/config` is disabled so masked secrets are not round-tripped. `PUT /api/settings` accepts `codexAutoStart`, `streamMode`, integer `appOwnedMemoryBudgetMb` (64..4096), strict boolean `codexAccountPickerEnabled`, strict boolean `fastRows`, and a validated per-account `codexQuotaAutoRefresh` toggle (each optional, at least one required). `fastRows` defaults on when absent: false is persisted, true deletes the key, and successful writes echo the effective boolean. An effective change converges the Codex catalog and refreshes enabled or already-owned client integrations after persistence. Picker enable initializes an empty UI-managed selector map, persists before one bounded catalog convergence, and reports only `catalogRefreshPending`; allocation/save failure restores every touched live field and skips convergence. Budget changes synchronously enforce the process-wide evictable retained-state cap; this is separate from RSS/native memory. `streamMode` persists the #314 stream-shape selection in config.json (Windows services need persisted input; macOS eager relay is explicit-only). | | Startup safety | `GET /api/startup-health` reports whether injected Codex routing is restart-safe, with secret-free service/shim diagnostics. `POST /api/startup-action` provides allowlisted one-click installation for the background service or launcher shim. On Windows a healthy script shim is CLI-only; Codex Desktop requires the background service for full protection. Optional Codex compatibility uses the [confirmed local runtime](clients/codex-desktop.md#optional-compatibility-runtime), [machine-local dashboard consent](clients/codex-desktop.md#dashboard-controls), and [revision-bound next-start preference with shared awaited teardown](clients/codex-desktop.md#proxy-startup-preference); saving that preference performs no runtime action. | | Windows tray | `GET/POST /api/windows-tray` controls an owned, per-user HKCU login tray. The tray delegates fixed actions to the CLI and is never a proxy supervisor or restart-protection signal. | -| Updates | `GET /api/update/check`, `POST /api/update/run`, and `GET /api/update/status` own dashboard self-update state. A launched worker PID is persisted in `update-job.json`; dead PIDs recover immediately, while legacy active records without a PID recover only after ten minutes. Live PIDs remain exclusive regardless of record age. `GET /api/update/check` and `POST /api/update/run` await one per-channel asynchronous registry lookup and write successful results through to the package cache; run passes that result to the job starter. `GET /api/update/badge` only reads the cache and reports unknown after 40 hours, on missing cache, or on channel mismatch. The badge links to the update surface rather than gating other actions. `GET /api/update/badge?surface=desktop&session=` projects only that process-local Tauri session; missing or expired state is unknown and never falls back to the package cache. `POST /api/update/desktop-snapshot` is a 1 KiB bounded display-state mutation with no install permission. It accepts the existing admin-token principal or a dedicated single-use, ten-second snapshot capability bound to nonce, method, path, PID, port and the SHA-256 digest of the exact body bytes; that capability cannot authorize another route. | +| Updates | `GET /api/update/check`, `POST /api/update/run`, and `GET /api/update/status` own dashboard self-update state. A launched worker PID is persisted in `update-job.json`; dead PIDs recover immediately when a new run starts, and `GET /api/update/status` already reports such a record as `failed` without writing it (`readUpdateJobForStatus`), while legacy active records without a PID recover only after ten minutes. Live PIDs remain exclusive regardless of record age. `GET /api/update/check` and `POST /api/update/run` await one per-channel asynchronous registry lookup and write successful results through to the package cache; run passes that result to the job starter. `GET /api/update/badge` only reads the cache and reports unknown after 40 hours, on missing cache, or on channel mismatch. The badge links to the update surface rather than gating other actions. `GET /api/update/badge?surface=desktop&session=` projects only that process-local Tauri session; missing or expired state is unknown and never falls back to the package cache. `POST /api/update/desktop-snapshot` is a 1 KiB bounded display-state mutation with no install permission. It accepts the existing admin-token principal or a dedicated single-use, ten-second snapshot capability bound to nonce, method, path, PID, port and the SHA-256 digest of the exact body bytes; that capability cannot authorize another route. | | Providers | Create/update/delete ordinary provider configs and enrich registry metadata. A `POST /api/providers` overwrite of an existing name keeps the ten operator compatibility settings (`PROVIDER_COMPAT_CARRY_FIELDS` in `src/server/management/provider-overwrite-carry.ts`) and the stored key pool only while the destination (adapter, normalized base URL, auth mode when named) is unchanged. It keeps omitted `hideRawReasoning` on every overwrite of the same name, including destination moves, because it is a display policy; a submitted boolean wins. An omitted `inferContextWindow` likewise keeps the latest live same-name state after asynchronous validation, including `false` or a concurrent clear; a submitted boolean wins. It never merges the rest of the old row. `PATCH` is a field mask and keeps every field it does not name; `hideRawReasoning`, `preserveResponsesReasoningContent`, `promoteAdditionalTools` and `inferContextWindow` accept a boolean or `null` to clear them, rejecting other types. `GET /api/providers` exposes configured `inferContextWindow` as an optional boolean without substituting a default; its [custom-only, default-off inference policy](catalog-vendor-context.md) is unchanged. Custom Responses provider forms expose `preserveResponsesReasoningContent` as a default-off plaintext replay switch; unrelated settings saves preserve omission and curated/OpenAI destinations keep their existing defaults. The reserved `openai` card exposes Pool(default)/Direct account mode; `openai-apikey` remains the separate API route. | | Models | Fetch routed model lists, disabled model visibility, and catalog-facing ids. New non-OAuth registration holds exposure until authoritative discovery; 20 or more distinct switch rows start OFF without disabling the provider. Pending rows cannot accept visibility changes. Ordinary model reads also apply the [new-arrival catalog contract](catalog.md#shared-catalog) before returning models. With a matching persisted configuration, discovery records the baseline, NEW badges, and automatic disables together; HTTP listing does not wait for a separate Codex sync. Inventory already drifted from disk uses a detached policy projection: arrivals remain disabled in management rows without changing live config or disk. | | OAuth | Login/status/logout for OAuth-backed providers, plus multiauth account management: `GET /api/oauth/accounts`, `PUT /api/oauth/accounts/active`, `PUT /api/oauth/accounts/alias`, `PUT /api/oauth/accounts/pause`, and `DELETE /api/oauth/accounts` list masked accounts per provider, switch the active one, edit its display-only alias, pause/resume a generic OAuth account, and remove one. Paused generic OAuth accounts are excluded from request selection, 429 failover, and proactive token refresh; pausing an active account selects the next usable account when available, and resuming an account restores an active selection if the current one remains paused. With no unpaused account, requests return 403 rather than a login error. Kiro account-list rows include the current automatic-selection projection and closed exclusion reason; an active singleton may still send. The login flow itself is `GET /api/oauth/providers`, `POST /api/oauth/login`, `POST /api/oauth/login/code`, `POST /api/oauth/login/cancel`, `POST /api/oauth/logout`, and `GET /api/oauth/status`; pool controls are `GET/PUT/PATCH /api/oauth/accounts/pool` and `POST /api/oauth/accounts/clear-cooldown`. Login accepts `addAccount: true` to force a fresh browser identity. Meta Muse login start and manual-code continuation require the server-resolved `gui-session` principal before credential acquisition or code submission (including reauth); see the [provider contract](providers-and-adapters.md). Device flows return a structured `deviceCode`; the GUI highlights and copies it before the user opens the verification page. Google Antigravity account rows in `gui/src/components/provider-workspace/ProviderAuthPanel.tsx` display the account-summary `plan` as a badge and include it in the account button’s accessible name. The value is the canonical upstream tier ID (for example `free-tier` or `gemini-pro`), preserved literally across locales rather than inferred as a product name. Absent plans render no badge; pause and reauthentication controls keep their existing behavior. | diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 00955b3e463..fc84ee93356 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -597,4 +597,4 @@ The desktop badge snapshot in src/update/desktop-badge.ts is process-local displ Desktop startup diagnostics use `src/service/desktop-startup.ts` to read the ownership record, the login registration (macOS: launchd; Linux: the `~/.config/autostart/OpenCodex.desktop` entry that auto-launch writes, credited only when `XDG_CONFIG_HOME` is unset or `~/.config`; it must launch an unquoted absolute `opencodex-desktop` with exactly `--autostart`, without conditional keys, and not be hidden or disabled) and exact parent/child executable paths (Linux: `/proc//exe` and the parent pid from `/proc//stat`) without mutating them. A durable desktop claim survives a failed identity or supervision check; its protection requires fresh matching identity, enabled login registration, and live supervision. Only ownership `none` enters the unowned branch: verified supervision adds `supervisor: { supervisorPid, runtimePid, app }` with `owned: false`, and protection requires that same app's verified login registration and a fresh, running, viable diagnostic. Unknown ownership and CLI-owned claims receive no supervision credit. The independent process evidence requires two matching snapshots and correlation of the target, PID-file and runtime-port PIDs, as described in [desktop supervision](desktop-supervision.md#runtime-ownership-from-the-apps-side); it never becomes ownership. An unloaded macOS job receives credit only from two matching plist/target/enabled snapshots; a boolean plist Disabled default combines with explicit launchctl overrides, and malformed or changing evidence refuses credit. On macOS, ownership and PID are re-read before crediting an owned result; on Linux, the complete owned evidence chain is read twice and both reads must agree. The startup-health subprocess uses `selfLaunchArgv` to support both source and compiled entrypoints. -On Linux, a dashboard update worker started from the systemd user service is launched through an executable regular file at a trusted absolute path — `/usr/bin/systemd-run`, `/bin/systemd-run`, `/usr/local/bin/systemd-run` (local installs), or `/run/current-system/sw/bin/systemd-run` (the NixOS layout) — with `--user --scope --quiet --collect` (`src/update/worker-launch.ts`), so it leaves the service cgroup before the updater stops `opencodex-proxy.service`; the default `KillMode=control-group` otherwise kills it with the proxy (#5750). The inherited `PATH` is never searched, and each candidate's resolved target — plus every ancestor directory able to substitute it — must be root-owned and not group/world-writable: a trusted-path symlink into a user-replaceable directory is skipped, as is a group-writable `/usr/local/bin`, rather than exec'd under the service account. Candidates are tried in order and a path whose no-op scope probe fails falls through to the next trusted path; the probe applies only when `INVOCATION_ID` is set, and every other case keeps the plain detached spawn. The management route resolves the launcher with `resolveSystemdRunAsync` before spawning, so first-request probing overlaps other work instead of blocking the event loop for up to twenty seconds. `--scope` moves `systemd-run` itself into the scope and then execs the worker, so the recorded PID is the worker's (`tests/update/update-worker-launch.test.ts`). +On Linux, a dashboard update worker started from the systemd user service is launched through an executable regular file at a trusted absolute path — `/usr/bin/systemd-run`, `/bin/systemd-run`, `/usr/local/bin/systemd-run` (local installs), or `/run/current-system/sw/bin/systemd-run` (the NixOS layout) — with `--user --scope --quiet --collect` (`src/update/worker-launch.ts`), so it leaves the service cgroup before the updater stops `opencodex-proxy.service`; the default `KillMode=control-group` otherwise kills it with the proxy (#5750). The inherited `PATH` is never searched, and each candidate's resolved target — plus every ancestor directory able to substitute it — must be root-owned and not group/world-writable: a trusted-path symlink into a user-replaceable directory is skipped, as is a group-writable `/usr/local/bin`, rather than exec'd under the service account. Candidates are tried in order and a path whose no-op scope probe fails falls through to the next trusted path; the probe applies only when `INVOCATION_ID` is set, and every other case keeps the plain detached spawn. The management route resolves the launcher with `resolveSystemdRunAsync` before spawning, so first-request probing overlaps other work instead of blocking the event loop for up to twenty seconds. `--scope` moves `systemd-run` itself into the scope and then execs the worker, so the recorded PID is the worker's (`tests/update/update-worker-launch.test.ts`). A dashboard update that would run, with or without restart, refuses before any package mutation when the proxy runs inside a systemd service: the innermost `.service` or `.scope` unit of `/proc/self/cgroup` decides (a `.service` other than the per-user manager is a service, so a unit that unsets `INVOCATION_ID` is still seen; a terminal or app `.scope` is not, even with an inherited `INVOCATION_ID`), and only when no cgroup can be read does `INVOCATION_ID` alone count, failing closed unless a trusted scope launcher works and the cgroup places the proxy in OpenCodex's own `opencodex-proxy.service` below a `user@.service` manager: the update stops the proxy before replacing the package, so a worker left in the service cgroup dies with it, and a custom system unit with `User=` is not one the updater can restart, nor may an installed but inactive user unit become the restart target (`dashboardRestartRefusal`, 409 `update_restart_unsupervised`, #7967). diff --git a/tests/server/update-async-routes.test.ts b/tests/server/update-async-routes.test.ts index e7318f43fba..05cbc8fe616 100644 --- a/tests/server/update-async-routes.test.ts +++ b/tests/server/update-async-routes.test.ts @@ -1,7 +1,13 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import { handleManagementAPI } from "../../src/server/management-api"; import type { ManagementApiDeps } from "../../src/server/management/context"; import type { OcxConfig } from "../../src/types"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import * as updateJobModule from "../../src/update/job"; +import { updateJobPath } from "../../src/update/job"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; const config = { port: 10100, defaultProvider: "openai", providers: {} } as OcxConfig; @@ -57,9 +63,71 @@ describe("asynchronous package update routes", () => { }); test("source checkout run rejects a worker", async () => { - const response = await call("POST", "/api/update/run", { tag: "latest", restart: false }); + // The supervisor check must not preempt the checkout's own refusal, even when the test + // process itself runs inside a systemd service (a CI runner, for example). + const response = await call("POST", "/api/update/run", { tag: "latest", restart: false }, { + dashboardRestartRefusal: () => "must not be consulted", + }); expect(response?.status).toBe(409); const body = await response!.json() as Record; expect(body.code).toBe("source_checkout"); }); }); + +describe("dashboard update restart supervision (#7967)", () => { + const available = async (channel: "latest" | "preview") => ({ + currentVersion: "2.7.43", latestVersion: "2.7.44", channel, + installer: "npm" as const, updateAvailable: true, canUpdate: true, + command: "npm install -g @bitkyc08/opencodex@2.7.44", releaseNotesUrl: "https://example.test/releases", + }); + + test.each([true, false])("a refused supervisor answers 409 before any update job starts (restart=%p)", async restart => { + const home = mkdtempSync(join(tmpdir(), "ocx-update-supervisor-")); + const previous = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = home; + // Hard barriers: a regression must fail here, never launch a real updater. + const start = spyOn(updateJobModule, "startUpdateJob").mockImplementation(() => { throw new Error("must not start a job"); }); + const spawn = spyOn(updateJobModule, "spawnGuiUpdateWorker").mockImplementation(() => { throw new Error("must not spawn a worker"); }); + try { + const response = await call("POST", "/api/update/run", { tag: "latest", restart }, { + checkPackageUpdate: available, + dashboardRestartRefusal: () => "Dashboard update is unavailable: fixture", + }); + expect(response?.status).toBe(409); + expect(await response!.json()).toEqual({ error: "Dashboard update is unavailable: fixture", code: "update_restart_unsupervised" }); + expect(start).not.toHaveBeenCalled(); + expect(spawn).not.toHaveBeenCalled(); + expect(existsSync(updateJobPath())).toBe(false); + } finally { + start.mockRestore(); spawn.mockRestore(); + if (previous === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previous; + removeTreeWithRetry(home); + } + }); + + test("status reports an active job whose worker died as failed, without writing", async () => { + const home = mkdtempSync(join(tmpdir(), "ocx-update-status-")); + const previous = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = home; + try { + const now = new Date().toISOString(); + mkdirSync(dirname(updateJobPath()), { recursive: true }); + const persisted = JSON.stringify({ + id: "dead-worker", status: "restarting", startedAt: now, updatedAt: now, pid: 2147483646, + currentVersion: "2.7.43", latestVersion: "2.7.44", channel: "latest", installer: "npm", + restart: true, command: "", releaseNotesUrl: "", log: [], + }); + writeFileSync(updateJobPath(), persisted); + const response = await call("GET", "/api/update/status?jobId=dead-worker"); + expect(response?.status).toBe(200); + const body = await response!.json() as { job: { status: string; error?: string } }; + expect(body.job.status).toBe("failed"); + expect(body.job.error).toContain("is no longer running"); + // The next run settles the record under its own start path; a poll never writes it. + expect(readFileSync(updateJobPath(), "utf8")).toBe(persisted); + } finally { + if (previous === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previous; + removeTreeWithRetry(home); + } + }); +}); diff --git a/tests/update/update-worker-launch.test.ts b/tests/update/update-worker-launch.test.ts index 661c4fea581..3ca852cd5af 100644 --- a/tests/update/update-worker-launch.test.ts +++ b/tests/update/update-worker-launch.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { guiUpdateWorkerCommand, isTrustedSystemdRunFile, resolveSystemdRun, resetSystemdRunProbeForTests, - resolveSystemdRunAsync, SYSTEMD_SCOPE_ARGS, + resolveSystemdRunAsync, SYSTEMD_SCOPE_ARGS, dashboardRestartRefusal, } from "../../src/update/worker-launch"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -237,3 +237,51 @@ test("launcher trust also checks lexical ancestors of a canonical system target" expect(isTrustedSystemdRunFile(candidate, deps("/nix/store"))).toBe(false); expect(isTrustedSystemdRunFile("relative/systemd-run", deps(undefined))).toBe(false); }); + +// #7967: a dashboard restart must not stop a proxy whose worker cannot survive the stop, or one +// run by a unit the updater does not restart. +describe("dashboard restart supervisor check", () => { + // The per-user manager unit, joined so the literal does not read as an e-mail address. + const manager = ["user", "1000.service"].join("@"); + const userUnit = `0::/user.slice/user-1000.slice/${manager}/app.slice/opencodex-proxy.service\n`; + const base = { platform: "linux" as const, env: { INVOCATION_ID: "abc" }, systemdRun: "/usr/bin/systemd-run", unitName: "opencodex-proxy" }; + + test("OpenCodex's own user unit with a usable scope may update", () => { + expect(dashboardRestartRefusal({ ...base, readCgroup: () => userUnit })).toBeNull(); + // cgroup v1 lists the systemd hierarchy beside controller lines. + expect(dashboardRestartRefusal({ ...base, readCgroup: () => `4:memory:/user.slice\n1:name=systemd:/user.slice/user-1000.slice/${manager}/app.slice/opencodex-proxy.service\n` })).toBeNull(); + // A delegated child cgroup below the unit is still that unit. + expect(dashboardRestartRefusal({ ...base, readCgroup: () => `0::/user.slice/user-1000.slice/${manager}/app.slice/opencodex-proxy.service/payload\n` })).toBeNull(); + }); + + test("a custom system unit is refused even when it reuses the unit name", () => { + for (const cgroup of [ + "0::/system.slice/opencodex.service\n", + "0::/system.slice/opencodex-proxy.service\n", + `0::/user.slice/user-1000.slice/${manager}/app.slice/other.service\n`, + `0::/user.slice/user-1000.slice/${manager}/app.slice/not-opencodex-proxy.service\n`, + "", + ]) { + expect(dashboardRestartRefusal({ ...base, readCgroup: () => cgroup })).toContain("not OpenCodex's own"); + } + expect(dashboardRestartRefusal({ ...base, readCgroup: () => { throw new Error("no proc"); } })).toContain("not OpenCodex's own"); + }); + + test("no usable scope launcher under systemd is refused", () => { + expect(dashboardRestartRefusal({ ...base, systemdRun: undefined, readCgroup: () => userUnit })).toContain("cannot leave the service's cgroup"); + // A unit that unsets INVOCATION_ID is still found from its .service cgroup. + expect(dashboardRestartRefusal({ ...base, env: {}, readCgroup: () => "0::/system.slice/opencodex.service\n" })).toContain("cannot leave the service's cgroup"); + }); + + test("outside systemd or off Linux nothing changes", () => { + const unread = () => { throw new Error("must not read the cgroup"); }; + expect(dashboardRestartRefusal({ ...base, env: {}, systemdRun: undefined, readCgroup: () => "0::/user.slice/user-1000.slice/session-3.scope\n" })).toBeNull(); + // A desktop terminal runs in a scope below the per-user manager: not a service. + expect(dashboardRestartRefusal({ ...base, env: {}, systemdRun: undefined, readCgroup: () => `0::/user.slice/user-1000.slice/${manager}/app.slice/app-org.gnome.Terminal.slice/vte-spawn-1.scope\n` })).toBeNull(); + expect(dashboardRestartRefusal({ ...base, env: {}, systemdRun: undefined, readCgroup: () => `0::/user.slice/user-1000.slice/${manager}/init.scope\n` })).toBeNull(); + // A shell moved into its own scope keeps an inherited INVOCATION_ID; the cgroup decides. + expect(dashboardRestartRefusal({ ...base, readCgroup: () => `0::/user.slice/user-1000.slice/${manager}/app.slice/run-r1.scope\n` })).toBeNull(); + expect(dashboardRestartRefusal({ ...base, env: {}, systemdRun: undefined, readCgroup: () => { throw new Error("no proc"); } })).toBeNull(); + expect(dashboardRestartRefusal({ ...base, platform: "darwin", systemdRun: undefined, readCgroup: unread })).toBeNull(); + }); +}); From 19ab113945530483f4524ae3e6f8374d16572976 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 30 Oct 2026 22:28:10 +0900 Subject: [PATCH 76/84] fix(windows): admit config-root cache writes after the optional config-root ACL flight (#7998) * fix(windows): admit config-root cache writes after the optional config-root ACL flight The provider and Codex account quota caches, the reasoning support cache and the models.dev refresh wrote under the config root while an optional config-root ACL flight could still run, so parent inheritance could race the fresh file's explicit grant. Each write now goes through withConfigDirHardeningSettled with its snapshot built inside the callback. Part of #7815. * fix(windows): invalidate waiting cache writes on cancel and report refused refreshes to joiners A fired cache write waiting for config-root ACL admission now checks a generation that cancel, clear and reset bump; the reasoning support flush seam is admitted too; a joined models.dev refresh receives the shared outcome instead of a coalesced success. * fix(windows): drop a reasoning support flush invalidated while it waited for admission --------- Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> --- src/codex/quota.ts | 21 ++++-- src/providers/account-quota-disk.ts | 19 ++++-- src/providers/reasoning-metadata.ts | 46 ++++++++----- structure/config.md | 2 +- .../main-quota-provenance.test.ts | 46 ++++++------- .../reasoning-metadata.test.ts | 62 +++++++++++++++++- ...provider-account-quota-persistence.test.ts | 65 +++++++++++++++++++ 7 files changed, 214 insertions(+), 47 deletions(-) diff --git a/src/codex/quota.ts b/src/codex/quota.ts index 744e8127d7c..b0933c46231 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -5,6 +5,7 @@ import { forgetMainAccountUsage, observeMainAccountUsage, type FreshWindow } fro import { closeSync, constants as fsConstants, existsSync, fstatSync, openSync, readSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { atomicWriteFile, getConfigDir } from "../config"; +import { withConfigDirHardeningSettled } from "../config/paths"; import { captureConfigGeneration, type GenerationContext } from "../lib/state-store-sweeper"; import { isThirtyDayOnlyCodexPlan } from "./plan"; import { stampCodexQuotaUsageObservation } from "./quota-observation-freshness"; @@ -42,6 +43,12 @@ let mainPolicyQuota: MainPolicyQuota | null = null; let diskHydrated = false; let persistTimer: ReturnType | null = null; let persistStartedAt: number | null = null; +/** The latest admitted snapshot write; settles even when admission times out. */ +let persistWrite: Promise = Promise.resolve(); +/** Bumped by a full clear so a fired callback still waiting for ACL admission writes nothing. */ +let persistGeneration = 0; +/** Test seam: wait for a fired persistence callback's admitted write. */ +export function settleQuotaPersistenceForTests(): Promise { return persistWrite; } const MONTHLY_WINDOW_MIN_SECONDS = 28 * 24 * 60 * 60; /** @@ -800,7 +807,12 @@ function schedulePersistAccountQuotas(): void { persistTimer = setTimeout(() => { persistTimer = null; persistStartedAt = null; - try { + // Admitted after an overlapping optional config-root ACL flight (#7815); the snapshot is + // taken inside the callback, and a timed-out admission skips this best-effort write. + const dir = getConfigDir(); + const generation = persistGeneration; + persistWrite = withConfigDirHardeningSettled(dir, () => { + if (generation !== persistGeneration) return; const quotas: Record = {}; for (const [accountId, quota] of accountQuota.entries()) { quotas[accountId] = quota; @@ -811,10 +823,10 @@ function schedulePersistAccountQuotas(): void { ...(mainPolicyQuota ? { mainPolicyQuota } : {}), history: quotaHistory.serialize(), }; - atomicWriteFile(join(getConfigDir(), QUOTA_CACHE_FILENAME), `${JSON.stringify(body)}\n`); - } catch { + atomicWriteFile(join(dir, QUOTA_CACHE_FILENAME), `${JSON.stringify(body)}\n`); + }).catch(() => { // Best-effort persistence only. - } + }); }, delay); } @@ -890,6 +902,7 @@ export function clearAccountQuota(accountId?: string): void { persistTimer = null; } persistStartedAt = null; + persistGeneration += 1; try { const path = join(getConfigDir(), QUOTA_CACHE_FILENAME); if (existsSync(path)) unlinkSync(path); diff --git a/src/providers/account-quota-disk.ts b/src/providers/account-quota-disk.ts index bf0aa1e5160..bf6593f7a98 100644 --- a/src/providers/account-quota-disk.ts +++ b/src/providers/account-quota-disk.ts @@ -13,6 +13,7 @@ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { atomicWriteFile, getConfigDir } from "../config"; +import { withConfigDirHardeningSettled } from "../config/paths"; import type { ProviderQuota } from "./quota-types"; import type { KiroPersistedQuota, KiroPersistedVerdict } from "./kiro-account-state-disk"; import { ACCOUNT_QUOTA_TTL_MS } from "./quota-wire"; @@ -41,6 +42,8 @@ type DiskFile = { let persistTimer: ReturnType | null = null; let persistStartedAt: number | null = null; +/** Bumped by cancellation so a fired callback still waiting for ACL admission writes nothing. */ +let persistGeneration = 0; /** Read the snapshot. Returns an empty map for a missing, corrupt or stale file. */ export function readPersistedAccountQuotas(now = Date.now()): Map { @@ -102,7 +105,14 @@ export function schedulePersistAccountQuotas( persistTimer = setTimeout(() => { persistTimer = null; persistStartedAt = null; - try { + // Writes under the config root wait out an overlapping optional config-root ACL flight, + // whose inheritance can drop the fresh file's explicit grant (#7815). Rows are read inside + // the admitted callback so a late admission still writes the newest snapshot; a timeout + // skips this best-effort write. + const dir = getConfigDir(); + const generation = persistGeneration; + void withConfigDirHardeningSettled(dir, () => { + if (generation !== persistGeneration) return; const out: Record = {}; for (const [key, quota] of rows()) out[key] = quota; const kiroVerdicts: Record = {}; @@ -110,10 +120,10 @@ export function schedulePersistAccountQuotas( if (key.startsWith("kiro\0") && /^[a-f0-9]{64}$/.test(verdict.identity)) kiroVerdicts[key] = verdict; } const body: DiskFile = { version: 1, rows: out, kiroVerdicts }; - atomicWriteFile(join(getConfigDir(), FILENAME), `${JSON.stringify(body)}\n`); - } catch { + atomicWriteFile(join(dir, FILENAME), `${JSON.stringify(body)}\n`); + }).catch(() => { // Best-effort persistence only. - } + }); }, delay); } @@ -122,4 +132,5 @@ export function cancelPendingAccountQuotaPersist(): void { if (persistTimer) clearTimeout(persistTimer); persistTimer = null; persistStartedAt = null; + persistGeneration += 1; } diff --git a/src/providers/reasoning-metadata.ts b/src/providers/reasoning-metadata.ts index 45f83ebc65c..a58a93889ed 100644 --- a/src/providers/reasoning-metadata.ts +++ b/src/providers/reasoning-metadata.ts @@ -25,7 +25,7 @@ import { join } from "node:path"; // already imports. Going through the ../config barrel closes a cycle back into account-namespaces.ts // and leaves COMBO_NAMESPACE in its temporal dead zone for entry points that start at combos/types.ts. import { atomicWriteFile } from "../config/atomic-write"; -import { getConfigDir } from "../config/paths"; +import { getConfigDir, withConfigDirHardeningSettled } from "../config/paths"; import type { OcxProviderConfig } from "../types"; import { resolveProviderApiKey } from "./api-key-resolve"; @@ -96,7 +96,9 @@ interface SupportSnapshot { let snapshotMemo: MetadataSnapshot | null | undefined; let supportMemo: Map | undefined; let persistTimer: ReturnType | null = null; -let refreshInFlight: Promise | null = null; +let refreshInFlight: Promise | null = null; +/** Bumped by reset so a fired support write still waiting for ACL admission writes nothing. */ +let persistGeneration = 0; /** Test seam: drop the memoised snapshot/support caches so a suite can drive the load paths. */ export function resetReasoningMetadataCachesForTests(): void { @@ -107,6 +109,7 @@ export function resetReasoningMetadataCachesForTests(): void { persistTimer = null; } refreshInFlight = null; + persistGeneration += 1; } function readJsonFile(filename: string): T | null { @@ -375,7 +378,12 @@ export function recordUnsupportedReasoningEffort( if (persistTimer) clearTimeout(persistTimer); persistTimer = setTimeout(() => { persistTimer = null; - try { + // Admitted after an overlapping optional config-root ACL flight (#7815); rows are read + // inside the callback, and a timed-out admission skips this best-effort write. + const dir = getConfigDir(); + const generation = persistGeneration; + void withConfigDirHardeningSettled(dir, () => { + if (generation !== persistGeneration) return; const out: SupportSnapshot["rows"] = {}; for (const [rowKey, at] of rows) { const evidenceText = supportEvidence.get(rowKey); @@ -385,30 +393,33 @@ export function recordUnsupportedReasoningEffort( ...(evidenceText ? { evidence: evidenceText } : {}), }; } - atomicWriteFile(join(getConfigDir(), SUPPORT_FILENAME), JSON.stringify({ version: 2, rows: out }) + "\n"); - } catch { + atomicWriteFile(join(dir, SUPPORT_FILENAME), JSON.stringify({ version: 2, rows: out }) + "\n"); + }).catch(() => { // Best-effort persistence only. - } + }); }, PERSIST_DEBOUNCE_MS); return true; } -/** Test seam: flush a pending support write so a script sees the snapshot immediately. */ -export function flushReasoningSupportCache(): void { +/** Test seam: flush a pending support write, admitted like the timer write (#7815). */ +export async function flushReasoningSupportCache(): Promise { if (!persistTimer) return; clearTimeout(persistTimer); persistTimer = null; - try { + const dir = getConfigDir(); + const generation = persistGeneration; + await withConfigDirHardeningSettled(dir, () => { + if (generation !== persistGeneration) return; const rows = loadSupport(); const out: SupportSnapshot["rows"] = {}; for (const [rowKey, at] of rows) { const evidenceText = supportEvidence.get(rowKey); out[rowKey] = { effort: JSON.parse(rowKey)[3] ?? "", at, ...(evidenceText ? { evidence: evidenceText } : {}) }; } - atomicWriteFile(join(getConfigDir(), SUPPORT_FILENAME), JSON.stringify({ version: 2, rows: out }) + "\n"); - } catch { + atomicWriteFile(join(dir, SUPPORT_FILENAME), JSON.stringify({ version: 2, rows: out }) + "\n"); + }).catch(() => { // Best-effort persistence only. - } + }); } /** @@ -521,7 +532,8 @@ export async function refreshReasoningMetadata(options: { force?: boolean; waitM return { ok: true, reason: "fresh" }; } if (refreshInFlight) { - return waitForRefresh(refreshInFlight.then(() => ({ ok: true, reason: "coalesced" })), options.waitMs); + // A joined caller learns the shared outcome: a refused or failed refresh is not a success. + return waitForRefresh(refreshInFlight.then(outcome => outcome.ok ? { ok: true, reason: "coalesced" } : outcome), options.waitMs); } const job = (async () => { const response = await fetch(SOURCE_URL, { @@ -565,15 +577,19 @@ export async function refreshReasoningMetadata(options: { force?: boolean; waitM providers[providerKey] = out; } const next: MetadataSnapshot = { version: 2, fetchedAt: Date.now(), source: SOURCE_URL, providers, apis }; - atomicWriteFile(join(getConfigDir(), FILENAME), JSON.stringify(next) + "\n"); + // The last await is the network read; publication waits out an overlapping optional + // config-root ACL flight (#7815) and a timed-out admission fails this refresh unpublished. + const dir = getConfigDir(); + await withConfigDirHardeningSettled(dir, () => atomicWriteFile(join(dir, FILENAME), JSON.stringify(next) + "\n")); snapshotMemo = next; return { ok: true, reason: "refreshed", providers: Object.keys(providers).length, models }; })(); - refreshInFlight = job.catch(() => undefined).finally(() => { refreshInFlight = null; }); const settled = job.catch((error): RefreshOutcome => ({ ok: false, reason: error instanceof Error ? error.message : String(error), })); + const shared = settled.finally(() => { if (refreshInFlight === shared) refreshInFlight = null; }); + refreshInFlight = shared; return waitForRefresh(settled, options.waitMs); } diff --git a/structure/config.md b/structure/config.md index 7f6345e6529..5e33e304171 100644 --- a/structure/config.md +++ b/structure/config.md @@ -62,7 +62,7 @@ test seam and the mutation types remain re-exported through `src/config.ts`. Suc `src/config/paths.ts` owns `OPENCODEX_HOME` expansion and resolution, the config directory and `config.json` path. A relative home is resolved once per distinct raw environment value, so a later working-directory change cannot silently move the active installation. Config reads in this leaf and `src/config/load-degrade.ts` use NTFS ACLs on Windows and private POSIX modes elsewhere. Optional directory hardening follows directory symlinks/junctions but skips non-directory or unstatable targets before starting chmod or ACL work. Windows reads omit `chmod` before ACL hardening so repeated loads preserve successful object/freshness observations; external metadata changes still invalidate them. Directory flights keep their existing flush/reap lifecycle, and file/required-write failure policy is unchanged. `tests/windows/windows-config-read-acl-memo.test.ts` covers repeated loads, non-directory and directory-link behavior, external invalidation and POSIX permissions. -`withConfigDirHardeningSettled` in `src/config/paths.ts` admits synchronous mutation after the last unrelated await. Its registry includes the optional config flight, ancestors and retained timeout children; one existing ACL deadline bounds admission, including newly overlapping work. Expiry throws sanitized `WindowsSecretAclSettleTimeoutError` (`ETIMEDOUT`) without running the writer or dropping child cleanup ownership. The final empty-registry check and callback share one turn, with no await in the callback. Intercept/picker CA, token, cleanup, profile and snapshot phases re-enter after trust/discovery awaits. Config saves in `src/server/management/agent-settings-routes.ts` and `src/server/management/config-routes.ts`, Desktop intent/mode commits in `src/server/management/native-integration-routes.ts`, and gateway publication/cleanup in those routes and `src/cli/ensure-desired-integrations.ts` use the same admission. `src/cli/claude-desktop.ts` admits local profile/mode commits and the complete remote-store transaction again after hub download. Lifecycle/config locks are acquired inside admission; refresh re-read, mode/freshness checks, profile and marker publication share one callback. Live setting edits and their save/publication share the admitted synchronous callback; a refused admission leaves both live and durable state untouched. Ordered post-commit phases preserve applied-state warnings or settled compensation, including mode/fingerprint marker refusal and retained CLI intent after incomplete rollback. Synchronous leaf APIs retain their contracts. `tests/claude-integration/claude-config-root-writer-barrier.test.ts` holds parent flights and drives refusal deadlines without sleeps. Lexical/canonical aliases and uncertain identities drain conservatively; removal keeps its separate at-or-below scope and actual-exit requirement. Async local config commands (`src/cli/config-command.ts`, `src/cli/effort.ts`, `src/cli/models.ts`, `src/cli/provider.ts`) admit complete synchronous read/validate/save phases through `withConfigDirHardeningSettled`. Preliminary model selection and client-connection diagnostics use passive observations that cannot publish recovery copies. Final authority and recovery publication are re-read inside admission without starting optional hardening. `src/cli/config-mutation.ts` refuses invalid or unreadable authority, including array/non-object roots rejected before defaults repair, with `CliUsageError`; full-config commands retain fresh-install initialization when the file is absent, while set/unset retain the persisted mutation owner's missing-file refusal. Custom-model removal revalidates the confirmed row by identity and contents while retaining current siblings. Import reads its input and checks its target inside admission. Provider-add credential cleanup shares the synchronous post-save phase and retains its existing post-commit warning. Timeout leaves the mutation database and config untouched and follows existing CLI failure handling. `tests/windows/windows-config-root-async-writers.test.ts` covers held parent flights, deadline refusal, recovery copies, invalid authority and concurrent edits. Codex multi-agent management changes that can resolve a runtime additionally await fresh [selection admission](codex-cli-update.md#worker-realm-and-the-selection-commit-window) before entering this barrier, keep that owner across hardening, and re-evaluate the mode plan inside the synchronous callback; neither ACL wait is inserted into the callback, and refusal leaves the combined mutation untouched. +`withConfigDirHardeningSettled` in `src/config/paths.ts` admits synchronous mutation after the last unrelated await. Its registry includes the optional config flight, ancestors and retained timeout children; one existing ACL deadline bounds admission, including newly overlapping work. Expiry throws sanitized `WindowsSecretAclSettleTimeoutError` (`ETIMEDOUT`) without running the writer or dropping child cleanup ownership. The final empty-registry check and callback share one turn, with no await in the callback. Intercept/picker CA, token, cleanup, profile and snapshot phases re-enter after trust/discovery awaits. Config saves in `src/server/management/agent-settings-routes.ts` and `src/server/management/config-routes.ts`, Desktop intent/mode commits in `src/server/management/native-integration-routes.ts`, and gateway publication/cleanup in those routes and `src/cli/ensure-desired-integrations.ts` use the same admission. `src/cli/claude-desktop.ts` admits local profile/mode commits and the complete remote-store transaction again after hub download. Lifecycle/config locks are acquired inside admission; refresh re-read, mode/freshness checks, profile and marker publication share one callback. Live setting edits and their save/publication share the admitted synchronous callback; a refused admission leaves both live and durable state untouched. Ordered post-commit phases preserve applied-state warnings or settled compensation, including mode/fingerprint marker refusal and retained CLI intent after incomplete rollback. Synchronous leaf APIs retain their contracts. `tests/claude-integration/claude-config-root-writer-barrier.test.ts` holds parent flights and drives refusal deadlines without sleeps. Lexical/canonical aliases and uncertain identities drain conservatively; removal keeps its separate at-or-below scope and actual-exit requirement. Async local config commands (`src/cli/config-command.ts`, `src/cli/effort.ts`, `src/cli/models.ts`, `src/cli/provider.ts`) admit complete synchronous read/validate/save phases through `withConfigDirHardeningSettled`. Preliminary model selection and client-connection diagnostics use passive observations that cannot publish recovery copies. Final authority and recovery publication are re-read inside admission without starting optional hardening. `src/cli/config-mutation.ts` refuses invalid or unreadable authority, including array/non-object roots rejected before defaults repair, with `CliUsageError`; full-config commands retain fresh-install initialization when the file is absent, while set/unset retain the persisted mutation owner's missing-file refusal. Custom-model removal revalidates the confirmed row by identity and contents while retaining current siblings. Import reads its input and checks its target inside admission. Provider-add credential cleanup shares the synchronous post-save phase and retains its existing post-commit warning. Timeout leaves the mutation database and config untouched and follows existing CLI failure handling. `tests/windows/windows-config-root-async-writers.test.ts` covers held parent flights, deadline refusal, recovery copies, invalid authority and concurrent edits. Codex multi-agent management changes that can resolve a runtime additionally await fresh [selection admission](codex-cli-update.md#worker-realm-and-the-selection-commit-window) before entering this barrier, keep that owner across hardening, and re-evaluate the mode plan inside the synchronous callback; neither ACL wait is inserted into the callback, and refusal leaves the combined mutation untouched. Timer-driven caches under the config root admit their write the same way and build the snapshot inside the callback: the provider account quota cache (`src/providers/account-quota-disk.ts`), the Codex account quota cache (`src/codex/quota.ts`) and the reasoning support cache, while a models.dev refresh admits its publication after the network read (`src/providers/reasoning-metadata.ts`). A timed-out admission skips a best-effort cache write and fails that refresh unpublished, a joined refresh reports the same failure, and a cancel, clear or reset invalidates a write still waiting for admission (#7815; `tests/providers/provider-account-quota-persistence.test.ts`, `tests/codex-integration/reasoning-metadata.test.ts`). `src/config/process-state.ts` derives `ocx.pid` and `runtime-port.json` from that resolved directory. It owns their byte-compatible writes, parsing, expected-PID filters, cheap liveness, full OCX command diff --git a/tests/codex-integration/main-quota-provenance.test.ts b/tests/codex-integration/main-quota-provenance.test.ts index 11db5da67f4..f75163fef92 100644 --- a/tests/codex-integration/main-quota-provenance.test.ts +++ b/tests/codex-integration/main-quota-provenance.test.ts @@ -1,5 +1,5 @@ import { capturePoolQuotaWriter, saveCodexAccountCredential, saveCodexAccountCredentialIfGeneration } from "../../src/codex/account-store"; -import { getAccountQuotaHistory, isValidWhamHistoryObservation } from "../../src/codex/quota"; +import { getAccountQuotaHistory, isValidWhamHistoryObservation, settleQuotaPersistenceForTests } from "../../src/codex/quota"; import { afterEach, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -75,12 +75,14 @@ function installPersistenceClock() { } /** Run the captured quota persistence callback and read its actual disk snapshot without a sleep. */ -function flushPersistence(): string { +async function flushPersistence(): Promise { if (!pendingPersist) throw new Error("Expected a scheduled quota persistence"); const pending = pendingPersist; pendingPersist = undefined; clearTimeout(pending.timer); pending.run(); + // The callback admits its write after any overlapping config-root ACL flight (#7815). + await settleQuotaPersistenceForTests(); return readFileSync(join(testDir, "codex-quota-cache.json"), "utf8"); } @@ -276,7 +278,7 @@ describe("main policy quota writes", () => { }); } - test("header writer carries provenance and untagged main writes invalidate it", () => { + test("header writer carries provenance and untagged main writes invalidate it", async () => { const writer = writerFor(); const headers = new Headers({ "x-codex-primary-used-percent": "99" }); applyAccountQuotaFromUpstreamHeaders(MAIN, headers, undefined, writer); @@ -289,10 +291,10 @@ describe("main policy quota writes", () => { updateAccountQuota(MAIN, 20); expect(getMainPolicyQuota()).toBeNull(); expect(getAccountQuota(MAIN)?.weeklyPercent).toBe(20); - expect(JSON.parse(flushPersistence()).mainPolicyQuota).toBeUndefined(); + expect(JSON.parse(await flushPersistence()).mainPolicyQuota).toBeUndefined(); }); - test("public quota mutation and serializers cannot expose or mutate policy provenance", () => { + test("public quota mutation and serializers cannot expose or mutate policy provenance", async () => { const writer = writerFor(); observeMainQuotaCredential("fixture-private-bearer", "fixture-main-a"); setAccountQuotaFromParsed(MAIN, { weeklyPercent: 99 }, undefined, writer); @@ -301,7 +303,7 @@ describe("main policy quota writes", () => { expect(getMainPolicyQuota()?.weeklyPercent).toBe(99); const publicJson = JSON.stringify(Object.fromEntries(listAccountQuotas())); expect(publicJson).not.toContain("identityKey"); - const disk = flushPersistence(); + const disk = await flushPersistence(); expect(disk).not.toContain("fixture-private-bearer"); expect(disk).not.toContain("fixture-main-a"); expect(disk).not.toContain("bearerHmac"); @@ -310,7 +312,7 @@ describe("main policy quota writes", () => { }); }); -test("window replacement persists without carrying its proof into later partial updates", () => { +test("window replacement persists without carrying its proof into later partial updates", async () => { const cfg = { codexMainAccountHardLock: true }; const writer = writerFor(); /** Publish both parsed projections with the captured writer throughout the simulated restart. */ @@ -324,7 +326,7 @@ test("window replacement persists without carrying its proof into later partial allowed: true, primary_window: { used_percent: 35, limit_window_seconds: 604_800 }, secondary_window: null, } }); // Execute quota's actual debounced serializer through the existing deterministic clock. - const persisted = flushPersistence(); + const persisted = await flushPersistence(); expect(JSON.parse(persisted).mainPolicyQuota.quota.weeklyPercent).toBe(35); expect(persisted).not.toContain("shortWindowAbsent"); clearAccountQuota(); @@ -359,11 +361,11 @@ describe("main policy quota durability and lifecycle", () => { { name: "startup file replacement", account: "fixture-main-b", tag: "known", selected: true, source: "file" }, { name: "startup same-account file", account: "fixture-main-a", tag: "known", selected: false, source: "file", refusal: "CodexMainAccountHardLockError" }, ]) { - test(`cold keyring Pool preserves quota unless replacement is proven: ${scenario.name}`, () => { + test(`cold keyring Pool preserves quota unless replacement is proven: ${scenario.name}`, async () => { const writer = writerFor("fixture-main-a"); setAccountQuotaFromParsed(MAIN, { weeklyPercent: 100, weeklyResetAt: Date.now() / 1000 + 3600 }, undefined, writer); setAccountQuotaFromParsed("fixture-stored", { weeklyPercent: 33 }); - const snapshot = JSON.parse(flushPersistence()); + const snapshot = JSON.parse(await flushPersistence()); if (scenario.tag === "missing") delete snapshot.mainPolicyQuota; if (scenario.tag === "invalid") snapshot.mainPolicyQuota.identityKey = "invalid-fixture-identity"; writeSnapshot(snapshot); @@ -431,7 +433,7 @@ describe("main policy quota durability and lifecycle", () => { } for (const unavailable of [true, false]) { - test(`persisted ${unavailable ? "unavailable" : "legacy"} null remains identity-bound and cannot authorize spending`, () => { + test(`persisted ${unavailable ? "unavailable" : "legacy"} null remains identity-bound and cannot authorize spending`, async () => { const writer = writerFor(); const resetAt = Date.now() + 86_400_000; setAccountQuotaFromParsed(MAIN, { weeklyPercent: 100, weeklyResetAt: resetAt, @@ -447,7 +449,7 @@ describe("main policy quota durability and lifecycle", () => { expect(quota?.creditObservationUnavailable).toBe(unavailable ? true : undefined); expect(hasSpendableCodexCredits(quota)).toBe(false); expect(isCodexAccountHeldForCredits({ creditCodexAccountIds: [MAIN] }, MAIN, quota, "plus", Date.now())).toBe(true); - const persisted = JSON.parse(flushPersistence()); + const persisted = JSON.parse(await flushPersistence()); expect(persisted.mainPolicyQuota).toEqual({ identityKey: writer.identityKey, quota }); // Read the actual serializer output in a fresh process, without clearing or rewriting it. const script = ` @@ -474,7 +476,7 @@ describe("main policy quota durability and lifecycle", () => { }, SPAWN_BUDGET_MS); } - test("stale main writers cannot replace unavailable evidence or attach it to a new identity", () => { + test("stale main writers cannot replace unavailable evidence or attach it to a new identity", async () => { const oldA = writerFor(); const unavailable = parseUsageQuota({ credits: {} }); const positive = { hasCredits: true, balance: 5, observedAt: Date.now() }; @@ -498,7 +500,7 @@ describe("main policy quota durability and lifecycle", () => { expect(getAccountQuota(MAIN)?.credits).toBeNull(); expect(hasSpendableCodexCredits(getMainPolicyQuota())).toBe(false); expect(isCodexAccountHeldForCredits({ creditCodexAccountIds: [MAIN] }, MAIN, getMainPolicyQuota(), "plus", Date.now())).toBe(true); - expect(JSON.parse(flushPersistence()).mainPolicyQuota).toEqual({ identityKey: newA.identityKey, quota: before }); + expect(JSON.parse(await flushPersistence()).mainPolicyQuota).toEqual({ identityKey: newA.identityKey, quota: before }); }); for (const resetAt of [undefined, 4_000_000_000]) { @@ -535,12 +537,12 @@ describe("main policy quota durability and lifecycle", () => { }, SPAWN_BUDGET_MS); } - test("unrelated persistence hydrates and retains policy after legacy TTL expiry", () => { + test("unrelated persistence hydrates and retains policy after legacy TTL expiry", async () => { const writer = writerFor(); const quota = { weeklyPercent: 99, updatedAt: Date.now() - 7 * 60 * 60_000 }; writeSnapshot({ version: 1, quotas: { [MAIN]: quota }, mainPolicyQuota: { identityKey: writer.identityKey, quota } }); setAccountQuotaFromParsed("fixture-pool", { weeklyPercent: 12 }); - const saved = JSON.parse(flushPersistence()); + const saved = JSON.parse(await flushPersistence()); expect(saved.quotas[MAIN]).toBeUndefined(); expect(saved.mainPolicyQuota.quota).toEqual(quota); expect(getMainPolicyQuota()).toEqual(quota); @@ -549,7 +551,7 @@ describe("main policy quota durability and lifecycle", () => { setAccountQuotaFromParsed(MAIN, { resetCredits: 0 }, undefined, writer); expect(getMainPolicyQuota()).toMatchObject({ weeklyPercent: 99, resetCredits: 0 }); expect(getAccountQuota(MAIN)).toEqual({ resetCredits: 0, updatedAt: expect.any(Number) }); - const afterCredits = JSON.parse(flushPersistence()); + const afterCredits = JSON.parse(await flushPersistence()); expect(afterCredits.quotas[MAIN].weeklyPercent).toBeUndefined(); expect(afterCredits.mainPolicyQuota.quota.weeklyPercent).toBe(99); expect(getMainAccountHardLockStatus({ codexMainAccountHardLock: true }).state).toBe("blocked"); @@ -557,7 +559,7 @@ describe("main policy quota durability and lifecycle", () => { expect(getMainPolicyQuota()?.weeklyPercent).toBe(99); clearAccountQuota(MAIN); expect(getMainPolicyQuota()).toBeNull(); - expect(JSON.parse(flushPersistence()).mainPolicyQuota).toBeUndefined(); + expect(JSON.parse(await flushPersistence()).mainPolicyQuota).toBeUndefined(); }); test("clear before first hydration cannot resurrect disk policy", () => { @@ -612,7 +614,7 @@ describe("main policy quota durability and lifecycle", () => { }); -test("pool history records fresh windows only and preserves identity across token refresh", () => { +test("pool history records fresh windows only and preserves identity across token refresh", async () => { const credential = { accessToken: "history-token", refreshToken: "history-refresh", chatgptAccountId: "history-account", expiresAt: Date.now() + 3600_000 }; const generation = saveCodexAccountCredential("history-pool", credential); const writer = capturePoolQuotaWriter("history-pool", { ...credential, generation })!; @@ -637,7 +639,7 @@ test("pool history records fresh windows only and preserves identity across toke expect(rows).toHaveLength(2); expect(isValidWhamHistoryObservation({ rate_limit: { primary_window: { used_percent: 101 } } })).toBe(false); expect(isValidWhamHistoryObservation({ additional_rate_limits: [{ rate_limit: { primary_window: { used_percent: -1 } } }] })).toBe(false); - const body = flushPersistence(); + const body = await flushPersistence(); expect(JSON.parse(body).history.accounts["history-pool"].samples).toHaveLength(2); expect(body).not.toContain("history-token"); expect(body).not.toContain("history-refresh"); @@ -648,11 +650,11 @@ test("pool history records fresh windows only and preserves identity across toke expect(getAccountQuotaHistory("history-pool").observations).toEqual([]); }); -test("native main observations and oversized cache never become pool history", () => { +test("native main observations and oversized cache never become pool history", async () => { const raw = { weeklyPercent: 20 }; setAccountQuotaFromParsed(MAIN, raw, undefined, writerFor()); expect(getAccountQuotaHistory(MAIN).observations).toEqual([]); - const persisted = JSON.parse(flushPersistence()); + const persisted = JSON.parse(await flushPersistence()); expect(persisted.history.accounts).not.toHaveProperty(MAIN); clearAccountQuota(); const credential = { accessToken: "large-cache-access", refreshToken: "large-cache-refresh", chatgptAccountId: "large-cache-account", expiresAt: Date.now() + 3600_000 }; diff --git a/tests/codex-integration/reasoning-metadata.test.ts b/tests/codex-integration/reasoning-metadata.test.ts index 882b9151f4f..4bd1f5cb9dc 100644 --- a/tests/codex-integration/reasoning-metadata.test.ts +++ b/tests/codex-integration/reasoning-metadata.test.ts @@ -322,7 +322,7 @@ describe("learned rung refusals", () => { provider: ZEN_GO, modelId: DEEPSEEK_FLASH, requested: "max", rejectionText: REJECTION_BODY, }); expect(first).toEqual({ effort: "high", recorded: true }); - metadata.flushReasoningSupportCache(); + await metadata.flushReasoningSupportCache(); const second = metadata.planReasoningEffortDowngrade({ provider: ZEN_GO, modelId: DEEPSEEK_FLASH, requested: "max", rejectionText: REJECTION_BODY, }); @@ -402,3 +402,63 @@ describe("destination resolution", () => { expect(zenGo.models).toBe(1); }); }); + +describe("config-root ACL admission (#7815)", () => { + test("a refresh publishes its snapshot only after an in-flight config-root ACL flight settles", async () => { + const { metadata } = await load(); + const home = process.env["OPENCODEX_HOME"]!; + const acl = await import("../../src/lib/windows-secret-acl"); + const { setSyntheticWindowsPrincipalForTests } = await import("../../src/lib/windows-user-principal"); + const { existsSync } = await import("node:fs"); + const success = { success: true, exitCode: 0, timedOut: false, stdout: "" }; + let entered!: () => void, release!: () => void, fetched!: () => void; + const started = new Promise(resolve => { entered = resolve; }); + const held = new Promise(resolve => { release = resolve; }); + const fetchedOnce = new Promise(resolve => { fetched = resolve; }); + const previousFetch = globalThis.fetch; + acl.resetHardenedStateForTests(); + acl.setPlatformForTests("win32"); + setSyntheticWindowsPrincipalForTests("*S-1-5-21-1-2-3-1001"); + acl.setIcaclsRunnerForTests(() => success); + acl.setAsyncIcaclsRunnerForTests(async () => { entered(); await held; return success; }); + try { + globalThis.fetch = (async () => { fetched(); return new Response("{}", { status: 200 }); }) as unknown as typeof fetch; + const flight = acl.hardenSecretDirAsync(home, { required: false }); + await started; + let refresh: Promise | undefined; + try { + refresh = metadata.refreshReasoningMetadata({ force: true }); + await fetchedOnce; + await new Promise(resolve => setImmediate(resolve)); + expect(existsSync(join(home, "reasoning-metadata-cache.json"))).toBe(false); + } finally { release(); await flight; } + expect(await refresh).toMatchObject({ ok: true, reason: "refreshed" }); + expect(existsSync(join(home, "reasoning-metadata-cache.json"))).toBe(true); + } finally { + globalThis.fetch = previousFetch; + acl.setAsyncIcaclsRunnerForTests(null); + acl.setIcaclsRunnerForTests(null); + acl.setPlatformForTests(null); + setSyntheticWindowsPrincipalForTests(null); + acl.resetHardenedStateForTests(); + metadata.resetReasoningMetadataCachesForTests(); + } + }); + + test("a joined refresh reports the shared failure instead of a coalesced success", async () => { + const { metadata } = await load(); + const previousFetch = globalThis.fetch; + let reject!: (error: Error) => void; + try { + globalThis.fetch = (() => new Promise((_resolve, fail) => { reject = fail; })) as unknown as typeof fetch; + const first = metadata.refreshReasoningMetadata({ force: true }); + const joined = metadata.refreshReasoningMetadata({ force: true }); + reject(new Error("fixture refusal")); + expect(await first).toEqual({ ok: false, reason: "fixture refusal" }); + expect(await joined).toEqual({ ok: false, reason: "fixture refusal" }); + } finally { + globalThis.fetch = previousFetch; + metadata.resetReasoningMetadataCachesForTests(); + } + }); +}); diff --git a/tests/providers/provider-account-quota-persistence.test.ts b/tests/providers/provider-account-quota-persistence.test.ts index 22c4ccf2618..90e1e792024 100644 --- a/tests/providers/provider-account-quota-persistence.test.ts +++ b/tests/providers/provider-account-quota-persistence.test.ts @@ -188,3 +188,68 @@ describe("provider quota snapshot deadlines", () => { expect(readPersistedAccountQuotas().get(KEY)?.monthlyPercent).toBe(20); }); }); + +// #7815: a cache write under the config root waits out an overlapping optional config-root ACL +// flight, whose inheritance would otherwise race the new file's explicit grant. +test("a snapshot write waits for an in-flight config-root ACL flight, then lands", async () => { + const acl = await import("../../src/lib/windows-secret-acl"); + const { setSyntheticWindowsPrincipalForTests } = await import("../../src/lib/windows-user-principal"); + const success = { success: true, exitCode: 0, timedOut: false, stdout: "" }; + let entered!: () => void, release!: () => void; + const started = new Promise(resolve => { entered = resolve; }); + const held = new Promise(resolve => { release = resolve; }); + acl.resetHardenedStateForTests(); + acl.setPlatformForTests("win32"); + setSyntheticWindowsPrincipalForTests("*S-1-5-21-1-2-3-1001"); + acl.setIcaclsRunnerForTests(() => success); + acl.setAsyncIcaclsRunnerForTests(async () => { entered(); await held; return success; }); + try { + const flight = acl.hardenSecretDirAsync(home, { required: false }); + await started; + try { + schedulePersistAccountQuotas(() => [[KEY, quota(15)]]); + await settle(); + expect(existsSync(join(home, FILE))).toBe(false); + } finally { release(); await flight; } + await settle(); + expect(readPersistedAccountQuotas().get(KEY)?.monthlyPercent).toBe(15); + } finally { + acl.setAsyncIcaclsRunnerForTests(null); + acl.setIcaclsRunnerForTests(null); + acl.setPlatformForTests(null); + setSyntheticWindowsPrincipalForTests(null); + acl.resetHardenedStateForTests(); + } +}); + +test("a snapshot still waiting for ACL admission is dropped by cancellation", async () => { + const acl = await import("../../src/lib/windows-secret-acl"); + const { setSyntheticWindowsPrincipalForTests } = await import("../../src/lib/windows-user-principal"); + const success = { success: true, exitCode: 0, timedOut: false, stdout: "" }; + let entered!: () => void, release!: () => void; + const started = new Promise(resolve => { entered = resolve; }); + const held = new Promise(resolve => { release = resolve; }); + acl.resetHardenedStateForTests(); + acl.setPlatformForTests("win32"); + setSyntheticWindowsPrincipalForTests("*S-1-5-21-1-2-3-1001"); + acl.setIcaclsRunnerForTests(() => success); + acl.setAsyncIcaclsRunnerForTests(async () => { entered(); await held; return success; }); + try { + const flight = acl.hardenSecretDirAsync(home, { required: false }); + await started; + try { + schedulePersistAccountQuotas(() => [[KEY, quota(15)]]); + await settle(); + // The timer fired and the write is waiting; a clear must still win. + cancelPendingAccountQuotaPersist(); + } finally { release(); await flight; } + await settle(); + expect(existsSync(join(home, FILE))).toBe(false); + } finally { + acl.setAsyncIcaclsRunnerForTests(null); + acl.setIcaclsRunnerForTests(null); + acl.setPlatformForTests(null); + setSyntheticWindowsPrincipalForTests(null); + acl.resetHardenedStateForTests(); + } +}); From 8e58c9683d7d1e0fd2dbb3604cf05dca62b02b99 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 30 Oct 2026 22:57:44 +0900 Subject: [PATCH 77/84] test(cli): name the pinned-port holder when a sibling misses its deadline (#8003) * test(cli): name the pinned-port holder when a sibling misses its deadline A pinned sibling start that misses its record deadline is ambiguous: the port was taken between probe and bind, the child bound without writing its record, or it never bound. Report whether the port is bindable and, on Windows, the netstat owners of that port (windows 5/11 of lane=all run 41061583113). * test(cli): keep the port-holder diagnostic best effort and read-only while the child lives Review: a missing netstat threw over the original timeout, and the bind probe ran while the child was alive. List sockets before the kill with a bounded, non-throwing netstat, then probe bindability after the child exits. * test(cli): bound the post-kill wait in the port-holder diagnostic * test(cli): skip the bind probe while the killed child may still be alive --------- Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> --- tests/cli/cli-start-journal-order.test.ts | 37 ++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/tests/cli/cli-start-journal-order.test.ts b/tests/cli/cli-start-journal-order.test.ts index 4a6def739e0..969d75f5245 100644 --- a/tests/cli/cli-start-journal-order.test.ts +++ b/tests/cli/cli-start-journal-order.test.ts @@ -397,6 +397,34 @@ function freeLoopbackPort(): number { return port; } +/** + * Who holds a pinned port when a sibling missed its deadline: the child itself (bound, record not + * yet written), another process (the port was taken between probe and bind) or nobody (the child + * never reached its bind). The bare deadline cannot tell these apart. + * + * Best effort and read-only while the child lives: socket owners are listed before the child is + * killed, the bind probe runs after. Nothing here may throw over the original timeout. + */ +function pinnedPortSockets(port: number, childPid: number): string { + if (process.platform !== "win32") return ""; + try { + const listing = Bun.spawnSync(["netstat", "-ano", "-p", "TCP"], { stdout: "pipe", stderr: "ignore", timeout: 5_000 }); + if (listing.exitCode !== 0) return `; netstat exit ${listing.exitCode}`; + const rows = [...new Set(listing.stdout.toString().split(/\r?\n/) + .map(line => line.trim().split(/\s+/)) + .filter(cols => cols.length >= 5 && cols[1]!.endsWith(`:${port}`)) + .map(cols => `${cols[3]} pid ${cols[4]}${cols[4] === String(childPid) ? " (child)" : ""}`))]; + return rows.length ? `; ${rows.join(", ")}` : "; no socket rows"; + } catch (error) { + return `; netstat unavailable (${(error as NodeJS.ErrnoException).code ?? "error"})`; + } +} + +function pinnedPortBindable(port: number): string { + try { Bun.serve({ hostname: "127.0.0.1", port, fetch: () => new Response(null) }).stop(true); return "free after kill"; } + catch { return "still bound after kill"; } +} + /** * Spawn `ocx start` and wait for its runtime record and settled readiness. * @@ -446,7 +474,14 @@ async function startSibling( // An early exit already reported its stderr and consumed the stream. if (exitedEarly) throw error; // Report what the child was doing, which the bare deadline message cannot. + const sockets = port === undefined ? "" : pinnedPortSockets(port, child.pid); if (child.exitCode === null) child.kill("SIGKILL"); + // Bounded like tail() below: the diagnostic must never outlast the original failure. + const exited = port === undefined + || await Promise.race([child.exited.then(() => true), Bun.sleep(5_000).then(() => false)]); + // A bind probe while the child may still be alive would only find the child itself. + const holders = port === undefined ? "not pinned" + : `${exited ? pinnedPortBindable(port) : "child alive 5s after kill; bind probe skipped"}${sockets}`; const tail = async (stream: unknown): Promise => { try { return await Promise.race([ @@ -459,7 +494,7 @@ async function startSibling( }; const [stdout, stderr] = await Promise.all([tail(child.stdout), tail(child.stderr)]); throw new Error( - `${(error as Error).message} (requested port ${port ?? "config"}, pid ${child.pid}, last record ${lastRecord ?? "none"})\n` + `${(error as Error).message} (requested port ${port ?? "config"}, pid ${child.pid}, last record ${lastRecord ?? "none"}, port holders: ${holders})\n` + `--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`, ); } From 3cab4a74e4eec15ee5ff3bdd59dcab8ede938372 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 30 Oct 2026 23:26:26 +0900 Subject: [PATCH 78/84] test(anthropic): await the admitted provider quota cache write (#8004) #7998 made the provider account quota cache write wait for config-root ACL admission, so the deferred-persistence test read the file before it existed on Windows (ENOENT, windows 9/11 of lane=all run 41068188012). Expose the admitted write like settleQuotaPersistenceForTests and await it. Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> --- src/providers/account-quota-disk.ts | 5 ++++- tests/adapters/anthropic/anthropic-ratelimit-headers.test.ts | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/providers/account-quota-disk.ts b/src/providers/account-quota-disk.ts index bf6593f7a98..362aa622b62 100644 --- a/src/providers/account-quota-disk.ts +++ b/src/providers/account-quota-disk.ts @@ -44,6 +44,9 @@ let persistTimer: ReturnType | null = null; let persistStartedAt: number | null = null; /** Bumped by cancellation so a fired callback still waiting for ACL admission writes nothing. */ let persistGeneration = 0; +let persistWrite: Promise = Promise.resolve(); +/** Test seam: wait for a fired persistence callback's admitted write (as settleQuotaPersistenceForTests). */ +export function settleAccountQuotaPersistForTests(): Promise { return persistWrite; } /** Read the snapshot. Returns an empty map for a missing, corrupt or stale file. */ export function readPersistedAccountQuotas(now = Date.now()): Map { @@ -111,7 +114,7 @@ export function schedulePersistAccountQuotas( // skips this best-effort write. const dir = getConfigDir(); const generation = persistGeneration; - void withConfigDirHardeningSettled(dir, () => { + persistWrite = withConfigDirHardeningSettled(dir, () => { if (generation !== persistGeneration) return; const out: Record = {}; for (const [key, quota] of rows()) out[key] = quota; diff --git a/tests/adapters/anthropic/anthropic-ratelimit-headers.test.ts b/tests/adapters/anthropic/anthropic-ratelimit-headers.test.ts index c9809009f9d..022eeb6009e 100644 --- a/tests/adapters/anthropic/anthropic-ratelimit-headers.test.ts +++ b/tests/adapters/anthropic/anthropic-ratelimit-headers.test.ts @@ -28,6 +28,7 @@ import { import { getAccountSet, saveCredential, setActiveAccount } from "../../../src/oauth/store"; import { clearPoolRotationState } from "../../../src/codex/pool-rotation"; import { removeTreeWithRetry } from "../../helpers/remove-tree"; +import { settleAccountQuotaPersistForTests } from "../../../src/providers/account-quota-disk"; import type { OcxConfig } from "../../../src/types"; const originalHome = process.env.OPENCODEX_HOME; @@ -810,6 +811,8 @@ describe("Anthropic known-reset expiry", () => { try { observe(id!); } finally { timer.mockRestore(); } now += 60_000; flush(); + // The fired callback admits its write after any overlapping config-root ACL flight (#7815). + await settleAccountQuotaPersistForTests(); const disk = JSON.parse(readFileSync(join(home, "provider-account-quota-cache.json"), "utf8")); expect(disk.rows[`anthropic\u0000${id}`]).toEqual({ fiveHourPercent: 41, updatedAt: start }); expect(disk.rows["zai\u0000untouched"]).toEqual(saved); From c34d26ff2bde9516517fe7fd58c2bcda70ca5e03 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 31 Oct 2026 04:42:12 +0900 Subject: [PATCH 79/84] test(claude): report the hardened local-CA ACL in the first-party apply suite (#8016) * test(claude): report the hardened local-CA ACL in the first-party apply suite The local-CA ACL inspection spawns PowerShell under the shared 30 s principal budget, which a loaded hosted Windows runner exhausted (windows 1/11 of lane=all run 41073826161: local_ca_path_unsafe after 30095 ms). This suite covers first-party apply; inject the private ACL for the real current SID through the existing seam. Native inspection stays covered by claude-intercept-local-ca-windows.test.ts. * test(claude): read the cached SID inside the injected ACL runner and reset it in finally Review: no separate principal lookup before the environment snapshot; reset the seam even if the hardening drain throws. --------- Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> --- .../claude-desktop-first-party.test.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/tests/claude-integration/claude-desktop-first-party.test.ts b/tests/claude-integration/claude-desktop-first-party.test.ts index 3ab33edae87..1595da1f9b4 100644 --- a/tests/claude-integration/claude-desktop-first-party.test.ts +++ b/tests/claude-integration/claude-desktop-first-party.test.ts @@ -25,6 +25,8 @@ import { firstPartyDesired } from "../../src/claude/first-party-settings"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { flushConfigDirHardeningAndReaps } from "../../src/config/paths"; +import { setLocalCaWindowsAclRunnerForTests } from "../../src/claude/intercept/local-ca-windows"; +import { resolveCurrentWindowsPrincipal } from "../../src/lib/windows-user-principal"; let root = ""; let lifecycleLock: ReturnType; @@ -72,10 +74,23 @@ beforeEach(() => { process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = library; process.env.CLAUDE_CONFIG_DIR = claudeDir; writeFileSync(join(root, "config.json"), JSON.stringify(config())); + // The local-CA ACL inspection spawns PowerShell under the shared 30 s principal-lookup budget, + // which a loaded hosted Windows runner can exhaust (local_ca_path_unsafe). This file tests + // first-party apply; claude-intercept-local-ca.test.ts runs the native inspection. Report the + // private ACL for the current SID, which production resolves (and caches) before this runner. + if (process.platform === "win32") { + setLocalCaWindowsAclRunnerForTests((_path, timeoutMs) => { + const sid = resolveCurrentWindowsPrincipal(timeoutMs).replace(/^\*/, ""); + return { success: true, exitCode: 0, timedOut: false, stdout: JSON.stringify({ + owner: sid, protected: true, rules: [{ sid, type: 0, rights: 2032127 }], + }) }; + }); + } }); afterEach(async () => { - await flushConfigDirHardeningAndReaps(root); + try { await flushConfigDirHardeningAndReaps(root); } + finally { setLocalCaWindowsAclRunnerForTests(null); } lifecycleLock.restore(); for (const key of ENV_KEYS) { if (previous[key] === undefined) delete process.env[key]; From 86b72a689c21b97b0a425303e64b54ccceeb530a Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 31 Oct 2026 06:17:51 +0900 Subject: [PATCH 80/84] fix: compare file identity with bigint stat (#8019) * fix: compare file identity with bigint stat NTFS file IDs can exceed 2^53, where adjacent IDs are equal as JS numbers. Take dev/ino from stat calls made with { bigint: true } wherever a module compares file identity, and add a shared FileIdentity helper. * test: register file-identity tests in a layout shard and scan stat calls with balanced parentheses * test: resolve guard scopes by declaration and ignore strings and comments * test: mask template text and regex literals, require same-line method bodies and an exact bigint options argument * test: recognize regex literals after keywords in the identity tripwire * fix(oauth): name the best-effort catches on the rewritten lock line * fix(oauth): name the best-effort metadata parse catch beside the lock line --------- Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> --- scripts/test-layout/layout-file-identity.json | 4 + scripts/test-layout/layout.json | 2 +- src/adapters/command-code-project-context.ts | 6 +- src/claude/desktop-remote-store-io.ts | 16 +- src/claude/intercept/proxy-auth.ts | 32 +-- src/codex/catalog/write-audit.ts | 36 ++-- src/config/initialize.ts | 12 +- src/integrations/aside-profile-context.ts | 2 +- src/integrations/droid-settings.ts | 8 +- src/integrations/hermes-profiles.ts | 6 +- src/lib/bounded-file-read.ts | 12 +- src/lib/file-identity.ts | 10 + src/lib/spend-pool-alias-validation.ts | 12 +- src/oauth/store.ts | 12 +- src/providers/cc-switch-import-source.ts | 16 +- src/server/persistent-gui-session.ts | 8 +- src/service/state-lock.ts | 22 +- .../codex-catalog-write-audit.test.ts | 29 ++- tests/config/config-mutation-lock.test.ts | 25 ++- tests/fixtures/test-layout-expected.json | 2 + tests/lib/file-identity-callers.test.ts | 191 ++++++++++++++++++ tests/lib/file-identity.test.ts | 12 ++ tests/providers/cc-switch-import.test.ts | 23 +++ .../command-code-project-context.test.ts | 34 +++- tests/service/service-ownership-state.test.ts | 22 +- 25 files changed, 442 insertions(+), 112 deletions(-) create mode 100644 scripts/test-layout/layout-file-identity.json create mode 100644 src/lib/file-identity.ts create mode 100644 tests/lib/file-identity-callers.test.ts create mode 100644 tests/lib/file-identity.test.ts diff --git a/scripts/test-layout/layout-file-identity.json b/scripts/test-layout/layout-file-identity.json new file mode 100644 index 00000000000..e9595bddef2 --- /dev/null +++ b/scripts/test-layout/layout-file-identity.json @@ -0,0 +1,4 @@ +{ + "file-identity.test.ts": "lib", + "file-identity-callers.test.ts": "lib" +} diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 2442a618c9c..3232f870b0f 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1,7 +1,7 @@ { "version": 1, "root": "tests", - "explicitShards": ["layout-clients.json", "layout-codex-collision.json", "layout-agent-assertion.json", "layout-chat.json", "layout-replay.json", "layout-adapters.json", "layout-jev.json", "layout-workbuddy.json", "layout-combos.json", "layout-credit-renewal.json", "layout-server-inbound.json", "layout-providers.json", "layout-responses.json", "layout-exec-boundary.json", "layout-persistent-dashboard.json", "layout-start-ownership.json", "layout-config.json", "layout-kiro-usage.json", "layout-usage.json"], + "explicitShards": ["layout-file-identity.json", "layout-clients.json", "layout-codex-collision.json", "layout-agent-assertion.json", "layout-chat.json", "layout-replay.json", "layout-adapters.json", "layout-jev.json", "layout-workbuddy.json", "layout-combos.json", "layout-credit-renewal.json", "layout-server-inbound.json", "layout-providers.json", "layout-responses.json", "layout-exec-boundary.json", "layout-persistent-dashboard.json", "layout-start-ownership.json", "layout-config.json", "layout-kiro-usage.json", "layout-usage.json"], "explicit": { "service-desktop-recovery.test.ts": "service", "catalog-omit-disabled-native.test.ts": "codex-integration", "provider-input-modalities.test.ts": "providers", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "codex-provider-routing.test.ts": "codex-integration", "config-native-reasoning-retention.test.ts": "config", "server-native-reasoning-retention-merge.test.ts": "server", "server-native-reasoning-retention.test.ts": "server", "config-workflow-budget.test.ts": "config", "server-workflow-budget-settings.test.ts": "server", "responses-retained-reasoning-stream-recovery.test.ts": "responses", "openai-native-hosted-search-history.test.ts": "adapters/openai", "responses-native-compact-search-history.test.ts": "responses", "config-hosted-search-projection.test.ts": "config", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "cli-provider-list-active.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "bridge-terminal-refusal.test.ts": "adapters", "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", "combo-terminal-refusal.test.ts": "routing", "server-terminal-refusal-wiring.test.ts": "server", "incomplete-reason-normalization.test.ts": "responses", "advisor-retention.test.ts": "advisor", "responses-native-key-cooldown.test.ts": "responses", "responses-run-turn-key-readmission.test.ts": "responses", "qoder-spawn-readmission.test.ts": "adapters", "api-key-selection-cooldown.test.ts": "providers", "api-key-auto-pause-distribution.test.ts": "providers", "explicit-key-pause-oauth-boundary.test.ts": "providers", "server-chat-native-key-selection.test.ts": "server", "compact-key-selection.test.ts": "responses", "messages-native-key-selection.test.ts": "claude-integration", "coding-agent-windows-dispatch.test.ts": "adapters", "coding-agent-native-empty-argv.test.ts": "windows", "stop-restore-history-ownership.test.ts": "service", "cli-stop-refusal-transport.test.ts": "cli", diff --git a/src/adapters/command-code-project-context.ts b/src/adapters/command-code-project-context.ts index c79eff473c1..617cbfeda62 100644 --- a/src/adapters/command-code-project-context.ts +++ b/src/adapters/command-code-project-context.ts @@ -189,15 +189,15 @@ function truncateUtf8(text: string, capBytes: number): string { async function openedFileIsConfined( handle: Awaited>, path: string, cwdCanonical: string, deadline: number, scope: ScanScope, ): Promise { - const opened = await withinDeadline(() => handle.stat(), deadline, scope); + const opened = await withinDeadline(() => handle.stat({ bigint: true }), deadline, scope); if (!opened.isFile()) return false; - const current = await withinDeadline(() => lstat(path), deadline, scope); + const current = await withinDeadline(() => lstat(path, { bigint: true }), deadline, scope); if (!current.isFile() || opened.dev !== current.dev || opened.ino !== current.ino) return false; const resolved = await withinDeadline(() => realpath(path), deadline, scope); // The input path was canonical before open. A changed intermediate symlink changes this // result even though O_NOFOLLOW protects only the final component on macOS and Linux. if (!isContainedCanonicalPath(cwdCanonical, resolved) || resolved !== path) return false; - const resolvedInfo = await withinDeadline(() => lstat(resolved), deadline, scope); + const resolvedInfo = await withinDeadline(() => lstat(resolved, { bigint: true }), deadline, scope); return resolvedInfo.isFile() && opened.dev === resolvedInfo.dev && opened.ino === resolvedInfo.ino; } diff --git a/src/claude/desktop-remote-store-io.ts b/src/claude/desktop-remote-store-io.ts index 97579222def..4d33891862b 100644 --- a/src/claude/desktop-remote-store-io.ts +++ b/src/claude/desktop-remote-store-io.ts @@ -1,4 +1,4 @@ -import { chmodSync, lstatSync, mkdirSync, realpathSync, unlinkSync } from "node:fs"; +import { chmodSync, lstatSync, mkdirSync, realpathSync, unlinkSync, type BigIntStats } from "node:fs"; import { basename, dirname, join, resolve } from "node:path"; import { atomicWriteFile, getConfigDir } from "../config"; import { hardenSecretDir } from "../lib/windows-secret-acl"; @@ -8,7 +8,7 @@ import { DesktopStoreError, digest } from "./desktop-remote-store-state"; export const MAX_DESKTOP_METADATA_ENTRIES = 256; export interface JsonFile { value: Record; hash: string; identity: string } -const identity = (s: NonNullable>): string => `${s.dev}:${s.ino}:${s.size}:${s.mtimeMs}`; +const identity = (s: BigIntStats): string => `${s.dev}:${s.ino}:${s.size}:${s.mtimeNs}`; function missing(error: unknown): boolean { return (error as NodeJS.ErrnoException)?.code === "ENOENT"; } export function canonicalDirectory(path: string): string { const absolute = resolve(path); @@ -33,20 +33,20 @@ export class StoreIO { private bytes = 0; private readonly seenSizes = new Map(); read(path: string, maxBytes = 1024 * 1024, privateFile = false): JsonFile | null { - let stat: ReturnType; - try { stat = lstatSync(path); } + let stat: BigIntStats; + try { stat = lstatSync(path, { bigint: true }); } catch (error) { if (missing(error)) return null; throw new DesktopStoreError("unsafe"); } if (!stat.isFile() || stat.isSymbolicLink() || stat.size > maxBytes - || (privateFile && process.platform !== "win32" && (stat.mode & 0o077) !== 0)) throw new DesktopStoreError("unsafe"); + || (privateFile && process.platform !== "win32" && (stat.mode & 0o077n) !== 0n)) throw new DesktopStoreError("unsafe"); const counted = this.seenSizes.get(path) ?? 0; - this.bytes += Math.max(0, stat.size - counted); - this.seenSizes.set(path, Math.max(counted, stat.size)); + this.bytes += Math.max(0, Number(stat.size) - counted); + this.seenSizes.set(path, Math.max(counted, Number(stat.size))); if (this.bytes > 8 * 1024 * 1024) throw new DesktopStoreError("unsafe"); try { // The shared verified regular-file read keeps a swapped FIFO from parking this // open before the post-read identity recheck below can run. const bytes = readDesktopLibraryBytes(path, Math.min(maxBytes, MAX_DESKTOP_PROFILE_FILE_BYTES)); - if (bytes.byteLength > maxBytes || identity(lstatSync(path)) !== identity(stat)) throw new DesktopStoreError("conflict"); + if (bytes.byteLength > maxBytes || identity(lstatSync(path, { bigint: true })) !== identity(stat)) throw new DesktopStoreError("conflict"); const value: unknown = JSON.parse(bytes.toString("utf8")); if (!isRecord(value)) throw new DesktopStoreError("unsafe"); return { value, hash: digest(bytes.toString("utf8")), identity: identity(stat) }; diff --git a/src/claude/intercept/proxy-auth.ts b/src/claude/intercept/proxy-auth.ts index a1131b5f913..80ea683119b 100644 --- a/src/claude/intercept/proxy-auth.ts +++ b/src/claude/intercept/proxy-auth.ts @@ -1,7 +1,7 @@ import { randomBytes } from "node:crypto"; import { closeSync, constants, fchmodSync, fstatSync, linkSync, lstatSync, mkdirSync, - openSync, readSync, unlinkSync, writeFileSync, type Stats, + openSync, readSync, unlinkSync, writeFileSync, type BigIntStats, } from "node:fs"; import { join } from "node:path"; import { forgetEphemeralSecretPath, hardenSecretDir, hardenSecretPath } from "../../lib/windows-secret-acl"; @@ -20,15 +20,15 @@ function invalidCredential(): Error { return new Error("Claude intercept credential is not a safe, valid owner-controlled file"); } -function assertOwnedNode(stat: Stats, directory = false): void { +function assertOwnedNode(stat: BigIntStats, directory = false): void { if (stat.isSymbolicLink() || !(directory ? stat.isDirectory() : stat.isFile())) throw invalidCredential(); - if (process.platform !== "win32" && typeof process.geteuid === "function" && stat.uid !== process.geteuid()) { + if (process.platform !== "win32" && typeof process.geteuid === "function" && stat.uid !== BigInt(process.geteuid())) { throw invalidCredential(); } } -function assertSameNode(path: string, expected: Stats, directory = false): void { - const current = lstatSync(path); +function assertSameNode(path: string, expected: BigIntStats, directory = false): void { + const current = lstatSync(path, { bigint: true }); assertOwnedNode(current, directory); if (current.dev !== expected.dev || current.ino !== expected.ino) throw invalidCredential(); } @@ -36,17 +36,17 @@ function assertSameNode(path: string, expected: Stats, directory = false): void /** Read only through the validated descriptor, with a fixed maximum allocation and no links. */ function readPinnedToken(configDir: string, harden: boolean): string { const dir = join(configDir, "claude-intercept"); - const directory = lstatSync(dir); + const directory = lstatSync(dir, { bigint: true }); assertOwnedNode(directory, true); - if (process.platform !== "win32" && (directory.mode & 0o077) !== 0) throw invalidCredential(); + if (process.platform !== "win32" && (directory.mode & 0o077n) !== 0n) throw invalidCredential(); const path = claudeInterceptProxyTokenPath(configDir); - const entry = lstatSync(path); + const entry = lstatSync(path, { bigint: true }); assertOwnedNode(entry); if (entry.size < TOKEN_LENGTH || entry.size > TOKEN_MAX_BYTES) throw invalidCredential(); // NONBLOCK prevents a raced-in FIFO from blocking before fstat can reject it. const fd = openSync(path, constants.O_RDONLY | NOFOLLOW | NONBLOCK); try { - const opened = fstatSync(fd); + const opened = fstatSync(fd, { bigint: true }); assertOwnedNode(opened); if (opened.dev !== entry.dev || opened.ino !== entry.ino || opened.size < TOKEN_LENGTH || opened.size > TOKEN_MAX_BYTES) throw invalidCredential(); @@ -62,8 +62,8 @@ function readPinnedToken(configDir: string, harden: boolean): string { const ending = contents.slice(TOKEN_LENGTH); if (token.length !== TOKEN_LENGTH || !/^[A-Za-z0-9_-]+$/.test(token) || !["", "\n", "\r\n"].includes(ending)) throw invalidCredential(); - const afterRead = fstatSync(fd); - if (length !== afterRead.size || afterRead.mtimeMs !== opened.mtimeMs) throw invalidCredential(); + const afterRead = fstatSync(fd, { bigint: true }); + if (BigInt(length) !== afterRead.size || afterRead.mtimeNs !== opened.mtimeNs) throw invalidCredential(); assertSameNode(path, opened); assertSameNode(dir, directory, true); if (harden) { @@ -71,7 +71,7 @@ function readPinnedToken(configDir: string, harden: boolean): string { else fchmodSync(fd, 0o600); // Never chmod a followed/replaced pathname. assertSameNode(path, opened); assertSameNode(dir, directory, true); - } else if (process.platform !== "win32" && (afterRead.mode & 0o077) !== 0) { + } else if (process.platform !== "win32" && (afterRead.mode & 0o077n) !== 0n) { throw invalidCredential(); } return token; @@ -89,7 +89,7 @@ export function readClaudeInterceptProxyToken(configDir: string): string | null } } -function ensurePrivateDirectory(configDir: string): Stats { +function ensurePrivateDirectory(configDir: string): BigIntStats { const dir = join(configDir, "claude-intercept"); mkdirSync(configDir, { recursive: true, mode: 0o700 }); try { @@ -97,14 +97,14 @@ function ensurePrivateDirectory(configDir: string): Stats { } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; } - const entry = lstatSync(dir); + const entry = lstatSync(dir, { bigint: true }); assertOwnedNode(entry, true); if (process.platform === "win32") { hardenSecretDir(dir, { required: true }); } else { const fd = openSync(dir, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0) | NOFOLLOW); try { - const opened = fstatSync(fd); + const opened = fstatSync(fd, { bigint: true }); assertOwnedNode(opened, true); if (entry.dev !== opened.dev || entry.ino !== opened.ino) throw invalidCredential(); fchmodSync(fd, 0o700); @@ -130,7 +130,7 @@ export function ensureClaudeInterceptProxyToken(configDir: string): string { const token = randomBytes(32).toString("base64url"); const temp = join(dir, `.${TOKEN_FILE}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`); const fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | NOFOLLOW, 0o600); - const created = fstatSync(fd); + const created = fstatSync(fd, { bigint: true }); try { writeFileSync(fd, `${token}\n`, { encoding: "utf8" }); if (process.platform !== "win32") fchmodSync(fd, 0o600); diff --git a/src/codex/catalog/write-audit.ts b/src/codex/catalog/write-audit.ts index 37aeb932056..12b17934344 100644 --- a/src/codex/catalog/write-audit.ts +++ b/src/codex/catalog/write-audit.ts @@ -1,6 +1,6 @@ import { closeSync, constants, fstatSync, ftruncateSync, lstatSync, openSync, readSync, writeSync, - type Stats, + type BigIntStats, } from "node:fs"; import { join } from "node:path"; import { redactUserPath } from "../../lib/redact"; @@ -40,15 +40,15 @@ function auditLine(event: CatalogWriteAuditEvent): Buffer { })}\n`); } -function regularPrivateFile(stat: Stats): boolean { +function regularPrivateFile(stat: BigIntStats): boolean { // POSIX bits cannot prove NTFS privacy. Windows requires fresh-file ACL hardening. - return !windowsSecretAclApplies() && stat.isFile() && stat.nlink === 1 - && stat.uid === process.getuid?.() && (stat.mode & 0o777) === 0o600; + return !windowsSecretAclApplies() && stat.isFile() && stat.nlink === 1n + && stat.uid === BigInt(process.getuid?.() ?? -1) && (stat.mode & 0o777n) === 0o600n; } -function sameFile(left: Stats, right: Stats): boolean { +function sameFile(left: BigIntStats, right: BigIntStats): boolean { return left.dev === right.dev && left.ino === right.ino && left.size === right.size - && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs; + && left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs; } /** Read at most the tail budget plus one byte to locate a complete first record. */ @@ -116,8 +116,8 @@ export function appendCatalogWriteAudit( if (line.length > CATALOG_AUDIT_MAX_EVENT_BYTES) return "skipped"; const path = codexCatalogAuditPath(codexHome); const windows = windowsSecretAclApplies(); - let before: Stats | undefined; - try { before = lstatSync(path); } + let before: BigIntStats | undefined; + try { before = lstatSync(path, { bigint: true }); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT" || !options.create) return "skipped"; } @@ -126,29 +126,29 @@ export function appendCatalogWriteAudit( const flags = constants.O_RDWR | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0) | (before ? (options.create ? 0 : constants.O_APPEND) : constants.O_CREAT | constants.O_EXCL); fd = openSync(path, flags, 0o600); - let opened = fstatSync(fd); + let opened = fstatSync(fd, { bigint: true }); if (windows) { // Existing files have already been refused: no read-only NTFS privacy verifier exists. - if (!opened.isFile() || opened.nlink !== 1 || opened.size !== 0 - || !sameFile(opened, lstatSync(path))) return "skipped"; + if (!opened.isFile() || opened.nlink !== 1n || opened.size !== 0n + || !sameFile(opened, lstatSync(path, { bigint: true }))) return "skipped"; if (!hardenSecretPath(path, { required: true, deadlineMs: AUDIT_ACL_DEADLINE_MS }).ok) return "skipped"; - const hardened = fstatSync(fd); + const hardened = fstatSync(fd, { bigint: true }); // ACL changes legitimately move ctime. Retain object identity, then take a fresh baseline. - if (!hardened.isFile() || hardened.nlink !== 1 || hardened.size !== 0 + if (!hardened.isFile() || hardened.nlink !== 1n || hardened.size !== 0n || opened.dev !== hardened.dev || opened.ino !== hardened.ino - || !sameFile(hardened, lstatSync(path))) return "skipped"; + || !sameFile(hardened, lstatSync(path, { bigint: true }))) return "skipped"; opened = hardened; } else if (!regularPrivateFile(opened) || (before && !sameFile(before, opened))) return "skipped"; - if (!options.create && opened.size + line.length > CATALOG_AUDIT_MAX_BYTES) return "skipped"; - const tail = readTail(fd, opened.size); + if (!options.create && opened.size + BigInt(line.length) > CATALOG_AUDIT_MAX_BYTES) return "skipped"; + const tail = readTail(fd, Number(opened.size)); const parsed = completeRecords(tail); - if (!sameFile(opened, fstatSync(fd)) || !sameFile(opened, lstatSync(path))) return "skipped"; + if (!sameFile(opened, fstatSync(fd, { bigint: true })) || !sameFile(opened, lstatSync(path, { bigint: true }))) return "skipped"; if (!options.create) { if (!parsed.clean || parsed.count + 1 > CATALOG_AUDIT_KEEP_RECORDS) return "skipped"; writeAll(fd, line, null); } else if (opened.size <= CATALOG_AUDIT_MAX_BYTES - line.length && parsed.clean && parsed.count < CATALOG_AUDIT_KEEP_RECORDS) { - writeAll(fd, line, opened.size); + writeAll(fd, line, Number(opened.size)); } else { const retained: Buffer[] = [line]; let bytes = line.length; diff --git a/src/config/initialize.ts b/src/config/initialize.ts index ed9d121eaae..e87a4d1fd2a 100644 --- a/src/config/initialize.ts +++ b/src/config/initialize.ts @@ -43,16 +43,16 @@ function hardenInitialConfig(fd: number, temp: string, target: string): void { } } -function identifiesDescriptor(fd: number, path: string): boolean { - const opened = fstatSync(fd); - const entry = lstatSync(path); +function identifiesDescriptor(fd: number, path: string, privateMode = false): boolean { + const opened = fstatSync(fd, { bigint: true }); + const entry = lstatSync(path, { bigint: true }); return opened.isFile() && entry.isFile() - && opened.dev === entry.dev && opened.ino === entry.ino; + && opened.dev === entry.dev && opened.ino === entry.ino + && (!privateMode || process.platform === "win32" || (opened.mode & 0o777n) === 0o600n); } function verifyPrivateTemp(fd: number, temp: string): void { - if (!identifiesDescriptor(fd, temp) - || (process.platform !== "win32" && (fstatSync(fd).mode & 0o777) !== 0o600)) { + if (!identifiesDescriptor(fd, temp, true)) { throw new Error("Initial config temporary file identity or permissions changed."); } } diff --git a/src/integrations/aside-profile-context.ts b/src/integrations/aside-profile-context.ts index f5091b4b549..5c5c170a917 100644 --- a/src/integrations/aside-profile-context.ts +++ b/src/integrations/aside-profile-context.ts @@ -74,7 +74,7 @@ function storeGuard(anchor: string, target: string): () => void { const identities = new Map(); const inspect = (path: string, directory: boolean): boolean => { try { - const stat = lstatSync(path); + const stat = lstatSync(path, { bigint: true }); if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile()) || (!directory && stat.nlink > 1)) storeUnsafe(); if (directory) { const identity = `${realpathSync(path)}:${stat.dev}:${stat.ino}`; diff --git a/src/integrations/droid-settings.ts b/src/integrations/droid-settings.ts index 0c0989f8130..5bec667200e 100644 --- a/src/integrations/droid-settings.ts +++ b/src/integrations/droid-settings.ts @@ -1,5 +1,5 @@ /** Read-only guard for competing Factory settings before a managed write. */ -import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync } from "node:fs"; +import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, type BigIntStats } from "node:fs"; import { join, win32 } from "node:path"; const isWindowsRoot = (path: string) => /^[A-Za-z]:[\\/]|^\\\\/.test(path); @@ -26,8 +26,8 @@ export function assertDroidSettingsUnambiguous(root: string, baseUrl?: string, m } for (const name of ["config.json", "settings.local.json"]) { const path = isWindowsRoot(root) ? win32.join(root, name) : join(root, name); - let stat: ReturnType; - try { stat = lstatSync(path); } + let stat: BigIntStats; + try { stat = lstatSync(path, { bigint: true }); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw new Error(`Cannot inspect Factory ${name}`); @@ -37,7 +37,7 @@ export function assertDroidSettingsUnambiguous(root: string, baseUrl?: string, m let fd: number | undefined; try { fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); - const opened = fstatSync(fd); + const opened = fstatSync(fd, { bigint: true }); if (!opened.isFile() || opened.size > 1024 * 1024 || opened.ino !== stat.ino || opened.dev !== stat.dev) { throw new Error("file changed during inspection"); } diff --git a/src/integrations/hermes-profiles.ts b/src/integrations/hermes-profiles.ts index e4bda8d2efb..3ffc0dfc3ef 100644 --- a/src/integrations/hermes-profiles.ts +++ b/src/integrations/hermes-profiles.ts @@ -16,7 +16,7 @@ * `/hermes-profiles//`, so its records, journal and snapshots * can never be confused with the default profile's. */ -import { lstatSync, readdirSync, type Stats } from "node:fs"; +import { lstatSync, readdirSync, type BigIntStats } from "node:fs"; import { homedir } from "node:os"; import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import { hermesHomeDir } from "../clients/config-export"; @@ -46,8 +46,8 @@ export class HermesProfileError extends Error { } } -function lstatOrNull(path: string): Stats | null { - try { return lstatSync(path); } +function lstatOrNull(path: string): BigIntStats | null { + try { return lstatSync(path, { bigint: true }); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT" || (error as NodeJS.ErrnoException).code === "ENOTDIR") return null; throw new HermesProfileError("hermes_profiles_unavailable", 409, "Hermes profiles cannot be read"); diff --git a/src/lib/bounded-file-read.ts b/src/lib/bounded-file-read.ts index 42555c57ed2..f37045d0b2c 100644 --- a/src/lib/bounded-file-read.ts +++ b/src/lib/bounded-file-read.ts @@ -1,15 +1,9 @@ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs"; +import { sameFileIdentity } from "./file-identity"; const MAX_BOUNDED_READ_BYTES = 64 * 1024 * 1024; const READ_CHUNK_BYTES = 64 * 1024; -function sameIdentity( - left: { dev: bigint; ino: bigint }, - right: { dev: bigint; ino: bigint }, -): boolean { - return left.dev === right.dev && left.ino === right.ino; -} - export function readBoundedRegularFileSync(path: string, limit: number): Buffer { if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_BOUNDED_READ_BYTES) { throw new RangeError("invalid bounded file read limit"); @@ -25,7 +19,7 @@ export function readBoundedRegularFileSync(path: string, limit: number): Buffer try { const opened = fstatSync(fd, { bigint: true }); if (!opened.isFile() || opened.size > BigInt(limit)) throw new Error("file is not bounded regular data"); - if (entryBefore && (!sameIdentity(entryBefore, opened) || opened.ino === 0n)) { + if (entryBefore && (!sameFileIdentity(entryBefore, opened) || opened.ino === 0n)) { throw new Error("file entry changed while opening"); } @@ -47,7 +41,7 @@ export function readBoundedRegularFileSync(path: string, limit: number): Buffer if (entryBefore) { const entryAfter = lstatSync(path, { bigint: true }); - if (entryAfter.isSymbolicLink() || !entryAfter.isFile() || !sameIdentity(opened, entryAfter)) { + if (entryAfter.isSymbolicLink() || !entryAfter.isFile() || !sameFileIdentity(opened, entryAfter)) { throw new Error("file entry changed while reading"); } } diff --git a/src/lib/file-identity.ts b/src/lib/file-identity.ts new file mode 100644 index 00000000000..86474299131 --- /dev/null +++ b/src/lib/file-identity.ts @@ -0,0 +1,10 @@ +/** Acquire stats with { bigint: true }; never widen a Number to form an identity. */ +export type FileIdentity = Readonly<{ dev: bigint; ino: bigint }>; + +export function sameFileIdentity(a: FileIdentity, b: FileIdentity): boolean { + return a.dev === b.dev && a.ino === b.ino; +} + +export function fileIdentityKey(id: FileIdentity): string { + return `${id.dev}:${id.ino}`; +} diff --git a/src/lib/spend-pool-alias-validation.ts b/src/lib/spend-pool-alias-validation.ts index 344ad179967..e07c2dbcac2 100644 --- a/src/lib/spend-pool-alias-validation.ts +++ b/src/lib/spend-pool-alias-validation.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { constants, lstatSync, openSync, fstatSync, readFileSync, closeSync, type Stats } from "node:fs"; +import { constants, lstatSync, openSync, fstatSync, readFileSync, closeSync, type BigIntStats } from "node:fs"; import { join } from "node:path"; /** Read an existing single-owner salt without creating or hardening any file. */ @@ -8,14 +8,14 @@ export function readExistingSpendSalt(home: string): string | undefined { let salt: string | undefined; try { const path = join(home, "spend-ledger.salt"); - const before = lstatSync(path); - if (!before.isFile() || before.nlink !== 1) return undefined; - const matches = (stat: Stats): boolean => stat.isFile() && stat.nlink === 1 + const before = lstatSync(path, { bigint: true }); + if (!before.isFile() || before.nlink !== 1n) return undefined; + const matches = (stat: BigIntStats): boolean => stat.isFile() && stat.nlink === 1n && stat.dev === before.dev && stat.ino === before.ino; fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); - if (!matches(fstatSync(fd))) return undefined; + if (!matches(fstatSync(fd, { bigint: true }))) return undefined; const contents = readFileSync(fd, "utf8").trim(); - if (matches(fstatSync(fd)) && matches(lstatSync(path)) && /^[0-9a-f]{32,}$/.test(contents)) salt = contents; + if (matches(fstatSync(fd, { bigint: true })) && matches(lstatSync(path, { bigint: true })) && /^[0-9a-f]{32,}$/.test(contents)) salt = contents; } catch { salt = undefined; } finally { if (fd !== undefined) { diff --git a/src/oauth/store.ts b/src/oauth/store.ts index 12c4339ab8b..2985194917b 100644 --- a/src/oauth/store.ts +++ b/src/oauth/store.ts @@ -429,18 +429,18 @@ function persist(store: AuthStore): void { } export class OAuthFileLockError extends Error { readonly code = "OAUTH_FILE_LOCK_UNAVAILABLE"; constructor(message: string, options?: { cause?: unknown }) { super(message, options); this.name = "OAuthFileLockError"; } } -interface LockSnapshot { bytes: string; dev: number; ino: number; mtimeMs: number; size: number } +interface LockSnapshot { bytes: string; dev: bigint; ino: bigint; mtimeMs: number; mtimeNs: bigint; size: bigint } export interface OAuthFileLockOptions { path: string; waitTimeoutMs?: number; staleAfterMs?: number; pollMinMs?: number; pollMaxMs?: number; sleep?: (ms: number) => Promise; now?: () => number; random?: () => number; beforeStaleUnlink?: () => void; beforeReleaseUnlink?: () => void; beforeFailedCreateUnlink?: () => void; writeMetadata?: (fd: number, bytes: string) => void } export interface OAuthFileLockGuard { readonly ownerId: string; release(): void } function errorCode(error: unknown): string | undefined { return error && typeof error === "object" && "code" in error ? String((error as { code?: unknown }).code) : undefined; } -function snapshot(path: string): LockSnapshot { const bytes = readFileSync(path, "utf8"); const s = statSync(path); return { bytes, dev:s.dev, ino:s.ino, mtimeMs:s.mtimeMs, size:s.size }; } -function sameSnapshot(a: LockSnapshot,b: LockSnapshot): boolean { return a.bytes===b.bytes&&a.dev===b.dev&&a.ino===b.ino&&a.mtimeMs===b.mtimeMs&&a.size===b.size; } -function sameFd(a: LockSnapshot,b: ReturnType): boolean { return a.dev===b.dev&&a.ino===b.ino&&a.mtimeMs===b.mtimeMs&&a.size===b.size; } +function snapshot(path: string): LockSnapshot { const bytes = readFileSync(path, "utf8"); const s = statSync(path, { bigint: true }); return { bytes, dev:s.dev, ino:s.ino, mtimeMs:Number(s.mtimeNs)/1e6, mtimeNs:s.mtimeNs, size:s.size }; } +function sameSnapshot(a: LockSnapshot,b: LockSnapshot): boolean { return a.bytes===b.bytes&&a.dev===b.dev&&a.ino===b.ino&&a.mtimeNs===b.mtimeNs&&a.size===b.size; } +function sameFd(a: LockSnapshot,b: import("node:fs").BigIntStats): boolean { return a.dev===b.dev&&a.ino===b.ino&&a.mtimeNs===b.mtimeNs&&a.size===b.size; } export function createOAuthFileLock(options: OAuthFileLockOptions): { acquire(): Promise } { const wait=options.waitTimeoutMs??5000, stale=options.staleAfterMs??120000, min=options.pollMinMs??25,max=options.pollMaxMs??100,sleep=options.sleep??(ms=>Bun.sleep(ms)),now=options.now??Date.now,random=options.random??Math.random,write=options.writeMetadata??((fd,b)=>writeFileSync(fd,b,"utf8")); if(wait<0||stale<=0||min<0||maxstale){options.beforeStaleUnlink?.();const b=snapshot(options.path);if(sameSnapshot(a,b))unlinkSync(options.path);continue;}}catch(e){if(errorCode(e)==="ENOENT")continue;throw new OAuthFileLockError("Could not inspect OAuth file lock",{cause:e});} const elapsed=now()-started;if(elapsed>=wait)throw new OAuthFileLockError(`Timed out after ${wait}ms waiting for OAuth file lock`);await sleep(Math.min(wait-elapsed,min+Math.floor(random()*(max-min+1)))); } } }; + return { async acquire() { hardenConfigDir(); recordOwnedConfigPath(getConfigDir(),options.path); if(!existsSync(getConfigDir())) mkdirSync(getConfigDir(),{recursive:true,mode:0o700}); const ownerId=randomUUID(),started=now(); for(;;){ let fd:number|undefined; try { fd=openSync(options.path,"wx",0o600); const bytes=`${JSON.stringify({version:1,ownerId,pid:process.pid,createdAt:now()})}\n`; write(fd,bytes); const fs=fstatSync(fd,{bigint:true}); closeSync(fd); fd=undefined; const owned=snapshot(options.path); if(owned.bytes!==bytes||!sameFd(owned,fs)) throw new OAuthFileLockError("OAuth lock changed during creation"); let released=false; return {ownerId,release(){if(released)return;released=true;try{const a=snapshot(options.path);if(!sameSnapshot(owned,a))return;options.beforeReleaseUnlink?.();const b=snapshot(options.path);if(sameSnapshot(owned,b))unlinkSync(options.path);}catch(e){if(errorCode(e)!=="ENOENT")console.warn(`[oauth] lock release failed: ${e instanceof Error?e.message:String(e)}`);}}}; } catch(e) { if(fd!==undefined){let fs;try{fs=fstatSync(fd,{bigint:true});}catch{/* best effort: no identity, no cleanup */}try{closeSync(fd);}catch{/* best effort */}if(fs)try{const a=snapshot(options.path);if(sameFd(a,fs)){options.beforeFailedCreateUnlink?.();const b=snapshot(options.path);if(sameSnapshot(a,b)&&sameFd(b,fs))unlinkSync(options.path);}}catch{/* best-effort cleanup of our own failed create */}} if(errorCode(e)!=="EEXIST")throw e instanceof OAuthFileLockError?e:new OAuthFileLockError("Could not create OAuth file lock",{cause:e}); } + try{const a=snapshot(options.path);let created=a.mtimeMs;try{const p=JSON.parse(a.bytes);if(typeof p.createdAt==="number")created=Math.max(created,p.createdAt);}catch{/* unparsable metadata: fall back to mtime */}if(now()-created>stale){options.beforeStaleUnlink?.();const b=snapshot(options.path);if(sameSnapshot(a,b))unlinkSync(options.path);continue;}}catch(e){if(errorCode(e)==="ENOENT")continue;throw new OAuthFileLockError("Could not inspect OAuth file lock",{cause:e});} const elapsed=now()-started;if(elapsed>=wait)throw new OAuthFileLockError(`Timed out after ${wait}ms waiting for OAuth file lock`);await sleep(Math.min(wait-elapsed,min+Math.floor(random()*(max-min+1)))); } } }; } /** Wait long enough for slow IdP refreshes (e.g. Cursor 15s × 3 attempts) before timing out. */ export const OAUTH_REFRESH_LOCK_WAIT_MS = 60_000; diff --git a/src/providers/cc-switch-import-source.ts b/src/providers/cc-switch-import-source.ts index 576ad2c7300..8f11dc834cf 100644 --- a/src/providers/cc-switch-import-source.ts +++ b/src/providers/cc-switch-import-source.ts @@ -971,10 +971,10 @@ function scanCcSwitchJsonBytesInternal(bytes: Uint8Array): RawExtractedRow[] { export function readSourceRows(sourcePath: string, requireSqlite = false): RawExtractedRow[] { const resolved = resolve(sourcePath); - let beforeStat: ReturnType; + let beforeStat: import("node:fs").BigIntStats; try { assertPlainLocalPath(resolved); - beforeStat = lstatSync(resolved); + beforeStat = lstatSync(resolved, { bigint: true }); } catch { throw new CcSwitchImportError("CC-Switch source path must be a plain regular local file without symlink traversal."); } @@ -993,7 +993,7 @@ export function readSourceRows(sourcePath: string, requireSqlite = false): RawEx resolved, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0), ); - const opened = fstatSync(fd); + const opened = fstatSync(fd, { bigint: true }); if (!opened.isFile()) { throw new CcSwitchImportError("CC-Switch source path must be a regular file."); } @@ -1006,7 +1006,7 @@ export function readSourceRows(sourcePath: string, requireSqlite = false): RawEx ) { throw new CcSwitchImportError("CC-Switch source file changed while opening."); } - size = opened.size; + size = Number(opened.size); const n = readSync(fd, header, 0, 16, 0); if (hasSqliteMagicBytes(header, n)) { isSqlite = true; @@ -1038,8 +1038,8 @@ export function readSourceRows(sourcePath: string, requireSqlite = false): RawEx assertNoUncheckpointedSqliteWal(resolved); } assertPlainLocalPath(resolved); - const afterStat = lstatSync(resolved); - const afterFd = fstatSync(fd); + const afterStat = lstatSync(resolved, { bigint: true }); + const afterFd = fstatSync(fd, { bigint: true }); if (isSqlite && afterFd.nlink > 1) { throw new CcSwitchImportError("CC-Switch SQLite source has multiple hard links; import the original CC-Switch database path directly."); } @@ -1051,7 +1051,7 @@ export function readSourceRows(sourcePath: string, requireSqlite = false): RawEx || afterStat.gid !== opened.gid || afterStat.mode !== opened.mode || afterStat.size !== opened.size - || afterStat.mtimeMs !== opened.mtimeMs + || afterStat.mtimeNs !== opened.mtimeNs || afterFd.dev !== opened.dev || afterFd.ino !== opened.ino || afterFd.nlink !== opened.nlink @@ -1059,7 +1059,7 @@ export function readSourceRows(sourcePath: string, requireSqlite = false): RawEx || afterFd.gid !== opened.gid || afterFd.mode !== opened.mode || afterFd.size !== opened.size - || afterFd.mtimeMs !== opened.mtimeMs + || afterFd.mtimeNs !== opened.mtimeNs ) { throw new CcSwitchImportError("CC-Switch source file changed while reading."); } diff --git a/src/server/persistent-gui-session.ts b/src/server/persistent-gui-session.ts index 0327481abfa..d45c08f2dcd 100644 --- a/src/server/persistent-gui-session.ts +++ b/src/server/persistent-gui-session.ts @@ -60,13 +60,13 @@ function readRecords(): DeviceRecord[] { try { fd = openSync(path, constants.O_RDONLY | (process.platform === "win32" ? 0 : constants.O_NOFOLLOW | constants.O_NONBLOCK)); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; throw error; } try { - const stat = fstatSync(fd); - const entry = lstatSync(path); - if (!stat.isFile() || stat.nlink !== 1 || stat.size > STORE_LIMIT || entry.isSymbolicLink() + const stat = fstatSync(fd, { bigint: true }); + const entry = lstatSync(path, { bigint: true }); + if (!stat.isFile() || stat.nlink !== 1n || stat.size > STORE_LIMIT || entry.isSymbolicLink() || stat.dev !== entry.dev || stat.ino !== entry.ino) throw new Error("unsafe dashboard session store"); // Size the read from the stat we validated: a store that grows mid-read overflows // the +1 sentinel byte and is rejected instead of silently truncated at a fixed limit. - const bytes = Buffer.alloc(stat.size + 1); + const bytes = Buffer.alloc(Number(stat.size) + 1); let length = 0; while (length < bytes.length) { const count = readSync(fd, bytes, length, bytes.length - length, null); if (!count) break; length += count; } if (length > stat.size) throw new Error("dashboard session store changed during read"); diff --git a/src/service/state-lock.ts b/src/service/state-lock.ts index 6bc2fabfb92..d57b85b0b69 100644 --- a/src/service/state-lock.ts +++ b/src/service/state-lock.ts @@ -12,7 +12,7 @@ import { rmdirSync, unlinkSync, writeFileSync, - type Stats, + type BigIntStats, } from "node:fs"; import { basename, dirname, join } from "node:path"; @@ -32,8 +32,8 @@ interface ServiceStateLockRecord { interface ServiceStateLockSnapshot { readonly record: ServiceStateLockRecord; readonly ownerPath: string; - readonly lockIdentity: Pick; - readonly ownerIdentity: Pick; + readonly lockIdentity: Pick; + readonly ownerIdentity: Pick; readonly mtimeMs: number; } @@ -72,8 +72,8 @@ function lockOwnerProcessAlive(pid: number): boolean { } function sameIdentity( - left: Pick, - right: Pick, + left: Pick, + right: Pick, ): boolean { return left.dev === right.dev && left.ino === right.ino; } @@ -90,10 +90,10 @@ function parseOwnerFileName(name: string): { pid: number; pathToken: string } | } function readLockSnapshot(lockPath: string): ServiceStateLockSnapshot | null { - let lockIdentity: Stats; + let lockIdentity: BigIntStats; let entries: string[]; try { - lockIdentity = lstatSync(lockPath); + lockIdentity = lstatSync(lockPath, { bigint: true }); if (!lockIdentity.isDirectory()) return null; entries = readdirSync(lockPath); } catch { @@ -102,7 +102,7 @@ function readLockSnapshot(lockPath: string): ServiceStateLockSnapshot | null { if (entries.length !== 1 || !parseOwnerFileName(entries[0]!)) return null; const ownerPath = join(lockPath, entries[0]!); try { - const ownerIdentity = lstatSync(ownerPath); + const ownerIdentity = lstatSync(ownerPath, { bigint: true }); if (!ownerIdentity.isFile() || ownerIdentity.size > 4096) return null; const value = JSON.parse(readFileSync(ownerPath, "utf8")) as Partial; if (value.version !== 1 || !Number.isSafeInteger(value.pid) || (value.pid ?? 0) <= 0 @@ -111,12 +111,12 @@ function readLockSnapshot(lockPath: string): ServiceStateLockSnapshot | null { || typeof value.createdAt !== "number" || !Number.isFinite(value.createdAt)) return null; const record = value as ServiceStateLockRecord; if (entries[0] !== ownerFileName(record)) return null; - const currentLock = lstatSync(lockPath); - const currentOwner = lstatSync(ownerPath); + const currentLock = lstatSync(lockPath, { bigint: true }); + const currentOwner = lstatSync(ownerPath, { bigint: true }); if (!currentLock.isDirectory() || !sameIdentity(lockIdentity, currentLock) || !currentOwner.isFile() || !sameIdentity(ownerIdentity, currentOwner) || currentOwner.size !== ownerIdentity.size) return null; - return { record, ownerPath, lockIdentity, ownerIdentity, mtimeMs: ownerIdentity.mtimeMs }; + return { record, ownerPath, lockIdentity, ownerIdentity, mtimeMs: Number(ownerIdentity.mtimeNs) / 1e6 }; } catch { return null; } diff --git a/tests/codex-integration/codex-catalog-write-audit.test.ts b/tests/codex-integration/codex-catalog-write-audit.test.ts index a2ce4b036c6..a75d2b1c9a2 100644 --- a/tests/codex-integration/codex-catalog-write-audit.test.ts +++ b/tests/codex-integration/codex-catalog-write-audit.test.ts @@ -595,11 +595,11 @@ describe("Windows audit privacy (r4176128642)", () => { const realFstat = fs.fstatSync; const realLstat = fs.lstatSync; // Deterministic ACL metadata transition; no dependency on filesystem clock resolution. - const postAcl = (stat: fs.Stats) => Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, - { ctimeMs: stat.ctimeMs + (hardened ? 1 : 0) }) as fs.Stats; - const fstat = spyOn(fs, "fstatSync").mockImplementation(fd => postAcl(realFstat(fd))); - const lstat = spyOn(fs, "lstatSync").mockImplementation(path => - path === auditPath() ? postAcl(realLstat(path)) : realLstat(path)); + const postAcl = (stat: fs.BigIntStats) => Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + { ctimeNs: stat.ctimeNs + (hardened ? 1_000_000n : 0n) }) as fs.BigIntStats; + const fstat = spyOn(fs, "fstatSync").mockImplementation(((fd, options) => options?.bigint ? postAcl(realFstat(fd, { bigint: true })) : realFstat(fd)) as typeof fs.fstatSync); + const lstat = spyOn(fs, "lstatSync").mockImplementation(((path, options) => + path === auditPath() && options?.bigint ? postAcl(realLstat(path, { bigint: true })) : realLstat(path)) as typeof fs.lstatSync); const write = spyOn(fs, "writeSync"); const open = spyOn(fs, "openSync"); harden.mockImplementation((path, options) => { @@ -620,6 +620,25 @@ describe("Windows audit privacy (r4176128642)", () => { } finally { fstat.mockRestore(); lstat.mockRestore(); write.mockRestore(); open.mockRestore(); } }); + test("file identity preserves adjacent NTFS-sized values", () => { + const realFstat = fs.fstatSync; + const realLstat = fs.lstatSync; + const identity = 9_007_199_254_740_992n; + const fstat = spyOn(fs, "fstatSync").mockImplementation(((fd, options) => { + const stat = options?.bigint ? realFstat(fd, { bigint: true }) : realFstat(fd); + return Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, { ino: options?.bigint ? identity : Number(identity) }); + }) as typeof fs.fstatSync); + const lstat = spyOn(fs, "lstatSync").mockImplementation(((path, options) => { + const stat = options?.bigint ? realLstat(path, { bigint: true }) : realLstat(path); + return path === auditPath() ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + { ino: options?.bigint ? identity + 1n : Number(identity + 1n) }) : stat; + }) as typeof fs.lstatSync); + try { + expect(appendUnderK()).toBe("skipped"); + expect(harden).not.toHaveBeenCalled(); + } finally { fstat.mockRestore(); lstat.mockRestore(); } + }); + for (const code of ["EICACLS", "ETIMEDOUT", "returned-failure"]) { test(`${code} leaves a blank file and preserves catalog publication`, () => { // Only the audit file's ACL fails. On real Windows the catalog writer also hardens its own diff --git a/tests/config/config-mutation-lock.test.ts b/tests/config/config-mutation-lock.test.ts index 278ed3d80a9..4b3103cc0bb 100644 --- a/tests/config/config-mutation-lock.test.ts +++ b/tests/config/config-mutation-lock.test.ts @@ -1,4 +1,5 @@ -import { afterEach, beforeEach, expect, test } from "bun:test"; +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import * as fs from "node:fs"; import { closeSync, existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; @@ -285,6 +286,28 @@ test("real link collision preserves the winner and does not advance generation o expect(initTemps()).toEqual([]); }); +test("initial config file identity preserves adjacent NTFS-sized values", () => { + const identity = 9_007_199_254_740_992n; + let ownedFd = -1, tempPath = ""; + const realFstat = fs.fstatSync, realLstat = fs.lstatSync; + const fstat = spyOn(fs, "fstatSync").mockImplementation(((fd, options) => { + const stat = options?.bigint ? realFstat(fd, { bigint: true }) : realFstat(fd); + return fd === ownedFd ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + { ino: options?.bigint ? identity : Number(identity) }) : stat; + }) as typeof fs.fstatSync); + const lstat = spyOn(fs, "lstatSync").mockImplementation(((path, options) => { + const stat = options?.bigint ? realLstat(path, { bigint: true }) : realLstat(path); + return path === tempPath ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + { ino: options?.bigint ? identity + 1n : Number(identity + 1n) }) : stat; + }) as typeof fs.lstatSync); + try { + expect(() => publishInitialConfigNoReplace(getConfigPath(), "candidate bytes", { + harden(fd, temp) { ownedFd = fd; tempPath = temp; }, + })).toThrow(InitialConfigPublicationError); + expect(existsSync(getConfigPath())).toBe(false); + } finally { fstat.mockRestore(); lstat.mockRestore(); } +}); + test("exclusive temp collision does not remove or modify somebody else's file", () => { const sequence = nextAtomicTempSequence() + 1; const occupied = `${getConfigPath()}.ocx.${process.pid}.${sequence}.tmp`; diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 7939d5f9243..b97dd7e3e9c 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -132,6 +132,8 @@ "messaging-remote-enrollment-gaps.test.ts": "codex-integration", "messages-request-id-headers.test.ts": "claude-integration", "bounded-file-read.test.ts": "lib", + "file-identity.test.ts": "lib", + "file-identity-callers.test.ts": "lib", "responses-undeclared-tool-guard-inbound.test.ts": "responses", "messages-request-id-endpoint.test.ts": "claude-integration", "anthropic-revoked-token.test.ts": "adapters/anthropic", diff --git a/tests/lib/file-identity-callers.test.ts b/tests/lib/file-identity-callers.test.ts new file mode 100644 index 00000000000..9adb4fea11c --- /dev/null +++ b/tests/lib/file-identity-callers.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { repoPath } from "../helpers/repo-root"; + +// NTFS file IDs exceed 2^53; these modules compare file identity, which must come from stat +// calls made with { bigint: true }. A lexical tripwire over the named declarations, not a +// full parser: it catches the regression forms below and fails closed on scopes it cannot find. +const scopes: Record = { + "src/config/initialize.ts": ["identifiesDescriptor"], + "src/service/state-lock.ts": ["readLockSnapshot"], + "src/oauth/store.ts": ["snapshot", "createOAuthFileLock"], + "src/claude/intercept/proxy-auth.ts": ["assertSameNode", "readPinnedToken", "ensurePrivateDirectory", "ensureClaudeInterceptProxyToken"], + "src/claude/desktop-remote-store-io.ts": ["read"], + "src/server/persistent-gui-session.ts": ["readRecords"], + "src/lib/spend-pool-alias-validation.ts": ["readExistingSpendSalt"], + "src/integrations/droid-settings.ts": ["assertDroidSettingsUnambiguous"], + "src/integrations/aside-profile-context.ts": ["storeGuard"], + "src/integrations/hermes-profiles.ts": ["lstatOrNull"], + "src/providers/cc-switch-import-source.ts": ["readSourceRows"], + "src/adapters/command-code-project-context.ts": ["openedFileIsConfined"], + "src/codex/catalog/write-audit.ts": ["appendCatalogWriteAudit"], +}; + +const REGEX_PRECEDES = new Set(["", "(", ",", "=", ":", "[", "!", "&", "|", "?", "{", "}", ";", "+", "-", "*", "%", "<", ">", "~", "^"]); +const REGEX_KEYWORDS = /(?:^|[^\w$])(?:return|typeof|instanceof|in|of|new|delete|void|throw|case|do|else|yield|await)$/; + +/** + * Blank literal text (strings, template text, regex literals, comments) keeping offsets, so only + * code is balanced or matched. Template ${...} expressions stay code. + */ +function maskNonCode(source: string): string { + const out = source.split(""); + const blank = (from: number, to: number) => { for (let at = from; at < to; at++) if (out[at] !== "\n") out[at] = " "; }; + const regexMayStart = (at: number): boolean => { + let back = at - 1; + while (back >= 0 && /\s/.test(out[back]!)) back--; + if (back < 0 || REGEX_PRECEDES.has(out[back]!)) return true; + return REGEX_KEYWORDS.test(out.slice(Math.max(0, back - 11), back + 1).join("")); + }; + const templateDepths: number[] = []; + let braces = 0; + let index = 0; + const scanTemplate = (from: number): number => { + let at = from; + while (at < source.length) { + if (source[at] === "\\") { at += 2; continue; } + if (source[at] === "`") { blank(from, at); return at + 1; } + if (source[at] === "$" && source[at + 1] === "{") { blank(from, at); templateDepths.push(braces); braces++; return at + 2; } + at++; + } + blank(from, at); + return at; + }; + while (index < source.length) { + const char = source[index]!; + const next = source[index + 1]; + if (char === "/" && next === "/") { const end = source.indexOf("\n", index); const stop = end < 0 ? source.length : end; blank(index, stop); index = stop; continue; } + if (char === "/" && next === "*") { const end = source.indexOf("*/", index + 2); const stop = end < 0 ? source.length : end + 2; blank(index, stop); index = stop; continue; } + if (char === "'" || char === '"') { + let at = index + 1; + while (at < source.length && source[at] !== char && source[at] !== "\n") at += source[at] === "\\" ? 2 : 1; + blank(index + 1, at); index = at + 1; continue; + } + if (char === "`") { index = scanTemplate(index + 1); continue; } + if (char === "/" && regexMayStart(index)) { + let at = index + 1; + let inClass = false; + while (at < source.length && source[at] !== "\n" && (inClass || source[at] !== "/")) { + if (source[at] === "\\") { at += 2; continue; } + if (source[at] === "[") inClass = true; + else if (source[at] === "]") inClass = false; + at++; + } + blank(index + 1, at); index = at + 1; continue; + } + if (char === "{") braces++; + if (char === "}") { + braces--; + if (templateDepths.length > 0 && braces === templateDepths[templateDepths.length - 1]) { + templateDepths.pop(); index = scanTemplate(index + 1); continue; + } + } + index++; + } + return out.join(""); +} + +function matching(code: string, open: number, left: string, right: string): number { + let depth = 0; + for (let at = open; at < code.length; at++) { + if (code[at] === left) depth++; + else if (code[at] === right && --depth === 0) return at; + } + return -1; +} + +/** The body of NAME's declaration: a function, or a method head whose body opens on the same line. */ +function declarationBody(code: string, name: string): [number, number] | null { + const head = new RegExp(`(?:\\bfunction\\s+|^[ \\t]*(?:(?:export|async|public|private|protected|static|override)\\s+)*)${name}\\s*\\(`, "gm"); + for (const match of code.matchAll(head)) { + const close = matching(code, match.index! + match[0].length - 1, "(", ")"); + if (close < 0) continue; + let at = close + 1; + while (code[at] === " " || code[at] === "\t") at++; + if (code[at] === ":") { + at++; + while (/\s/.test(code[at] ?? "")) at++; + if (code[at] === "{") at = matching(code, at, "{", "}") + 1; + while (at < code.length && code[at] !== "{" && code[at] !== ";" && code[at] !== "\n") at++; + } + if (code[at] !== "{") continue; + const end = matching(code, at, "{", "}"); + if (end > 0) return [at, end + 1]; + } + return null; +} + +/** The last top-level argument of a call's masked argument text. */ +function lastArgument(args: string): string { + let depth = 0; + let start = 0; + for (let at = 0; at < args.length; at++) { + const char = args[at]!; + if ("([{".includes(char)) depth++; + else if (")]}".includes(char)) depth--; + else if (char === "," && depth === 0) start = at + 1; + } + return args.slice(start).trim(); +} + +function inspect(source: string, names: string[], droid = false): string[] { + const code = maskNonCode(source); + const problems: string[] = []; + for (const name of names) { + const range = declarationBody(code, name); + if (!range) { problems.push(`missing declaration: ${name}`); continue; } + let observed = 0; + for (const match of code.slice(range[0], range[1]).matchAll(/\b(?:lstatSync|fstatSync|statSync|lstat|stat|handle\.stat)\s*\(/g)) { + const start = range[0] + match.index!; + const close = matching(code, start + match[0].length - 1, "(", ")"); + if (close < 0) { problems.push(`unbalanced stat call in: ${name}`); continue; } + const call = source.slice(start, close + 1); + if (droid && /^lstatSync\(root\)$/.test(call)) continue; + observed++; + if (!/^\{\s*bigint\s*:\s*true\s*\}$/.test(lastArgument(code.slice(start + match[0].length, close)))) problems.push(`numeric stat: ${call}`); + } + if (observed === 0) problems.push(`no stat call in: ${name}`); + } + if (/\bNumber\s*\([^)]*\.(?:ino|dev)\b/.test(code)) problems.push("numeric identity"); + return problems; +} + +describe("file identity caller stats", () => { + test("detects a numeric identity observation", () => { + expect(inspect("function check() { const s = lstatSync(path); return s.ino === 1; }", ["check"])) + .toEqual(["numeric stat: lstatSync(path)"]); + expect(inspect("function check() { const s = lstatSync(join(root, name)); return s.ino === 1; }", ["check"])) + .toEqual(["numeric stat: lstatSync(join(root, name))"]); + expect(inspect("function check() { return lstatSync(join(root, name), { bigint: true }).ino; }", ["check"])).toEqual([]); + expect(inspect("function check() { return lstatSync(p, convert({ bigint: true })).ino; }", ["check"])) + .toEqual(["numeric stat: lstatSync(p, convert({ bigint: true }))"]); + expect(inspect("function check() { const s = lstatSync(path, { bigint: true }); return Number(s.ino); }", ["check"])) + .toEqual(["numeric identity"]); + }); + + test("strings, templates, regexes and comments cannot hide or invent a call", () => { + expect(inspect('function check() { const a = lstatSync(join(root, "(")); const b = lstatSync(p, { bigint: true }); }', ["check"])) + .toEqual(['numeric stat: lstatSync(join(root, "("))']); + expect(inspect('function check() { const b = lstatSync(join(root, ")"), { bigint: true }); /* lstatSync(x) */ }', ["check"])).toEqual([]); + expect(inspect("function check() { const b = lstatSync(p, { bigint: true }); return `${lstatSync(q).ino}`; }", ["check"])) + .toEqual(["numeric stat: lstatSync(q)"]); + expect(inspect("function check() { const r = /'(/; const a = lstatSync(p); // '\n}", ["check"])) + .toEqual(["numeric stat: lstatSync(p)"]); + expect(inspect("function check() { lstatSync(p, { bigint: true }); if (x) return /'/.test(y); lstatSync(q); // '\n}", ["check"])) + .toEqual(["numeric stat: lstatSync(q)"]); + expect(inspect("function check() { const half = a / b; const s = lstatSync(p, { bigint: true }); return half / 2; }", ["check"])).toEqual([]); + }); + + test("resolves the declaration, not an earlier call, and needs a real observation", () => { + expect(inspect("function caller() { return check(); }\nfunction check() { return lstatSync(path).ino; }", ["check"])) + .toEqual(["numeric stat: lstatSync(path)"]); + expect(inspect("check()\n{ lstatSync(p, { bigint: true }); }\nfunction check() { return lstatSync(path).ino; }", ["check"])) + .toEqual(["numeric stat: lstatSync(path)"]); + expect(inspect("function check() { return 1; }", ["check"])).toEqual(["no stat call in: check"]); + expect(inspect("function check() { lstatSync(root); }", ["check"], true)).toEqual(["no stat call in: check"]); + }); + + for (const [path, names] of Object.entries(scopes)) { + test(path, () => expect(inspect(readFileSync(repoPath(path), "utf8"), names, path.endsWith("droid-settings.ts"))).toEqual([])); + } +}); diff --git a/tests/lib/file-identity.test.ts b/tests/lib/file-identity.test.ts new file mode 100644 index 00000000000..cb4e63afe42 --- /dev/null +++ b/tests/lib/file-identity.test.ts @@ -0,0 +1,12 @@ +import { expect, test } from "bun:test"; +import { fileIdentityKey, sameFileIdentity } from "../../src/lib/file-identity"; + +test("compares and formats adjacent large file IDs", () => { + const a = { dev: 7n, ino: 2n ** 53n }; + const b = { dev: 7n, ino: a.ino + 1n }; + expect(Number(a.ino)).toBe(Number(b.ino)); + expect(sameFileIdentity(a, a)).toBe(true); + expect(sameFileIdentity(a, b)).toBe(false); + expect(sameFileIdentity(a, { ...a, dev: 8n })).toBe(false); + expect(fileIdentityKey(b).split(":").map(BigInt)).toEqual([b.dev, b.ino]); +}); diff --git a/tests/providers/cc-switch-import.test.ts b/tests/providers/cc-switch-import.test.ts index a4608177aba..e308da771d2 100644 --- a/tests/providers/cc-switch-import.test.ts +++ b/tests/providers/cc-switch-import.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { Database } from "bun:sqlite"; +import * as fs from "node:fs"; import { existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, statSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -24,6 +25,7 @@ import { setCcSwitchSourceOpenHookForTests, } from "../../src/providers/cc-switch-import"; import { cmdProviderCcSwitchImport } from "../../src/cli/provider-cc-switch-import"; +import { readSourceRows } from "../../src/providers/cc-switch-import-source"; import { removeTreeWithRetry } from "../helpers/remove-tree"; // Runtime-assembled canary so privacy:scan never sees a literal token prefix in the source file. @@ -105,6 +107,27 @@ afterEach(async () => { }); describe("CC-Switch keyless provider import", () => { + test("file identity preserves adjacent NTFS-sized values", () => { + const path = join(sourceDir, "source.json"); + writeFileSync(path, JSON.stringify({ codex: { providers: {} } })); + const identity = 9_007_199_254_740_992n; + let opening = false; + const realLstat = fs.lstatSync, realFstat = fs.fstatSync; + const lstat = spyOn(fs, "lstatSync").mockImplementation(((target, options) => { + const stat = options?.bigint ? realLstat(target, { bigint: true }) : realLstat(target); + return target === path ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + { ino: options?.bigint ? identity : Number(identity) }) : stat; + }) as typeof fs.lstatSync); + const fstat = spyOn(fs, "fstatSync").mockImplementation(((fd, options) => { + const stat = options?.bigint ? realFstat(fd, { bigint: true }) : realFstat(fd); + return opening ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + { ino: options?.bigint ? identity + 1n : Number(identity + 1n) }) : stat; + }) as typeof fs.fstatSync); + setCcSwitchSourceOpenHookForTests(() => { opening = true; }); + try { expect(() => readSourceRows(path)).toThrow("CC-Switch source file changed while opening."); } + finally { fstat.mockRestore(); lstat.mockRestore(); } + }); + test("SQLite preview and apply strip TOML comments, drop free-text names, keep source read-only, and never materialize secrets", async () => { const codexToml = [ `model = "gpt-5.6-sol" # inline_comment_token = "${CANARY_SECRET}" and ${PREFIXLESS_HEX_SECRET}`, diff --git a/tests/providers/command-code-project-context.test.ts b/tests/providers/command-code-project-context.test.ts index c0b37b38dfd..cd1deee5f69 100644 --- a/tests/providers/command-code-project-context.test.ts +++ b/tests/providers/command-code-project-context.test.ts @@ -80,6 +80,35 @@ afterEach(() => { }); describe("loadCommandCodeProjectContext", () => { + test("file identity preserves adjacent NTFS-sized values", async () => { + const root = makeTempDir("ocx-cc-ctx-identity-"); + const agentsPath = join(root, "AGENTS.md"); + const identity = 9_007_199_254_740_992n; + try { + writeFileSync(agentsPath, "project memory", "utf8"); + lstatMock.mockImplementation((async (path: Parameters[0], options?: { bigint?: boolean }) => { + const stat = options?.bigint ? await realLstat(path, { bigint: true }) : await realLstat(path); + return String(path) === agentsPath ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + { ino: options?.bigint ? identity + 1n : Number(identity + 1n) }) : stat; + }) as typeof realLstat); + openMock.mockImplementation(async path => { + const handle = await realOpen(path); + if (String(path) !== agentsPath) return handle; + return { ...handle, read: handle.read.bind(handle), close: handle.close.bind(handle), + stat: async (options?: { bigint?: boolean }) => { + const stat = options?.bigint ? await handle.stat({ bigint: true }) : await handle.stat(); + return Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + { ino: options?.bigint ? identity : Number(identity) }); + }, + } as Awaited>; + }); + expect((await loadCommandCodeProjectContext(root)).memory).toBe(""); + } finally { + openMock.mockImplementation(realOpen); + rmSync(root, { recursive: true, force: true }); + } + }); + test("undefined cwd returns empty context", async () => { const result = await loadCommandCodeProjectContext(undefined); expect(result).toEqual(EMPTY_COMMAND_CODE_PROJECT_CONTEXT); @@ -594,10 +623,12 @@ describe("loadCommandCodeProjectContext", () => { const root = makeTempDir("ocx-cc-ctx-read-timeout-"); const agentsPath = join(root, "AGENTS.md"); let closeCalls = 0; + let readCalls = 0; let releaseRead: () => void = () => {}; const hangingFile = { - stat: async () => statSync(agentsPath), + stat: async (options?: { bigint?: boolean }) => options?.bigint ? statSync(agentsPath, { bigint: true }) : statSync(agentsPath), read: () => new Promise<{ bytesRead: number; buffer: Buffer }>(resolve => { + readCalls++; releaseRead = () => resolve({ bytesRead: 0, buffer: Buffer.alloc(0) }); }), close: async () => { @@ -618,6 +649,7 @@ describe("loadCommandCodeProjectContext", () => { expect(result).not.toBe("timeout"); expect(result).toEqual(EMPTY_COMMAND_CODE_PROJECT_CONTEXT); + expect(readCalls).toBe(1); expect(closeCalls).toBe(1); } finally { openMock.mockImplementation(realOpen); diff --git a/tests/service/service-ownership-state.test.ts b/tests/service/service-ownership-state.test.ts index 6b8be911d31..ee4565599c6 100644 --- a/tests/service/service-ownership-state.test.ts +++ b/tests/service/service-ownership-state.test.ts @@ -7,10 +7,12 @@ * therefore lasted until the next repair — from a tray helper, from `ocx update`, from a * doctor suggestion — and nothing said it had gone. */ -import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"; +import * as fs from "node:fs"; import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, unlinkSync, utimesSync, writeFileSync } from "node:fs"; import { createTempHome, type TempHome } from "../helpers/temp-home"; import { repoPath } from "../helpers/repo-root"; +import { withServiceStateLocks } from "../../src/service/state-lock"; import { desktopOwnsService, inspectServiceStateEvidence, @@ -587,6 +589,24 @@ describe("the anchor lock", () => { expect(existsSync(serviceStatePath())).toBe(false); }); + test("lock identity preserves adjacent NTFS-sized values", () => { + const statePath = serviceStatePath(); + const lockPath = `${statePath}.lock`; + const identity = 9_007_199_254_740_992n; + let changed = false; + const realLstat = fs.lstatSync; + const lstat = spyOn(fs, "lstatSync").mockImplementation(((path, options) => { + const stat = options?.bigint ? realLstat(path, { bigint: true }) : realLstat(path); + const ino = identity + (changed ? 1n : 0n); + return path === lockPath ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + { ino: options?.bigint ? ino : Number(ino) }) : stat; + }) as typeof fs.lstatSync); + try { + withServiceStateLocks([statePath], () => {}, { hooks: { beforeRelease: () => { changed = true; } } }); + expect(existsSync(lockPath)).toBe(true); + } finally { lstat.mockRestore(); } + }); + test("a late release cannot remove a successor lock", () => { const statePath = serviceStatePath(); const lockPath = `${statePath}.lock`; From 6c86d0fad2e1690358bf1adbe5e3687f9cc28bc8 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 31 Oct 2026 07:08:01 +0900 Subject: [PATCH 81/84] fix: compare Codex and remote workspace file identity with bigint stat (#8023) * fix: compare Codex and remote workspace file identity with bigint stat NTFS file IDs can exceed 2^53, where adjacent IDs are equal as JS numbers. Take dev/ino from bigint stats in the Codex home, coordinator, main-account, native-main, residue, history, shim runtime and remote-workspace identity checks. Persisted shim state keeps its numeric fields. * fix: keep stat millisecond values exact and compare shim runtime identity as bigint Derive *Ms from bigint *Ns with the runtime's own formula so values still match numeric and persisted stats; carry bigint identity through the shim wrapper ownership and inspection checks; fingerprints without identity compare unequal. * test: pin the stat millisecond formula with a deterministic case --------- Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> --- src/codex/codex-home-owner.ts | 17 +-- src/codex/coordinator-doctor.ts | 22 ++-- src/codex/history-provider.ts | 21 ++-- src/codex/inject/bounded-config-reader.ts | 27 ++--- src/codex/main-account.ts | 12 +- src/codex/native-main-lock-file.ts | 25 +++-- src/codex/native-residue.ts | 32 +++--- src/codex/orca-auth-source.ts | 15 +-- src/codex/shim-fingerprint.ts | 20 ++-- src/codex/shim-inspect.ts | 5 +- src/codex/shim-restore-lock.ts | 25 ++--- src/codex/shim-state-file.ts | 36 +++--- src/codex/shim-wrapper-write.ts | 13 ++- src/codex/shim.ts | 3 +- src/codex/transition-state.ts | 9 +- src/lib/file-identity.ts | 5 + src/oauth/store.ts | 3 +- src/remote-control/workspace-executor.ts | 27 ++--- src/service/state-lock.ts | 3 +- tests/clients/remote-workspace.test.ts | 48 +++++++- .../codex-coordinator-doctor.test.ts | 20 +++- .../codex-history-provider.test.ts | 50 ++++++++- .../codex-main-account-refresh.test.ts | 22 +++- .../codex-native-residue.test.ts | 21 +++- .../codex-shim-windows-revision.test.ts | 104 +++++++++++++++++- .../codex-transition-state.test.ts | 22 +++- .../native-main-owner-lifetime.test.ts | 31 +++++- tests/lib/file-identity-callers.test.ts | 28 ++++- tests/lib/file-identity.test.ts | 64 ++++++++++- 29 files changed, 573 insertions(+), 157 deletions(-) diff --git a/src/codex/codex-home-owner.ts b/src/codex/codex-home-owner.ts index 75533d99b71..bd1816a00c6 100644 --- a/src/codex/codex-home-owner.ts +++ b/src/codex/codex-home-owner.ts @@ -1,6 +1,7 @@ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpathSync, statSync } from "node:fs"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { getConfigDir } from "../config/paths"; +import { sameFileIdentity } from "../lib/file-identity"; import { samePathIdentity } from "./user-identity"; export const CODEX_HOME_JOURNAL_FILE = "opencodex-journal.json"; @@ -55,21 +56,21 @@ function validJournal(value: unknown): value is Record { export function readCodexHomeJournal(journalPath: string): CodexHomeJournalInspection { let fd: number | undefined; try { - const before = lstatSync(journalPath); - if (!before.isFile() || before.isSymbolicLink() || before.size > MAX_JOURNAL_BYTES) return { kind: "unknown" }; + const before = lstatSync(journalPath, { bigint: true }); + if (!before.isFile() || before.isSymbolicLink() || before.size > BigInt(MAX_JOURNAL_BYTES)) return { kind: "unknown" }; fd = openSync(journalPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0)); - const opened = fstatSync(fd); - if (!opened.isFile() || opened.size > MAX_JOURNAL_BYTES || before.dev !== opened.dev || before.ino !== opened.ino) return { kind: "unknown" }; - const bytes = Buffer.alloc(opened.size + 1); + const opened = fstatSync(fd, { bigint: true }); + if (!opened.isFile() || opened.size > BigInt(MAX_JOURNAL_BYTES) || !sameFileIdentity(before, opened)) return { kind: "unknown" }; + const bytes = Buffer.alloc(Number(opened.size) + 1); let length = 0; while (length < bytes.length) { const count = readSync(fd, bytes, length, bytes.length - length, null); if (!count) break; length += count; } - const after = lstatSync(journalPath); - if (length !== opened.size || !after.isFile() || after.dev !== opened.dev || after.ino !== opened.ino - || after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs) return { kind: "unknown" }; + const after = lstatSync(journalPath, { bigint: true }); + if (BigInt(length) !== opened.size || !after.isFile() || !sameFileIdentity(after, opened) + || after.size !== opened.size || after.mtimeNs !== opened.mtimeNs || after.ctimeNs !== opened.ctimeNs) return { kind: "unknown" }; const journal: unknown = JSON.parse(bytes.subarray(0, length).toString("utf8")); return validJournal(journal) ? { kind: "read", journal } : { kind: "unknown" }; } catch (error) { diff --git a/src/codex/coordinator-doctor.ts b/src/codex/coordinator-doctor.ts index 1c238bd922b..ef53b2d0662 100644 --- a/src/codex/coordinator-doctor.ts +++ b/src/codex/coordinator-doctor.ts @@ -12,11 +12,13 @@ import { realpathSync, renameSync, type Stats, + type BigIntStats, } from "node:fs"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; import { Database, constants as sqliteConstants } from "bun:sqlite"; +import { sameFileIdentity, type FileIdentity as NodeIdentity } from "../lib/file-identity"; import { resolveCodexHomeDir } from "./home"; import { @@ -33,7 +35,11 @@ import { const IMMUTABLE_READONLY_FLAGS = sqliteConstants.SQLITE_OPEN_READONLY | sqliteConstants.SQLITE_OPEN_URI; -export type FileIdentity = Pick; +export type FileIdentity = NodeIdentity & Pick; + +function milliseconds(ns: bigint): number { + return Number(ns / 1_000_000_000n) * 1000 + Number(ns % 1_000_000_000n) / 1e6; +} export interface CodexCoordinatorDiagnosticEvidence { sizeBytes: number; @@ -66,15 +72,14 @@ function errorCode(error: unknown): string { } function sameIdentity(left: FileIdentity, right: FileIdentity): boolean { - return left.dev === right.dev - && left.ino === right.ino + return sameFileIdentity(left, right) && left.size === right.size && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs; } function sameNodeAndSize(left: FileIdentity, right: FileIdentity): boolean { - return left.dev === right.dev && left.ino === right.ino && left.size === right.size; + return sameFileIdentity(left, right) && left.size === right.size; } function coordinatorPathWithoutCreation(): { kind: "absent"; path: string | null } | { kind: "path"; path: string } { @@ -113,9 +118,9 @@ function inspectTarget( path: string, options: { allowSqliteSidecars?: boolean } = {}, ): { kind: "absent" } | { kind: "file"; identity: FileIdentity } | { kind: "unsafe"; reason: string } { - let entry: Stats; + let entry: BigIntStats; try { - entry = lstatSync(path); + entry = lstatSync(path, { bigint: true }); } catch (cause) { if (errorCode(cause) === "ENOENT") return { kind: "absent" }; return { kind: "unsafe", reason: "the coordinator file cannot be inspected" }; @@ -132,7 +137,7 @@ function inspectTarget( } if (process.platform !== "win32") { const uid = process.getuid?.(); - if (uid === undefined || entry.uid !== uid || (entry.mode & 0o777) !== 0o600) { + if (uid === undefined || Number(entry.uid) !== uid || (Number(entry.mode) & 0o777) !== 0o600) { return { kind: "unsafe", reason: "the coordinator file has unsafe ownership or permissions" }; } } @@ -143,7 +148,8 @@ function inspectTarget( } } } - return { kind: "file", identity: entry }; + return { kind: "file", identity: { dev: entry.dev, ino: entry.ino, size: Number(entry.size), + mtimeMs: milliseconds(entry.mtimeNs), ctimeMs: milliseconds(entry.ctimeNs) } }; } function classifyOpenedDatabase( diff --git a/src/codex/history-provider.ts b/src/codex/history-provider.ts index e02edf40964..8996c4ad081 100644 --- a/src/codex/history-provider.ts +++ b/src/codex/history-provider.ts @@ -7,6 +7,7 @@ import { Database } from "bun:sqlite"; import { resolveCodexStateDbPath } from "./paths"; import { openCodexStateForPreflight } from "./history-state-open"; import { atomicWriteFile, getConfigDir } from "../config"; +import { sameFileIdentity, statTimeMs, type FileIdentity } from "../lib/file-identity"; import { CODEX_HISTORY_RESUMABLE_SOURCES, codexHistoryBackupId, @@ -268,15 +269,15 @@ function readFirstLineProviderValue(path: string, expectedId: string): string | function patchFirstLineProviderInPlace( path: string, expectedId: string, provider: string, - expectedIdentity: { dev: number | bigint; ino: number | bigint }, + expectedIdentity: FileIdentity, ): FirstLineProviderResult { historyAppendHooks?.beforeFirstLineOpen?.(path); const fd = openSync(path, "r+"); try { const assertIdentity = (): void => { assertHistoryDescriptorIdentity(path, fd); - const held = fstatSync(fd); - if (held.dev !== expectedIdentity.dev || held.ino !== expectedIdentity.ino) { + const held = fstatSync(fd, { bigint: true }); + if (!sameFileIdentity(held, expectedIdentity)) { throw new CodexHistoryIntegrityError("history_rollout_identity_changed"); } }; @@ -360,10 +361,10 @@ function assertLegacyHistoryRecord(line: string): void { } function assertHistoryDescriptorIdentity(path: string, fd: number): void { - const held = fstatSync(fd); - let current: ReturnType; - try { current = lstatSync(path); } catch { throw new CodexHistoryIntegrityError("history_rollout_identity_changed"); } - if (!current.isFile() || current.isSymbolicLink() || current.dev !== held.dev || current.ino !== held.ino) { + const held = fstatSync(fd, { bigint: true }); + let current: import("node:fs").BigIntStats; + try { current = lstatSync(path, { bigint: true }); } catch { throw new CodexHistoryIntegrityError("history_rollout_identity_changed"); } + if (!current.isFile() || current.isSymbolicLink() || !sameFileIdentity(current, held)) { throw new CodexHistoryIntegrityError("history_rollout_identity_changed"); } } @@ -693,8 +694,8 @@ function inspectBackupForNoop(path: string, stateDbPath: string): StrictBackupIn function historyFileIdentity(path: string): string | null { try { - const stat = statSync(path); - return [stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs].join(":"); + const stat = statSync(path, { bigint: true }); + return [stat.dev, stat.ino, Number(stat.size), statTimeMs(stat.mtimeNs), statTimeMs(stat.ctimeNs)].join(":"); } catch { return null; } @@ -1132,7 +1133,7 @@ function updateSessionMeta( } = {}, ): SessionMetaUpdateResult { if (!path || !existsSync(path)) return { changed: false, durableProvider: false }; - const validatedIdentity = lstatSync(path); + const validatedIdentity = lstatSync(path, { bigint: true }); if (options.expectedFileIdentity !== undefined && historyFileIdentity(path) !== options.expectedFileIdentity) { return { changed: false, durableProvider: false, conflict: true }; diff --git a/src/codex/inject/bounded-config-reader.ts b/src/codex/inject/bounded-config-reader.ts index 78a7ce95030..ffab48e9da5 100644 --- a/src/codex/inject/bounded-config-reader.ts +++ b/src/codex/inject/bounded-config-reader.ts @@ -1,4 +1,5 @@ -import { closeSync, constants, fstatSync, openSync, readSync, statSync, type Stats } from "node:fs"; +import { closeSync, constants, fstatSync, openSync, readSync, statSync, type BigIntStats } from "node:fs"; +import { sameFileIdentity } from "../../lib/file-identity"; const MAX_CODEX_CONFIG_BYTES = 1024 * 1024; @@ -14,15 +15,15 @@ const MAX_CODEX_CONFIG_BYTES = 1024 * 1024; export function readBoundedCodexConfig(path: string): string | null { let fd: number | undefined; try { - let namedBefore: Stats; + let namedBefore: BigIntStats; try { - namedBefore = statSync(path); + namedBefore = statSync(path, { bigint: true }); } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code === "ENOENT" || code === "ENOTDIR") return null; throw error; } - if (!namedBefore.isFile() || namedBefore.size > MAX_CODEX_CONFIG_BYTES) { + if (!namedBefore.isFile() || namedBefore.size > BigInt(MAX_CODEX_CONFIG_BYTES)) { throw new Error("config.toml is not a bounded regular file"); } // Deliberately no O_NOFOLLOW: Codex and the injector read through a symlinked @@ -33,27 +34,27 @@ export function readBoundedCodexConfig(path: string): string | null { ? 0 : (constants.O_NONBLOCK ?? 0); fd = openSync(path, constants.O_RDONLY | guardedFlags); - const before = fstatSync(fd); - if (before.dev !== namedBefore.dev || before.ino !== namedBefore.ino) { + const before = fstatSync(fd, { bigint: true }); + if (!sameFileIdentity(before, namedBefore)) { throw new Error("config.toml changed while it was read"); } - if (!before.isFile() || before.size > MAX_CODEX_CONFIG_BYTES) { + if (!before.isFile() || before.size > BigInt(MAX_CODEX_CONFIG_BYTES)) { throw new Error("config.toml is not a bounded regular file"); } - const buffer = Buffer.allocUnsafe(before.size + 1); + const buffer = Buffer.allocUnsafe(Number(before.size) + 1); let bytesRead = 0; while (bytesRead < buffer.length) { const count = readSync(fd, buffer, bytesRead, buffer.length - bytesRead, null); if (count === 0) break; bytesRead += count; } - const after = fstatSync(fd); - const namedAfter = statSync(path); - if (bytesRead !== before.size || after.size !== before.size - || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs + const after = fstatSync(fd, { bigint: true }); + const namedAfter = statSync(path, { bigint: true }); + if (BigInt(bytesRead) !== before.size || after.size !== before.size + || after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs || !namedAfter.isFile() - || namedAfter.dev !== before.dev || namedAfter.ino !== before.ino) { + || !sameFileIdentity(namedAfter, before)) { throw new Error("config.toml changed while it was read"); } return buffer.toString("utf8", 0, bytesRead); diff --git a/src/codex/main-account.ts b/src/codex/main-account.ts index 50137e447dd..1d687c3c66d 100644 --- a/src/codex/main-account.ts +++ b/src/codex/main-account.ts @@ -4,6 +4,7 @@ import { classifyChatgptRefreshFailure, noteChatgptRefreshFailure } from "./chat import { readBoundedResponseBody } from "../lib/bounded-body"; import { createHash } from "node:crypto"; import { lstatSync, readFileSync, statSync } from "node:fs"; +import { sameFileIdentity, type FileIdentity } from "../lib/file-identity"; import { dirname, join, resolve } from "node:path"; import { readCodexTokens } from "./auth-collision"; import { @@ -86,7 +87,7 @@ type MainAuthJsonCredential = { * the content hash notices those, and only once the new bytes are visible (#2999). * `null` when the target could not be stat'ed, which fails closed. */ - identity: { dev: number; ino: number } | null; + identity: FileIdentity | null; root: Record; tokens: Record; accessToken?: string; @@ -201,10 +202,10 @@ function sha256(value: string): string { * fail closed, because the whole point here is refusing to overwrite a file we can no * longer vouch for. */ -function statIdentity(path: string): { dev: number; ino: number } | null { +function statIdentity(path: string): FileIdentity | null { try { - const stat = statSync(path); - return { dev: Number(stat.dev), ino: Number(stat.ino) }; + const stat = statSync(path, { bigint: true }); + return { dev: stat.dev, ino: stat.ino }; } catch { return null; } @@ -316,8 +317,7 @@ function assertMainAuthJsonIdentityUnchanged(expected: MainAuthJsonCredential): const identity = statIdentity(expected.path); if (!identity || !expected.identity - || identity.dev !== expected.identity.dev - || identity.ino !== expected.identity.ino) { + || !sameFileIdentity(identity, expected.identity)) { throw new MainAuthJsonChangedDuringRefreshError(); } } diff --git a/src/codex/native-main-lock-file.ts b/src/codex/native-main-lock-file.ts index a61490cfab0..a8050881942 100644 --- a/src/codex/native-main-lock-file.ts +++ b/src/codex/native-main-lock-file.ts @@ -6,15 +6,16 @@ import { fstatSync, lstatSync, openSync, - type Stats, + type BigIntStats, } from "node:fs"; import { hardenSecretPathAsync } from "../lib/windows-secret-acl"; +import { sameFileIdentity } from "../lib/file-identity"; export interface StableLockFile { readonly fd: number; - readonly dev: number; - readonly ino: number; + readonly dev: bigint; + readonly ino: bigint; close(): void; } @@ -27,8 +28,8 @@ export class StableLockPathUnsafeError extends Error { interface StableLockEntry { readonly fd: number; - readonly dev: number; - readonly ino: number; + readonly dev: bigint; + readonly ino: bigint; refs: number; } @@ -55,7 +56,7 @@ function reference(path: string, entry: StableLockEntry): StableLockFile { }; } -function assertRegular(stats: Stats, path: string): void { +function assertRegular(stats: BigIntStats, path: string): void { if (!stats.isFile() || stats.isSymbolicLink()) { throw new StableLockPathUnsafeError(`unsafe SQLite lock path: ${path}`); } @@ -67,8 +68,8 @@ function errorCode(error: unknown): string | undefined { : undefined; } -function matches(handle: StableLockFile, stats: Stats): boolean { - return handle.dev === stats.dev && handle.ino === stats.ino; +function matches(handle: StableLockFile, stats: BigIntStats): boolean { + return sameFileIdentity(handle, stats); } export function openStableLockFile(path: string, platform: NodeJS.Platform = process.platform): StableLockFile { @@ -84,11 +85,11 @@ export function openStableLockFile(path: string, platform: NodeJS.Platform = pro throw error; } } - if (existsSync(path)) assertRegular(lstatSync(path), path); + if (existsSync(path)) assertRegular(lstatSync(path, { bigint: true }), path); const noFollow = platform === "win32" ? 0 : constants.O_NOFOLLOW; const fd = openSync(path, constants.O_RDWR | constants.O_CREAT | noFollow, 0o600); try { - const descriptor = fstatSync(fd); + const descriptor = fstatSync(fd, { bigint: true }); assertRegular(descriptor, path); const entry: StableLockEntry = { fd, @@ -109,9 +110,9 @@ export function openStableLockFile(path: string, platform: NodeJS.Platform = pro } export function assertStableLockFile(path: string, handle: StableLockFile): void { - let pathStats: Stats; + let pathStats: BigIntStats; try { - pathStats = lstatSync(path); + pathStats = lstatSync(path, { bigint: true }); } catch (error) { if (errorCode(error) === "ENOENT") { throw new StableLockPathUnsafeError(`SQLite lock path identity changed: ${path}`); diff --git a/src/codex/native-residue.ts b/src/codex/native-residue.ts index a8cd47f58fe..c56ffe1cc31 100644 --- a/src/codex/native-residue.ts +++ b/src/codex/native-residue.ts @@ -10,7 +10,8 @@ import { readSync, statSync, } from "node:fs"; -import type { Stats } from "node:fs"; +import type { BigIntStats } from "node:fs"; +import { sameFileIdentity } from "../lib/file-identity"; import { basename, dirname, join, resolve } from "node:path"; import { Database } from "bun:sqlite"; @@ -57,7 +58,7 @@ type ReadResult = type PathResult = | { kind: "absent" } - | { kind: "path"; path: string; stat: Stats } + | { kind: "path"; path: string; stat: BigIntStats } | { kind: "indeterminate"; reason: string }; type CatalogTarget = { @@ -95,14 +96,17 @@ function errorReason(error: unknown): string { return String(error); } +function milliseconds(ns: bigint): number { + return Number(ns / 1_000_000_000n) * 1000 + Number(ns % 1_000_000_000n) / 1e6; +} + function sameStat( - left: Stats, - right: Stats, + left: BigIntStats, + right: BigIntStats, ): boolean { - return left.dev === right.dev - && left.ino === right.ino - && left.size === right.size - && left.mtimeMs === right.mtimeMs; + return sameFileIdentity(left, right) + && Number(left.size) === Number(right.size) + && milliseconds(left.mtimeNs) === milliseconds(right.mtimeNs); } function resolveRegularFile(path: string): PathResult { @@ -124,7 +128,7 @@ function resolveRegularFile(path: string): PathResult { } try { - const before = statSync(target); + const before = statSync(target, { bigint: true }); if (!before.isFile()) { return { kind: "indeterminate", reason: "surface is not a regular file" }; } @@ -141,12 +145,12 @@ function readRegularFile(path: string): ReadResult { // depend on who ran the suite. No read bit means the configured surface is // operationally unreadable to an ordinary Codex process and must remain // indeterminate even when the inspector itself has elevated privileges. - if (process.platform !== "win32" && (resolved.stat.mode & 0o444) === 0) { + if (process.platform !== "win32" && (Number(resolved.stat.mode) & 0o444) === 0) { return { kind: "indeterminate", reason: "EACCES: surface has no read permission bits" }; } try { const content = readFileSync(resolved.path, "utf8"); - const after = statSync(resolved.path); + const after = statSync(resolved.path, { bigint: true }); if (!sameStat(resolved.stat, after)) { return { kind: "indeterminate", reason: "surface changed while it was being observed" }; } @@ -497,11 +501,11 @@ function classifyReferencedRollout( latest = payload.payload; } } - const after = fstatSync(handle); + const after = fstatSync(handle, { bigint: true }); if (!sameStat(resolved.stat, after)) { return indeterminate(surface, resolved.path, "rollout changed while it was being observed"); } - const pathAfter = statSync(resolved.path); + const pathAfter = statSync(resolved.path, { bigint: true }); if (!sameStat(resolved.stat, pathAfter)) { return indeterminate(surface, resolved.path, "rollout pathname was replaced while it was being observed"); } @@ -591,7 +595,7 @@ function classifyHistoryDatabase(path: string, budget: RolloutInspectionBudget): budget, ); if (rollouts.kind !== "clean") return rollouts; - const after = statSync(resolved.path); + const after = statSync(resolved.path, { bigint: true }); if (!sameStat(resolved.stat, after)) { return indeterminate("history", resolved.path, "history database changed while it was being observed"); } diff --git a/src/codex/orca-auth-source.ts b/src/codex/orca-auth-source.ts index 55b5ea37cfc..3f49a74bd04 100644 --- a/src/codex/orca-auth-source.ts +++ b/src/codex/orca-auth-source.ts @@ -1,5 +1,6 @@ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpathSync } from "node:fs"; import { dirname, isAbsolute, join, parse, relative, resolve } from "node:path"; +import { sameFileIdentity } from "../lib/file-identity"; const MAX_AUTH_BYTES = 256 * 1024; export const ORCA_ACCOUNT_DIRECTORY = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -23,27 +24,27 @@ export function assertPlainLocalPath(path: string): string { export function readBoundedLocalFile(path: string, limit = MAX_AUTH_BYTES): string { assertPlainLocalPath(path); - const before = lstatSync(path); + const before = lstatSync(path, { bigint: true }); if (!before.isFile() || before.size > limit) throw new Error("Invalid local credential file."); const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0)); try { - const opened = fstatSync(fd); - if (!opened.isFile() || opened.size > limit || before.dev !== opened.dev || before.ino !== opened.ino) { + const opened = fstatSync(fd, { bigint: true }); + if (!opened.isFile() || opened.size > limit || !sameFileIdentity(before, opened)) { throw new Error("Local credential file changed."); } // The cap bounds the read, not the allocation: tiny registry files should not // allocate 32 MiB on each preview/recheck. One extra byte detects concurrent growth. - const bytes = Buffer.alloc(opened.size + 1); + const bytes = Buffer.alloc(Number(opened.size) + 1); let length = 0; while (length < bytes.length) { const count = readSync(fd, bytes, length, bytes.length - length, null); if (!count) break; length += count; } - if (length !== opened.size) throw new Error("Local credential file changed."); + if (BigInt(length) !== opened.size) throw new Error("Local credential file changed."); assertPlainLocalPath(path); - const after = lstatSync(path); - if (after.dev !== opened.dev || after.ino !== opened.ino || after.size !== opened.size || after.mtimeMs !== opened.mtimeMs) { + const after = lstatSync(path, { bigint: true }); + if (!sameFileIdentity(after, opened) || after.size !== opened.size || after.mtimeNs !== opened.mtimeNs) { throw new Error("Local credential file changed."); } return bytes.subarray(0, length).toString("utf8"); diff --git a/src/codex/shim-fingerprint.ts b/src/codex/shim-fingerprint.ts index 51fbb5cf043..763e5eedd98 100644 --- a/src/codex/shim-fingerprint.ts +++ b/src/codex/shim-fingerprint.ts @@ -13,6 +13,7 @@ import { unlinkSync, } from "node:fs"; import { posix, win32 } from "node:path"; +import { sameFileIdentity, statTimeMs, type FileIdentity } from "../lib/file-identity"; import { SHIM_MARKER, UNIX_SHIM_REVISION_MARKER, WINDOWS_SHIM_REVISION_MARKER } from "./shim-templates"; import type { ShimFileState } from "./shim-state-file"; @@ -21,6 +22,7 @@ const CODEX_SHIM_PROBE_BYTES = 16 * 1024; interface ShimPathFingerprint { dev: number; ino: number; + identity: FileIdentity; kind: "file" | "symlink"; mode: number; size: number; @@ -48,16 +50,17 @@ function readShimProbePrefix(path: string): string { function statFingerprint(path: string, follow: boolean): Omit | null { try { - const stat = follow ? statSync(path) : lstatSync(path); + const stat = follow ? statSync(path, { bigint: true }) : lstatSync(path, { bigint: true }); if (follow ? !stat.isFile() : (!stat.isFile() && !stat.isSymbolicLink())) return null; return { - dev: stat.dev, - ino: stat.ino, + dev: Number(stat.dev), + ino: Number(stat.ino), + identity: { dev: stat.dev, ino: stat.ino }, kind: stat.isSymbolicLink() ? "symlink" : "file", - mode: stat.mode, - size: stat.size, - mtimeMs: stat.mtimeMs, - ctimeMs: stat.ctimeMs, + mode: Number(stat.mode), + size: Number(stat.size), + mtimeMs: statTimeMs(stat.mtimeNs), + ctimeMs: statTimeMs(stat.ctimeNs), }; } catch { return null; @@ -68,8 +71,7 @@ function sameFingerprint( left: ShimPathFingerprint | Omit, right: ShimPathFingerprint | Omit, ): boolean { - return left.dev === right.dev - && left.ino === right.ino + return !!left.identity && !!right.identity && sameFileIdentity(left.identity, right.identity) && left.kind === right.kind && left.mode === right.mode && left.size === right.size diff --git a/src/codex/shim-inspect.ts b/src/codex/shim-inspect.ts index dd696d5bcb0..3cc3dd7c620 100644 --- a/src/codex/shim-inspect.ts +++ b/src/codex/shim-inspect.ts @@ -1,6 +1,7 @@ import { lstatSync } from "node:fs"; import { extname, join, posix, win32 } from "node:path"; import { getConfigDir } from "../config"; +import { sameFileIdentity } from "../lib/file-identity"; import { fileErrorCode, readStateResult, stateFiles } from "./shim-state-file"; import { isHealthyShimProbe, @@ -111,7 +112,7 @@ export function inspectCodexShimBackingForCommand( const sameEffectiveIdentity = (fingerprint: ShimPathFingerprint | null): boolean => { if (!fingerprint) return false; const identity = fingerprint.target ?? fingerprint; - return identity.dev === selectedIdentity.dev && identity.ino === selectedIdentity.ino; + return sameFileIdentity(identity.identity, selectedIdentity.identity); }; const matches = files.flatMap(file => { const backingPath = file.realPath ?? file.backupPath; @@ -151,7 +152,7 @@ export function inspectCodexShimBackingForCommand( }); } const wrapperIdentity = wrapperProbe.fingerprint.target ?? wrapperProbe.fingerprint; - if (backing.dev === wrapperIdentity.dev && backing.ino === wrapperIdentity.ino) { + if (sameFileIdentity(backing.identity, wrapperIdentity.identity)) { return Object.freeze({ status: "unknown" as const, reason: "backing_mismatch" as const }); } const wrapperExt = extname(file.wrapperPath).toLowerCase(); diff --git a/src/codex/shim-restore-lock.ts b/src/codex/shim-restore-lock.ts index 3ee2e843acf..27d1eae8a78 100644 --- a/src/codex/shim-restore-lock.ts +++ b/src/codex/shim-restore-lock.ts @@ -10,11 +10,12 @@ import { rmdirSync, unlinkSync, writeFileSync, - type Stats, + type BigIntStats, } from "node:fs"; import { join } from "node:path"; import { getConfigDir } from "../config"; import { isProcessAlive } from "../lib/process-control"; +import { sameFileIdentity, type FileIdentity } from "../lib/file-identity"; import { sameFingerprint, stableShimPathProbe, type ShimPathFingerprint } from "./shim-fingerprint"; import { fileErrorCode } from "./shim-state-file"; @@ -34,7 +35,7 @@ interface ShimRestoreLockRecord { interface ShimRestoreLockSnapshot { record: ShimRestoreLockRecord; ownerPath: string; - lockIdentity: Pick; + lockIdentity: FileIdentity; fingerprint: ShimPathFingerprint; } @@ -42,15 +43,11 @@ function restoreLockPath(): string { return join(getConfigDir(), "codex-shim.autorestore.lock"); } -function sameFileIdentity(left: Pick, right: Pick): boolean { - return left.dev === right.dev && left.ino === right.ino; -} - function readShimRestoreLockSnapshot(path: string): ShimRestoreLockSnapshot | null { - let lockIdentity: Stats; + let lockIdentity: BigIntStats; let entries: string[]; try { - lockIdentity = lstatSync(path); + lockIdentity = lstatSync(path, { bigint: true }); if (!lockIdentity.isDirectory()) return null; entries = readdirSync(path); } catch { @@ -66,7 +63,7 @@ function readShimRestoreLockSnapshot(path: string): ShimRestoreLockSnapshot | nu || typeof value.pid !== "number" || !Number.isSafeInteger(value.pid) || value.pid <= 0 || typeof value.createdAt !== "number" || !Number.isFinite(value.createdAt)) return null; if (entries[0] !== `${value.token}.json`) return null; - const currentLockIdentity = lstatSync(path); + const currentLockIdentity = lstatSync(path, { bigint: true }); if (!currentLockIdentity.isDirectory() || !sameFileIdentity(lockIdentity, currentLockIdentity)) return null; return { record: value as ShimRestoreLockRecord, @@ -112,7 +109,7 @@ function tryAcquireShimRestoreLock(beforeStaleDelete?: () => void): ShimRestoreL const path = restoreLockPath(); for (let attempt = 0; attempt < 2; attempt += 1) { let fd: number | null = null; - let identity: Stats | null = null; + let identity: BigIntStats | null = null; let createdDirectory = false; const record: ShimRestoreLockRecord = { version: 1, @@ -125,9 +122,9 @@ function tryAcquireShimRestoreLock(beforeStaleDelete?: () => void): ShimRestoreL mkdirSync(path, { mode: 0o700 }); createdDirectory = true; fd = openSync(ownerPath, "wx", 0o600); - identity = fstatSync(fd); + identity = fstatSync(fd, { bigint: true }); writeFileSync(fd, `${JSON.stringify(record)}\n`, "utf8"); - identity = fstatSync(fd); + identity = fstatSync(fd, { bigint: true }); let released = false; return { release(): void { @@ -137,7 +134,7 @@ function tryAcquireShimRestoreLock(beforeStaleDelete?: () => void): ShimRestoreL try { const current = readShimRestoreLockSnapshot(path); if (identity && current && current.record.token === record.token - && sameFileIdentity(identity, current.fingerprint)) { + && sameFileIdentity(identity, current.fingerprint.identity)) { unlinkSync(ownerPath); rmdirSync(path); } @@ -150,7 +147,7 @@ function tryAcquireShimRestoreLock(beforeStaleDelete?: () => void): ShimRestoreL try { const current = readShimRestoreLockSnapshot(path); if (identity && current && current.record.token === record.token - && sameFileIdentity(identity, current.fingerprint)) { + && sameFileIdentity(identity, current.fingerprint.identity)) { unlinkSync(ownerPath); rmdirSync(path); } diff --git a/src/codex/shim-state-file.ts b/src/codex/shim-state-file.ts index ba2753297e5..aeb9e0cc389 100644 --- a/src/codex/shim-state-file.ts +++ b/src/codex/shim-state-file.ts @@ -10,12 +10,14 @@ import { renameSync, unlinkSync, writeFileSync, + type BigIntStats, type Stats, } from "node:fs"; import { randomUUID } from "node:crypto"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { getConfigDir } from "../config"; import { recordOwnedConfigPath } from "../lib/config-ownership"; +import { sameFileIdentity, statTimeMs } from "../lib/file-identity"; export const CODEX_SHIM_STATE_MAX_BYTES = 1024 * 1024; @@ -99,9 +101,9 @@ function fileErrorCode(error: unknown): string | undefined { } function readBoundedRegularFile(path: string, maxBytes: number): { bytes: Buffer; content: string; stat: Stats } | { warning: string } | null { - let lexicalBefore: Stats; + let lexicalBefore: BigIntStats; try { - lexicalBefore = lstatSync(path); + lexicalBefore = lstatSync(path, { bigint: true }); if (lexicalBefore.isSymbolicLink() || !lexicalBefore.isFile()) { return { warning: `Codex shim state is not a direct regular file at ${path}; auto-restore skipped.` }; } @@ -117,12 +119,12 @@ function readBoundedRegularFile(path: string, maxBytes: number): { bytes: Buffer return { warning: `Codex shim state could not be opened as a regular file at ${path}.` }; } try { - const before = fstatSync(fd); + const before = fstatSync(fd, { bigint: true }); if (!before.isFile()) return { warning: `Codex shim state is not a regular file at ${path}; auto-restore skipped.` }; if (before.size > maxBytes) { return { warning: `Codex shim state exceeds the 1 MiB startup limit at ${path}; auto-restore skipped.` }; } - const buffer = Buffer.allocUnsafe(before.size); + const buffer = Buffer.allocUnsafe(Number(before.size)); let offset = 0; while (offset < buffer.length) { const bytesRead = readSync(fd, buffer, offset, buffer.length - offset, offset); @@ -133,25 +135,31 @@ function readBoundedRegularFile(path: string, maxBytes: number): { bytes: Buffer if (readSync(fd, extra, 0, 1, offset) !== 0) { return { warning: `Codex shim state exceeds the 1 MiB startup limit at ${path}; auto-restore skipped.` }; } - const after = fstatSync(fd); - let lexicalAfter: Stats; + const after = fstatSync(fd, { bigint: true }); + let lexicalAfter: BigIntStats; try { - lexicalAfter = lstatSync(path); + lexicalAfter = lstatSync(path, { bigint: true }); } catch { return { warning: `Codex shim state changed while being read at ${path}; auto-restore skipped.` }; } - if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size - || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs + if (!sameFileIdentity(before, after) || before.size !== after.size + || statTimeMs(before.mtimeNs) !== statTimeMs(after.mtimeNs) || statTimeMs(before.ctimeNs) !== statTimeMs(after.ctimeNs) || before.mode !== after.mode || before.uid !== after.uid - || lexicalBefore.dev !== before.dev || lexicalBefore.ino !== before.ino + || !sameFileIdentity(lexicalBefore, before) || lexicalBefore.mode !== before.mode || lexicalBefore.uid !== before.uid - || lexicalBefore.size !== before.size || lexicalBefore.mtimeMs !== before.mtimeMs || lexicalBefore.ctimeMs !== before.ctimeMs - || !lexicalAfter.isFile() || lexicalAfter.isSymbolicLink() || lexicalAfter.dev !== after.dev || lexicalAfter.ino !== after.ino + || lexicalBefore.size !== before.size || statTimeMs(lexicalBefore.mtimeNs) !== statTimeMs(before.mtimeNs) || statTimeMs(lexicalBefore.ctimeNs) !== statTimeMs(before.ctimeNs) + || !lexicalAfter.isFile() || lexicalAfter.isSymbolicLink() || !sameFileIdentity(lexicalAfter, after) || lexicalAfter.mode !== after.mode || lexicalAfter.uid !== after.uid - || lexicalAfter.size !== after.size || lexicalAfter.mtimeMs !== after.mtimeMs || lexicalAfter.ctimeMs !== after.ctimeMs) { + || lexicalAfter.size !== after.size || statTimeMs(lexicalAfter.mtimeNs) !== statTimeMs(after.mtimeNs) || statTimeMs(lexicalAfter.ctimeNs) !== statTimeMs(after.ctimeNs)) { return { warning: `Codex shim state changed while being read at ${path}; auto-restore skipped.` }; } - return { bytes: buffer, content: buffer.toString("utf8"), stat: after }; + const stat = Object.assign(Object.create(Object.getPrototypeOf(after)), after, { + dev: Number(after.dev), ino: Number(after.ino), mode: Number(after.mode), nlink: Number(after.nlink), + uid: Number(after.uid), gid: Number(after.gid), rdev: Number(after.rdev), size: Number(after.size), + blksize: Number(after.blksize), blocks: Number(after.blocks), atimeMs: statTimeMs(after.atimeNs), + mtimeMs: statTimeMs(after.mtimeNs), ctimeMs: statTimeMs(after.ctimeNs), birthtimeMs: statTimeMs(after.birthtimeNs), + }) as Stats; + return { bytes: buffer, content: buffer.toString("utf8"), stat }; } finally { closeSync(fd); } diff --git a/src/codex/shim-wrapper-write.ts b/src/codex/shim-wrapper-write.ts index 8d3f39a9e52..e05b8950793 100644 --- a/src/codex/shim-wrapper-write.ts +++ b/src/codex/shim-wrapper-write.ts @@ -10,6 +10,7 @@ import { randomUUID } from "node:crypto"; import { chmodSync, lstatSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; import { basename, dirname, join } from "node:path"; import { durableBunRuntime, type BunRuntimeSource, type DurableBunRuntime } from "../lib/bun-runtime"; +import { sameFileIdentity, type FileIdentity } from "../lib/file-identity"; import { serviceApiTokenFilePath } from "../lib/service-secrets"; import { buildUnixCodexShim, @@ -54,7 +55,7 @@ export function windowsShimContent(wrapperPath: string, realCodexPath: string, r return buildUnixCodexShim(gitBashPath(realCodexPath), gitBashPath(bun), gitBashPath(cli), bunRuntimeSource, gitBashPath(serviceApiTokenFilePath())); } -export function writeShim(wrapperPath: string, realCodexPath: string, runtime?: DurableBunRuntime): { dev: number; ino: number } | undefined { +export function writeShim(wrapperPath: string, realCodexPath: string, runtime?: DurableBunRuntime): FileIdentity | undefined { const { bun, bunRuntimeSource, cli } = cliEntry(runtime); if (process.platform === "win32") { writeFileSync(wrapperPath, windowsShimContent(wrapperPath, realCodexPath, runtime), "utf8"); @@ -74,7 +75,7 @@ export function writeShim(wrapperPath: string, realCodexPath: string, runtime?: try { // "wx" fails if the staging path somehow exists, so we never inherit a file. writeFileSync(staged, buildUnixCodexShim(realCodexPath, bun, cli, bunRuntimeSource), { encoding: "utf8", flag: "wx", mode: 0o600 }); - const stagedStat = lstatSync(staged); + const stagedStat = lstatSync(staged, { bigint: true }); chmodSync(staged, 0o755); renameSync(staged, wrapperPath); renamed = true; @@ -96,14 +97,14 @@ export function writeShim(wrapperPath: string, realCodexPath: string, runtime?: */ export function ownedWrapperFingerprint( wrapperPath: string, - written: { dev: number; ino: number } | undefined, + written: FileIdentity | undefined, ): ShimPathFingerprint | undefined { const probe = stableShimPathProbe(wrapperPath); if (!probe) return undefined; // Platforms that still write in place (Windows) have no staged identity; fall // back to the marker check they have always used. if (!written) return probe.prefix.includes(SHIM_MARKER) ? probe.fingerprint : undefined; - if (probe.fingerprint.dev !== written.dev || probe.fingerprint.ino !== written.ino) return undefined; + if (!sameFileIdentity(probe.fingerprint.identity, written)) return undefined; return probe.fingerprint; } @@ -118,11 +119,11 @@ export function ownedWrapperFingerprint( */ export function wrapperInodeIsOurs( wrapper: StableShimPathProbe, - written: { dev: number; ino: number } | undefined, + written: FileIdentity | undefined, recorded: ShimPathFingerprint | undefined, ): boolean { // A different inode is conclusive: someone renamed their own file over ours. - if (written && (wrapper.fingerprint.dev !== written.dev || wrapper.fingerprint.ino !== written.ino)) { + if (written && !sameFileIdentity(wrapper.fingerprint.identity, written)) { return false; } // Same inode is not sufficient on its own — an in-place truncation (shell `>`, diff --git a/src/codex/shim.ts b/src/codex/shim.ts index 99b525e2beb..24d0f8b99eb 100644 --- a/src/codex/shim.ts +++ b/src/codex/shim.ts @@ -10,6 +10,7 @@ import { } from "node:fs"; import { delimiter, extname, join, posix, win32 } from "node:path"; import { durableBunRuntime, type DurableBunRuntime } from "../lib/bun-runtime"; +import type { FileIdentity } from "../lib/file-identity"; import { assertSelectedRuntimeWritable, RuntimePreflightError, type RuntimePreflightDeps } from "../lib/bun-runtime-preflight"; import { getConfigDir } from "../config"; import { isWslRuntime, wslAutomountRoot } from "./home"; @@ -264,7 +265,7 @@ interface FreshShimInstallJournalEntry { writtenWrapperFingerprint?: ShimPathFingerprint; wrapperWriteStarted: boolean; /** dev/ino of the file our `writeShim()` created, recorded before anything can fail. */ - writtenWrapperInode?: { dev: number; ino: number }; + writtenWrapperInode?: FileIdentity; } function rollbackFreshShimInstall(journal: readonly FreshShimInstallJournalEntry[]): void { diff --git a/src/codex/transition-state.ts b/src/codex/transition-state.ts index b23c68ef8c2..e1cd8ad4110 100644 --- a/src/codex/transition-state.ts +++ b/src/codex/transition-state.ts @@ -21,6 +21,7 @@ import { unlinkSync, } from "node:fs"; import { basename, dirname, join } from "node:path"; +import { fileIdentityKey } from "../lib/file-identity"; import { Database } from "bun:sqlite"; @@ -524,11 +525,11 @@ export function openCodexCoordinatorTransaction( ); } } - const opened = lstatSync(finalDatabasePath); + const opened = lstatSync(finalDatabasePath, { bigint: true }); if (opened.isSymbolicLink() || !opened.isFile()) { throw new CodexUserIdentityRefusal("The coordinator database path changed during open."); } - initialIdentity = `${opened.dev}:${opened.ino}`; + initialIdentity = fileIdentityKey(opened); database.exec("PRAGMA busy_timeout = 0; PRAGMA locking_mode = NORMAL; BEGIN IMMEDIATE"); transactionOpen = true; initialize(database, databaseWasAbsent || databaseWasEmpty); @@ -546,9 +547,9 @@ export function openCodexCoordinatorTransaction( }; const assertStablePath = (): void => { requireOpen(); - const entry = lstatSync(finalDatabasePath); + const entry = lstatSync(finalDatabasePath, { bigint: true }); if (entry.isSymbolicLink() || !entry.isFile() - || `${entry.dev}:${entry.ino}` !== initialIdentity + || fileIdentityKey(entry) !== initialIdentity || !samePathIdentity(realpathSync.native(finalDatabasePath), finalDatabasePath)) { throw new CodexUserIdentityRefusal("The coordinator database path was substituted."); } diff --git a/src/lib/file-identity.ts b/src/lib/file-identity.ts index 86474299131..4258a1cd742 100644 --- a/src/lib/file-identity.ts +++ b/src/lib/file-identity.ts @@ -1,6 +1,11 @@ /** Acquire stats with { bigint: true }; never widen a Number to form an identity. */ export type FileIdentity = Readonly<{ dev: bigint; ino: bigint }>; +/** Matches the numeric stat's *Ms value, so a bigint observation can be compared with a numeric or persisted one. */ +export function statTimeMs(ns: bigint): number { + return Number(ns / 1_000_000_000n) * 1e3 + Number(ns % 1_000_000_000n) / 1e6; +} + export function sameFileIdentity(a: FileIdentity, b: FileIdentity): boolean { return a.dev === b.dev && a.ino === b.ino; } diff --git a/src/oauth/store.ts b/src/oauth/store.ts index 2985194917b..20a4b418dbf 100644 --- a/src/oauth/store.ts +++ b/src/oauth/store.ts @@ -35,6 +35,7 @@ import { getConfigDir, atomicWriteFile, backupInvalidConfig, hardenConfigDir, ha import { atomicWriteFileNoFollowUnclaimed } from "../config/atomic-write"; import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; import { recordOwnedConfigPath } from "../lib/config-ownership"; +import { statTimeMs } from "../lib/file-identity"; import { MAX_PENDING_OAUTH_MUTATIONS } from "../lib/translator-budget"; import { publishAccountSelection, publishOAuthAccountPauseChange, publishOAuthAccountRoutingPolicyChange } from "../lib/account-selection-events"; import { @@ -433,7 +434,7 @@ interface LockSnapshot { bytes: string; dev: bigint; ino: bigint; mtimeMs: numbe export interface OAuthFileLockOptions { path: string; waitTimeoutMs?: number; staleAfterMs?: number; pollMinMs?: number; pollMaxMs?: number; sleep?: (ms: number) => Promise; now?: () => number; random?: () => number; beforeStaleUnlink?: () => void; beforeReleaseUnlink?: () => void; beforeFailedCreateUnlink?: () => void; writeMetadata?: (fd: number, bytes: string) => void } export interface OAuthFileLockGuard { readonly ownerId: string; release(): void } function errorCode(error: unknown): string | undefined { return error && typeof error === "object" && "code" in error ? String((error as { code?: unknown }).code) : undefined; } -function snapshot(path: string): LockSnapshot { const bytes = readFileSync(path, "utf8"); const s = statSync(path, { bigint: true }); return { bytes, dev:s.dev, ino:s.ino, mtimeMs:Number(s.mtimeNs)/1e6, mtimeNs:s.mtimeNs, size:s.size }; } +function snapshot(path: string): LockSnapshot { const bytes = readFileSync(path, "utf8"); const s = statSync(path, { bigint: true }); return { bytes, dev:s.dev, ino:s.ino, mtimeMs:statTimeMs(s.mtimeNs), mtimeNs:s.mtimeNs, size:s.size }; } function sameSnapshot(a: LockSnapshot,b: LockSnapshot): boolean { return a.bytes===b.bytes&&a.dev===b.dev&&a.ino===b.ino&&a.mtimeNs===b.mtimeNs&&a.size===b.size; } function sameFd(a: LockSnapshot,b: import("node:fs").BigIntStats): boolean { return a.dev===b.dev&&a.ino===b.ino&&a.mtimeNs===b.mtimeNs&&a.size===b.size; } export function createOAuthFileLock(options: OAuthFileLockOptions): { acquire(): Promise } { diff --git a/src/remote-control/workspace-executor.ts b/src/remote-control/workspace-executor.ts index e165cdc59f2..fa499dc2a93 100644 --- a/src/remote-control/workspace-executor.ts +++ b/src/remote-control/workspace-executor.ts @@ -14,6 +14,7 @@ import { } from "node:fs"; import { basename, dirname, isAbsolute, posix, relative, resolve, sep, win32 } from "node:path"; import { renameAtomicFile } from "../lib/windows-atomic-replace"; +import { sameFileIdentity, statTimeMs } from "../lib/file-identity"; import { REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES, type RemoteWorkspaceToolName, @@ -65,8 +66,8 @@ export interface RemoteWorkspaceCommandRunner { interface ApprovedRoot { id: string; path: string; - dev: number; - ino: number; + dev: bigint; + ino: bigint; birthtimeMs: number; } @@ -170,20 +171,20 @@ function decodeUtf8(value: Uint8Array): string { } function assertOpenedRegularFile(root: string, target: string, descriptor: number, maximum: number) { - const opened = fstatSync(descriptor); - const linked = lstatSync(target); - if (opened.isFile() && linked.isFile() && (opened.nlink !== 1 || linked.nlink !== 1)) { + const opened = fstatSync(descriptor, { bigint: true }); + const linked = lstatSync(target, { bigint: true }); + if (opened.isFile() && linked.isFile() && (opened.nlink !== 1n || linked.nlink !== 1n)) { throw new Error("remote workspace hard-linked files are not allowed"); } if (!opened.isFile() || !linked.isFile() || linked.isSymbolicLink() - || opened.dev !== linked.dev || opened.ino !== linked.ino - || opened.birthtimeMs !== linked.birthtimeMs) { + || !sameFileIdentity(opened, linked) + || statTimeMs(opened.birthtimeNs) !== statTimeMs(linked.birthtimeNs)) { throw new Error("remote workspace file identity changed during access"); } const canonical = realpathSync(target); if (!inside(root, canonical)) throw new Error("remote workspace path escapes the approved root"); if (opened.size > maximum) throw new Error("remote workspace file exceeds the read limit"); - return opened; + return { size: Number(opened.size), mode: Number(opened.mode) }; } function readBoundedRegularFile(root: string, target: string, maximum: number): { body: Buffer; mode: number } { @@ -216,11 +217,11 @@ function assertStableWritableParent(root: string, target: string): void { } function assertApprovedRootIdentity(root: ApprovedRoot): void { - const linked = lstatSync(root.path); + const linked = lstatSync(root.path, { bigint: true }); const canonical = realpathSync(root.path); if (!linked.isDirectory() || linked.isSymbolicLink() - || linked.dev !== root.dev || linked.ino !== root.ino - || linked.birthtimeMs !== root.birthtimeMs + || !sameFileIdentity(linked, root) + || statTimeMs(linked.birthtimeNs) !== root.birthtimeMs || relative(root.path, canonical) !== "") { throw new Error("remote workspace approved root identity changed; pair the folder again"); } @@ -256,13 +257,13 @@ export class RemoteWorkspaceExecutor { const metadata = lstatSync(root.path); if (!metadata.isDirectory() || metadata.isSymbolicLink()) throw new Error("remote workspace root must be a real directory"); const canonical = realpathSync(root.path); - const identity = lstatSync(canonical); + const identity = lstatSync(canonical, { bigint: true }); this.roots.set(root.id, { id: root.id, path: canonical, dev: identity.dev, ino: identity.ino, - birthtimeMs: identity.birthtimeMs, + birthtimeMs: statTimeMs(identity.birthtimeNs), }); } if (this.roots.size === 0) throw new Error("remote workspace executor needs one approved root"); diff --git a/src/service/state-lock.ts b/src/service/state-lock.ts index d57b85b0b69..4f4fe6da189 100644 --- a/src/service/state-lock.ts +++ b/src/service/state-lock.ts @@ -15,6 +15,7 @@ import { type BigIntStats, } from "node:fs"; import { basename, dirname, join } from "node:path"; +import { statTimeMs } from "../lib/file-identity"; const SERVICE_STATE_LOCK_WAIT_MS = 2_000; const SERVICE_STATE_LOCK_POLL_MS = 20; @@ -116,7 +117,7 @@ function readLockSnapshot(lockPath: string): ServiceStateLockSnapshot | null { if (!currentLock.isDirectory() || !sameIdentity(lockIdentity, currentLock) || !currentOwner.isFile() || !sameIdentity(ownerIdentity, currentOwner) || currentOwner.size !== ownerIdentity.size) return null; - return { record, ownerPath, lockIdentity, ownerIdentity, mtimeMs: Number(ownerIdentity.mtimeNs) / 1e6 }; + return { record, ownerPath, lockIdentity, ownerIdentity, mtimeMs: statTimeMs(ownerIdentity.mtimeNs) }; } catch { return null; } diff --git a/tests/clients/remote-workspace.test.ts b/tests/clients/remote-workspace.test.ts index a03d536ec02..bd68c779e1d 100644 --- a/tests/clients/remote-workspace.test.ts +++ b/tests/clients/remote-workspace.test.ts @@ -1,4 +1,5 @@ -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import * as fs from "node:fs"; import { createHash, randomUUID } from "node:crypto"; import { execFileSync, spawnSync } from "node:child_process"; import { @@ -144,6 +145,51 @@ function responseValue(response: Awaited { + test("rejects an opened target with an adjacent NTFS-sized inode", async () => { + const state = fixture(); + const target = join(state.executorRoot, "project", "marker.txt"); + const id = 9_007_199_254_740_992n; + const realLstat = fs.lstatSync, realFstat = fs.fstatSync; + const lstat = spyOn(fs, "lstatSync").mockImplementation(((path, options) => { + const stat = options?.bigint ? realLstat(path, { bigint: true }) : realLstat(path); + return path === target ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + { ino: options?.bigint ? id : Number(id) }) : stat; + }) as typeof fs.lstatSync); + const fstat = spyOn(fs, "fstatSync").mockImplementation(((fd, options) => { + const stat = options?.bigint ? realFstat(fd, { bigint: true }) : realFstat(fd); + return Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + { ino: options?.bigint ? id + 1n : Number(id + 1n) }); + }) as typeof fs.fstatSync); + try { + const result = responseValue(await state.coordinator.handle(state.request("read_file", { path: "project/marker.txt" }))); + expect(result.ok).toBe(false); + expect(result.error).toContain("file identity changed"); + } finally { fstat.mockRestore(); lstat.mockRestore(); } + }); + + test("rejects an approved root with an adjacent NTFS-sized inode", async () => { + const state = fixture(); + const realLstat = fs.lstatSync; + const id = 9_007_199_254_740_992n; + let replacement = false; + const lstat = spyOn(fs, "lstatSync").mockImplementation(((path, options) => { + const stat = options?.bigint ? realLstat(path, { bigint: true }) : realLstat(path); + const ino = id + (replacement ? 1n : 0n); + return path === state.executorRoot ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + { ino: options?.bigint ? ino : Number(ino) }) : stat; + }) as typeof fs.lstatSync); + try { + const executor = new RemoteWorkspaceExecutor({ deviceId: "device", roots: [{ id: "root", path: state.executorRoot }] }); + replacement = true; + const result = await executor.invoke({ requestId: "request", sessionId: "session", executorDeviceId: "device", + rootId: "root", tool: "read_file", arguments: { path: "project/marker.txt" } }); + expect(result.ok).toBe(false); + expect(result.error).toContain("approved root identity changed"); + } finally { lstat.mockRestore(); } + }); +}); + describe("remote workspace coordinator and executor", () => { test.skipIf(process.platform === "win32")("refuses FIFO read and write preconditions without blocking", () => { const state = fixture(); diff --git a/tests/codex-integration/codex-coordinator-doctor.test.ts b/tests/codex-integration/codex-coordinator-doctor.test.ts index acbf312ca26..d7d85da1f14 100644 --- a/tests/codex-integration/codex-coordinator-doctor.test.ts +++ b/tests/codex-integration/codex-coordinator-doctor.test.ts @@ -1,4 +1,5 @@ -import { afterEach, beforeEach, expect, test } from "bun:test"; +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import * as fs from "node:fs"; import { chmodSync, existsSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -80,6 +81,23 @@ test("doctor classifies and explicitly backs up a stable zero-byte coordinator", rmSync(recovered.backupPath, { force: true }); }); +test("doctor detects adjacent large file identities", () => { + privateFile(coordinatorPath); + const realLstat = fs.lstatSync; + const id = 2n ** 53n; + let observations = 0; + const spy = spyOn(fs, "lstatSync").mockImplementation(((target, options) => { + const value = options?.bigint ? realLstat(target, { bigint: true }) : realLstat(target); + if (target !== coordinatorPath) return value; + const ino = id + BigInt(observations++ > 0); + return Object.assign(Object.create(Object.getPrototypeOf(value)), value, { ino: options?.bigint ? ino : Number(ino) }); + }) as typeof fs.lstatSync); + try { + expect(Number(id)).toBe(Number(id + 1n)); + expect(inspectCodexCoordinator().kind).toBe("changed"); + } finally { spy.mockRestore(); } +}); + test("doctor distinguishes unversioned, rowless, and authoritative coordinators", () => { let database = new Database(coordinatorPath, { create: true }); database.exec("CREATE TABLE temporary_probe (id INTEGER); DROP TABLE temporary_probe"); diff --git a/tests/codex-integration/codex-history-provider.test.ts b/tests/codex-integration/codex-history-provider.test.ts index 06b7a5e39e4..c4ac9817963 100644 --- a/tests/codex-integration/codex-history-provider.test.ts +++ b/tests/codex-integration/codex-history-provider.test.ts @@ -1,8 +1,9 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs"; +import * as fs from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Database } from "bun:sqlite"; -import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { afterEach, describe, expect, setDefaultTimeout, spyOn, test } from "bun:test"; import { classifyRecoverableHistoryError, countPendingOpencodexHistory, historyBackupPathFor, isRecoverableHistoryError, legacyHistoryBackupPathFor, migrateHistoryToOpenai, resolveExistingHistoryBackupPath, restoreLegacyOpenaiHistory, restoredUserEventFor, setAfterNoopPendingCountForTests, setAfterStrictHistoryRolloutAppendForTests, setBeforeHistoryApplyTransactionForTests, setBeforeHistoryBackupConsumeForTests, setBeforeStrictHistoryRolloutAppendForTests, setHistoryDbBusyTimeoutForTests, snapshotCodexHistoryNoop, syncCodexHistoryProvider, withHistoryRetry } from "../../src/codex/history-provider"; import { codexHistoryBackupId, legacyCodexHistoryBackupId, sameCodexHistoryPath } from "../../src/codex/history-manifest"; import { INVALID_HISTORY_BACKUP_FIXTURES, validHistoryBackupFixture } from "../helpers/codex-history-manifest-fixtures"; @@ -106,6 +107,35 @@ function makeFixture({ includeExec = false, includeLegacy = false } = {}) { } describe("Codex history provider sync", () => { + test("descriptor guard rejects adjacent NTFS-sized file identities", () => { + const fixture = makeFixture(); + noopSnapshotArtifacts.add(join(fixture.dbPath, "..")); + const id = 9_007_199_254_740_992n; + const realLstat = fs.lstatSync, realFstat = fs.fstatSync, realOpen = fs.openSync, realClose = fs.closeSync; + let armed = false; + const rolloutFds = new Set(); + const open = spyOn(fs, "openSync").mockImplementation(((path, flags, mode) => { + const fd = realOpen(path, flags, mode); + if (path === fixture.rollout) rolloutFds.add(fd); + return fd; + }) as typeof fs.openSync); + const close = spyOn(fs, "closeSync").mockImplementation((fd => { rolloutFds.delete(fd); realClose(fd); }) as typeof fs.closeSync); + const lstat = spyOn(fs, "lstatSync").mockImplementation(((path, options) => { + const stat = options?.bigint ? realLstat(path, { bigint: true }) : realLstat(path); + return path === fixture.rollout && armed ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + { ino: options?.bigint ? id : Number(id) }) : stat; + }) as typeof fs.lstatSync); + const fstat = spyOn(fs, "fstatSync").mockImplementation(((fd, options) => { + const stat = options?.bigint ? realFstat(fd, { bigint: true }) : realFstat(fd); + return armed && rolloutFds.has(fd) ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + { ino: options?.bigint ? id + 1n : Number(id + 1n) }) : stat; + }) as typeof fs.fstatSync); + setHistoryAppendHooksForTests({ beforeWrite: () => { armed = true; } }); + try { + expect(syncCodexHistoryProvider("opencodex", fixture.dbPath, fixture.backupPath)) + .toMatchObject({ failed: true, integrityCode: "history_rollout_identity_changed" }); + } finally { close.mockRestore(); open.mockRestore(); fstat.mockRestore(); lstat.mockRestore(); } + }); test.each(["{broken", "null", "[]", "42", '"text"'])("invalid first record %s is a structured integrity failure", (first) => { const fixture = makeFixture(); noopSnapshotArtifacts.add(join(fixture.dbPath,"..")); @@ -1286,6 +1316,24 @@ describe("history lock retry", () => { }); describe("Design B migration helpers", () => { + test("no-op snapshot rejects adjacent NTFS-sized database identities", () => { + const fixture = makeFixture(); + const backup = historyBackupPathFor(fixture.dbPath); + noopSnapshotArtifacts.add(join(fixture.dbPath, "..")); + noopSnapshotArtifacts.add(backup); + const id = 9_007_199_254_740_992n; + const real = fs.statSync; + let replaced = false; + const stat = spyOn(fs, "statSync").mockImplementation(((path, options) => { + const result = options?.bigint ? real(path, { bigint: true }) : real(path); + const ino = id + (replaced ? 1n : 0n); + return path === fixture.dbPath ? Object.assign(Object.create(Object.getPrototypeOf(result)), result, + { ino: options?.bigint ? ino : Number(ino) }) : result; + }) as typeof fs.statSync); + setAfterNoopPendingCountForTests(() => { replaced = true; }); + try { expect(snapshotCodexHistoryNoop(fixture.dbPath, backup)).toMatchObject({ kind: "unknown", reason: "snapshot-race" }); } + finally { setAfterNoopPendingCountForTests(undefined); stat.mockRestore(); } + }); test("strict no-op snapshots distinguish absence from manifest uncertainty", () => { const dir = join(tmpdir(), `ocx-history-noop-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`); mkdirSync(dir, { recursive: true }); diff --git a/tests/codex-integration/codex-main-account-refresh.test.ts b/tests/codex-integration/codex-main-account-refresh.test.ts index 17ba7ac84fc..2535bc88242 100644 --- a/tests/codex-integration/codex-main-account-refresh.test.ts +++ b/tests/codex-integration/codex-main-account-refresh.test.ts @@ -1,4 +1,5 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import * as fs from "node:fs"; import { existsSync, mkdtempSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -32,6 +33,25 @@ afterEach(() => { }); describe("native main token refresh", () => { + test("rejects adjacent large file identities before publication", async () => { + const path = join(home, "auth.json"); + const original = JSON.stringify({ auth_mode: "chatgpt", tokens: { refresh_token: "old-refresh", account_id: "account-main" } }); + writeFileSync(path, original); + const realStat = fs.statSync; + const id = 2n ** 53n; + let replaced = false; + const spy = spyOn(fs, "statSync").mockImplementation(((target, options) => { + const value = options?.bigint ? realStat(target, { bigint: true }) : realStat(target); + const ino = replaced ? id + 1n : id; + return target === path ? Object.assign(Object.create(Object.getPrototypeOf(value)), value, { ino: options?.bigint ? ino : Number(ino) }) : value; + }) as typeof fs.statSync); + setMainAuthJsonBeforeRenameHookForTests(() => { replaced = true; }); + try { + expect(Number(id)).toBe(Number(id + 1n)); + expect(await getValidMainAccountToken({ refreshToken: async () => ({ access: "new-access", refresh: "new-refresh", expires: Date.now() + 3600000, accountId: "account-main" }) })).toMatchObject({ accessToken: "new-access" }); + expect(readFileSync(path, "utf8")).toBe(original); + } finally { spy.mockRestore(); } + }); test("refreshes a refresh-only auth file and atomically preserves unrelated fields", async () => { const authPath = join(home, "auth.json"); const original = { diff --git a/tests/codex-integration/codex-native-residue.test.ts b/tests/codex-integration/codex-native-residue.test.ts index 0a870265e65..3291cd5119d 100644 --- a/tests/codex-integration/codex-native-residue.test.ts +++ b/tests/codex-integration/codex-native-residue.test.ts @@ -1,4 +1,5 @@ -import { afterEach, beforeEach, expect, test } from "bun:test"; +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import * as fs from "node:fs"; import { createHash, randomUUID } from "node:crypto"; import { chmodSync, @@ -68,6 +69,24 @@ function pathInCodexHome(name: string): string { return join(codexHome, name); } +test("residue inspection detects adjacent large file identities", () => { + const path = pathInCodexHome("config.toml"); + writeFileSync(path, 'model = "example"\n'); + const realStat = fs.statSync; + const id = 2n ** 53n; + let observations = 0; + const spy = spyOn(fs, "statSync").mockImplementation(((target, options) => { + const value = options?.bigint ? realStat(target, { bigint: true }) : realStat(target); + if (target !== path) return value; + const ino = id + BigInt(observations++ > 0); + return Object.assign(Object.create(Object.getPrototypeOf(value)), value, { ino: options?.bigint ? ino : Number(ino) }); + }) as typeof fs.statSync); + try { + expect(Number(id)).toBe(Number(id + 1n)); + expect(classifyNativeRoutedResidue()).toMatchObject({ kind: "indeterminate", surface: "config", reason: "surface changed while it was being observed" }); + } finally { spy.mockRestore(); } +}); + function canonicalPathInCodexHome(name: string): string { return join(realpathSync.native(codexHome), name); } diff --git a/tests/codex-integration/codex-shim-windows-revision.test.ts b/tests/codex-integration/codex-shim-windows-revision.test.ts index 2d6e08aff0b..dbd9c281c82 100644 --- a/tests/codex-integration/codex-shim-windows-revision.test.ts +++ b/tests/codex-integration/codex-shim-windows-revision.test.ts @@ -8,10 +8,13 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import * as fs from "node:fs"; import { tmpdir } from "node:os"; -import { basename, join } from "node:path"; +import { basename, join, sep } from "node:path"; import { autoRestoreCodexShim, installCodexShim, uninstallCodexShim } from "../../src/codex/shim"; import { createWorkerCommitWindow, createCodexCliUpdateFence, codexCliUpdateSelectionGate } from "../../src/codex/cli-update-selection-gate"; -import { stableShimPathProbe } from "../../src/codex/shim-fingerprint"; +import { sameFingerprint, stableShimPathProbe } from "../../src/codex/shim-fingerprint"; +import { ownedWrapperFingerprint, wrapperInodeIsOurs } from "../../src/codex/shim-wrapper-write"; +import { readBoundedRegularFile } from "../../src/codex/shim-state-file"; +import { tryAcquireShimRestoreLock } from "../../src/codex/shim-restore-lock"; import { refreshObsoleteWindowsShim } from "../../src/codex/shim-windows-refresh"; import { buildWindowsCodexShim, buildWindowsPowerShellCodexShim, WINDOWS_SHIM_REVISION_MARKER } from "../../src/codex/shim-templates"; import { isolateCodexShimEnvironment } from "../helpers/codex-shim-install-fixture"; @@ -50,6 +53,103 @@ afterEach(() => { removeTreeWithRetry(dir); }); +test("shim probe rejects adjacent NTFS-sized file identities", () => { + const path = join(dir, "launcher"); + fs.writeFileSync(path, "launcher fixture"); + const id = 9_007_199_254_740_992n; + const real = fs.lstatSync; + let calls = 0; + const stat = spyOn(fs, "lstatSync").mockImplementation(((target, options) => { + const result = options?.bigint ? real(target, { bigint: true }) : real(target); + if (target !== path) return result; + const ino = id + BigInt(calls++ > 0); + return Object.assign(Object.create(Object.getPrototypeOf(result)), result, + { ino: options?.bigint ? ino : Number(ino) }); + }) as typeof fs.lstatSync); + try { expect(stableShimPathProbe(path)).toBeNull(); } + finally { stat.mockRestore(); } +}); + +for (const [name, owns] of [ + ["wrapperInodeIsOurs", (path, probe, written) => wrapperInodeIsOurs(probe, written, undefined)], + ["ownedWrapperFingerprint", (path, _probe, written) => ownedWrapperFingerprint(path, written) !== undefined], +] as const satisfies ReadonlyArray>, written: { dev: bigint; ino: bigint }) => boolean]>) test(`${name} rejects adjacent identities with equal numeric fields`, () => { + const path = join(dir, "launcher"); + fs.writeFileSync(path, "launcher fixture"); + const id = 2n ** 53n; + const real = fs.lstatSync; + const stat = spyOn(fs, "lstatSync").mockImplementation(((target, options) => { + const value = options?.bigint ? real(target, { bigint: true }) : real(target); + return target === path ? Object.assign(Object.create(Object.getPrototypeOf(value)), value, + { dev: options?.bigint ? 1n : 1, ino: options?.bigint ? id : Number(id) }) : value; + }) as typeof fs.lstatSync); + try { + const probe = stableShimPathProbe(path)!; + const written = { dev: 1n, ino: id + 1n }; + expect(probe.fingerprint.ino).toBe(Number(written.ino)); + expect(owns(path, probe, written)).toBe(false); + // A legacy numeric observation must not regain ownership after rounding. + const legacy = { dev: 1, ino: Number(written.ino) } as unknown as typeof written; + expect(owns(path, probe, legacy)).toBe(false); + } finally { stat.mockRestore(); } +}); + +test("fingerprint comparisons fail closed when identity is missing", () => { + const path = join(dir, "launcher"); + fs.writeFileSync(path, "launcher fixture"); + const fingerprint = stableShimPathProbe(path)!.fingerprint; + const missing = { ...fingerprint, identity: undefined } as unknown as typeof fingerprint; + expect(sameFingerprint(fingerprint, missing)).toBe(false); + expect(sameFingerprint(missing, fingerprint)).toBe(false); +}); + +test("shim state read rejects adjacent NTFS-sized file identities", () => { + const path = join(dir, "state.json"); + fs.writeFileSync(path, "{}"); + const id = 9_007_199_254_740_992n; + const realLstat = fs.lstatSync, realFstat = fs.fstatSync; + const lstat = spyOn(fs, "lstatSync").mockImplementation(((target, options) => { + const stat = options?.bigint ? realLstat(target, { bigint: true }) : realLstat(target); + return target === path ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + { ino: options?.bigint ? id : Number(id) }) : stat; + }) as typeof fs.lstatSync); + const fstat = spyOn(fs, "fstatSync").mockImplementation(((fd, options) => { + const stat = options?.bigint ? realFstat(fd, { bigint: true }) : realFstat(fd); + return Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + { ino: options?.bigint ? id + 1n : Number(id + 1n) }); + }) as typeof fs.fstatSync); + try { expect(readBoundedRegularFile(path, 1024)).toMatchObject({ warning: expect.stringContaining("changed while being read") }); } + finally { fstat.mockRestore(); lstat.mockRestore(); } +}); + +test("shim restore lock preserves an owner with an adjacent NTFS-sized identity", () => { + const lockPath = join(dir, "codex-shim.autorestore.lock"); + const id = 9_007_199_254_740_992n; + const realOpen = fs.openSync, realFstat = fs.fstatSync, realLstat = fs.lstatSync; + let ownerFd: number | undefined; + const open = spyOn(fs, "openSync").mockImplementation(((path, flags, mode) => { + const fd = realOpen(path, flags, mode); + if (String(path).startsWith(lockPath) && flags === "wx") ownerFd = fd; + return fd; + }) as typeof fs.openSync); + const fstat = spyOn(fs, "fstatSync").mockImplementation(((fd, options) => { + const stat = options?.bigint ? realFstat(fd, { bigint: true }) : realFstat(fd); + return fd === ownerFd ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + { ino: options?.bigint ? id : Number(id) }) : stat; + }) as typeof fs.fstatSync); + const lstat = spyOn(fs, "lstatSync").mockImplementation(((path, options) => { + const stat = options?.bigint ? realLstat(path, { bigint: true }) : realLstat(path); + return String(path).startsWith(lockPath + sep) ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + { ino: options?.bigint ? id + 1n : Number(id + 1n) }) : stat; + }) as typeof fs.lstatSync); + try { + const lock = tryAcquireShimRestoreLock(); + expect(lock).not.toBeNull(); + lock!.release(); + expect(fs.existsSync(lockPath)).toBe(true); + } finally { lstat.mockRestore(); fstat.mockRestore(); open.mockRestore(); } +}); + /** The pre-fix template: no Windows revision line, and the token load ahead of the bypass check. */ function previousTemplate(flavor: "cmd" | "ps1", backing: string): string { const current = flavor === "cmd" diff --git a/tests/codex-integration/codex-transition-state.test.ts b/tests/codex-integration/codex-transition-state.test.ts index 9032dfcbe47..5fc97aaea9e 100644 --- a/tests/codex-integration/codex-transition-state.test.ts +++ b/tests/codex-integration/codex-transition-state.test.ts @@ -1,4 +1,5 @@ -import { afterEach, beforeEach, expect, test } from "bun:test"; +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import * as fs from "node:fs"; import { chmodSync, mkdirSync, mkdtempSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -59,6 +60,25 @@ function transition(txId: string) { }; } +test("coordinator detects adjacent large file identities", () => { + const realLstat = fs.lstatSync; + const id = 2n ** 53n; + let replaced = false; + const spy = spyOn(fs, "lstatSync").mockImplementation(((target, options) => { + const value = options?.bigint ? realLstat(target, { bigint: true }) : realLstat(target); + const ino = replaced ? id + 1n : id; + return target === coordinatorPath ? Object.assign(Object.create(Object.getPrototypeOf(value)), value, { ino: options?.bigint ? ino : Number(ino) }) : value; + }) as typeof fs.lstatSync); + try { + const controller = openCodexCoordinatorTransaction(coordinatorPath); + try { + replaced = true; + expect(Number(id)).toBe(Number(id + 1n)); + expect(() => controller.assertStablePath()).toThrow("substituted"); + } finally { controller.close(); } + } finally { spy.mockRestore(); } +}); + test("resolve persists zero counts only for a verified-noop proof", () => { const started = beginCodexTransition( { nativeGeneration: 0, currentTxId: null }, diff --git a/tests/codex-integration/native-main-owner-lifetime.test.ts b/tests/codex-integration/native-main-owner-lifetime.test.ts index 018afa750ad..c47d572850d 100644 --- a/tests/codex-integration/native-main-owner-lifetime.test.ts +++ b/tests/codex-integration/native-main-owner-lifetime.test.ts @@ -1,4 +1,5 @@ -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import * as fs from "node:fs"; import { Database } from "bun:sqlite"; import { existsSync, @@ -27,6 +28,34 @@ import { probeNativeProfileRecoveryState } from "../../src/codex/native-profile- import type { NativeProfileKey, NativeProfileKeyProvider } from "../../src/codex/native-profile-types"; import type { OcxConfig } from "../../src/types"; import { helperPath, repoRoot } from "../helpers/repo-root"; +import { assertStableLockFile, openStableLockFile } from "../../src/codex/native-main-lock-file"; + +test("stable lock rejects adjacent large file identities", () => { + const scratch = join(repoRoot(), ".tmp", "ino-audit"); + mkdirSync(scratch, { recursive: true }); + const root = mkdtempSync(join(scratch, "lock-")); + const path = join(root, "lock"); + const id = 2n ** 53n; + const realFstat = fs.fstatSync, realLstat = fs.lstatSync; + const fstat = spyOn(fs, "fstatSync").mockImplementation(((fd, options) => { + const value = options?.bigint ? realFstat(fd, { bigint: true }) : realFstat(fd); + return Object.assign(Object.create(Object.getPrototypeOf(value)), value, { ino: options?.bigint ? id : Number(id) }); + }) as typeof fs.fstatSync); + let replaced = false; + const lstat = spyOn(fs, "lstatSync").mockImplementation(((target, options) => { + const value = options?.bigint ? realLstat(target, { bigint: true }) : realLstat(target); + const ino = replaced ? id + 1n : id; + return target === path ? Object.assign(Object.create(Object.getPrototypeOf(value)), value, { ino: options?.bigint ? ino : Number(ino) }) : value; + }) as typeof fs.lstatSync); + try { + const handle = openStableLockFile(path); + try { + replaced = true; + expect(Number(id)).toBe(Number(id + 1n)); + expect(() => assertStableLockFile(path, handle)).toThrow("identity changed"); + } finally { handle.close(); } + } finally { fstat.mockRestore(); lstat.mockRestore(); rmSync(root, { recursive: true, force: true }); } +}); const roots: string[] = []; const previousCodexHome = process.env.CODEX_HOME; diff --git a/tests/lib/file-identity-callers.test.ts b/tests/lib/file-identity-callers.test.ts index 9adb4fea11c..d11920d7080 100644 --- a/tests/lib/file-identity-callers.test.ts +++ b/tests/lib/file-identity-callers.test.ts @@ -6,6 +6,19 @@ import { repoPath } from "../helpers/repo-root"; // calls made with { bigint: true }. A lexical tripwire over the named declarations, not a // full parser: it catches the regression forms below and fails closed on scopes it cannot find. const scopes: Record = { + "src/codex/codex-home-owner.ts": ["readCodexHomeJournal"], + "src/codex/orca-auth-source.ts": ["readBoundedLocalFile"], + "src/codex/inject/bounded-config-reader.ts": ["readBoundedCodexConfig"], + "src/codex/main-account.ts": ["statIdentity"], + "src/codex/coordinator-doctor.ts": ["inspectTarget"], + "src/codex/native-main-lock-file.ts": ["openStableLockFile", "assertStableLockFile"], + "src/codex/native-residue.ts": ["readRegularFile", "classifyHistoryDatabase"], + "src/codex/history-provider.ts": ["patchFirstLineProviderInPlace", "assertHistoryDescriptorIdentity", "historyFileIdentity"], + // shim-fingerprint.ts and shim-state-file.ts keep numeric dev/ino fields for the persisted shim + // state format; their runtime comparisons use bigint identity and have behavioral coverage. + "src/codex/shim-restore-lock.ts": ["readShimRestoreLockSnapshot", "tryAcquireShimRestoreLock"], + "src/codex/shim-wrapper-write.ts": ["writeShim"], + "src/remote-control/workspace-executor.ts": ["assertOpenedRegularFile", "assertApprovedRootIdentity"], "src/config/initialize.ts": ["identifiesDescriptor"], "src/service/state-lock.ts": ["readLockSnapshot"], "src/oauth/store.ts": ["snapshot", "createOAuthFileLock"], @@ -104,9 +117,16 @@ function declarationBody(code: string, name: string): [number, number] | null { while (code[at] === " " || code[at] === "\t") at++; if (code[at] === ":") { at++; - while (/\s/.test(code[at] ?? "")) at++; - if (code[at] === "{") at = matching(code, at, "{", "}") + 1; - while (at < code.length && code[at] !== "{" && code[at] !== ";" && code[at] !== "\n") at++; + // A "{" after | & : < , ( opens an object type, not the body. + for (;;) { + while (at < code.length && code[at] !== "{" && code[at] !== ";" && code[at] !== "\n") at++; + if (code[at] !== "{") break; + let back = at - 1; + while (back >= 0 && (code[back] === " " || code[back] === "\t")) back--; + if (!"|&:<,(".includes(code[back] ?? "")) break; + at = matching(code, at, "{", "}") + 1; + if (at <= 0) break; + } } if (code[at] !== "{") continue; const end = matching(code, at, "{", "}"); @@ -182,6 +202,8 @@ describe("file identity caller stats", () => { expect(inspect("check()\n{ lstatSync(p, { bigint: true }); }\nfunction check() { return lstatSync(path).ino; }", ["check"])) .toEqual(["numeric stat: lstatSync(path)"]); expect(inspect("function check() { return 1; }", ["check"])).toEqual(["no stat call in: check"]); + expect(inspect('function check(): { kind: "a" } | { kind: "b"; n: number } { return lstatSync(p).ino; }', ["check"])) + .toEqual(["numeric stat: lstatSync(p)"]); expect(inspect("function check() { lstatSync(root); }", ["check"], true)).toEqual(["no stat call in: check"]); }); diff --git a/tests/lib/file-identity.test.ts b/tests/lib/file-identity.test.ts index cb4e63afe42..f91b709d100 100644 --- a/tests/lib/file-identity.test.ts +++ b/tests/lib/file-identity.test.ts @@ -1,5 +1,35 @@ -import { expect, test } from "bun:test"; -import { fileIdentityKey, sameFileIdentity } from "../../src/lib/file-identity"; +import { expect, spyOn, test } from "bun:test"; +import * as fs from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileIdentityKey, sameFileIdentity, statTimeMs } from "../../src/lib/file-identity"; +import { readCodexHomeJournal } from "../../src/codex/codex-home-owner"; +import { readBoundedCodexConfig } from "../../src/codex/inject/bounded-config-reader"; +import { readBoundedLocalFile } from "../../src/codex/orca-auth-source"; +import { repoPath } from "../helpers/repo-root"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +test("bigint timestamps match numeric stat timestamps", () => { + const root = fs.mkdtempSync(join(tmpdir(), "ocx-stat-time-")); + try { + const path = join(root, "file"); + fs.writeFileSync(path, "fixture"); + for (const time of [1793358320.9811772, 1793358320.5383171, 1700000000.123456789, 1.5]) { + fs.utimesSync(path, time, time); + const big = fs.lstatSync(path, { bigint: true }); + const numeric = fs.lstatSync(path); + expect(statTimeMs(big.mtimeNs)).toBe(numeric.mtimeMs); + expect(statTimeMs(big.ctimeNs)).toBe(numeric.ctimeMs); + } + } finally { removeTreeWithRetry(root); } +}); + +test("stat milliseconds use whole seconds plus the nanosecond remainder", () => { + // Dividing the whole nanosecond count rounds differently from the runtime's sec*1e3 + nsec/1e6. + const ns = 1793358320981177200n; + expect(statTimeMs(ns)).toBe(1793358320981.1772); + expect(Number(ns) / 1e6).toBe(1793358320981.177); +}); test("compares and formats adjacent large file IDs", () => { const a = { dev: 7n, ino: 2n ** 53n }; @@ -10,3 +40,33 @@ test("compares and formats adjacent large file IDs", () => { expect(sameFileIdentity(a, { ...a, dev: 8n })).toBe(false); expect(fileIdentityKey(b).split(":").map(BigInt)).toEqual([b.dev, b.ino]); }); + +for (const [name, read] of [ + ["journal", (path: string) => expect(readCodexHomeJournal(path).kind).toBe("unknown")], + ["config", (path: string) => expect(() => readBoundedCodexConfig(path)).toThrow("changed while it was read")], + ["local file", (path: string) => expect(() => readBoundedLocalFile(path)).toThrow("Local credential file changed")], +] as const) { + test(`${name} compares adjacent large file IDs`, () => { + const scratch = repoPath(".tmp", "ino-audit"); + fs.mkdirSync(scratch, { recursive: true }); + const root = fs.mkdtempSync(join(scratch, "identity-")); + const path = join(root, "data"); + fs.writeFileSync(path, JSON.stringify({ version: 1, originalConfig: "", originalProfile: null })); + const id = 2n ** 53n; + const realStat = fs.statSync, realLstat = fs.lstatSync, realFstat = fs.fstatSync; + const stat = spyOn(fs, "statSync").mockImplementation(((target, options) => { + const value = options?.bigint ? realStat(target, { bigint: true }) : realStat(target); + return target === path ? Object.assign(Object.create(Object.getPrototypeOf(value)), value, { ino: options?.bigint ? id : Number(id) }) : value; + }) as typeof fs.statSync); + const lstat = spyOn(fs, "lstatSync").mockImplementation(((target, options) => { + const value = options?.bigint ? realLstat(target, { bigint: true }) : realLstat(target); + return target === path ? Object.assign(Object.create(Object.getPrototypeOf(value)), value, { ino: options?.bigint ? id : Number(id) }) : value; + }) as typeof fs.lstatSync); + const fstat = spyOn(fs, "fstatSync").mockImplementation(((fd, options) => { + const value = options?.bigint ? realFstat(fd, { bigint: true }) : realFstat(fd); + return Object.assign(Object.create(Object.getPrototypeOf(value)), value, { ino: options?.bigint ? id + 1n : Number(id + 1n) }); + }) as typeof fs.fstatSync); + try { read(path); } + finally { fstat.mockRestore(); lstat.mockRestore(); stat.mockRestore(); fs.rmSync(root, { recursive: true, force: true }); } + }); +} From c618c77b1e81aa8952422e2d3657439ed6d5f8fe Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 31 Oct 2026 07:47:12 +0900 Subject: [PATCH 82/84] test: select injected stat targets by canonical path (#8029) Regression tests from #8019/#8023 matched the spied path by raw string; code under test stats canonical paths (/private/var on macOS, long names for an 8.3 short TEMP on hosted Windows), so the injection never applied (lane=all run 41136081084). Add tests/helpers/same-path.ts and use it for every target selection. Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> --- tests/clients/remote-workspace.test.ts | 5 +-- .../codex-catalog-write-audit.test.ts | 7 ++-- .../codex-coordinator-doctor.test.ts | 3 +- .../codex-history-provider.test.ts | 7 ++-- .../codex-main-account-refresh.test.ts | 3 +- .../codex-native-residue.test.ts | 3 +- .../codex-shim-windows-revision.test.ts | 7 ++-- .../codex-transition-state.test.ts | 3 +- .../native-main-owner-lifetime.test.ts | 3 +- tests/config/config-mutation-lock.test.ts | 3 +- tests/helpers/same-path.ts | 34 +++++++++++++++++++ tests/lib/file-identity.test.ts | 5 +-- tests/providers/cc-switch-import.test.ts | 3 +- .../command-code-project-context.test.ts | 25 +++++++------- tests/service/service-ownership-state.test.ts | 3 +- 15 files changed, 81 insertions(+), 33 deletions(-) create mode 100644 tests/helpers/same-path.ts diff --git a/tests/clients/remote-workspace.test.ts b/tests/clients/remote-workspace.test.ts index bd68c779e1d..42742ef7501 100644 --- a/tests/clients/remote-workspace.test.ts +++ b/tests/clients/remote-workspace.test.ts @@ -1,3 +1,4 @@ +import { samePath } from "../helpers/same-path"; import { afterEach, describe, expect, spyOn, test } from "bun:test"; import * as fs from "node:fs"; import { createHash, randomUUID } from "node:crypto"; @@ -153,7 +154,7 @@ describe("remote workspace file identity", () => { const realLstat = fs.lstatSync, realFstat = fs.fstatSync; const lstat = spyOn(fs, "lstatSync").mockImplementation(((path, options) => { const stat = options?.bigint ? realLstat(path, { bigint: true }) : realLstat(path); - return path === target ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + return samePath(path, target) ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, { ino: options?.bigint ? id : Number(id) }) : stat; }) as typeof fs.lstatSync); const fstat = spyOn(fs, "fstatSync").mockImplementation(((fd, options) => { @@ -176,7 +177,7 @@ describe("remote workspace file identity", () => { const lstat = spyOn(fs, "lstatSync").mockImplementation(((path, options) => { const stat = options?.bigint ? realLstat(path, { bigint: true }) : realLstat(path); const ino = id + (replacement ? 1n : 0n); - return path === state.executorRoot ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + return samePath(path, state.executorRoot) ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, { ino: options?.bigint ? ino : Number(ino) }) : stat; }) as typeof fs.lstatSync); try { diff --git a/tests/codex-integration/codex-catalog-write-audit.test.ts b/tests/codex-integration/codex-catalog-write-audit.test.ts index a75d2b1c9a2..4b4dd3c660c 100644 --- a/tests/codex-integration/codex-catalog-write-audit.test.ts +++ b/tests/codex-integration/codex-catalog-write-audit.test.ts @@ -1,3 +1,4 @@ +import { samePath } from "../helpers/same-path"; import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync, symlinkSync } from "node:fs"; import * as fs from "node:fs"; @@ -599,7 +600,7 @@ describe("Windows audit privacy (r4176128642)", () => { { ctimeNs: stat.ctimeNs + (hardened ? 1_000_000n : 0n) }) as fs.BigIntStats; const fstat = spyOn(fs, "fstatSync").mockImplementation(((fd, options) => options?.bigint ? postAcl(realFstat(fd, { bigint: true })) : realFstat(fd)) as typeof fs.fstatSync); const lstat = spyOn(fs, "lstatSync").mockImplementation(((path, options) => - path === auditPath() && options?.bigint ? postAcl(realLstat(path, { bigint: true })) : realLstat(path)) as typeof fs.lstatSync); + samePath(path, auditPath()) && options?.bigint ? postAcl(realLstat(path, { bigint: true })) : realLstat(path)) as typeof fs.lstatSync); const write = spyOn(fs, "writeSync"); const open = spyOn(fs, "openSync"); harden.mockImplementation((path, options) => { @@ -630,7 +631,7 @@ describe("Windows audit privacy (r4176128642)", () => { }) as typeof fs.fstatSync); const lstat = spyOn(fs, "lstatSync").mockImplementation(((path, options) => { const stat = options?.bigint ? realLstat(path, { bigint: true }) : realLstat(path); - return path === auditPath() ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + return samePath(path, auditPath()) ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, { ino: options?.bigint ? identity + 1n : Number(identity + 1n) }) : stat; }) as typeof fs.lstatSync); try { @@ -718,7 +719,7 @@ describe("Windows audit privacy (r4176128642)", () => { const realOpen = fs.openSync; const open = spyOn(fs, "openSync").mockImplementation((path, flags, mode) => { const fd = realOpen(path, flags, mode); - if (phase === "before" && path === auditPath()) substitute(); + if (phase === "before" && samePath(path, auditPath())) substitute(); return fd; }); harden.mockImplementation(() => { substitute(); return { ok: true }; }); diff --git a/tests/codex-integration/codex-coordinator-doctor.test.ts b/tests/codex-integration/codex-coordinator-doctor.test.ts index d7d85da1f14..e7edaa29742 100644 --- a/tests/codex-integration/codex-coordinator-doctor.test.ts +++ b/tests/codex-integration/codex-coordinator-doctor.test.ts @@ -1,3 +1,4 @@ +import { samePath } from "../helpers/same-path"; import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; import * as fs from "node:fs"; import { chmodSync, existsSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; @@ -88,7 +89,7 @@ test("doctor detects adjacent large file identities", () => { let observations = 0; const spy = spyOn(fs, "lstatSync").mockImplementation(((target, options) => { const value = options?.bigint ? realLstat(target, { bigint: true }) : realLstat(target); - if (target !== coordinatorPath) return value; + if (!samePath(target, coordinatorPath)) return value; const ino = id + BigInt(observations++ > 0); return Object.assign(Object.create(Object.getPrototypeOf(value)), value, { ino: options?.bigint ? ino : Number(ino) }); }) as typeof fs.lstatSync); diff --git a/tests/codex-integration/codex-history-provider.test.ts b/tests/codex-integration/codex-history-provider.test.ts index c4ac9817963..28f1d94a377 100644 --- a/tests/codex-integration/codex-history-provider.test.ts +++ b/tests/codex-integration/codex-history-provider.test.ts @@ -1,3 +1,4 @@ +import { samePath } from "../helpers/same-path"; import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs"; import * as fs from "node:fs"; import { tmpdir } from "node:os"; @@ -116,13 +117,13 @@ describe("Codex history provider sync", () => { const rolloutFds = new Set(); const open = spyOn(fs, "openSync").mockImplementation(((path, flags, mode) => { const fd = realOpen(path, flags, mode); - if (path === fixture.rollout) rolloutFds.add(fd); + if (samePath(path, fixture.rollout)) rolloutFds.add(fd); return fd; }) as typeof fs.openSync); const close = spyOn(fs, "closeSync").mockImplementation((fd => { rolloutFds.delete(fd); realClose(fd); }) as typeof fs.closeSync); const lstat = spyOn(fs, "lstatSync").mockImplementation(((path, options) => { const stat = options?.bigint ? realLstat(path, { bigint: true }) : realLstat(path); - return path === fixture.rollout && armed ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + return samePath(path, fixture.rollout) && armed ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, { ino: options?.bigint ? id : Number(id) }) : stat; }) as typeof fs.lstatSync); const fstat = spyOn(fs, "fstatSync").mockImplementation(((fd, options) => { @@ -1327,7 +1328,7 @@ describe("Design B migration helpers", () => { const stat = spyOn(fs, "statSync").mockImplementation(((path, options) => { const result = options?.bigint ? real(path, { bigint: true }) : real(path); const ino = id + (replaced ? 1n : 0n); - return path === fixture.dbPath ? Object.assign(Object.create(Object.getPrototypeOf(result)), result, + return samePath(path, fixture.dbPath) ? Object.assign(Object.create(Object.getPrototypeOf(result)), result, { ino: options?.bigint ? ino : Number(ino) }) : result; }) as typeof fs.statSync); setAfterNoopPendingCountForTests(() => { replaced = true; }); diff --git a/tests/codex-integration/codex-main-account-refresh.test.ts b/tests/codex-integration/codex-main-account-refresh.test.ts index 2535bc88242..81610c1d0db 100644 --- a/tests/codex-integration/codex-main-account-refresh.test.ts +++ b/tests/codex-integration/codex-main-account-refresh.test.ts @@ -1,3 +1,4 @@ +import { samePath } from "../helpers/same-path"; import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import * as fs from "node:fs"; import { existsSync, mkdtempSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs"; @@ -43,7 +44,7 @@ describe("native main token refresh", () => { const spy = spyOn(fs, "statSync").mockImplementation(((target, options) => { const value = options?.bigint ? realStat(target, { bigint: true }) : realStat(target); const ino = replaced ? id + 1n : id; - return target === path ? Object.assign(Object.create(Object.getPrototypeOf(value)), value, { ino: options?.bigint ? ino : Number(ino) }) : value; + return samePath(target, path) ? Object.assign(Object.create(Object.getPrototypeOf(value)), value, { ino: options?.bigint ? ino : Number(ino) }) : value; }) as typeof fs.statSync); setMainAuthJsonBeforeRenameHookForTests(() => { replaced = true; }); try { diff --git a/tests/codex-integration/codex-native-residue.test.ts b/tests/codex-integration/codex-native-residue.test.ts index 3291cd5119d..d2d81c02ba2 100644 --- a/tests/codex-integration/codex-native-residue.test.ts +++ b/tests/codex-integration/codex-native-residue.test.ts @@ -1,3 +1,4 @@ +import { samePath } from "../helpers/same-path"; import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; import * as fs from "node:fs"; import { createHash, randomUUID } from "node:crypto"; @@ -77,7 +78,7 @@ test("residue inspection detects adjacent large file identities", () => { let observations = 0; const spy = spyOn(fs, "statSync").mockImplementation(((target, options) => { const value = options?.bigint ? realStat(target, { bigint: true }) : realStat(target); - if (target !== path) return value; + if (!samePath(target, path)) return value; const ino = id + BigInt(observations++ > 0); return Object.assign(Object.create(Object.getPrototypeOf(value)), value, { ino: options?.bigint ? ino : Number(ino) }); }) as typeof fs.statSync); diff --git a/tests/codex-integration/codex-shim-windows-revision.test.ts b/tests/codex-integration/codex-shim-windows-revision.test.ts index dbd9c281c82..423e1696fa7 100644 --- a/tests/codex-integration/codex-shim-windows-revision.test.ts +++ b/tests/codex-integration/codex-shim-windows-revision.test.ts @@ -1,3 +1,4 @@ +import { samePath } from "../helpers/same-path"; /** * An installed Windows CMD or PowerShell wrapper from before the bypass-token fix loads the * service token before honoring OCX_SHIM_BYPASS. Those wrappers carry the shim marker but no @@ -61,7 +62,7 @@ test("shim probe rejects adjacent NTFS-sized file identities", () => { let calls = 0; const stat = spyOn(fs, "lstatSync").mockImplementation(((target, options) => { const result = options?.bigint ? real(target, { bigint: true }) : real(target); - if (target !== path) return result; + if (!samePath(target, path)) return result; const ino = id + BigInt(calls++ > 0); return Object.assign(Object.create(Object.getPrototypeOf(result)), result, { ino: options?.bigint ? ino : Number(ino) }); @@ -80,7 +81,7 @@ for (const [name, owns] of [ const real = fs.lstatSync; const stat = spyOn(fs, "lstatSync").mockImplementation(((target, options) => { const value = options?.bigint ? real(target, { bigint: true }) : real(target); - return target === path ? Object.assign(Object.create(Object.getPrototypeOf(value)), value, + return samePath(target, path) ? Object.assign(Object.create(Object.getPrototypeOf(value)), value, { dev: options?.bigint ? 1n : 1, ino: options?.bigint ? id : Number(id) }) : value; }) as typeof fs.lstatSync); try { @@ -110,7 +111,7 @@ test("shim state read rejects adjacent NTFS-sized file identities", () => { const realLstat = fs.lstatSync, realFstat = fs.fstatSync; const lstat = spyOn(fs, "lstatSync").mockImplementation(((target, options) => { const stat = options?.bigint ? realLstat(target, { bigint: true }) : realLstat(target); - return target === path ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + return samePath(target, path) ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, { ino: options?.bigint ? id : Number(id) }) : stat; }) as typeof fs.lstatSync); const fstat = spyOn(fs, "fstatSync").mockImplementation(((fd, options) => { diff --git a/tests/codex-integration/codex-transition-state.test.ts b/tests/codex-integration/codex-transition-state.test.ts index 5fc97aaea9e..4314b675ff1 100644 --- a/tests/codex-integration/codex-transition-state.test.ts +++ b/tests/codex-integration/codex-transition-state.test.ts @@ -1,3 +1,4 @@ +import { samePath } from "../helpers/same-path"; import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; import * as fs from "node:fs"; import { chmodSync, mkdirSync, mkdtempSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"; @@ -67,7 +68,7 @@ test("coordinator detects adjacent large file identities", () => { const spy = spyOn(fs, "lstatSync").mockImplementation(((target, options) => { const value = options?.bigint ? realLstat(target, { bigint: true }) : realLstat(target); const ino = replaced ? id + 1n : id; - return target === coordinatorPath ? Object.assign(Object.create(Object.getPrototypeOf(value)), value, { ino: options?.bigint ? ino : Number(ino) }) : value; + return samePath(target, coordinatorPath) ? Object.assign(Object.create(Object.getPrototypeOf(value)), value, { ino: options?.bigint ? ino : Number(ino) }) : value; }) as typeof fs.lstatSync); try { const controller = openCodexCoordinatorTransaction(coordinatorPath); diff --git a/tests/codex-integration/native-main-owner-lifetime.test.ts b/tests/codex-integration/native-main-owner-lifetime.test.ts index c47d572850d..425bf74a26a 100644 --- a/tests/codex-integration/native-main-owner-lifetime.test.ts +++ b/tests/codex-integration/native-main-owner-lifetime.test.ts @@ -1,3 +1,4 @@ +import { samePath } from "../helpers/same-path"; import { afterEach, describe, expect, spyOn, test } from "bun:test"; import * as fs from "node:fs"; import { Database } from "bun:sqlite"; @@ -45,7 +46,7 @@ test("stable lock rejects adjacent large file identities", () => { const lstat = spyOn(fs, "lstatSync").mockImplementation(((target, options) => { const value = options?.bigint ? realLstat(target, { bigint: true }) : realLstat(target); const ino = replaced ? id + 1n : id; - return target === path ? Object.assign(Object.create(Object.getPrototypeOf(value)), value, { ino: options?.bigint ? ino : Number(ino) }) : value; + return samePath(target, path) ? Object.assign(Object.create(Object.getPrototypeOf(value)), value, { ino: options?.bigint ? ino : Number(ino) }) : value; }) as typeof fs.lstatSync); try { const handle = openStableLockFile(path); diff --git a/tests/config/config-mutation-lock.test.ts b/tests/config/config-mutation-lock.test.ts index 4b3103cc0bb..a64d3e93841 100644 --- a/tests/config/config-mutation-lock.test.ts +++ b/tests/config/config-mutation-lock.test.ts @@ -1,3 +1,4 @@ +import { samePath } from "../helpers/same-path"; import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; import * as fs from "node:fs"; import { closeSync, existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; @@ -297,7 +298,7 @@ test("initial config file identity preserves adjacent NTFS-sized values", () => }) as typeof fs.fstatSync); const lstat = spyOn(fs, "lstatSync").mockImplementation(((path, options) => { const stat = options?.bigint ? realLstat(path, { bigint: true }) : realLstat(path); - return path === tempPath ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + return samePath(path, tempPath) ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, { ino: options?.bigint ? identity + 1n : Number(identity + 1n) }) : stat; }) as typeof fs.lstatSync); try { diff --git a/tests/helpers/same-path.ts b/tests/helpers/same-path.ts new file mode 100644 index 00000000000..c10c5dc1c37 --- /dev/null +++ b/tests/helpers/same-path.ts @@ -0,0 +1,34 @@ +import { realpathSync } from "node:fs"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +function canonicalPath(path: string): string { + let current = resolve(path); + const remainder: string[] = []; + for (;;) { + try { + return join(realpathSync.native(current), ...remainder.reverse()); + } catch { + // Missing components are resolved against their nearest existing ancestor. + const parent = dirname(current); + if (parent === current) return resolve(path); + remainder.push(basename(current)); + current = parent; + } + } +} + +export function samePath(target: unknown, expected: string): boolean { + if (typeof target === "number" || typeof target === "bigint") return false; + try { + const path = target instanceof URL ? fileURLToPath(target) : String(target); + const actual = canonicalPath(path); + const reference = canonicalPath(expected); + return process.platform === "win32" + ? actual.toLowerCase() === reference.toLowerCase() + : actual === reference; + } catch { + // Invalid paths and unsupported URL schemes never match. + return false; + } +} diff --git a/tests/lib/file-identity.test.ts b/tests/lib/file-identity.test.ts index f91b709d100..7716c208203 100644 --- a/tests/lib/file-identity.test.ts +++ b/tests/lib/file-identity.test.ts @@ -1,3 +1,4 @@ +import { samePath } from "../helpers/same-path"; import { expect, spyOn, test } from "bun:test"; import * as fs from "node:fs"; import { tmpdir } from "node:os"; @@ -56,11 +57,11 @@ for (const [name, read] of [ const realStat = fs.statSync, realLstat = fs.lstatSync, realFstat = fs.fstatSync; const stat = spyOn(fs, "statSync").mockImplementation(((target, options) => { const value = options?.bigint ? realStat(target, { bigint: true }) : realStat(target); - return target === path ? Object.assign(Object.create(Object.getPrototypeOf(value)), value, { ino: options?.bigint ? id : Number(id) }) : value; + return samePath(target, path) ? Object.assign(Object.create(Object.getPrototypeOf(value)), value, { ino: options?.bigint ? id : Number(id) }) : value; }) as typeof fs.statSync); const lstat = spyOn(fs, "lstatSync").mockImplementation(((target, options) => { const value = options?.bigint ? realLstat(target, { bigint: true }) : realLstat(target); - return target === path ? Object.assign(Object.create(Object.getPrototypeOf(value)), value, { ino: options?.bigint ? id : Number(id) }) : value; + return samePath(target, path) ? Object.assign(Object.create(Object.getPrototypeOf(value)), value, { ino: options?.bigint ? id : Number(id) }) : value; }) as typeof fs.lstatSync); const fstat = spyOn(fs, "fstatSync").mockImplementation(((fd, options) => { const value = options?.bigint ? realFstat(fd, { bigint: true }) : realFstat(fd); diff --git a/tests/providers/cc-switch-import.test.ts b/tests/providers/cc-switch-import.test.ts index e308da771d2..d152fcee1ef 100644 --- a/tests/providers/cc-switch-import.test.ts +++ b/tests/providers/cc-switch-import.test.ts @@ -1,3 +1,4 @@ +import { samePath } from "../helpers/same-path"; import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { Database } from "bun:sqlite"; import * as fs from "node:fs"; @@ -115,7 +116,7 @@ describe("CC-Switch keyless provider import", () => { const realLstat = fs.lstatSync, realFstat = fs.fstatSync; const lstat = spyOn(fs, "lstatSync").mockImplementation(((target, options) => { const stat = options?.bigint ? realLstat(target, { bigint: true }) : realLstat(target); - return target === path ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + return samePath(target, path) ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, { ino: options?.bigint ? identity : Number(identity) }) : stat; }) as typeof fs.lstatSync); const fstat = spyOn(fs, "fstatSync").mockImplementation(((fd, options) => { diff --git a/tests/providers/command-code-project-context.test.ts b/tests/providers/command-code-project-context.test.ts index cd1deee5f69..250cf6dd04f 100644 --- a/tests/providers/command-code-project-context.test.ts +++ b/tests/providers/command-code-project-context.test.ts @@ -1,3 +1,4 @@ +import { samePath } from "../helpers/same-path"; import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test"; import { execFileSync } from "node:child_process"; import { @@ -88,12 +89,12 @@ describe("loadCommandCodeProjectContext", () => { writeFileSync(agentsPath, "project memory", "utf8"); lstatMock.mockImplementation((async (path: Parameters[0], options?: { bigint?: boolean }) => { const stat = options?.bigint ? await realLstat(path, { bigint: true }) : await realLstat(path); - return String(path) === agentsPath ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + return samePath(path, agentsPath) ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, { ino: options?.bigint ? identity + 1n : Number(identity + 1n) }) : stat; }) as typeof realLstat); openMock.mockImplementation(async path => { const handle = await realOpen(path); - if (String(path) !== agentsPath) return handle; + if (!samePath(path, agentsPath)) return handle; return { ...handle, read: handle.read.bind(handle), close: handle.close.bind(handle), stat: async (options?: { bigint?: boolean }) => { const stat = options?.bigint ? await handle.stat({ bigint: true }) : await handle.stat(); @@ -148,7 +149,7 @@ describe("loadCommandCodeProjectContext", () => { let releaseStalled: () => Promise = async () => {}; if (stalled === "lstat") { lstatMock.mockImplementation(path => { - if (String(path) !== agentsPath) return realLstat(path); + if (!samePath(path, agentsPath)) return realLstat(path); stalledCalls++; return new Promise(resolve => { releaseStalled = async () => { resolve(await realLstat(path)); }; @@ -156,7 +157,7 @@ describe("loadCommandCodeProjectContext", () => { }); } else { realpathMock.mockImplementation(path => { - if (String(path) !== agentsPath) return realRealpath(path); + if (!samePath(path, agentsPath)) return realRealpath(path); stalledCalls++; return new Promise(resolve => { releaseStalled = async () => { resolve(await realRealpath(path)); }; @@ -249,7 +250,7 @@ describe("loadCommandCodeProjectContext", () => { try { execFileSync("mkfifo", [fifo]); openMock.mockImplementation(async (path, flags) => { - if (String(path) === fifo) { + if (samePath(path, fifo)) { openedFifo = true; throw new Error("FIFO must be rejected before open"); } @@ -303,7 +304,7 @@ describe("loadCommandCodeProjectContext", () => { try { writeFileSync(agentsPath, "recovered memory", "utf8"); lstatMock.mockImplementation(path => { - if (String(path) !== agentsPath) return realLstat(path); + if (!samePath(path, agentsPath)) return realLstat(path); return new Promise(resolve => { release = async () => { resolve(await realLstat(path)); }; }); @@ -413,7 +414,7 @@ describe("loadCommandCodeProjectContext", () => { }; opendirMock.mockImplementation(async path => { - if (String(path) === skillRoot) { + if (samePath(path, skillRoot)) { return hangingDir as Awaited>; } return realOpendir(path); @@ -464,7 +465,7 @@ describe("loadCommandCodeProjectContext", () => { let entriesIterated = 0; opendirMock.mockImplementation(async path => { const dir = await realOpendir(path); - if (String(path) !== skillRoot) return dir; + if (!samePath(path, skillRoot)) return dir; const realIter = dir[Symbol.asyncIterator](); return { close: () => dir.close(), @@ -560,7 +561,7 @@ describe("loadCommandCodeProjectContext", () => { writeFileSync(agentsPath, "x".repeat(4 * 1024 * 1024), "utf8"); openMock.mockImplementation(async path => { const handle = await realOpen(path); - if (String(path) !== agentsPath) return handle; + if (!samePath(path, agentsPath)) return handle; const originalRead = handle.read.bind(handle); return { ...handle, @@ -636,7 +637,7 @@ describe("loadCommandCodeProjectContext", () => { }, }; openMock.mockImplementation(async path => { - if (String(path) === agentsPath) { + if (samePath(path, agentsPath)) { return hangingFile as Awaited>; } return realOpen(path); @@ -700,7 +701,7 @@ describe("loadCommandCodeProjectContext", () => { writeFileSync(outsidePath, "outside secret", "utf8"); symlinkSync(insidePath, agentsPath); openMock.mockImplementation(async path => { - if (String(path) === agentsPath) { + if (samePath(path, agentsPath)) { unlinkSync(agentsPath); symlinkSync(outsidePath, agentsPath); } @@ -761,7 +762,7 @@ describe("loadCommandCodeProjectContext", () => { if (path !== skillFile) return; realpathMock.mockImplementation(async (p, opts) => { const resolved = await realRealpath(p, opts); - if (typeof resolved === "string" && resolved === skillFile) { + if (typeof resolved === "string" && samePath(resolved, skillFile)) { changedCanonicalPaths += 1; return resolved.replace(/SKILL\.md$/, "skill.md"); } diff --git a/tests/service/service-ownership-state.test.ts b/tests/service/service-ownership-state.test.ts index ee4565599c6..6aa451de3b4 100644 --- a/tests/service/service-ownership-state.test.ts +++ b/tests/service/service-ownership-state.test.ts @@ -1,3 +1,4 @@ +import { samePath } from "../helpers/same-path"; /** * Durable runtime ownership in the shared service install state. * @@ -598,7 +599,7 @@ describe("the anchor lock", () => { const lstat = spyOn(fs, "lstatSync").mockImplementation(((path, options) => { const stat = options?.bigint ? realLstat(path, { bigint: true }) : realLstat(path); const ino = identity + (changed ? 1n : 0n); - return path === lockPath ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + return samePath(path, lockPath) ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, { ino: options?.bigint ? ino : Number(ino) }) : stat; }) as typeof fs.lstatSync); try { From 51030653fcf95e83c491388ec3eb5f0fbc613198 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 31 Oct 2026 08:38:42 +0900 Subject: [PATCH 83/84] fix: compare CLI install and update artifact identity with bigint stat (#8031) * fix: compare CLI install and update artifact identity with bigint stat * test: honor bigint stat options in the CLI install fakes --------- Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> --- src/codex/cli-install-provenance.ts | 91 ++++++++++--------- src/codex/cli-update-artifacts.ts | 14 +-- .../codex-cli-install-provenance.test.ts | 48 +++++++++- .../codex-cli-update-artifacts.test.ts | 17 ++++ tests/helpers/codex-cli-apply-admission.ts | 14 ++- tests/helpers/codex-cli-content-fs.ts | 48 ++++++---- tests/lib/file-identity-callers.test.ts | 2 + 7 files changed, 155 insertions(+), 79 deletions(-) diff --git a/src/codex/cli-install-provenance.ts b/src/codex/cli-install-provenance.ts index 3841710957a..9e44ae48677 100644 --- a/src/codex/cli-install-provenance.ts +++ b/src/codex/cli-install-provenance.ts @@ -9,8 +9,9 @@ import { readSync, realpathSync, statSync, - type Stats, + type BigIntStats, } from "node:fs"; +import { sameFileIdentity, statTimeMs } from "../lib/file-identity"; import { posix, win32 } from "node:path"; import { getConfigDir } from "../config"; import { parseStrictSemver } from "../lib/strict-semver"; @@ -503,10 +504,10 @@ function readBoundedFile( // injected stat/read behavior. const inspectLexical = deps.lstat ?? (deps.stat === undefined && deps.readFile === undefined ? lstatSync : null); - let lexicalBefore: ReturnType | null = null; + let lexicalBefore: BigIntStats | null = null; if (inspectLexical) { try { - lexicalBefore = inspectLexical(path); + lexicalBefore = inspectLexical(path, { bigint: true }); if (lexicalBefore.isSymbolicLink() || !lexicalBefore.isFile()) return null; } catch { return null; @@ -520,9 +521,9 @@ function readBoundedFile( ? fsConstants.O_RDONLY : fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK; fd = openSync(path, flags); - const before = fstatSync(fd); + const before = fstatSync(fd, { bigint: true }); if (!before.isFile() || before.size > maxBytes) return null; - const bytes = Buffer.allocUnsafe(before.size); + const bytes = Buffer.allocUnsafe(Number(before.size)); let offset = 0; while (offset < bytes.length) { const count = readSync(fd, bytes, offset, bytes.length - offset, offset); @@ -531,16 +532,16 @@ function readBoundedFile( } const extra = Buffer.allocUnsafe(1); if (readSync(fd, extra, 0, 1, offset) !== 0) return null; - const after = fstatSync(fd); - const lexicalAfter = inspectLexical ? inspectLexical(path) : null; + const after = fstatSync(fd, { bigint: true }); + const lexicalAfter = inspectLexical ? inspectLexical(path, { bigint: true }) : null; if (lexicalAfter?.isSymbolicLink()) return null; - if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size - || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs + if (!sameFileIdentity(before, after) || before.size !== after.size + || statTimeMs(before.mtimeNs) !== statTimeMs(after.mtimeNs) || statTimeMs(before.ctimeNs) !== statTimeMs(after.ctimeNs) || (lexicalBefore !== null && (lexicalAfter === null || lexicalAfter.isSymbolicLink() - || lexicalBefore.dev !== before.dev || lexicalBefore.ino !== before.ino - || lexicalAfter.dev !== after.dev || lexicalAfter.ino !== after.ino - || lexicalAfter.size !== after.size || lexicalAfter.mtimeMs !== after.mtimeMs - || lexicalAfter.ctimeMs !== after.ctimeMs))) return null; + || !sameFileIdentity(lexicalBefore, before) + || !sameFileIdentity(lexicalAfter, after) + || lexicalAfter.size !== after.size || statTimeMs(lexicalAfter.mtimeNs) !== statTimeMs(after.mtimeNs) + || statTimeMs(lexicalAfter.ctimeNs) !== statTimeMs(after.ctimeNs)))) return null; return bytes; } catch { return null; @@ -552,20 +553,19 @@ function readBoundedFile( const stat = deps.stat; const read = deps.readFile; try { - const before = stat(path); + const before = stat(path, { bigint: true }); if (!before.isFile() || before.size > maxBytes) return null; const bytes = read(path); - const after = stat(path); - const lexicalAfter = inspectLexical ? inspectLexical(path) : null; + const after = stat(path, { bigint: true }); + const lexicalAfter = inspectLexical ? inspectLexical(path, { bigint: true }) : null; if ( - bytes.byteLength !== before.size - || before.dev !== after.dev - || before.ino !== after.ino + BigInt(bytes.byteLength) !== before.size + || !sameFileIdentity(before, after) || before.size !== after.size - || before.mtimeMs !== after.mtimeMs + || statTimeMs(before.mtimeNs) !== statTimeMs(after.mtimeNs) || (lexicalBefore !== null && (lexicalAfter === null || lexicalAfter.isSymbolicLink() - || lexicalBefore.dev !== before.dev || lexicalBefore.ino !== before.ino - || lexicalAfter.dev !== after.dev || lexicalAfter.ino !== after.ino)) + || !sameFileIdentity(lexicalBefore, before) + || !sameFileIdentity(lexicalAfter, after))) ) return null; return bytes; } catch { @@ -830,16 +830,16 @@ function observeHeldFileSet( const read = deps.readFile; if (!stat || !read) return null; const results = new Map(); - const identities: { before: Stats; req: InstallContentFile }[] = []; + const identities: { before: BigIntStats; req: InstallContentFile }[] = []; try { for (const req of requests) { - const before = stat(req.path); + const before = stat(req.path, { bigint: true }); if (!before.isFile() || before.size > req.maxBytes) return null; const bytes = read(req.path); - const after = stat(req.path); - if (bytes.byteLength !== before.size - || before.dev !== after.dev || before.ino !== after.ino - || before.size !== after.size || before.mtimeMs !== after.mtimeMs) return null; + const after = stat(req.path, { bigint: true }); + if (BigInt(bytes.byteLength) !== before.size + || !sameFileIdentity(before, after) + || before.size !== after.size || statTimeMs(before.mtimeNs) !== statTimeMs(after.mtimeNs)) return null; identities.push({ before, req }); results.set(req.role, { digest: sha256(fileContentDomain(req.role), bytes), @@ -848,9 +848,9 @@ function observeHeldFileSet( } // A replacement that slipped in after a file's own read still failed. for (const { before, req } of identities) { - const now = stat(req.path); - if (before.dev !== now.dev || before.ino !== now.ino - || before.size !== now.size || before.mtimeMs !== now.mtimeMs) return null; + const now = stat(req.path, { bigint: true }); + if (!sameFileIdentity(before, now) + || before.size !== now.size || statTimeMs(before.mtimeNs) !== statTimeMs(now.mtimeNs)) return null; } if (finalize && !finalize()) return null; return results; @@ -858,7 +858,7 @@ function observeHeldFileSet( return null; } } - const held: { fd: number; req: InstallContentFile; before: Stats }[] = []; + const held: { fd: number; req: InstallContentFile; before: BigIntStats }[] = []; const openedFds: number[] = []; try { const flags = process.platform === "win32" @@ -870,7 +870,7 @@ function observeHeldFileSet( // The descriptor joins the close set before any check that can bail, so // an fstat failure or a refused type/size cannot leak it. openedFds.push(fd); - const before = fstatSync(fd); + const before = fstatSync(fd, { bigint: true }); // O_NONBLOCK keeps a FIFO open nonblocking, so the type check must come // first: anything that is not a regular file is refused before a read. if (!before.isFile() || before.size > req.maxBytes) return null; @@ -881,11 +881,12 @@ function observeHeldFileSet( const hasher = createHash("sha256") .update(fileContentDomain(req.role), "utf8") .update("\0", "utf8"); - const bytes = keepBytes(req.role) ? Buffer.allocUnsafe(before.size) : null; - const chunk = Buffer.allocUnsafe(Math.max(1, Math.min(HELD_READ_CHUNK_BYTES, before.size))); + const size = Number(before.size); + const bytes = keepBytes(req.role) ? Buffer.allocUnsafe(size) : null; + const chunk = Buffer.allocUnsafe(Math.max(1, Math.min(HELD_READ_CHUNK_BYTES, size))); let offset = 0; - while (offset < before.size) { - const count = readSync(fd, chunk, 0, Math.min(chunk.length, before.size - offset), offset); + while (offset < size) { + const count = readSync(fd, chunk, 0, Math.min(chunk.length, size - offset), offset); if (count <= 0) return null; hasher.update(chunk.subarray(0, count)); if (bytes) chunk.copy(bytes, offset, 0, count); @@ -896,15 +897,15 @@ function observeHeldFileSet( results.set(req.role, { digest: hasher.digest("hex"), bytes }); } for (const { fd, req, before } of held) { - const after = fstatSync(fd); - const lexical = lstatSync(req.path); - const followed = statSync(req.path); - if (before.dev !== after.dev || before.ino !== after.ino - || before.size !== after.size || before.mtimeMs !== after.mtimeMs - || before.ctimeMs !== after.ctimeMs + const after = fstatSync(fd, { bigint: true }); + const lexical = lstatSync(req.path, { bigint: true }); + const followed = statSync(req.path, { bigint: true }); + if (!sameFileIdentity(before, after) + || before.size !== after.size || statTimeMs(before.mtimeNs) !== statTimeMs(after.mtimeNs) + || statTimeMs(before.ctimeNs) !== statTimeMs(after.ctimeNs) || lexical.isSymbolicLink() || !lexical.isFile() - || lexical.dev !== before.dev || lexical.ino !== before.ino - || followed.dev !== before.dev || followed.ino !== before.ino) return null; + || !sameFileIdentity(lexical, before) + || !sameFileIdentity(followed, before)) return null; } if (finalize && !finalize()) return null; return results; diff --git a/src/codex/cli-update-artifacts.ts b/src/codex/cli-update-artifacts.ts index f40d83d0453..8d333cedbec 100644 --- a/src/codex/cli-update-artifacts.ts +++ b/src/codex/cli-update-artifacts.ts @@ -4,6 +4,8 @@ import { closeSync, constants, createReadStream, fstatSync, lstatSync, openSync import { Readable, Transform } from "node:stream"; import { pipeline } from "node:stream/promises"; import { parseStrictSemver } from "../lib/strict-semver"; +import { sameFileIdentity, statTimeMs } from "../lib/file-identity"; +import type { BigIntStats } from "node:fs"; import { codexCliContentFileDigest, codexCliContentSetDigest, codexCliPlatformBacking } from "./cli-install-provenance"; import { windowsArchivePathValidator, WINDOWS_CODEX_HEADER_BYTES, WINDOWS_CODEX_NATIVE_MEMBER } from "./cli-update-windows-paths"; @@ -105,10 +107,10 @@ export async function verifyCodexCliArchive(source: Buffer | string, target: Cod options: { windowsX64?: boolean } = {}): Promise { if (!isSha512Integrity(target.integrity)) throw new Error("artifact_integrity_mismatch"); let fd: number | undefined; - let before: ReturnType | undefined; + let before: BigIntStats | undefined; if (typeof source === "string") { fd = openSync(source, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); - try { before = fstatSync(fd); } catch (error) { closeSync(fd); throw error; } + try { before = fstatSync(fd, { bigint: true }); } catch (error) { closeSync(fd); throw error; } if (!before.isFile() || before.size > MAX_CODEX_ARCHIVE_BYTES) { closeSync(fd); throw new Error("artifact_file_unverified"); } } let input: Readable; @@ -208,10 +210,10 @@ export async function verifyCodexCliArchive(source: Buffer | string, target: Cod if (!ended || trailer < 512 || current || padding || headerSize) throw new Error("artifact_tar_truncated"); if ("sha512-" + archiveHash.digest("base64") !== target.integrity) throw new Error("artifact_integrity_mismatch"); if (fd !== undefined && before && typeof source === "string") { - const after = fstatSync(fd), lexical = lstatSync(source); - if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size - || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs || lexical.isSymbolicLink() - || lexical.dev !== before.dev || lexical.ino !== before.ino) throw new Error("artifact_file_changed"); + const after = fstatSync(fd, { bigint: true }), lexical = lstatSync(source, { bigint: true }); + if (!sameFileIdentity(before, after) || before.size !== after.size + || statTimeMs(before.mtimeNs) !== statTimeMs(after.mtimeNs) || statTimeMs(before.ctimeNs) !== statTimeMs(after.ctimeNs) || lexical.isSymbolicLink() + || !sameFileIdentity(lexical, before)) throw new Error("artifact_file_changed"); } verified = true; } finally { diff --git a/tests/codex-integration/codex-cli-install-provenance.test.ts b/tests/codex-integration/codex-cli-install-provenance.test.ts index 9d282870d1c..b482bdc83e7 100644 --- a/tests/codex-integration/codex-cli-install-provenance.test.ts +++ b/tests/codex-integration/codex-cli-install-provenance.test.ts @@ -696,13 +696,37 @@ describe("Codex CLI install provenance", () => { }); describe("install content binding", () => { + test("a manifest path identity change above numeric precision refuses", async () => { + const fs = virtualManagedNpmGlobal(); + let changed = false; + const deps = virtualInspectionDeps(fs, { + lstat: (path: string, options?: { bigint?: boolean }) => { + const value = fs.lstat(path, options) as object; + return path === VIRTUAL_MANIFEST + ? { ...value, ino: options?.bigint ? 9007199254740992n + BigInt(changed) : 9007199254740992 } + : value; + }, + stat: (path: string, options?: { bigint?: boolean }) => { + const value = fs.stat(path, options) as object; + return path === VIRTUAL_MANIFEST ? { ...value, ino: options?.bigint ? 9007199254740992n : 9007199254740992 } : value; + }, + readFile: (path: string) => { + const value = fs.readFile(path); + if (path === VIRTUAL_MANIFEST) changed = true; + return value; + }, + }); + expect((await inspectCodexCliInstall(deps)).managed).toBe(false); + expect(changed).toBe(true); + }); + test("an ownership-only read never opens the backing bytes", async () => { const fs = virtualManagedNpmGlobal(); const probed: string[] = []; const deps = virtualInspectionDeps(fs, { - lstat: (path: string) => { + lstat: (path: string, options?: { bigint?: boolean }) => { if (path.includes("@openai/codex-")) probed.push(path); - return fs.lstat(path); + return fs.lstat(path, options); }, readFile: (path: string) => { if (path === VIRTUAL_BACKING_BINARY) { @@ -795,8 +819,8 @@ describe("install content binding", () => { const stat = fs.stat; const deps = virtualInspectionDeps(fs, { observeInstallContent: true, - stat: (path: string) => { - const result = stat(path); + stat: (path: string, options?: { bigint?: boolean }) => { + const result = stat(path, options); if (path === VIRTUAL_BACKING_BINARY) { backingStats.count += 1; if (backingStats.count === 1) fs.bumpIdentity(path); @@ -808,6 +832,22 @@ describe("install content binding", () => { expect(report.installContentDigest).toBeNull(); }); + test("a backing identity change above numeric precision refuses", async () => { + const fs = virtualManagedNpmGlobal(); + let count = 0; + const deps = virtualInspectionDeps(fs, { + observeInstallContent: true, + stat: (path: string, options?: { bigint?: boolean }) => { + const value = fs.stat(path, options) as object; + if (path !== VIRTUAL_BACKING_BINARY) return value; + const changed = count++ > 0; + return { ...value, ino: options?.bigint ? 9007199254740992n + BigInt(changed) : 9007199254740992 }; + }, + }); + expect((await inspectCodexCliInstall(deps)).installContentDigest).toBeNull(); + expect(count).toBeGreaterThan(1); + }); + test("the package-vendor fallback binds when no resolve hit exists and globals are fully probed", async () => { const fs = virtualPosixFs({ [VIRTUAL_LAUNCHER]: { link: VIRTUAL_ENTRYPOINT }, diff --git a/tests/codex-integration/codex-cli-update-artifacts.test.ts b/tests/codex-integration/codex-cli-update-artifacts.test.ts index 2c19159983e..7b2d91fc487 100644 --- a/tests/codex-integration/codex-cli-update-artifacts.test.ts +++ b/tests/codex-integration/codex-cli-update-artifacts.test.ts @@ -87,6 +87,23 @@ describe("Codex CLI apply artifacts", () => { const f = fixtureArtifactSet(); const changed = Buffer.from(f.main); changed[changed.length - 1] ^= 1; await expect(verifyCodexCliArchive(changed, f.artifacts.main)).rejects.toThrow(); }); + test("rejects an archive path identity differing above numeric precision", async () => { + const root = createCodexCliTestRoot("ocx-archive-identity-"); + const f = fixtureArtifactSet(), path = join(root, "artifact.tgz"); + writeFileSync(path, f.main); + const fstat = fs.fstatSync, lstat = fs.lstatSync; + const opened = spyOn(fs, "fstatSync").mockImplementation(((fd: number, options?: { bigint?: boolean }) => { + const value = fstat(fd, options as never); + return { ...value, isFile: () => value.isFile(), dev: options?.bigint ? 42n : 42, ino: options?.bigint ? 9007199254740992n : 9007199254740992 }; + }) as typeof fs.fstatSync); + const lexical = spyOn(fs, "lstatSync").mockImplementation(((name: fs.PathLike, options?: { bigint?: boolean }) => { + const value = lstat(name, options as never); + return { ...value, isSymbolicLink: () => value.isSymbolicLink(), dev: options?.bigint ? 42n : 42, ino: options?.bigint ? 9007199254740993n : 9007199254740992 }; + }) as typeof fs.lstatSync); + try { + await expect(verifyCodexCliArchive(path, f.artifacts.main)).rejects.toThrow("artifact_file_changed"); + } finally { opened.mockRestore(); lexical.mockRestore(); removeTreeWithRetry(root); } + }); test.each(["valid", "corrupt"] as const)("real-file archive %s closes its stream before return and preserves a reused sentinel fd", async kind => { const root = createCodexCliTestRoot("ocx-archive-lifetime-"); const f = fixtureArtifactSet(), path = join(root, "artifact.tgz"), sentinelPath = join(root, "sentinel"); diff --git a/tests/helpers/codex-cli-apply-admission.ts b/tests/helpers/codex-cli-apply-admission.ts index 5d3507676ef..5768c0dd2eb 100644 --- a/tests/helpers/codex-cli-apply-admission.ts +++ b/tests/helpers/codex-cli-apply-admission.ts @@ -43,11 +43,15 @@ const lstat = fs.lstatSync, uid = process.geteuid?.() ?? 1234; const originalUid = Object.getOwnPropertyDescriptor(process, "geteuid"); let barrier = false; Object.defineProperty(process, "geteuid", { configurable: true, value: () => uid }); -const stat = spyOn(fs, "lstatSync").mockImplementation(((path: fs.PathLike) => { - const value = lstat(path); - return value.isDirectory() ? Object.assign(value, { uid, - mode: 0o40700 | (path === temporaryRoot && lane === "unsafe-stage" ? 0o002 : 0) - | (barrier && path === mutationDirectory && lane === "unsafe-install" ? 0o020 : 0) }) : value; +// Honor the options argument: identity checks request bigint stats. +const stat = spyOn(fs, "lstatSync").mockImplementation(((path: fs.PathLike, options?: fs.StatOptions) => { + const value = lstat(path, options as fs.StatSyncOptions & { bigint: false }); + if (!value?.isDirectory()) return value; + const mode = 0o40700 | (path === temporaryRoot && lane === "unsafe-stage" ? 0o002 : 0) + | (barrier && path === mutationDirectory && lane === "unsafe-install" ? 0o020 : 0); + return options?.bigint + ? Object.assign(value, { uid: BigInt(uid), mode: BigInt(mode) }) + : Object.assign(value, { uid, mode }); }) as typeof fs.lstatSync); let arms = 0, unavailable = false; const recoveryTimers: (() => void)[] = []; diff --git a/tests/helpers/codex-cli-content-fs.ts b/tests/helpers/codex-cli-content-fs.ts index bef0c687720..8f06cef717f 100644 --- a/tests/helpers/codex-cli-content-fs.ts +++ b/tests/helpers/codex-cli-content-fs.ts @@ -8,9 +8,8 @@ import type { CodexCliInstallProvenanceDeps } from "../../src/codex/cli-install- * Every path is absolute and lexical; only regular files and final-component * symlinks exist (directories are implied by path prefixes, which is all the * provenance inspection ever stats). stat follows the whole link chain, lstat - * reports the final component as written. Inode and mtime fields are plain - * numbers so a test can model a mid-observation identity change by mutating - * them between calls. + * reports the final component as written. Observations honor the bigint option; + * tests can mutate inode and timestamp fields between calls. */ export interface VirtualFileSpec { readonly data?: string | Buffer; @@ -42,8 +41,8 @@ const enoent = (): never => { export interface VirtualPosixFs { readonly exists: (path: string) => boolean; - readonly lstat: (path: string) => unknown; - readonly stat: (path: string) => unknown; + readonly lstat: (path: string, options?: { bigint?: boolean }) => unknown; + readonly stat: (path: string, options?: { bigint?: boolean }) => unknown; readonly readFile: (path: string) => Buffer; readonly realpath: (path: string) => string; /** Replace a regular file's bytes in place, keeping inode and timestamps. */ @@ -92,17 +91,28 @@ export function virtualPosixFs(initial: Record): Virtua } }; - const statLike = (entry: VirtualEntry) => ({ - isFile: () => entry.kind === "file", - isSymbolicLink: () => entry.kind === "symlink", - isDirectory: () => false, - mode: entry.kind === "file" ? entry.mode : 0o777, - size: entry.kind === "file" ? entry.bytes.length : entry.target.length, - dev: 42, - ino: entry.kind === "file" ? entry.ino : 0, - mtimeMs: entry.kind === "file" ? entry.mtimeMs : 0, - ctimeMs: entry.kind === "file" ? entry.ctimeMs : 0, - }); + // Fractional milliseconds to nanoseconds without requiring an integral value. + const nanoseconds = (ms: number): bigint => BigInt(Math.trunc(ms)) * 1_000_000n + BigInt(Math.round((ms % 1) * 1e6)); + // Same field types as node:fs Stats and BigIntStats: bigint observations carry bigint mode, + // truncated bigint *Ms and exact *Ns; numeric observations carry no *Ns fields. + const statLike = (entry: VirtualEntry, options?: { bigint?: boolean }) => { + const mode = entry.kind === "file" ? entry.mode : 0o777; + const size = entry.kind === "file" ? entry.bytes.length : entry.target.length; + const ino = entry.kind === "file" ? entry.ino : 0; + const mtimeMs = entry.kind === "file" ? entry.mtimeMs : 0; + const ctimeMs = entry.kind === "file" ? entry.ctimeMs : 0; + const kind = { + isFile: () => entry.kind === "file", + isSymbolicLink: () => entry.kind === "symlink", + isDirectory: () => false, + }; + if (!options?.bigint) return { ...kind, mode, size, dev: 42, ino, mtimeMs, ctimeMs }; + return { + ...kind, mode: BigInt(mode), size: BigInt(size), dev: 42n, ino: BigInt(ino), + mtimeMs: BigInt(Math.trunc(mtimeMs)), ctimeMs: BigInt(Math.trunc(ctimeMs)), + mtimeNs: nanoseconds(mtimeMs), ctimeNs: nanoseconds(ctimeMs), + }; + }; const fileAt = (path: string): VirtualFileEntry => { const resolved = entries.get(resolvePath(path)); @@ -112,12 +122,12 @@ export function virtualPosixFs(initial: Record): Virtua return { exists: path => entries.get(resolvePath(path))?.kind === "file", - lstat: path => { + lstat: (path, options) => { const entry = entries.get(norm(path)); if (!entry) enoent(); - return statLike(entry); + return statLike(entry, options); }, - stat: path => statLike(fileAt(path)), + stat: (path, options) => statLike(fileAt(path), options), readFile: path => fileAt(path).bytes, realpath: path => { const resolved = resolvePath(path); diff --git a/tests/lib/file-identity-callers.test.ts b/tests/lib/file-identity-callers.test.ts index d11920d7080..43004e286f2 100644 --- a/tests/lib/file-identity-callers.test.ts +++ b/tests/lib/file-identity-callers.test.ts @@ -7,6 +7,8 @@ import { repoPath } from "../helpers/repo-root"; // full parser: it catches the regression forms below and fails closed on scopes it cannot find. const scopes: Record = { "src/codex/codex-home-owner.ts": ["readCodexHomeJournal"], + "src/codex/cli-update-artifacts.ts": ["verifyCodexCliArchive"], + "src/codex/cli-install-provenance.ts": ["readBoundedFile", "observeHeldFileSet"], "src/codex/orca-auth-source.ts": ["readBoundedLocalFile"], "src/codex/inject/bounded-config-reader.ts": ["readBoundedCodexConfig"], "src/codex/main-account.ts": ["statIdentity"], From c68885b5f16a2a36d8eaa9877ffcae7235128c1b Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 31 Oct 2026 09:22:58 +0900 Subject: [PATCH 84/84] test: give the Windows apply x64 fixture compile the shared spawn budget (#8032) * test: give the Windows apply x64 fixture compile the shared spawn budget * test: report the compile deadline state on the reap assertion too --------- Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com> --- .../codex-cli-update-windows-apply-shared.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/codex-integration/codex-cli-update-windows-apply-shared.test.ts b/tests/codex-integration/codex-cli-update-windows-apply-shared.test.ts index 26496f34e5e..69679995562 100644 --- a/tests/codex-integration/codex-cli-update-windows-apply-shared.test.ts +++ b/tests/codex-integration/codex-cli-update-windows-apply-shared.test.ts @@ -7,6 +7,7 @@ import { applyCodexCliUpdate } from "../../src/codex/cli-update-apply"; import { createCodexCliUpdatePlan } from "../../src/codex/cli-update-plan"; import { repoPath, repoRoot } from "../helpers/repo-root"; import { spawnTestChild } from "../helpers/spawn-test-child"; +import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; import { startWindowsCliChild, type WindowsCliChild } from "../../src/codex/windows-cli-child"; import { windowsInstallNamespaceSupported } from "../../src/codex/cli-update-windows-observation"; import { createWindowsApplyFixtures, native, WINDOWS_APPLY_BUDGET_MS, WINDOWS_APPLY_CHILD_BUDGET_MS, mainRoot, backingRoot, write } from "../helpers/codex-cli-windows-apply-fixture"; @@ -31,9 +32,13 @@ native("Windows shared prefix preserves unrelated package and shims while execut ].join("\n")); const compiler = join(process.env.SystemRoot ?? "C:/Windows", "Microsoft.NET/Framework64/v4.0.30319/csc.exe"); expect(existsSync(compiler), "x64 fixture compiler must be available; do not substitute a synthetic PE").toBe(true); + // A cold .NET Framework compiler on a fresh runner can take seconds before it emits anything; + // it is a real external binary, so it gets the shared spawn budget (still well inside the test's). const compiled = await spawnTestChild([compiler, "/nologo", "/platform:x64", "/target:exe", `/out:${exe}`, source], - { cwd: build, env: process.env, deadlineMs: 10000 }); - expect(compiled.reaped && compiled.pipesClosed).toBe(true); expect(compiled.exitCode, compiled.stdout + compiled.stderr).toBe(0); + { cwd: build, env: process.env, deadlineMs: SPAWN_BUDGET_MS }); + const compileReport = JSON.stringify({ timedOut: compiled.timedOut, elapsedMs: compiled.elapsedMs, signal: compiled.signal }); + expect(compiled.reaped && compiled.pipesClosed, compileReport).toBe(true); + expect(compiled.exitCode, `${compileReport}\n${compiled.stdout}${compiled.stderr}`).toBe(0); const f = await fixture(readFileSync(exe)); const unrelated = { "node_modules/unrelated/package.json": '{"name":"unrelated","version":"2.0.0","bin":"index.js"}', "node_modules/unrelated/index.js": '#!/usr/bin/env node\nconsole.log("unrelated");\n',