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 @@
模型白名单解锁
从环境变量和 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}
/>
+
+
+