diff --git a/assets/inject/renderer-inject.js b/assets/inject/renderer-inject.js index 469bc28..75c8d91 100644 --- a/assets/inject/renderer-inject.js +++ b/assets/inject/renderer-inject.js @@ -14,26 +14,167 @@ return false; } }; - if (typeof window.fetch === "function" && !window.fetch.__codexPlusFastStartupPatched) { + const timeoutSignal = (signal) => { + const controller = new AbortController(); + const timer = window.setTimeout(() => controller.abort(), timeoutMs); + const clear = () => window.clearTimeout(timer); + if (signal) { + if (signal.aborted) controller.abort(); + else signal.addEventListener("abort", () => controller.abort(), { once: true }); + } + return { signal: controller.signal, clear }; + }; + const patchFetch = () => { + if (typeof window.fetch !== "function" || window.fetch.__codexPlusFastStartupPatched) return; const originalFetch = window.fetch.bind(window); const patchedFetch = (input, init = undefined) => { if (!isStatsigUrl(input)) return originalFetch(input, init); - const controller = new AbortController(); - const timer = window.setTimeout(() => controller.abort(), timeoutMs); - const nextInit = { ...(init || {}), signal: controller.signal }; - return originalFetch(input, nextInit).finally(() => window.clearTimeout(timer)); + const { signal, clear } = timeoutSignal(init?.signal); + return originalFetch(input, { ...(init || {}), signal }).finally(clear); }; patchedFetch.__codexPlusFastStartupPatched = true; window.fetch = patchedFetch; - } + }; + const markStatsigReady = (client) => { + if (!client || typeof client !== "object" || client.__codexPlusFastStartupReadyPatched) return; + client.__codexPlusFastStartupReadyPatched = true; + const markReady = () => { + try { + if (client.loadingStatus && client.loadingStatus !== "Ready") client.loadingStatus = "Ready"; + if (typeof client.$emt === "function") client.$emt({ name: "values_updated" }); + } catch { + } + }; + if (typeof client.initializeAsync === "function") { + const originalInitializeAsync = client.initializeAsync.bind(client); + client.initializeAsync = (...args) => Promise.race([ + originalInitializeAsync(...args).catch(() => null), + new Promise((resolve) => window.setTimeout(() => resolve(null), timeoutMs)), + ]).finally(markReady); + } + markReady(); + }; + const patchStatsigRoot = () => { + const root = window.__STATSIG__ || globalThis.__STATSIG__; + if (!root || typeof root !== "object") return; + const clients = [root.firstInstance, typeof root.instance === "function" ? root.instance() : null]; + if (root.instances && typeof root.instances === "object") clients.push(...Object.values(root.instances)); + clients.forEach(markStatsigReady); + }; + patchFetch(); + patchStatsigRoot(); + const startedAt = Date.now(); + const timer = window.setInterval(() => { + patchFetch(); + patchStatsigRoot(); + if (Date.now() - startedAt > 5000) window.clearInterval(timer); + }, 50); } function installCodexPlusForceChineseLocale() { const config = window.__CODEX_PLUS_FORCE_CHINESE_LOCALE__; - if (!config || config.enabled !== true) return; - if (window.__codexPlusForceChineseLocaleInstalled === "1") return; - window.__codexPlusForceChineseLocaleInstalled = "1"; + if (!config) return; + const enabled = config.enabled === true; const locale = typeof config.locale === "string" && config.locale ? config.locale : "zh-CN"; + const installationKey = `2:${enabled ? "on" : "off"}:${locale}`; + if (window.__codexPlusForceChineseLocaleInstalled === installationKey) return; + window.__codexPlusForceChineseLocaleInstalled = installationKey; + const managedLocaleStorageKey = "codexPlus.forceChineseLocale.managed.v1"; + const localeReloadStorageKey = "codexPlus.forceChineseLocale.reload.v1"; + const readManagedLocale = () => { + try { + const value = JSON.parse(localStorage.getItem(managedLocaleStorageKey) || "null"); + return value && typeof value === "object" ? value : null; + } catch { + return null; + } + }; + const writeManagedLocale = (value) => { + try { + if (value) localStorage.setItem(managedLocaleStorageKey, JSON.stringify(value)); + else localStorage.removeItem(managedLocaleStorageKey); + } catch { + } + }; + const waitForElectronBridge = () => new Promise((resolve) => { + const startedAt = Date.now(); + const check = () => { + if (window.electronBridge?.sendMessageFromView) return resolve(window.electronBridge); + if (Date.now() - startedAt >= 5000) return resolve(null); + setTimeout(check, 50); + }; + check(); + }); + const callCodexSettingApi = (bridge, method, params) => new Promise((resolve, reject) => { + const requestId = crypto?.randomUUID?.() || `codex-plus-locale-${Date.now()}-${Math.random().toString(16).slice(2)}`; + let timeout; + const cleanup = () => { + clearTimeout(timeout); + window.removeEventListener("message", onMessage); + }; + const onMessage = (event) => { + const message = event?.data; + if (message?.type !== "fetch-response" || message.requestId !== requestId) return; + cleanup(); + if (message.responseType !== "success") return reject(new Error(message.error || `Codex ${method} failed`)); + try { + resolve(JSON.parse(message.bodyJsonString || "null")); + } catch (error) { + reject(error); + } + }; + window.addEventListener("message", onMessage); + timeout = setTimeout(() => { + cleanup(); + reject(new Error(`Codex ${method} timed out`)); + }, 5000); + Promise.resolve(bridge.sendMessageFromView({ + type: "fetch", + requestId, + method: "POST", + url: `vscode://codex/${method}`, + body: JSON.stringify({ params }), + })).catch((error) => { + cleanup(); + reject(error); + }); + }); + const reloadAfterLocaleChange = (value) => { + const marker = JSON.stringify(value); + try { + if (sessionStorage.getItem(localeReloadStorageKey) === marker) return; + sessionStorage.setItem(localeReloadStorageKey, marker); + } catch { + } + location.reload(); + }; + const syncOfficialLocaleSetting = async () => { + const managed = readManagedLocale(); + if (!enabled && !managed) return; + const bridge = await waitForElectronBridge(); + if (!bridge) return; + const currentValue = (await callCodexSettingApi(bridge, "get-setting", { key: "localeOverride" }))?.value ?? null; + if (enabled) { + if (currentValue === locale) { + sessionStorage.removeItem(localeReloadStorageKey); + return; + } + if (!managed) writeManagedLocale({ appliedLocale: locale, previousValue: currentValue }); + await callCodexSettingApi(bridge, "set-setting", { key: "localeOverride", value: locale }); + reloadAfterLocaleChange(locale); + return; + } + if (currentValue !== managed.appliedLocale) { + writeManagedLocale(null); + return; + } + const previousValue = managed.previousValue ?? null; + await callCodexSettingApi(bridge, "set-setting", { key: "localeOverride", value: previousValue }); + writeManagedLocale(null); + reloadAfterLocaleChange(previousValue); + }; + syncOfficialLocaleSetting().catch(() => {}); + if (!enabled) return; try { Object.defineProperty(Navigator.prototype, "language", { configurable: true, get: () => locale }); Object.defineProperty(Navigator.prototype, "languages", { configurable: true, get: () => [locale, "zh", "en-US", "en"] }); @@ -121,7 +262,10 @@ const codexThreadServiceTierMaxEntries = 120; const codexThreadServiceTierDraftBindWindowMs = 60 * 1000; const codexServiceTierRequestOverrideVersion = "2"; - const codexForcePluginInstallRefreshIntervalMs = 1000; + const codexPluginMarketplaceUnlockVersion = "12"; + const codexPluginAutoExpandVersion = "1"; + const codexPluginAutoExpandMaxClicks = 80; + const codexPluginAutoExpandClickDelayMs = 90; const codexPlusImageOverlayId = "codex-plus-image-overlay"; const codexThreadScrollMaxEntries = 120; const codexThreadScrollSaveThrottleMs = 120; @@ -732,12 +876,12 @@ } function defaultCodexPlusSettings() { - return { pluginEntryUnlock: false, forcePluginInstall: true, modelWhitelistUnlock: true, sessionDelete: true, markdownExport: true, pasteFix: false, projectMove: true, conversationTimeline: false, threadIdBadge: false, conversationView: false, conversationViewMaxWidth: conversationViewDefaultWidth, threadScrollRestore: true, zedRemoteOpen: true, upstreamWorktreeCreate: true, nativeMenuPlacement: true, nativeMenuLocalization: true, serviceTierControls: false }; + return { pluginMarketplaceUnlock: true, pluginAutoExpand: true, modelWhitelistUnlock: true, sessionDelete: true, markdownExport: true, pasteFix: false, projectMove: true, conversationTimeline: false, threadIdBadge: false, conversationView: false, conversationViewMaxWidth: conversationViewDefaultWidth, threadScrollRestore: true, zedRemoteOpen: true, upstreamWorktreeCreate: true, nativeMenuPlacement: true, nativeMenuLocalization: true, serviceTierControls: false }; } const codexPlusBackendSettingMap = { - pluginEntryUnlock: "codexAppPluginEntryUnlock", - forcePluginInstall: "codexAppForcePluginInstall", + pluginMarketplaceUnlock: "codexAppPluginMarketplaceUnlock", + pluginAutoExpand: "codexAppPluginAutoExpand", modelWhitelistUnlock: "codexAppModelWhitelistUnlock", sessionDelete: "codexAppSessionDelete", markdownExport: "codexAppMarkdownExport", @@ -768,8 +912,8 @@ const relayPatchDisabled = pluginPatchDisabledInRelayMode(); if (codexPlusBackendSettings.enhancementsEnabled === false) { return { - pluginEntryUnlock: false, - forcePluginInstall: false, + pluginMarketplaceUnlock: false, + pluginAutoExpand: false, modelWhitelistUnlock: false, sessionDelete: false, markdownExport: false, @@ -790,15 +934,15 @@ try { const settings = { ...defaultCodexPlusSettings(), ...JSON.parse(localStorage.getItem(codexPlusSettingsKey) || "{}"), ...backendCodexPlusSettings() }; if (relayPatchDisabled) { - settings.pluginEntryUnlock = false; - settings.forcePluginInstall = false; + settings.pluginMarketplaceUnlock = false; + settings.pluginAutoExpand = false; } return settings; } catch { const settings = { ...defaultCodexPlusSettings(), ...backendCodexPlusSettings() }; if (relayPatchDisabled) { - settings.pluginEntryUnlock = false; - settings.forcePluginInstall = false; + settings.pluginMarketplaceUnlock = false; + settings.pluginAutoExpand = false; } return settings; } @@ -829,6 +973,11 @@ window.__codexThreadScrollSyncTimers = []; window.__codexThreadScrollRuntime = null; } + if (key === "pluginAutoExpand" && !value) { + clearTimeout(window.__codexPluginAutoExpandTimer); + window.__codexPluginAutoExpandTimer = null; + window.__codexPluginAutoExpandRunning = false; + } if (key === "serviceTierControls") { if (value) { void loadCodexServiceTierState(); @@ -881,6 +1030,7 @@ let codexPlusBackendSettings = { providerSyncEnabled: false, enhancementsEnabled: true, launchMode: "patch", activeRelayMode: "official", activeRelayID: "", codexAppVersion: "" }; const codexPluginLegacyEntryUnlockBeforeVersion = "26.601.2237"; + const codexPluginBridgeRequestUnlockFromVersion = "26.616.0"; function parseCodexVersionParts(version) { const raw = String(version || "").trim(); @@ -924,6 +1074,15 @@ }); } + function codexPluginMarketplaceRequestPatchStrategy() { + const pluginStrategy = codexPluginUnlockStrategy(); + if (pluginStrategy === "legacy") return "none"; + const version = String(codexPlusBackendSettings.codexAppVersion || "").trim(); + const comparison = compareCodexVersions(version, codexPluginBridgeRequestUnlockFromVersion); + if (comparison == null) return "unknown"; + return comparison >= 0 ? "bridge" : "client"; + } + let codexPlusBackendSettingsLoaded = false; const upstreamBranchDefaultsCache = new Map(); const upstreamBranchDefaultsInflight = new Map(); @@ -958,7 +1117,7 @@ async function loadCodexAppModule(namePart) { if (!codexServiceTierModulePromises.has(namePart)) { codexServiceTierModulePromises.set(namePart, Promise.resolve().then(async () => { - const url = codexAppAssetUrl(namePart); + const url = codexAppAssetUrl(namePart) || await codexAppAssetUrlFromScriptText(namePart); if (!url) throw new Error(`未找到 Codex App asset: ${namePart}`); return await import(url); }).catch((error) => { @@ -969,6 +1128,21 @@ return await codexServiceTierModulePromises.get(namePart); } + async function codexAppAssetUrlFromScriptText(namePart) { + const scripts = Array.from(document.scripts || []).map((script) => script.src).filter(Boolean); + for (const src of scripts) { + if (!src.includes("/assets/") || !src.split("?")[0].endsWith(".js")) continue; + try { + const text = await fetch(src).then((response) => response.ok ? response.text() : ""); + const escaped = namePart.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = text.match(new RegExp(`["'](\\./assets/${escaped}[^"']+\\.js)["']`)); + if (match) return new URL(match[1], src).href; + } catch { + } + } + return ""; + } + function codexDispatcherFromModule(module) { if (!module || typeof module !== "object") return null; for (const value of Object.values(module)) { @@ -1724,7 +1898,10 @@ } async function loadUserScripts(path = "/user-scripts/list", payload = {}) { - const result = await postJson(path, payload); + const requestPayload = path === "/user-scripts/list" + ? { ...payload, runtime_status: window.__codexPlusUserScripts?.scripts || {} } + : payload; + const result = await postJson(path, requestPayload); if (result?.scripts) { codexPlusUserScripts = result; renderUserScripts(); @@ -1773,8 +1950,12 @@
-
特殊插件/站点强制安装
解除 App unavailable / 应用不可用导致的前端安装禁用;纯中转/聚合兼容路径会自动关闭。
- +
插件市场解锁
扩展插件市场请求并显示官方 marketplace 中被客户端版本过滤的插件。
+ +
+
+
插件列表自动展开
进入插件页后自动点击“显示更多”,展开当前 marketplace 的完整列表。
+
模型白名单解锁
从环境变量和 Codex config.toml 中的中转站 /v1/models 拉取模型,并补进模型选择列表。
@@ -2136,72 +2317,6 @@ return String(codexPlusBackendSettings.activeRelayMode || "").trim() === "mixedApi"; } - function reactFiberFrom(element) { - const fiberKey = Object.keys(element).find((key) => key.startsWith("__reactFiber")); - return fiberKey ? element[fiberKey] : null; - } - - function authContextValueFrom(element) { - for (let fiber = reactFiberFrom(element); fiber; fiber = fiber.return) { - for (const value of [fiber.memoizedProps?.value, fiber.pendingProps?.value]) { - if (value && typeof value === "object" && typeof value.setAuthMethod === "function" && "authMethod" in value) return value; - } - } - return null; - } - - function spoofChatGPTAuthMethod(element) { - const auth = authContextValueFrom(element); - if (!auth || auth.authMethod === "chatgpt") return false; - auth.setAuthMethod("chatgpt"); - return true; - } - - function pluginEntryButton() { - const byIcon = document.querySelector(`${selectors.pluginNavButton} ${selectors.pluginSvgPath}`)?.closest("button"); - if (byIcon) return byIcon; - return Array.from(document.querySelectorAll(selectors.pluginNavButton)) - .find((button) => /^(插件|Plugins)(\s+-\s+.*)?$/i.test((button.textContent || "").trim())) || null; - } - - function labelUnlockedPluginEntry(button) { - const labelTextNode = Array.from(button.querySelectorAll("span, div")).reverse() - .flatMap((node) => Array.from(node.childNodes)) - .find((node) => node.nodeType === 3 && /^(插件|Plugins)( - 已解锁| - Unlocked)?$/i.test((node.nodeValue || "").trim())); - if (!labelTextNode) return; - const current = (labelTextNode.nodeValue || "").trim(); - labelTextNode.nodeValue = /^Plugins/i.test(current) ? "Plugins - Unlocked" : "插件 - 已解锁"; - } - - function clearPluginEntryUnlockLabel(button) { - const labelTextNode = Array.from(button.querySelectorAll("span, div")).reverse() - .flatMap((node) => Array.from(node.childNodes)) - .find((node) => node.nodeType === 3 && /^(插件 - 已解锁|Plugins - Unlocked)$/i.test((node.nodeValue || "").trim())); - if (labelTextNode) labelTextNode.nodeValue = /^Plugins/i.test((labelTextNode.nodeValue || "").trim()) ? "Plugins" : "插件"; - } - - function enablePluginEntry() { - if (pluginPatchDisabledInRelayMode()) return; - if (!codexPlusSettings().pluginEntryUnlock) return; - const pluginButton = pluginEntryButton(); - if (!pluginButton) return; - const spoofed = spoofChatGPTAuthMethod(pluginButton); - pluginButton.disabled = false; - pluginButton.removeAttribute("disabled"); - pluginButton.style.display = ""; - pluginButton.querySelectorAll("*").forEach((node) => { - node.style.display = ""; - }); - labelUnlockedPluginEntry(pluginButton); - const reactPropsKey = Object.keys(pluginButton).find((key) => key.startsWith("__reactProps")); - if (reactPropsKey) pluginButton[reactPropsKey].disabled = false; - if (pluginButton.dataset.codexPluginEnabled !== "true") { - pluginButton.dataset.codexPluginEnabled = "true"; - pluginButton.addEventListener("click", () => spoofChatGPTAuthMethod(pluginButton), true); - } - sendCodexPlusDiagnostic("plugin_entry_unlock_applied", { spoofed }); - } - function installPasteFix() { if (!codexPlusSettings().pasteFix || window.__codexPasteFixInstalled === "1") return; window.__codexPasteFixInstalled = "1"; @@ -2222,125 +2337,384 @@ }, true); } - function pluginInstallCandidates() { - const nodes = Array.from(document.querySelectorAll(selectors.disabledInstallButton)); - return Array.from(new Set(nodes.map((node) => node.closest?.("button, [role='button']") || node))); + function appServerPluginRequestMethod(method, params) { + if (method === "send-cli-request-for-host" && params?.method) return String(params.method); + if (method === "vscode://codex/list-plugins" || method === "plugin/list") return "list-plugins"; + if (method === "vscode://codex/plugin/install" || method === "plugin/install") return "install-plugin"; + return String(method || ""); } - function installButtonLabel(element) { - return (element.textContent || "").trim(); + function restorePluginMarketplaceName(name) { + const value = String(name || ""); + return value.startsWith("codex-plus-") ? value.slice("codex-plus-".length) : value; } - function isInstallButtonLabel(text) { - return /^安装\s*/.test(text) || /^Install\s*/i.test(text) || text === "强制安装"; + function codexPluginOfficialMarketplaceName(name) { + const restored = restorePluginMarketplaceName(name); + return ["openai-bundled", "openai-curated", "openai-primary-runtime", "openai-api-curated", "openai-curated-remote"].includes(restored); } - function patchReactDisabledProps(element) { - Object.keys(element) - .filter((key) => key.startsWith("__reactProps")) - .forEach((key) => { - const props = element[key]; - if (!props || typeof props !== "object") return; - props.disabled = false; - props["aria-disabled"] = false; - props["data-disabled"] = undefined; - }); + function isCodexPluginBuildFlavorFilter(callback, sample) { + if (!Array.isArray(sample) || sample.length === 0 || typeof callback !== "function") return false; + let source = ""; + try { + source = Function.prototype.toString.call(callback); + } catch { + return false; + } + const known = source.includes("!u(e.marketplaceName)||e.marketplaceName===r") + || source.includes("!ne(e.marketplaceName)||e.marketplaceName===n"); + if (!known || !sample.some((plugin) => codexPluginOfficialMarketplaceName(plugin?.marketplaceName))) return false; + return sample.some((plugin) => codexPluginOfficialMarketplaceName(plugin?.marketplaceName) && !callback(plugin)); } - function clearDisabledState(element) { - if (!(element instanceof HTMLElement)) return; - if ("disabled" in element) element.disabled = false; - element.removeAttribute("disabled"); - element.removeAttribute("aria-disabled"); - element.removeAttribute("data-disabled"); - element.removeAttribute("inert"); - element.classList.remove("disabled", "opacity-50", "cursor-not-allowed", "pointer-events-none"); - element.classList.add("codex-force-install-unlocked"); - element.style.pointerEvents = "auto"; - element.style.opacity = ""; - element.style.cursor = "pointer"; - element.tabIndex = 0; - patchReactDisabledProps(element); - } - - function installButtonUnlockNodes(button) { - const nodes = [button]; - button.querySelectorAll?.("button, [role='button'], [disabled], [aria-disabled], [data-disabled], .cursor-not-allowed, .pointer-events-none").forEach((node) => nodes.push(node)); - let parent = button.parentElement; - for (let depth = 0; parent && depth < 3; depth += 1, parent = parent.parentElement) { - if (parent.matches?.("button, [role='button'], [disabled], [aria-disabled], [data-disabled], .cursor-not-allowed, .pointer-events-none")) nodes.push(parent); + function isCodexPluginMarketplaceHiddenFilter(callback, sample) { + if (!Array.isArray(sample) || sample.length === 0 || typeof callback !== "function") return false; + let source = ""; + try { + source = Function.prototype.toString.call(callback); + } catch { + return false; } - return Array.from(new Set(nodes)); + if (!source.includes("!t.includes(e.name)")) return false; + if (!sample.some((marketplace) => codexPluginOfficialMarketplaceName(marketplace?.name))) return false; + return sample.some((marketplace) => codexPluginOfficialMarketplaceName(marketplace?.name) && !callback(marketplace)); } - function installForcedInstallGuard(button) { - if (button.dataset.codexForceInstallUnlocked === "true") return; - button.dataset.codexForceInstallUnlocked = "true"; - const keepUnlocked = () => installButtonUnlockNodes(button).forEach(clearDisabledState); - ["pointerdown", "mousedown", "mouseup", "click", "focus"].forEach((eventName) => button.addEventListener(eventName, keepUnlocked, true)); + function installPluginBuildFlavorFilterPatch() { + if (window.__codexPluginBuildFlavorFilterPatch === codexPluginMarketplaceUnlockVersion) return; + if (pluginPatchDisabledInRelayMode() || !codexPlusSettings().pluginMarketplaceUnlock) return; + const originalFilter = Array.prototype.__codexPluginBuildFlavorOriginalFilter || Array.prototype.filter; + if (!Array.prototype.__codexPluginBuildFlavorOriginalFilter) { + Object.defineProperty(Array.prototype, "__codexPluginBuildFlavorOriginalFilter", { value: originalFilter, configurable: true, writable: true }); + } + if (Array.prototype.filter.__codexPluginBuildFlavorPatched === codexPluginMarketplaceUnlockVersion) { + window.__codexPluginBuildFlavorFilterPatch = codexPluginMarketplaceUnlockVersion; + return; + } + const patchedFilter = function codexPluginBuildFlavorFilterPatch(callback, thisArg) { + if (isCodexPluginBuildFlavorFilter(callback, this)) { + sendCodexPlusDiagnostic("plugin_build_flavor_filter_bypassed", { pluginCount: this.length }); + return Array.from(this); + } + if (isCodexPluginMarketplaceHiddenFilter(callback, this)) { + sendCodexPlusDiagnostic("plugin_marketplace_hidden_filter_bypassed", { marketplaceCount: this.length }); + return Array.from(this); + } + return originalFilter.call(this, callback, thisArg); + }; + patchedFilter.__codexPluginBuildFlavorPatched = codexPluginMarketplaceUnlockVersion; + Array.prototype.filter = patchedFilter; + window.__codexPluginBuildFlavorFilterPatch = codexPluginMarketplaceUnlockVersion; + sendCodexPlusDiagnostic("plugin_build_flavor_filter_patch_installed", {}); } - function unblockButtonElement(button) { - installButtonUnlockNodes(button).forEach(clearDisabledState); - installForcedInstallGuard(button); + function restorePluginMarketplaceRequestParams(params, method = "") { + if (!params || typeof params !== "object") return params; + let next = params; + if (Array.isArray(params.marketplaceKinds)) { + next = { ...next, marketplaceKinds: Array.from(new Set(params.marketplaceKinds.map((kind) => kind === "remote:openai-curated" ? "openai-curated" : restorePluginMarketplaceName(kind)))) }; + } + if (method === "install-plugin") { + next = next === params ? { ...params } : next; + if (next.remoteMarketplaceName) next.remoteMarketplaceName = restorePluginMarketplaceName(next.remoteMarketplaceName); + if (typeof next.marketplacePath === "string" && next.marketplacePath.startsWith("remote:")) { + next.remoteMarketplaceName = restorePluginMarketplaceName(next.marketplacePath.slice("remote:".length)); + delete next.marketplacePath; + } + } + return next; } - function labelForcedInstallButton(button) { - const walker = document.createTreeWalker(button, NodeFilter.SHOW_TEXT); - let textNode = null; - while (!textNode && walker.nextNode()) { - const node = walker.currentNode; - if (isInstallButtonLabel((node.nodeValue || "").trim())) textNode = node; + function clonePluginMarketplaceValue(value) { + if (!value || typeof value !== "object") return null; + try { + return JSON.parse(JSON.stringify(value)); + } catch { + return null; } - if (textNode) textNode.nodeValue = "强制安装"; } - function clearForcedInstallButtonLabel(button) { - const walker = document.createTreeWalker(button, NodeFilter.SHOW_TEXT); - let textNode = null; - while (!textNode && walker.nextNode()) { - const node = walker.currentNode; - if ((node.nodeValue || "").trim() === "强制安装") textNode = node; - } - if (textNode) textNode.nodeValue = "安装"; + function pluginMarketplacePluginKey(plugin) { + if (!plugin || typeof plugin !== "object") return ""; + return String(plugin.name || plugin.id || plugin.pluginName || "").split("@")[0].trim(); + } + + function normalizeLocalPluginMarketplacePlugin(plugin, marketplaceName) { + const cloned = clonePluginMarketplaceValue(plugin); + if (!cloned) return null; + const name = pluginMarketplacePluginKey(cloned); + if (!name) return null; + if (!cloned.name) cloned.name = name; + if (!cloned.id) cloned.id = `${name}@${marketplaceName}`; + if (!cloned.marketplaceName) cloned.marketplaceName = marketplaceName; + if (!cloned.marketplacePath) cloned.marketplacePath = marketplaceName; + if (!cloned.interface || typeof cloned.interface !== "object") cloned.interface = {}; + if (!cloned.interface.displayName) cloned.interface.displayName = name; + if (!Array.isArray(cloned.keywords)) cloned.keywords = []; + return cloned; + } + + function mergeLocalPluginMarketplacePlugins(target, source, marketplaceName) { + if (!target || !source || !Array.isArray(source.plugins)) return 0; + if (!Array.isArray(target.plugins)) target.plugins = []; + const existing = new Set(target.plugins.map(pluginMarketplacePluginKey).filter(Boolean)); + let added = 0; + source.plugins.forEach((plugin) => { + const key = pluginMarketplacePluginKey(plugin); + if (!key || existing.has(key)) return; + const normalized = normalizeLocalPluginMarketplacePlugin(plugin, marketplaceName); + if (!normalized) return; + target.plugins.push(normalized); + existing.add(key); + added += 1; + }); + return added; } - function clearPluginPatchArtifacts() { - const pluginButton = pluginEntryButton(); - if (pluginButton) { - delete pluginButton.dataset.codexPluginEnabled; - clearPluginEntryUnlockLabel(pluginButton); - } - pluginInstallCandidates().forEach(clearForcedInstallButtonLabel); - } - - function unblockPluginInstallButtons() { - if (pluginPatchDisabledInRelayMode()) return; - if (!codexPlusSettings().forcePluginInstall) return; - pluginInstallCandidates().forEach((button) => { - const text = installButtonLabel(button); - if (!isInstallButtonLabel(text)) return; - unblockButtonElement(button); - labelForcedInstallButton(button); + function mergeLocalPluginMarketplaces(marketplaces) { + if (!Array.isArray(marketplaces)) return { addedMarketplaces: 0, addedPlugins: 0 }; + const local = Array.isArray(window.__CODEX_PLUS_PLUGIN_MARKETPLACES__) ? window.__CODEX_PLUS_PLUGIN_MARKETPLACES__ : []; + if (!local.length) return { addedMarketplaces: 0, addedPlugins: 0 }; + const byName = new Map(); + marketplaces.forEach((marketplace) => { + const name = restorePluginMarketplaceName(marketplace?.name || ""); + if (name) byName.set(name, marketplace); + }); + let addedMarketplaces = 0; + let addedPlugins = 0; + local.forEach((marketplace) => { + const name = restorePluginMarketplaceName(marketplace?.name || ""); + if (!name) return; + const existing = byName.get(name); + if (existing) { + addedPlugins += mergeLocalPluginMarketplacePlugins(existing, marketplace, name); + return; + } + const cloned = clonePluginMarketplaceValue(marketplace); + if (!cloned) return; + cloned.name = name; + cloned.plugins = Array.isArray(cloned.plugins) + ? cloned.plugins.map((plugin) => normalizeLocalPluginMarketplacePlugin(plugin, name)).filter(Boolean) + : []; + marketplaces.push(cloned); + byName.set(name, cloned); + addedMarketplaces += 1; + addedPlugins += cloned.plugins.length; }); + if (addedMarketplaces > 0 || addedPlugins > 0) { + sendCodexPlusDiagnostic("plugin_marketplace_local_merged", { addedMarketplaces, addedPlugins }); + } + return { addedMarketplaces, addedPlugins }; + } + + function patchPluginMarketplaceRequestParams(method, params) { + if (method !== "list-plugins" || !params || typeof params !== "object") return params; + const next = { ...params }; + const kinds = Array.isArray(next.marketplaceKinds) ? next.marketplaceKinds.map(restorePluginMarketplaceName) : ["local"]; + if (!kinds.includes("vertical")) kinds.push("vertical"); + next.marketplaceKinds = Array.from(new Set(kinds)); + sendCodexPlusDiagnostic("plugin_marketplace_request_expanded", { marketplaceKinds: next.marketplaceKinds }); + return next; + } + + function patchPluginMarketplaceResult(method, result) { + if (method !== "list-plugins" || !result || typeof result !== "object") return result; + const marketplaces = Array.isArray(result.marketplaces) ? result.marketplaces : Array.isArray(result.data?.marketplaces) ? result.data.marketplaces : []; + mergeLocalPluginMarketplaces(marketplaces); + marketplaces.forEach((marketplace) => { + if (marketplace?.name) marketplace.name = restorePluginMarketplaceName(marketplace.name); + if (Array.isArray(marketplace?.plugins)) { + marketplace.plugins.forEach((plugin) => { + if (plugin?.marketplaceName) plugin.marketplaceName = restorePluginMarketplaceName(plugin.marketplaceName); + }); + } + }); + sendCodexPlusDiagnostic("plugin_marketplace_response_expanded", { marketplaceCount: marketplaces.length }); + return result; } - function refreshForcePluginInstallUnlockLoop() { - const shouldRun = !pluginPatchDisabledInRelayMode() && codexPlusSettings().forcePluginInstall; - if (!shouldRun) { - clearInterval(window.__codexForcePluginInstallRefreshTimer); - window.__codexForcePluginInstallRefreshTimer = null; - return; + function pluginAutoExpandVisibleElement(element) { + if (!(element instanceof HTMLElement) || !element.isConnected) return false; + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style.display !== "none" && style.visibility !== "hidden" && style.pointerEvents !== "none" && rect.width > 0 && rect.height > 0; + } + + function pluginAutoExpandButtonText(button) { + return String(button?.textContent || button?.getAttribute?.("aria-label") || button?.getAttribute?.("title") || "").replace(/\s+/g, " ").trim(); + } + + function pluginAutoExpandButtonLooksScoped(button) { + let node = button; + for (let depth = 0; node instanceof HTMLElement && node !== document.body && depth < 8; depth += 1, node = node.parentElement) { + const text = String(node.innerText || ""); + if (text.length <= 16000 && /插件|Plugins?|Marketplace|市场/i.test(text)) return true; } - if (window.__codexForcePluginInstallRefreshTimer) return; - window.__codexForcePluginInstallRefreshTimer = setInterval(() => { - if (!codexPlusSettings().forcePluginInstall || pluginPatchDisabledInRelayMode()) { - clearInterval(window.__codexForcePluginInstallRefreshTimer); - window.__codexForcePluginInstallRefreshTimer = null; + return false; + } + + function pluginAutoExpandButtonCandidates() { + if (!codexPlusSettings().pluginAutoExpand || !/插件|Plugins?|Marketplace|市场/i.test(String(document.body?.innerText || ""))) return []; + return Array.from(document.querySelectorAll('button, [role="button"]')) + .filter(pluginAutoExpandVisibleElement) + .filter((button) => !button.disabled && button.getAttribute("aria-disabled") !== "true") + .filter((button) => /^(更多|显示更多|查看更多|加载更多|Show more|Load more|More)$/i.test(pluginAutoExpandButtonText(button)) + || /^查看\s+.+以及另外\s*\d+\s*个$/i.test(pluginAutoExpandButtonText(button)) + || /^(View|Show)\s+.+\s+and\s+\d+\s+more$/i.test(pluginAutoExpandButtonText(button))) + .filter(pluginAutoExpandButtonLooksScoped) + .filter((button) => !button.closest?.(`#${codexPlusMenuId}, .codex-plus-modal-overlay`)); + } + + function schedulePluginAutoExpand(force = false) { + if (!codexPlusSettings().pluginAutoExpand || pluginPatchDisabledInRelayMode()) return; + if (window.__codexPluginAutoExpandRunning && !force) return; + clearTimeout(window.__codexPluginAutoExpandTimer); + window.__codexPluginAutoExpandTimer = setTimeout(() => runPluginAutoExpand(force), force ? 30 : 180); + } + + function runPluginAutoExpand(force = false) { + if (!codexPlusSettings().pluginAutoExpand || pluginPatchDisabledInRelayMode()) return; + const signature = pluginAutoExpandButtonCandidates().map(pluginAutoExpandButtonText).join("|"); + if (!force && signature && signature === window.__codexPluginAutoExpandLastSignature) return; + window.__codexPluginAutoExpandLastSignature = signature; + window.__codexPluginAutoExpandRunning = true; + window.__codexPluginAutoExpandClicks = 0; + const clickNext = () => { + const button = pluginAutoExpandButtonCandidates()[0]; + if (!button || !codexPlusSettings().pluginAutoExpand || window.__codexPluginAutoExpandClicks >= codexPluginAutoExpandMaxClicks) { + window.__codexPluginAutoExpandRunning = false; + sendCodexPlusDiagnostic("plugin_auto_expand_finished", { version: codexPluginAutoExpandVersion, clicks: window.__codexPluginAutoExpandClicks || 0, exhausted: !!button }); return; } - unblockPluginInstallButtons(); - }, codexForcePluginInstallRefreshIntervalMs); + window.__codexPluginAutoExpandClicks += 1; + button.click(); + setTimeout(clickNext, codexPluginAutoExpandClickDelayMs); + }; + clickNext(); + } + + function patchPluginMarketplaceRequestClient(client) { + if (!client || typeof client.sendRequest !== "function") return false; + if (client.__codexPluginMarketplaceUnlockPatch === codexPluginMarketplaceUnlockVersion) return true; + const original = client.sendRequest.bind(client); + client.sendRequest = async function codexPluginMarketplacePatchedSendRequest(method, params, options) { + const requestMethod = appServerPluginRequestMethod(String(method || ""), params); + const requestParams = patchPluginMarketplaceRequestParams(requestMethod, restorePluginMarketplaceRequestParams(params, requestMethod)); + return patchPluginMarketplaceResult(requestMethod, await original(method, requestParams, options)); + }; + client.__codexPluginMarketplaceUnlockPatch = codexPluginMarketplaceUnlockVersion; + return true; + } + + function patchPluginMarketplaceRequestMessage(message) { + if (!message || typeof message !== "object") return message; + if (message.type === "fetch" && typeof message.url === "string") { + const requestMethod = appServerPluginRequestMethod(message.url, message.body); + if (requestMethod !== "list-plugins" && requestMethod !== "install-plugin") return message; + let params = message.body; + if (typeof params === "string") { + try { params = JSON.parse(params); } catch { return message; } + } + const next = patchPluginMarketplaceRequestParams(requestMethod, restorePluginMarketplaceRequestParams(params, requestMethod)); + if (requestMethod === "list-plugins" && message.requestId != null) { + window.__codexPluginMarketplaceFetchRequestIds = window.__codexPluginMarketplaceFetchRequestIds || new Set(); + window.__codexPluginMarketplaceFetchRequestIds.add(String(message.requestId)); + } + return { ...message, body: typeof message.body === "string" ? JSON.stringify(next) : next }; + } + if (message.type === "mcp-request" && message.request && typeof message.request === "object") { + const requestMethod = appServerPluginRequestMethod(String(message.request.method || ""), message.request.params); + if (requestMethod !== "list-plugins" && requestMethod !== "install-plugin") return message; + const params = patchPluginMarketplaceRequestParams(requestMethod, restorePluginMarketplaceRequestParams(message.request.params, requestMethod)); + if (requestMethod === "list-plugins" && message.request.id != null) { + window.__codexPluginMarketplaceRequestIds = window.__codexPluginMarketplaceRequestIds || new Set(); + window.__codexPluginMarketplaceRequestIds.add(String(message.request.id)); + } + return { ...message, request: { ...message.request, params } }; + } + return message; + } + + function patchPluginMarketplaceResponseData(data) { + if (data?.type === "fetch-response") { + const requestId = String(data.requestId ?? ""); + const ids = window.__codexPluginMarketplaceFetchRequestIds; + if (!(ids instanceof Set) || !ids.has(requestId)) return false; + ids.delete(requestId); + try { + const result = JSON.parse(data.bodyJsonString || "null"); + patchPluginMarketplaceResult("list-plugins", result); + data.bodyJsonString = JSON.stringify(result); + return true; + } catch { + return false; + } + } + if (data?.type !== "mcp-response") return false; + const message = data.message || data.response; + const requestId = String(message?.id ?? ""); + const ids = window.__codexPluginMarketplaceRequestIds; + if (!(ids instanceof Set) || !ids.has(requestId)) return false; + ids.delete(requestId); + patchPluginMarketplaceResult("list-plugins", message?.result); + return true; + } + + function installPluginMarketplaceWindowEventPatchOnly() { + if (window.__codexPluginMarketplaceWindowEventPatch === codexPluginMarketplaceUnlockVersion) return; + if (pluginPatchDisabledInRelayMode() || !codexPlusSettings().pluginMarketplaceUnlock) return; + const originalDispatchEvent = window.__codexPluginMarketplaceOriginalDispatchEvent || window.dispatchEvent; + if (!window.__codexPluginMarketplaceOriginalDispatchEvent) { + window.__codexPluginMarketplaceOriginalDispatchEvent = originalDispatchEvent; + window.dispatchEvent = function patchedCodexPluginMarketplaceDispatchEvent(event) { + const detail = event?.detail; + if (event?.type === "codex-message-from-view" && detail?.type === "mcp-request") { + const patched = patchPluginMarketplaceRequestMessage(detail); + if (patched !== detail) Object.assign(detail, patched); + } + if (event?.type === "message") patchPluginMarketplaceResponseData(event.data); + return originalDispatchEvent.call(this, event); + }; + } + if (!window.__codexPluginMarketplaceResponseListenerInstalled) { + window.__codexPluginMarketplaceResponseListenerInstalled = true; + window.addEventListener("message", (event) => patchPluginMarketplaceResponseData(event?.data), true); + } + window.__codexPluginMarketplaceWindowEventPatch = codexPluginMarketplaceUnlockVersion; + } + + function installPluginMarketplaceBridgePatch() { + if (window.__codexPluginMarketplaceBridgePatch === codexPluginMarketplaceUnlockVersion) return; + if (pluginPatchDisabledInRelayMode() || !codexPlusSettings().pluginMarketplaceUnlock) return; + installPluginMarketplaceWindowEventPatchOnly(); + const bridge = window.electronBridge; + if (!bridge?.sendMessageFromView) return; + if (!bridge.__codexPluginMarketplaceOriginalSendMessageFromView) { + bridge.__codexPluginMarketplaceOriginalSendMessageFromView = bridge.sendMessageFromView.bind(bridge); + bridge.sendMessageFromView = (message) => bridge.__codexPluginMarketplaceOriginalSendMessageFromView(patchPluginMarketplaceRequestMessage(message)); + } + window.__codexPluginMarketplaceBridgePatch = codexPluginMarketplaceUnlockVersion; + } + + function installPluginMarketplaceRequestPatch() { + if (window.__codexPluginMarketplaceUnlockInstalled === codexPluginMarketplaceUnlockVersion) return; + if (pluginPatchDisabledInRelayMode() || !codexPlusSettings().pluginMarketplaceUnlock) return; + void loadCodexAppModule("app-server-manager-signals-").then((module) => { + let patchedCount = 0; + Object.values(module || {}).filter((value) => value && typeof value === "object").forEach((candidate) => { + if (patchPluginMarketplaceRequestClient(candidate)) patchedCount += 1; + try { + if (typeof candidate.get === "function" && patchPluginMarketplaceRequestClient(candidate.get())) patchedCount += 1; + } catch { + } + }); + if (patchedCount > 0) window.__codexPluginMarketplaceUnlockInstalled = codexPluginMarketplaceUnlockVersion; + sendCodexPlusDiagnostic("plugin_marketplace_request_patch_installed", { patchedCount }); + }).catch((error) => sendCodexPlusDiagnostic("plugin_marketplace_request_patch_failed", { errorMessage: error?.message || String(error) })); + } + + function clearPluginPatchArtifacts() { } let cachedSessionRows = []; @@ -2545,26 +2919,36 @@ return; } const opacity = Math.min(1, Math.max(0.01, Number(config.opacity) || 0.35)); - const image = existing || document.createElement("img"); - image.id = codexPlusImageOverlayId; - image.src = source; - image.alt = ""; - image.setAttribute("aria-hidden", "true"); - Object.assign(image.style, { + const fitMode = ["fill", "fit", "stretch", "tile", "center"].includes(config.fitMode) ? config.fitMode : "fit"; + const fitStyles = { + fill: { size: "cover", position: "center center", repeat: "no-repeat" }, + fit: { size: "contain", position: "center center", repeat: "no-repeat" }, + stretch: { size: "100% 100%", position: "center center", repeat: "no-repeat" }, + tile: { size: "auto", position: "left top", repeat: "repeat" }, + center: { size: "auto", position: "center center", repeat: "no-repeat" }, + }[fitMode]; + const overlay = existing?.tagName === "DIV" ? existing : document.createElement("div"); + if (existing && existing !== overlay) existing.remove(); + overlay.id = codexPlusImageOverlayId; + overlay.setAttribute("aria-hidden", "true"); + Object.assign(overlay.style, { position: "fixed", inset: "0", width: "100vw", height: "100vh", - objectFit: "contain", - objectPosition: "center center", + backgroundImage: `url("${source.replace(/"/g, "%22")}")`, + backgroundSize: fitStyles.size, + backgroundPosition: fitStyles.position, + backgroundRepeat: fitStyles.repeat, opacity: String(opacity), pointerEvents: "none", zIndex: "2147483646", userSelect: "none", }); - if (!existing) root.appendChild(image); + if (!overlay.parentElement) root.appendChild(overlay); sendCodexPlusDiagnostic("image_overlay_installed", { opacity, + fitMode, sourceKind: source.startsWith("data:") ? "data-uri" : "helper-url", }); } @@ -6543,16 +6927,23 @@ function scanDeferred() { if (pluginPatchDisabledInRelayMode()) { clearPluginPatchArtifacts(); - refreshForcePluginInstallUnlockLoop(); } else { const pluginUnlockStrategy = codexPluginUnlockStrategy(); const settings = codexPlusSettings(); logCodexPluginUnlockStrategy(pluginUnlockStrategy); - if ((pluginUnlockStrategy === "legacy" || pluginUnlockStrategy === "unknown") && settings.pluginEntryUnlock) { - enablePluginEntry(); + if ((pluginUnlockStrategy === "modern" || pluginUnlockStrategy === "unknown") && settings.pluginMarketplaceUnlock) { + const strategy = codexPluginMarketplaceRequestPatchStrategy(); + installPluginBuildFlavorFilterPatch(); + if (strategy === "bridge") { + installPluginMarketplaceBridgePatch(); + } else if (strategy === "client") { + installPluginMarketplaceRequestPatch(); + } else { + installPluginMarketplaceWindowEventPatchOnly(); + installPluginMarketplaceBridgePatch(); + installPluginMarketplaceRequestPatch(); + } } - unblockPluginInstallButtons(); - refreshForcePluginInstallUnlockLoop(); } sessionRows().forEach(tryAttachButton); syncActionGroupsLayout(); @@ -6567,6 +6958,7 @@ installCodexServiceTierBadge(); scheduleThreadScrollSync(); patchCodexModelWhitelist(); + schedulePluginAutoExpand(); } function runScanStep(step) { diff --git a/assets/plugin-marketplaces/openai-curated-remote.zip b/assets/plugin-marketplaces/openai-curated-remote.zip new file mode 100644 index 0000000..4021881 Binary files /dev/null and b/assets/plugin-marketplaces/openai-curated-remote.zip differ diff --git a/bridge.go b/bridge.go index fdc3d52..a764c82 100644 --- a/bridge.go +++ b/bridge.go @@ -33,7 +33,7 @@ func (r *launcherRuntime) handleBridgeRequest(path string, payload json.RawMessa r.logRendererDiagnostic(payload) result = map[string]any{"status": "ok", "message": "日志已记录"} case "/user-scripts/list": - result = userScriptInventoryValue() + result = userScriptInventoryValue(payloadMap["runtime_status"]) case "/user-scripts/set-enabled": config := loadUserScriptConfig() config.Enabled = boolFromAny(payloadMap["enabled"]) @@ -143,8 +143,8 @@ func (r *launcherRuntime) bridgeSettingsValue(settings backendSettings) map[stri "relayProfilesEnabled": settings.RelayProfilesEnabled, "ccsLinkEnabled": settings.CCSLinkEnabled, "enhancementsEnabled": settings.Enhancements, - "codexAppPluginEntryUnlock": settings.CodexAppPluginEntryUnlock, - "codexAppForcePluginInstall": settings.CodexAppForcePluginInstall, + "codexAppPluginMarketplaceUnlock": settings.CodexAppPluginMarketplaceUnlock, + "codexAppPluginAutoExpand": settings.CodexAppPluginAutoExpand, "codexAppModelWhitelistUnlock": settings.CodexAppModelWhitelistUnlock, "codexAppSessionDelete": settings.CodexAppSessionDelete, "codexAppMarkdownExport": settings.CodexAppMarkdownExport, @@ -168,6 +168,7 @@ func (r *launcherRuntime) bridgeSettingsValue(settings backendSettings) map[stri "codexAppImageOverlayEnabled": settings.CodexAppImageOverlayEnabled, "codexAppImageOverlayPath": settings.CodexAppImageOverlayPath, "codexAppImageOverlayOpacity": settings.CodexAppImageOverlayOpacity, + "codexAppImageOverlayFitMode": settings.CodexAppImageOverlayFitMode, "codexGoalsEnabled": settings.CodexGoalsEnabled, "mobileControlEnabled": settings.MobileControlEnabled, "mobileControlRelayUrl": settings.MobileControlRelayURL, @@ -206,8 +207,8 @@ func (r *launcherRuntime) setBridgeSettings(payload map[string]any) map[string]a applyBool("relayProfilesEnabled", &settings.RelayProfilesEnabled) applyBool("ccsLinkEnabled", &settings.CCSLinkEnabled) applyBool("enhancementsEnabled", &settings.Enhancements) - applyBool("codexAppPluginEntryUnlock", &settings.CodexAppPluginEntryUnlock) - applyBool("codexAppForcePluginInstall", &settings.CodexAppForcePluginInstall) + applyBool("codexAppPluginMarketplaceUnlock", &settings.CodexAppPluginMarketplaceUnlock) + applyBool("codexAppPluginAutoExpand", &settings.CodexAppPluginAutoExpand) applyBool("codexAppModelWhitelistUnlock", &settings.CodexAppModelWhitelistUnlock) applyBool("codexAppSessionDelete", &settings.CodexAppSessionDelete) applyBool("codexAppMarkdownExport", &settings.CodexAppMarkdownExport) @@ -239,6 +240,9 @@ func (r *launcherRuntime) setBridgeSettings(payload map[string]any) map[string]a if _, ok := payload["codexAppImageOverlayOpacity"]; ok { settings.CodexAppImageOverlayOpacity = intArg(payload, "codexAppImageOverlayOpacity", settings.CodexAppImageOverlayOpacity) } + if _, ok := payload["codexAppImageOverlayFitMode"]; ok { + settings.CodexAppImageOverlayFitMode = normalizeImageOverlayFitMode(stringFromAny(payload["codexAppImageOverlayFitMode"])) + } if _, ok := payload["mobileControlRelayUrl"]; ok { settings.MobileControlRelayURL = strings.TrimSpace(stringFromAny(payload["mobileControlRelayUrl"])) } @@ -287,7 +291,24 @@ func enabledUserScriptBundle() string { if err != nil { continue } - parts = append(parts, fmt.Sprintf("\n;(() => {\n%s\n})();\n", string(data))) + keyJSON, _ := json.Marshal(item.Key) + nameJSON, _ := json.Marshal(item.Name) + sourceJSON, _ := json.Marshal(item.Source) + parts = append(parts, fmt.Sprintf(` +;(() => { + window.__codexPlusUserScripts = window.__codexPlusUserScripts || { scripts: {} }; + const key = %s; + window.__codexPlusUserScripts.scripts[key] = { key, name: %s, source: %s, status: "loading", error: "", loadedAt: new Date().toISOString() }; + try { +%s + window.__codexPlusUserScripts.scripts[key].status = "loaded"; + window.__codexPlusUserScripts.scripts[key].loadedAt = new Date().toISOString(); + } catch (error) { + window.__codexPlusUserScripts.scripts[key].status = "failed"; + window.__codexPlusUserScripts.scripts[key].error = String(error && (error.stack || error.message) || error); + } +})(); +`, keyJSON, nameJSON, sourceJSON, string(data))) } return strings.Join(parts, "\n") } @@ -561,10 +582,11 @@ func injectionScript(helperPort uint16, settings backendSettings) string { versionJSON, _ := json.Marshal(version) buildJSON, _ := json.Marshal("go-20260711-1") imageOverlayJSON, _ := json.Marshal(imageOverlayConfig(helperPort, settings)) + pluginMarketplacesJSON, _ := json.Marshal(localPluginMarketplaces(codexHomeDir())) fastStartupJSON, _ := json.Marshal(map[string]any{"enabled": settings.CodexAppFastStartup, "statsigTimeoutMs": 800}) chineseLocaleJSON, _ := json.Marshal(map[string]any{"enabled": settings.CodexAppForceChineseLocale, "locale": "zh-CN"}) nativeMenuLocalizationJSON, _ := json.Marshal(map[string]any{"enabled": settings.CodexAppNativeMenuLocalization, "locale": "zh-CN"}) - return fmt.Sprintf("window.__CODEX_SESSION_DELETE_HELPER__ = %s;\nwindow.__CODEX_PLUS_VERSION__ = %s;\nwindow.__CODEX_PLUS_BUILD__ = %s;\nwindow.__CODEX_PLUS_IMAGE_OVERLAY__ = %s;\nwindow.__CODEX_PLUS_FAST_STARTUP__ = %s;\nwindow.__CODEX_PLUS_FORCE_CHINESE_LOCALE__ = %s;\nwindow.__CODEX_PLUS_NATIVE_MENU_LOCALIZATION__ = %s;\n%s", helperJSON, versionJSON, buildJSON, imageOverlayJSON, fastStartupJSON, chineseLocaleJSON, nativeMenuLocalizationJSON, rendererInjectScript) + return fmt.Sprintf("window.__CODEX_SESSION_DELETE_HELPER__ = %s;\nwindow.__CODEX_PLUS_VERSION__ = %s;\nwindow.__CODEX_PLUS_BUILD__ = %s;\nwindow.__CODEX_PLUS_IMAGE_OVERLAY__ = %s;\nwindow.__CODEX_PLUS_PLUGIN_MARKETPLACES__ = %s;\nwindow.__CODEX_PLUS_FAST_STARTUP__ = %s;\nwindow.__CODEX_PLUS_FORCE_CHINESE_LOCALE__ = %s;\nwindow.__CODEX_PLUS_NATIVE_MENU_LOCALIZATION__ = %s;\n%s", helperJSON, versionJSON, buildJSON, imageOverlayJSON, pluginMarketplacesJSON, fastStartupJSON, chineseLocaleJSON, nativeMenuLocalizationJSON, rendererInjectScript) } func imageOverlayConfig(helperPort uint16, settings backendSettings) map[string]any { @@ -597,6 +619,7 @@ func imageOverlayConfig(helperPort uint16, settings backendSettings) map[string] return map[string]any{ "enabled": enabled, "opacity": float64(opacity) / 100.0, + "fitMode": normalizeImageOverlayFitMode(settings.CodexAppImageOverlayFitMode), "dataUrl": dataURL, "imageUrl": imageURL, } diff --git a/bridge_parity_test.go b/bridge_parity_test.go index 7b185a6..5d1e4f1 100644 --- a/bridge_parity_test.go +++ b/bridge_parity_test.go @@ -185,18 +185,21 @@ func TestBridgeSettingsIncludesRuntimeCodexAppVersion(t *testing.T) { } } -func TestInjectionScriptDoesNotInjectLocalPluginMarketplaces(t *testing.T) { +func TestInjectionScriptInjectsLocalPluginMarketplacesWithoutAds(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) manifest := filepath.Join(home, ".codex", ".tmp", "plugins", ".agents", "plugins", "marketplace.json") writeTestFile(t, manifest, `{"name":"openai-curated","plugins":[{"name":"writer"}]}`) script := injectionScript(57321, defaultSettings()) - for _, forbidden := range []string{"window.__CODEX_PLUS_PLUGIN_MARKETPLACES__", `"openai-curated"`, `"writer"`} { - if strings.Contains(script, forbidden) { - t.Fatalf("injection should rely on Codex's native marketplace instead of injecting local payload %q", forbidden) + for _, expected := range []string{"window.__CODEX_PLUS_PLUGIN_MARKETPLACES__", `"writer"`, `"openai-curated"`} { + if !strings.Contains(script, expected) { + t.Fatalf("injection should include normalized local marketplace payload %q", expected) } } + if strings.Contains(script, "/ads") { + t.Fatal("local marketplace payload must not reintroduce ads") + } } func TestInjectionScriptIncludesV1224RuntimeConfig(t *testing.T) { @@ -228,6 +231,7 @@ func TestImageOverlayConfigAndInjectionScript(t *testing.T) { settings.CodexAppImageOverlayEnabled = true settings.CodexAppImageOverlayPath = imagePath settings.CodexAppImageOverlayOpacity = 42 + settings.CodexAppImageOverlayFitMode = "tile" config := imageOverlayConfig(57321, settings) @@ -240,6 +244,9 @@ func TestImageOverlayConfigAndInjectionScript(t *testing.T) { if got := stringFromAny(config["imageUrl"]); got != "http://127.0.0.1:57321/overlay/image" { t.Fatalf("overlay image URL mismatch: %q", got) } + if got := stringFromAny(config["fitMode"]); got != "tile" { + t.Fatalf("overlay fit mode mismatch: %q", got) + } script := injectionScript(57321, settings) for _, expected := range []string{"__CODEX_PLUS_IMAGE_OVERLAY__", "codex-plus-image-overlay", "image_overlay_installed"} { if !strings.Contains(script, expected) { @@ -248,6 +255,27 @@ func TestImageOverlayConfigAndInjectionScript(t *testing.T) { } } +func TestBridgeSettingsPersistLatestEnhancementFields(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + runtime := &launcherRuntime{} + result := runtime.handleBridgeRequest("/settings/set", json.RawMessage(`{ + "codexAppPluginMarketplaceUnlock": false, + "codexAppPluginAutoExpand": false, + "codexAppFastStartup": true, + "codexAppImageOverlayFitMode": "stretch" +}`)) + if stringFromAny(result["status"]) != "ok" { + t.Fatalf("settings set failed: %#v", result) + } + loaded := loadSettings() + if loaded.CodexAppPluginMarketplaceUnlock || loaded.CodexAppPluginAutoExpand { + t.Fatalf("explicitly disabled plugin settings were not preserved: %#v", loaded) + } + if !loaded.CodexAppFastStartup || loaded.CodexAppImageOverlayFitMode != "stretch" { + t.Fatalf("latest enhancement fields were not persisted: %#v", loaded) + } +} + func TestResilientLoopbackGuardHoldsLockAndListener(t *testing.T) { port := freeLoopbackPort(t) guard, err := acquireResilientLoopbackPortGuardAt(port, t.TempDir()) diff --git a/codex_sqlite.go b/codex_sqlite.go new file mode 100644 index 0000000..bcecb29 --- /dev/null +++ b/codex_sqlite.go @@ -0,0 +1,87 @@ +package main + +import ( + "os" + "path/filepath" + "sort" + "strings" +) + +func codexSQLiteHome(home string) string { + if value := strings.TrimSpace(os.Getenv("CODEX_SQLITE_HOME")); value != "" { + return filepath.Clean(os.ExpandEnv(value)) + } + return filepath.Clean(home) +} + +func codexSessionDBPaths(home string) []string { + sqliteHome := codexSQLiteHome(home) + paths := make([]string, 0) + seen := map[string]bool{} + add := func(path string) { + path = filepath.Clean(path) + if path == "" || seen[path] || !fileExists(path) { + return + } + seen[path] = true + paths = append(paths, path) + } + sqliteDir := filepath.Join(sqliteHome, "sqlite") + if entries, err := os.ReadDir(sqliteDir); err == nil { + for _, entry := range entries { + if entry.IsDir() { + continue + } + lower := strings.ToLower(entry.Name()) + if strings.HasSuffix(lower, ".db") || strings.HasSuffix(lower, ".sqlite") { + add(filepath.Join(sqliteDir, entry.Name())) + } + } + } + sort.Strings(paths) + add(filepath.Join(sqliteHome, "state_5.sqlite")) + return paths +} + +func codexPreferredSessionDBPath(home string) string { + paths := codexSessionDBPaths(home) + for _, path := range paths { + if sqlitePathHasTable(path, "threads") { + return path + } + } + return filepath.Join(codexSQLiteHome(home), "state_5.sqlite") +} + +func sqlitePathHasTable(path, table string) bool { + if !fileExists(path) { + return false + } + db, err := openSQLite(path) + if err != nil { + return false + } + defer db.Close() + columns, err := sqliteTableColumns(db, table) + return err == nil && len(columns) > 0 +} + +func codexLogsDBPath(home string) string { + return filepath.Join(codexSQLiteHome(home), "logs_2.sqlite") +} + +func pathWithin(root, candidate string) bool { + rootAbs, err := filepath.Abs(root) + if err != nil { + return false + } + candidateAbs, err := filepath.Abs(candidate) + if err != nil { + return false + } + relative, err := filepath.Rel(rootAbs, candidateAbs) + if err != nil { + return false + } + return relative == "." || relative != ".." && !strings.HasPrefix(relative, ".."+string(os.PathSeparator)) +} diff --git a/codex_sqlite_test.go b/codex_sqlite_test.go new file mode 100644 index 0000000..510c8b0 --- /dev/null +++ b/codex_sqlite_test.go @@ -0,0 +1,145 @@ +package main + +import ( + "database/sql" + "os" + "path/filepath" + "reflect" + "testing" +) + +func sqliteCountForTest(t *testing.T, db *sql.DB, query string, args ...any) int { + t.Helper() + var count int + if err := db.QueryRow(query, args...).Scan(&count); err != nil { + t.Fatal(err) + } + return count +} + +func TestCodexSessionDBPathsHonorSQLiteHomeAndMultipleDatabases(t *testing.T) { + home := t.TempDir() + sqliteHome := filepath.Join(t.TempDir(), "sqlite-home") + t.Setenv("CODEX_SQLITE_HOME", sqliteHome) + for _, path := range []string{ + filepath.Join(sqliteHome, "sqlite", "b.db"), + filepath.Join(sqliteHome, "sqlite", "a.sqlite"), + filepath.Join(sqliteHome, "state_5.sqlite"), + } { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, nil, 0o600); err != nil { + t.Fatal(err) + } + } + want := []string{ + filepath.Join(sqliteHome, "sqlite", "a.sqlite"), + filepath.Join(sqliteHome, "sqlite", "b.db"), + filepath.Join(sqliteHome, "state_5.sqlite"), + } + if got := codexSessionDBPaths(home); !reflect.DeepEqual(got, want) { + t.Fatalf("session DB paths = %#v, want %#v", got, want) + } + if got := codexLogsDBPath(home); got != filepath.Join(sqliteHome, "logs_2.sqlite") { + t.Fatalf("logs DB = %q", got) + } +} + +func TestCodexSQLiteHomeKeepsMissingExplicitOverride(t *testing.T) { + home := t.TempDir() + override := filepath.Join(t.TempDir(), "not-created") + t.Setenv("CODEX_SQLITE_HOME", override) + if got := codexSQLiteHome(home); got != override { + t.Fatalf("missing explicit override should still win: %q", got) + } +} + +func TestAutomationRunRowsDeleteAndRestoreInOriginDatabase(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + dbPath := filepath.Join(home, ".codex", "sqlite", "automation.db") + if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil { + t.Fatal(err) + } + db, err := openSQLite(dbPath) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err := db.Exec(`CREATE TABLE automation_runs (thread_id TEXT PRIMARY KEY, thread_title TEXT, status TEXT)`); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`INSERT INTO automation_runs(thread_id, thread_title, status) VALUES (?, ?, ?)`, "auto-1", "Automation", "ready"); err != nil { + t.Fatal(err) + } + rows, err := sqliteAutomationRunRowsByIDs(dbPath, []string{"auto-1"}) + if err != nil || len(rows) != 1 { + t.Fatalf("automation rows = %#v, err = %v", rows, err) + } + lookup := sessionLookupResult{RequestedID: "auto-1", CanonicalID: "auto-1", Variants: []string{"auto-1"}, DBRows: rows} + if err := deleteSessionLookupRows(lookup); err != nil { + t.Fatal(err) + } + if got := sqliteCountForTest(t, db, `SELECT COUNT(*) FROM automation_runs WHERE thread_id = ?`, "auto-1"); got != 0 { + t.Fatalf("automation row count after delete = %d", got) + } + if err := restoreSessionSQLiteRows(rows); err != nil { + t.Fatal(err) + } + if got := sqliteCountForTest(t, db, `SELECT COUNT(*) FROM automation_runs WHERE thread_id = ?`, "auto-1"); got != 1 { + t.Fatalf("automation row count after restore = %d", got) + } +} + +func TestAutomationBackupAllowsOnlyMatchingCodexRolloutPath(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + allowed := filepath.Join(home, ".codex", "sessions", "2026", "rollout-auto-1.jsonl") + manifest := deletedSessionManifest{ + Version: 2, + SessionID: "auto-1", + Rows: []sessionSQLiteRow{{ + Table: "automation_runs", + Values: map[string]any{"thread_id": "auto-1"}, + }}, + Files: []deletedSessionFileBackup{{OriginalPath: allowed, BackupName: "files/rollout.jsonl"}}, + } + if err := validateDeletedSessionRestorePaths(manifest); err != nil { + t.Fatalf("matching automation rollout should be allowed: %v", err) + } + manifest.Files[0].OriginalPath = filepath.Join(t.TempDir(), "rollout-auto-1.jsonl") + if err := validateDeletedSessionRestorePaths(manifest); err == nil { + t.Fatal("automation backup must not restore outside Codex session roots") + } +} + +func TestProviderSyncDatabaseSnapshotsRestoreEveryDatabase(t *testing.T) { + first := filepath.Join(t.TempDir(), "first.db") + second := filepath.Join(t.TempDir(), "second.db") + if err := os.WriteFile(first, []byte("first-before"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(second, []byte("second-before"), 0o600); err != nil { + t.Fatal(err) + } + snapshots, err := captureProviderSyncDatabaseSnapshots([]string{first, second}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(first, []byte("first-after"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(second, []byte("second-after"), 0o600); err != nil { + t.Fatal(err) + } + if err := restoreProviderSyncDatabaseSnapshots(snapshots); err != nil { + t.Fatal(err) + } + for path, want := range map[string]string{first: "first-before", second: "second-before"} { + data, err := os.ReadFile(path) + if err != nil || string(data) != want { + t.Fatalf("restored %s = %q, err = %v", path, data, err) + } + } +} diff --git a/diagnostics.go b/diagnostics.go index 885430b..4b49701 100644 --- a/diagnostics.go +++ b/diagnostics.go @@ -147,14 +147,15 @@ func diagnosticSettingsValue(settings backendSettings) any { "relayContextConfigConfigured": strings.TrimSpace(settings.RelayContextConfigContents) != "", "ccsLinkEnabled": settings.CCSLinkEnabled, "enhancementsEnabled": settings.Enhancements, - "codexAppPluginEntryUnlock": settings.CodexAppPluginEntryUnlock, - "codexAppForcePluginInstall": settings.CodexAppForcePluginInstall, + "codexAppPluginMarketplaceUnlock": settings.CodexAppPluginMarketplaceUnlock, + "codexAppPluginAutoExpand": settings.CodexAppPluginAutoExpand, "codexAppModelWhitelistUnlock": settings.CodexAppModelWhitelistUnlock, "codexAppSessionDelete": settings.CodexAppSessionDelete, "codexAppMarkdownExport": settings.CodexAppMarkdownExport, "codexAppPasteFix": settings.CodexAppPasteFix, "codexAppForceChineseLocale": settings.CodexAppForceChineseLocale, "codexAppFastStartup": settings.CodexAppFastStartup, + "codexAppImageOverlayFitMode": settings.CodexAppImageOverlayFitMode, "codexAppProjectMove": settings.CodexAppProjectMove, "codexAppConversationTimeline": settings.CodexAppConversationTimeline, "codexAppThreadIdBadge": settings.CodexAppThreadIDBadge, diff --git a/helper.go b/helper.go index 7b34938..08619e2 100644 --- a/helper.go +++ b/helper.go @@ -342,6 +342,13 @@ func (r *launcherRuntime) forwardRelayProxy(w http.ResponseWriter, req *http.Req } func forwardRelayProxyAttempt(settings backendSettings, w http.ResponseWriter, req *http.Request, body []byte, profile relayProfile, attempt, candidateCount int) bool { + proxyBody, protocolContext, protocolErr := convertResponsesRequestForProfile(profile, req.URL.Path, body) + if protocolErr != nil { + writeHelperJSON(w, http.StatusBadRequest, map[string]any{"error": map[string]any{"message": protocolErr.Error(), "type": "invalid_request_error"}}) + recordRelayRequestFailure(settings) + return true + } + body = proxyBody baseURL := relayProxyBaseURL(effectiveUpstreamBaseURL(profile), profile.Protocol) apiKey := strings.TrimSpace(profile.APIKey) decision := relayRouteDecision{body: body, route: "text", reason: "default_text"} @@ -361,7 +368,7 @@ func forwardRelayProxyAttempt(settings backendSettings, w http.ResponseWriter, r recordRelayRequestFailure(settings) return true } - target := relayTargetURL(baseURL, req.URL.Path) + target := protocolProxyTargetURL(profile, req.URL.Path, protocolContext.Converted) if decision.useImageAPI { target = relayImageTargetURL(baseURL, req.URL.Path) } @@ -379,6 +386,11 @@ func forwardRelayProxyAttempt(settings backendSettings, w http.ResponseWriter, r upstreamReq.Header.Set("authorization", "Bearer "+apiKey) copyProxyHeaders(req.Header, upstreamReq.Header) setRelayProxyUserAgent(profile.UserAgent, req.Header, upstreamReq.Header) + upstreamReq.Header.Set("content-type", "application/json") + if protocolContext.Stream { + upstreamReq.Header.Set("accept", "text/event-stream") + upstreamReq.Header.Set("cache-control", "no-cache") + } upstreamReq.Header.Set("accept-encoding", "identity") client, err := relayHTTPClient(profile) if err != nil { @@ -431,17 +443,12 @@ func forwardRelayProxyAttempt(settings backendSettings, w http.ResponseWriter, r return false } writeCORSHeaders(w) - for _, name := range []string{"content-type", "cache-control", "openai-request-id", "x-request-id"} { + for _, name := range []string{"cache-control", "openai-request-id", "x-request-id"} { if value := resp.Header.Get(name); value != "" { w.Header().Set(name, value) } } - if w.Header().Get("content-type") == "" { - w.Header().Set("content-type", "application/json") - } - w.WriteHeader(resp.StatusCode) - flushRelayResponseHeaders(w) - responseBytes, copyErr := copyRelayResponseBody(w, resp.Body) + responseBytes, copyErr := writeProtocolProxyResponse(w, resp, protocolContext) logDetail := map[string]any{ "path": req.URL.Path, "status": resp.StatusCode, @@ -450,6 +457,7 @@ func forwardRelayProxyAttempt(settings backendSettings, w http.ResponseWriter, r "reason": decision.reason, "key_source": decision.keySource, "stripped_image_tool": decision.strippedImageTool, + "wire_api": map[bool]string{true: "chatCompletions", false: profile.Protocol}[protocolContext.Converted], "relay_id": profile.ID, "relay_name": profile.Name, "attempt": attempt, @@ -619,7 +627,11 @@ func decideRelayRouteForPath(path string, body []byte, profile relayProfile) rel decision := relayRouteDecision{body: body, route: "text", reason: "default_text", keySource: "default"} var value map[string]any if json.Unmarshal(body, &value) != nil { - decision.reason = "invalid_json" + if len(bytes.TrimSpace(body)) == 0 { + decision.reason = "empty_body" + } else { + decision.reason = "invalid_json" + } if usesSeparateImageGenerationAPI(profile) && relayPathRequestsImageGeneration(path) { decision.useImageAPI = true decision.route = "image" @@ -630,8 +642,10 @@ func decideRelayRouteForPath(path string, body []byte, profile relayProfile) rel } if !profile.ImageGenerationEnabled { - decision.reason = "image_disabled" decision.body, decision.strippedImageTool = stripImageGenerationTools(value, body) + if decision.strippedImageTool { + decision.reason = "image_disabled" + } return decision } diff --git a/image_relay_routing_test.go b/image_relay_routing_test.go index f1d1e85..a7d1581 100644 --- a/image_relay_routing_test.go +++ b/image_relay_routing_test.go @@ -87,6 +87,30 @@ func TestImageRelayUsesImageEndpointKeyAndHTTPProxyOnlyForImageRequests(t *testi } } +func TestRelayRouteReasonOnlyReportsImageDisabledWhenToolWasRemoved(t *testing.T) { + profile := relayProfile{ + Protocol: "responses", + ImageGenerationEnabled: false, + } + + empty := decideRelayRouteForPath("/v1/models", nil, profile) + if empty.reason != "empty_body" { + t.Fatalf("an ordinary bodyless request should not be reported as invalid JSON: %#v", empty) + } + + textBody := []byte(`{"model":"gpt-test","input":"implement a parser"}`) + text := decideRelayRouteForPath("/v1/responses", textBody, profile) + if text.reason != "default_text" || text.strippedImageTool { + t.Fatalf("an ordinary text request should not be reported as image-disabled routing: %#v", text) + } + + imageToolBody := []byte(`{"model":"gpt-test","input":"implement a parser","tools":[{"type":"image_generation"},{"type":"web_search"}]}`) + stripped := decideRelayRouteForPath("/v1/responses", imageToolBody, profile) + if stripped.reason != "image_disabled" || !stripped.strippedImageTool { + t.Fatalf("image-disabled routing should be reported only when the tool was removed: %#v", stripped) + } +} + func TestImageRelayTargetAcceptsBaseAndCompleteEndpoints(t *testing.T) { tests := []struct { name string diff --git a/launcher.go b/launcher.go index 9e6d6b0..8d542ca 100644 --- a/launcher.go +++ b/launcher.go @@ -163,6 +163,14 @@ func runLauncher(args []string) error { "message": repairResult.Message, }) } + if settings.CodexAppPluginMarketplaceUnlock { + configured, marketplaceErr := ensureOpenAICuratedMarketplaceConfig(codexHomeDir()) + if marketplaceErr != nil { + appendDiagnosticLog("launcher.plugin_marketplace_config_failed_nonfatal", map[string]any{"home": codexHomeDir(), "error": marketplaceErr.Error()}) + } else if configured { + appendDiagnosticLog("launcher.plugin_marketplace_configured", map[string]any{"home": codexHomeDir()}) + } + } if helperNeeded(settings) { if err := runtimeState.startHelper(helperPort); err != nil { failure := launchStatus{ diff --git a/main_test.go b/main_test.go index 914c626..1384d78 100644 --- a/main_test.go +++ b/main_test.go @@ -542,9 +542,10 @@ func TestNormalizeCodexAppPathRejectsCodexToolsMacApps(t *testing.T) { } } -func TestRendererInjectionKeepsPluginInstallWithoutMarketplaceRuntimePatches(t *testing.T) { +func TestRendererInjectionUsesVersionGatedMarketplaceAndAutoExpandStrategy(t *testing.T) { for _, required := range []string{ - `forcePluginInstall`, + `pluginMarketplaceUnlock`, + `pluginAutoExpand`, `pluginPatchDisabledInRelayMode`, `activeRelayModeSupportsOfficialSites`, `activeRelayMode: "official"`, @@ -562,29 +563,29 @@ func TestRendererInjectionKeepsPluginInstallWithoutMarketplaceRuntimePatches(t * `compareCodexVersions`, `codexPluginUnlockStrategy`, `plugin_unlock_strategy_selected`, - `pluginUnlockStrategy === "legacy"`, + `pluginUnlockStrategy === "modern"`, `pluginUnlockStrategy === "unknown"`, - `unblockPluginInstallButtons`, + `installPluginBuildFlavorFilterPatch`, + `installPluginMarketplaceBridgePatch`, + `installPluginMarketplaceWindowEventPatchOnly`, + `installPluginMarketplaceRequestPatch`, + `mergeLocalPluginMarketplaces`, + `__CODEX_PLUS_PLUGIN_MARKETPLACES__`, + `plugin_marketplace_local_merged`, + `plugin_auto_expand_finished`, + `schedulePluginAutoExpand`, `threadIdBadge`, `showSaveFilePicker`, `patchReactModelStateNodes`, - `强制安装`, } { if !strings.Contains(rendererInjectScript, required) { t.Fatalf("renderer injection should keep supported plugin controls; missing %q", required) } } for _, forbidden := range []string{ - `pluginAutoExpand`, - `pluginMarketplaceUnlock`, - `installPluginBuildFlavorFilterPatch`, - `installPluginMarketplaceBridgePatch`, - `installPluginMarketplaceWindowEventPatchOnly`, - `installPluginMarketplaceRequestPatch`, - `mergeLocalPluginMarketplaces`, - `__CODEX_PLUS_PLUGIN_MARKETPLACES__`, - `plugin_auto_expand_finished`, - `schedulePluginAutoExpand`, + `forcePluginInstall`, + `unblockPluginInstallButtons`, + `强制安装`, `/ads`, `load_ads`, `RecommendationsScreen`, @@ -595,7 +596,7 @@ func TestRendererInjectionKeepsPluginInstallWithoutMarketplaceRuntimePatches(t * `请作者喝咖啡`, } { if strings.Contains(rendererInjectScript, forbidden) { - t.Fatalf("renderer injection should not include removed marketplace/auto-expand patches or ads; found %q", forbidden) + t.Fatalf("renderer injection should not include removed force-install patches or ads; found %q", forbidden) } } } @@ -1002,8 +1003,9 @@ func TestSettingsDefaultsIncludeCodexPlusV1224Fields(t *testing.T) { for _, key := range []string{ "codexAppForceChineseLocale", - "codexAppFastStartup", "codexAppNativeMenuLocalization", + "codexAppPluginMarketplaceUnlock", + "codexAppPluginAutoExpand", } { if !boolFromAny(value[key]) { t.Fatalf("bridge settings should default %s to true: %#v", key, value) @@ -1012,6 +1014,21 @@ func TestSettingsDefaultsIncludeCodexPlusV1224Fields(t *testing.T) { if boolFromAny(value["codexAppPasteFix"]) { t.Fatalf("paste fix should default false: %#v", value) } + if boolFromAny(value["codexAppFastStartup"]) { + t.Fatalf("fast startup should default false: %#v", value) + } + if got := stringFromAny(value["codexAppImageOverlayFitMode"]); got != "fit" { + t.Fatalf("image overlay fit mode should default to fit, got %q", got) + } +} + +func TestDeprecatedPluginSettingsAreNotExposedByBridge(t *testing.T) { + value := (&launcherRuntime{}).bridgeSettingsValue(defaultSettings()) + for _, key := range []string{"codexAppPluginEntryUnlock", "codexAppForcePluginInstall"} { + if _, ok := value[key]; ok { + t.Fatalf("deprecated setting %s should not be exposed: %#v", key, value) + } + } } func TestSettingsPreserveAggregateRelayAndMobileControl(t *testing.T) { @@ -1322,6 +1339,8 @@ func TestSaveSettingsPreservesUnknownTopLevelFields(t *testing.T) { t.Setenv("HOME", home) writeTestFile(t, settingsPath(), `{ "futureFeature": {"enabled": true, "mode": "next"}, + "codexAppPluginEntryUnlock": true, + "codexAppForcePluginInstall": true, "codexAppPath": "", "relayProfiles": [{"id":"default","name":"Default","protocol":"responses","relayMode":"official"}], "activeRelayId": "default" @@ -1343,6 +1362,11 @@ func TestSaveSettingsPreservesUnknownTopLevelFields(t *testing.T) { if !strings.Contains(string(raw["futureFeature"]), `"mode": "next"`) { t.Fatalf("unknown field value changed: %s", string(raw["futureFeature"])) } + for _, key := range []string{"codexAppPluginEntryUnlock", "codexAppForcePluginInstall"} { + if string(raw[key]) != "true" { + t.Fatalf("deprecated unknown field %s should remain byte-compatible, got %s", key, raw[key]) + } + } } func TestSaveSettingsPersistsOnboardingCompletion(t *testing.T) { @@ -2398,11 +2422,12 @@ func TestRepairCodexPluginConfigRestoresCachedPluginTables(t *testing.T) { if pluginCount != 2 { t.Fatalf("plugin count mismatch: %d", pluginCount) } - if marketplaceCount != 1 { + if marketplaceCount != 2 { t.Fatalf("marketplace count mismatch: %d", marketplaceCount) } for _, expected := range []string{ `[marketplaces.openai-curated]`, + `[marketplaces.openai-api-curated]`, `source = "` + filepath.Join(home, ".tmp", "plugins") + `"`, `[plugins."browser@openai-bundled"]`, `[plugins."github@openai-curated"]`, @@ -2430,7 +2455,7 @@ func TestRepairCodexPluginConfigCorrectsMarketplaceSource(t *testing.T) { ``, }, "\n")) - if marketplaceCount != 1 { + if marketplaceCount != 2 { t.Fatalf("marketplace count mismatch: %d", marketplaceCount) } if strings.Contains(updated, `/stale/plugins`) { diff --git a/manager.go b/manager.go index bfc6e05..b19221e 100644 --- a/manager.go +++ b/manager.go @@ -218,6 +218,10 @@ func (s *server) dispatch(ctx context.Context, command string, args map[string]a return s.pluginMarketplaceStatus() case "repair_plugin_marketplace": return s.repairPluginMarketplace(ctx) + case "remote_plugin_marketplace_status": + return s.remotePluginMarketplaceStatus() + case "repair_remote_plugin_marketplace": + return s.repairRemotePluginMarketplace() case "repair_codex_goals": return s.repairCodexGoals() case "load_computer_use_status": diff --git a/model_catalog.go b/model_catalog.go index a26eb08..68b0cb1 100644 --- a/model_catalog.go +++ b/model_catalog.go @@ -449,6 +449,9 @@ func modelsEndpoint(baseURL string) string { if cleaned == "" { return "" } + if strings.HasSuffix(strings.ToLower(cleaned), "/chat/completions") { + cleaned = cleaned[:len(cleaned)-len("/chat/completions")] + } if strings.HasSuffix(cleaned, "/models") { return cleaned } diff --git a/plugin_marketplace.go b/plugin_marketplace.go index 8bc9da9..6d14abe 100644 --- a/plugin_marketplace.go +++ b/plugin_marketplace.go @@ -4,6 +4,7 @@ import ( "archive/zip" "bytes" "context" + _ "embed" "encoding/json" "errors" "fmt" @@ -15,10 +16,16 @@ import ( "time" ) +//go:embed assets/plugin-marketplaces/openai-curated-remote.zip +var openAICuratedRemoteMarketplaceZip []byte + const ( - openAICuratedMarketplaceName = "openai-curated" - openAIPluginsZipURL = "https://codeload.github.com/openai/plugins/zip/refs/heads/main" - openAIPluginsDownloadLimitBytes = 128 * 1024 * 1024 + openAICuratedMarketplaceName = "openai-curated" + openAIAPICuratedMarketplaceName = "openai-api-curated" + openAICuratedRemoteMarketplaceName = "openai-curated-remote" + roleSpecificMarketplaceName = "role-specific-plugins" + openAIPluginsZipURL = "https://codeload.github.com/openai/plugins/zip/refs/heads/main" + openAIPluginsDownloadLimitBytes = 128 * 1024 * 1024 ) type pluginMarketplaceStatus struct { @@ -36,6 +43,15 @@ type pluginMarketplaceRepairPayload struct { NeedsRepair bool `json:"needsRepair"` } +type remotePluginMarketplacePayload struct { + CodexHome string `json:"codexHome"` + MarketplaceRoot *string `json:"marketplaceRoot,omitempty"` + ConfigRegistered bool `json:"configRegistered"` + NeedsRepair bool `json:"needsRepair"` + PluginCount int `json:"pluginCount"` + SkillCount int `json:"skillCount"` +} + func (s *server) pluginMarketplaceStatus() commandResult { status := openAICuratedMarketplaceStatus(codexHomeDir()) message := "插件市场已可用。" @@ -94,6 +110,70 @@ func (s *server) repairPluginMarketplace(ctx context.Context) commandResult { }) } +func (s *server) remotePluginMarketplaceStatus() commandResult { + payload := currentRemotePluginMarketplacePayload(codexHomeDir()) + message := "官方远端插件缓存已可用。" + if payload.NeedsRepair { + message = "官方远端插件缓存需要释放或注册。" + } + return ok(message, remotePluginMarketplaceValue(payload)) +} + +func (s *server) repairRemotePluginMarketplace() commandResult { + home := codexHomeDir() + initialized := false + if localNamedPluginMarketplaceRoot(filepath.Join(home, ".tmp", "plugins-remote"), openAICuratedRemoteMarketplaceName) == "" { + if err := installOpenAICuratedRemoteMarketplaceZip(home, openAICuratedRemoteMarketplaceZip); err != nil { + return failed("官方远端插件缓存修复失败:"+err.Error(), remotePluginMarketplaceValue(currentRemotePluginMarketplacePayload(home))) + } + initialized = true + } + configured, err := ensureOpenAICuratedMarketplaceConfig(home) + if err != nil { + return failed("官方远端插件缓存修复失败:"+err.Error(), remotePluginMarketplaceValue(currentRemotePluginMarketplacePayload(home))) + } + payload := currentRemotePluginMarketplacePayload(home) + message := "官方远端插件缓存已可用,无需修复。" + if initialized { + message = "已释放并注册内置官方远端插件缓存。" + } else if configured { + message = "已注册官方远端插件缓存。" + } + return ok(message, remotePluginMarketplaceValue(payload)) +} + +func remotePluginMarketplaceValue(payload remotePluginMarketplacePayload) map[string]any { + return map[string]any{ + "codexHome": payload.CodexHome, + "marketplaceRoot": nullableStringPtr(payload.MarketplaceRoot), + "configRegistered": payload.ConfigRegistered, + "needsRepair": payload.NeedsRepair, + "pluginCount": payload.PluginCount, + "skillCount": payload.SkillCount, + } +} + +func currentRemotePluginMarketplacePayload(home string) remotePluginMarketplacePayload { + root := localNamedPluginMarketplaceRoot(filepath.Join(home, ".tmp", "plugins-remote"), openAICuratedRemoteMarketplaceName) + var rootPtr *string + pluginCount, skillCount := 0, 0 + if root != "" { + rootPtr = &root + pluginCount = len(localMarketplacePluginNames(root, openAICuratedRemoteMarketplaceName)) + for _, name := range localMarketplacePluginNames(root, openAICuratedRemoteMarketplaceName) { + skillRoot := filepath.Join(root, "plugins", name, "skills") + entries, _ := os.ReadDir(skillRoot) + for _, entry := range entries { + if entry.IsDir() && fileExists(filepath.Join(skillRoot, entry.Name(), "SKILL.md")) { + skillCount++ + } + } + } + } + registered := root != "" && marketplaceConfigPointsToRoot(home, openAICuratedRemoteMarketplaceName, root) + return remotePluginMarketplacePayload{CodexHome: home, MarketplaceRoot: rootPtr, ConfigRegistered: registered, NeedsRepair: root == "" || !registered, PluginCount: pluginCount, SkillCount: skillCount} +} + func openAICuratedMarketplaceStatus(home string) pluginMarketplaceStatus { root := localOpenAICuratedMarketplaceRoot(home) var rootPtr *string @@ -137,13 +217,26 @@ func marketplaceConfigPointsToRoot(home, name, root string) bool { } func ensureOpenAICuratedMarketplaceConfig(home string) (bool, error) { - root := localOpenAICuratedMarketplaceRoot(home) - if root == "" { - return false, nil - } path := filepath.Join(home, "config.toml") contents := readFile(path) - updated := repairCodexMarketplaceTable(contents, marketplaceSpec{Name: openAICuratedMarketplaceName, Source: root}) + updated := contents + if root := localOpenAICuratedMarketplaceRoot(home); root != "" { + for _, name := range []string{openAICuratedMarketplaceName, openAIAPICuratedMarketplaceName} { + updated = repairCodexMarketplaceTable(updated, marketplaceSpec{Name: name, Source: root}) + } + } + if root := localNamedPluginMarketplaceRoot(filepath.Join(home, ".tmp", "plugins-remote"), openAICuratedRemoteMarketplaceName); root != "" { + updated = repairCodexMarketplaceTable(updated, marketplaceSpec{Name: openAICuratedRemoteMarketplaceName, Source: root}) + } + if root := localNamedPluginMarketplaceRoot(filepath.Join(home, ".tmp", "marketplaces", roleSpecificMarketplaceName), roleSpecificMarketplaceName); root != "" { + updated = repairCodexMarketplaceTable(updated, marketplaceSpec{Name: roleSpecificMarketplaceName, Source: root}) + for _, pluginName := range localMarketplacePluginNames(root, roleSpecificMarketplaceName) { + table := "plugins." + quoteToml(pluginName+"@"+roleSpecificMarketplaceName) + if !hasTable(updated, table) { + updated = appendTomlBlock(updated, []string{"[" + table + "]", "enabled = true"}) + } + } + } if updated == contents { return false, nil } @@ -153,6 +246,149 @@ func ensureOpenAICuratedMarketplaceConfig(home string) (bool, error) { return true, nil } +func localNamedPluginMarketplaceRoot(root, expectedName string) string { + manifestPath := filepath.Join(root, ".agents", "plugins", "marketplace.json") + data, err := os.ReadFile(manifestPath) + if err != nil { + return "" + } + var manifest map[string]any + if json.Unmarshal(data, &manifest) != nil || stringFromAny(manifest["name"]) != expectedName { + return "" + } + plugins, _ := manifest["plugins"].([]any) + if len(plugins) == 0 || !isDir(filepath.Join(root, "plugins")) { + return "" + } + return root +} + +func localMarketplacePluginNames(root, expectedName string) []string { + data, err := os.ReadFile(filepath.Join(root, ".agents", "plugins", "marketplace.json")) + if err != nil { + return nil + } + var manifest map[string]any + if json.Unmarshal(data, &manifest) != nil || stringFromAny(manifest["name"]) != expectedName { + return nil + } + var names []string + for _, raw := range anySlice(manifest["plugins"]) { + plugin, _ := raw.(map[string]any) + if name := strings.TrimSpace(stringFromAny(plugin["name"])); name != "" { + names = append(names, name) + } + } + return names +} + +func localPluginMarketplaces(home string) []any { + marketplaceDir := filepath.Join(home, ".tmp", "plugins", ".agents", "plugins") + candidates := []string{ + filepath.Join(marketplaceDir, "marketplace.json"), + filepath.Join(marketplaceDir, "api_marketplace.json"), + filepath.Join(home, ".tmp", "plugins-remote", ".agents", "plugins", "marketplace.json"), + } + installed := installedPluginIDs(readFile(filepath.Join(home, "config.toml"))) + marketplaces := make([]any, 0, len(candidates)) + for _, path := range candidates { + data, err := os.ReadFile(path) + if err != nil { + continue + } + var marketplace map[string]any + if json.Unmarshal(data, &marketplace) != nil || strings.TrimSpace(stringFromAny(marketplace["name"])) == "" { + continue + } + expandLocalPluginMarketplace(marketplace, path, installed) + if marketplace["path"] == nil { + marketplace["path"] = path + } + marketplaces = append(marketplaces, marketplace) + } + return marketplaces +} + +func installedPluginIDs(config string) map[string]bool { + installed := map[string]bool{} + for _, line := range splitLines(config) { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, "[plugins.") || !strings.HasSuffix(trimmed, "]") { + continue + } + table := strings.TrimSuffix(strings.TrimPrefix(trimmed, "["), "]") + values := tableValues(config, table) + if strings.EqualFold(strings.TrimSpace(values["enabled"]), "true") { + id := strings.TrimPrefix(table, "plugins.") + installed[strings.TrimSpace(unquoteToml(id))] = true + } + } + return installed +} + +func expandLocalPluginMarketplace(marketplace map[string]any, marketplacePath string, installed map[string]bool) { + marketplaceName := stringFromAny(marketplace["name"]) + plugins, _ := marketplace["plugins"].([]any) + marketplaceRoot := filepath.Dir(filepath.Dir(filepath.Dir(marketplacePath))) + for _, raw := range plugins { + plugin, _ := raw.(map[string]any) + if plugin == nil { + continue + } + name := strings.TrimSpace(firstNonEmpty(stringFromAny(plugin["name"]), strings.SplitN(stringFromAny(plugin["id"]), "@", 2)[0])) + if name == "" { + continue + } + pluginRoot := filepath.Join(marketplaceRoot, "plugins", name) + if data, err := os.ReadFile(filepath.Join(pluginRoot, ".codex-plugin", "plugin.json")); err == nil { + var manifest map[string]any + if json.Unmarshal(data, &manifest) == nil { + for key, value := range manifest { + if plugin[key] == nil { + plugin[key] = value + } + } + } + } + absolutizePluginAssetPaths(plugin, pluginRoot) + if plugin["name"] == nil { + plugin["name"] = name + } + if plugin["id"] == nil { + plugin["id"] = name + "@" + marketplaceName + } + if plugin["marketplaceName"] == nil { + plugin["marketplaceName"] = marketplaceName + } + if plugin["marketplacePath"] == nil { + plugin["marketplacePath"] = marketplaceName + } + if plugin["keywords"] == nil { + plugin["keywords"] = []any{} + } + plugin["installed"] = installed[name+"@"+marketplaceName] + } +} + +func absolutizePluginAssetPaths(plugin map[string]any, root string) { + for _, key := range []string{"composerIconPath", "logoPath"} { + absolutizePluginAssetPath(plugin, key, root) + } + if pluginInterface, ok := plugin["interface"].(map[string]any); ok { + for _, key := range []string{"composerIcon", "composerIconPath", "logo", "logoPath"} { + absolutizePluginAssetPath(pluginInterface, key, root) + } + } +} + +func absolutizePluginAssetPath(object map[string]any, key, root string) { + value := strings.TrimSpace(stringFromAny(object[key])) + if value == "" || filepath.IsAbs(value) || strings.HasPrefix(value, "data:") || strings.HasPrefix(value, "http:") || strings.HasPrefix(value, "https:") || strings.HasPrefix(value, "file:") { + return + } + object[key] = filepath.Join(root, strings.TrimPrefix(filepath.FromSlash(value), "."+string(filepath.Separator))) +} + func initializeOpenAICuratedMarketplaceFromGitHub(ctx context.Context, home string) error { bytes, err := downloadOpenAIPluginsZip(ctx) if err != nil { @@ -212,10 +448,86 @@ func installOpenAIPluginsZip(home string, data []byte) error { if err := replaceDirectory(destination, staging); err != nil { return err } + if err := os.RemoveAll(staging); err != nil { + return err + } cleanup = false return nil } +func installOpenAICuratedRemoteMarketplaceZip(home string, data []byte) error { + if len(data) == 0 { + return errors.New("embedded official remote plugin marketplace is empty") + } + destination := filepath.Join(home, ".tmp", "plugins-remote") + stagingParent := filepath.Join(home, ".tmp") + if err := os.MkdirAll(stagingParent, 0o755); err != nil { + return err + } + staging := filepath.Join(stagingParent, fmt.Sprintf("plugins-remote-embedded-%d", time.Now().UnixMilli())) + if err := os.MkdirAll(staging, 0o755); err != nil { + return err + } + defer os.RemoveAll(staging) + if err := extractPluginMarketplaceZipExact(data, staging); err != nil { + return err + } + if localNamedPluginMarketplaceRoot(staging, openAICuratedRemoteMarketplaceName) == "" { + return errors.New("embedded official remote plugin marketplace is invalid") + } + return replaceDirectory(destination, staging) +} + +func extractPluginMarketplaceZipExact(data []byte, destination string) error { + reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return err + } + for _, file := range reader.File { + relative, err := safePluginMarketplaceZipPath(file.Name) + if err != nil { + return err + } + target := filepath.Join(destination, relative) + if file.FileInfo().IsDir() { + if err := os.MkdirAll(target, 0o755); err != nil { + return err + } + continue + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + input, err := file.Open() + if err != nil { + return err + } + writeErr := writeZipFile(target, input, file.Mode().Perm()) + closeErr := input.Close() + if writeErr != nil { + return writeErr + } + if closeErr != nil { + return closeErr + } + } + return nil +} + +func safePluginMarketplaceZipPath(name string) (string, error) { + name = filepath.ToSlash(strings.TrimSpace(name)) + if name == "" || strings.HasPrefix(name, "/") { + return "", fmt.Errorf("zip entry has unsafe path: %q", name) + } + parts := strings.Split(name, "/") + for _, part := range parts { + if part == "" || part == "." || part == ".." { + return "", fmt.Errorf("zip entry escapes destination: %q", name) + } + } + return filepath.Join(parts...), nil +} + func extractOpenAIPluginsZip(data []byte, destination string) error { reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) if err != nil { diff --git a/plugin_marketplace_parity_test.go b/plugin_marketplace_parity_test.go new file mode 100644 index 0000000..bdc5ef2 --- /dev/null +++ b/plugin_marketplace_parity_test.go @@ -0,0 +1,121 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestMarketplaceConfigAndInjectedSnapshotsMatchUpstreamStrategy(t *testing.T) { + home := t.TempDir() + curatedRoot := filepath.Join(home, ".tmp", "plugins") + remoteRoot := filepath.Join(home, ".tmp", "plugins-remote") + roleRoot := filepath.Join(home, ".tmp", "marketplaces", roleSpecificMarketplaceName) + + writeMarketplaceFixture(t, curatedRoot, "marketplace.json", `{"name":"openai-curated","plugins":[{"name":"gmail"}]}`) + writeMarketplaceFixture(t, curatedRoot, "api_marketplace.json", `{"name":"openai-api-curated","plugins":[{"name":"build-web-apps"}]}`) + writeMarketplaceFixture(t, remoteRoot, "marketplace.json", `{"name":"openai-curated-remote","plugins":[{"name":"product-design"}]}`) + writeMarketplaceFixture(t, roleRoot, "marketplace.json", `{"name":"role-specific-plugins","plugins":[{"name":"sales"},{"name":"data-analytics"}]}`) + writeTestFile(t, filepath.Join(curatedRoot, "plugins", "gmail", ".codex-plugin", "plugin.json"), `{"interface":{"displayName":"Gmail","composerIcon":"./icon.png"}}`) + for _, fixture := range []struct{ root, name string }{{curatedRoot, "build-web-apps"}, {remoteRoot, "product-design"}, {roleRoot, "sales"}, {roleRoot, "data-analytics"}} { + if err := os.MkdirAll(filepath.Join(fixture.root, "plugins", fixture.name), 0o755); err != nil { + t.Fatal(err) + } + } + writeTestFile(t, filepath.Join(home, "config.toml"), `[plugins."gmail@openai-curated"] +enabled = true + +[plugins."sales@role-specific-plugins"] +enabled = false +`) + + changed, err := ensureOpenAICuratedMarketplaceConfig(home) + if err != nil || !changed { + t.Fatalf("ensure marketplace config: changed=%v err=%v", changed, err) + } + config := readFile(filepath.Join(home, "config.toml")) + for _, table := range []string{"marketplaces.openai-curated", "marketplaces.openai-api-curated", "marketplaces.openai-curated-remote", "marketplaces.role-specific-plugins", `plugins."data-analytics@role-specific-plugins"`} { + if !hasTable(config, table) { + t.Fatalf("missing config table [%s]:\n%s", table, config) + } + } + if tableValues(config, `plugins."sales@role-specific-plugins"`)["enabled"] != "false" { + t.Fatalf("explicit disabled role plugin was overwritten:\n%s", config) + } + + marketplaces := localPluginMarketplaces(home) + if len(marketplaces) != 3 { + t.Fatalf("marketplace snapshot count = %d, want 3: %#v", len(marketplaces), marketplaces) + } + curated := marketplaces[0].(map[string]any) + gmail := curated["plugins"].([]any)[0].(map[string]any) + if gmail["installed"] != true || gmail["marketplaceName"] != "openai-curated" || gmail["id"] != "gmail@openai-curated" { + t.Fatalf("expanded plugin metadata mismatch: %#v", gmail) + } + pluginInterface := gmail["interface"].(map[string]any) + wantIcon := filepath.Join(curatedRoot, "plugins", "gmail", "icon.png") + if pluginInterface["composerIcon"] != wantIcon { + t.Fatalf("absolute icon path = %q, want %q", pluginInterface["composerIcon"], wantIcon) + } + + t.Setenv("CODEX_HOME", home) + script := injectionScript(57321, defaultSettings()) + for _, marker := range []string{"window.__CODEX_PLUS_PLUGIN_MARKETPLACES__", `"openai-api-curated"`, "mergeLocalPluginMarketplaces", "plugin_marketplace_local_merged"} { + if !strings.Contains(script, marker) { + t.Fatalf("injection script missing %q", marker) + } + } +} + +func TestMarketplaceConfigIsStableAfterFirstRepair(t *testing.T) { + home := t.TempDir() + root := filepath.Join(home, ".tmp", "plugins") + writeMarketplaceFixture(t, root, "marketplace.json", `{"name":"openai-curated","plugins":[{"name":"gmail"}]}`) + if err := os.MkdirAll(filepath.Join(root, "plugins", "gmail"), 0o755); err != nil { + t.Fatal(err) + } + if changed, err := ensureOpenAICuratedMarketplaceConfig(home); err != nil || !changed { + t.Fatalf("first repair: changed=%v err=%v", changed, err) + } + if changed, err := ensureOpenAICuratedMarketplaceConfig(home); err != nil || changed { + t.Fatalf("second repair should be stable: changed=%v err=%v", changed, err) + } +} + +func TestEmbeddedRemoteMarketplaceInstallsAndRegisters(t *testing.T) { + home := t.TempDir() + if len(openAICuratedRemoteMarketplaceZip) < 1024 { + t.Fatalf("embedded remote marketplace payload is unexpectedly small: %d", len(openAICuratedRemoteMarketplaceZip)) + } + if err := installOpenAICuratedRemoteMarketplaceZip(home, openAICuratedRemoteMarketplaceZip); err != nil { + t.Fatalf("install embedded marketplace: %v", err) + } + payload := currentRemotePluginMarketplacePayload(home) + if payload.MarketplaceRoot == nil || payload.PluginCount == 0 || !payload.NeedsRepair { + t.Fatalf("unexpected pre-registration status: %#v", payload) + } + if changed, err := ensureOpenAICuratedMarketplaceConfig(home); err != nil || !changed { + t.Fatalf("register embedded marketplace: changed=%v err=%v", changed, err) + } + payload = currentRemotePluginMarketplacePayload(home) + if !payload.ConfigRegistered || payload.NeedsRepair || payload.PluginCount == 0 { + t.Fatalf("unexpected registered status: %#v", payload) + } +} + +func TestPluginMarketplaceZipPathRejectsTraversal(t *testing.T) { + for _, path := range []string{"../escape", "/absolute", "plugins/../../escape", "plugins//escape", ""} { + if _, err := safePluginMarketplaceZipPath(path); err == nil { + t.Fatalf("unsafe zip path accepted: %q", path) + } + } + if got, err := safePluginMarketplaceZipPath("plugins/example/file.txt"); err != nil || got != filepath.Join("plugins", "example", "file.txt") { + t.Fatalf("safe zip path = %q, %v", got, err) + } +} + +func writeMarketplaceFixture(t *testing.T, root, filename, contents string) { + t.Helper() + writeTestFile(t, filepath.Join(root, ".agents", "plugins", filename), contents) +} diff --git a/protocol_proxy.go b/protocol_proxy.go new file mode 100644 index 0000000..42a82d4 --- /dev/null +++ b/protocol_proxy.go @@ -0,0 +1,1029 @@ +package main + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "sort" + "strconv" + "strings" +) + +type protocolProxyContext struct { + OriginalRequest map[string]any + Converted bool + Stream bool +} + +func convertResponsesRequestForProfile(profile relayProfile, path string, body []byte) ([]byte, protocolProxyContext, error) { + ctx := protocolProxyContext{} + if profile.Protocol != "chatCompletions" || !isResponsesProxyPath(path) { + return body, ctx, nil + } + var request map[string]any + if err := json.Unmarshal(body, &request); err != nil { + return nil, ctx, fmt.Errorf("invalid Responses request: %w", err) + } + converted, err := responsesToChatCompletions(request) + if err != nil { + return nil, ctx, err + } + data, err := json.Marshal(converted) + if err != nil { + return nil, ctx, err + } + ctx.OriginalRequest = request + ctx.Converted = true + ctx.Stream = boolFromAny(request["stream"]) + return data, ctx, nil +} + +func isResponsesProxyPath(path string) bool { + path = strings.SplitN(path, "?", 2)[0] + switch path { + case "/responses", "/v1/responses", "/v1/v1/responses", "/codex/v1/responses": + return true + default: + return false + } +} + +func protocolProxyTargetURL(profile relayProfile, requestPath string, converted bool) string { + if converted { + return chatCompletionsURL(effectiveUpstreamBaseURL(profile)) + } + if isModelsProxyPath(requestPath) { + return modelsEndpoint(effectiveUpstreamBaseURL(profile)) + } + return relayTargetURL(relayProxyBaseURL(effectiveUpstreamBaseURL(profile), profile.Protocol), requestPath) +} + +func isModelsProxyPath(path string) bool { + path = strings.SplitN(path, "?", 2)[0] + switch path { + case "/models", "/v1/models", "/v1/v1/models", "/codex/v1/models": + return true + default: + return false + } +} + +func chatCompletionsURL(baseURL string) string { + base := strings.TrimRight(strings.TrimSpace(baseURL), "/") + if strings.HasSuffix(strings.ToLower(base), "/chat/completions") { + return base + } + originOnly := false + if parsed, err := http.NewRequest(http.MethodGet, base, nil); err == nil && parsed.URL != nil { + originOnly = parsed.URL.Path == "" || parsed.URL.Path == "/" + } + if originOnly { + base += "/v1" + } + for strings.Contains(base, "/v1/v1") { + base = strings.ReplaceAll(base, "/v1/v1", "/v1") + } + return base + "/chat/completions" +} + +func responsesToChatCompletions(body map[string]any) (map[string]any, error) { + result := map[string]any{} + copyIfPresent(result, body, "model") + messages := make([]any, 0) + if instructions := responseText(body["instructions"]); instructions != "" { + messages = append(messages, map[string]any{"role": "system", "content": instructions}) + } + appendResponsesInput(body["input"], &messages) + result["messages"] = collapseSystemMessages(messages) + + model := strings.ToLower(stringFromAny(body["model"])) + if value, ok := body["max_output_tokens"]; ok { + if strings.HasPrefix(model, "o1") || strings.HasPrefix(model, "o3") || strings.HasPrefix(model, "o4") { + result["max_completion_tokens"] = value + } else { + result["max_tokens"] = value + } + } + for _, key := range []string{"max_tokens", "max_completion_tokens", "temperature", "top_p", "stream", "frequency_penalty", "logit_bias", "logprobs", "metadata", "n", "presence_penalty", "response_format", "seed", "service_tier", "stop", "top_logprobs", "user"} { + copyIfPresent(result, body, key) + } + if boolFromAny(body["stream"]) { + streamOptions, _ := body["stream_options"].(map[string]any) + streamOptions = cloneMap(streamOptions) + streamOptions["include_usage"] = true + result["stream_options"] = streamOptions + } else { + copyIfPresent(result, body, "stream_options") + } + applyChatReasoningOptions(result, body, model) + toolContext := buildProtocolToolContext(body["tools"]) + tools := convertResponsesToolsToChat(body["tools"], toolContext) + if len(tools) > 0 { + result["tools"] = tools + if choice := convertResponsesToolChoice(body["tool_choice"], toolContext); choice != nil { + result["tool_choice"] = choice + } + copyIfPresent(result, body, "parallel_tool_calls") + } + return result, nil +} + +func appendResponsesInput(input any, messages *[]any) { + switch value := input.(type) { + case string: + *messages = append(*messages, map[string]any{"role": "user", "content": value}) + case []any: + pendingCalls := make([]any, 0) + pendingReasoning := make([]string, 0) + seenCallIDs := map[string]bool{} + flushCalls := func() { + if len(pendingCalls) == 0 { + return + } + message := map[string]any{"role": "assistant", "content": "", "tool_calls": pendingCalls} + if len(pendingReasoning) > 0 { + message["reasoning_content"] = strings.Join(pendingReasoning, "\n") + pendingReasoning = nil + } + *messages = append(*messages, message) + pendingCalls = nil + } + flushReasoning := func() { + if len(pendingReasoning) == 0 { + return + } + *messages = append(*messages, map[string]any{"role": "assistant", "content": "", "reasoning_content": strings.Join(pendingReasoning, "\n")}) + pendingReasoning = nil + } + appendOutput := func(callID string, output any) { + if callID == "" { + return + } + flushCalls() + if !seenCallIDs[callID] { + flushReasoning() + *messages = append(*messages, map[string]any{"role": "user", "content": fmt.Sprintf("Function call output (%s): %s", callID, responseText(output))}) + return + } + *messages = append(*messages, map[string]any{"role": "tool", "tool_call_id": callID, "content": responseText(output)}) + } + for _, raw := range value { + item, ok := raw.(map[string]any) + if !ok { + continue + } + typeName := strings.TrimSpace(stringFromAny(item["type"])) + switch typeName { + case "function_call": + callID := firstString(stringFromAny(item["call_id"]), stringFromAny(item["id"])) + name := flattenProtocolToolName(stringFromAny(item["namespace"]), stringFromAny(item["name"])) + if callID == "" || name == "" { + continue + } + seenCallIDs[callID] = true + pendingCalls = append(pendingCalls, map[string]any{"id": callID, "type": "function", "function": map[string]any{"name": name, "arguments": normalizeChatToolArguments(item["arguments"])}}) + case "custom_tool_call": + callID := firstString(stringFromAny(item["call_id"]), stringFromAny(item["id"])) + if callID == "" { + continue + } + name, arguments := buildCustomToolHistory(stringFromAny(item["name"]), firstNonNil(item["input"], item["arguments"])) + seenCallIDs[callID] = true + pendingCalls = append(pendingCalls, map[string]any{"id": callID, "type": "function", "function": map[string]any{"name": name, "arguments": arguments}}) + case "function_call_output", "custom_tool_call_output": + appendOutput(firstString(stringFromAny(item["call_id"]), stringFromAny(item["id"])), item["output"]) + case "tool_call": + toolUse, _ := item["tool_use"].(map[string]any) + callID := firstString(stringFromAny(toolUse["id"]), stringFromAny(item["call_id"]), stringFromAny(item["id"])) + name := stringFromAny(toolUse["name"]) + if callID == "" || name == "" { + continue + } + seenCallIDs[callID] = true + pendingCalls = append(pendingCalls, map[string]any{"id": callID, "type": "function", "function": map[string]any{"name": name, "arguments": normalizeChatToolArguments(toolUse["input"])}}) + case "tool_result": + content, _ := item["content"].(map[string]any) + callID := firstString(stringFromAny(content["tool_use_id"]), stringFromAny(item["tool_call_id"]), stringFromAny(item["call_id"])) + output := item["content"] + if content != nil && content["content"] != nil { + output = content["content"] + } + appendOutput(callID, output) + case "reasoning": + text := reasoningText(item) + if text != "" { + pendingReasoning = append(pendingReasoning, text) + } + default: + flushCalls() + role := stringFromAny(item["role"]) + if role == "" && item["content"] == nil { + continue + } + if role == "" { + role = "user" + } + message := map[string]any{"role": role, "content": responsesContentToChat(item["content"])} + if role == "assistant" && len(pendingReasoning) > 0 { + message["reasoning_content"] = strings.Join(pendingReasoning, "\n") + pendingReasoning = nil + } else if role != "assistant" { + flushReasoning() + } + *messages = append(*messages, message) + } + } + flushCalls() + flushReasoning() + case map[string]any: + appendResponsesInput([]any{value}, messages) + } +} + +func responsesContentToChat(value any) any { + switch content := value.(type) { + case string: + return content + case []any: + parts := make([]any, 0, len(content)) + for _, raw := range content { + part, ok := raw.(map[string]any) + if !ok { + continue + } + switch stringFromAny(part["type"]) { + case "input_text", "output_text", "text": + parts = append(parts, map[string]any{"type": "text", "text": responseText(firstNonNil(part["text"], part["content"]))}) + case "input_image", "image_url": + imageURL := firstNonEmpty(stringFromAny(part["image_url"]), stringFromAny(part["url"])) + if imageURL != "" { + parts = append(parts, map[string]any{"type": "image_url", "image_url": map[string]any{"url": imageURL}}) + } + } + } + if len(parts) == 1 { + if part, ok := parts[0].(map[string]any); ok && stringFromAny(part["type"]) == "text" { + return stringFromAny(part["text"]) + } + } + return parts + default: + return responseText(value) + } +} + +func collapseSystemMessages(messages []any) []any { + var systems []string + var rest []any + for _, raw := range messages { + message, _ := raw.(map[string]any) + if stringFromAny(message["role"]) == "system" { + if text := responseText(message["content"]); text != "" { + systems = append(systems, text) + } + continue + } + rest = append(rest, raw) + } + if len(systems) > 0 { + rest = append([]any{map[string]any{"role": "system", "content": strings.Join(systems, "\n\n")}}, rest...) + } + return rest +} + +func responsesToolsToChat(raw any) ([]any, map[string]string) { + items, _ := raw.([]any) + tools := make([]any, 0, len(items)) + names := map[string]string{} + for index, rawTool := range items { + tool, ok := rawTool.(map[string]any) + if !ok { + continue + } + typeName := stringFromAny(tool["type"]) + if typeName != "function" && typeName != "custom" { + continue + } + name := flattenedToolName(tool) + if name == "" { + name = fmt.Sprintf("tool_%d", index) + } + names[name] = stringFromAny(tool["name"]) + parameters, _ := tool["parameters"].(map[string]any) + if parameters == nil { + parameters = map[string]any{"type": "object", "properties": map[string]any{}} + } + if typeName == "custom" { + parameters = map[string]any{"type": "object", "properties": map[string]any{"input": map[string]any{"type": "string"}}, "required": []any{"input"}} + } + tools = append(tools, map[string]any{"type": "function", "function": map[string]any{ + "name": name, "description": stringFromAny(tool["description"]), "parameters": parameters, + }}) + } + return tools, names +} + +func responsesToolChoiceToChat(raw any, names map[string]string) any { + switch value := raw.(type) { + case string: + switch value { + case "auto", "none", "required": + return value + } + case map[string]any: + name := flattenedToolName(value) + if name == "" { + name = stringFromAny(value["name"]) + } + if name != "" { + return map[string]any{"type": "function", "function": map[string]any{"name": name}} + } + } + return nil +} + +func applyChatReasoningOptions(result, body map[string]any, model string) { + reasoning, _ := body["reasoning"].(map[string]any) + effort := firstNonEmpty(stringFromAny(reasoning["effort"]), stringFromAny(body["reasoning_effort"])) + if effort == "" { + return + } + result["reasoning_effort"] = effort + if strings.Contains(model, "deepseek") || strings.Contains(model, "qwen") { + result["enable_thinking"] = effort != "none" && effort != "minimal" + } +} + +func chatCompletionToResponse(body map[string]any, original map[string]any) (map[string]any, error) { + choices, _ := body["choices"].([]any) + if len(choices) == 0 { + return nil, errors.New("chat response missing choices") + } + choice, _ := choices[0].(map[string]any) + message, _ := choice["message"].(map[string]any) + if message == nil { + return nil, errors.New("chat response choice missing message") + } + responseID := responseIDFromChat(stringFromAny(body["id"])) + output := make([]any, 0) + if reasoning := reasoningText(message); reasoning != "" { + output = append(output, reasoningOutputItem(responseID, reasoning)) + } + if content := chatMessageOutputItem(responseID, message); content != nil { + output = append(output, content) + } + toolContext := buildProtocolToolContext(original["tools"]) + output = append(output, chatToolCallOutputItems(message, toolContext)...) + finishReason := stringFromAny(choice["finish_reason"]) + status := "completed" + if finishReason == "length" { + status = "incomplete" + } + response := map[string]any{ + "id": responseID, "object": "response", "created_at": uint64FromAny(body["created"], 0), + "status": status, "model": stringFromAny(body["model"]), "output": output, + "usage": chatUsageToResponses(body["usage"]), + } + if status == "incomplete" { + response["incomplete_details"] = map[string]any{"reason": "max_output_tokens"} + } + copyResponseRequestFields(response, original) + return response, nil +} + +func chatMessageOutputItem(responseID string, message map[string]any) any { + var content []any + switch raw := message["content"].(type) { + case string: + _, answer := splitLeadingThinkBlock(raw) + if answer == "" && !strings.Contains(raw, "") { + answer = raw + } + if answer != "" { + content = append(content, map[string]any{"type": "output_text", "text": answer, "annotations": []any{}}) + } + case []any: + for _, rawPart := range raw { + part, _ := rawPart.(map[string]any) + typeName := stringFromAny(part["type"]) + if typeName == "text" || typeName == "output_text" { + content = append(content, map[string]any{"type": "output_text", "text": stringFromAny(part["text"]), "annotations": []any{}}) + } + } + } + if refusal := stringFromAny(message["refusal"]); refusal != "" { + content = append(content, map[string]any{"type": "refusal", "refusal": refusal}) + } + if len(content) == 0 { + return nil + } + return map[string]any{"id": responseID + "_msg", "type": "message", "status": "completed", "role": "assistant", "content": content} +} + +func chatToolCallOutputItems(message map[string]any, toolContext protocolToolContext) []any { + var output []any + if calls, ok := message["tool_calls"].([]any); ok { + for index, raw := range calls { + call, _ := raw.(map[string]any) + function, _ := call["function"].(map[string]any) + callID := firstString(stringFromAny(call["id"]), fmt.Sprintf("call_%d", index)) + output = append(output, protocolResponseToolCall(callID, stringFromAny(function["name"]), normalizeChatToolArguments(function["arguments"]), toolContext)) + } + } else if function, ok := message["function_call"].(map[string]any); ok { + callID := firstString(stringFromAny(function["id"]), "call_0") + output = append(output, protocolResponseToolCall(callID, stringFromAny(function["name"]), normalizeChatToolArguments(function["arguments"]), toolContext)) + } + return output +} + +func reasoningOutputItem(responseID, text string) map[string]any { + return map[string]any{"id": "rs_" + responseID, "type": "reasoning", "reasoning_content": text, "summary": []any{map[string]any{"type": "summary_text", "text": text}}} +} + +func reasoningText(value map[string]any) string { + for _, key := range []string{"reasoning_content", "reasoning"} { + if text, ok := value[key].(string); ok && text != "" { + return text + } + } + if reasoning, ok := value["reasoning"].(map[string]any); ok { + for _, key := range []string{"content", "text", "summary"} { + if text := responseText(reasoning[key]); text != "" { + return text + } + } + } + if text := reasoningDetailsText(value["reasoning_details"]); text != "" { + return text + } + if text, ok := value["content"].(string); ok { + reasoning, _ := splitLeadingThinkBlock(text) + return reasoning + } + return "" +} + +func reasoningDetailsText(value any) string { + switch value := value.(type) { + case string: + return value + case []any: + parts := make([]string, 0, len(value)) + for _, part := range value { + if text := reasoningDetailsText(part); text != "" { + parts = append(parts, text) + } + } + return strings.Join(parts, "\n\n") + case map[string]any: + for _, key := range []string{"text", "content", "summary"} { + if text, ok := value[key].(string); ok && text != "" { + return text + } + } + return reasoningDetailsText(value["parts"]) + default: + return "" + } +} + +func chatUsageToResponses(raw any) map[string]any { + usage, _ := raw.(map[string]any) + input := uint64FromAny(firstNonNil(usage["prompt_tokens"], usage["input_tokens"], usage["promptTokenCount"]), 0) + inputIncludesCache := usage["prompt_tokens"] != nil + output := uint64FromAny(firstNonNil(usage["completion_tokens"], usage["output_tokens"], usage["candidatesTokenCount"]), 0) + cached := uint64(0) + if details, ok := usage["prompt_tokens_details"].(map[string]any); ok { + cached = uint64FromAny(details["cached_tokens"], 0) + } + if cached == 0 { + if details, ok := usage["input_tokens_details"].(map[string]any); ok { + cached = uint64FromAny(details["cached_tokens"], 0) + } + } + if usage["cachedContentTokenCount"] != nil { + cached = uint64FromAny(usage["cachedContentTokenCount"], 0) + } + cacheCreation := uint64FromAny(usage["cache_creation_input_tokens"], 0) + cacheCreation5m := uint64FromAny(usage["cache_creation_5m_input_tokens"], 0) + cacheCreation1h := uint64FromAny(usage["cache_creation_1h_input_tokens"], 0) + cacheCreated := cacheCreation + if cacheCreated == 0 { + cacheCreated = cacheCreation5m + cacheCreation1h + } + hasClaudeCache := usage["cache_read_input_tokens"] != nil || usage["cache_creation_input_tokens"] != nil || usage["cache_creation_5m_input_tokens"] != nil || usage["cache_creation_1h_input_tokens"] != nil + if usage["input_tokens"] != nil { + input = uint64FromAny(usage["input_tokens"], 0) + inputIncludesCache = false + } + if usage["cache_read_input_tokens"] != nil { + cached = uint64FromAny(usage["cache_read_input_tokens"], 0) + } + if usage["promptTokenCount"] != nil { + prompt := uint64FromAny(usage["promptTokenCount"], 0) + if cached > prompt { + cached = prompt + } + input = prompt - cached + inputIncludesCache = false + } + usageInput := input + if inputIncludesCache { + deduct := cached + cacheCreated + if deduct > usageInput { + usageInput = 0 + } else { + usageInput -= deduct + } + } + total := uint64FromAny(usage["total_tokens"], usageInput+output) + if usage["total_tokens"] == nil || cached > 0 || cacheCreated > 0 || usage["promptTokenCount"] != nil { + total = usageInput + output + cached + cacheCreated + } + result := map[string]any{"input_tokens": usageInput, "output_tokens": output, "total_tokens": total} + if cached > 0 && !hasClaudeCache { + result["input_tokens_details"] = map[string]any{"cached_tokens": cached} + } + if details, ok := usage["completion_tokens_details"]; ok { + result["output_tokens_details"] = details + } + for _, key := range []string{"cache_read_input_tokens", "cache_creation_input_tokens", "cache_creation_5m_input_tokens", "cache_creation_1h_input_tokens"} { + if usage[key] != nil { + result[key] = usage[key] + } + } + if cacheCreation5m > 0 && cacheCreation1h > 0 { + result["cache_ttl"] = "mixed" + } else if cacheCreation5m > 0 { + result["cache_ttl"] = "5m" + } else if cacheCreation1h > 0 { + result["cache_ttl"] = "1h" + } + return result +} + +func responsesErrorFromUpstream(status int, contentType string, body []byte) map[string]any { + errorObject := map[string]any{"message": fmt.Sprintf("Upstream returned HTTP %d", status), "type": "upstream_error", "code": strconv.Itoa(status)} + if strings.Contains(strings.ToLower(contentType), "json") { + var value map[string]any + if json.Unmarshal(body, &value) == nil { + source, _ := value["error"].(map[string]any) + if source == nil { + source = value + } + for _, key := range []string{"message", "type", "code", "param"} { + if source[key] != nil { + errorObject[key] = source[key] + } + } + } + } else if preview := strings.TrimSpace(string(body)); preview != "" { + runes := []rune(preview) + if len(runes) > 1024 { + runes = runes[:1024] + } + errorObject["message"] = string(runes) + } + return map[string]any{"error": errorObject} +} + +func writeProtocolProxyResponse(w http.ResponseWriter, resp *http.Response, ctx protocolProxyContext) (int64, error) { + contentType := resp.Header.Get("content-type") + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, err := io.ReadAll(io.LimitReader(resp.Body, 4*1024*1024)) + if err != nil { + return 0, err + } + payload, _ := json.Marshal(responsesErrorFromUpstream(resp.StatusCode, contentType, body)) + w.Header().Set("content-type", "application/json; charset=utf-8") + w.WriteHeader(resp.StatusCode) + n, err := w.Write(payload) + return int64(n), err + } + if !ctx.Converted { + if contentType != "" { + w.Header().Set("content-type", contentType) + } + w.WriteHeader(resp.StatusCode) + flushRelayResponseHeaders(w) + return copyRelayResponseBody(w, resp.Body) + } + if ctx.Stream || strings.Contains(strings.ToLower(contentType), "text/event-stream") { + w.Header().Set("content-type", "text/event-stream; charset=utf-8") + w.Header().Set("cache-control", "no-cache") + w.WriteHeader(http.StatusOK) + flushRelayResponseHeaders(w) + return streamChatSSEAsResponses(w, resp.Body, ctx.OriginalRequest) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 32*1024*1024)) + if err != nil { + return 0, err + } + var chat map[string]any + if err := json.Unmarshal(body, &chat); err != nil { + return 0, fmt.Errorf("invalid Chat Completions response: %w", err) + } + converted, err := chatCompletionToResponse(chat, ctx.OriginalRequest) + if err != nil { + return 0, err + } + payload, err := json.Marshal(converted) + if err != nil { + return 0, err + } + w.Header().Set("content-type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusOK) + n, err := w.Write(payload) + return int64(n), err +} + +type chatSSEState struct { + started bool + completed bool + responseID string + model string + createdAt uint64 + text strings.Builder + reasoning strings.Builder + textAdded bool + reasoningAdded bool + tools map[int]*chatSSETool + usage map[string]any + finishReason string + request map[string]any + toolContext protocolToolContext +} + +type chatSSETool struct { + Index int + Output int + CallID string + Name string + Arguments strings.Builder + Added bool +} + +func streamChatSSEAsResponses(w io.Writer, reader io.Reader, request map[string]any) (int64, error) { + state := &chatSSEState{responseID: "resp_compat", tools: map[int]*chatSSETool{}, request: request, toolContext: buildProtocolToolContext(request["tools"])} + scanner := bufio.NewScanner(reader) + scanner.Buffer(make([]byte, 64*1024), 8*1024*1024) + var block []string + var written int64 + flush := func() error { + if len(block) == 0 { + return nil + } + chunk, done, err := state.consumeBlock(block) + block = nil + if err != nil { + return err + } + if len(chunk) > 0 { + n, err := io.WriteString(w, chunk) + written += int64(n) + if err != nil { + return err + } + if flusher, ok := w.(http.Flusher); ok { + flusher.Flush() + } + } + if done { + state.completed = true + } + return nil + } + for scanner.Scan() { + line := strings.TrimSuffix(scanner.Text(), "\r") + if line == "" { + if err := flush(); err != nil { + return written, err + } + continue + } + block = append(block, line) + } + if err := scanner.Err(); err != nil { + return written, err + } + if err := flush(); err != nil { + return written, err + } + if !state.completed { + chunk := state.finishEvents() + n, err := io.WriteString(w, chunk) + written += int64(n) + return written, err + } + return written, nil +} + +func (s *chatSSEState) consumeBlock(lines []string) (string, bool, error) { + var event string + var data []string + for _, line := range lines { + if value, ok := strings.CutPrefix(line, "event:"); ok { + event = strings.TrimSpace(value) + } + if value, ok := strings.CutPrefix(line, "data:"); ok { + data = append(data, strings.TrimSpace(value)) + } + } + if len(data) == 0 { + return "", false, nil + } + joined := strings.Join(data, "\n") + if joined == "[DONE]" { + return s.finishEvents(), true, nil + } + var chunk map[string]any + if err := json.Unmarshal([]byte(joined), &chunk); err != nil { + return "", false, nil + } + if event == "error" || chunk["error"] != nil { + message := "upstream stream error" + if source, ok := chunk["error"].(map[string]any); ok { + message = firstString(stringFromAny(source["message"]), stringFromAny(source["detail"]), message) + } else if text := stringFromAny(chunk["error"]); text != "" { + message = text + } + return s.failedEvents(message), true, nil + } + return s.chunkEvents(chunk), false, nil +} + +func (s *chatSSEState) chunkEvents(chunk map[string]any) string { + if id := stringFromAny(chunk["id"]); id != "" { + s.responseID = responseIDFromChat(id) + } + if model := stringFromAny(chunk["model"]); model != "" { + s.model = model + } + s.createdAt = uint64FromAny(chunk["created"], s.createdAt) + var out strings.Builder + s.ensureStarted(&out) + if usage := chatUsageToResponses(chunk["usage"]); uint64FromAny(usage["total_tokens"], 0) > 0 { + s.usage = usage + } + choices, _ := chunk["choices"].([]any) + if len(choices) == 0 { + return out.String() + } + choice, _ := choices[0].(map[string]any) + delta, _ := choice["delta"].(map[string]any) + if reasoning := reasoningText(delta); reasoning != "" { + s.addReasoningDelta(&out, reasoning) + } + if content := stringFromAny(delta["content"]); content != "" { + s.addTextDelta(&out, content) + } + if calls, ok := delta["tool_calls"].([]any); ok { + for _, raw := range calls { + s.addToolDelta(&out, raw) + } + } + if finish := stringFromAny(choice["finish_reason"]); finish != "" { + s.finishReason = finish + } + return out.String() +} + +func (s *chatSSEState) ensureStarted(out *strings.Builder) { + if s.started { + return + } + s.started = true + base := s.baseResponse("in_progress", []any{}) + writeSSE(out, "response.created", map[string]any{"type": "response.created", "response": base}) + writeSSE(out, "response.in_progress", map[string]any{"type": "response.in_progress", "response": base}) +} + +func (s *chatSSEState) addReasoningDelta(out *strings.Builder, delta string) { + if !s.reasoningAdded { + s.reasoningAdded = true + writeSSE(out, "response.output_item.added", map[string]any{"type": "response.output_item.added", "output_index": 0, "item": map[string]any{"id": "rs_" + s.responseID, "type": "reasoning", "status": "in_progress", "summary": []any{}}}) + writeSSE(out, "response.reasoning_summary_part.added", map[string]any{"type": "response.reasoning_summary_part.added", "item_id": "rs_" + s.responseID, "output_index": 0, "summary_index": 0, "part": map[string]any{"type": "summary_text", "text": ""}}) + } + s.reasoning.WriteString(delta) + writeSSE(out, "response.reasoning_summary_text.delta", map[string]any{"type": "response.reasoning_summary_text.delta", "item_id": "rs_" + s.responseID, "output_index": 0, "summary_index": 0, "delta": delta}) +} + +func (s *chatSSEState) addTextDelta(out *strings.Builder, delta string) { + index := 0 + if s.reasoningAdded { + index = 1 + } + if !s.textAdded { + s.textAdded = true + writeSSE(out, "response.output_item.added", map[string]any{"type": "response.output_item.added", "output_index": index, "item": map[string]any{"id": s.responseID + "_msg", "type": "message", "status": "in_progress", "role": "assistant", "content": []any{}}}) + writeSSE(out, "response.content_part.added", map[string]any{"type": "response.content_part.added", "item_id": s.responseID + "_msg", "output_index": index, "content_index": 0, "part": map[string]any{"type": "output_text", "text": "", "annotations": []any{}}}) + } + s.text.WriteString(delta) + writeSSE(out, "response.output_text.delta", map[string]any{"type": "response.output_text.delta", "item_id": s.responseID + "_msg", "output_index": index, "content_index": 0, "delta": delta}) +} + +func (s *chatSSEState) addToolDelta(out *strings.Builder, raw any) { + call, _ := raw.(map[string]any) + index := int(uint64FromAny(call["index"], 0)) + tool := s.tools[index] + if tool == nil { + tool = &chatSSETool{Index: index} + s.tools[index] = tool + } + if id := stringFromAny(call["id"]); id != "" { + tool.CallID = id + } + function, _ := call["function"].(map[string]any) + if name := stringFromAny(function["name"]); name != "" { + tool.Name = name + } + args := stringFromAny(function["arguments"]) + if !tool.Added && (tool.CallID != "" || tool.Name != "") { + tool.Added = true + tool.Output = s.nextToolOutputIndex(index) + if tool.CallID == "" { + tool.CallID = fmt.Sprintf("call_%d", index) + } + item := map[string]any{"id": "fc_" + tool.CallID, "type": "function_call", "status": "in_progress", "call_id": tool.CallID, "name": tool.Name, "arguments": ""} + if spec, ok := s.toolContext.Custom[tool.Name]; ok { + item = map[string]any{"id": "ctc_" + tool.CallID, "type": "custom_tool_call", "status": "in_progress", "call_id": tool.CallID, "name": spec.OpenAIName, "input": ""} + } else if spec, ok := s.toolContext.Function[tool.Name]; ok { + item["name"] = spec.Name + if spec.Namespace != "" { + item["namespace"] = spec.Namespace + } + } + writeSSE(out, "response.output_item.added", map[string]any{"type": "response.output_item.added", "output_index": tool.Output, "item": item}) + } + if args != "" { + tool.Arguments.WriteString(args) + if _, custom := s.toolContext.Custom[tool.Name]; !custom { + writeSSE(out, "response.function_call_arguments.delta", map[string]any{"type": "response.function_call_arguments.delta", "item_id": "fc_" + tool.CallID, "output_index": tool.Output, "delta": args}) + } + } +} + +func (s *chatSSEState) nextToolOutputIndex(index int) int { + base := 0 + if s.reasoningAdded { + base++ + } + if s.textAdded { + base++ + } + return base + index +} + +func (s *chatSSEState) finishEvents() string { + if s.completed { + return "" + } + var out strings.Builder + s.ensureStarted(&out) + var output []any + if s.reasoningAdded { + item := reasoningOutputItem(s.responseID, s.reasoning.String()) + output = append(output, item) + writeSSE(&out, "response.reasoning_summary_text.done", map[string]any{"type": "response.reasoning_summary_text.done", "item_id": "rs_" + s.responseID, "output_index": 0, "summary_index": 0, "text": s.reasoning.String()}) + writeSSE(&out, "response.output_item.done", map[string]any{"type": "response.output_item.done", "output_index": 0, "item": item}) + } + if s.textAdded { + index := 0 + if s.reasoningAdded { + index = 1 + } + item := map[string]any{"id": s.responseID + "_msg", "type": "message", "status": "completed", "role": "assistant", "content": []any{map[string]any{"type": "output_text", "text": s.text.String(), "annotations": []any{}}}} + output = append(output, item) + writeSSE(&out, "response.output_text.done", map[string]any{"type": "response.output_text.done", "item_id": s.responseID + "_msg", "output_index": index, "content_index": 0, "text": s.text.String()}) + writeSSE(&out, "response.output_item.done", map[string]any{"type": "response.output_item.done", "output_index": index, "item": item}) + } + keys := make([]int, 0, len(s.tools)) + for key := range s.tools { + keys = append(keys, key) + } + sort.Ints(keys) + for _, key := range keys { + tool := s.tools[key] + item := protocolResponseToolCall(tool.CallID, tool.Name, tool.Arguments.String(), s.toolContext) + output = append(output, item) + if stringFromAny(item["type"]) == "custom_tool_call" { + writeSSE(&out, "response.custom_tool_call_input.delta", map[string]any{"type": "response.custom_tool_call_input.delta", "item_id": stringFromAny(item["id"]), "call_id": tool.CallID, "output_index": tool.Output, "delta": stringFromAny(item["input"])}) + } else { + writeSSE(&out, "response.function_call_arguments.done", map[string]any{"type": "response.function_call_arguments.done", "item_id": "fc_" + tool.CallID, "output_index": tool.Output, "arguments": stringFromAny(item["arguments"])}) + } + writeSSE(&out, "response.output_item.done", map[string]any{"type": "response.output_item.done", "output_index": tool.Output, "item": item}) + } + status := "completed" + if s.finishReason == "length" { + status = "incomplete" + } + response := s.baseResponse(status, output) + copyResponseRequestFields(response, s.request) + writeSSE(&out, "response.completed", map[string]any{"type": "response.completed", "response": response}) + out.WriteString("data: [DONE]\n\n") + s.completed = true + return out.String() +} + +func (s *chatSSEState) failedEvents(message string) string { + var out strings.Builder + s.ensureStarted(&out) + writeSSE(&out, "response.failed", map[string]any{"type": "response.failed", "response": map[string]any{"id": s.responseID, "object": "response", "status": "failed", "error": map[string]any{"message": message, "type": "upstream_error"}}}) + s.completed = true + return out.String() +} + +func (s *chatSSEState) baseResponse(status string, output []any) map[string]any { + usage := s.usage + if usage == nil { + usage = chatUsageToResponses(nil) + } + return map[string]any{"id": s.responseID, "object": "response", "created_at": s.createdAt, "status": status, "model": s.model, "output": output, "usage": usage} +} + +func writeSSE(out *strings.Builder, event string, data any) { + payload, _ := json.Marshal(data) + out.WriteString("event: ") + out.WriteString(event) + out.WriteString("\ndata: ") + out.Write(payload) + out.WriteString("\n\n") +} + +func responseIDFromChat(id string) string { + if id == "" { + id = "compat" + } + if strings.HasPrefix(id, "resp_") { + return id + } + return "resp_" + id +} + +func splitLeadingThinkBlock(text string) (string, string) { + trimmed := strings.TrimSpace(text) + if !strings.HasPrefix(trimmed, "") { + return "", "" + } + closeIndex := strings.Index(trimmed, "") + if closeIndex < 0 { + return strings.TrimSpace(strings.TrimPrefix(trimmed, "")), "" + } + reasoning := strings.TrimSpace(trimmed[len(""):closeIndex]) + answer := strings.TrimSpace(trimmed[closeIndex+len(""):]) + return reasoning, answer +} + +func responseText(value any) string { + switch value := value.(type) { + case string: + return value + case nil: + return "" + default: + return canonicalJSONString(value) + } +} + +func canonicalJSONString(value any) string { + if text, ok := value.(string); ok { + return text + } + data, err := json.Marshal(value) + if err != nil { + return "" + } + return string(data) +} + +func flattenedToolName(value map[string]any) string { + name := stringFromAny(value["name"]) + namespace := stringFromAny(value["namespace"]) + if namespace == "" || name == "" { + return name + } + return namespace + "__" + name +} + +func copyResponseRequestFields(target, source map[string]any) { + for _, key := range []string{"background", "max_output_tokens", "metadata", "parallel_tool_calls", "previous_response_id", "service_tier", "temperature", "tool_choice", "tools", "top_p", "truncation"} { + copyIfPresent(target, source, key) + } +} + +func copyIfPresent(target, source map[string]any, key string) { + if value, ok := source[key]; ok { + target[key] = value + } +} + +func cloneMap(value map[string]any) map[string]any { + result := map[string]any{} + for key, item := range value { + result[key] = item + } + return result +} diff --git a/protocol_proxy_test.go b/protocol_proxy_test.go new file mode 100644 index 0000000..6c7dce3 --- /dev/null +++ b/protocol_proxy_test.go @@ -0,0 +1,287 @@ +package main + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +func TestResponsesToChatCompletionsConvertsMessagesToolsAndReasoning(t *testing.T) { + request := map[string]any{ + "model": "deepseek-r1", + "instructions": "Be precise", + "max_output_tokens": float64(128), + "stream": true, + "parallel_tool_calls": true, + "reasoning": map[string]any{"effort": "high"}, + "input": []any{ + map[string]any{"role": "user", "content": []any{map[string]any{"type": "input_text", "text": "hello"}}}, + map[string]any{"type": "function_call", "call_id": "call_1", "name": "lookup", "arguments": `{"q":"go"}`}, + map[string]any{"type": "function_call_output", "call_id": "call_1", "output": "result"}, + }, + "tools": []any{map[string]any{"type": "function", "name": "lookup", "description": "Search", "parameters": map[string]any{"type": "object"}}}, + } + + converted, err := responsesToChatCompletions(request) + if err != nil { + t.Fatalf("convert request: %v", err) + } + if converted["model"] != "deepseek-r1" || converted["max_tokens"] != float64(128) { + t.Fatalf("model/token conversion mismatch: %#v", converted) + } + if converted["enable_thinking"] != true || converted["reasoning_effort"] != "high" { + t.Fatalf("reasoning conversion mismatch: %#v", converted) + } + streamOptions, _ := converted["stream_options"].(map[string]any) + if streamOptions["include_usage"] != true { + t.Fatalf("stream usage was not requested: %#v", converted) + } + messages, _ := converted["messages"].([]any) + if len(messages) != 4 || stringFromAny(messages[0].(map[string]any)["role"]) != "system" || stringFromAny(messages[3].(map[string]any)["role"]) != "tool" { + t.Fatalf("message conversion mismatch: %#v", messages) + } + tools, _ := converted["tools"].([]any) + if len(tools) != 1 || stringFromAny(tools[0].(map[string]any)["type"]) != "function" { + t.Fatalf("tool conversion mismatch: %#v", tools) + } +} + +func TestProtocolProxyUsesChatEndpointForResponsesRequest(t *testing.T) { + profile := relayProfile{Protocol: "chatCompletions", BaseURL: "https://api.example.test/v1"} + body := []byte(`{"model":"gpt-test","input":"hello","stream":false}`) + converted, ctx, err := convertResponsesRequestForProfile(profile, "/v1/responses", body) + if err != nil { + t.Fatalf("convert request: %v", err) + } + if !ctx.Converted || ctx.Stream { + t.Fatalf("unexpected protocol context: %#v", ctx) + } + if got := protocolProxyTargetURL(profile, "/v1/responses", ctx.Converted); got != "https://api.example.test/v1/chat/completions" { + t.Fatalf("chat target = %q", got) + } + var payload map[string]any + if json.Unmarshal(converted, &payload) != nil || stringFromAny(payload["model"]) != "gpt-test" { + t.Fatalf("invalid converted body: %s", converted) + } + if got := protocolProxyTargetURL(relayProfile{Protocol: "chatCompletions", BaseURL: "https://api.example.test/v1/chat/completions"}, "/v1/models", false); got != "https://api.example.test/v1/models" { + t.Fatalf("models target should remove complete chat endpoint, got %q", got) + } +} + +func TestChatCompletionToResponsesPreservesReasoningToolsAndUsage(t *testing.T) { + chat := map[string]any{ + "id": "chatcmpl_1", "created": float64(42), "model": "gpt-test", + "choices": []any{map[string]any{ + "finish_reason": "tool_calls", + "message": map[string]any{ + "content": "answer", "reasoning_content": "thought", + "tool_calls": []any{map[string]any{"id": "call_1", "type": "function", "function": map[string]any{"name": "lookup", "arguments": `{"q":"go"}`}}}, + }, + }}, + "usage": map[string]any{"prompt_tokens": float64(10), "completion_tokens": float64(4), "prompt_tokens_details": map[string]any{"cached_tokens": float64(3)}}, + } + response, err := chatCompletionToResponse(chat, map[string]any{"service_tier": "priority"}) + if err != nil { + t.Fatalf("convert response: %v", err) + } + if response["id"] != "resp_chatcmpl_1" || response["status"] != "completed" || response["service_tier"] != "priority" { + t.Fatalf("response envelope mismatch: %#v", response) + } + output, _ := response["output"].([]any) + if len(output) != 3 || stringFromAny(output[0].(map[string]any)["type"]) != "reasoning" || stringFromAny(output[2].(map[string]any)["type"]) != "function_call" { + t.Fatalf("response output mismatch: %#v", output) + } + usage, _ := response["usage"].(map[string]any) + if uint64FromAny(usage["input_tokens"], 0) != 7 || uint64FromAny(usage["total_tokens"], 0) != 14 { + t.Fatalf("usage conversion mismatch: %#v", usage) + } +} + +func TestChatSSEToResponsesStreamsTextReasoningToolsAndCompletion(t *testing.T) { + input := strings.Join([]string{ + `data: {"id":"chatcmpl_stream","model":"gpt-test","created":42,"choices":[{"delta":{"reasoning_content":"think "},"finish_reason":null}]}`, + "", + `data: {"id":"chatcmpl_stream","choices":[{"delta":{"content":"answer"},"finish_reason":null}]}`, + "", + `data: {"id":"chatcmpl_stream","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"lookup","arguments":"{\\\"q\\\":"}}]},"finish_reason":null}]}`, + "", + `data: {"id":"chatcmpl_stream","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\\"go\\\"}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":10,"completion_tokens":4}}`, + "", + "data: [DONE]", + "", + }, "\n") + var output bytes.Buffer + if _, err := streamChatSSEAsResponses(&output, strings.NewReader(input), map[string]any{"model": "gpt-test"}); err != nil { + t.Fatalf("convert stream: %v", err) + } + text := output.String() + for _, marker := range []string{"event: response.created", "event: response.reasoning_summary_text.delta", "event: response.output_text.delta", "event: response.function_call_arguments.delta", "event: response.function_call_arguments.done", "event: response.completed", "data: [DONE]"} { + if !strings.Contains(text, marker) { + t.Fatalf("stream missing %q:\n%s", marker, text) + } + } +} + +func TestChatSSEErrorBecomesResponsesFailure(t *testing.T) { + input := "event: error\ndata: {\"error\":{\"message\":\"rate limited\",\"type\":\"rate_limit\"}}\n\n" + var output bytes.Buffer + if _, err := streamChatSSEAsResponses(&output, strings.NewReader(input), nil); err != nil { + t.Fatalf("convert stream error: %v", err) + } + if !strings.Contains(output.String(), "event: response.failed") || strings.Contains(output.String(), "event: response.completed") { + t.Fatalf("unexpected error stream:\n%s", output.String()) + } +} + +func TestResponsesErrorConversionPreservesStructuredFields(t *testing.T) { + converted := responsesErrorFromUpstream(429, "application/json", []byte(`{"error":{"message":"slow down","type":"rate_limit","code":"quota"}}`)) + errorValue, _ := converted["error"].(map[string]any) + if errorValue["message"] != "slow down" || errorValue["type"] != "rate_limit" || errorValue["code"] != "quota" { + t.Fatalf("error conversion mismatch: %#v", converted) + } +} + +func TestResponsesRequestMapsCustomNamespaceAndApplyPatchTools(t *testing.T) { + request := map[string]any{ + "model": "gpt-test", "input": "hi", "stream": true, + "tools": []any{ + map[string]any{"type": "custom", "name": "exec", "description": "Run command"}, + map[string]any{"type": "namespace", "name": "mcp__vscode_mcp__", "description": "VS Code", "tools": []any{ + map[string]any{"type": "function", "name": "open_file", "parameters": map[string]any{}}, + }}, + map[string]any{"type": "custom", "name": "apply_patch", "description": "Patch files"}, + map[string]any{"type": "web_search"}, + }, + "tool_choice": map[string]any{"type": "custom", "name": "apply_patch"}, + } + converted, err := responsesToChatCompletions(request) + if err != nil { + t.Fatalf("convert request: %v", err) + } + tools, _ := converted["tools"].([]any) + var names []string + for _, raw := range tools { + tool := raw.(map[string]any) + function := tool["function"].(map[string]any) + names = append(names, stringFromAny(function["name"])) + parameters := function["parameters"].(map[string]any) + if parameters["type"] == nil || parameters["properties"] == nil || parameters["required"] == nil { + t.Fatalf("tool parameters were not normalized: %#v", parameters) + } + } + wantNames := []string{"exec", "mcp__vscode_mcp__open_file", "apply_patch_add_file", "apply_patch_delete_file", "apply_patch_update_file", "apply_patch_replace_file", "apply_patch_batch", "web_search"} + if strings.Join(names, ",") != strings.Join(wantNames, ",") { + t.Fatalf("tool names = %#v, want %#v", names, wantNames) + } + choice := converted["tool_choice"].(map[string]any)["function"].(map[string]any) + if choice["name"] != "apply_patch_batch" { + t.Fatalf("apply_patch tool choice = %#v", choice) + } + streamOptions := converted["stream_options"].(map[string]any) + if streamOptions["include_usage"] != true { + t.Fatalf("stream usage missing: %#v", streamOptions) + } +} + +func TestResponsesHistorySanitizesLegacyPatchAndOrphanToolItems(t *testing.T) { + request := map[string]any{ + "model": "gpt-test", + "input": []any{ + map[string]any{"type": "function_call", "call_id": "bad", "name": "broken", "arguments": `{foo: "bar"}`}, + map[string]any{"type": "function_call_output", "call_id": "bad", "output": "handled"}, + map[string]any{"type": "custom_tool_call", "call_id": "patch", "name": "apply_patch", "input": "*** Begin Patch\n*** Add File: docs/test.md\n+# Test\n*** End Patch"}, + map[string]any{"type": "custom_tool_call_output", "call_id": "missing", "output": "orphan"}, + map[string]any{"type": "tool_call", "tool_use": map[string]any{"id": "legacy", "name": "lookup", "input": map[string]any{"query": "go"}}}, + map[string]any{"type": "tool_result", "content": map[string]any{"tool_use_id": "legacy", "content": map[string]any{"ok": true}}}, + }, + } + converted, err := responsesToChatCompletions(request) + if err != nil { + t.Fatalf("convert history: %v", err) + } + messages := converted["messages"].([]any) + firstCall := messages[0].(map[string]any)["tool_calls"].([]any)[0].(map[string]any) + arguments := firstCall["function"].(map[string]any)["arguments"].(string) + var parsed map[string]any + if json.Unmarshal([]byte(arguments), &parsed) != nil || parsed["input"] != `{foo: "bar"}` { + t.Fatalf("invalid arguments were not sanitized: %q", arguments) + } + patchCall := messages[2].(map[string]any)["tool_calls"].([]any)[0].(map[string]any)["function"].(map[string]any) + if patchCall["name"] != "apply_patch_add_file" || patchCall["arguments"] != `{"content":"# Test","path":"docs/test.md"}` { + t.Fatalf("patch history conversion mismatch: %#v", patchCall) + } + orphan := messages[3].(map[string]any) + if orphan["role"] != "user" || orphan["content"] != "Function call output (missing): orphan" { + t.Fatalf("orphan output was not downgraded: %#v", orphan) + } + legacy := messages[4].(map[string]any)["tool_calls"].([]any)[0].(map[string]any)["function"].(map[string]any) + if legacy["name"] != "lookup" || legacy["arguments"] != `{"query":"go"}` { + t.Fatalf("legacy tool history mismatch: %#v", legacy) + } +} + +func TestChatResponseRestoresCustomNamespacePatchReasoningAndCacheUsage(t *testing.T) { + original := map[string]any{"tools": []any{ + map[string]any{"type": "custom", "name": "exec"}, + map[string]any{"type": "custom", "name": "apply_patch"}, + map[string]any{"type": "namespace", "name": "mcp__vscode_mcp__", "tools": []any{map[string]any{"type": "function", "name": "open_file", "parameters": map[string]any{}}}}, + }} + chat := map[string]any{ + "id": "chatcmpl_tools", "created": float64(123), "model": "gpt-test", + "choices": []any{map[string]any{"finish_reason": "tool_calls", "message": map[string]any{ + "reasoning_details": []any{map[string]any{"summary": "Step one."}, map[string]any{"parts": []any{map[string]any{"text": "Step two."}}}}, + "tool_calls": []any{ + map[string]any{"id": "custom", "function": map[string]any{"name": "exec", "arguments": `{"input":"ls -la"}`}}, + map[string]any{"id": "ns", "function": map[string]any{"name": "mcp__vscode_mcp__open_file", "arguments": `{"path":"main.go"}`}}, + map[string]any{"id": "patch", "function": map[string]any{"name": "apply_patch_add_file", "arguments": `{"path":"README.md","content":"hello"}`}}, + }, + }}}, + "usage": map[string]any{"input_tokens": float64(10), "output_tokens": float64(3), "cache_read_input_tokens": float64(2), "cache_creation_5m_input_tokens": float64(4), "cache_creation_1h_input_tokens": float64(6)}, + } + response, err := chatCompletionToResponse(chat, original) + if err != nil { + t.Fatalf("convert response: %v", err) + } + output := response["output"].([]any) + if output[0].(map[string]any)["summary"].([]any)[0].(map[string]any)["text"] != "Step one.\n\nStep two." { + t.Fatalf("reasoning details mismatch: %#v", output[0]) + } + custom := output[1].(map[string]any) + if custom["type"] != "custom_tool_call" || custom["name"] != "exec" || custom["input"] != "ls -la" { + t.Fatalf("custom call mismatch: %#v", custom) + } + namespace := output[2].(map[string]any) + if namespace["type"] != "function_call" || namespace["name"] != "open_file" || namespace["namespace"] != "mcp__vscode_mcp__" { + t.Fatalf("namespace call mismatch: %#v", namespace) + } + patch := output[3].(map[string]any) + if patch["name"] != "apply_patch" || patch["input"] != "*** Begin Patch\n*** Add File: README.md\n+hello\n*** End Patch" { + t.Fatalf("patch call mismatch: %#v", patch) + } + usage := response["usage"].(map[string]any) + if usage["total_tokens"] != uint64(25) || usage["cache_ttl"] != "mixed" || usage["input_tokens_details"] != nil { + t.Fatalf("cache usage mismatch: %#v", usage) + } +} + +func TestChatSSErestoresCustomAndApplyPatchCalls(t *testing.T) { + input := strings.Join([]string{ + `data: {"id":"chatcmpl_stream","choices":[{"delta":{"tool_calls":[{"index":0,"id":"exec_call","function":{"name":"exec","arguments":"{\"input\":\"ls -la\"}"}},{"index":1,"id":"patch_call","function":{"name":"apply_patch_add_file","arguments":"{\"path\":\"x.txt\",\"content\":\"hello\"}"}}]},"finish_reason":"tool_calls"}]}`, + "", "data: [DONE]", "", + }, "\n") + request := map[string]any{"tools": []any{map[string]any{"type": "custom", "name": "exec"}, map[string]any{"type": "custom", "name": "apply_patch"}}} + var output bytes.Buffer + if _, err := streamChatSSEAsResponses(&output, strings.NewReader(input), request); err != nil { + t.Fatalf("convert custom stream: %v", err) + } + text := output.String() + if strings.Count(text, "event: response.custom_tool_call_input.delta") != 2 || strings.Contains(text, "event: response.function_call_arguments.delta") { + t.Fatalf("custom stream event mapping mismatch:\n%s", text) + } + for _, marker := range []string{`"name":"exec"`, `"input":"ls -la"`, `"name":"apply_patch"`, `*** Add File: x.txt`, `data: [DONE]`} { + if !strings.Contains(text, marker) { + t.Fatalf("custom stream missing %q:\n%s", marker, text) + } + } +} diff --git a/protocol_tools.go b/protocol_tools.go new file mode 100644 index 0000000..6bf0037 --- /dev/null +++ b/protocol_tools.go @@ -0,0 +1,551 @@ +package main + +import ( + "encoding/json" + "fmt" + "strings" +) + +type protocolPatchAction string + +const ( + patchActionAdd protocolPatchAction = "add_file" + patchActionDelete protocolPatchAction = "delete_file" + patchActionUpdate protocolPatchAction = "update_file" + patchActionReplace protocolPatchAction = "replace_file" + patchActionBatch protocolPatchAction = "batch" +) + +type protocolCustomToolSpec struct { + OpenAIName string + ApplyPatch bool + Action protocolPatchAction +} + +type protocolFunctionToolSpec struct { + Namespace string + Name string +} + +type protocolToolContext struct { + Custom map[string]protocolCustomToolSpec + Function map[string]protocolFunctionToolSpec +} + +func buildProtocolToolContext(raw any) protocolToolContext { + ctx := protocolToolContext{Custom: map[string]protocolCustomToolSpec{}, Function: map[string]protocolFunctionToolSpec{}} + tools, _ := raw.([]any) + for _, rawTool := range tools { + if name, ok := rawTool.(string); ok && strings.TrimSpace(name) != "" { + name = strings.TrimSpace(name) + if action, ok := patchActionFromProxyName(name); ok { + ctx.Custom[name] = protocolCustomToolSpec{OpenAIName: "apply_patch", ApplyPatch: true, Action: action} + } else { + ctx.Custom[name] = protocolCustomToolSpec{OpenAIName: name} + } + continue + } + tool, ok := rawTool.(map[string]any) + if !ok { + continue + } + typeName := strings.TrimSpace(stringFromAny(tool["type"])) + name := strings.TrimSpace(stringFromAny(tool["name"])) + switch typeName { + case "custom": + if name == "" { + continue + } + isPatch := isApplyPatchTool(tool, name) + ctx.Custom[name] = protocolCustomToolSpec{OpenAIName: name, ApplyPatch: isPatch} + if isPatch { + for _, action := range []protocolPatchAction{patchActionAdd, patchActionDelete, patchActionUpdate, patchActionReplace, patchActionBatch} { + ctx.Custom[name+"_"+string(action)] = protocolCustomToolSpec{OpenAIName: name, ApplyPatch: true, Action: action} + } + } + case "function": + if name != "" { + ctx.Function[name] = protocolFunctionToolSpec{Name: name} + } + case "namespace": + children, _ := tool["tools"].([]any) + for _, rawChild := range children { + child, _ := rawChild.(map[string]any) + if stringFromAny(child["type"]) != "function" { + continue + } + childName := strings.TrimSpace(stringFromAny(child["name"])) + if childName == "" { + continue + } + flat := flattenProtocolToolName(name, childName) + if existing, found := ctx.Function[flat]; !found || existing.Namespace != "" { + ctx.Function[flat] = protocolFunctionToolSpec{Namespace: name, Name: childName} + } + } + case "web_search", "local_shell", "computer_use": + if name == "" { + name = typeName + } + ctx.Custom[name] = protocolCustomToolSpec{OpenAIName: name} + } + } + return ctx +} + +func convertResponsesToolsToChat(raw any, ctx protocolToolContext) []any { + items, _ := raw.([]any) + tools := make([]any, 0, len(items)) + for index, rawTool := range items { + if name, ok := rawTool.(string); ok && strings.TrimSpace(name) != "" { + tools = append(tools, genericCustomProxyTool(strings.TrimSpace(name), "")) + continue + } + tool, ok := rawTool.(map[string]any) + if !ok { + continue + } + typeName := stringFromAny(tool["type"]) + name := strings.TrimSpace(stringFromAny(tool["name"])) + switch typeName { + case "function": + if converted := responseFunctionToolToChat(tool, index); converted != nil { + tools = append(tools, converted) + } + case "custom", "web_search", "local_shell", "computer_use": + if name == "" { + name = typeName + } + description := stringFromAny(tool["description"]) + if isApplyPatchTool(tool, name) { + tools = append(tools, applyPatchProxyTools(name, description)...) + } else { + tools = append(tools, genericCustomProxyTool(name, description)) + } + case "namespace": + children, _ := tool["tools"].([]any) + for _, rawChild := range children { + child, _ := rawChild.(map[string]any) + if stringFromAny(child["type"]) != "function" { + continue + } + childName := strings.TrimSpace(stringFromAny(child["name"])) + flat := flattenProtocolToolName(name, childName) + if childName == "" || (name != "" && ctx.Function[flat].Namespace == "") { + continue + } + description := strings.TrimSpace(strings.Join(nonEmptyStrings(stringFromAny(tool["description"]), stringFromAny(child["description"])), "\n\n")) + function := map[string]any{"name": flat, "parameters": normalizeChatToolParameters(child["parameters"])} + if description != "" { + function["description"] = description + } + tools = append(tools, map[string]any{"type": "function", "function": function}) + } + } + } + return tools +} + +func responseFunctionToolToChat(tool map[string]any, index int) any { + if nested, ok := tool["function"].(map[string]any); ok { + function := cloneMap(nested) + function["parameters"] = normalizeChatToolParameters(function["parameters"]) + if function["strict"] == nil && tool["strict"] != nil { + function["strict"] = tool["strict"] + } + return map[string]any{"type": "function", "function": function} + } + name := strings.TrimSpace(stringFromAny(tool["name"])) + if name == "" { + name = fmt.Sprintf("tool_%d", index) + } + function := map[string]any{ + "name": name, + "description": stringFromAny(tool["description"]), + "parameters": normalizeChatToolParameters(tool["parameters"]), + } + if tool["strict"] != nil { + function["strict"] = tool["strict"] + } + return map[string]any{"type": "function", "function": function} +} + +func normalizeChatToolParameters(raw any) map[string]any { + parameters, _ := raw.(map[string]any) + parameters = cloneMap(parameters) + if parameters["type"] == nil { + parameters["type"] = "object" + } + if parameters["properties"] == nil { + parameters["properties"] = map[string]any{} + } + if parameters["required"] == nil { + parameters["required"] = []any{} + } + return parameters +} + +func convertResponsesToolChoice(raw any, ctx protocolToolContext) any { + switch choice := raw.(type) { + case string: + if choice == "auto" || choice == "none" || choice == "required" { + return choice + } + case map[string]any: + typeName := stringFromAny(choice["type"]) + if typeName == "custom" { + name := stringFromAny(choice["name"]) + spec, ok := ctx.Custom[name] + if !ok { + return nil + } + if spec.ApplyPatch { + name = spec.OpenAIName + "_batch" + } + return map[string]any{"type": "function", "function": map[string]any{"name": name}} + } + function, _ := choice["function"].(map[string]any) + name := firstNonEmpty(stringFromAny(choice["name"]), stringFromAny(function["name"])) + namespace := firstNonEmpty(stringFromAny(choice["namespace"]), stringFromAny(function["namespace"])) + if name != "" { + return map[string]any{"type": "function", "function": map[string]any{"name": flattenProtocolToolName(namespace, name)}} + } + } + return nil +} + +func genericCustomProxyTool(name, description string) map[string]any { + description = strings.TrimSpace(description) + if description == "" { + description = "FREEFORM custom tool: " + name + ". Put only the tool input text here." + } else { + description += "\n\nThis is a FREEFORM tool. Do not wrap the input in JSON or markdown." + } + return protocolFunctionTool(name, description, map[string]any{ + "type": "object", "additionalProperties": false, + "properties": map[string]any{"input": map[string]any{"type": "string", "description": "Raw freeform input for this custom tool."}}, + "required": []any{"input"}, + }) +} + +func applyPatchProxyTools(name, description string) []any { + desc := func(action, fallback string) string { + if strings.TrimSpace(description) == "" { + return fallback + } + return strings.TrimSpace(description) + " (proxy action: " + action + ")" + } + return []any{ + protocolFunctionTool(name+"_add_file", desc("add_file", "Create one new file by providing a target path and full file content."), patchAddSchema()), + protocolFunctionTool(name+"_delete_file", desc("delete_file", "Delete one file by providing a target path."), patchDeleteSchema()), + protocolFunctionTool(name+"_update_file", desc("update_file", "Edit one existing file with structured hunks."), patchUpdateSchema()), + protocolFunctionTool(name+"_replace_file", desc("replace_file", "Replace one existing file by providing a target path and full new file content."), patchReplaceSchema()), + protocolFunctionTool(name+"_batch", desc("batch", "Edit files by providing structured JSON patch operations."), patchBatchSchema()), + } +} + +func protocolFunctionTool(name, description string, parameters map[string]any) map[string]any { + return map[string]any{"type": "function", "function": map[string]any{"name": name, "description": description, "parameters": parameters}} +} + +func patchAddSchema() map[string]any { + return map[string]any{"type": "object", "additionalProperties": false, "properties": map[string]any{"path": map[string]any{"type": "string"}, "content": map[string]any{"type": "string"}}, "required": []any{"path", "content"}} +} + +func patchDeleteSchema() map[string]any { + return map[string]any{"type": "object", "additionalProperties": false, "properties": map[string]any{"path": map[string]any{"type": "string"}}, "required": []any{"path"}} +} + +func patchHunksSchema() map[string]any { + return map[string]any{"type": "array", "items": map[string]any{"type": "object", "additionalProperties": false, "properties": map[string]any{"context": map[string]any{"type": "string"}, "lines": map[string]any{"type": "array", "items": map[string]any{"type": "object", "additionalProperties": false, "properties": map[string]any{"op": map[string]any{"type": "string", "enum": []any{"context", "add", "remove"}}, "text": map[string]any{"type": "string"}}, "required": []any{"op", "text"}}}}, "required": []any{"lines"}}} +} + +func patchUpdateSchema() map[string]any { + return map[string]any{"type": "object", "additionalProperties": false, "properties": map[string]any{"path": map[string]any{"type": "string"}, "move_to": map[string]any{"type": "string"}, "hunks": patchHunksSchema()}, "required": []any{"path", "hunks"}} +} + +func patchReplaceSchema() map[string]any { + return map[string]any{"type": "object", "additionalProperties": false, "properties": map[string]any{"path": map[string]any{"type": "string"}, "content": map[string]any{"type": "string"}}, "required": []any{"path", "content"}} +} + +func patchBatchSchema() map[string]any { + operation := map[string]any{"type": "object", "additionalProperties": false, "properties": map[string]any{"type": map[string]any{"type": "string", "enum": []any{"add_file", "delete_file", "update_file", "replace_file"}}, "path": map[string]any{"type": "string"}, "move_to": map[string]any{"type": "string"}, "content": map[string]any{"type": "string"}, "hunks": patchHunksSchema()}, "required": []any{"type", "path"}} + return map[string]any{"type": "object", "additionalProperties": false, "properties": map[string]any{"operations": map[string]any{"type": "array", "items": operation}}, "required": []any{"operations"}} +} + +func isApplyPatchTool(tool map[string]any, name string) bool { + if name == "apply_patch" { + return true + } + format, _ := tool["format"].(map[string]any) + definition := stringFromAny(format["definition"]) + return strings.Contains(definition, "begin_patch") && strings.Contains(definition, "end_patch") && strings.Contains(definition, "add_hunk") +} + +func patchActionFromProxyName(name string) (protocolPatchAction, bool) { + for _, action := range []protocolPatchAction{patchActionAdd, patchActionDelete, patchActionUpdate, patchActionReplace, patchActionBatch} { + if strings.HasSuffix(name, "_"+string(action)) { + return action, true + } + } + return "", false +} + +func flattenProtocolToolName(namespace, name string) string { + if namespace == "" { + return name + } + if name == "" { + return namespace + } + if strings.HasSuffix(namespace, "__") || strings.HasPrefix(name, "__") { + return namespace + name + } + return namespace + "__" + name +} + +func protocolResponseToolCall(callID, name, arguments string, ctx protocolToolContext) map[string]any { + if spec, ok := ctx.Custom[name]; ok { + input := reconstructCustomToolInput(arguments) + if spec.ApplyPatch { + input = reconstructApplyPatchInput(spec.Action, arguments) + } + return map[string]any{"id": "ctc_" + callID, "type": "custom_tool_call", "status": "completed", "call_id": callID, "name": spec.OpenAIName, "input": input} + } + displayName, namespace := name, "" + if spec, ok := ctx.Function[name]; ok { + if spec.Name != "" { + displayName = spec.Name + } + namespace = spec.Namespace + } + item := map[string]any{"id": "fc_" + callID, "type": "function_call", "status": "completed", "call_id": callID, "name": displayName, "arguments": normalizeChatToolArguments(arguments)} + if namespace != "" { + item["namespace"] = namespace + } + return item +} + +func normalizeChatToolArguments(raw any) string { + if raw == nil { + return "{}" + } + if text, ok := raw.(string); ok { + var value any + if json.Unmarshal([]byte(text), &value) == nil { + if _, object := value.(map[string]any); object { + return canonicalJSONString(value) + } + return canonicalJSONString(map[string]any{"input": value}) + } + return canonicalJSONString(map[string]any{"input": text}) + } + if _, object := raw.(map[string]any); object { + return canonicalJSONString(raw) + } + return canonicalJSONString(map[string]any{"input": raw}) +} + +func buildCustomToolHistory(name string, input any) (string, string) { + text := responseText(input) + if name != "apply_patch" && !strings.HasPrefix(text, "*** Begin Patch") { + return name, canonicalJSONString(map[string]any{"input": text}) + } + operations := parseApplyPatchOperations(text) + if len(operations) == 1 { + action := protocolPatchAction(stringFromAny(operations[0]["type"])) + if action != patchActionAdd && action != patchActionDelete && action != patchActionUpdate && action != patchActionReplace { + action = patchActionBatch + } + return name + "_" + string(action), patchOperationArguments(operations[0], action) + } + return name + "_batch", canonicalJSONString(map[string]any{"operations": operations, "raw_patch": text}) +} + +func reconstructCustomToolInput(arguments string) string { + var value map[string]any + if json.Unmarshal([]byte(arguments), &value) != nil || value["input"] == nil { + return arguments + } + return responseText(value["input"]) +} + +func reconstructApplyPatchInput(action protocolPatchAction, arguments string) string { + var value map[string]any + if json.Unmarshal([]byte(arguments), &value) != nil { + return arguments + } + for _, key := range []string{"raw_patch", "patch", "input"} { + if text := stringFromAny(value[key]); text != "" { + return text + } + } + var operations []map[string]any + switch action { + case patchActionAdd, patchActionDelete, patchActionUpdate, patchActionReplace: + op := cloneMap(value) + op["type"] = string(action) + operations = append(operations, op) + default: + for _, raw := range anySlice(value["operations"]) { + if operation, ok := raw.(map[string]any); ok { + operations = append(operations, operation) + } + } + } + return buildApplyPatchText(operations) +} + +func buildApplyPatchText(operations []map[string]any) string { + var out strings.Builder + out.WriteString("*** Begin Patch") + for _, operation := range operations { + path := stringFromAny(operation["path"]) + switch stringFromAny(operation["type"]) { + case "add_file": + out.WriteString("\n*** Add File: " + path) + for _, line := range strings.Split(strings.TrimSuffix(stringFromAny(operation["content"]), "\n"), "\n") { + if line != "" || stringFromAny(operation["content"]) != "" { + out.WriteString("\n+" + line) + } + } + case "delete_file": + out.WriteString("\n*** Delete File: " + path) + case "replace_file": + out.WriteString("\n*** Delete File: " + path + "\n*** Add File: " + path) + for _, line := range strings.Split(strings.TrimSuffix(stringFromAny(operation["content"]), "\n"), "\n") { + if line != "" || stringFromAny(operation["content"]) != "" { + out.WriteString("\n+" + line) + } + } + case "update_file": + out.WriteString("\n*** Update File: " + path) + if moveTo := stringFromAny(operation["move_to"]); moveTo != "" { + out.WriteString("\n*** Move to: " + moveTo) + } + for _, rawHunk := range anySlice(operation["hunks"]) { + hunk, _ := rawHunk.(map[string]any) + out.WriteString("\n@@") + if context := stringFromAny(hunk["context"]); context != "" { + out.WriteString(" " + context) + } + for _, rawLine := range anySlice(hunk["lines"]) { + line, _ := rawLine.(map[string]any) + prefix := " " + if stringFromAny(line["op"]) == "add" { + prefix = "+" + } else if op := stringFromAny(line["op"]); op == "remove" || op == "delete" { + prefix = "-" + } + out.WriteString("\n" + prefix + stringFromAny(line["text"])) + } + } + } + } + out.WriteString("\n*** End Patch") + return out.String() +} + +func parseApplyPatchOperations(input string) []map[string]any { + var operations []map[string]any + var current map[string]any + var content []string + var hunks []any + var hunk map[string]any + var lines []any + flushHunk := func() { + if hunk != nil { + hunk["lines"] = lines + hunks = append(hunks, hunk) + hunk, lines = nil, nil + } + } + flushOperation := func() { + if current == nil { + return + } + if typeName := stringFromAny(current["type"]); typeName == "add_file" || typeName == "replace_file" { + current["content"] = strings.Join(content, "\n") + } else if typeName == "update_file" { + current["hunks"] = hunks + } + operations = append(operations, current) + current, content, hunks = nil, nil, nil + } + for _, line := range strings.Split(strings.ReplaceAll(input, "\r\n", "\n"), "\n") { + start := func(typeName, prefix string) bool { + if !strings.HasPrefix(line, prefix) { + return false + } + flushHunk() + flushOperation() + current = map[string]any{"type": typeName, "path": strings.TrimPrefix(line, prefix)} + return true + } + if line == "*** Begin Patch" || line == "*** End Patch" || start("add_file", "*** Add File: ") || start("delete_file", "*** Delete File: ") || start("update_file", "*** Update File: ") { + continue + } + if strings.HasPrefix(line, "*** Move to: ") && current != nil { + current["move_to"] = strings.TrimPrefix(line, "*** Move to: ") + continue + } + if strings.HasPrefix(line, "@@") { + flushHunk() + hunk = map[string]any{"context": strings.TrimSpace(strings.TrimPrefix(line, "@@"))} + continue + } + if current == nil { + continue + } + switch stringFromAny(current["type"]) { + case "add_file", "replace_file": + if strings.HasPrefix(line, "+") { + content = append(content, strings.TrimPrefix(line, "+")) + } + case "update_file": + op, text := "context", line + if strings.HasPrefix(line, "+") { + op, text = "add", line[1:] + } else if strings.HasPrefix(line, "-") { + op, text = "remove", line[1:] + } else if strings.HasPrefix(line, " ") { + text = line[1:] + } + lines = append(lines, map[string]any{"op": op, "text": text}) + } + } + flushHunk() + flushOperation() + return operations +} + +func patchOperationArguments(operation map[string]any, action protocolPatchAction) string { + result := map[string]any{"path": stringFromAny(operation["path"])} + switch action { + case patchActionAdd, patchActionReplace: + result["content"] = stringFromAny(operation["content"]) + case patchActionUpdate: + result["hunks"] = firstNonNil(operation["hunks"], []any{}) + if moveTo := stringFromAny(operation["move_to"]); moveTo != "" { + result["move_to"] = moveTo + } + case patchActionBatch: + result = map[string]any{"operations": []any{operation}} + } + return canonicalJSONString(result) +} + +func anySlice(value any) []any { + items, _ := value.([]any) + return items +} + +func nonEmptyStrings(values ...string) []string { + result := make([]string, 0, len(values)) + for _, value := range values { + if strings.TrimSpace(value) != "" { + result = append(result, strings.TrimSpace(value)) + } + } + return result +} diff --git a/repair.go b/repair.go index 2ad2dec..c0185f7 100644 --- a/repair.go +++ b/repair.go @@ -482,6 +482,9 @@ func discoverCodexMarketplaces(home string) []marketplaceSpec { paths := []marketplaceSpec{ {Name: "openai-bundled", Source: filepath.Join(home, ".tmp", "bundled-marketplaces", "openai-bundled")}, {Name: "openai-curated", Source: filepath.Join(home, ".tmp", "plugins")}, + {Name: "openai-api-curated", Source: filepath.Join(home, ".tmp", "plugins")}, + {Name: "openai-curated-remote", Source: filepath.Join(home, ".tmp", "plugins-remote")}, + {Name: "role-specific-plugins", Source: filepath.Join(home, ".tmp", "marketplaces", "role-specific-plugins")}, } if userHome, err := os.UserHomeDir(); err == nil && userHome != "" { paths = append(paths, marketplaceSpec{Name: "openai-primary-runtime", Source: filepath.Join(userHome, ".cache", "codex-runtimes", "codex-primary-runtime", "plugins", "openai-primary-runtime")}) @@ -504,7 +507,7 @@ func codexMarketplaceExists(path string) bool { func discoverCachedPluginEnables(home string) []pluginEnableSpec { cacheRoot := filepath.Join(home, "plugins", "cache") - marketplaces := []string{"openai-curated", "openai-primary-runtime", "openai-bundled"} + marketplaces := []string{"openai-curated", "openai-api-curated", "openai-curated-remote", "role-specific-plugins", "openai-primary-runtime", "openai-bundled"} var plugins []pluginEnableSpec seen := map[string]bool{} for _, marketplace := range marketplaces { @@ -751,7 +754,11 @@ func runProviderSyncLocked(home string) providerSyncResult { rewriteChanges = append(rewriteChanges, change) } } - sqliteCount := countSQLiteUpdates(filepath.Join(home, "state_5.sqlite"), targetProvider, changes) + sqlitePaths := codexSessionDBPaths(home) + sqliteCount := 0 + for _, path := range sqlitePaths { + sqliteCount += countSQLiteUpdates(path, targetProvider, changes) + } globalCount, err := countGlobalStateUpdates(filepath.Join(home, ".codex-global-state.json"), changes) if err != nil { return providerSyncResult{Status: "skipped", Message: "Provider sync skipped: " + err.Error(), TargetProvider: targetProvider} @@ -774,25 +781,33 @@ func runProviderSyncLocked(home string) providerSyncResult { if err != nil { return providerSyncFailureBeforeMutation(targetProvider, backupDir, err) } + databaseSnapshots, err := captureProviderSyncDatabaseSnapshots(sqlitePaths) + if err != nil { + return providerSyncFailureBeforeMutation(targetProvider, backupDir, err) + } if err := applySessionChanges(rewriteChanges); err != nil { return providerSyncSessionFailure(targetProvider, backupDir, err) } if err := ensureProviderSyncWritersStopped(); err != nil { - rollbackErr := rollbackProviderSyncFiles(rewriteChanges, globalSnapshot) + rollbackErr := rollbackProviderSyncFiles(rewriteChanges, globalSnapshot, databaseSnapshots) return providerSyncRollbackFailure(targetProvider, backupDir, err, rollbackErr) } if _, err := applyProviderSyncGlobalStateUpdate(globalPath, changes); err != nil { - rollbackErr := rollbackProviderSyncFiles(rewriteChanges, globalSnapshot) + rollbackErr := rollbackProviderSyncFiles(rewriteChanges, globalSnapshot, databaseSnapshots) return providerSyncRollbackFailure(targetProvider, backupDir, err, rollbackErr) } if err := ensureProviderSyncWritersStopped(); err != nil { - rollbackErr := rollbackProviderSyncFiles(rewriteChanges, globalSnapshot) + rollbackErr := rollbackProviderSyncFiles(rewriteChanges, globalSnapshot, databaseSnapshots) return providerSyncRollbackFailure(targetProvider, backupDir, err, rollbackErr) } - sqliteRows, sqliteErr := applySQLiteUpdates(filepath.Join(home, "state_5.sqlite"), targetProvider, changes) - if sqliteErr != nil { - rollbackErr := rollbackProviderSyncFiles(rewriteChanges, globalSnapshot) - return providerSyncRollbackFailure(targetProvider, backupDir, sqliteErr, rollbackErr) + sqliteRows := 0 + for _, path := range sqlitePaths { + updated, sqliteErr := applySQLiteUpdates(path, targetProvider, changes) + if sqliteErr != nil { + rollbackErr := rollbackProviderSyncFiles(rewriteChanges, globalSnapshot, databaseSnapshots) + return providerSyncRollbackFailure(targetProvider, backupDir, sqliteErr, rollbackErr) + } + sqliteRows += updated } pruneProviderSyncBackups(home) return providerSyncResult{Status: "synced", Message: "Provider sync complete", TargetProvider: targetProvider, BackupDir: &backupDir, ChangedSessionFiles: len(rewriteChanges), SQLiteRowsUpdated: sqliteRows} @@ -917,13 +932,38 @@ func providerSyncRollbackFailure(targetProvider, backupDir string, operationErr, return providerSyncResult{Status: "failed", Message: message, TargetProvider: targetProvider, BackupDir: &backupDir, Partial: true, RollbackStatus: "rollback_failed"} } -func rollbackProviderSyncFiles(changes []sessionChange, globalSnapshot providerSyncFileSnapshot) error { +func rollbackProviderSyncFiles(changes []sessionChange, globalSnapshot providerSyncFileSnapshot, databaseSnapshots []providerSyncFileSnapshot) error { if err := ensureProviderSyncWritersStopped(); err != nil { return fmt.Errorf("rollback not attempted because a history writer is active: %w", err) } + databaseErr := restoreProviderSyncDatabaseSnapshots(databaseSnapshots) globalErr := restoreProviderSyncFileSnapshot(globalSnapshot) sessionErr := restoreSessionChanges(changes) - return errors.Join(globalErr, sessionErr) + return errors.Join(databaseErr, globalErr, sessionErr) +} + +func captureProviderSyncDatabaseSnapshots(paths []string) ([]providerSyncFileSnapshot, error) { + snapshots := make([]providerSyncFileSnapshot, 0, len(paths)*3) + for _, path := range paths { + for _, suffix := range []string{"", "-wal", "-shm"} { + snapshot, err := captureProviderSyncFileSnapshot(path + suffix) + if err != nil { + return nil, fmt.Errorf("capture SQLite snapshot %s: %w", path+suffix, err) + } + snapshots = append(snapshots, snapshot) + } + } + return snapshots, nil +} + +func restoreProviderSyncDatabaseSnapshots(snapshots []providerSyncFileSnapshot) error { + var restoreErrors []error + for index := len(snapshots) - 1; index >= 0; index-- { + if err := restoreProviderSyncFileSnapshot(snapshots[index]); err != nil { + restoreErrors = append(restoreErrors, fmt.Errorf("%s: %w", snapshots[index].Path, err)) + } + } + return errors.Join(restoreErrors...) } func providerSyncLockStale(lockDir string, now time.Time) (bool, string) { @@ -1263,10 +1303,21 @@ func createProviderSyncBackup(home, targetProvider string, changes []sessionChan return fail(fmt.Errorf("备份 %s 失败:%w", name, err)) } } - dbDir := filepath.Join(backupDir, "db") - for _, name := range []string{"state_5.sqlite", "state_5.sqlite-wal", "state_5.sqlite-shm"} { - if _, err := copyProviderSyncBackupFileIfExists(filepath.Join(home, name), filepath.Join(dbDir, name)); err != nil { - return fail(fmt.Errorf("备份 %s 失败:%w", name, err)) + databaseManifest := make([]map[string]any, 0) + for index, dbPath := range codexSessionDBPaths(home) { + name := fmt.Sprintf("%02d-%s", index+1, filepath.Base(dbPath)) + for _, suffix := range []string{"", "-wal", "-shm"} { + backupRelative := filepath.Join("db", name+suffix) + copied, err := copyProviderSyncBackupFileIfExists(dbPath+suffix, filepath.Join(backupDir, backupRelative)) + if err != nil { + return fail(fmt.Errorf("备份 %s 失败:%w", dbPath+suffix, err)) + } + if copied { + databaseManifest = append(databaseManifest, map[string]any{ + "path": dbPath + suffix, + "backupPath": filepath.ToSlash(backupRelative), + }) + } } } manifest := make([]map[string]any, 0, len(changes)) @@ -1298,7 +1349,11 @@ func createProviderSyncBackup(home, targetProvider string, changes []sessionChan if err := atomicWriteJSON(filepath.Join(backupDir, "session-meta-backup.json"), manifest); err != nil { return fail(err) } - if err := atomicWriteJSON(filepath.Join(backupDir, "metadata.json"), map[string]any{"managedBy": "ChatGPT Codex Tools provider sync", "targetProvider": targetProvider}); err != nil { + if err := atomicWriteJSON(filepath.Join(backupDir, "metadata.json"), map[string]any{ + "managedBy": "ChatGPT Codex Tools provider sync", + "targetProvider": targetProvider, + "databaseBackups": databaseManifest, + }); err != nil { return fail(err) } return backupDir, nil diff --git a/scripts.go b/scripts.go index 936a0cf..6500169 100644 --- a/scripts.go +++ b/scripts.go @@ -199,15 +199,16 @@ func saveUserScriptConfig(config userScriptConfig) error { return atomicWriteJSON(userScriptsConfigPath(), config) } -func userScriptInventoryValue() map[string]any { - inventory := scanUserScripts() +func userScriptInventoryValue(runtimeStatus ...any) map[string]any { + inventory := scanUserScripts(runtimeStatus...) return map[string]any{ "enabled": inventory.Enabled, "builtin_dir": inventory.BuiltinDir, "user_dir": inventory.UserDir, "scripts": inventory.Scripts, } } -func scanUserScripts() userScriptInventory { +func scanUserScripts(runtimeStatus ...any) userScriptInventory { config := loadUserScriptConfig() + runtimeScripts := userScriptRuntimeStatusMap(runtimeStatus) inventory := userScriptInventory{Enabled: config.Enabled, BuiltinDir: builtinUserScriptsDir(), UserDir: userScriptsDir(), Scripts: []userScriptInventoryItem{}} _ = os.MkdirAll(userScriptsDir(), 0o755) appendScripts := func(source, dir string) { @@ -226,10 +227,14 @@ func scanUserScripts() userScriptInventory { enabled = true } status := "not_loaded" + errorText := "" if !config.Enabled || !enabled { status = "disabled" + } else if live, ok := runtimeScripts[key]; ok { + status = normalizeUserScriptRuntimeStatus(stringFromAny(live["status"])) + errorText = truncateRunes(stringFromAny(live["error"]), 4000) } - item := userScriptInventoryItem{Key: key, Name: entry.Name(), Source: source, Enabled: enabled, Status: status, Error: ""} + item := userScriptInventoryItem{Key: key, Name: entry.Name(), Source: source, Enabled: enabled, Status: status, Error: errorText} if market, ok := config.Market[key]; ok { item.MarketID = market.ID item.Version = market.Version @@ -245,6 +250,38 @@ func scanUserScripts() userScriptInventory { return inventory } +func userScriptRuntimeStatusMap(values []any) map[string]map[string]any { + if len(values) == 0 || values[0] == nil { + return nil + } + raw, ok := values[0].(map[string]any) + if !ok { + return nil + } + if nested, ok := raw["scripts"].(map[string]any); ok { + raw = nested + } + out := map[string]map[string]any{} + for key, value := range raw { + key = strings.TrimSpace(key) + item, ok := value.(map[string]any) + if !ok || key == "" || len(key) > 512 { + continue + } + out[key] = item + } + return out +} + +func normalizeUserScriptRuntimeStatus(value string) string { + switch strings.TrimSpace(value) { + case "loaded", "failed", "loading", "not_loaded": + return strings.TrimSpace(value) + default: + return "not_loaded" + } +} + func (s *server) setUserScriptEnabled(key string, enabled bool) commandResult { key = strings.TrimSpace(key) if key == "" { diff --git a/scripts_runtime_test.go b/scripts_runtime_test.go new file mode 100644 index 0000000..294bf68 --- /dev/null +++ b/scripts_runtime_test.go @@ -0,0 +1,76 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestUserScriptInventoryMergesValidatedRuntimeStatus(t *testing.T) { + configHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configHome) + path := filepath.Join(userScriptsDir(), "runtime.js") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("window.runtimeTest = true;"), 0o644); err != nil { + t.Fatal(err) + } + + inventory := scanUserScripts(map[string]any{ + "user:runtime.js": map[string]any{"status": "failed", "error": "boom"}, + "user:missing.js": map[string]any{"status": "loaded"}, + }) + if len(inventory.Scripts) != 1 { + t.Fatalf("scripts = %#v", inventory.Scripts) + } + item := inventory.Scripts[0] + if item.Status != "failed" || item.Error != "boom" { + t.Fatalf("runtime status was not merged: %#v", item) + } + + inventory = scanUserScripts(map[string]any{ + "scripts": map[string]any{"user:runtime.js": map[string]any{"status": "arbitrary", "error": strings.Repeat("x", 5000)}}, + }) + item = inventory.Scripts[0] + if item.Status != "not_loaded" || len([]rune(item.Error)) != 4000 { + t.Fatalf("runtime status should be normalized and bounded: %#v", item) + } +} + +func TestEnabledUserScriptBundleRecordsRuntimeLifecycle(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + path := filepath.Join(userScriptsDir(), "lifecycle.js") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("window.lifecycleTest = true;"), 0o644); err != nil { + t.Fatal(err) + } + bundle := enabledUserScriptBundle() + for _, expected := range []string{"__codexPlusUserScripts", `status = "loaded"`, `status = "failed"`, `user:lifecycle.js`} { + if !strings.Contains(bundle, expected) { + t.Fatalf("bundle missing %q:\n%s", expected, bundle) + } + } +} + +func TestUserScriptListBridgeAcceptsRendererRuntimeStatus(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + path := filepath.Join(userScriptsDir(), "bridge.js") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("window.bridgeTest = true;"), 0o644); err != nil { + t.Fatal(err) + } + result := (&launcherRuntime{}).handleBridgeRequest("/user-scripts/list", json.RawMessage(`{ + "runtime_status": {"user:bridge.js": {"status": "loaded", "error": ""}} +}`)) + items, ok := result["scripts"].([]userScriptInventoryItem) + if !ok || len(items) != 1 || items[0].Status != "loaded" { + t.Fatalf("bridge runtime status was not merged: %#v", result) + } +} diff --git a/session_actions.go b/session_actions.go index 5aef304..aad38e4 100644 --- a/session_actions.go +++ b/session_actions.go @@ -13,7 +13,7 @@ import ( "time" ) -const sessionDeleteBackupVersion = 1 +const sessionDeleteBackupVersion = 2 type sessionRolloutFile struct { Path string @@ -30,6 +30,8 @@ type sessionRolloutFile struct { type sessionSQLiteRow struct { Columns []string `json:"columns"` Values map[string]any `json:"values"` + DBPath string `json:"db_path,omitempty"` + Table string `json:"table,omitempty"` } type sessionLookupResult struct { @@ -120,13 +122,12 @@ func deleteSessionDataRoute(payload map[string]any) map[string]any { if err != nil { return map[string]any{"status": "failed", "session_id": sessionID, "message": "删除失败:创建备份失败:" + err.Error()} } - dbPath := filepath.Join(home, "state_5.sqlite") - if err := deleteSQLiteThreadRows(dbPath, lookup.allIDs()); err != nil { + if err := deleteSessionLookupRows(lookup); err != nil { return map[string]any{"status": "failed", "session_id": sessionID, "message": "删除失败:更新会话索引失败:" + err.Error()} } for _, file := range lookup.Files { if err := os.Remove(file.Path); err != nil && !errors.Is(err, os.ErrNotExist) { - _ = restoreSQLiteThreadRows(dbPath, lookup.DBRows) + _ = restoreSessionSQLiteRows(lookup.DBRows) return map[string]any{"status": "failed", "session_id": sessionID, "message": "删除失败:移除会话文件失败:" + err.Error(), "undo_token": undoToken} } } @@ -151,13 +152,16 @@ func undoSessionDataRoute(payload map[string]any) map[string]any { if err := readJSON(filepath.Join(backupDir, "manifest.json"), &manifest); err != nil { return map[string]any{"status": "failed", "message": "撤销失败:备份不存在或已损坏"} } + if err := validateDeletedSessionRestorePaths(manifest); err != nil { + return map[string]any{"status": "failed", "session_id": manifest.SessionID, "message": "撤销失败:" + err.Error()} + } for _, file := range manifest.Files { source := filepath.Join(backupDir, file.BackupName) if err := copyFileIfExists(source, file.OriginalPath); err != nil { return map[string]any{"status": "failed", "session_id": manifest.SessionID, "message": "撤销失败:恢复会话文件失败:" + err.Error()} } } - if err := restoreSQLiteThreadRows(filepath.Join(codexHomeDir(), "state_5.sqlite"), manifest.Rows); err != nil { + if err := restoreSessionSQLiteRows(manifest.Rows); err != nil { return map[string]any{"status": "failed", "session_id": manifest.SessionID, "message": "撤销失败:恢复会话索引失败:" + err.Error()} } return map[string]any{"status": "ok", "session_id": manifest.SessionID, "message": "已恢复会话。"} @@ -198,7 +202,7 @@ func moveThreadWorkspaceDataRoute(payload map[string]any) map[string]any { return map[string]any{"status": "failed", "session_id": sessionID, "message": "移动失败:更新会话文件失败:" + err.Error()} } } - if err := updateSQLiteThreadWorkspace(filepath.Join(home, "state_5.sqlite"), lookup.allIDs(), targetCWD); err != nil { + if err := updateSessionLookupWorkspace(lookup, targetCWD); err != nil { return map[string]any{"status": "failed", "session_id": sessionID, "message": "移动失败:更新会话索引失败:" + err.Error()} } if err := updateCodexGlobalStateForWorkspaceMove(home, lookup, targetCWD); err != nil { @@ -286,22 +290,25 @@ func lookupSession(home, sessionID, title string, archivedOnly bool) (sessionLoo var lookup sessionLookupResult lookup.RequestedID = strings.TrimSpace(sessionID) lookup.Variants = sessionIDVariants(sessionID) - dbPath := filepath.Join(home, "state_5.sqlite") var rows []sessionSQLiteRow var err error - if len(lookup.Variants) > 0 { - rows, err = sqliteThreadRowsByIDs(dbPath, lookup.Variants) - if err == nil && len(rows) == 0 { - rows, err = sqliteAutomationRunRowsByIDs(dbPath, lookup.Variants) + for _, dbPath := range codexSessionDBPaths(home) { + var candidate []sessionSQLiteRow + if len(lookup.Variants) > 0 { + candidate, err = sqliteThreadRowsByIDs(dbPath, lookup.Variants) + if err == nil && len(candidate) == 0 { + candidate, err = sqliteAutomationRunRowsByIDs(dbPath, lookup.Variants) + } + } else if strings.TrimSpace(title) != "" { + candidate, err = sqliteThreadRowsByTitle(dbPath, title, archivedOnly) + if err == nil && len(candidate) == 0 { + candidate, err = sqliteAutomationRunRowsByTitle(dbPath, title, archivedOnly) + } } - } else if strings.TrimSpace(title) != "" { - rows, err = sqliteThreadRowsByTitle(dbPath, title, archivedOnly) - if err == nil && len(rows) == 0 { - rows, err = sqliteAutomationRunRowsByTitle(dbPath, title, archivedOnly) + if err != nil { + return lookup, err } - } - if err != nil { - return lookup, err + rows = append(rows, candidate...) } lookup.DBRows = rows for _, row := range rows { @@ -385,6 +392,137 @@ func (l sessionLookupResult) allIDs() []string { return uniqueNonEmptyStrings(ids) } +func annotateSessionRows(rows []sessionSQLiteRow, dbPath, table string) []sessionSQLiteRow { + for index := range rows { + rows[index].DBPath = filepath.Clean(dbPath) + rows[index].Table = table + } + return rows +} + +func rowsByDatabase(rows []sessionSQLiteRow) map[string][]sessionSQLiteRow { + grouped := map[string][]sessionSQLiteRow{} + for _, row := range rows { + path := strings.TrimSpace(row.DBPath) + if path == "" { + path = codexPreferredSessionDBPath(codexHomeDir()) + } + grouped[filepath.Clean(path)] = append(grouped[filepath.Clean(path)], row) + } + return grouped +} + +func deleteSessionLookupRows(lookup sessionLookupResult) error { + ids := lookup.allIDs() + tablesByPath := map[string]map[string]bool{} + for _, row := range lookup.DBRows { + if (row.Table == "threads" || row.Table == "automation_runs") && strings.TrimSpace(row.DBPath) != "" { + path := filepath.Clean(row.DBPath) + if tablesByPath[path] == nil { + tablesByPath[path] = map[string]bool{} + } + tablesByPath[path][row.Table] = true + } + } + if len(tablesByPath) == 0 { + for _, path := range codexSessionDBPaths(codexHomeDir()) { + tablesByPath[path] = map[string]bool{"threads": true} + } + } + for path, tables := range tablesByPath { + for table := range tables { + if err := deleteSQLiteSessionRows(path, table, ids); err != nil { + return err + } + } + } + return nil +} + +func updateSessionLookupWorkspace(lookup sessionLookupResult, targetCWD string) error { + ids := lookup.allIDs() + paths := map[string]bool{} + for _, row := range lookup.DBRows { + if row.Table == "threads" && strings.TrimSpace(row.DBPath) != "" { + paths[filepath.Clean(row.DBPath)] = true + } + } + if len(paths) == 0 { + for _, path := range codexSessionDBPaths(codexHomeDir()) { + paths[path] = true + } + } + for path := range paths { + if err := updateSQLiteThreadWorkspace(path, ids, targetCWD); err != nil { + return err + } + } + return nil +} + +func restoreSessionSQLiteRows(rows []sessionSQLiteRow) error { + for dbPath, group := range rowsByDatabase(rows) { + byTable := map[string][]sessionSQLiteRow{} + for _, row := range group { + table := row.Table + if table == "" { + table = "threads" + } + byTable[table] = append(byTable[table], row) + } + for table, tableRows := range byTable { + if err := restoreSQLiteSessionRows(dbPath, table, tableRows); err != nil { + return err + } + } + } + return nil +} + +func validateDeletedSessionRestorePaths(manifest deletedSessionManifest) error { + allowed := map[string]bool{} + for _, row := range manifest.Rows { + if path := strings.TrimSpace(stringFromAny(row.Values["rollout_path"])); path != "" { + allowed[filepath.Clean(normalizeRolloutPath(codexHomeDir(), path))] = true + } + } + for _, file := range manifest.Files { + original := filepath.Clean(strings.TrimSpace(file.OriginalPath)) + if original == "." || original == "" { + return errors.New("备份包含空的会话恢复路径") + } + if allowed[original] { + continue + } + if manifest.Version >= 2 && manifestHasAutomationRows(manifest) && sessionRestorePathMatchesManifest(original, manifest) { + continue + } + // Legacy v1 backups did not store DB provenance; constrain them to Codex-owned rollout roots. + if manifest.Version <= 1 && (pathWithin(filepath.Join(codexHomeDir(), "sessions"), original) || pathWithin(filepath.Join(codexHomeDir(), "archived_sessions"), original)) { + continue + } + return fmt.Errorf("备份包含未授权的恢复路径:%s", original) + } + return nil +} + +func manifestHasAutomationRows(manifest deletedSessionManifest) bool { + for _, row := range manifest.Rows { + if row.Table == "automation_runs" { + return true + } + } + return false +} + +func sessionRestorePathMatchesManifest(path string, manifest deletedSessionManifest) bool { + if !pathWithin(filepath.Join(codexHomeDir(), "sessions"), path) && !pathWithin(filepath.Join(codexHomeDir(), "archived_sessions"), path) { + return false + } + id := bareSessionID(manifest.SessionID) + return id != "" && strings.Contains(filepath.Base(path), id) +} + func sessionIDVariants(sessionID string) []string { sessionID = strings.TrimSpace(sessionID) if sessionID == "" { @@ -976,7 +1114,8 @@ func sqliteThreadRowsByIDs(dbPath string, ids []string) ([]sessionSQLiteRow, err return nil, err } query := "SELECT * FROM threads WHERE id IN (" + sqlitePlaceholders(len(ids)) + ")" - return querySessionSQLiteRows(db, query, stringsToAny(ids)...) + rows, err := querySessionSQLiteRows(db, query, stringsToAny(ids)...) + return annotateSessionRows(rows, dbPath, "threads"), err } func sqliteThreadRowsByTitle(dbPath, title string, archivedOnly bool) ([]sessionSQLiteRow, error) { @@ -999,11 +1138,13 @@ func sqliteThreadRowsByTitle(dbPath, title string, archivedOnly bool) ([]session } order := sqliteSortOrder(columns) rows, err := querySessionSQLiteRows(db, "SELECT * FROM threads "+where+order+" LIMIT 5", title) + rows = annotateSessionRows(rows, dbPath, "threads") if err != nil || len(rows) > 0 { return rows, err } // Archived rows can be rendered with trimmed or decorated text in the UI, so keep a conservative normalized fallback. - return sqliteThreadRowsByNormalizedTitle(db, title, archivedOnly, columns) + rows, err = sqliteThreadRowsByNormalizedTitle(db, title, archivedOnly, columns) + return annotateSessionRows(rows, dbPath, "threads"), err } func sqliteAutomationRunRowsByIDs(dbPath string, ids []string) ([]sessionSQLiteRow, error) { @@ -1021,7 +1162,8 @@ func sqliteAutomationRunRowsByIDs(dbPath string, ids []string) ([]sessionSQLiteR return nil, err } query := "SELECT * FROM automation_runs WHERE thread_id IN (" + sqlitePlaceholders(len(ids)) + ")" - return querySessionSQLiteRows(db, query, stringsToAny(ids)...) + rows, err := querySessionSQLiteRows(db, query, stringsToAny(ids)...) + return annotateSessionRows(rows, dbPath, "automation_runs"), err } func sqliteAutomationRunRowsByTitle(dbPath, title string, archivedOnly bool) ([]sessionSQLiteRow, error) { @@ -1052,10 +1194,12 @@ func sqliteAutomationRunRowsByTitle(dbPath, title string, archivedOnly bool) ([] where += " AND COALESCE(status, '') = 'archived'" } rows, err := querySessionSQLiteRows(db, "SELECT * FROM automation_runs "+where+" LIMIT 5", title) + rows = annotateSessionRows(rows, dbPath, "automation_runs") if err != nil || len(rows) > 0 { return rows, err } - return sqliteAutomationRunRowsByNormalizedTitle(db, title, archivedOnly, columns, titleColumn) + rows, err = sqliteAutomationRunRowsByNormalizedTitle(db, title, archivedOnly, columns, titleColumn) + return annotateSessionRows(rows, dbPath, "automation_runs"), err } func sqliteAutomationRunRowsByNormalizedTitle(db *sql.DB, title string, archivedOnly bool, columns []string, titleColumn string) ([]sessionSQLiteRow, error) { @@ -1154,20 +1298,33 @@ func querySessionSQLiteRows(db *sql.DB, query string, args ...any) ([]sessionSQL } func deleteSQLiteThreadRows(dbPath string, ids []string) error { + return deleteSQLiteSessionRows(dbPath, "threads", ids) +} + +func deleteSQLiteSessionRows(dbPath, table string, ids []string) error { ids = uniqueNonEmptyStrings(ids) if len(ids) == 0 || !fileExists(dbPath) { return nil } + idColumn := "" + switch table { + case "threads": + idColumn = "id" + case "automation_runs": + idColumn = "thread_id" + default: + return fmt.Errorf("unsupported session SQLite table %q", table) + } db, err := openSQLite(dbPath) if err != nil { return err } defer db.Close() - columns, err := sqliteTableColumns(db, "threads") - if err != nil || len(columns) == 0 || !containsString(columns, "id") { + columns, err := sqliteTableColumns(db, table) + if err != nil || len(columns) == 0 || !containsString(columns, idColumn) { return err } - _, err = db.Exec("DELETE FROM threads WHERE id IN ("+sqlitePlaceholders(len(ids))+")", stringsToAny(ids)...) + _, err = db.Exec("DELETE FROM "+quoteSQLiteIdentifier(table)+" WHERE "+quoteSQLiteIdentifier(idColumn)+" IN ("+sqlitePlaceholders(len(ids))+")", stringsToAny(ids)...) return err } @@ -1191,15 +1348,22 @@ func updateSQLiteThreadWorkspace(dbPath string, ids []string, targetCWD string) } func restoreSQLiteThreadRows(dbPath string, rows []sessionSQLiteRow) error { + return restoreSQLiteSessionRows(dbPath, "threads", rows) +} + +func restoreSQLiteSessionRows(dbPath, table string, rows []sessionSQLiteRow) error { if len(rows) == 0 { return nil } + if table != "threads" && table != "automation_runs" { + return fmt.Errorf("unsupported session SQLite table %q", table) + } db, err := openSQLite(dbPath) if err != nil { return err } defer db.Close() - currentColumns, err := sqliteTableColumns(db, "threads") + currentColumns, err := sqliteTableColumns(db, table) if err != nil { return err } @@ -1224,7 +1388,7 @@ func restoreSQLiteThreadRows(dbPath string, rows []sessionSQLiteRow) error { for index, column := range columns { quoted[index] = quoteSQLiteIdentifier(column) } - query := "INSERT OR REPLACE INTO threads (" + strings.Join(quoted, ", ") + ") VALUES (" + sqlitePlaceholders(len(columns)) + ")" + query := "INSERT OR REPLACE INTO " + quoteSQLiteIdentifier(table) + " (" + strings.Join(quoted, ", ") + ") VALUES (" + sqlitePlaceholders(len(columns)) + ")" if _, err := db.Exec(query, args...); err != nil { return err } diff --git a/session_actions_test.go b/session_actions_test.go index e504a96..2b4d601 100644 --- a/session_actions_test.go +++ b/session_actions_test.go @@ -194,6 +194,72 @@ func TestDeleteThreadAndUndoRestoresRolloutAndSQLite(t *testing.T) { } } +func TestSessionActionsHonorCodexSQLiteHomeAndDatabaseProvenance(t *testing.T) { + home := t.TempDir() + sqliteHome := filepath.Join(t.TempDir(), "sqlite-home") + t.Setenv("HOME", home) + t.Setenv("CODEX_SQLITE_HOME", sqliteHome) + sessionID := "019a61dd-9748-7743-9ce9-92b8663a935b" + rolloutPath := filepath.Join(home, ".codex", "sessions", "2026", "07", "rollout-"+sessionID+".jsonl") + dbPath := filepath.Join(sqliteHome, "sqlite", "codex-dev.db") + writeTestFile(t, rolloutPath, testSessionRolloutLine(sessionID, "/old", "SQLite override")+"\n") + if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil { + t.Fatalf("create sqlite home: %v", err) + } + createTestThreadsTable(t, dbPath, sessionID, rolloutPath, "/old", "SQLite override") + + moved := handleSessionDataRoute("/move-thread-workspace", map[string]any{"session_id": sessionID, "target_cwd": "/new"}) + if moved["status"] != "moved" { + t.Fatalf("move through CODEX_SQLITE_HOME failed: %#v", moved) + } + if got := testThreadCWD(t, dbPath, sessionID); got != "/new" { + t.Fatalf("override database cwd = %q", got) + } + + deleted := handleSessionDataRoute("/delete", map[string]any{"session_id": sessionID}) + if deleted["status"] != "local_deleted" || testThreadCount(t, dbPath, sessionID) != 0 { + t.Fatalf("delete through CODEX_SQLITE_HOME failed: %#v", deleted) + } + restored := handleSessionDataRoute("/undo", map[string]any{"undo_token": deleted["undo_token"]}) + if restored["status"] != "ok" || testThreadCount(t, dbPath, sessionID) != 1 { + t.Fatalf("undo should restore the originating database: %#v", restored) + } +} + +func TestUndoRejectsManifestFileOutsideRecordedRollout(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + sessionID := "019a61dd-9748-7743-9ce9-92b8663a935b" + dbPath := filepath.Join(home, ".codex", "state_5.sqlite") + rolloutPath := filepath.Join(home, ".codex", "sessions", "rollout-"+sessionID+".jsonl") + outsidePath := filepath.Join(home, "outside.txt") + writeTestFile(t, rolloutPath, testSessionRolloutLine(sessionID, "/project", "Delete me")+"\n") + createTestThreadsTable(t, dbPath, sessionID, rolloutPath, "/project", "Delete me") + + deleted := handleSessionDataRoute("/delete", map[string]any{"session_id": sessionID}) + token := stringFromAny(deleted["undo_token"]) + backupDir, err := sessionDeleteBackupDir(token) + if err != nil { + t.Fatalf("backup dir: %v", err) + } + var manifest deletedSessionManifest + if err := readJSON(filepath.Join(backupDir, "manifest.json"), &manifest); err != nil { + t.Fatalf("read manifest: %v", err) + } + manifest.Files[0].OriginalPath = outsidePath + if err := atomicWriteJSON(filepath.Join(backupDir, "manifest.json"), manifest); err != nil { + t.Fatalf("tamper manifest: %v", err) + } + + restored := handleSessionDataRoute("/undo", map[string]any{"undo_token": token}) + if restored["status"] != "failed" || !strings.Contains(stringFromAny(restored["message"]), "未授权") { + t.Fatalf("tampered restore should fail: %#v", restored) + } + if fileExists(outsidePath) { + t.Fatal("tampered restore wrote outside the recorded rollout path") + } +} + func testSessionRolloutLine(sessionID, cwd, title string) string { data, _ := json.Marshal(map[string]any{ "type": "session_meta", diff --git a/settings.go b/settings.go index 68e238d..de4944f 100644 --- a/settings.go +++ b/settings.go @@ -106,12 +106,13 @@ func defaultSettings() backendSettings { ProviderSyncManualProviders: []string{}, RelayProfilesEnabled: true, Enhancements: true, - CodexAppForcePluginInstall: true, + CodexAppPluginMarketplaceUnlock: true, + CodexAppPluginAutoExpand: true, CodexAppModelWhitelistUnlock: true, CodexAppSessionDelete: true, CodexAppMarkdownExport: true, CodexAppForceChineseLocale: true, - CodexAppFastStartup: true, + CodexAppFastStartup: false, CodexAppProjectMove: true, CodexAppThreadScrollRestore: true, CodexAppZedRemoteOpen: true, @@ -121,6 +122,7 @@ func defaultSettings() backendSettings { ZedRemoteOpenStrategy: "addToFocusedWorkspace", ZedRemoteProjectRegistryEnabled: true, CodexAppImageOverlayOpacity: 35, + CodexAppImageOverlayFitMode: "fit", LaunchMode: "patch", RelayProfiles: []relayProfile{defaultRelayProfile()}, AggregateRelayProfiles: []aggregateRelayProfile{}, @@ -192,7 +194,6 @@ func normalizeSettings(settings backendSettings) backendSettings { settings.ProviderSyncManualProviders = []string{} } settings.Language = normalizeLanguage(settings.Language) - settings = normalizeDefaultEnabledSettings(settings) settings.ZedRemoteOpenStrategy = normalizeZedOpenStrategy(settings.ZedRemoteOpenStrategy) if settings.CodexAppImageOverlayOpacity <= 0 { settings.CodexAppImageOverlayOpacity = 35 @@ -204,6 +205,7 @@ func normalizeSettings(settings backendSettings) backendSettings { settings.CodexAppImageOverlayOpacity = 1 } settings.CodexAppImageOverlayPath = strings.TrimSpace(settings.CodexAppImageOverlayPath) + settings.CodexAppImageOverlayFitMode = normalizeImageOverlayFitMode(settings.CodexAppImageOverlayFitMode) settings.MobileControlRelayURL = strings.TrimSpace(settings.MobileControlRelayURL) settings.MobileControlRoom = strings.TrimSpace(settings.MobileControlRoom) settings.MobileControlKey = strings.TrimSpace(settings.MobileControlKey) @@ -336,56 +338,6 @@ func normalizeAggregateRelayStrategy(value string) string { } } -func normalizeDefaultEnabledSettings(settings backendSettings) backendSettings { - defaults := defaultEnabledSettingsFromRaw() - if !settings.RelayProfilesEnabled && !defaults["relayProfilesEnabled"] { - settings.RelayProfilesEnabled = true - } - if !settings.Enhancements && !defaults["enhancementsEnabled"] { - settings.Enhancements = true - } - if !settings.CodexAppForcePluginInstall && !defaults["codexAppForcePluginInstall"] { - settings.CodexAppForcePluginInstall = true - } - if !settings.CodexAppModelWhitelistUnlock && !defaults["codexAppModelWhitelistUnlock"] { - settings.CodexAppModelWhitelistUnlock = true - } - if !settings.CodexAppSessionDelete && !defaults["codexAppSessionDelete"] { - settings.CodexAppSessionDelete = true - } - if !settings.CodexAppMarkdownExport && !defaults["codexAppMarkdownExport"] { - settings.CodexAppMarkdownExport = true - } - if !settings.CodexAppForceChineseLocale && !defaults["codexAppForceChineseLocale"] { - settings.CodexAppForceChineseLocale = true - } - if !settings.CodexAppFastStartup && !defaults["codexAppFastStartup"] { - settings.CodexAppFastStartup = true - } - if !settings.CodexAppProjectMove && !defaults["codexAppProjectMove"] { - settings.CodexAppProjectMove = true - } - if !settings.CodexAppThreadScrollRestore && !defaults["codexAppThreadScrollRestore"] { - settings.CodexAppThreadScrollRestore = true - } - if !settings.CodexAppZedRemoteOpen && !defaults["codexAppZedRemoteOpen"] { - settings.CodexAppZedRemoteOpen = true - } - if !settings.CodexAppUpstreamWorktreeCreate && !defaults["codexAppUpstreamWorktreeCreate"] { - settings.CodexAppUpstreamWorktreeCreate = true - } - if !settings.CodexAppNativeMenuPlacement && !defaults["codexAppNativeMenuPlacement"] { - settings.CodexAppNativeMenuPlacement = true - } - if !settings.CodexAppNativeMenuLocalization && !defaults["codexAppNativeMenuLocalization"] { - settings.CodexAppNativeMenuLocalization = true - } - if !settings.ZedRemoteProjectRegistryEnabled && !defaults["zedRemoteProjectRegistryEnabled"] { - settings.ZedRemoteProjectRegistryEnabled = true - } - return settings -} - func normalizeZedOpenStrategy(value string) string { switch strings.TrimSpace(value) { case "reuseWindow", "newWindow", "default", "addToFocusedWorkspace": @@ -395,38 +347,13 @@ func normalizeZedOpenStrategy(value string) string { } } -func defaultEnabledSettingsFromRaw() map[string]bool { - data, err := os.ReadFile(settingsPath()) - if err != nil { - return map[string]bool{} - } - var raw map[string]any - if err := json.Unmarshal(data, &raw); err != nil { - return map[string]bool{} - } - out := map[string]bool{} - for _, key := range []string{ - "relayProfilesEnabled", - "enhancementsEnabled", - "codexAppForcePluginInstall", - "codexAppModelWhitelistUnlock", - "codexAppSessionDelete", - "codexAppMarkdownExport", - "codexAppPasteFix", - "codexAppForceChineseLocale", - "codexAppFastStartup", - "codexAppProjectMove", - "codexAppThreadIdBadge", - "codexAppThreadScrollRestore", - "codexAppZedRemoteOpen", - "codexAppUpstreamWorktreeCreate", - "codexAppNativeMenuPlacement", - "codexAppNativeMenuLocalization", - "zedRemoteProjectRegistryEnabled", - } { - _, out[key] = raw[key] - } - return out +func normalizeImageOverlayFitMode(value string) string { + switch strings.TrimSpace(value) { + case "fit", "fill", "stretch", "tile", "center": + return strings.TrimSpace(value) + default: + return "fit" + } } func migrateOfficialAuthBinding(settings backendSettings) (backendSettings, bool) { @@ -591,7 +518,7 @@ func pendingProviderImportPayload() any { } func (s *server) saveSettings(args map[string]any) commandResult { - var settings backendSettings + settings := loadSettings() if err := remarshal(args["settings"], &settings); err != nil { return failed("保存设置失败:"+err.Error(), settingsPayloadValue(loadSettings())) } diff --git a/types.go b/types.go index 6b1e657..9a33994 100644 --- a/types.go +++ b/types.go @@ -19,8 +19,8 @@ type backendSettings struct { RelayProfilesEnabled bool `json:"relayProfilesEnabled"` CCSLinkEnabled bool `json:"ccsLinkEnabled"` Enhancements bool `json:"enhancementsEnabled"` - CodexAppPluginEntryUnlock bool `json:"codexAppPluginEntryUnlock"` - CodexAppForcePluginInstall bool `json:"codexAppForcePluginInstall"` + CodexAppPluginMarketplaceUnlock bool `json:"codexAppPluginMarketplaceUnlock"` + CodexAppPluginAutoExpand bool `json:"codexAppPluginAutoExpand"` CodexAppModelWhitelistUnlock bool `json:"codexAppModelWhitelistUnlock"` CodexAppSessionDelete bool `json:"codexAppSessionDelete"` CodexAppMarkdownExport bool `json:"codexAppMarkdownExport"` @@ -44,6 +44,7 @@ type backendSettings struct { CodexAppImageOverlayEnabled bool `json:"codexAppImageOverlayEnabled"` CodexAppImageOverlayPath string `json:"codexAppImageOverlayPath"` CodexAppImageOverlayOpacity int `json:"codexAppImageOverlayOpacity"` + CodexAppImageOverlayFitMode string `json:"codexAppImageOverlayFitMode"` CodexGoalsEnabled bool `json:"codexGoalsEnabled"` MobileControlEnabled bool `json:"mobileControlEnabled"` MobileControlRelayURL string `json:"mobileControlRelayUrl"` diff --git a/web/src/App.tsx b/web/src/App.tsx index 2d1c81f..9b041cf 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -252,8 +252,8 @@ type BackendSettings = { relayProfilesEnabled: boolean; ccsLinkEnabled: boolean; enhancementsEnabled: boolean; - codexAppPluginEntryUnlock: boolean; - codexAppForcePluginInstall: boolean; + codexAppPluginMarketplaceUnlock: boolean; + codexAppPluginAutoExpand: boolean; codexAppModelWhitelistUnlock: boolean; codexAppSessionDelete: boolean; codexAppMarkdownExport: boolean; @@ -277,6 +277,7 @@ type BackendSettings = { codexAppImageOverlayEnabled: boolean; codexAppImageOverlayPath: string; codexAppImageOverlayOpacity: number; + codexAppImageOverlayFitMode: "fit" | "fill" | "stretch" | "tile" | "center"; codexGoalsEnabled: boolean; mobileControlEnabled: boolean; mobileControlRelayUrl: string; @@ -822,14 +823,14 @@ const defaultSettings: BackendSettings = { relayProfilesEnabled: true, ccsLinkEnabled: false, enhancementsEnabled: true, - codexAppPluginEntryUnlock: false, - codexAppForcePluginInstall: true, + codexAppPluginMarketplaceUnlock: true, + codexAppPluginAutoExpand: true, codexAppModelWhitelistUnlock: true, codexAppSessionDelete: true, codexAppMarkdownExport: true, codexAppPasteFix: false, codexAppForceChineseLocale: true, - codexAppFastStartup: true, + codexAppFastStartup: false, codexAppProjectMove: true, codexAppConversationTimeline: false, codexAppThreadIdBadge: false, @@ -847,6 +848,7 @@ const defaultSettings: BackendSettings = { codexAppImageOverlayEnabled: false, codexAppImageOverlayPath: "", codexAppImageOverlayOpacity: 35, + codexAppImageOverlayFitMode: "fit", codexGoalsEnabled: false, mobileControlEnabled: false, mobileControlRelayUrl: "", @@ -1191,7 +1193,7 @@ export function App() { showNotice("图片覆盖层", "未选择图片。", "not_checked"); return; } - const next = { + const next: BackendSettings = { ...settingsForm, codexAppImageOverlayEnabled: true, codexAppImageOverlayPath: selected.trim(), @@ -1202,11 +1204,12 @@ export function App() { }; const resetImageOverlaySettings = async () => { - const next = { + const next: BackendSettings = { ...settingsForm, codexAppImageOverlayEnabled: false, codexAppImageOverlayPath: "", codexAppImageOverlayOpacity: 35, + codexAppImageOverlayFitMode: "fit", }; setSettingsForm(next); const result = await saveSettingsValue(next, true); @@ -1810,6 +1813,14 @@ export function App() { } }; + const repairRemotePluginMarketplace = async () => { + const result = await run(() => call>>("repair_remote_plugin_marketplace")); + if (result) { + showNotice("官方远端插件缓存", result.message, result.status); + await refreshRelayFiles(true); + } + }; + const repairCodexGoals = async () => { const result = await run(() => call("repair_codex_goals")); if (result) { @@ -2265,6 +2276,7 @@ export function App() { repairBackend, repairCodexApp, repairPluginMarketplace, + repairRemotePluginMarketplace, installEntrypoints, uninstallEntrypoints, uninstallCodexTools, @@ -2609,6 +2621,7 @@ type Actions = { restart: () => Promise; repairBackend: () => Promise; repairPluginMarketplace: () => Promise; + repairRemotePluginMarketplace: () => Promise; installEntrypoints: () => Promise; uninstallEntrypoints: () => Promise; uninstallCodexTools: () => Promise; @@ -4045,16 +4058,17 @@ function EnhanceScreen({ {form.launchMode === "relay" ? (
- {mixedRelayMode ? "当前为混合 API 增强:保留官方登录能力,原生站点/插件市场和强制安装可继续使用。" : "当前为兼容增强模式:纯中转/聚合会关闭强制入口解锁和强制安装,其它页面功能仍可用。"} + {mixedRelayMode ? "当前为混合 API 增强:保留官方登录能力,插件市场解锁与自动展开可继续使用。" : "当前为兼容增强模式:纯中转/聚合会关闭插件市场 patch,其它页面功能仍可用。"}
) : null}
- setEnhanceFlag("codexAppForcePluginInstall", value)} /> + setEnhanceFlag("codexAppPluginMarketplaceUnlock", value)} /> + setEnhanceFlag("codexAppPluginAutoExpand", value)} /> setEnhanceFlag("codexAppModelWhitelistUnlock", value)} /> setEnhanceFlag("codexAppServiceTierControls", value)} /> setEnhanceFlag("codexAppPasteFix", value)} /> setEnhanceFlag("codexAppForceChineseLocale", value)} /> - setEnhanceFlag("codexAppFastStartup", value)} /> + setEnhanceFlag("codexAppFastStartup", value)} /> setEnhanceFlag("codexAppSessionDelete", value)} /> setEnhanceFlag("codexAppMarkdownExport", value)} /> setEnhanceFlag("codexAppProjectMove", value)} /> @@ -4087,6 +4101,10 @@ function EnhanceScreen({ 修复插件市场 + @@ -4809,6 +4827,22 @@ function SettingsScreen({ value={form.codexAppImageOverlayOpacity || 35} /> + + +