diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index f292b55120..0279ee96ba 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -491,8 +491,26 @@ For a split self-hosted deployment, set `ORCAROUTER_API_BASE_URL` and `ORCAROUTER_AUTH_BASE_URL` separately. The value must be an HTTPS origin (or HTTP loopback for local development) with no credentials, -query, or fragment. Re-run the login after a relay `401`; OrcaRouter keys are durable and do not -have a refresh-token grant. +query, or fragment. Before the first login to a loopback/private self-hosted endpoint, explicitly +allow that destination in your `~/.opencodex/config.json` provider row. For example, merge this +entry into the existing `providers` object for a local development server: + +```json +{ + "orcarouter-oauth": { + "adapter": "openai-chat", + "baseUrl": "http://127.0.0.1:9999/v1", + "authMode": "oauth", + "allowPrivateNetwork": true + } +} +``` + +Then run `ORCAROUTER_BASE_URL=http://127.0.0.1:9999 ocx login orcarouter-oauth`. +Login preserves this explicit consent; setting the URL alone never enables private-network access. +Without the opt-in, destination validation rejects inference and model discovery for that endpoint. +This requirement concerns the provider endpoint; the browser callback listener needs no such opt-in. +Re-run the login after a relay `401`; OrcaRouter keys are durable and do not have a refresh-token grant. **Meta Model API (`meta-model`).** Muse Spark on Meta's own OpenAI-compatible endpoint, served over `/v1/responses`. Create a key in diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 32cd198174..72adcdcd70 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -134,6 +134,10 @@ on. **Logs** works the same way with `#logs` and `#logs/debug`. An older `#provi bookmark now lands on `#providers`. Cost values in **Logs** and **Usage** are API list-price equivalents calculated from reported tokens. +For a custom usage interval, the server must confirm the exact requested start and end times. +If an older running proxy does not support those bounds, the dashboard and CLI reject its report; +upgrade and restart that proxy before retrying. Resetting a manual model price affects only that +model, preserving other rates saved independently. They are not billing receipts or evidence of an actual charge; subscription usage or provider credits may apply instead. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index d8ae722afe..a6ecac02ae 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -415,6 +415,12 @@ an account credential. The public defaults intentionally split authentication (`https://www.orcarouter.ai`) from inference (`https://api.orcarouter.ai/v1`). Set `ORCAROUTER_BASE_URL` before the first account login for a one-origin self-hosted deployment, or use `ORCAROUTER_AUTH_BASE_URL` and `ORCAROUTER_API_BASE_URL` for separate origins. +For a loopback/private self-hosted endpoint, **before the first login**, create or update +`providers["orcarouter-oauth"]` with `adapter: "openai-chat"`, the intended `baseUrl`, +`authMode: "oauth"`, and an explicit `allowPrivateNetwork: true`. Login preserves that operator +setting and never grants it from a URL override. Without it, destination validation rejects the +local endpoint for inference and model discovery. The OAuth browser callback listener itself +does not require this provider opt-in. See the [OrcaRouter setup example](/guides/providers/). ## Provider diagnostic outbound safety diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index 54c3c0d614..fdaad97fb9 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -286,6 +286,24 @@ ORCAROUTER_BASE_URL=https://router.example ocx login orcarouter-oauth `ORCAROUTER_API_BASE_URL`。 该值必须是 HTTPS origin(本地开发可使用 HTTP loopback),且不能包含用户名密码、query 或 fragment。 +首次登录回环或私有网络中的自托管服务前,必须在 `~/.opencodex/config.json` 中明确允许访问该地址。 +例如,将以下条目合并到现有的 `providers` 对象中,用于本地开发服务: + +```json +{ + "orcarouter-oauth": { + "adapter": "openai-chat", + "baseUrl": "http://127.0.0.1:9999/v1", + "authMode": "oauth", + "allowPrivateNetwork": true + } +} +``` + +然后运行 `ORCAROUTER_BASE_URL=http://127.0.0.1:9999 ocx login orcarouter-oauth`。 +登录会保留这项明确授权;仅设置 URL 不会自动启用私有网络访问。 +未设置此选项时,目标地址校验会拒绝该服务的推理和模型发现请求。 +此要求针对 provider 的服务地址,浏览器回调监听器不需要此选项。 若 relay 返回 `401`,重新运行登录即可;OrcaRouter 签发的是长期 API key,不存在 refresh-token grant。 **Command Code 配额:**仪表盘和 `ocx account refresh` 会在规范主机 `https://api.commandcode.ai` 上探测 `/alpha/billing/credits` 窗口(5 小时和每周)。OAuth 预设 (`command-code`) 使用已保存的账户 bearer;Provider-API 密钥预设 (`commandcode`) 使用当前配置的有效密钥。用户改写后的仿冒 base URL 不会被探测。当 Command Code 同时返回周期消耗时,剩余的 monthly / purchased / free credits 会显示为 USD 窗口。 diff --git a/gui/src/components/ModelPickerOrderEditor.tsx b/gui/src/components/ModelPickerOrderEditor.tsx index 1ca60c96b1..e5a29efcc9 100644 --- a/gui/src/components/ModelPickerOrderEditor.tsx +++ b/gui/src/components/ModelPickerOrderEditor.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useEffectEvent, useLayoutEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useEffectEvent, useLayoutEffect, useRef, useState, type DragEvent } from "react"; import { createBoundedFetch, type BoundedFetch } from "../bounded-fetch"; import { readJsonOrThrow } from "../fetch-json"; import { IconArrowDown, IconArrowUp, IconGrip } from "../icons"; @@ -133,7 +133,14 @@ export default function ModelPickerOrderEditor({ apiBase, active, identities, on setAnnouncement(t("models.pickerOrder.position", { model: id, position: next.indexOf(id) + 1, total: next.length })); clearDrag(); }; - const movable = (id: string) => !disabled && draft.includes(id) && !snapshot?.fixed.includes(id); + const draftSet = new Set(draft); + const fixedSet = new Set(snapshot?.fixed); + const movable = (id: string) => !disabled && draftSet.has(id) && !fixedSet.has(id); + const dragOver = (event: DragEvent, id: string) => { + if (!lifetime.current.drag || lifetime.current.drag.id === id || !movable(lifetime.current.drag.id) || !movable(id) + || !event.dataTransfer.types.includes(DRAG_TYPE)) return; + event.preventDefault(); event.dataTransfer.dropEffect = "move"; setOver(id); + }; return

{t("models.pickerOrder.editorHint")}

