diff --git a/docs-site/src/content/docs/reference/configuration/agents.md b/docs-site/src/content/docs/reference/configuration/agents.md index b5dbfa645..8ef27e59d 100644 --- a/docs-site/src/content/docs/reference/configuration/agents.md +++ b/docs-site/src/content/docs/reference/configuration/agents.md @@ -31,6 +31,15 @@ The management API exposes `GET`/`PUT /api/v2`, `/api/injection-model`, `/api/ef `/api/subagent-models`, and `/api/subagent-model-fallback`. Injection-model updates are partial; the custom prompt is the `prompt` field on that API. +The Codex Auth page can also toggle Codex's own `default_mode_request_user_input` +feature flag (`GET`/`PUT /api/codex-auth/features/default-mode-request-user-input`). Enabling it +adds `[features] default_mode_request_user_input = true` to Codex's +`$CODEX_HOME/config.toml` through the official `codex features enable|disable` CLI +(format-preserving edit, removed again when disabled), which lets Codex pause a +Default-mode session and ask you questions with the `request_user_input` tool. The +flag is under development upstream and only applies to new sessions; the toggle fails +loudly when the installed Codex build does not know the flag yet. + ## Roster and guidance The effective v2 roster is the configured, picker-visible, priority-sorted first five models that diff --git a/gui/src/components/DefaultModeRequestUserInputSetting.tsx b/gui/src/components/DefaultModeRequestUserInputSetting.tsx new file mode 100644 index 000000000..b72fabbff --- /dev/null +++ b/gui/src/components/DefaultModeRequestUserInputSetting.tsx @@ -0,0 +1,138 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useT } from "../i18n/shared"; +import { readJsonOrThrow } from "../fetch-json"; + +const FEATURE_ENDPOINT = "/api/codex-auth/features/default-mode-request-user-input"; + +type Feedback = { tone: "ok" | "err"; message: string } | null; + +/** + * Codex Auth page toggle for Codex's own `default_mode_request_user_input` + * feature flag. Reads/writes $CODEX_HOME/config.toml through the management + * API, which flips the flag via the official `codex features` CLI. + */ +export default function DefaultModeRequestUserInputSetting({ apiBase }: { apiBase: string }) { + const t = useT(); + const [enabled, setEnabled] = useState(false); + const [hydrated, setHydrated] = useState(false); + const [saving, setSaving] = useState(false); + const [loadError, setLoadError] = useState(false); + const [feedback, setFeedback] = useState(null); + const savingRef = useRef(false); + const enabledRef = useRef(false); + const loadGenerationRef = useRef(0); + + const load = useCallback(async () => { + // A poll landing between the optimistic flip and the PUT response must not + // revert the UI to the server's pre-save value. The generation also drops + // GETs that were already in flight when a save started. + if (savingRef.current) return; + const generation = ++loadGenerationRef.current; + try { + const res = await fetch(`${apiBase}${FEATURE_ENDPOINT}`); + if (!res.ok) throw new Error("load"); + const payload = await res.json() as { enabled?: unknown }; + if (savingRef.current || generation !== loadGenerationRef.current) return; + enabledRef.current = payload.enabled === true; + setEnabled(enabledRef.current); + setHydrated(true); + setLoadError(false); + } catch { + if (!savingRef.current && generation === loadGenerationRef.current) setLoadError(true); + } + }, [apiBase]); + + useEffect(() => { + const timeout = window.setTimeout(() => { void load(); }, 0); + const interval = window.setInterval(() => { void load(); }, 30_000); + return () => { + window.clearTimeout(timeout); + window.clearInterval(interval); + }; + }, [load]); + + const toggle = useCallback(async () => { + if (savingRef.current || !hydrated || loadError) return; + const next = !enabledRef.current; + const previous = enabledRef.current; + enabledRef.current = next; + setEnabled(next); + savingRef.current = true; + setSaving(true); + setFeedback(null); + loadGenerationRef.current++; + try { + const res = await fetch(`${apiBase}${FEATURE_ENDPOINT}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled: next }), + }); + const payload = (await readJsonOrThrow<{ ok?: boolean; enabled?: unknown; changed?: unknown }>(res)) ?? {}; + if (payload.ok !== true) throw new Error(String(res.status)); + enabledRef.current = payload.enabled === true; + setEnabled(enabledRef.current); + setHydrated(true); + setFeedback({ + tone: "ok", + message: t(payload.changed === true ? "codexAuth.requestUserInputUpdatedRestart" : "codexAuth.requestUserInputUpdated"), + }); + } catch (error) { + enabledRef.current = previous; + setEnabled(previous); + const reason = error instanceof Error && error.message && !/^HTTP \d{3}$/.test(error.message) + ? error.message + : t("codexAuth.requestUserInputUpdateFailed"); + setFeedback({ tone: "err", message: reason }); + } finally { + savingRef.current = false; + setSaving(false); + } + }, [apiBase, hydrated, loadError, t]); + + const controlsDisabled = saving || !hydrated || loadError; + + return ( +
+
+ {t("codexAuth.requestUserInput")} +
+ {loadError ? t("codexAuth.requestUserInputLoadFailed") : t("codexAuth.requestUserInputDesc")} +
+ + {`[features]\ndefault_mode_request_user_input = true`} + +
+
+ {loadError && ( + + )} + +
+ {feedback && ( +
+ {feedback.message} +
+ )} +
+ ); +} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index a07fdfbe0..ba20a16eb 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -818,6 +818,12 @@ export const de: Record = { "codexAuth.autoSwitchThresholdInvalid": "Gib eine ganze Zahl von 1 bis 100 ein", "codexAuth.autoSwitchUpdated": "Der proaktive Wechsel nach Nutzung wurde aktualisiert", "codexAuth.autoSwitchUpdateFailed": "Die Aktualisierung des nutzungsbasierten Wechsels konnte nicht bestätigt werden. Der zuletzt bestätigte Wert wird angezeigt.", + "codexAuth.requestUserInput": "Im Default-Modus nachfragen", + "codexAuth.requestUserInputDesc": "Erlaubt Codex, eine Session im Default-Modus zu pausieren und dir über das request_user_input-Tool Fragen zu stellen.", + "codexAuth.requestUserInputUpdated": "Feature-Flag aktualisiert - gilt für neue Sessions.", + "codexAuth.requestUserInputUpdatedRestart": "Feature-Flag aktualisiert - gilt für neue Sessions. Starte die Codex-App neu, damit es wirksam wird.", + "codexAuth.requestUserInputUpdateFailed": "Feature-Flag konnte nicht aktualisiert werden. Es wurde nichts geändert.", + "codexAuth.requestUserInputLoadFailed": "Feature-Flag konnte nicht aus config.toml gelesen werden.", "anthropicPool.title": "Claude-Kontenpool (experimentell)", "anthropicPool.enabledDesc": "Bei 429 wird das Konto gekühlt und umgeschaltet. Neue Sitzungen bevorzugen Nutzung unter {threshold}% (5-Stunden-Balken).", "anthropicPool.disabledDesc": "Nutzt nur das aktive Claude-Konto. Nur aktivieren, wenn experimentelles Routing akzeptabel ist.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 0a1c77a9c..c11ed7a0f 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1260,6 +1260,12 @@ export const en = { "codexAuth.autoSwitchThresholdInvalid": "Enter a whole number from 1 to 100", "codexAuth.autoSwitchUpdated": "Usage-based proactive switching updated", "codexAuth.autoSwitchUpdateFailed": "The usage-based switching update could not be confirmed. The last confirmed value is shown.", + "codexAuth.requestUserInput": "Ask for input in Default mode", + "codexAuth.requestUserInputDesc": "Lets Codex pause a Default-mode session and ask you questions with the request_user_input tool.", + "codexAuth.requestUserInputUpdated": "Feature flag updated - applies to new sessions.", + "codexAuth.requestUserInputUpdatedRestart": "Feature flag updated - applies to new sessions. Restart the Codex app to pick it up.", + "codexAuth.requestUserInputUpdateFailed": "Could not update the feature flag. Nothing was changed.", + "codexAuth.requestUserInputLoadFailed": "Could not read the feature flag from config.toml.", "anthropicPool.title": "Claude account pool (experimental)", "anthropicPool.enabledDesc": "On 429, cools the account and fails over. New sessions prefer usage under {threshold}% (5-hour bar).", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 8aa731b25..b2ca659dc 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1210,6 +1210,12 @@ export const ja: Record = { "codexAuth.autoSwitchThresholdInvalid": "1 から 100 までの整数を入力してください", "codexAuth.autoSwitchUpdated": "使用量ベースのプロアクティブ切り替え設定を更新しました", "codexAuth.autoSwitchUpdateFailed": "使用量ベースの切り替え更新を確認できませんでした。最後に確認された値を表示しています。", + "codexAuth.requestUserInput": "Default モードで入力を求める", + "codexAuth.requestUserInputDesc": "Default モードのセッションで Codex が一時停止し、request_user_input ツールで質問できるようにします。", + "codexAuth.requestUserInputUpdated": "機能フラグを更新しました - 新しいセッションから適用されます。", + "codexAuth.requestUserInputUpdatedRestart": "機能フラグを更新しました - 新しいセッションから適用されます。Codex アプリを再起動してください。", + "codexAuth.requestUserInputUpdateFailed": "機能フラグを更新できませんでした。変更はありません。", + "codexAuth.requestUserInputLoadFailed": "config.toml から機能フラグを読み込めませんでした。", "anthropicPool.title": "Claude アカウントプール(実験的)", "anthropicPool.enabledDesc": "429 時にアカウントをクールダウンしてフェイルオーバーします。新規セッションは 5 時間使用率が {threshold}% 未満のアカウントを優先します。", "anthropicPool.disabledDesc": "アクティブな Claude アカウントのみを使用します。実験的ルーティングを受け入れる場合のみ有効にしてください。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 88993b854..cafd04060 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -842,6 +842,12 @@ export const ko: Record = { "codexAuth.autoSwitchThresholdInvalid": "1~100 사이의 정수를 입력하세요", "codexAuth.autoSwitchUpdated": "사용량 기반 선제 전환 설정을 저장했습니다", "codexAuth.autoSwitchUpdateFailed": "사용량 기반 전환 설정 변경을 확인하지 못했습니다. 마지막으로 확인된 값을 표시합니다.", + "codexAuth.requestUserInput": "Default 모드에서 입력 요청", + "codexAuth.requestUserInputDesc": "Default 모드 세션에서 Codex가 일시 중지하고 request_user_input 도구로 질문할 수 있게 합니다.", + "codexAuth.requestUserInputUpdated": "기능 플래그가 업데이트되었습니다 - 새 세션부터 적용됩니다.", + "codexAuth.requestUserInputUpdatedRestart": "기능 플래그가 업데이트되었습니다 - 새 세션부터 적용됩니다. Codex 앱을 다시 시작하세요.", + "codexAuth.requestUserInputUpdateFailed": "기능 플래그를 업데이트하지 못했습니다. 변경된 내용이 없습니다.", + "codexAuth.requestUserInputLoadFailed": "config.toml에서 기능 플래그를 읽지 못했습니다.", "anthropicPool.title": "Claude 계정 풀(실험적)", "anthropicPool.enabledDesc": "429 시 계정을 쿨다운하고 장애 조치합니다. 새 세션은 5시간 사용량이 {threshold}% 미만인 계정을 우선합니다.", "anthropicPool.disabledDesc": "활성 Claude 계정만 사용합니다. 실험적 라우팅을 감수할 때만 켜세요.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 65a11f264..dffa3ac18 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1252,6 +1252,12 @@ export const ru: Record = { "codexAuth.autoSwitchThresholdInvalid": "Введите целое число от 1 до 100", "codexAuth.autoSwitchUpdated": "Проактивное переключение по использованию обновлено", "codexAuth.autoSwitchUpdateFailed": "Не удалось подтвердить обновление переключения по использованию. Показано последнее подтверждённое значение.", + "codexAuth.requestUserInput": "Запрашивать ввод в режиме Default", + "codexAuth.requestUserInputDesc": "Позволяет Codex ставить сеанс Default на паузу и задавать вопросы через инструмент request_user_input.", + "codexAuth.requestUserInputUpdated": "Флаг обновлён - применяется к новым сеансам.", + "codexAuth.requestUserInputUpdatedRestart": "Флаг обновлён - применяется к новым сеансам. Перезапустите приложение Codex.", + "codexAuth.requestUserInputUpdateFailed": "Не удалось обновить флаг. Ничего не изменено.", + "codexAuth.requestUserInputLoadFailed": "Не удалось прочитать флаг из config.toml.", "anthropicPool.title": "Пул аккаунтов Claude (экспериментально)", "anthropicPool.enabledDesc": "При 429 аккаунт охлаждается и выполняется переключение. Новые сессии предпочитают использование ниже {threshold}% (полоса 5 часов).", "anthropicPool.disabledDesc": "Используется только активный аккаунт Claude. Включайте только если принимаете экспериментальную маршрутизацию.", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 065b234d6..1a900a2f1 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -835,6 +835,12 @@ export const zh: Record = { "codexAuth.autoSwitchThresholdInvalid": "请输入 1 到 100 之间的整数", "codexAuth.autoSwitchUpdated": "基于用量的主动切换设置已更新", "codexAuth.autoSwitchUpdateFailed": "无法确认基于用量的切换更新。当前显示最后一次确认的值。", + "codexAuth.requestUserInput": "在 Default 模式下请求输入", + "codexAuth.requestUserInputDesc": "允许 Codex 在 Default 模式会话中暂停,并通过 request_user_input 工具向你提问。", + "codexAuth.requestUserInputUpdated": "功能标志已更新 - 适用于新会话。", + "codexAuth.requestUserInputUpdatedRestart": "功能标志已更新 - 适用于新会话。请重启 Codex 应用。", + "codexAuth.requestUserInputUpdateFailed": "无法更新功能标志。未做任何更改。", + "codexAuth.requestUserInputLoadFailed": "无法从 config.toml 读取功能标志。", "anthropicPool.title": "Claude 账户池(实验性)", "anthropicPool.enabledDesc": "遇到 429 时冷却该账户并故障转移。新会话优先使用 5 小时用量低于 {threshold}% 的账户。", "anthropicPool.disabledDesc": "仅使用当前活跃的 Claude 账户。仅在接受实验性路由时启用。", diff --git a/gui/src/pages/CodexAuth.tsx b/gui/src/pages/CodexAuth.tsx index a70d6837e..ea71df89b 100644 --- a/gui/src/pages/CodexAuth.tsx +++ b/gui/src/pages/CodexAuth.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useT } from "../i18n/shared"; import CodexAccountPool from "../components/CodexAccountPool"; +import DefaultModeRequestUserInputSetting from "../components/DefaultModeRequestUserInputSetting"; import { codexAccountModeState, type CodexAccountModeState } from "../codex-multi-state"; import { ensureOpenAiProvider, openAiAccountProviderState, OpenAiEnableError } from "../provider-payload"; import { readSessionListCache, writeSessionListCache } from "../session-list-cache"; @@ -174,5 +175,10 @@ export default function CodexAuth({ apiBase }: { apiBase: string }) { {enableError &&
{enableError}
} ; - return ; + return ( + <> + + + + ); } diff --git a/gui/src/styles.css b/gui/src/styles.css index fd02be2f5..df4ac8b1c 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -1357,6 +1357,14 @@ dialog.modal-overlay::backdrop { .codex-auto-switch-copy { flex: 1 1 auto; min-width: 0; } .codex-auto-switch-copy .card-sub { padding: 2px 0 0; } .codex-auto-switch-controls { display: flex; align-items: flex-end; gap: 12px; flex: 0 0 auto; margin-left: auto; } +.codex-request-user-input-card { gap: 16px; flex-wrap: wrap; } +.codex-request-user-input-copy { flex: 1 1 auto; min-width: 0; } +.codex-request-user-input-copy .card-sub { padding: 2px 0 0; } +.codex-request-user-input-config { display: block; margin-top: 6px; overflow-wrap: anywhere; font-size: var(--text-label); color: var(--muted); } +.codex-request-user-input-controls { display: flex; align-items: flex-end; gap: 12px; flex: 0 0 auto; margin-left: auto; } +.codex-request-user-input-controls > .toggle { margin-left: auto; } +.codex-request-user-input-feedback { flex: 1 0 100%; margin-top: -8px; color: var(--muted); font-size: var(--text-label); line-height: var(--leading-body); text-align: right; } +.codex-request-user-input-feedback.is-error { color: var(--red); } /* The toggle now lives in the slot below, which carries the auto margin itself. */ /* Toggle slot: matches the height of the threshold compound so the 20px toggle centres against diff --git a/gui/tests/codex-auth-request-user-input.test.tsx b/gui/tests/codex-auth-request-user-input.test.tsx new file mode 100644 index 000000000..466598066 --- /dev/null +++ b/gui/tests/codex-auth-request-user-input.test.tsx @@ -0,0 +1,241 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import DefaultModeRequestUserInputSetting from "../src/components/DefaultModeRequestUserInputSetting"; +import { LanguageProvider } from "../src/i18n/provider"; + +let previousLanguage: unknown; + +const domGlobals = ["document", "window", "navigator", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousDomGlobals: Record<(typeof domGlobals)[number], unknown>; +let testWindow: Window; +let mountedRoot: Root | null; + +function deferred(): { + promise: Promise; + resolve(value: T): void; + reject(reason?: unknown): void; +} { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +async function flush(): Promise { + await Promise.resolve(); + await new Promise((resolve) => testWindow.setTimeout(resolve, 0)); + await Promise.resolve(); +} + +beforeEach(() => { + previousLanguage = (globalThis.navigator as { language?: unknown } | undefined)?.language; + Object.defineProperty(globalThis.navigator, "language", { + configurable: true, + value: "en-US", + }); +}); + +afterEach(() => { + Object.defineProperty(globalThis.navigator, "language", { + configurable: true, + value: previousLanguage, + }); +}); + +function setupDom(): void { + previousDomGlobals = Object.fromEntries( + domGlobals.map((key) => [key, Reflect.get(globalThis, key)]), + ) as typeof previousDomGlobals; + testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperty(testWindow.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + mountedRoot = null; +} + +async function teardownDom(): Promise { + if (mountedRoot) { + await act(async () => { + mountedRoot?.unmount(); + }); + mountedRoot = null; + } + for (const key of domGlobals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousDomGlobals[key] }); + } + await testWindow.happyDOM?.close?.(); +} + +describe("DefaultModeRequestUserInputSetting", () => { + beforeEach(() => setupDom()); + afterEach(async () => { + await teardownDom(); + }); + + async function mount(fetchMock: typeof fetch): Promise { + globalThis.fetch = fetchMock; + const host = testWindow.document.createElement("div"); + testWindow.document.body.appendChild(host as never); + const { createRoot } = await import("react-dom/client"); + await act(async () => { + mountedRoot = createRoot(host); + mountedRoot.render( + + + , + ); + }); + await act(async () => { await flush(); }); + return host; + } + + function toggleButton(host: ParentNode): HTMLButtonElement { + const el = host.querySelector("button.toggle"); + if (!el) throw new Error("toggle missing"); + return el; + } + + test("renders the flag card and stays disabled until the GET hydrates", async () => { + const active = deferred(); + const host = await mount((async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/api/codex-auth/features/default-mode-request-user-input") && (!init || init.method === undefined)) { + return active.promise; + } + throw new Error(`unexpected fetch: ${url} ${init?.method ?? "GET"}`); + }) as typeof fetch); + + expect(host.textContent).toContain("Ask for input in Default mode"); + expect(host.textContent).toContain("[features]\ndefault_mode_request_user_input = true"); + expect(toggleButton(host).disabled).toBe(true); + + await act(async () => { + active.resolve(new Response(JSON.stringify({ enabled: true, key: "default_mode_request_user_input" }), { status: 200 })); + await flush(); + }); + expect(toggleButton(host).disabled).toBe(false); + expect(toggleButton(host).getAttribute("aria-pressed")).toBe("true"); + }); + + test("toggle PUTs the new value and shows confirmation", async () => { + const puts: unknown[] = []; + const host = await mount((async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/api/codex-auth/features/default-mode-request-user-input") && init?.method === "PUT") { + puts.push(init.body ? JSON.parse(String(init.body)) : null); + return new Response(JSON.stringify({ ok: true, enabled: true, changed: true, warnings: [] }), { status: 200 }); + } + return new Response(JSON.stringify({ enabled: false, key: "default_mode_request_user_input" }), { status: 200 }); + }) as typeof fetch); + + await act(async () => { + toggleButton(host).click(); + await flush(); + }); + expect(puts).toEqual([{ enabled: true }]); + expect(toggleButton(host).getAttribute("aria-pressed")).toBe("true"); + expect(host.textContent).toContain("Restart the Codex app to pick it up."); + }); + + test("an unchanged PUT keeps the base success copy without the restart hint", async () => { + const host = await mount((async (_input: RequestInfo | URL, init?: RequestInit) => { + if (init?.method === "PUT") { + return new Response(JSON.stringify({ ok: true, enabled: true, changed: false, warnings: [] }), { status: 200 }); + } + return new Response(JSON.stringify({ enabled: false, key: "default_mode_request_user_input" }), { status: 200 }); + }) as typeof fetch); + + await act(async () => { + toggleButton(host).click(); + await flush(); + }); + expect(host.textContent).toContain("Feature flag updated - applies to new sessions."); + expect(host.textContent).not.toContain("Restart the Codex app"); + }); + + test("failed PUT reverts the toggle and reports the error", async () => { + const host = await mount((async (_input: RequestInfo | URL, init?: RequestInit) => { + if (init?.method === "PUT") { + return new Response(JSON.stringify({ error: "toggle failed" }), { status: 502 }); + } + return new Response(JSON.stringify({ enabled: false, key: "default_mode_request_user_input" }), { status: 200 }); + }) as typeof fetch); + + await act(async () => { + toggleButton(host).click(); + await flush(); + }); + expect(toggleButton(host).getAttribute("aria-pressed")).toBe("false"); + expect(host.textContent).toContain("toggle failed"); + }); + + test("a poll GET in flight during a toggle cannot revert the optimistic state", async () => { + const firstGet = deferred(); + const secondGet = deferred(); + const put = deferred(); + let getCount = 0; + let putStarted = false; + let pollCallback: (() => void) | null = null; + const originalSetInterval = testWindow.setInterval.bind(testWindow); + testWindow.setInterval = ((_cb: TimerHandler, _ms?: number, ..._args: unknown[]) => { + // Hold the 30s poll so the test can fire it at the exact overlap point. + if (typeof _cb === "function") pollCallback = _cb as () => void; + return originalSetInterval(_cb, _ms, ..._args) as number; + }) as typeof testWindow.setInterval; + + const host = await mount((async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/api/codex-auth/features/default-mode-request-user-input") && init?.method === "PUT") { + putStarted = true; + return put.promise; + } + if (url.endsWith("/api/codex-auth/features/default-mode-request-user-input")) { + getCount++; + return getCount === 1 ? firstGet.promise : secondGet.promise; + } + throw new Error(`unexpected fetch: ${url}`); + }) as typeof fetch); + + await act(async () => { + firstGet.resolve(new Response(JSON.stringify({ enabled: false, key: "default_mode_request_user_input" }), { status: 200 })); + await flush(); + }); + expect(toggleButton(host).disabled).toBe(false); + expect(toggleButton(host).getAttribute("aria-pressed")).toBe("false"); + + // Start the poll GET that will still be in flight when the user toggles. + await act(async () => { + pollCallback?.(); + await flush(); + }); + + await act(async () => { + toggleButton(host).click(); + await flush(); + }); + expect(toggleButton(host).getAttribute("aria-pressed")).toBe("true"); + + // The stale pre-toggle GET resolves mid-PUT and must be ignored. + await act(async () => { + secondGet.resolve(new Response(JSON.stringify({ enabled: false, key: "default_mode_request_user_input" }), { status: 200 })); + await flush(); + }); + expect(toggleButton(host).getAttribute("aria-pressed")).toBe("true"); + + await act(async () => { + put.resolve(new Response(JSON.stringify({ ok: true, enabled: true, changed: true, warnings: [] }), { status: 200 })); + await flush(); + }); + expect(toggleButton(host).getAttribute("aria-pressed")).toBe("true"); + expect(host.textContent).toContain("Restart the Codex app to pick it up."); + }); +}); diff --git a/src/cli/v2.ts b/src/cli/v2.ts index 7f0a7beb3..952d5741d 100644 --- a/src/cli/v2.ts +++ b/src/cli/v2.ts @@ -11,7 +11,8 @@ * - nothing in the catalog build path calls this module; no auto-flip exists. */ import { execFileSync } from "node:child_process"; -import { getAgentsEnabled, getAgentsMaxDepth, getLogicalMaxThreads, getSubagentDeveloperInstructions, hasAgentsMaxThreads, isMultiAgentV2Enabled, transitionMultiAgentV2 } from "../codex/features"; +import { dirname } from "node:path"; +import { activeCodexConfigPath, getAgentsEnabled, getAgentsMaxDepth, getLogicalMaxThreads, getSubagentDeveloperInstructions, hasAgentsMaxThreads, isMultiAgentV2Enabled, transitionMultiAgentV2 } from "../codex/features"; import { commandInvocation, type SpawnInvocation } from "../lib/win-exec"; import { loadConfig, saveConfig } from "../config"; @@ -30,13 +31,16 @@ export type CodexFeaturesInvocationDeps = & Pick; /** - * Shared invocation for `codex features enable|disable multi_agent_v2` — the single + * Shared invocation for `codex features enable|disable ` — the single * source of truth for the CLI and the management API fallback. Windows npm installs * expose `codex` as a `.cmd` shim, which needs the win-exec launcher - * (devlog 260715_cross_platform_audit/020). + * (devlog 260715_cross_platform_audit/020). Upstream `codex features` validates + * the key against the installed build's feature registry, so an old Codex will + * fail loudly instead of silently writing an unknown flag. */ export function codexFeaturesInvocation( action: "enable" | "disable", + feature: string = "multi_agent_v2", platform: NodeJS.Platform = process.platform, deps: CodexFeaturesInvocationDeps = {}, ): SpawnInvocation { @@ -48,15 +52,38 @@ export function codexFeaturesInvocation( configDir: deps.configDir, readFileSync: deps.readFileSync, }).runtime.command || "codex"; - return commandInvocation(command, ["features", action, "multi_agent_v2"], platform, deps); + return commandInvocation(command, ["features", action, feature], platform, deps); +} + +/** + * Run `codex features ` synchronously - the management API + * fallback when no deps toggle is injected. Shares the invocation builder and + * the bounded timeout/stdio options so every production toggle path behaves + * identically. + */ +export function runCodexFeaturesCommand( + action: "enable" | "disable", + feature: string = "multi_agent_v2", +): void { + const inv = codexFeaturesInvocation(action, feature); + execFileSync(inv.file, inv.args, + { + stdio: ["ignore", "pipe", "pipe"], timeout: 15_000, windowsHide: true, encoding: "utf8", + // The reader resolves $CODEX_HOME at call time (including the WSL Windows-home + // detection); force the same home on the child so it never toggles a different + // config than the one the postcondition re-reads. + env: { ...process.env, CODEX_HOME: dirname(activeCodexConfigPath()) }, + ...inv.options, + }); } function runCodexFeatures(action: "enable" | "disable", deps: V2CliDeps): void { - const exec = deps.execFile ?? ((file: string, args: string[], options?: SpawnInvocation["options"]) => { - execFileSync(file, args, { stdio: ["ignore", "pipe", "pipe"], timeout: 15_000, windowsHide: true, ...options }); - }); - const inv = codexFeaturesInvocation(action); - exec(inv.file, inv.args, inv.options); + if (deps.execFile) { + const inv = codexFeaturesInvocation(action); + deps.execFile(inv.file, inv.args, inv.options); + return; + } + runCodexFeaturesCommand(action); } export function v2StatusLine(enabled: boolean): string { diff --git a/src/codex/features.ts b/src/codex/features.ts index 8990391f5..489cb0e7d 100644 --- a/src/codex/features.ts +++ b/src/codex/features.ts @@ -1,11 +1,14 @@ /** * features.ts — codex feature-flag view for $CODEX_HOME/config.toml. * - * Scope boundary: this module mirrors ONLY `multi_agent_v2`, because opencodex has - * to migrate its concurrency value across the v1/v2 boundary and expose the - * multi-agent config surface. Every other upstream feature flag is delegated to - * the native `codex features` command (see src/cli/v2.ts) and must not be - * hardcoded here. + * Scope boundary: this module mirrors only the flags opencodex has to READ + * directly from config.toml: + * - `multi_agent_v2`, because opencodex migrates its concurrency value across + * the v1/v2 boundary and exposes the multi-agent config surface; + * - `default_mode_request_user_input` (Codex Auth page toggle), because the + * management API needs a live reader for the flag it manages. + * Every other upstream feature flag is delegated to the native `codex features` + * command (see src/cli/v2.ts) and must not be hardcoded here. * * Upstream reshapes flags freely: in the 1f0566d3f..5a1097ed2 range alone, * `code_mode_host` changed from a boolean to a table (it is Stage::Stable and @@ -34,6 +37,9 @@ import { AtomicWriteResidualTempError, AtomicWriteSecretResidualError, atomicWri import { forgetEphemeralSecretPath } from "../lib/windows-secret-acl"; import { CODEX_CONFIG_PATH } from "./paths"; +/** Upstream codex-rs feature key: allow `request_user_input` in Default mode. */ +export const DEFAULT_MODE_REQUEST_USER_INPUT_FEATURE_KEY = "default_mode_request_user_input"; + // EOL preservation, local copies of inject.ts dominantEol/applyEol: importing // inject here would close a module cycle (features -> inject -> catalog -> features). function dominantEol(content: string): "\r\n" | "\n" { @@ -56,7 +62,7 @@ function mergeTrailingComments(existing?: string, migrated?: string): string { return `${existing}; ${migratedText}`; } -function activeCodexConfigPath(): string { +export function activeCodexConfigPath(): string { const raw = process.env.CODEX_HOME?.trim(); if (!raw) return CODEX_CONFIG_PATH; const path = resolve(expandUserPath(raw)); @@ -128,6 +134,22 @@ export function isMultiAgentV2Enabled(configPath?: string): boolean { return false; } +/** + * TRUE when the codex `default_mode_request_user_input` feature is enabled in + * config.toml — lets a Default-mode session pause and ask the user questions + * through `request_user_input` (upstream FeatureSpec: under development, + * default_enabled = false). Recognizes the shipped boolean form + * `[features] default_mode_request_user_input = true`. + * Missing file/key -> false. + */ +export function isDefaultModeRequestUserInputEnabled(configPath?: string): boolean { + const content = readConfigText(configPath); + if (content === null) return false; + const features = tomlTableBody(content, "features"); + if (features === null) return false; + return tomlBoolInBody(features, DEFAULT_MODE_REQUEST_USER_INPUT_FEATURE_KEY) === true; +} + /** * TRUE when config.toml still carries `[agents] max_threads` — codex-rs REFUSES to * boot with that key while multi_agent_v2 is enabled ("agents.max_threads cannot be diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index 200dd956a..3d4f1e241 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -229,16 +229,11 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise const warnings: string[] = []; const requestedFlag = wantsFlag ? body.enabled as boolean : modeFlag; if (requestedFlag !== undefined || wantsThreads) { - const targetFlag = requestedFlag ?? isMultiAgentV2Enabled(); - let toggle = deps.toggleCodexMultiAgentV2; - if (!toggle) { - const { execFileSync } = await import("node:child_process"); - const { codexFeaturesInvocation } = await import("../../cli/v2"); - toggle = (enabled: boolean) => { - const inv = codexFeaturesInvocation(enabled ? "enable" : "disable"); - execFileSync(inv.file, inv.args, - { stdio: ["ignore", "pipe", "pipe"], timeout: 15_000, windowsHide: true, ...inv.options }); - }; + const targetFlag = requestedFlag ?? isMultiAgentV2Enabled(); + let toggle = deps.toggleCodexMultiAgentV2; + if (!toggle) { + const { runCodexFeaturesCommand } = await import("../../cli/v2"); + toggle = (enabled: boolean) => runCodexFeaturesCommand(enabled ? "enable" : "disable"); } const result = transitionMultiAgentV2(targetFlag, toggle, { ...(wantsThreads ? { threadLimit: body.maxConcurrentThreadsPerSession as number } : {}), @@ -299,6 +294,60 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise }); } + // default_mode_request_user_input feature toggle (Codex Auth page). GET reads the + // flag from $CODEX_HOME/config.toml; PUT flips it via the official `codex features` + // CLI so the TOML edit stays upstream-owned and format-preserving. + if (url.pathname === "/api/codex-auth/features/default-mode-request-user-input" && req.method === "GET") { + const { isDefaultModeRequestUserInputEnabled, DEFAULT_MODE_REQUEST_USER_INPUT_FEATURE_KEY } = await import("../../codex/features"); + return jsonResponse({ + enabled: isDefaultModeRequestUserInputEnabled(), + key: DEFAULT_MODE_REQUEST_USER_INPUT_FEATURE_KEY, + }); + } + if (url.pathname === "/api/codex-auth/features/default-mode-request-user-input" && req.method === "PUT") { + let parsedBody: unknown; + try { + parsedBody = await readManagementJsonBody(req); + } catch (error) { + rethrowManagementBodyTooLarge(error); + return jsonResponse({ error: "invalid JSON body" }, 400); + } + if (!parsedBody || typeof parsedBody !== "object" || Array.isArray(parsedBody)) { + return jsonResponse({ error: "body must be a JSON object" }, 400); + } + const body = parsedBody as { enabled?: unknown }; + if (typeof body.enabled !== "boolean") return jsonResponse({ error: "body.enabled must be a boolean" }, 400); + const { isDefaultModeRequestUserInputEnabled, DEFAULT_MODE_REQUEST_USER_INPUT_FEATURE_KEY } = await import("../../codex/features"); + const before = isDefaultModeRequestUserInputEnabled(); + let toggle = deps.toggleDefaultModeRequestUserInput; + if (!toggle) { + const { runCodexFeaturesCommand } = await import("../../cli/v2"); + toggle = (enabled: boolean) => runCodexFeaturesCommand(enabled ? "enable" : "disable", DEFAULT_MODE_REQUEST_USER_INPUT_FEATURE_KEY); + } + let toggleError: string | null = null; + try { + toggle(body.enabled); + } catch (error) { + const err = error as { stderr?: unknown; message?: string }; + const raw = err.stderr; + const stderrText = typeof raw === "string" + ? raw.trim() + : raw instanceof Uint8Array ? new TextDecoder().decode(raw).trim() : ""; + toggleError = stderrText || (err.message ?? String(error)); + } + const enabled = isDefaultModeRequestUserInputEnabled(); + if (toggleError !== null || enabled !== body.enabled) { + const reason = toggleError + ?? `postcondition failed - the installed Codex build may not know the ${DEFAULT_MODE_REQUEST_USER_INPUT_FEATURE_KEY} flag yet`; + return jsonResponse({ error: `default_mode_request_user_input toggle failed: ${reason}` }, 502); + } + const warnings: string[] = []; + if (enabled !== before) { + warnings.push("Applies to new sessions; restart the Codex app or wait out its picker cache to see the change."); + } + return jsonResponse({ ok: true, enabled, changed: enabled !== before, warnings }); + } + // Subagent prompt injection model: single native or routed model whose info is // dynamically injected into the v1 proactive prompt, plus an optional reasoning // effort the prompt tells the agent to pass to spawn_agent. GET returns the current diff --git a/src/server/management/context.ts b/src/server/management/context.ts index 61d37e7a7..cb163fd52 100644 --- a/src/server/management/context.ts +++ b/src/server/management/context.ts @@ -3,6 +3,7 @@ import type { StartupInstallAction } from "../startup-action-control"; export interface ManagementApiDeps { toggleCodexMultiAgentV2?: (enabled: boolean) => void; + toggleDefaultModeRequestUserInput?: (enabled: boolean) => void; refreshCodexCatalog?: () => Promise; /** * Persistence seam for route-level tests. Production leaves this unset and uses diff --git a/tests/codex-v2-gate.test.ts b/tests/codex-v2-gate.test.ts index e064fbc80..0a8a86c0b 100644 --- a/tests/codex-v2-gate.test.ts +++ b/tests/codex-v2-gate.test.ts @@ -17,6 +17,7 @@ import { getMaxConcurrentThreads, getSubagentDeveloperInstructions, hasAgentsMaxThreads, + isDefaultModeRequestUserInputEnabled, isMultiAgentV2Enabled, isTranslatableV1ChildLimit, isTranslatableV2TotalLimit, @@ -123,6 +124,15 @@ describe("features.ts config reader", () => { expect(isMultiAgentV2Enabled(fixtureConfig("[features.multi_agent_v2]\n[notice]\nenabled = true\n"))).toBe(false); }); + test("default_mode_request_user_input: boolean under [features]", () => { + expect(isDefaultModeRequestUserInputEnabled(fixtureConfig("[features]\ndefault_mode_request_user_input = true\n"))).toBe(true); + expect(isDefaultModeRequestUserInputEnabled(fixtureConfig("[features]\ndefault_mode_request_user_input = false\n"))).toBe(false); + expect(isDefaultModeRequestUserInputEnabled(fixtureConfig("[features]\nfast_mode = true\n"))).toBe(false); + expect(isDefaultModeRequestUserInputEnabled(fixtureConfig("model = \"gpt-5.5\"\n"))).toBe(false); + expect(isDefaultModeRequestUserInputEnabled(fixtureConfig("[features.multi_agent_v2]\nenabled = true\n"))).toBe(false); + expect(isDefaultModeRequestUserInputEnabled("/nonexistent/config.toml")).toBe(false); + }); + test("hasAgentsMaxThreads detects the boot-conflict key", () => { expect(hasAgentsMaxThreads(fixtureConfig("[agents]\nmax_threads = 1000\n"))).toBe(true); expect(hasAgentsMaxThreads(fixtureConfig("[features.multi_agent_v2]\nenabled = true\n"))).toBe(false); @@ -822,6 +832,147 @@ describe("management API parity surface for the WP2 keys", () => { }); }); +describe("management API default_mode_request_user_input toggle", () => { + function requestUserInputEnv(run: () => Promise): Promise { + const oldCodexHome = process.env.CODEX_HOME; + const path = fixtureConfig(""); + process.env.CODEX_HOME = dirname(path); + return run().finally(() => { + if (oldCodexHome === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = oldCodexHome; + }); + } + + function putRequest(enabled: unknown): Request { + return new Request("http://localhost/api/codex-auth/features/default-mode-request-user-input", { + method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ enabled }), + }); + } + + test("GET reports the flag from config.toml", async () => { + await requestUserInputEnv(async () => { + const response = await handleManagementAPI( + new Request("http://localhost/api/codex-auth/features/default-mode-request-user-input"), + new URL("http://localhost/api/codex-auth/features/default-mode-request-user-input"), + { providers: [] } as never, + { refreshCodexCatalog: async () => {} }, + ); + expect(response?.status).toBe(200); + expect(await response?.json()).toEqual({ enabled: false, key: "default_mode_request_user_input" }); + }); + }); + + test("PUT round-trips through the injected toggle and persists config.toml", async () => { + await requestUserInputEnv(async () => { + const path = join(process.env.CODEX_HOME!, "config.toml"); + const toggle = (enabled: boolean) => { + const content = readFileSync(path, "utf8"); + const line = `default_mode_request_user_input = ${enabled}`; + const next = /default_mode_request_user_input = (?:true|false)/.test(content) + ? content.replace(/default_mode_request_user_input = (?:true|false)/, line) + : `${content}\n[features]\n${line}\n`; + writeFileSync(path, next); + }; + const deps = { toggleDefaultModeRequestUserInput: toggle, refreshCodexCatalog: async () => {} }; + const url = new URL("http://localhost/api/codex-auth/features/default-mode-request-user-input"); + + const on = await handleManagementAPI(putRequest(true), url, { providers: [] } as never, deps); + expect(on?.status).toBe(200); + expect(await on?.json()).toMatchObject({ ok: true, enabled: true, changed: true }); + expect(readFileSync(path, "utf8")).toContain("[features]\ndefault_mode_request_user_input = true"); + + const off = await handleManagementAPI(putRequest(false), url, { providers: [] } as never, deps); + expect(off?.status).toBe(200); + expect(await off?.json()).toMatchObject({ ok: true, enabled: false, changed: true }); + expect(readFileSync(path, "utf8")).toContain("default_mode_request_user_input = false"); + }); + }); + + test("PUT rejects non-boolean bodies before any toggle runs", async () => { + await requestUserInputEnv(async () => { + let toggles = 0; + const response = await handleManagementAPI( + putRequest("yes"), + new URL("http://localhost/api/codex-auth/features/default-mode-request-user-input"), + { providers: [] } as never, + { toggleDefaultModeRequestUserInput: () => { toggles++; }, refreshCodexCatalog: async () => {} }, + ); + expect(response?.status).toBe(400); + expect(toggles).toBe(0); + }); + }); + + test("PUT rejects null, array, and non-object bodies with 400", async () => { + await requestUserInputEnv(async () => { + const url = new URL("http://localhost/api/codex-auth/features/default-mode-request-user-input"); + for (const rawBody of ["null", "[]", "\"yes\"", "42"]) { + const response = await handleManagementAPI( + new Request("http://localhost/api/codex-auth/features/default-mode-request-user-input", { + method: "PUT", headers: { "content-type": "application/json" }, body: rawBody, + }), + url, + { providers: [] } as never, + { toggleDefaultModeRequestUserInput: () => { throw new Error("must not toggle"); }, refreshCodexCatalog: async () => {} }, + ); + expect(response?.status).toBe(400); + } + }); + }); + + test("PUT rejects an oversized chunked body with 413", async () => { + await requestUserInputEnv(async () => { + const payload = JSON.stringify({ enabled: true, pad: "x".repeat(5 * 1024 * 1024) }); + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(payload)); + controller.close(); + }, + }); + const response = await handleManagementAPI( + new Request("http://localhost/api/codex-auth/features/default-mode-request-user-input", { + method: "PUT", headers: { "content-type": "application/json" }, body: stream, + }), + new URL("http://localhost/api/codex-auth/features/default-mode-request-user-input"), + { providers: [] } as never, + { toggleDefaultModeRequestUserInput: () => { throw new Error("must not toggle"); }, refreshCodexCatalog: async () => {} }, + ); + expect(response?.status).toBe(413); + }); + }); + + test("PUT surfaces the CLI diagnostic in the 502 when the toggle throws", async () => { + await requestUserInputEnv(async () => { + const toggle = () => { + throw Object.assign(new Error("Command failed: codex features enable"), { + stderr: Buffer.from("unknown feature flag: default_mode_request_user_input"), + }); + }; + const response = await handleManagementAPI( + putRequest(true), + new URL("http://localhost/api/codex-auth/features/default-mode-request-user-input"), + { providers: [] } as never, + { toggleDefaultModeRequestUserInput: toggle, refreshCodexCatalog: async () => {} }, + ); + expect(response?.status).toBe(502); + const body = await response?.json(); + expect(body.error).toContain("unknown feature flag: default_mode_request_user_input"); + }); + }); + + test("PUT fails with 502 when the toggle does not land (unknown flag / old Codex)", async () => { + await requestUserInputEnv(async () => { + const response = await handleManagementAPI( + putRequest(true), + new URL("http://localhost/api/codex-auth/features/default-mode-request-user-input"), + { providers: [] } as never, + { toggleDefaultModeRequestUserInput: () => {}, refreshCodexCatalog: async () => {} }, + ); + expect(response?.status).toBe(502); + expect(await response?.json()).toMatchObject({ error: expect.stringContaining("default_mode_request_user_input toggle failed") }); + }); + }); +}); + describe("cli surface", () => { test("status lines describe the multi-agent surface", () => { expect(v2StatusLine(true)).toContain("ON"); @@ -863,14 +1014,20 @@ describe("cli surface", () => { test("codexFeaturesInvocation: POSIX passthrough; win32 .cmd routed through cmd.exe (devlog 260715 020)", () => { const execFileSync = () => "codex-cli 0.145.0"; - expect(codexFeaturesInvocation("enable", "darwin", { + expect(codexFeaturesInvocation("enable", "multi_agent_v2", "darwin", { env: { PATH: "" }, configDir: mkdtempSync(join(tmpdir(), "ocx-v2-inv-posix-")), existsSync: () => false, execFileSync, })).toEqual({ file: "codex", args: ["features", "enable", "multi_agent_v2"], options: {} }); + expect(codexFeaturesInvocation("enable", "default_mode_request_user_input", "darwin", { + env: { PATH: "" }, + configDir: mkdtempSync(join(tmpdir(), "ocx-v2-inv-posix-")), + existsSync: () => false, + execFileSync, + })).toEqual({ file: "codex", args: ["features", "enable", "default_mode_request_user_input"], options: {} }); // Explicit CODEX_CLI_PATH pointing at a .cmd (npm-only Windows Codex install). - const inv = codexFeaturesInvocation("disable", "win32", { + const inv = codexFeaturesInvocation("disable", "multi_agent_v2", "win32", { env: { CODEX_CLI_PATH: "C:\\npm\\codex.cmd", ComSpec: "C:\\WINDOWS\\system32\\cmd.exe", PATH: "" }, configDir: mkdtempSync(join(tmpdir(), "ocx-v2-inv-cmd-")), existsSync: () => true, @@ -881,7 +1038,7 @@ describe("cli surface", () => { expect(inv.args).toEqual(["/d", "/s", "/c", '"C:\\npm\\codex.cmd ^"features^" ^"disable^" ^"multi_agent_v2^""']); expect(inv.options).toEqual({ windowsVerbatimArguments: true }); // Bare `codex` resolving to codex.exe stays a direct spawn. - const exe = codexFeaturesInvocation("enable", "win32", { + const exe = codexFeaturesInvocation("enable", "multi_agent_v2", "win32", { env: { PATH: "C:\\bin" }, configDir: mkdtempSync(join(tmpdir(), "ocx-v2-inv-exe-")), existsSync: (p: string) => p === "C:\\bin\\codex.exe",