{(blocked || identityChanged) &&

{t(blocked ?? "models.pickerOrder.changed")}

} @@ -141,13 +148,9 @@ export default function ModelPickerOrderEditor({ apiBase, active, identities, on {snapshot && draft.length === 0 &&

{t("models.pickerOrder.empty")}

}
    {draft.map((id, index) => { - const fixed = snapshot?.fixed.includes(id) === true; + const fixed = fixedSet.has(id); return
  1. { - if (!lifetime.current.drag || lifetime.current.drag.id === id || !movable(lifetime.current.drag.id) || !movable(id) - || !event.dataTransfer.types.includes(DRAG_TYPE)) return; - event.preventDefault(); event.dataTransfer.dropEffect = "move"; setOver(id); - }} + onDragOver={event => dragOver(event, id)} onDragLeave={() => setOver(null)} onDrop={event => { const source = lifetime.current.drag; @@ -167,12 +170,12 @@ export default function ModelPickerOrderEditor({ apiBase, active, identities, on {fixed && {t("models.pickerOrder.featured")}} diff --git a/gui/src/components/subagents-workspace/SubagentDelegationSection.tsx b/gui/src/components/subagents-workspace/SubagentDelegationSection.tsx index a1b405c2d9..a975786a94 100644 --- a/gui/src/components/subagents-workspace/SubagentDelegationSection.tsx +++ b/gui/src/components/subagents-workspace/SubagentDelegationSection.tsx @@ -63,6 +63,13 @@ export default function SubagentDelegationSection({ const nativeMayUseV2 = ultraMode.enabled || (ultraMode.multiAgentMode !== "v1" && !(ultraMode.multiAgentMode === "v2" && ultraMode.keepNativeChatGptOnV1)); const showV2Compatibility = !ultraLoadFailed && ultraMode.loaded === true && routedPreferred && nativeMayUseV2; + const availableModelSet = new Set(availableModels); + const fallbackSet = new Set(fallback); + const [pollDraft, setPollDraft] = useState(() => ({ pollMs: fallbackPollMs, text: String(fallbackPollMs) })); + // Keep blank/invalid input text while reconciling accepted settings from a load or save. + if (!Object.is(pollDraft.pollMs, fallbackPollMs)) { + setPollDraft({ pollMs: fallbackPollMs, text: Number.isFinite(fallbackPollMs) ? String(fallbackPollMs) : "" }); + } const fallbackControlsRef = useRef(null); const [identity, setIdentity] = useState(() => ({ models: fallback, @@ -172,7 +179,7 @@ export default function SubagentDelegationSection({ {fallback.map((modelName, index) => (
    {index + 1}. {modelName} - {!availableModels.includes(modelName) && {t("sub.fallbackUnavailable")}} + {!availableModelSet.has(modelName) && {t("sub.fallbackUnavailable")}} @@ -188,10 +195,16 @@ export default function SubagentDelegationSection({ ))} onFallbackPollMsChange(Number(e.target.value))} disabled={fallbackBusy} aria-invalid={!validPollMs} /> ms + { + const text = e.currentTarget.value; + const parsed = Number(text); + const pollMs = text.trim() !== "" && Number.isFinite(parsed) ? parsed : Number.NaN; + setPollDraft({ pollMs, text }); + onFallbackPollMsChange(pollMs); + }} disabled={fallbackBusy} aria-invalid={!validPollMs} /> ms {!validPollMs &&
    {t("sub.fallbackPollInvalid")}
    } diff --git a/gui/src/components/use-add-provider-oauth.ts b/gui/src/components/use-add-provider-oauth.ts index 87ec2146a8..b28fa4473c 100644 --- a/gui/src/components/use-add-provider-oauth.ts +++ b/gui/src/components/use-add-provider-oauth.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef } from "react"; import type { TFn } from "../i18n/shared"; import { readJsonIfOk } from "../fetch-json"; import { openBrowserRequestField } from "../oauth-open-browser-pref"; +import { afterOAuthCancellation, cancelOAuthLogin } from "../oauth-cancellation-barrier"; export const OAUTH_LOGIN_POLL_INTERVAL_MS = 2_000; @@ -35,14 +36,8 @@ export function useAddProviderOAuth({ return generation; }, []); - const cancelServerLogin = useCallback(async (providerId: string) => { - await fetch(`${apiBase}/api/oauth/login/cancel`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ provider: providerId }), - keepalive: true, - }).catch(() => undefined); - }, [apiBase]); + const cancelServerLogin = useCallback((providerId: string) => + cancelOAuthLogin(apiBase, providerId), [apiBase]); useEffect(() => { const cancelActiveLogins = (clearUi: boolean) => { @@ -97,15 +92,19 @@ export function useAddProviderOAuth({ setManualCodeMsg(""); setManualCodeOk(true); try { - const res = await fetch(`${apiBase}/api/oauth/login`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ provider: providerId, ...openBrowserRequestField() }), + const res = await afterOAuthCancellation(apiBase, providerId, () => { + if (!aliveRef.current || !isCurrent()) return; + return fetch(`${apiBase}/api/oauth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: providerId, ...openBrowserRequestField() }), + }); }); - if (!aliveRef.current || !isCurrent()) return; + if (!res || !aliveRef.current || !isCurrent()) return; if (!res.ok) { activeProvidersRef.current.delete(providerId); const data = await res.json().catch(() => ({})) as { error?: string }; + if (!aliveRef.current || !isCurrent()) return; setOauthMsgTone("warn"); setOauthMsg(data.error === "unknown oauth provider" ? t("modal.oauthComingSoonShort") @@ -116,6 +115,7 @@ export function useAddProviderOAuth({ // carry the only human-readable step. Keep all three: the hint renderer // decides what to show, rather than this hook deciding what to discard. const data = await res.json() as { url?: string; instructions?: string; deviceCode?: string; error?: string }; + if (!aliveRef.current || !isCurrent()) return; setOauthUrl(data.url ?? "", providerId, data.deviceCode, data.instructions); if (data.url || data.deviceCode) setOauthMsg(t("modal.waitingLogin")); else setOauthMsg(data.instructions || t("modal.loggingIn")); @@ -144,7 +144,7 @@ export function useAddProviderOAuth({ setOauthMsg(t("modal.loginTimeout")); } catch { if (isCurrent()) await cancelServerLogin(providerId); - activeProvidersRef.current.delete(providerId); + if (isCurrent()) activeProvidersRef.current.delete(providerId); if (aliveRef.current && isCurrent()) { setOauthMsgTone("warn"); setOauthMsg(t("modal.networkError")); diff --git a/gui/src/oauth-cancellation-barrier.ts b/gui/src/oauth-cancellation-barrier.ts new file mode 100644 index 0000000000..7af337ff1e --- /dev/null +++ b/gui/src/oauth-cancellation-barrier.ts @@ -0,0 +1,41 @@ +// Cancellation is provider-scoped on the server. Keep outstanding deliveries +// outside React instances so reopening either login surface cannot overtake one. +const cancellations = new Map>(); + +export function cancelOAuthLogin(apiBase: string, provider: string): Promise { + const key = JSON.stringify([apiBase, provider]); + const pending = cancellations.get(key); + if (pending) return pending; + + const delivery = (async () => { + await fetch(`${apiBase}/api/oauth/login/cancel`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider }), + keepalive: true, + }); + })().catch(() => { + // Preserve best-effort cleanup: a transport failure must not wedge retries. + // Settlement is an ordering barrier, not proof of server cancellation. + }).finally(() => { + if (cancellations.get(key) === delivery) cancellations.delete(key); + }); + cancellations.set(key, delivery); + return delivery; +} + +export async function afterOAuthCancellation( + apiBase: string, + provider: string, + start: () => T | Promise, +): Promise { + const key = JSON.stringify([apiBase, provider]); + const pending = cancellations.get(key); + if (pending) { + await pending; + return afterOAuthCancellation(apiBase, provider, start); + } + // Check the hook's generation and dispatch in the same turn as the barrier + // check, so another cancellation cannot slip into an extra await boundary. + return start(); +} diff --git a/gui/src/pages/Subagents.tsx b/gui/src/pages/Subagents.tsx index cf9f1b33d0..bee28ec901 100644 --- a/gui/src/pages/Subagents.tsx +++ b/gui/src/pages/Subagents.tsx @@ -8,7 +8,7 @@ import { useDataSurface } from "../data-surface"; import { DataSurfaceSkeleton } from "../components/data-surface"; import { useSubagentDelegation, type UltraModePatch, type UltraModeState } from "./use-subagent-delegation"; -type CachedSubagents = { available: string[]; chosen: string[]; fallback?: string[]; pollMs?: number }; +type CachedSubagents = { available: string[]; chosen: string[]; fallback?: string[]; pollMs?: number; fallbackAvailable?: string[] }; function seedSubagents(cacheKey: string): CachedSubagents | null { return readSessionListCache(cacheKey); @@ -23,6 +23,13 @@ export default function Subagents({ apiBase }: { apiBase: string }) { const [fallbackPollMs, setFallbackPollMs] = useState(() => cached?.pollMs ?? 60000); const [fallbackBusy, setFallbackBusy] = useState(false); const [fallbackLoaded, setFallbackLoaded] = useState(() => Array.isArray(cached?.fallback) && Number.isInteger(cached?.pollMs)); + const [fallbackAvailable, setFallbackAvailable] = useState(() => cached?.fallbackAvailable); + const [fallbackError, setFallbackError] = useState(""); + const [fallbackLoading, setFallbackLoading] = useState(true); + const fallbackLoadController = useRef(null); + const fallbackSnapshot = useRef>({ + fallback: cached?.fallback, pollMs: cached?.pollMs, fallbackAvailable: cached?.fallbackAvailable, + }); const fallbackRevision = useRef(0); const rosterRevision = useRef(0); const fallbackSaveInFlight = useRef(false); @@ -125,37 +132,64 @@ export default function Subagents({ apiBase }: { apiBase: string }) { } }, [loadUltraMode, t]); + const loadFallback = useCallback(async () => { + fallbackLoadController.current?.abort(); + const controller = new AbortController(); + fallbackLoadController.current = controller; + const { signal } = controller; + const readRevision = fallbackRevision.current; + try { + const res = await fetch(`${apiBase}/api/subagent-model-fallback`, { signal }); + const data = await readJsonOrThrow<{ models?: unknown; pollMs?: unknown; available?: unknown }>(res); + if (!data || !Array.isArray(data.models) || !data.models.every(model => typeof model === "string" && model.trim()) + || typeof data.pollMs !== "number" || !Number.isInteger(data.pollMs) || data.pollMs < 5000 || data.pollMs > 600000 + || !Array.isArray(data.available) || !data.available.every(model => typeof model === "string" && model.trim())) { + throw new Error(t("sub.loadFail")); + } + if (signal.aborted || readRevision !== fallbackRevision.current || fallbackSaveInFlight.current) return; + const next = { fallback: data.models, pollMs: data.pollMs, fallbackAvailable: data.available }; + fallbackSnapshot.current = next; + setFallback(next.fallback); + setFallbackPollMs(next.pollMs); + setFallbackAvailable(next.fallbackAvailable); + setFallbackLoaded(true); + setFallbackError(""); + // An auxiliary success cannot seed a successful roster before its own read settles. + if (committed.current) { + committed.current = { ...committed.current, ...next }; + writeSessionListCache(cacheKey, committed.current); + } + } catch (error) { + if (signal.aborted || readRevision !== fallbackRevision.current || fallbackSaveInFlight.current) return; + setFallbackLoaded(false); + setFallbackError(error instanceof Error && !(error instanceof SyntaxError) ? error.message : t("sub.loadFail")); + } finally { + if (!signal.aborted) setFallbackLoading(false); + } + }, [apiBase, cacheKey, t]); + + useEffect(() => { + void (async () => { await loadFallback(); })(); + return () => { fallbackLoadController.current?.abort(); }; + }, [loadFallback]); + const loadSubagents = useCallback(async (signal?: AbortSignal): Promise => { - // The resource layer's deadline abort must reach the wire — a signal dropped - // here is a store that can only settle by race timeout. + // Auxiliary fallback discovery must neither reject nor delay the roster resource. const rosterReadRevision = rosterRevision.current; - const fallbackReadRevision = fallbackRevision.current; - const [rosterRes, fallbackRes] = await Promise.all([ - fetch(`${apiBase}/api/subagent-models`, { signal }), - fetch(`${apiBase}/api/subagent-model-fallback`, { signal }), - ]); + const rosterRes = await fetch(`${apiBase}/api/subagent-models`, { signal }); const response = await readJsonOrThrow<{ available?: string[]; chosen?: string[] }>(rosterRes, t("sub.loadFail")); - const fallbackResponse = await readJsonOrThrow<{ available?: string[]; models?: string[]; pollMs?: number }>(fallbackRes, t("sub.loadFail")); - if (!response || !fallbackResponse) throw new Error(t("sub.loadFail")); - const available = response.available ?? fallbackResponse.available ?? []; + if (!response) throw new Error(t("sub.loadFail")); + const available = response.available ?? []; const availableSet = new Set(available); const rosterCurrent = rosterReadRevision === rosterRevision.current && !saveInFlight.current; - const fallbackCurrent = fallbackReadRevision === fallbackRevision.current && !fallbackSaveInFlight.current; const next = { + ...fallbackSnapshot.current, available, chosen: rosterCurrent ? (response.chosen ?? []).filter(model => availableSet.has(model)) : committed.current?.chosen ?? [], - // Configured targets remain editable even when discovery no longer advertises them. - fallback: fallbackCurrent ? fallbackResponse.models ?? [] : committed.current?.fallback ?? [], - pollMs: fallbackCurrent ? fallbackResponse.pollMs ?? 60000 : committed.current?.pollMs ?? 60000, }; if (signal?.aborted) throw signal.reason; committed.current = next; if (rosterCurrent) setChosen(next.chosen); - if (fallbackCurrent) { - setFallback(next.fallback); - setFallbackPollMs(next.pollMs); - setFallbackLoaded(true); - } writeSessionListCache(cacheKey, next); return next; }, [apiBase, cacheKey, t]); @@ -241,7 +275,8 @@ export default function Subagents({ apiBase }: { apiBase: string }) { fallbackRevision.current += 1; setFallback(d.models); setFallbackPollMs(d.pollMs); - const next = { available, chosen: committed.current?.chosen ?? [], fallback: d.models, pollMs: d.pollMs }; + fallbackSnapshot.current = { ...fallbackSnapshot.current, fallback: d.models, pollMs: d.pollMs }; + const next = { available, chosen: committed.current?.chosen ?? [], ...fallbackSnapshot.current }; committed.current = next; writeSessionListCache(cacheKey, next); setOk(true); @@ -277,9 +312,17 @@ export default function Subagents({ apiBase }: { apiBase: string }) {
    {status && {status}} {state.showError && {t("sub.loadFail")}} - {!fallbackLoaded && state.showError && } + {fallbackError && ( + + {t("sub.fallbackLabel")}: {t("sub.loadFail")} + {fallbackError !== t("sub.loadFail") && <> {fallbackError}} + + + )} ) : state.kind === "failed-cold" ? ( - {connected ? t("usage.hubOffline") : state.error instanceof Error ? `${t("usage.loadError")} ${state.error.message}` : t("usage.loadError")}{" "} + {state.error instanceof UsageWindowMismatchError + ? `${t("usage.loadError")} ${t("dash.codexRestartMalformed")}` + : connected ? t("usage.hubOffline") : state.error instanceof Error ? `${t("usage.loadError")} ${state.error.message}` : t("usage.loadError")}{" "} diff --git a/gui/src/pages/use-dashboard-data.ts b/gui/src/pages/use-dashboard-data.ts index 8634e7caa8..605d4eefc4 100644 --- a/gui/src/pages/use-dashboard-data.ts +++ b/gui/src/pages/use-dashboard-data.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; import { useKeyedClientResource } from "../client-resource"; import { replaceHash } from "../hash-routing"; import { useI18n } from "../i18n/shared"; @@ -70,6 +70,55 @@ type CachedOverview = { type MaMode = "v1" | "default" | "v2"; +type CodexPreference = "codexAutoStart" | "codexDesktopAuthless"; +type DashboardSettingsState = { + settings: SettingsData | null; + beforeSave: SettingsData | null; +}; +type DashboardSettingsAction = + | { type: "polled"; settings: SettingsData } + | { type: "save-started"; key: CodexPreference; value: boolean } + | { type: "save-succeeded"; key: CodexPreference; settings: SettingsData } + | { type: "save-failed" } + | { type: "save-finished" } + | { type: "applied" }; + +// Own both server snapshots and the local save/apply transaction. A poll has no +// application receipt and must not overwrite a preference while it is being saved. +function dashboardSettingsReducer(state: DashboardSettingsState, action: DashboardSettingsAction): DashboardSettingsState { + switch (action.type) { + case "polled": + if (state.beforeSave) return state; + return { + ...state, + settings: { + ...action.settings, + catalogRefreshPending: state.settings?.catalogRefreshPending === true || action.settings.catalogRefreshPending, + }, + }; + case "save-started": + if (!state.settings || state.beforeSave) return state; + return { beforeSave: state.settings, settings: { ...state.settings, [action.key]: action.value } }; + case "save-succeeded": + if (!state.settings || !state.beforeSave) return state; + return { + ...state, + settings: { + ...state.settings, + [action.key]: action.settings[action.key], + catalogRefreshPending: action.key === "codexDesktopAuthless" ? true : state.settings.catalogRefreshPending, + startupHealth: action.settings.startupHealth ?? state.settings.startupHealth, + }, + }; + case "save-failed": + return state.beforeSave ? { ...state, settings: state.beforeSave } : state; + case "save-finished": + return { ...state, beforeSave: null }; + case "applied": + return state.settings ? { ...state, settings: { ...state.settings, catalogRefreshPending: false } } : state; + } +} + export function groupDashboardModels(models: ModelInfo[]): Array<[string, ModelInfo[]]> { const groups = new Map(); for (const model of models) { @@ -114,14 +163,18 @@ export function useDashboardData(apiBase: string) { const [startupHealth, setStartupHealth] = useState(() => cachedStartup); const [providers, setProviders] = useState(() => cachedOverview?.providers ?? []); const [models, setModels] = useState([]); - const [settings, setSettings] = useState(() => cachedControls?.settings ?? null); + const [settingsState, dispatchSettings] = useReducer(dashboardSettingsReducer, { + settings: cachedControls?.settings ?? null, + beforeSave: null, + }); + const { settings } = settingsState; + const settingsSaving = settingsState.beforeSave !== null; const [sidecar, setSidecar] = useState(() => cachedControls?.sidecar ?? null); const [shadowCall, setShadowCall] = useState(() => cachedControls?.shadowCall ?? null); const [usage30d, setUsage30d] = useState(() => cachedUsage); const [sidecarSaving, setSidecarSaving] = useState(false); const [shadowCallSaving, setShadowCallSaving] = useState(false); const [modelsLoading, setModelsLoading] = useState(false); - const [settingsSaving, setSettingsSaving] = useState(false); const [syncing, setSyncing] = useState(false); const [maMode, setMaMode] = useState(() => cachedMaMode ?? "default"); const [maBusy, setMaBusy] = useState(false); @@ -362,10 +415,7 @@ export function useDashboardData(apiBase: string) { const data = settingsPoll.data; if (!data) return; if (data.settings !== undefined) { - const next = data.settings; - // GET settings does not report application receipts. Keep a saved preference's - // pending indication until an affirmative sync result clears it. - setSettings(prev => ({ ...next, catalogRefreshPending: prev?.catalogRefreshPending === true || next.catalogRefreshPending })); + dispatchSettings({ type: "polled", settings: data.settings }); } // Latest-wins: only seed from settings when no newer dedicated probe has committed // while this settings poll was in flight. Always merge against the live ref. @@ -378,15 +428,16 @@ export function useDashboardData(apiBase: string) { startupHealthRef.current = merged; if (merged) writeSessionListCache(`${STARTUP_CACHE_PREFIX}${apiBase}`, merged); } - if (data.settings !== undefined) { - const prev = readSessionListCache(controlsCacheKey(apiBase)) ?? {}; - writeSessionListCache(controlsCacheKey(apiBase), { - ...prev, - settings: data.settings, - }); - } }, [settingsPoll.data, apiBase]); + // Cache the merged UI state, including preference saves and successful applies. + // Raw GET settings cannot replace the local application receipt on a revisit. + useEffect(() => { + if (!settings) return; + const prev = readSessionListCache(controlsCacheKey(apiBase)) ?? {}; + writeSessionListCache(controlsCacheKey(apiBase), { ...prev, settings }); + }, [settings, apiBase]); + useEffect(() => { if (usagePoll.data !== undefined) { setUsage30d(usagePoll.data); @@ -612,12 +663,11 @@ export function useDashboardData(apiBase: string) { finally { setInjectionSaving(false); } }; - const toggleCodexSetting = async (key: "codexAutoStart" | "codexDesktopAuthless") => { + const toggleCodexSetting = async (key: CodexPreference) => { if (!settings || settingsSaving || syncing) return; const next = !(settings[key] ?? (key === "codexAutoStart")); - setSettingsSaving(true); settingsMutationInFlightRef.current = true; - setSettings({ ...settings, [key]: next }); + dispatchSettings({ type: "save-started", key, value: next }); try { const res = await fetch(`${apiBase}/api/settings`, { method: "PUT", @@ -626,14 +676,14 @@ export function useDashboardData(apiBase: string) { }); const data = await requireJson(res, "save failed"); settingsMutationEpochRef.current += 1; - setSettings(prev => prev ? { ...prev, [key]: data[key], catalogRefreshPending: key === "codexDesktopAuthless" ? data.catalogRefreshPending : prev.catalogRefreshPending, startupHealth: data.startupHealth ?? prev.startupHealth } : prev); + dispatchSettings({ type: "save-succeeded", key, settings: data }); if (key === "codexDesktopAuthless") await runSync(); } catch { - setSettings(prev => prev ? { ...prev, [key]: !next } : prev); + dispatchSettings({ type: "save-failed" }); setError(true); } finally { settingsMutationInFlightRef.current = false; - setSettingsSaving(false); + dispatchSettings({ type: "save-finished" }); } }; @@ -659,7 +709,7 @@ export function useDashboardData(apiBase: string) { const data = await requireJson(res, "sync failed"); setSyncResult(data); if (data.ok && data.status === "applied") { - setSettings(prev => prev ? { ...prev, catalogRefreshPending: false } : prev); + dispatchSettings({ type: "applied" }); } if (data.projectConfigGrouped) setProjectConfigWarnings(data.projectConfigGrouped); } catch (err) { diff --git a/gui/src/pages/use-providers-oauth.ts b/gui/src/pages/use-providers-oauth.ts index 9de0a7537d..d97d28dfc0 100644 --- a/gui/src/pages/use-providers-oauth.ts +++ b/gui/src/pages/use-providers-oauth.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef } from "react"; import type { TFn } from "../i18n/shared"; import { readJsonIfOk } from "../fetch-json"; import { openBrowserRequestField } from "../oauth-open-browser-pref"; +import { afterOAuthCancellation, cancelOAuthLogin } from "../oauth-cancellation-barrier"; import type { OAuthAccount, OAuthStatus } from "./providers-shared"; import { oauthLabel } from "./providers-shared"; @@ -53,14 +54,8 @@ export function useProvidersOAuth({ return gen; }, []); - const cancelServerLogin = useCallback(async (provider: string) => { - await fetch(`${apiBase}/api/oauth/login/cancel`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ provider }), - keepalive: true, - }).catch(() => undefined); - }, [apiBase]); + const cancelServerLogin = useCallback((provider: string) => + cancelOAuthLogin(apiBase, provider), [apiBase]); useEffect(() => { const cancelActiveLogins = (clearUi: boolean) => { @@ -87,11 +82,9 @@ export function useProvidersOAuth({ const gen = bumpLoginGeneration(provider); activeLoginGenerationsRef.current.delete(provider); await cancelServerLogin(provider); - if (!aliveRef.current) return; - if (oauthLoginGenerationRef.current!.get(provider) === gen) { - setBusy(current => current === provider ? null : current); - setLoginInfo(current => current?.provider === provider ? null : current); - } + if (!aliveRef.current || oauthLoginGenerationRef.current!.get(provider) !== gen) return; + setBusy(current => current === provider ? null : current); + setLoginInfo(current => current?.provider === provider ? null : current); notify(t("prov.loginCancelled", { provider: oauthLabel(provider) }), false); }, [aliveRef, bumpLoginGeneration, cancelServerLogin, notify, setBusy, setLoginInfo, t]); @@ -103,26 +96,31 @@ export function useProvidersOAuth({ setStatus(""); setLoginInfo(null); try { - const res = await fetch(`${apiBase}/api/oauth/login`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - provider, - // Explicit, never inferred, and omitted entirely when this operator has - // expressed no preference — otherwise the request would permanently - // overrule a persisted `oauthOpenBrowser: false`. - ...openBrowserRequestField(), - ...(addAccount || reauthTargetId ? { addAccount: true } : {}), - ...(reauthTargetId ? { accountId: reauthTargetId, reauth: true } : {}), - }), + const res = await afterOAuthCancellation(apiBase, provider, () => { + if (!aliveRef.current || oauthLoginGenerationRef.current!.get(provider) !== generation) return; + return fetch(`${apiBase}/api/oauth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider, + // Explicit, never inferred, and omitted entirely when this operator has + // expressed no preference — otherwise the request would permanently + // overrule a persisted `oauthOpenBrowser: false`. + ...openBrowserRequestField(), + ...(addAccount || reauthTargetId ? { addAccount: true } : {}), + ...(reauthTargetId ? { accountId: reauthTargetId, reauth: true } : {}), + }), + }); }); - if (oauthLoginGenerationRef.current!.get(provider) !== generation || !aliveRef.current) return; + if (!res || oauthLoginGenerationRef.current!.get(provider) !== generation || !aliveRef.current) return; if (!res.ok) { const data = await res.json().catch(() => ({})) as { error?: string }; + if (!aliveRef.current || oauthLoginGenerationRef.current!.get(provider) !== generation) return; notify(data.error || t("prov.loginFailStart", { provider: oauthLabel(provider) }), false); return; } const data = await res.json() as { url?: string; instructions?: string; deviceCode?: string }; + if (!aliveRef.current || oauthLoginGenerationRef.current!.get(provider) !== generation) return; if (data.url || data.instructions || data.deviceCode) { setLoginInfo({ provider, url: data.url, instructions: data.instructions, deviceCode: data.deviceCode }); } @@ -135,6 +133,7 @@ export function useProvidersOAuth({ const s: (OAuthStatus & { accounts?: OAuthAccount[]; activeAccountId?: string | null }) | null = sRes ? ((await readJsonIfOk(sRes)) ?? null) : null; + if (!aliveRef.current || oauthLoginGenerationRef.current!.get(provider) !== generation) return; if (!s) continue; if (s.error) { setOauthStatus(prev => ({ ...prev, [provider]: s })); @@ -203,12 +202,14 @@ export function useProvidersOAuth({ } if (!finished && oauthLoginGenerationRef.current!.get(provider) === generation && aliveRef.current) { await cancelServerLogin(provider); + if (!aliveRef.current || oauthLoginGenerationRef.current!.get(provider) !== generation) return; notify(t("prov.loginTimeout", { provider: oauthLabel(provider) }), false); setLoginInfo(null); } } catch { if (oauthLoginGenerationRef.current!.get(provider) === generation) { await cancelServerLogin(provider); + if (!aliveRef.current || oauthLoginGenerationRef.current!.get(provider) !== generation) return; notify(t("prov.loginRequestFail", { provider: oauthLabel(provider) }), false); } } finally { diff --git a/gui/tests/add-provider-oauth-url-leak.test.tsx b/gui/tests/add-provider-oauth-url-leak.test.tsx index c21cf372b5..347e314b2d 100644 --- a/gui/tests/add-provider-oauth-url-leak.test.tsx +++ b/gui/tests/add-provider-oauth-url-leak.test.tsx @@ -104,19 +104,23 @@ async function mountModal(onAdded: (name: string) => void = () => {}) { await act(async () => { await new Promise((r) => setTimeout(r, 40)); }); } -function ProvidersOAuthHarness() { +function ProvidersOAuthHarness({ provider = "orcarouter-oauth", apiBase = "", onSettled }: { + provider?: string; + apiBase?: string; + onSettled?: (provider: string) => void; +}) { const t = useT(); const aliveRef = useRef(true); const startedRef = useRef(false); const [accountSets, setAccountSets] = useState>({}); const [busy, setBusy] = useState(null); - const [, setStatus] = useState(""); + const [status, setStatus] = useState(""); const [loginInfo, setLoginInfo] = useState<{ provider: string; url?: string; instructions?: string; deviceCode?: string } | null>(null); const [, setOauthStatus] = useState>({}); useEffect(() => () => { aliveRef.current = false; }, []); - const { loginOAuth } = useProvidersOAuth({ - apiBase: "", + const { loginOAuth, cancelLoginOAuth } = useProvidersOAuth({ + apiBase, t, aliveRef, accountSets, @@ -125,7 +129,8 @@ function ProvidersOAuthHarness() { setStatus, setLoginInfo, setOauthStatus, - notify: () => {}, + notify: (message) => setStatus(message), + onLoginSettled: onSettled, fetchConfig: async () => {}, fetchOauth: async () => {}, fetchAccountSets: async () => undefined, @@ -136,16 +141,18 @@ function ProvidersOAuthHarness() { useEffect(() => { if (startedRef.current) return; startedRef.current = true; - void loginOAuth("orcarouter-oauth"); - }, [loginOAuth]); + void loginOAuth(provider); + }, [loginOAuth, provider]); return ( <> + {status} + {busy ?? "idle"} {loginInfo?.url ?? "no-login-info"} @@ -153,13 +160,13 @@ function ProvidersOAuthHarness() { ); } -async function mountProvidersOAuthHarness() { +async function mountProvidersOAuthHarness(props: Parameters[0] = {}) { const { createRoot } = await import("react-dom/client"); await act(async () => { root = createRoot(host); root.render( - + , ); await new Promise((r) => setTimeout(r, 20)); @@ -427,3 +434,323 @@ test("a login error wins over a retained OAuth credential", async () => { timeoutSpy.mockRestore(); } }); + +for (const surface of ['providers', 'modal'] as const) { + test(`AUDIT ${surface} waits for pending cancellation before replacement login`, async () => { + const inheritedFetch=globalThis.fetch; + const cancelGate=Promise.withResolvers(); + let loginRequests=0, cancelRequests=0; + globalThis.fetch=(async(input,init)=>{ + const path=new URL(String(input),'http://localhost').pathname; + if(path==='/api/oauth/login/cancel'){cancelRequests++;return cancelGate.promise;} + if(path==='/api/oauth/login')loginRequests++; + return inheritedFetch(input,init); + }) as typeof fetch; + try { + if(surface==='providers')await mountProvidersOAuthHarness(); + else {await mountModal();await act(async()=>{clickByText('Claude');});await act(async()=>{clickByText('Log in with Claude');});} + expect(loginRequests).toBe(1); + await act(async()=>{win.dispatchEvent(new win.Event('pagehide'));}); + expect(cancelRequests).toBe(1); + await act(async()=>{clickByText(surface==='providers'?'Log in again':'Log in with Claude');}); + console.log(JSON.stringify({surface,loginRequests,cancelRequests,cancellation:'STILL PENDING'})); + expect(loginRequests).toBe(1); + } finally { await act(async()=>{cancelGate.resolve(Response.json({ok:true,cancelled:true}));}); } + }); +} + +type RaceSurface = "providers" | "modal"; + +async function mountRaceSurface(surface: RaceSurface, settled: string[] = []) { + if (surface === "providers") { + await mountProvidersOAuthHarness({ provider: "claude", onSettled: name => settled.push(name) }); + } else { + await mountModal(name => settled.push(name)); + await act(async () => { clickByText("Claude"); }); + await retryRaceLogin(surface); + } +} + +async function retryRaceLogin(surface: RaceSurface) { + await act(async () => { clickByText(surface === "providers" ? "Log in again" : "Log in with Claude"); }); +} + +async function unmountRaceSurface() { + const current = root; + root = null; + await act(async () => { current?.unmount(); }); +} + +// Provider-only cancellation affects the flow current at DELIVERY, not dispatch. +// Keep both network delivery and polling under explicit test control. +function raceServer() { + const inheritedFetch = globalThis.fetch; + const logins: Array>> = []; + const cancels: Array>> = []; + const active = new Map(); + const loginKeys: string[] = []; + const ticks: Array<() => void> = []; + let complete = false; + let statusOverride: Promise | undefined; + const realSetTimeout = globalThis.setTimeout; + const timer = spyOn(globalThis, "setTimeout").mockImplementation((( + callback: (...args: unknown[]) => void, delay?: number, ...args: unknown[] + ) => { + if (delay === OAUTH_LOGIN_POLL_INTERVAL_MS) { + ticks.push(() => callback(...args)); + return 0 as unknown as ReturnType; + } + return realSetTimeout(callback, delay, ...args); + }) as typeof setTimeout); + globalThis.fetch = (async (input, init) => { + const url = new URL(String(input), "http://localhost"); + const provider = init?.body + ? (JSON.parse(String(init.body)) as { provider: string }).provider + : url.searchParams.get("provider"); + const base = url.pathname.split("/api/oauth/")[0]; + const key = `${base}:${provider}`; + if (url.pathname.endsWith("/api/oauth/login")) { + const gate = Promise.withResolvers(); + logins.push(gate); + loginKeys.push(key); + active.set(key, logins.length); + return gate.promise; + } + if (url.pathname.endsWith("/api/oauth/login/cancel")) { + const gate = Promise.withResolvers(); + cancels.push(gate); + const response = await gate.promise; + if (response.ok) active.delete(key); + return response; + } + if (url.pathname.endsWith("/api/oauth/status")) { + if (statusOverride) return statusOverride; + return Response.json(active.has(key) + ? { loggedIn: complete, done: complete } + : { loggedIn: false, error: "Login cancelled" }); + } + return inheritedFetch(input, init); + }) as typeof fetch; + return { + logins, cancels, active, loginKeys, + holdStatus(response: Promise | undefined) { statusOverride = response; }, + async tick() { + await act(async () => { ticks.splice(0).forEach(tick => tick()); }); + }, + async answerLogin(index: number, url = A_URL) { + await act(async () => { logins[index]!.resolve(Response.json({ url })); }); + }, + async deliverCancel(index = 0) { + await act(async () => { cancels[index]!.resolve(Response.json({ ok: true, cancelled: true })); }); + }, + async finish() { + complete = true; + await act(async () => { ticks.splice(0).forEach(tick => tick()); }); + }, + async dispose() { + await unmountRaceSurface(); + await act(async () => { + cancels.forEach(gate => gate.resolve(Response.json({ ok: true }))); + logins.forEach(gate => gate.resolve(Response.json({ url: A_URL }))); + ticks.splice(0).forEach(tick => tick()); + }); + timer.mockRestore(); + globalThis.fetch = inheritedFetch; + }, + }; +} + +for (const surface of ["providers", "modal"] as const) { + for (const trigger of ["pagehide", "remount", "explicit"] as const) { + test(`F2 ${surface}: ${trigger} waits for cancel delivery and replacement completes`, async () => { + const server = raceServer(); + const settled: string[] = []; + try { + await mountRaceSurface(surface, settled); + await server.answerLogin(0); + if (trigger === "remount") { + await unmountRaceSurface(); + await mountRaceSurface(surface, settled); + } else { + await act(async () => { + if (trigger === "pagehide") win.dispatchEvent(new win.Event("pagehide")); + else clickByText("Cancel"); + }); + if (trigger === "explicit") { + // The busy UI disables retry until cancel settles; reopening can + // still request a new flow before that delivery finishes. + await unmountRaceSurface(); + await mountRaceSurface(surface, settled); + } else await retryRaceLogin(surface); + } + expect(server.cancels).toHaveLength(1); + expect(server.logins).toHaveLength(1); + await server.deliverCancel(); + expect(server.logins).toHaveLength(2); + expect(server.active.get(":claude")).toBe(2); + await server.answerLogin(1, B_URL); + expect(host.textContent).toContain(B_URL); + await server.finish(); + expect(settled).toEqual(["claude"]); + await unmountRaceSurface(); + expect(server.cancels).toHaveLength(1); + } finally { await server.dispose(); } + }); + } + + test(`F2 ${surface}: abandoning a replacement waiting on cancellation never starts it`, async () => { + const server = raceServer(); + try { + await mountRaceSurface(surface); + await act(async () => { win.dispatchEvent(new win.Event("pagehide")); }); + await retryRaceLogin(surface); + await unmountRaceSurface(); + await server.deliverCancel(); + expect(server.logins).toHaveLength(1); + expect(server.cancels).toHaveLength(1); + } finally { await server.dispose(); } + }); + + test(`F2 ${surface}: stale login rejection cannot erase replacement cleanup`, async () => { + const server = raceServer(); + try { + await mountRaceSurface(surface); + await act(async () => { win.dispatchEvent(new win.Event("pagehide")); }); + await retryRaceLogin(surface); + await server.deliverCancel(); + await server.answerLogin(1, B_URL); + await act(async () => { server.logins[0]!.reject(new Error("old request failed")); }); + expect(host.textContent).toContain(B_URL); + expect(host.textContent).not.toContain("old request failed"); + await unmountRaceSurface(); + expect(server.cancels).toHaveLength(2); + } finally { await server.dispose(); } + }); + + for (const failure of ["rejection", "http"] as const) { + test(`F2 ${surface}: cancel ${failure} settles best-effort cleanup without wedging retry`, async () => { + const server = raceServer(); + try { + await mountRaceSurface(surface); + await act(async () => { win.dispatchEvent(new win.Event("pagehide")); }); + await retryRaceLogin(surface); + expect(server.logins).toHaveLength(1); + await act(async () => { + if (failure === "rejection") server.cancels[0]!.reject(new Error("offline")); + else server.cancels[0]!.resolve(Response.json({ error: "unavailable" }, { status: 503 })); + }); + expect(server.logins).toHaveLength(2); + await server.answerLogin(1, B_URL); + expect(host.textContent).toContain(B_URL); + await unmountRaceSurface(); + expect(server.cancels).toHaveLength(2); + } finally { await server.dispose(); } + }); + } +} + +for (const first of ["providers", "modal"] as const) { + test(`F2 shared barrier survives ${first} unmount and the other hook mounting`, async () => { + const server = raceServer(); + try { + await mountRaceSurface(first); + await unmountRaceSurface(); + await mountRaceSurface(first === "providers" ? "modal" : "providers"); + expect(server.logins).toHaveLength(1); + await server.deliverCancel(); + expect(server.logins).toHaveLength(2); + expect(server.active.get(":claude")).toBe(2); + } finally { await server.dispose(); } + }); +} + +for (const other of [{ provider: "gemini" }, { provider: "claude", apiBase: "/other" }]) { + test(`F2 pending cancel does not block distinct key ${JSON.stringify(other)}`, async () => { + const server = raceServer(); + try { + await mountRaceSurface("providers"); + await unmountRaceSurface(); + await mountProvidersOAuthHarness(other); + expect(server.cancels).toHaveLength(1); + expect(server.logins).toHaveLength(2); + await server.deliverCancel(); + expect(server.active.get(server.loginKeys[1]!)).toBe(2); + } finally { await server.dispose(); } + }); +} + +for (const surface of ["providers", "modal"] as const) { + for (const reason of ["request-error", "timeout"] as const) { + test(`F2 ${surface}: ${reason} cleanup cannot clear the replacement after cancellation`, async () => { + const server = raceServer(); + const settled: string[] = []; + try { + await mountRaceSurface(surface, settled); + if (reason === "request-error") { + await act(async () => { server.logins[0]!.reject(new Error("request failed")); }); + } else { + await server.answerLogin(0); + for (let i = 0; i < (surface === "modal" ? 100 : 150); i++) await server.tick(); + } + expect(server.cancels).toHaveLength(1); + await act(async () => { win.dispatchEvent(new win.Event("pagehide")); }); + await retryRaceLogin(surface); + expect(server.logins).toHaveLength(1); + await server.deliverCancel(); + expect(server.logins).toHaveLength(2); + await server.answerLogin(1, B_URL); + expect(host.textContent).toContain(B_URL); + expect(host.textContent).not.toContain("timed out"); + expect(host.querySelector('[data-testid="oauth-status"]')?.textContent ?? "").toBe(""); + await server.finish(); + expect(settled).toEqual(["claude"]); + } finally { await server.dispose(); } + }); + } + + test(`F2 ${surface}: stale response body cannot overwrite replacement URL`, async () => { + const server = raceServer(); + const body = Promise.withResolvers<{ url: string }>(); + try { + await mountRaceSurface(surface); + const response = Response.json({}); + Object.defineProperty(response, "json", { value: () => body.promise }); + await act(async () => { server.logins[0]!.resolve(response); }); + await act(async () => { win.dispatchEvent(new win.Event("pagehide")); }); + await retryRaceLogin(surface); + await server.deliverCancel(); + await server.answerLogin(1, B_URL); + await act(async () => { body.resolve({ url: A_URL }); }); + expect(host.textContent).toContain(B_URL); + expect(host.textContent).not.toContain(A_URL); + } finally { + body.resolve({ url: A_URL }); + await server.dispose(); + } + }); + + test(`F2 ${surface}: stale status cannot complete the replacement prematurely`, async () => { + const server = raceServer(); + const status = Promise.withResolvers(); + const settled: string[] = []; + try { + await mountRaceSurface(surface, settled); + await server.answerLogin(0); + server.holdStatus(status.promise); + await server.tick(); + await act(async () => { win.dispatchEvent(new win.Event("pagehide")); }); + await retryRaceLogin(surface); + await server.deliverCancel(); + await server.answerLogin(1, B_URL); + server.holdStatus(undefined); + await act(async () => { status.resolve(Response.json({ loggedIn: true, done: true })); }); + expect(settled).toEqual([]); + expect(host.textContent).toContain(B_URL); + await server.finish(); + expect(settled).toEqual(["claude"]); + } finally { + status.resolve(Response.json({ loggedIn: true })); + await server.dispose(); + } + }); +} diff --git a/gui/tests/model-picker-order-editor.test.tsx b/gui/tests/model-picker-order-editor.test.tsx index 5bdb3ef8fe..ea9f808cb7 100644 --- a/gui/tests/model-picker-order-editor.test.tsx +++ b/gui/tests/model-picker-order-editor.test.tsx @@ -82,10 +82,13 @@ function transfer() { setData: (type: string, value: string) => { data.set(type, value); }, getData: (type: string) => data.get(type) ?? "" }; } async function dragEvent(target: Element, type: string, dataTransfer: ReturnType) { + let defaultPrevented = false; await act(async () => { const event = new win.Event(type, { bubbles: true, cancelable: true }); Object.defineProperty(event, "dataTransfer", { value: dataTransfer }); target.dispatchEvent(event); + defaultPrevented = event.defaultPrevented; }); + return defaultPrevented; } async function drop(source: string, target: string) { const data = transfer(); @@ -165,6 +168,10 @@ test("external, self, fixed and expired drag tokens cannot reorder", async () => await dragEvent(row("p/b"), "drop", external); expect(order()).toEqual(original); await drop("p/a", "p/a"); await drop("p/a", "p/f"); expect(order()).toEqual(original); const local = transfer(); await dragEvent(button("Drag p/a"), "dragstart", local); + const wrongType = transfer(); wrongType.setData("text/plain", "p/a"); + expect(await dragEvent(row("p/b"), "dragover", wrongType)).toBe(false); + expect(await dragEvent(row("p/f"), "dragover", local)).toBe(false); + expect(await dragEvent(row("p/b"), "dragover", local)).toBe(true); await dragEvent(row("p/b"), "drop", external); expect(order()).toEqual(original); await dragEvent(row("p/b"), "drop", local); expect(order()).toEqual(original); await dragEvent(button("Drag p/a"), "dragstart", local); @@ -182,6 +189,13 @@ test("preflight roster drift blocks PUT, preserves draft, and requires explicit await click("Reload and discard draft"); expect(order()).toEqual(changedDraft); await reply(2, updated); expect(order()).toEqual(["p/b", "p/a", "p/c", "p/f"]); expect(button("Move p/a down").disabled).toBe(false); expect(receipts).toEqual([]); + expect(button("Drag p/b").disabled).toBe(true); + expect(button("Drag p/f").disabled).toBe(false); + expect(button("Move p/a up").disabled).toBe(true); + await drop("p/b", "p/f"); expect(order()).toEqual(["p/b", "p/a", "p/c", "p/f"]); + await drop("p/f", "p/a"); expect(order()).toEqual(["p/b", "p/f", "p/a", "p/c"]); + await click("Save draft"); await reply(3, updated); + expect(requests[4]?.body).toEqual({ pickerOrder: ["p/b", "p/f", "p/a", "p/c"], pickerOrderMode: null }); }); for (const failure of ["rejected", "malformed JSON", "malformed receipt", "network"] as const) diff --git a/gui/tests/subagents-fallback.test.tsx b/gui/tests/subagents-fallback.test.tsx index ad794b8261..cc1cc42dd8 100644 --- a/gui/tests/subagents-fallback.test.tsx +++ b/gui/tests/subagents-fallback.test.tsx @@ -16,7 +16,7 @@ const globals = [ "document", "window", "navigator", "localStorage", "sessionStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT", ] as const; -type CachedSubagents = { available: string[]; chosen: string[]; fallback?: string[]; pollMs?: number }; +type CachedSubagents = { available: string[]; chosen: string[]; fallback?: string[]; pollMs?: number; fallbackAvailable?: string[] }; type FallbackSettings = { models: string[]; pollMs: number }; type SentRequest = { path: string; method: string; init?: RequestInit }; type V2Settings = { @@ -33,6 +33,7 @@ let root: Root | null = null; let requests: SentRequest[]; let available: string[]; let chosen: string[]; +let fallbackAvailable: string[] | undefined; let fallbackSettings: FallbackSettings; let failFallbackPut: boolean; let v2Settings: V2Settings; @@ -57,6 +58,7 @@ beforeEach(() => { requests = []; available = ["a-1", "a-2", "a-3"]; chosen = ["a-1"]; + fallbackAvailable = undefined; fallbackSettings = { models: ["a-2"], pollMs: 45_000 }; failFallbackPut = false; v2Settings = { enabled: true, multiAgentMode: "v2", multiAgentModeHintText: null, keepNativeChatGptOnV1: false }; @@ -77,7 +79,7 @@ beforeEach(() => { return pending; } if (fallbackGetGate) await fallbackGetGate; - return Response.json({ ...fallbackSettings, available }); + return Response.json({ ...fallbackSettings, available: fallbackAvailable ?? available }); } if (path === FALLBACK_PATH && method === "PUT") { if (failFallbackPut) return Response.json({ error: "Fallback settings could not be persisted" }, { status: 500 }); @@ -203,7 +205,7 @@ function pollInput(): HTMLInputElement { return input; } -async function changePollMs(value: number) { +async function changePollMs(value: number | string) { await act(async () => { const input = pollInput(); Object.getOwnPropertyDescriptor(testWindow.HTMLInputElement.prototype, "value")!.set!.call(input, String(value)); @@ -221,6 +223,128 @@ function cached(): CachedSubagents | null { return readSessionListCache(CACHE_KEY); } +const failedFallbackReads = [ + { name: "404", response: () => Response.json({ error: "Fallback endpoint missing" }, { status: 404 }) }, + { name: "503", response: () => Response.json({ error: "Fallback discovery unavailable" }, { status: 503 }) }, + { name: "invalid JSON", response: () => new Response("{broken") }, + { name: "missing settings", response: () => Response.json({}) }, + { name: "invalid models", response: () => Response.json({ models: [null], pollMs: 45_000, available: [] }) }, + { name: "invalid poll interval", response: () => Response.json({ models: [], pollMs: 1, available: [] }) }, + { name: "invalid availability", response: () => Response.json({ models: [], pollMs: 45_000, available: [null] }) }, +]; + +test.each(failedFallbackReads)("cold roster survives fallback $name and recovers through retry", async ({ response }) => { + expect(cached()).toBeNull(); + pendingFallbackResponse = Promise.resolve(response()); + await mount(); + + expect(Array.from(container.querySelectorAll(".swi-featured-name"), node => node.textContent?.trim())).toEqual(["a-1"]); + expect(pollInput().disabled).toBe(true); + expect(saveButton().disabled).toBe(true); + expect(labelledButton(editor(), en["sub.fallbackAdd"]).disabled).toBe(true); + expect(container.textContent).toContain(en["sub.fallbackLabel"]); + expect(container.textContent).toContain(en["sub.loadFail"]); + expect(cached()).not.toHaveProperty("fallback"); + expect(cached()).not.toHaveProperty("pollMs"); + await act(async () => { saveButton().click(); }); + expect(putBodies()).toEqual([]); + + await click(labelledButton(container, en["sub.workspace.addToFeatured"].replace("{m}", "a-3"))); + const rosterSaveRow = container.querySelector(".swi-save-row"); + if (!rosterSaveRow) throw new Error("Roster Save row not found"); + await click(saveButton(rosterSaveRow)); + expect(putBodies(ROSTER_PATH)).toEqual([{ models: ["a-1", "a-3"] }]); + expect(cached()).not.toHaveProperty("fallback"); + + const rosterGets = requests.filter(request => request.path === ROSTER_PATH && request.method === "GET").length; + const retry = Array.from(container.querySelectorAll("button")) + .find(button => button.textContent?.trim() === en["common.retry"]); + if (!retry) throw new Error("Fallback retry not found"); + await click(retry); + expect(requests.filter(request => request.path === ROSTER_PATH && request.method === "GET")).toHaveLength(rosterGets); + expect(container.textContent).not.toContain(en["sub.loadFail"]); + expectOrder(["a-2"]); + expect(pollInput().value).toBe("45000"); + expect(saveButton().disabled).toBe(false); + expect(cached()?.chosen).toEqual(["a-1", "a-3"]); + await click(saveButton()); + expect(putBodies()).toEqual([{ models: ["a-2"], pollMs: 45_000 }]); +}); + +test("cold roster is usable while fallback discovery remains pending", async () => { + let releaseGet!: () => void; + fallbackGetGate = new Promise(resolve => { releaseGet = resolve; }); + try { + await mount(); + expect(container.querySelectorAll(".swi-featured-row")).toHaveLength(1); + expect(saveButton().disabled).toBe(true); + await click(labelledButton(container, en["sub.workspace.addToFeatured"].replace("{m}", "a-3"))); + expect(container.querySelectorAll(".swi-featured-row")).toHaveLength(2); + } finally { + await act(async () => { releaseGet(); }); + } + expectOrder(["a-2"]); + expect(container.querySelectorAll(".swi-featured-row")).toHaveLength(2); +}); + +test("fallback discovery excludes roster-only stale choices without losing configured values", async () => { + available.push(UNAVAILABLE_MODEL, "retired-provider/other-model"); + chosen = [UNAVAILABLE_MODEL, "a-1"]; + fallbackAvailable = ["a-1", "a-2", "a-3"]; + fallbackSettings.models = [UNAVAILABLE_MODEL, "a-2"]; + await mount(); + expectOrder([UNAVAILABLE_MODEL, "a-2"]); + expect(rows()[0]?.textContent).toContain(en["sub.fallbackUnavailable"]); + const trigger = labelledButton(editor(), en["sub.fallbackAdd"]); + await click(trigger); + const listbox = testWindow.document.getElementById(trigger.getAttribute("aria-controls") ?? ""); + expect(listbox?.textContent).not.toContain("retired-provider/other-model"); + await click(trigger); + const rosterSaveRow = container.querySelector(".swi-save-row"); + if (!rosterSaveRow) throw new Error("Roster Save row not found"); + await click(saveButton(rosterSaveRow)); + expect(putBodies(ROSTER_PATH)).toEqual([{ models: [UNAVAILABLE_MODEL, "a-1"] }]); + await click(saveButton()); + expect(putBodies()).toEqual([{ models: [UNAVAILABLE_MODEL, "a-2"], pollMs: 45_000 }]); +}); + +test("cached fallback availability survives remount while discovery is pending", async () => { + available.push(UNAVAILABLE_MODEL); + chosen = [UNAVAILABLE_MODEL, "a-1"]; + fallbackAvailable = ["a-1", "a-2", "a-3"]; + fallbackSettings.models = [UNAVAILABLE_MODEL]; + await mount(); + expect(cached()?.fallbackAvailable).toEqual(["a-1", "a-2", "a-3"]); + await act(async () => { root!.unmount(); }); + root = null; + clearClientResourceStoresForTests(); + let releaseGet!: () => void; + fallbackGetGate = new Promise(resolve => { releaseGet = resolve; }); + try { + await mount(); + expectOrder([UNAVAILABLE_MODEL]); + expect(rows()[0]?.textContent).toContain(en["sub.fallbackUnavailable"]); + expect(container.querySelectorAll(".swi-featured-row")).toHaveLength(2); + } finally { + await act(async () => { releaseGet(); }); + } +}); + +test("failed revalidation disables a cached fallback without replacing its committed settings", async () => { + testWindow.sessionStorage.setItem(CACHE_KEY, JSON.stringify({ + available, chosen, fallback: [UNAVAILABLE_MODEL], pollMs: 90_000, fallbackAvailable: available, + })); + pendingFallbackResponse = Promise.resolve(Response.json({ error: "Fallback unavailable" }, { status: 503 })); + await mount(); + expectOrder([UNAVAILABLE_MODEL]); + expect(pollInput().value).toBe("90000"); + expect(saveButton().disabled).toBe(true); + expect(cached()?.fallback).toEqual([UNAVAILABLE_MODEL]); + expect(cached()?.pollMs).toBe(90_000); + expect(container.textContent).toContain("Fallback unavailable"); + expect(container.querySelectorAll(".swi-featured-row")).toHaveLength(1); +}); + test("preserves an unavailable configured fallback ID on load and save", async () => { fallbackSettings = { models: [UNAVAILABLE_MODEL, "a-2"], pollMs: 45_000 }; expect(available).not.toContain(UNAVAILABLE_MODEL); @@ -316,7 +440,7 @@ test("removes only the selected duplicate fallback occurrence by index", async ( test("a failed fallback PUT retains the editable draft and leaves the committed cache unchanged", async () => { await mount(); const committed = cached(); - expect(committed).toEqual({ available, chosen: ["a-1"], fallback: ["a-2"], pollMs: 45_000 }); + expect(committed).toEqual({ available, fallbackAvailable: available, chosen: ["a-1"], fallback: ["a-2"], pollMs: 45_000 }); await addFallback("a-3"); await changePollMs(90_000); failFallbackPut = true; @@ -349,7 +473,7 @@ test("a successful fallback save updates committed session data without committi expect(putBodies()).toEqual([{ models: ["a-2", "a-3"], pollMs: 120_000 }]); expect(putBodies(ROSTER_PATH)).toEqual([]); - expect(cached()).toEqual({ available, chosen: ["a-1"], fallback: ["a-2", "a-3"], pollMs: 120_000 }); + expect(cached()).toEqual({ available, fallbackAvailable: available, chosen: ["a-1"], fallback: ["a-2", "a-3"], pollMs: 120_000 }); expectOrder(["a-2", "a-3"]); expect(container.querySelectorAll(".swi-featured-row").length).toBe(2); }); @@ -365,14 +489,14 @@ test("independent roster Save never caches an unsaved fallback draft", async () expect(putBodies(ROSTER_PATH)).toEqual([{ models: ["a-1", "a-3"] }]); expect(putBodies()).toEqual([]); - expect(cached()).toEqual({ available, chosen: ["a-1", "a-3"], fallback: ["a-2"], pollMs: 45_000 }); + expect(cached()).toEqual({ available, fallbackAvailable: available, chosen: ["a-1", "a-3"], fallback: ["a-2"], pollMs: 45_000 }); expectOrder(["a-2", "a-3"]); expect(pollInput().value).toBe("90000"); // Saving the fallback afterward must retain the already committed roster. await click(saveButton()); expect(putBodies()).toEqual([{ models: ["a-2", "a-3"], pollMs: 90_000 }]); - expect(cached()).toEqual({ available, chosen: ["a-1", "a-3"], fallback: ["a-2", "a-3"], pollMs: 90_000 }); + expect(cached()).toEqual({ available, fallbackAvailable: available, chosen: ["a-1", "a-3"], fallback: ["a-2", "a-3"], pollMs: 90_000 }); }); test("remount shows the committed fallback and roster while a fresh fallback GET is pending", async () => { @@ -407,7 +531,7 @@ test("remount shows the committed fallback and roster while a fresh fallback GET expect(pollInput().value).toBe("120000"); expect(Array.from(container.querySelectorAll(".swi-featured-name"), node => node.textContent?.trim())) .toEqual(["a-1", "a-3"]); - expect(cached()).toEqual({ available, chosen: ["a-1", "a-3"], fallback: ["a-2", "a-3"], pollMs: 120_000 }); + expect(cached()).toEqual({ available, fallbackAvailable: available, chosen: ["a-1", "a-3"], fallback: ["a-2", "a-3"], pollMs: 120_000 }); } finally { await act(async () => { releaseGet(); }); fallbackGetGate = null; @@ -513,6 +637,29 @@ test.each([false, true])("a captured old fallback GET cannot overwrite a newer d expect(putBodies()).toEqual(saveNewer ? [{ models: ["a-3"], pollMs: 90_000 }] : []); }); +test.each(["", "1e309"])("blank or overflowing polling input stays invalid until corrected (%s)", async value => { + await mount(); + const committed = cached(); + await changePollMs(value); + expect(pollInput().value).toBe(value); + expect(pollInput().getAttribute("aria-invalid")).toBe("true"); + expect(editor().querySelector('[role="alert"]')?.textContent).toContain(en["sub.fallbackPollInvalid"]); + expect(saveButton().disabled).toBe(true); + await act(async () => { saveButton().click(); }); + expect(putBodies()).toEqual([]); + expect(cached()).toEqual(committed); + + // An unrelated roster edit must not restore the last valid interval or coerce the blank to zero. + await click(labelledButton(container, en["sub.workspace.addToFeatured"].replace("{m}", "a-3"))); + expect(pollInput().value).toBe(value); + expect(saveButton().disabled).toBe(true); + await changePollMs(90_000); + expect(pollInput().getAttribute("aria-invalid")).toBe("false"); + expect(editor().querySelector('[role="alert"]')).toBeNull(); + await click(saveButton()); + expect(putBodies()).toEqual([{ models: ["a-2"], pollMs: 90_000 }]); +}); + test("invalid polling intervals disable Save without a PUT or cache mutation, and a valid interval recovers", async () => { await mount(); const committed = cached(); diff --git a/gui/tests/subagents-ultra-mode.test.tsx b/gui/tests/subagents-ultra-mode.test.tsx index 73e34907c0..6c2c1d8719 100644 --- a/gui/tests/subagents-ultra-mode.test.tsx +++ b/gui/tests/subagents-ultra-mode.test.tsx @@ -53,6 +53,7 @@ beforeEach(() => { return next ? response(next.body, next.ok, next.status ?? (next.ok ? 200 : 500)) : response({ enabled: false }); } if (path === "/api/subagent-models") return response({ available: [], chosen: [] }); + if (path === "/api/subagent-model-fallback") return response({ available: [], models: [], pollMs: 60_000 }); if (path === "/api/injection-model") return response({ available: [], efforts: [] }); return response({}); }, @@ -108,13 +109,15 @@ test("clears the page load error after a successful Ultra mode retry", async () await mount(); expect(container.textContent).toContain("Failed to load Ultra mode settings"); - const retry = Array.from(container.querySelectorAll("button")) - .find(button => button.textContent?.trim() === "Retry"); + const ultraErrorRow = Array.from(container.querySelectorAll(".swi-delegation-row")) + .find(row => row.textContent?.includes("Failed to load Ultra mode settings")); + const retry = ultraErrorRow?.querySelector("button"); expect(retry).toBeTruthy(); - await act(async () => { (retry as HTMLButtonElement).click(); }); + await act(async () => { retry!.click(); }); await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); }); + expect(v2Call).toBe(2); expect(container.textContent).not.toContain("Failed to load Ultra mode settings"); expect(ultraSwitch().disabled).toBe(false); }); @@ -145,6 +148,7 @@ test("a save refresh from an old API server cannot overwrite a newer server", as } if (path === "/new/api/v2") return response({ enabled: false, multiAgentMode: "default", multiAgentModeHintText: null }); if (path.endsWith("/api/subagent-models")) return response({ available: [], chosen: [] }); + if (path.endsWith("/api/subagent-model-fallback")) return response({ available: [], models: [], pollMs: 60_000 }); if (path.endsWith("/api/injection-model")) return response({ available: [], efforts: [] }); return response({}); }, diff --git a/gui/tests/usage-custom-range.test.tsx b/gui/tests/usage-custom-range.test.tsx index db441dbb83..887c31134e 100644 --- a/gui/tests/usage-custom-range.test.tsx +++ b/gui/tests/usage-custom-range.test.tsx @@ -119,6 +119,40 @@ function sessionEntries() { }); } +for (const connected of [false, true]) { + test.each([ + ["older daemon", { customWindow: undefined, until: undefined }], + ["missing mode", { customWindow: undefined }], + ["preset mode", { customWindow: false }], + ["nonboolean mode", { customWindow: "true" }], + ["missing since", { since: undefined }], + ["missing until", { until: undefined }], + ["wrong since", { since: since + 1 }], + ["wrong until", { until: until + 1 }], + ["string bounds", { since: String(since), until: String(until) }], + ])(`rejects custom %s receipts without displaying totals (connected=${connected})`, async (_name, receipt) => { + await mount(connected); + await respond(0, "held-preset-marker"); + const held = sessionEntries(); + await enter("2020-09-15T10:20", "2020-09-15T10:21"); + await apply(); + await act(async () => { + requests[1].resolve(Response.json({ ...report(requests[1], "mismatched-report-marker"), ...receipt })); + }); + expect(container.textContent).toContain("Could not load usage data."); + expect(container.textContent).toContain("The proxy returned an unexpected response."); + expect(container.textContent).not.toContain("mismatched-report-marker"); + expect(container.textContent).not.toContain("held-preset-marker"); + expect(container.querySelector(".stat-value")).toBeNull(); + expect(sessionEntries()).toEqual(held); + const retry = [...container.querySelectorAll("button")].find(button => button.textContent === "Retry")!; + await click(retry); + await respond(2, "exact-retry-marker"); + expect(container.textContent).toContain("exact-retry-marker"); + expect(container.textContent).not.toContain("Could not load usage data."); + }); +} + test("America/Santiago midnight DST retains final-day activity and tooltip", async () => { const previous = process.env.TZ; process.env.TZ = "America/Santiago"; diff --git a/gui/tests/vision-sidecar-dashboard.test.tsx b/gui/tests/vision-sidecar-dashboard.test.tsx index 738ed866e6..37bcf40d65 100644 --- a/gui/tests/vision-sidecar-dashboard.test.tsx +++ b/gui/tests/vision-sidecar-dashboard.test.tsx @@ -5,17 +5,18 @@ import { type HTMLElement as HappyHTMLElement, type HTMLInputElement as HappyHTMLInputElement, } from "happy-dom"; -import { act } from "react"; +import { act, useEffect } from "react"; import type { Root } from "react-dom/client"; import { en } from "../src/i18n/en"; import { LanguageProvider } from "../src/i18n/provider"; import { DashboardSidecarPanels } from "../src/pages/dashboard-overview-sections"; -import type { SidecarData, SidecarPatch } from "../src/pages/dashboard-shared"; +import type { SettingsData, SidecarData, SidecarPatch } from "../src/pages/dashboard-shared"; import { mergeSidecarSetting } from "../src/pages/dashboard-shared"; import { useDashboardData } from "../src/pages/use-dashboard-data"; -import { setClientResourceData } from "../src/client-resource"; +import { clearClientResourceStoresForTests, setClientResourceData } from "../src/client-resource"; +import { readSessionListCache } from "../src/session-list-cache"; -const globals = ["document", "window", "navigator", "IS_REACT_ACT_ENVIRONMENT"] as const; +const globals = ["document", "window", "navigator", "sessionStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; let previousGlobals: Record<(typeof globals)[number], PropertyDescriptor | undefined>; let testWindow: Window; let host: HTMLElement; @@ -49,6 +50,7 @@ beforeEach(() => { document: { configurable: true, value: testWindow.document }, window: { configurable: true, value: testWindow }, navigator: { configurable: true, value: testWindow.navigator }, + sessionStorage: { configurable: true, value: testWindow.sessionStorage }, }); (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; host = testWindow.document.createElement("div") as unknown as HTMLElement; @@ -434,7 +436,11 @@ test.each([undefined, false, true])("Desktop login preference %s persists before } return Response.json({}, { status: 503 }); }) as typeof fetch; - function Harness() { latest = useDashboardData(apiBase); return null; } + function Harness() { + const data = useDashboardData(apiBase); + useEffect(() => { latest = data; }, [data]); + return null; + } try { const { createRoot } = await import("react-dom/client"); await act(async () => { @@ -472,7 +478,11 @@ test.each(["skipped", "catalog-only", "applied"])("Desktop preference pending st if (path.endsWith("/api/settings")) return Response.json({ codexAutoStart: true, codexDesktopAuthless: false, port: 10100, hostname: "127.0.0.1" }); return Response.json({}, { status: 503 }); }) as typeof fetch; - function Harness() { latest = useDashboardData(apiBase); return null; } + function Harness() { + const data = useDashboardData(apiBase); + useEffect(() => { latest = data; }, [data]); + return null; + } try { const { createRoot } = await import("react-dom/client"); await act(async () => { root = createRoot(host); root.render(); }); @@ -494,3 +504,183 @@ test.each(["skipped", "catalog-only", "applied"])("Desktop preference pending st globalThis.fetch = originalFetch; } }); + +for (const putPending of [false, undefined, true]) { + test.each([ + { name: "HTTP failure", body: { error: "sync unavailable" }, status: 503 }, + { name: "skipped", body: { ok: true, status: "skipped" }, status: 200 }, + { name: "catalog-only", body: { ok: true, status: "catalog-only" }, status: 200 }, + { name: "unsuccessful applied", body: { ok: false, status: "applied" }, status: 200 }, + { name: "absent status", body: { ok: true }, status: 200 }, + { name: "absent ok", body: { status: "applied" }, status: 200 }, + ])(`Desktop saved preference stays pending with PUT ${String(putPending)} and $name sync`, async ({ body, status }) => { + const originalFetch = globalThis.fetch; + const apiBase = `/authless-pending-${String(putPending)}-${status}-${JSON.stringify(body)}`; + let latest: Dash | undefined; + let saved = false; + let apply = false; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const path = String(input); + if (path.endsWith("/api/settings")) { + if (init?.method === "PUT") { + saved = JSON.parse(String(init.body)).codexDesktopAuthless; + return Response.json({ codexDesktopAuthless: saved, catalogRefreshPending: putPending }); + } + return Response.json({ codexAutoStart: true, codexDesktopAuthless: saved, port: 10100, hostname: "127.0.0.1" }); + } + if (path.endsWith("/api/sync")) { + return apply ? Response.json({ ok: true, status: "applied" }) : Response.json(body, { status }); + } + return Response.json({}, { status: 503 }); + }) as typeof fetch; + function Harness() { + const data = useDashboardData(apiBase); + useEffect(() => { latest = data; }, [data]); + return null; + } + const { createRoot } = await import("react-dom/client"); + const render = async () => { + await act(async () => { root = createRoot(host); root.render(); }); + }; + const remount = async () => { + await act(async () => { root?.unmount(); }); + root = null; + clearClientResourceStoresForTests(); + await render(); + }; + const cachedSettings = () => readSessionListCache<{ settings: SettingsData }>(`ocx.dash.controls.v1:${apiBase}`)?.settings; + try { + await render(); + expect(latest?.settings?.catalogRefreshPending).toBeUndefined(); + await act(async () => { await latest!.toggleCodexDesktopAuthless(); }); + expect(latest?.settings?.codexDesktopAuthless).toBe(true); + expect(latest?.settings?.catalogRefreshPending).toBe(true); + expect(latest?.syncError).toBe(status === 503 ? "sync unavailable" : null); + expect(latest?.syncResult).toEqual(status === 503 ? null : body); + expect(cachedSettings()?.codexDesktopAuthless).toBe(true); + expect(cachedSettings()?.catalogRefreshPending).toBe(true); + // Real GETs on remount omit receipts; neither live state nor its cache may lose pending. + await remount(); + expect(latest?.settings?.catalogRefreshPending).toBe(true); + expect(cachedSettings()?.catalogRefreshPending).toBe(true); + await remount(); + expect(latest?.settings?.catalogRefreshPending).toBe(true); + apply = true; + await act(async () => { await latest!.runSync(); }); + expect(latest?.settings?.catalogRefreshPending).toBe(false); + expect(cachedSettings()?.catalogRefreshPending).toBe(false); + expect(latest?.syncError).toBeNull(); + expect(latest?.syncResult).toEqual({ ok: true, status: "applied" }); + await remount(); + expect(latest?.settings?.catalogRefreshPending === true).toBe(false); + expect(cachedSettings()?.catalogRefreshPending === true).toBe(false); + } finally { + await act(async () => { root?.unmount(); }); + root = null; + clearClientResourceStoresForTests(); + globalThis.fetch = originalFetch; + } + }); +} + +test.each([undefined, false])("Desktop GET pending %s preserves a cached pending receipt across repeated remounts", async (getPending) => { + const originalFetch = globalThis.fetch; + const apiBase = `/authless-cache-${String(getPending)}`; + let latest: Dash | undefined; + const cacheKey = `ocx.dash.controls.v1:${apiBase}`; + testWindow.sessionStorage.setItem(cacheKey, JSON.stringify({ + settings: { codexAutoStart: true, codexDesktopAuthless: true, catalogRefreshPending: true, port: 10100, hostname: "127.0.0.1" }, + })); + globalThis.fetch = (async (input: RequestInfo | URL) => String(input).endsWith("/api/settings") + ? Response.json({ codexAutoStart: true, codexDesktopAuthless: true, catalogRefreshPending: getPending, port: 10100, hostname: "127.0.0.1" }) + : Response.json({}, { status: 503 })) as typeof fetch; + function Harness() { + const data = useDashboardData(apiBase); + useEffect(() => { latest = data; }, [data]); + return null; + } + try { + const { createRoot } = await import("react-dom/client"); + for (let visit = 0; visit < 2; visit += 1) { + await act(async () => { root = createRoot(host); root.render(); }); + expect(latest?.settings?.catalogRefreshPending).toBe(true); + expect(readSessionListCache<{ settings: SettingsData }>(cacheKey)?.settings.catalogRefreshPending).toBe(true); + await act(async () => { root?.unmount(); }); + root = null; + clearClientResourceStoresForTests(); + } + } finally { + await act(async () => { root?.unmount(); }); + root = null; + clearClientResourceStoresForTests(); + globalThis.fetch = originalFetch; + } +}); + +test.each([true, false])("Desktop settings retain an optimistic preference during polling and settle save success=%s", async (saveSucceeds) => { + const originalFetch = globalThis.fetch; + const apiBase = `/authless-optimistic-${saveSucceeds}`; + let latest: Dash | undefined; + let syncCalls = 0; + const saveResponse = Promise.withResolvers(); + const initialSettings: SettingsData = { codexAutoStart: true, port: 10100, hostname: "127.0.0.1" }; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + if (String(input).endsWith("/api/settings")) { + return init?.method === "PUT" ? saveResponse.promise : Response.json(initialSettings); + } + if (String(input).endsWith("/api/sync")) { + syncCalls += 1; + return Response.json({ ok: true, status: "skipped" }); + } + return Response.json({}, { status: 503 }); + }) as typeof fetch; + function Harness() { + const data = useDashboardData(apiBase); + useEffect(() => { latest = data; }, [data]); + return null; + } + let save: Promise | undefined; + try { + const { createRoot } = await import("react-dom/client"); + await act(async () => { root = createRoot(host); root.render(); }); + expect(latest?.settings?.codexDesktopAuthless).toBeUndefined(); + await act(async () => { save = latest!.toggleCodexDesktopAuthless(); }); + expect(latest?.settingsSaving).toBe(true); + expect(latest?.settings?.codexDesktopAuthless).toBe(true); + expect(latest?.settings?.catalogRefreshPending).toBeUndefined(); + // A published snapshot must not replace a mutation that has not settled yet. + await act(async () => { + setClientResourceData(`dashboard-settings:${apiBase}`, { settings: initialSettings }); + }); + expect(latest?.settingsSaving).toBe(true); + expect(latest?.settings?.codexDesktopAuthless).toBe(true); + await act(async () => { + saveResponse.resolve(saveSucceeds + ? Response.json({ codexDesktopAuthless: true, catalogRefreshPending: false }) + : Response.json({ error: "save unavailable" }, { status: 503 })); + await save; + }); + expect(latest?.settingsSaving).toBe(false); + expect(latest?.settings?.codexDesktopAuthless).toBe(saveSucceeds ? true : undefined); + expect(latest?.settings?.catalogRefreshPending).toBe(saveSucceeds ? true : undefined); + expect(syncCalls).toBe(saveSucceeds ? 1 : 0); + expect(readSessionListCache<{ settings: SettingsData }>(`ocx.dash.controls.v1:${apiBase}`)?.settings).toEqual(latest!.settings!); + // A later, settled poll still updates unrelated settings and preserves any receipt. + await act(async () => { + setClientResourceData(`dashboard-settings:${apiBase}`, { + settings: { ...initialSettings, codexDesktopAuthless: saveSucceeds ? true : undefined, port: 10200 }, + }); + }); + expect(latest?.settings?.port).toBe(10200); + expect(latest?.settings?.catalogRefreshPending).toBe(saveSucceeds ? true : undefined); + } finally { + await act(async () => { + saveResponse.resolve(Response.json({ error: "test cleanup" }, { status: 503 })); + await save; + root?.unmount(); + }); + root = null; + clearClientResourceStoresForTests(); + globalThis.fetch = originalFetch; + } +}); diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index 0fc99e5cb3..35e7abc42f 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -58,6 +58,7 @@ import { buildCursorToolDefinitions, cursorToolWireName, cursorRequestHasShellAlias, + cursorRequestUsesCodeMode, CURSOR_SHELL_ALIAS_SYSTEM_NOTE, OCX_RESPONSES_TOOL_PROVIDER, } from "./tool-definitions"; @@ -222,6 +223,7 @@ function assistantRootText( function rootPromptMessages( request: CursorRunRequest, requestScope: CursorBlobRequestScopeToken, + codeMode: boolean, /** * Calls indexed from the FULL history. The checkpoint path replays only a suffix of * `rawMessages`, so a result in that suffix can have its originating call before the cut; indexing @@ -389,10 +391,10 @@ function rootPromptMessages( if (!echoToolResultInRoot) continue; // #1920: the prefix must reflect the NORMALIZED error state (an empty // node_repl result is an error even when the runtime said isError=false). - const prefix = normalizedToolResult(message, contentToText(message.content)).isError ? "[Tool Error]" : "[Tool Result]"; + const prefix = normalizedToolResult(message, contentToText(message.content), codeMode).isError ? "[Tool Error]" : "[Tool Result]"; // The bound compares in full-history space: this loop's `i` is already full-history on the // full-replay path, and `knownCallsOffset` re-bases it when only a suffix is replayed. - const text = `${prefix}\n${toolResultToText(message, callBefore(replayedCalls, decodeCursorCallId(message.toolCallId), knownCallsOffset + i))}`; + const text = `${prefix}\n${toolResultToText(message, callBefore(replayedCalls, decodeCursorCallId(message.toolCallId), knownCallsOffset + i), codeMode)}`; pushDeduped(toolResultRootPayload(text), "toolResult", { messageIndex: i, text }, text); } } @@ -829,6 +831,7 @@ function countImages(parts: DecodedResultPart[] | undefined): number { */ function toolResultContentItems( message: OcxToolResultMessage, + codeMode: boolean, decoded?: DecodedResultPart[], maxImages = Number.POSITIVE_INFINITY, normalizedText?: NormalizedToolResult, @@ -839,10 +842,10 @@ function toolResultContentItems( })]; if (!parts) { const normalized = normalizedText - ?? normalizedToolResult(message, typeof message.content === "string" ? message.content : ""); + ?? normalizedToolResult(message, typeof message.content === "string" ? message.content : "", codeMode); return textItem(normalized.text); } - const normalized = normalizedText ?? normalizedDecodedTextResult(message, parts); + const normalized = normalizedText ?? normalizedDecodedTextResult(message, parts, codeMode); if (normalized) { // #1920/#1866: empty or failure-state Computer Use / node_repl results are // normalized before they reach the native wire. Pure-text part arrays use @@ -1058,8 +1061,9 @@ function toolCallsByCallId(messages: readonly OcxMessage[]): Map, + codeMode = false, ): string { - const normalized = normalizedToolResult(message, contentToText(message.content)); + const normalized = normalizedToolResult(message, contentToText(message.content), codeMode); return [ "[tool_result]", `call_id: ${decodeCursorCallId(message.toolCallId)}`, @@ -1075,12 +1079,16 @@ function toolResultToText( * Shared #1920 normalization entry: pure-text results only. Image-bearing or * encrypted results pass through untouched (their content is not plain text). */ -function normalizedToolResult(message: OcxToolResultMessage, text: string): NormalizedToolResult { - if (message.containsEncryptedContent) return { text, isError: message.isError }; +function normalizedToolResult(message: OcxToolResultMessage, text: string, codeMode: boolean): NormalizedToolResult { + if (message.containsEncryptedContent + || (Array.isArray(message.content) && message.content.some(part => part.type !== "text"))) { + return { text, isError: message.isError }; + } return normalizeCursorToolResultText(text, { toolName: message.toolName, toolNamespace: message.toolNamespace, isError: message.isError, + codeMode, }); } @@ -1092,9 +1100,10 @@ function normalizedToolResult(message: OcxToolResultMessage, text: string): Norm function normalizedDecodedTextResult( message: OcxToolResultMessage, parts: DecodedResultPart[], + codeMode: boolean, ): NormalizedToolResult | undefined { if (parts.some(part => part.kind !== "text")) return undefined; - return normalizedToolResult(message, parts.map(part => part.kind === "text" ? part.text : "").join("\n")); + return normalizedToolResult(message, parts.map(part => part.kind === "text" ? part.text : "").join("\n"), codeMode); } function argBytes(value: unknown): Uint8Array { @@ -1109,6 +1118,7 @@ function toolCallStep( part: Extract, requestScope: CursorBlobRequestScopeToken, result?: OcxToolResultMessage, + codeMode = false, ): Uint8Array { const args: Record = {}; for (const [key, value] of Object.entries(part.arguments ?? {})) args[key] = argBytes(value); @@ -1130,7 +1140,7 @@ function toolCallStep( providerIdentifier: OCX_RESPONSES_TOOL_PROVIDER, args, }), - ...(result ? { result: toolResultPart(result, decodedResult, maxImages) } : {}), + ...(result ? { result: toolResultPart(result, codeMode, decodedResult, maxImages) } : {}), }), }, }), @@ -1151,17 +1161,17 @@ function toolCallStep( return storeCursorBlob(encoded, requestScope); } -function toolResultPart(message: OcxToolResultMessage, decoded?: DecodedResultPart[], maxImages?: number) { +function toolResultPart(message: OcxToolResultMessage, codeMode: boolean, decoded?: DecodedResultPart[], maxImages?: number) { const parts = decoded ?? decodeResultParts(message); const normalized = parts - ? normalizedDecodedTextResult(message, parts) - : normalizedToolResult(message, typeof message.content === "string" ? message.content : ""); + ? normalizedDecodedTextResult(message, parts, codeMode) + : normalizedToolResult(message, typeof message.content === "string" ? message.content : "", codeMode); return create(McpToolResultSchema, { result: { case: "success", value: create(McpSuccessSchema, { isError: normalized?.isError ?? message.isError, - content: toolResultContentItems(message, parts, maxImages, normalized), + content: toolResultContentItems(message, codeMode, parts, maxImages, normalized), }), }, }); @@ -1199,6 +1209,7 @@ function lastActionIndex(messages: readonly OcxMessage[] | undefined): number { function conversationTurns( request: CursorRunRequest, requestScope: CursorBlobRequestScopeToken, + codeMode: boolean, historyMessageStart = 0, /** Calls indexed from the FULL history; see {@link rootPromptMessages}. */ knownCalls?: Map>, @@ -1269,7 +1280,7 @@ function conversationTurns( // #1920/#1866: this external-replay site bypasses toolResultToText, so it // must consume the normalizer directly — cursor/grok-4.6 is the exact // reported repro path for empty Computer Use results. - const normalized = normalizedToolResult(message, contentToText(message.content)); + const normalized = normalizedToolResult(message, contentToText(message.content), codeMode); const prefix = normalized.isError ? "[Tool Error]" : "[Tool Result]"; // Name the invocation here as well, for the same reason the root replay does: a result with // no visible originating call reads as an interrupted attempt (devlog 260829 000_rca). @@ -1285,13 +1296,13 @@ function conversationTurns( } const priorCall = pendingToolCalls.get(message.toolCallId); if (priorCall) { - current.steps.push(toolCallStep(priorCall, requestScope, message)); + current.steps.push(toolCallStep(priorCall, requestScope, message, codeMode)); pendingToolCalls.delete(message.toolCallId); } else { current.steps.push(storeCursorBlob(toBinary(ConversationStepSchema, create(ConversationStepSchema, { message: { case: "assistantMessage", - value: create(AssistantMessageSchema, { text: toolResultToText(message) }), + value: create(AssistantMessageSchema, { text: toolResultToText(message, undefined, codeMode) }), }, })), requestScope)); } @@ -1368,6 +1379,9 @@ function buildPreparedCursorRunRequest( options?: { estimateInputTokens?: boolean }, ): PreparedCursorRunRequest { const rawText = activePromptText(request); + // Use the same visible catalog as mcp_tools, including tool_choice, for every history path. + const visibleTools = cursorToolsForActivePrompt(request.tools, rawText, request.toolChoice); + const codeMode = cursorRequestUsesCodeMode(visibleTools, request.toolChoice); const lastRole = request.messages.at(-1)?.role; const text = lastRole === "user" || lastRole === "developer" ? appendCursorGenericToolUseHint(request.tools, rawText) @@ -1471,7 +1485,7 @@ function buildPreparedCursorRunRequest( // against the raw limit left a band of a few hundred bytes below it where the checkpoint was kept, // the suffix budget collapsed, and the newest tool result vanished. Adding `systemBytes` moved the // band without closing it. Asking pruning what survived cannot drift from what pruning does. - const suffixRoots = rootPromptMessages(suffixRequest, requestScope, fullHistoryCalls, suffixStart, carriedRoots); + const suffixRoots = rootPromptMessages(suffixRequest, requestScope, codeMode, fullHistoryCalls, suffixStart, carriedRoots); const suffixSystemCount = systemPromptBlobs(suffixRequest).length; // A tool continuation whose own result did not survive is worthless: that result is the whole // reason the turn exists. "Kept SOMETHING" is not enough either — inside the band this fix first @@ -1543,7 +1557,7 @@ function buildPreparedCursorRunRequest( // checkpoint is re-decoded and re-abandoned each turn until TTL, which is wasted work rather // than wrong output (audit r8 rounds 3 and 4). } else { - const suffixTurns = conversationTurns(suffixRequest, requestScope, suffixRoots.historyMessageStart, fullHistoryCalls, suffixStart); + const suffixTurns = conversationTurns(suffixRequest, requestScope, codeMode, suffixRoots.historyMessageStart, fullHistoryCalls, suffixStart); const suffixHistoryIds = suffixRoots.ids.slice(suffixSystemCount); const suffixHistorySerialized = suffixRoots.serialized.slice(suffixSystemCount); conversationState = create(ConversationStateStructureSchema, { @@ -1573,10 +1587,10 @@ function buildPreparedCursorRunRequest( } } if (!conversationState) { - rootPromptMessagesState = rootPromptMessages(request, requestScope); + rootPromptMessagesState = rootPromptMessages(request, requestScope, codeMode); conversationState = create(ConversationStateStructureSchema, { rootPromptMessagesJson: rootPromptMessagesState.ids, - turns: conversationTurns(request, requestScope, rootPromptMessagesState.historyMessageStart), + turns: conversationTurns(request, requestScope, codeMode, rootPromptMessagesState.historyMessageStart), todos: [], pendingToolCalls: [], previousWorkspaceUris: [], @@ -1590,7 +1604,6 @@ function buildPreparedCursorRunRequest( } // Hoisted out of the mcp_tools spread below so the estimate can read the same // filtered definitions the wire carries. Both helpers are pure. - const visibleTools = cursorToolsForActivePrompt(request.tools, rawText, request.toolChoice); const mcpToolDefs = buildCursorToolDefinitions(visibleTools, request.toolChoice); // The envelope is measured HERE, on the final root set, and nowhere else. // diff --git a/src/adapters/cursor/tool-result-normalize.ts b/src/adapters/cursor/tool-result-normalize.ts index edec3a14f4..4f53a50d70 100644 --- a/src/adapters/cursor/tool-result-normalize.ts +++ b/src/adapters/cursor/tool-result-normalize.ts @@ -86,7 +86,13 @@ export interface NormalizedToolResultText { */ export function normalizeCursorToolResultText( text: string, - options: { toolName?: string; toolNamespace?: string; isError?: boolean } = {}, + options: { + toolName?: string; + toolNamespace?: string; + isError?: boolean; + /** True only when the request's visible catalog is Codex code mode. */ + codeMode?: boolean; + } = {}, ): NormalizedToolResultText { const isError = options.isError === true; const computerUse = isNodeReplOrComputerUseTool(options.toolName, options.toolNamespace); @@ -107,16 +113,18 @@ export function normalizeCursorToolResultText( changed: true, }; } - // A host failure string inside a code-mode exec result gets the rule it broke appended, with - // Cursor's isError decision left exactly as the caller passed it. A replayed result that already - // carries a recovery line returns here unchanged: falling through would let the legacy loop - // below match the lowercase import marker a second time and flip isError. - if (isCodexCodeModeExecResult(options.toolName, options.toolNamespace)) { - if (text.includes(CODE_MODE_HOST_RECOVERY_PREFIX)) return { text, isError, changed: false }; + // Replayed guidance and successful wrappers must not enter the legacy substring matcher. + if (text.includes(CODE_MODE_HOST_RECOVERY_PREFIX) + || /^(?:Script completed|Command finished|Execution finished)\b/.test(text.trimStart())) { + return { text, isError, changed: false }; + } + // The request's visible catalog establishes provenance; the name alone also matches structured + // exec tools. Host guidance preserves Cursor's original error status. + if (options.codeMode === true && isCodexCodeModeExecResult(options.toolName, options.toolNamespace)) { const hostFailure = annotateCodeModeHostFailure(text, options); if (hostFailure !== undefined) return { text: hostFailure, isError, changed: true }; } - if (!isError) { + if (computerUse && !isError) { for (const { marker, guidance } of RUNTIME_FAILURE_GUIDANCE) { if (text.includes(marker)) { return { text: `${text}\n[recovery: ${guidance}]`, isError: true, changed: true }; diff --git a/src/adapters/exec-tool-result-normalize.ts b/src/adapters/exec-tool-result-normalize.ts index a6e4a3190a..c31e1c76ef 100644 --- a/src/adapters/exec-tool-result-normalize.ts +++ b/src/adapters/exec-tool-result-normalize.ts @@ -158,6 +158,10 @@ export const CODE_MODE_HOST_FAILURE_GUIDANCE: ReadonlyArray<{ marker: string; gu /** Prefix of every recovery line this module appends; callers use it to recognise replayed annotations. */ export const CODE_MODE_HOST_RECOVERY_PREFIX = "[recovery: "; +// Only a leading failure envelope or a complete host diagnostic establishes error context. +// Do not search for this prefix inside output: successful source reads can quote any of these. +const CODE_MODE_HOST_ERROR_PREFIX = /^(?:Script failed(?:[ \t]*(?:\r?\n|$)|:)|Script error:|(?:Error|TypeError|SyntaxError):|tool `apply_patch` expects a string input\b|apply_patch verification failed:|Unsupported import in exec:)/i; + /** Namespaces under which Cursor displays Codex's own Responses tools (see cursor/tool-naming.ts). */ const CODEX_RESPONSES_DISPLAY_NAMESPACES: ReadonlySet = new Set(["opencodex-responses", "mcp__opencodex-responses"]); /** Flattened spellings of the same code-mode exec when a client folds the namespace into the name. */ @@ -181,10 +185,10 @@ export function isCodexCodeModeExecResult(toolName?: string, toolNamespace?: str } /** - * Append a one-line recovery hint when a code-mode exec result carries a known host failure string. - * Returns undefined when the tool is not the code-mode exec, no marker matches, or a recovery line is - * already present (a replayed annotated result must not grow a second one). Never touches error - * status: the host already decided whether the call failed. + * Append a one-line recovery hint when a code-mode exec result starts with a host error context + * and carries a known diagnostic. Successful wrappers and unframed phrase quotations pass through. + * Returns undefined when the tool/context/marker does not match or a recovery line is already + * present (a replayed result must not grow a second one). Never touches error status. */ export function annotateCodeModeHostFailure( text: string, @@ -192,6 +196,7 @@ export function annotateCodeModeHostFailure( ): string | undefined { if (!isCodexCodeModeExecResult(options.toolName, options.toolNamespace)) return undefined; if (text.includes(CODE_MODE_HOST_RECOVERY_PREFIX)) return undefined; + if (!CODE_MODE_HOST_ERROR_PREFIX.test(text.trimStart())) return undefined; const lower = text.toLowerCase(); const hit = CODE_MODE_HOST_FAILURE_GUIDANCE.find(({ marker }) => lower.includes(marker)); return hit ? `${text}\n${CODE_MODE_HOST_RECOVERY_PREFIX}${hit.guidance}]` : undefined; diff --git a/src/cli/observe.ts b/src/cli/observe.ts index 10a77254d0..62de3a0fc7 100644 --- a/src/cli/observe.ts +++ b/src/cli/observe.ts @@ -11,7 +11,7 @@ import { type RuntimeApiDeps, } from "./runtime-api"; import { formatUsageReport } from "./usage-report"; -import { USAGE_RANGES, USAGE_SURFACES } from "../usage/summary"; +import { USAGE_RANGES, USAGE_SURFACES, type UsageSummary } from "../usage/summary"; import { parseUsageTimeWindow, type UsageTimeWindow } from "../usage/time-range"; import { redactSecretString } from "../lib/redact"; @@ -165,7 +165,11 @@ async function usage(argv: string[], deps: RuntimeApiDeps): Promise { throw new CliUsageError(`--surface must be one of ${USAGE_SURFACES.join(", ")}`, USAGE); } rejectArgs(args.map(redactSecretString), USAGE); - const result = await runtimeRequest(`/api/usage${query({ range, surface, provider, model, since: window?.since, until: window?.until })}`, {}, deps); + const result = await runtimeRequest(`/api/usage${query({ range, surface, provider, model, since: window?.since, until: window?.until })}`, {}, deps); + // Older daemons ignore custom bounds and return successful preset reports. + if (window && (result?.customWindow !== true || result.since !== window.since || result.until !== window.until)) { + throw new Error("The server did not confirm the requested custom usage window. Upgrade and restart the proxy, then retry."); + } // Built only when it will be printed: JavaScript evaluates arguments before // the call, so passing formatUsageReport(...) inline would run the human // renderer during --json and let its assumptions affect a path that is meant diff --git a/src/oauth/orcarouter.ts b/src/oauth/orcarouter.ts index afbf6066f4..6c8c8ccb03 100644 --- a/src/oauth/orcarouter.ts +++ b/src/oauth/orcarouter.ts @@ -79,7 +79,9 @@ function parseKeyPayload(value: unknown): OAuthCredentials { if (!key.startsWith(ORCAROUTER_KEY_PREFIX) || key.length > 4096 || /[\r\n]/.test(key)) { throw new Error("OrcaRouter key exchange did not return a valid API key"); } - if (payload.scope !== "api") { + // The documented key/user_id response omits scope. If supplied, it must match + // the api scope requested by this PKCE flow. + if (payload.scope !== undefined && payload.scope !== "api") { throw new Error("OrcaRouter key exchange did not grant the required api scope"); } const accountId = typeof payload.user_id === "string" diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts index b5fca15abb..42364b3d57 100644 --- a/src/server/management/model-routes.ts +++ b/src/server/management/model-routes.ts @@ -418,8 +418,9 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise 0) provider.modelCosts = nextModelCosts; - else delete provider.modelCosts; + // Keep even an empty map until persistence reconciles individual model keys. + // Deleting the property would also delete prices another writer added on disk. + provider.modelCosts = nextModelCosts; try { // The persistence owner refreshes usage overlays after its atomic write. // Price-only edits do not change routing or require catalog convergence. diff --git a/structure/05_gui-and-management-api.md b/structure/05_gui-and-management-api.md index 4a697671a4..c1562df4ec 100644 --- a/structure/05_gui-and-management-api.md +++ b/structure/05_gui-and-management-api.md @@ -364,6 +364,18 @@ overlay-version and timezone checks. Preset warming never consumes custom summar The response retains its preset range discriminator for compatibility and explicitly marks `customWindow`, `since`, and `until`; the chart uses the window's local calendar days with the existing 366-day cap. GUI custom reports bypass the held preset/session cache. +Both dashboard and CLI reject a custom report unless the server echoes `customWindow: true` +and the exact requested numeric `since` and `until`. An older daemon that silently returns a +preset report cannot supply totals labelled with the requested custom interval. + +Resetting a manual model price keeps the map, even when temporarily empty, through persistence +reconciliation. This removes only the requested entry and preserves sibling rates independently +written to disk. The Desktop sign-in preference likewise distinguishes saved from applied state: +its pending flag survives cache refresh/remount until a successful sync confirms application. + +Subagent fallback settings load independently of the main roster. Their failure disables only +fallback controls and provides a retry; available fallback options come from that endpoint's +availability list while already-configured stale values remain editable. Account quota discovery is capability-based. Cheap OAuth and provider-key lists include `quotaMode` (`probe`, `passive`, or `unsupported`) without contacting upstream quota APIs. diff --git a/tests/adapters/exec-tool-result-normalize.test.ts b/tests/adapters/exec-tool-result-normalize.test.ts index a685ba6463..953e769aac 100644 --- a/tests/adapters/exec-tool-result-normalize.test.ts +++ b/tests/adapters/exec-tool-result-normalize.test.ts @@ -1,4 +1,7 @@ import { describe, expect, test } from "bun:test"; +import { createResponsesPassthroughAdapter } from "../../src/adapters/openai-responses"; +import { createTranslatorBudget } from "../../src/lib/translator-budget"; +import { parseRequest } from "../../src/responses/parser"; import { CODE_MODE_HOST_CONTRACT_SENTENCE, CODE_MODE_HOST_FAILURE_GUIDANCE, @@ -26,14 +29,63 @@ describe("code-mode host failure annotation", () => { )).toContain("bare marker line `*** Begin Patch`"); }); + const successfulSearch = "Script completed\nWall time 0.1 seconds\nOutput:\nREADME.md:8: expects a string input\nexit_code: 0"; + + test("preserves the audit's successful rg output byte-for-byte on the Responses wire", () => { + const body = { + model: "grok-4.6", + tools: [{ type: "namespace", name: "functions", tools: [{ + type: "custom", name: "exec", description: "Run JavaScript in a V8 isolate.", + }] }], + input: [ + { type: "custom_tool_call", name: "exec", call_id: "call_probe", input: 'text(await tools.exec_command({cmd:"rg phrase README.md"}))' }, + { type: "custom_tool_call_output", call_id: "call_probe", output: successfulSearch }, + ], + }; + const budget = createTranslatorBudget(); + try { + const request = createResponsesPassthroughAdapter({ + adapter: "openai-responses", baseUrl: "https://api.x.ai/v1", authMode: "key", apiKey: "test-key", + }).buildRequest(parseRequest(body), { headers: new Headers(), translatorBudget: budget }); + expect(JSON.parse(request.body).input[1].output).toBe(successfulSearch); + } finally { + budget.dispose(); + } + }); + + test.each([ + successfulSearch, + "README.md:8: expects a string input", + "expects a string input", + "the first line of the patch must be '*** Begin Patch'", + "the last line of the patch must be '*** End Patch'", + "The docs say Unsupported import in exec: node:fs", + "README.md:8: Script error: tool `apply_patch` expects a string input", + "Script completed\nWall time 0.1 seconds\nOutput:\nScript error:\ntool `apply_patch` expects a string input\nexit_code: 0", + "Script completed\nWall time 0.1 seconds\nOutput:\nError: Unsupported import in exec: node:fs\nexit_code: 0", + "Script completed\r\nWall time 0.1 seconds\r\nOutput:\napply_patch verification failed: invalid patch: The first line of the patch must be '*** Begin Patch'\nexit_code: 0", + "Script completed\nWall time 0.1 seconds\nOutput:\napply_patch verification failed: invalid patch: The last line of the patch must be '*** End Patch'\nexit_code: 0", + ])("does not annotate a phrase without a host error context: %p", text => { + expect(annotateCodeModeHostFailure(text, { toolName: "exec" })).toBeUndefined(); + }); + + test.each([ + "tool `apply_patch` expects a string input", + "Error: tool `apply_patch` expects a string input", + "Script error:\ntool `apply_patch` expects a string input", + "Script failed\r\nWall time 0.1 seconds\r\nOutput:\r\nError: tool `apply_patch` expects a string input", + ])("recognizes direct and wrapped host diagnostics: %p", text => { + expect(annotateCodeModeHostFailure(text, { toolName: "exec" })).toContain("exactly one string"); + }); + test("leaves non-exec tools, shell bridges, foreign namespaces, non-matching text and already-annotated text alone", () => { - expect(annotateCodeModeHostFailure("expects a string input", { toolName: "read_file" })).toBeUndefined(); + expect(annotateCodeModeHostFailure("Script error:\ntool `apply_patch` expects a string input", { toolName: "read_file" })).toBeUndefined(); // Flat shell bridges never run the isolate, so the four strings cannot be theirs. - expect(annotateCodeModeHostFailure("expects a string input", { toolName: "exec_command" })).toBeUndefined(); + expect(annotateCodeModeHostFailure("Script error:\ntool `apply_patch` expects a string input", { toolName: "exec_command" })).toBeUndefined(); // A foreign MCP server's own exec is not Codex's, even when its output quotes the phrase, and a // namespace that merely CONTAINS the provider name is still foreign. - expect(annotateCodeModeHostFailure("expects a string input", { toolName: "exec", toolNamespace: "mcp__docker" })).toBeUndefined(); - expect(annotateCodeModeHostFailure("expects a string input", { toolName: "exec", toolNamespace: "mcp__foreign-opencodex-responses" })).toBeUndefined(); + expect(annotateCodeModeHostFailure("Script error:\ntool `apply_patch` expects a string input", { toolName: "exec", toolNamespace: "mcp__docker" })).toBeUndefined(); + expect(annotateCodeModeHostFailure("Script error:\ntool `apply_patch` expects a string input", { toolName: "exec", toolNamespace: "mcp__foreign-opencodex-responses" })).toBeUndefined(); // Codex's own display namespaces and flattened aliases for the same code-mode tool still count. for (const options of [ { toolName: "exec", toolNamespace: "opencodex-responses" }, @@ -41,10 +93,10 @@ describe("code-mode host failure annotation", () => { { toolName: "mcp__opencodex-responses__exec" }, { toolName: "mcp_opencodex-responses_exec" }, ]) { - expect(annotateCodeModeHostFailure("expects a string input", options)).toContain("[recovery:"); + expect(annotateCodeModeHostFailure("Script error:\ntool `apply_patch` expects a string input", options)).toContain("[recovery:"); } expect(annotateCodeModeHostFailure("all good", { toolName: "exec" })).toBeUndefined(); - const once = annotateCodeModeHostFailure("expects a string input", { toolName: "exec" }); + const once = annotateCodeModeHostFailure("Script error:\ntool `apply_patch` expects a string input", { toolName: "exec" }); if (!once) throw new Error("expected one annotation"); expect(annotateCodeModeHostFailure(once, { toolName: "exec" })).toBeUndefined(); }); diff --git a/tests/cli/cli-usage-report.test.ts b/tests/cli/cli-usage-report.test.ts index d1fdd7b4b5..3342aab2ea 100644 --- a/tests/cli/cli-usage-report.test.ts +++ b/tests/cli/cli-usage-report.test.ts @@ -165,8 +165,40 @@ describe("ocx usage command", () => { since: "1709164800123", until: "1709164800123", }); expect(out.split("\n")[0]).toContain("custom 2024-02-29T00:00:00.123Z to 2024-02-29T00:00:00.123Z (inclusive)"); - expect((await run(["usage", "--since", "0", "--until", "0", "--json"], body)).out) - .toBe(JSON.stringify(body, null, 2)); + const epochBody = payload({ customWindow: true, since: 0, until: 0 }); + const epochResult = await run(["usage", "--since", "0", "--until", "0", "--json"], epochBody); + expect(epochResult.code).toBe(0); + expect(epochResult.out).toBe(JSON.stringify(epochBody, null, 2)); + }); + + test.each([ + ["older daemon", {}], + ["missing mode", { since: 100, until: 200 }], + ["preset mode", { customWindow: false, since: 100, until: 200 }], + ["nonboolean mode", { customWindow: "true", since: 100, until: 200 }], + ["missing since", { customWindow: true, since: undefined, until: 200 }], + ["missing until", { customWindow: true, since: 100 }], + ["wrong since", { customWindow: true, since: 101, until: 200 }], + ["wrong until", { customWindow: true, since: 100, until: 201 }], + ["string bounds", { customWindow: true, since: "100", until: "200" }], + ])("rejects custom %s receipts before human or JSON output", async (_name, receipt) => { + const errors: string[] = []; + const errorSpy = spyOn(console, "error").mockImplementation((...args: unknown[]) => { + errors.push(args.map(String).join(" ")); + }); + try { + for (const format of [[], ["--json"]]) { + errors.length = 0; + const result = await run(["usage", "--since", "100", "--until", "200", ...format], payload(receipt)); + expect(result.urls).toHaveLength(1); + expect(result.code).toBe(1); + expect(result.out).toBe(""); + expect(errors.join("\n")).toContain("custom usage window"); + expect(errors.join("\n")).toMatch(/upgrade.*restart/i); + } + } finally { + errorSpy.mockRestore(); + } }); test("rejects malformed or unpaired windows as usage errors without an API request", async () => { diff --git a/tests/gui/provider-workspace-auth.test.ts b/tests/gui/provider-workspace-auth.test.ts index e121142c01..5ae17bcb91 100644 --- a/tests/gui/provider-workspace-auth.test.ts +++ b/tests/gui/provider-workspace-auth.test.ts @@ -168,7 +168,10 @@ describe("workspace account integration seam", () => { expect(page).toContain("accountId: reauthTargetId, reauth: true"); expect(page).toContain("prov.reauthIdentityMismatch"); expect(page).toContain("oauthLoginGenerationRef"); - expect(page).toContain("/api/oauth/login/cancel"); + expect(page).toContain('from "../oauth-cancellation-barrier"'); + expect(page).toContain("cancelOAuthLogin(apiBase, provider)"); + const cancellation = await Bun.file("gui/src/oauth-cancellation-barrier.ts").text(); + expect(cancellation).toContain("/api/oauth/login/cancel"); expect(page).toContain("deviceCode"); // The device-code widget is now owned by the shared login-hint component so // every login surface renders the same one. The panel's obligation is to diff --git a/tests/providers/cursor/cursor-toolresult-normalize.test.ts b/tests/providers/cursor/cursor-toolresult-normalize.test.ts index 610e5e578e..e4b9dd49d6 100644 --- a/tests/providers/cursor/cursor-toolresult-normalize.test.ts +++ b/tests/providers/cursor/cursor-toolresult-normalize.test.ts @@ -10,6 +10,7 @@ import { GetBlobArgsSchema, KvServerMessageSchema, } from "../../../src/adapters/cursor/gen/agent_pb"; +import type { CursorRunRequest } from "../../../src/adapters/cursor/types"; import type { OcxMessage, OcxToolResultMessage } from "../../../src/types"; function blobData(blobId: Uint8Array): Uint8Array { @@ -52,6 +53,7 @@ function requestWith( isError: boolean; containsEncryptedContent: boolean; }> = {}, + requestOverrides: Partial = {}, ) { const rawMessages: OcxMessage[] = [ { role: "user", content: "run it", timestamp: 1 }, @@ -59,7 +61,7 @@ function requestWith( role: "assistant", model: "cursor/auto", timestamp: 2, - content: [{ type: "toolCall", id: "call_1", name: toolOverrides.toolName ?? "js", namespace: toolOverrides.toolNamespace ?? "mcp__node_repl", arguments: {} }], + content: [{ type: "toolCall", id: "call_1", name: toolOverrides.toolName ?? "js", namespace: "toolNamespace" in toolOverrides ? toolOverrides.toolNamespace : "mcp__node_repl", arguments: {} }], }, { role: "toolResult", @@ -78,6 +80,7 @@ function requestWith( system: ["You are helpful."], messages: [{ role: "tool", content: "[tool_result]" }], rawMessages, + ...requestOverrides, }); } @@ -109,13 +112,13 @@ describe("normalizeCursorToolResultText (#1920/#1866 unit rows)", () => { test.each(["Unsupported import in exec: node:fs", "unsupported import in exec: node:fs"])( "a code-mode exec result carrying %p gains the shared hint, keeps its isError, and is not re-annotated on replay", (payload) => { - const out = normalizeCursorToolResultText(payload, { toolName: "exec" }); + const out = normalizeCursorToolResultText(payload, { toolName: "exec", codeMode: true }); expect(out.changed).toBe(true); expect(out.isError).toBe(false); expect(out.text).toBe(`${payload}\n[recovery: Imports are not available in this exec context; use the injected globals (tools, text, notify, store, load, ALL_TOOLS) instead.]`); // Replay through Responses history arrives with isError=false; the legacy lowercase marker // row must not get a second look at it. - const replay = normalizeCursorToolResultText(out.text, { toolName: "exec", isError: false }); + const replay = normalizeCursorToolResultText(out.text, { toolName: "exec", isError: false, codeMode: true }); expect(replay).toEqual({ text: out.text, isError: false, changed: false }); }, ); @@ -221,3 +224,119 @@ describe("native wire decode (#1920 disposition: formatted text at toolResultPar expect(first.content.case === "text" ? first.content.value.text : "").toBe("plain output"); }); }); + +/** Read both model-visible roots and external-model assistant steps from stored wire blobs. */ +function decodedReplay(bytes: Uint8Array) { + const message = fromBinary(AgentClientMessageSchema, bytes); + if (message.message.case !== "runRequest") throw new Error("expected run request"); + const state = message.message.value.conversationState; + const roots = (state?.rootPromptMessagesJson ?? []).map(id => { + const root = JSON.parse(new TextDecoder().decode(blobData(id))); + return typeof root.content === "string" ? root.content : root.content?.[0]?.text ?? ""; + }).filter((text: string) => /^\[Tool (?:Result|Error)\]/.test(text)); + const steps: string[] = []; + for (const id of state?.turns ?? []) { + const turn = fromBinary(ConversationTurnStructureSchema, blobData(id)); + if (turn.turn.case !== "agentConversationTurn") continue; + for (const stepId of turn.turn.value.steps) { + const step = fromBinary(ConversationStepSchema, blobData(stepId)); + if (step.message.case === "assistantMessage") steps.push(step.message.value.text); + } + } + return { roots, steps }; +} + +const codeModeTools = [{ name: "exec", freeform: true, description: "Run JavaScript in a V8 isolate.", parameters: {} }]; +const execResult = { toolName: "exec", toolNamespace: undefined }; +const importFailure = "unsupported import in exec: node:fs"; +const importRecovery = "[recovery: Imports are not available in this exec context; use the injected globals (tools, text, notify, store, load, ALL_TOOLS) instead.]"; +const successfulSource = "Script completed\nWall time 0.1 seconds\nOutput:\nREADME.md:8: unsupported import in exec\nexit_code: 0"; + +function expectResultOutput(bytes: Uint8Array, modelId: string, output: string, isError = false) { + const { roots, steps } = decodedReplay(bytes); + expect(roots).toHaveLength(1); + expect(roots[0]).toContain(`is_error: ${isError}\noutput:\n${output}`); + expect(roots[0].endsWith(output)).toBe(true); + expect(roots[0].startsWith(isError ? "[Tool Error]" : "[Tool Result]")).toBe(true); + if (modelId === "composer-2.5") { + const result = decodedToolResult(bytes); + expect(result).toBeDefined(); + expect(result!.isError).toBe(isError); + const first = result!.content[0]; + expect(first?.content.case === "text" ? first.content.value.text : undefined).toBe(output); + } else { + expect(steps).toHaveLength(1); + expect(steps[0].startsWith(isError ? "[Tool Error]" : "[Tool Result]")).toBe(true); + expect(steps[0].endsWith(`\n${output}`)).toBe(true); + expect(steps[0].split("[recovery:").length).toBe(output.split("[recovery:").length); + } +} + +describe("Cursor host failure provenance and successful-output regression", () => { + for (const modelId of ["composer-2.5", "grok-4.6"]) { + test.each([ + ["structured exec", { tools: [{ ...codeModeTools[0], freeform: false }] }], + ["no catalog", {}], + ["shell bridge present", { tools: [...codeModeTools, { name: "exec_command", parameters: {} }] }], + ["tool choice none", { tools: codeModeTools, toolChoice: "none" }], + ["foreign exec namespace", { tools: [{ ...codeModeTools[0], namespace: "mcp__docker" }] }], + ] satisfies [string, Partial][])(`${modelId}: %s has no code-mode host annotation`, (_label, catalog) => { + for (const output of ["Script error:\ntool `apply_patch` expects a string input", importFailure]) { + expectResultOutput(requestWith(output, execResult, { modelId, ...catalog }), modelId, output); + } + }); + + test(`${modelId}: a genuine code-mode failure keeps error status and is idempotent`, () => { + const output = `${importFailure}\n${importRecovery}`; + for (const isError of [false, true]) { + const options = { modelId, tools: codeModeTools }; + expectResultOutput(requestWith([{ type: "text", text: importFailure }], { ...execResult, isError }, options), modelId, output, isError); + expectResultOutput(requestWith(output, { ...execResult, isError }, options), modelId, output, isError); + } + }); + + test(`${modelId}: successful source output bypasses legacy import fallback`, () => { + expectResultOutput(requestWith(successfulSource, execResult, { modelId, tools: codeModeTools }), modelId, successfulSource); + }); + + test(`${modelId}: node_repl keeps its legacy error guidance on replay`, () => { + const failure = "ReferenceError: sky is not defined"; + const output = `${failure}\n[recovery: The sky binding is unavailable in this context; Computer Use calls only work inside the privileged node_repl session.]`; + expectResultOutput(requestWith(failure, {}, { modelId, tools: codeModeTools }), modelId, output, true); + expectResultOutput(requestWith(output, { isError: true }, { modelId, tools: codeModeTools }), modelId, output, true); + }); + + test(`${modelId}: encrypted code-mode output is untouched`, () => { + expectResultOutput(requestWith(importFailure, { ...execResult, containsEncryptedContent: true }, { modelId, tools: codeModeTools }), modelId, importFailure); + }); + + test(`${modelId}: image-bearing replay does not infer a host failure from its text`, () => { + const bytes = requestWith([ + { type: "text", text: importFailure }, + { type: "image", imageUrl: "data:image/png;base64,iVBORw0KGgo=" }, + ], execResult, { modelId, tools: codeModeTools }); + const { roots, steps } = decodedReplay(bytes); + expect(roots).toHaveLength(1); + for (const text of [...roots, ...steps]) { + expect(text).toContain(importFailure); + expect(text).not.toContain("[recovery:"); + expect(text).not.toContain("[Tool Error]"); + } + if (modelId === "composer-2.5") { + const result = decodedToolResult(bytes)!; + expect(result.isError).toBe(false); + expect(result.content.map(part => part.content.case)).toEqual(["text", "image"]); + } + }); + } + + test("unit annotation requires explicit code-mode provenance", () => { + for (const codeMode of [undefined, false]) { + expect(normalizeCursorToolResultText(importFailure, { toolName: "exec", codeMode })).toEqual({ text: importFailure, isError: false, changed: false }); + } + }); + + test("successful node_repl wrappers also bypass legacy substring guidance", () => { + expect(normalizeCursorToolResultText(successfulSource, { toolName: "node_repl" })).toEqual({ text: successfulSource, isError: false, changed: false }); + }); +}); diff --git a/tests/providers/orcarouter-provider.test.ts b/tests/providers/orcarouter-provider.test.ts index 0c7d002a15..2b8cb02a54 100644 --- a/tests/providers/orcarouter-provider.test.ts +++ b/tests/providers/orcarouter-provider.test.ts @@ -1,9 +1,10 @@ -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { createHash } from "node:crypto"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { catalogHintsFromModelsApiItem } from "../../src/codex/catalog/provider-fetch"; +import { providerDestinationConfigError } from "../../src/lib/destination-policy"; import { forceRefreshOAuthAccessSnapshot, getValidAccessTokenSnapshot, @@ -35,9 +36,20 @@ import { removeTreeWithRetry } from "../helpers/remove-tree"; const originalFetch = globalThis.fetch; const englishT: TFn = (key, vars) => interpolate(en[key], vars); +const originEnvNames = ["ORCAROUTER_BASE_URL", "ORCAROUTER_API_BASE_URL", "ORCAROUTER_AUTH_BASE_URL"] as const; +const originalOrigins = originEnvNames.map(name => process.env[name]); + +beforeEach(() => { + for (const name of originEnvNames) delete process.env[name]; +}); afterEach(() => { globalThis.fetch = originalFetch; + originEnvNames.forEach((name, index) => { + const value = originalOrigins[index]; + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + }); }); function registryEntry(id: "orcarouter" | "orcarouter-oauth") { @@ -46,6 +58,61 @@ function registryEntry(id: "orcarouter" | "orcarouter-oauth") { return entry; } +/** Keep the callback listener and PKCE exchange real; replace only the upstream response. */ +async function exchangeThroughCallback(payload: unknown) { + const abort = new AbortController(); + const callbackDone = Promise.withResolvers(); + let exchanges = 0; + let challenge: string | null = null; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + expect(String(input)).toBe("https://www.orcarouter.ai/api/v1/auth/keys"); + expect(init?.method).toBe("POST"); + expect(init?.redirect).toBe("error"); + const body = JSON.parse(String(init?.body)) as Record; + expect(body.code).toBe("callback-test-code"); + expect(body.code_challenge_method).toBe("S256"); + expect(createHash("sha256").update(String(body.code_verifier)).digest("base64url")) + .toBe(challenge); + exchanges++; + return Response.json(payload); + }) as typeof fetch; + const flow = new OrcaRouterOAuthFlow({ + signal: AbortSignal.any([abort.signal, AbortSignal.timeout(3000)]), + onAuth: ({ url }) => { + void (async () => { + const auth = new URL(url); + challenge = auth.searchParams.get("code_challenge"); + expect(auth.searchParams.get("scope")).toBe("api"); + const callback = new URL(auth.searchParams.get("callback_url")!); + expect(callback.hostname).toBe("127.0.0.1"); + callback.search = new URLSearchParams({ code: "callback-test-code", state: "wrong-state" }).toString(); + const rejected = await originalFetch(callback); + expect(rejected.status).toBe(400); + await rejected.text(); + expect(exchanges).toBe(0); + callback.searchParams.set("state", auth.searchParams.get("state")!); + const accepted = await originalFetch(callback); + expect(accepted.status).toBe(200); + await accepted.text(); + })().then(callbackDone.resolve, callbackDone.reject); + }, + }); + // Observe a rejected exchange immediately, while the callback HTTP response drains. + const login = flow.login().then( + credential => ({ ok: true as const, credential }), + error => ({ ok: false as const, error }), + ); + try { + const [result] = await Promise.all([login, callbackDone.promise]); + expect(exchanges).toBe(1); + if (!result.ok) throw result.error; + return result.credential; + } finally { + abort.abort(); + await login; + } +} + describe("OrcaRouter dual authentication", () => { test("keeps API-key and PKCE account login as explicit first-class choices", () => { const key = registryEntry("orcarouter"); @@ -175,6 +242,50 @@ describe("OrcaRouter dual authentication", () => { expect(message).not.toContain(verifier); }); + test("completes the real callback with documented key/user_id and no response scope", async () => { + expect(await exchangeThroughCallback({ key: "sk-orca-callback-test", user_id: 123 })).toEqual({ + access: "sk-orca-callback-test", + refresh: "sk-orca-callback-test", + expires: Number.MAX_SAFE_INTEGER, + accountId: "123", + source: "oauth", + }); + }); + + test("completes the real callback with an explicit api scope and string identity", async () => { + expect(await exchangeThroughCallback({ key: "sk-orca-callback-test", user_id: "user-42", scope: "api" })) + .toMatchObject({ accountId: "user-42", source: "oauth" }); + }); + + test.each(["admin", "api read", "", null, false, ["api"]].map(scope => [scope]))( + "rejects an explicitly invalid response scope %j through the real callback", + async scope => { + await expect(exchangeThroughCallback({ key: "sk-orca-callback-test", user_id: 123, scope })) + .rejects.toThrow("did not grant the required api scope"); + }, + ); + + test.each([ + ["missing", undefined], ["null", null], ["blank", " "], ["fractional", 1.5], + ["unsafe integer", Number.MAX_SAFE_INTEGER + 1], ["object", {}], + ["too long", "u".repeat(257)], ["control character", "user\x00id"], + ])("rejects %s user identity even when scope is omitted", async (_name, user_id) => { + await expect(exchangeThroughCallback({ key: "sk-orca-callback-test", user_id })) + .rejects.toThrow("did not return a valid user id"); + }); + + test.each([ + ["missing", undefined], ["non-string", 123], ["wrong prefix", "invalid-key"], + ["too long", "sk-orca-" + "k".repeat(4089)], ["newline", "sk-orca-test\r\nkey"], + ])("rejects %s API key even when scope is omitted", async (_name, key) => { + await expect(exchangeThroughCallback({ key, user_id: 123 })) + .rejects.toThrow("did not return a valid API key"); + }); + + test.each([null, [], "invalid"].map(payload => [payload]))("rejects malformed exchange payload %j", async payload => { + await expect(exchangeThroughCallback(payload)).rejects.toThrow("returned an invalid response"); + }); + test("splits the public auth and inference origins while preserving one-origin self-hosting", async () => { expect(orcaRouterAuthBaseUrl()).toBe("https://www.orcarouter.ai"); expect(orcaRouterInferenceBaseUrl()).toBe("https://api.orcarouter.ai/v1"); @@ -225,6 +336,42 @@ describe("OrcaRouter dual authentication", () => { }); }); + test.each([true, false, undefined])( + "preserves explicit loopback private-network consent %j through login upsert", + allowPrivateNetwork => { + process.env.ORCAROUTER_BASE_URL = "http://127.0.0.1:9999"; + const config: OcxConfig = { + port: 10100, + defaultProvider: "orcarouter-oauth", + providers: { + "orcarouter-oauth": { + adapter: "openai-chat", + baseUrl: "http://127.0.0.1:9999/v1", + authMode: "oauth", + ...(allowPrivateNetwork === undefined ? {} : { allowPrivateNetwork }), + }, + }, + }; + upsertOAuthProvider(config, "orcarouter-oauth"); + const provider = config.providers["orcarouter-oauth"]!; + expect(provider).toMatchObject({ baseUrl: "http://127.0.0.1:9999/v1", authMode: "oauth", liveModels: true }); + expect(provider.allowPrivateNetwork).toBe(allowPrivateNetwork); + const error = providerDestinationConfigError("orcarouter-oauth", provider); + if (allowPrivateNetwork === true) expect(error).toBeNull(); + else expect(error).toContain("baseUrl must use https"); + }, + ); + + test("does not grant loopback consent when first login creates the provider row", () => { + process.env.ORCAROUTER_BASE_URL = "http://127.0.0.1:9999"; + const config: OcxConfig = { port: 10100, defaultProvider: "orcarouter-oauth", providers: {} }; + upsertOAuthProvider(config, "orcarouter-oauth"); + const provider = config.providers["orcarouter-oauth"]!; + expect(provider.baseUrl).toBe("http://127.0.0.1:9999/v1"); + expect(provider.allowPrivateNetwork).toBeUndefined(); + expect(providerDestinationConfigError("orcarouter-oauth", provider)).toContain("baseUrl must use https"); + }); + test("treats an upstream-rejected durable key as terminal instead of inventing a refresh grant", async () => { await expect(refreshOrcaRouterKey("bad-key")).rejects.toThrow("reconnect"); await expect(refreshOrcaRouterKey("sk-orca-existing-key")) diff --git a/tests/server/model-costs-management-api.test.ts b/tests/server/model-costs-management-api.test.ts index b1994bfb77..4e16bce057 100644 --- a/tests/server/model-costs-management-api.test.ts +++ b/tests/server/model-costs-management-api.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { clearModelCache } from "../../src/codex/model-cache"; import { resetCodexModelEntitlementCacheForTests } from "../../src/codex/model-entitlements"; -import { saveConfigPreservingClaudeCode } from "../../src/config"; +import { armClaudeCodeBaseline, saveConfigPreservingClaudeCode } from "../../src/config"; import { handleManagementAPI } from "../../src/server/management-api"; import { handleModelRoutes } from "../../src/server/management/model-routes"; import { listManagementModelRows } from "../../src/server/management/model-rows"; @@ -134,11 +134,11 @@ describe("provider model costs API", () => { expect(h.convergeCalls).toBe(0); }); - test("reset of the last entry removes the map and repeated reset remains successful", async () => { + test("reset of the last entry keeps an empty map and repeated reset remains successful", async () => { const h = harness(fixture({ "org/model": COST })); for (let attempt = 0; attempt < 2; attempt++) { expect((await h.call("PUT", { modelId: "org/model", cost: null })).status).toBe(200); - expect(Object.hasOwn(h.config.providers[PROVIDER]!, "modelCosts")).toBe(false); + expect(h.config.providers[PROVIDER]!.modelCosts).toEqual({}); expect(await (await h.call("GET")).json()).toEqual({ provider: PROVIDER, modelCosts: {} }); } }); @@ -157,6 +157,23 @@ describe("provider model costs API", () => { expect(activeUserCostOverlays().some(row => row.provider === PROVIDER && row.modelId === "org/model")).toBe(false); }); + test("resetting the last live price preserves a sibling added by another disk writer", async () => { + const config = fixture({ "org/model": COST }); + const path = join(home, "config.json"); + writeFileSync(path, JSON.stringify(config)); + armClaudeCodeBaseline(config); + const concurrent = fixture({ "org/model": COST, sibling: SIBLING }); + writeFileSync(path, JSON.stringify(concurrent)); + const h = harness(config, saveConfigPreservingClaudeCode); + + expect((await h.call("PUT", { modelId: "org/model", cost: null })).status).toBe(200); + const disk = JSON.parse(readFileSync(path, "utf8")) as OcxConfig; + expect(disk.providers[PROVIDER]!.modelCosts).toEqual({ sibling: SIBLING }); + expect(config.providers[PROVIDER]!.modelCosts).toEqual({ sibling: SIBLING }); + expect(activeUserCostOverlays().find(row => row.provider === PROVIDER && row.modelId === "sibling")?.cost4).toEqual(SIBLING); + expect(activeUserCostOverlays().some(row => row.provider === PROVIDER && row.modelId === "org/model")).toBe(false); + }); + test("price PUT follows a provider row replaced by a pin edit while parsing its body", async () => { const config = fixture({ "org/model": ZERO, sibling: SIBLING }); writeFileSync(join(home, "config.json"), JSON.stringify(config));