diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index bef498ce4..de39704d6 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -201,6 +201,19 @@ Add a display name from the CLI (the proxy syncs the catalog right away when liv ocx models add deepseek deepseek-v4 --display-name "DeepSeek V4" --context-window 128000 ``` +Remote Codex clients can fetch the same generated catalog over the management API (same +admission token as other `/api/*` routes): + +```bash +curl -fsS -H "x-opencodex-api-key: $OPENCODEX_ADMIN_AUTH_TOKEN" \ + "https://proxy.example.com/api/catalog" > "${CODEX_HOME:-$HOME/.codex}/opencodex-catalog.json" +ocx sync-cache +``` + +The response is the raw `opencodex-catalog.json` document (no provider credentials). When +available, the `x-opencodex-codex-version` header reports the Codex runtime version on the +server so clients can spot version skew. + You can also set or edit it through the management API (`POST /api/custom-models`, `PUT /api/custom-models/` with a `displayName` string) and the web dashboard. A `/` is rejected because it would collide with the routed-slug separator. diff --git a/gui/src/pages/Logs.tsx b/gui/src/pages/Logs.tsx index 0fbe8e961..83a5de3bd 100644 --- a/gui/src/pages/Logs.tsx +++ b/gui/src/pages/Logs.tsx @@ -280,12 +280,12 @@ function statusColor(status: number): string { return "var(--amber)"; } -function formatLogTimestamp(ts: number, localeTag?: string): string { - return new Date(ts).toLocaleTimeString(localeTag); +function formatLogTimestamp(ts: number, localeTag?: string, timeZone?: string): string { + return new Date(ts).toLocaleTimeString(localeTag, timeZone ? { timeZone } : undefined); } -function formatLogDateTime(ts: number, localeTag?: string): string { - return new Date(ts).toLocaleString(localeTag); +function formatLogDateTime(ts: number, localeTag?: string, timeZone?: string): string { + return new Date(ts).toLocaleString(localeTag, timeZone ? { timeZone } : undefined); } function modelTitle(log: LogEntry): string { @@ -333,6 +333,7 @@ export default function Logs({ apiBase }: { apiBase: string }) { const { t, locale } = useI18n(); const cachedLogs = readSessionListCache(logsCacheKey(apiBase)); const [logs, setLogs] = useState(() => cachedLogs ?? []); + const [serverTimeZone, setServerTimeZone] = useState(); const [loading, setLoading] = useState(() => !(cachedLogs && cachedLogs.length > 0)); const [error, setError] = useState(null); const [autoRefresh, setAutoRefresh] = useState(true); @@ -368,9 +369,13 @@ export default function Logs({ apiBase }: { apiBase: string }) { // failures flicker between the error banner, empty state, and stale table. if (!silent) setLoading(true); try { - const res = await fetch(`${apiBase}/api/logs`); + const res = await fetch(`${apiBase}/api/logs?limit=2000`); if (!res.ok) throw new Error(`${res.status} ${res.statusText}`.trim()); - const next = await res.json() as LogEntry[]; + const body = await res.json() as LogEntry[] | { logs?: LogEntry[]; timeZone?: string }; + const next = Array.isArray(body) ? body : (body.logs ?? []); + if (!Array.isArray(body) && typeof body.timeZone === "string" && body.timeZone.trim()) { + setServerTimeZone(body.timeZone.trim()); + } setLogs(next); writeSessionListCache(logsCacheKey(apiBase), next); setError(null); @@ -591,7 +596,7 @@ export default function Logs({ apiBase }: { apiBase: string }) { data-index={virtualRow.index} ref={rowVirtualizer.measureElement} > - {formatLogTimestamp(log.timestamp, localeTag)} + {formatLogTimestamp(log.timestamp, localeTag, serverTimeZone)} {(() => { const tokenTotal = displayContextTokenTotal(log); @@ -678,6 +683,7 @@ export default function Logs({ apiBase }: { apiBase: string }) { detailInfo={detailInfo} localeCode={locale} localeTag={localeTag} + serverTimeZone={serverTimeZone} t={t} onClose={() => setDetail(null)} onFilterConversation={id => { @@ -703,12 +709,13 @@ function useModalDialog(open: boolean) { } function LogDetailDialog({ - detail, detailInfo, localeCode, localeTag, t, onClose, onFilterConversation, + detail, detailInfo, localeCode, localeTag, serverTimeZone, t, onClose, onFilterConversation, }: { detail: LogEntry; detailInfo: ReturnType | null; localeCode: string; localeTag?: string; + serverTimeZone?: string; t: TFn; onClose: () => void; onFilterConversation?: (conversationId: string) => void; @@ -750,7 +757,7 @@ function LogDetailDialog({

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

- {t("logs.col.time")}{formatLogDateTime(detail.timestamp, localeTag)} + {t("logs.col.time")}{formatLogDateTime(detail.timestamp, localeTag, serverTimeZone)} {t("logs.col.request")} {detail.requestId ?? "\u2014"} diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index d54918f22..4ee98ac37 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -250,6 +250,36 @@ function usesNativeAnthropicEndpoint(provider: OcxProviderConfig): boolean { } } +/** Normalize provider baseUrl paths ending in `/`, `/v1`, or `/v1/messages` to `{origin}/v1/messages`. */ +export function anthropicMessagesUrl(baseUrl: string): string { + try { + new URL(baseUrl); + } catch { + throw new Error(`anthropic provider has malformed baseUrl: ${baseUrl}`); + } + const trimmed = baseUrl.trim().replace(/\/+$/, ""); + const root = trimmed.replace(/\/v1\/messages\/?$/i, "").replace(/\/v1\/?$/i, "").replace(/\/+$/, ""); + return `${root}/v1/messages`; +} + +function synthesizeToolUseId(): string { + return `toolu_${crypto.randomUUID().replace(/-/g, "").slice(0, 24)}`; +} + +function toolUseArguments(input: unknown): string { + if (typeof input === "string") { + const trimmed = input.trim(); + if (!trimmed) return "{}"; + try { + JSON.parse(trimmed); + return trimmed; + } catch { + return JSON.stringify(trimmed); + } + } + return JSON.stringify(input ?? {}); +} + function anthropicKeyUsesBearer(provider: OcxProviderConfig): boolean { return provider.apiKeyTransport === "bearer"; } @@ -680,8 +710,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti else if (typeof tc === "object" && "name" in tc) body.tool_choice = { type: "tool", name: toolNames.toWire(resolveToolChoiceWireName(parsed.context.tools, tc.name)) }; } - const base = provider.baseUrl.replace(/\/v1\/?$/, ""); - const url = `${base}/v1/messages`; + const url = anthropicMessagesUrl(provider.baseUrl); const unresolvedPlaceholder = url.match(/\{[^}]*\}/)?.[0]; if (unresolvedPlaceholder) { throw new Error(`anthropic baseUrl contains unresolved ${unresolvedPlaceholder}`); @@ -735,6 +764,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti let pendingUsage: Record | undefined; let pendingStopReason: string | undefined; let emittedDone = false; + let sawContent = false; const emitDone = function* (): Generator { if (emittedDone) return; @@ -773,8 +803,9 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti if (!block) break; currentBlockType = block.type; if (block.type === "tool_use") { - currentToolCallId = block.id ?? ""; + currentToolCallId = block.id ?? synthesizeToolUseId(); currentToolCallName = toolNames.fromWire(block.name ?? ""); + sawContent = true; yield { type: "tool_call_start", id: currentToolCallId, name: currentToolCallName }; } if (block.type === "redacted_thinking" && typeof block.data === "string") { @@ -787,19 +818,24 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti const delta = data.delta as Record | undefined; if (!delta) break; if (delta.type === "text_delta" && typeof delta.text === "string") { + sawContent = true; yield { type: "text_delta", text: delta.text }; } else if (delta.type === "thinking_delta" && typeof delta.thinking === "string") { + sawContent = true; yield { type: "thinking_delta", thinking: delta.thinking }; } else if (delta.type === "reasoning_delta" && typeof delta.reasoning === "string") { // Some Anthropic-compatible reasoning models use `reasoning` names for the // otherwise equivalent thinking block. Preserve it as raw reasoning and keep // later text blocks independent. + sawContent = true; yield { type: "thinking_delta", thinking: delta.reasoning }; } else if (delta.type === "signature_delta" && typeof delta.signature === "string" && (currentBlockType === "thinking" || currentBlockType === "reasoning")) { // Arrives once, just before the thinking block's content_block_stop; block-scoped // so a stray signature on a non-thinking block can never be captured. + sawContent = true; yield { type: "thinking_signature", signature: delta.signature }; - } else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string") { + } else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string" && currentBlockType === "tool_use") { + sawContent = true; yield { type: "tool_call_delta", arguments: delta.partial_json }; } break; @@ -831,12 +867,12 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti } } if (!emittedDone) { - if (pendingStopReason !== undefined) { + if (pendingStopReason !== undefined || sawContent) { const stopReason = pendingStopReason === "max_tokens" ? "max_tokens" : pendingStopReason === "refusal" || pendingStopReason === "content_filter" ? "content_filter" - : undefined; + : pendingStopReason; emittedDone = true; yield { type: "done", @@ -867,8 +903,9 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti } else if (block.type === "redacted_thinking" && typeof block.data === "string") { events.push({ type: "redacted_thinking", data: block.data }); } else if (block.type === "tool_use") { - events.push({ type: "tool_call_start", id: block.id ?? "", name: toolNames.fromWire(block.name ?? "") }); - events.push({ type: "tool_call_delta", arguments: JSON.stringify(block.input ?? {}) }); + const id = block.id ?? synthesizeToolUseId(); + events.push({ type: "tool_call_start", id, name: toolNames.fromWire(block.name ?? "") }); + events.push({ type: "tool_call_delta", arguments: toolUseArguments(block.input) }); events.push({ type: "tool_call_end" }); } } diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 0e463c65f..813ea5dbb 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -660,6 +660,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd const pendingToolCalls: PendingToolCall[] = []; let toolCallSeq = 0; const flushToolCalls = function* (): Generator { + if (pendingToolCalls.length > 0) sawOutput = true; for (const call of pendingToolCalls) { if (!call.id) call.id = `call_${++toolCallSeq}`; yield { type: "tool_call_start", id: call.id, name: call.name }; @@ -674,6 +675,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd // explicit `[DONE]` sentinel OR a chunk carrying a non-null `finish_reason` (some // OpenAI-compatible providers omit `[DONE]` but do send finish_reason). let finishReason: string | undefined; + let sawOutput = false; // Single per-line handler shared by the streaming loop and the EOF residual-frame flush, so // a final frame is parsed identically wherever it lands (no duplicated, drift-prone parsing). @@ -740,9 +742,11 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd const delta = choices[0].delta; if (delta) { if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) { + sawOutput = true; yield { type: "reasoning_raw_delta", text: delta.reasoning_content }; } if (typeof delta.content === "string" && delta.content.length > 0) { + sawOutput = true; yield { type: "text_delta", text: delta.content }; } @@ -806,7 +810,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd // at end-of-generation). If NONE of those were seen, the stream was cut mid-flight — fail // closed so the bridge emits a classified response.failed rather than a silent truncation. const sawFinish = finishReason !== undefined; - if (!sawFinish && pendingUsage === undefined) { + if (!sawFinish && pendingUsage === undefined && !sawOutput) { debugProviderDiagnostic("openai-chat", "stream-truncated", { finishReason: finishReason ?? null, hadUsage: false, diff --git a/src/cli/claude-desktop.ts b/src/cli/claude-desktop.ts index 4ab0148d2..6e426ffa8 100644 --- a/src/cli/claude-desktop.ts +++ b/src/cli/claude-desktop.ts @@ -10,7 +10,7 @@ import { type DesktopProfile, } from "../claude/desktop-profile"; import { writeDesktop3pConfig, type Desktop3pConfigMode, parseDesktop3pModeArgs } from "../claude/desktop-3p"; -import { filterCatalogVisibleModels, visibleNativeSlugs } from "../codex/catalog"; +import { filterCatalogVisibleModels, desktopVisibleNativeSlugs } from "../codex/catalog"; import { buildClaudeDesktopState, fetchAllModels } from "../server/management-api"; import { findLiveProxy } from "../server/proxy-liveness"; @@ -42,7 +42,7 @@ async function applyProfile(profile: DesktopProfile, mode: Desktop3pConfigMode): })); const result = writeDesktop3pConfig( live?.port ?? config.port ?? 10100, - [...visibleNativeSlugs(config)], + [...desktopVisibleNativeSlugs(config)], routed, config.apiKeys?.[0]?.key, mode, diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 5291fa10c..bad3fc759 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -307,13 +307,47 @@ export type ConfiguredProxyDiagnostic = { detail: string; }; -function envReferenceName(value: string): string | null { +export function envReferenceName(value: string): string | null { const braced = value.match(/^\$\{(\w+)\}$/); if (braced) return braced[1]!; const bare = value.match(/^\$(\w+)$/); return bare ? bare[1]! : null; } +export type ProviderApiKeyDiagnostic = { + provider: string; + envName: string; + detail: string; +}; + +/** Warn when a key-auth provider's apiKey env reference resolves empty in this process. */ +export function collectProviderApiKeyDiagnostics( + providers: Record = readConfigDiagnostics().config.providers ?? {}, + env: EnvMap = process.env, +): ProviderApiKeyDiagnostic[] { + const resolveInEnv = (value: string): string | undefined => { + const name = envReferenceName(value); + if (!name) return value; + return env[name]; + }; + const rows: ProviderApiKeyDiagnostic[] = []; + for (const [provider, config] of Object.entries(providers)) { + if (config.authMode !== "key") continue; + const raw = typeof config.apiKey === "string" ? config.apiKey.trim() : ""; + if (!raw) continue; + const envName = envReferenceName(raw); + if (!envName) continue; + const resolved = resolveInEnv(raw); + if (resolved?.trim()) continue; + rows.push({ + provider, + envName, + detail: `provider ${provider}: env reference ${envName} is unset or empty in this process`, + }); + } + return rows; +} + export function collectConfiguredProxy(): ConfiguredProxyDiagnostic { const diagnostics = readConfigDiagnostics(); const rawProxy = typeof diagnostics.config.proxy === "string" ? diagnostics.config.proxy.trim() : ""; @@ -742,6 +776,16 @@ export async function runDoctor(args: string[] = []): Promise { console.log("\nConfigured proxy (value hidden)"); console.log(` ${configuredProxy.present ? "set " : "unset "} ${configuredProxy.key} (${configuredProxy.source}; ${configuredProxy.detail})`); + const providerApiKeys = collectProviderApiKeyDiagnostics(doctorConfig.providers); + console.log("\nProvider API keys (value hidden)"); + if (providerApiKeys.length === 0) { + console.log(" ok no empty env-referenced provider keys detected in this process"); + } else { + for (const row of providerApiKeys) { + console.log(` !! ${row.detail}`); + } + } + console.log("\nRunning proxy process proxy env (presence only)"); if (runningProxyEnv.status === "not_running") { console.log(" -- no running ocx proxy process found"); @@ -825,6 +869,9 @@ export async function runDoctor(args: string[] = []): Promise { serviceViable: startup.serviceViable, }); if (proxyDown) hints.push(proxyDown); + for (const row of providerApiKeys) { + hints.push(`${row.detail}. Set ${row.envName} in the shell that starts the proxy, or store a literal key in config (value hidden here).`); + } const anyDrvfs = paths.some(p => detectFsType(p.path, mounts).isDrvfs || detectFsType(p.path, mounts).isMntDrive); const noProxy = currentProxyEnv.every(p => !p.present) && !configuredProxy.present; if (!startup.rebootSafe) { diff --git a/src/cli/init.ts b/src/cli/init.ts index 0908e78d4..7c6ab2356 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -8,11 +8,28 @@ import type { OcxConfig, OcxProviderConfig } from "../types"; function createPrompt(): { ask(question: string): Promise; close(): void } { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + let closed = false; + rl.on("close", () => { closed = true; }); return { ask(question: string): Promise { - return new Promise(resolve => rl.question(question, resolve)); + return new Promise((resolve, reject) => { + if (closed) { + reject(new Error("stdin closed before the prompt could be answered")); + return; + } + const onClose = () => { + reject(new Error("stdin reached EOF while waiting for input")); + }; + rl.once("close", onClose); + rl.question(question, answer => { + rl.off("close", onClose); + resolve(answer); + }); + }); + }, + close() { + if (!closed) rl.close(); }, - close() { rl.close(); }, }; } @@ -83,115 +100,125 @@ export function cleanupOpenAiTierBackupAfterInit(configPath = getConfigPath()): export async function runInit(): Promise { const prompt = createPrompt(); - console.log("\nšŸ”§ opencodex (ocx) setup\n"); - - const providers = buildInitProviders(); - printMenu(providers); - - const choice = await prompt.ask("\nSelect default provider (number): "); - const idx = parseInt(choice, 10) - 1; - - let providerName: string; - let providerConfig: OcxProviderConfig; - let oauthHint = false; - - if (idx >= 0 && idx < providers.length) { - const p = providers[idx]; - providerName = p.id; - console.log(`\nšŸ“” ${p.label}`); - console.log(` Base URL: ${p.baseUrl}`); - - if (p.kind === "forward") { - providerConfig = { adapter: p.adapter, baseUrl: p.baseUrl, authMode: "forward" }; - console.log(" No API key needed — forwards your existing `codex login`."); - } else if (p.kind === "oauth") { - providerConfig = { adapter: p.adapter, baseUrl: p.baseUrl, authMode: "oauth", ...(p.defaultModel ? { defaultModel: p.defaultModel } : {}) }; - oauthHint = true; - } else { - // key + local: collect a key (local usually blank). - if (p.dashboardUrl) console.log(` šŸ”‘ Get your key: ${p.dashboardUrl}`); - // Template URL with placeholders (e.g. Cloudflare's {account_id}) needs a resolved value. - let baseUrl = p.baseUrl; - if (/\{[^}]*\}/.test(baseUrl)) { - const resolved = (await prompt.ask(` Your endpoint URL (${baseUrl}): `)).trim(); - if (!resolved) { - console.error(" A resolved URL is required — replace the {placeholder} with your actual value."); - process.exit(1); + try { + console.log("\nšŸ”§ opencodex (ocx) setup\n"); + + const providers = buildInitProviders(); + printMenu(providers); + + const choice = await prompt.ask("\nSelect default provider (number): "); + const idx = parseInt(choice, 10) - 1; + + let providerName: string; + let providerConfig: OcxProviderConfig; + let oauthHint = false; + + if (idx >= 0 && idx < providers.length) { + const p = providers[idx]; + providerName = p.id; + console.log(`\nšŸ“” ${p.label}`); + console.log(` Base URL: ${p.baseUrl}`); + + if (p.kind === "forward") { + providerConfig = { adapter: p.adapter, baseUrl: p.baseUrl, authMode: "forward" }; + console.log(" No API key needed — forwards your existing `codex login`."); + } else if (p.kind === "oauth") { + providerConfig = { adapter: p.adapter, baseUrl: p.baseUrl, authMode: "oauth", ...(p.defaultModel ? { defaultModel: p.defaultModel } : {}) }; + oauthHint = true; + } else { + // key + local: collect a key (local usually blank). + if (p.dashboardUrl) console.log(` šŸ”‘ Get your key: ${p.dashboardUrl}`); + // Template URL with placeholders (e.g. Cloudflare's {account_id}) needs a resolved value. + let baseUrl = p.baseUrl; + if (/\{[^}]*\}/.test(baseUrl)) { + const resolved = (await prompt.ask(` Your endpoint URL (${baseUrl}): `)).trim(); + if (!resolved) { + console.error(" A resolved URL is required — replace the {placeholder} with your actual value."); + process.exit(1); + } + baseUrl = resolved; } - baseUrl = resolved; + const env = envKeyFor(p.id); + const hint = p.kind === "local" ? "API key (usually blank — press Enter): " : `API key (paste, or env var $${env}): `; + const apiKey = (await prompt.ask(`\n${hint}`)).trim(); + const modelChoice = (await prompt.ask(`Default model${p.defaultModel ? ` [${p.defaultModel}]` : " (optional)"}: `)).trim(); + const defaultModel = modelChoice || p.defaultModel; + providerConfig = { + adapter: p.adapter, + baseUrl, + ...(p.kind === "key" ? { apiKey: apiKey || `\${${env}}` } : apiKey ? { apiKey } : {}), + ...(defaultModel ? { defaultModel } : {}), + }; + // Apply the catalog's models / vision classification (same enrichment as the GUI). + enrichProviderFromCatalog(p.id, providerConfig); + } + } else { + providerName = (await prompt.ask("Provider name: ")).trim(); + if (!isValidProviderName(providerName)) { + console.error("Provider name must use letters, numbers, dot, underscore, or hyphen and cannot be a reserved object key."); + process.exit(1); } - const env = envKeyFor(p.id); - const hint = p.kind === "local" ? "API key (usually blank — press Enter): " : `API key (paste, or env var $${env}): `; - const apiKey = (await prompt.ask(`\n${hint}`)).trim(); - const modelChoice = (await prompt.ask(`Default model${p.defaultModel ? ` [${p.defaultModel}]` : " (optional)"}: `)).trim(); - const defaultModel = modelChoice || p.defaultModel; + const baseUrl = await prompt.ask("Base URL (e.g. http://localhost:11434/v1): "); + const adapter = await prompt.ask("Adapter [openai-chat]: ") || "openai-chat"; + const apiKey = await prompt.ask("API key (optional): "); + const defaultModel = await prompt.ask("Default model: "); providerConfig = { - adapter: p.adapter, - baseUrl, - ...(p.kind === "key" ? { apiKey: apiKey || `\${${env}}` } : apiKey ? { apiKey } : {}), - ...(defaultModel ? { defaultModel } : {}), + adapter: adapter.trim(), + baseUrl: baseUrl.trim(), + ...(apiKey.trim() ? { apiKey: apiKey.trim() } : {}), + ...(defaultModel.trim() ? { defaultModel: defaultModel.trim() } : {}), }; - // Apply the catalog's models / vision classification (same enrichment as the GUI). - enrichProviderFromCatalog(p.id, providerConfig); - } - } else { - providerName = (await prompt.ask("Provider name: ")).trim(); - if (!isValidProviderName(providerName)) { - console.error("Provider name must use letters, numbers, dot, underscore, or hyphen and cannot be a reserved object key."); - prompt.close(); - process.exit(1); } - const baseUrl = await prompt.ask("Base URL (e.g. http://localhost:11434/v1): "); - const adapter = await prompt.ask("Adapter [openai-chat]: ") || "openai-chat"; - const apiKey = await prompt.ask("API key (optional): "); - const defaultModel = await prompt.ask("Default model: "); - providerConfig = { - adapter: adapter.trim(), - baseUrl: baseUrl.trim(), - ...(apiKey.trim() ? { apiKey: apiKey.trim() } : {}), - ...(defaultModel.trim() ? { defaultModel: defaultModel.trim() } : {}), - }; - } - const portStr = await prompt.ask("\nProxy port [10100]: "); - const port = parseInt(portStr, 10) || 10100; + const portStr = await prompt.ask("\nProxy port [10100]: "); + const port = parseInt(portStr, 10) || 10100; - const config: OcxConfig = { - ...getDefaultConfig(), - port, - providers: { [providerName]: providerConfig }, - defaultProvider: providerName, - }; + const config: OcxConfig = { + ...getDefaultConfig(), + port, + providers: { [providerName]: providerConfig }, + defaultProvider: providerName, + }; - saveConfig(config); - // Init writes a fresh config, so a stale pre-migration backup from a previous - // installation would make the next `ocx start` crash on a stale-backup - // collision (issue #257). But only a STALE backup (unparseable, or already a - // post-migration v2 snapshot) may be deleted; a backup that still parses as a - // valid pre-migration (v1) config is a user-intentional rollback point and is - // preserved by renaming it out of the collision path (sol review 260722). - cleanupOpenAiTierBackupAfterInit(); - console.log(`\nāœ… Config saved to ~/.opencodex/config.json`); - if (oauthHint) console.log(`šŸ” Authenticate this provider with: ocx login ${providerName}`); - - const injectAnswer = await prompt.ask("Inject into Codex config.toml? [Y/n]: "); - if (injectAnswer.trim().toLowerCase() !== "n") { - console.log("Fetching available models from provider..."); - const result = await injectCodexConfig(port, config); - console.log(result.success ? `āœ… ${result.message}` : `āš ļø ${result.message}`); - } + saveConfig(config); + // Init writes a fresh config, so a stale pre-migration backup from a previous + // installation would make the next `ocx start` crash on a stale-backup + // collision (issue #257). But only a STALE backup (unparseable, or already a + // post-migration v2 snapshot) may be deleted; a backup that still parses as a + // valid pre-migration (v1) config is a user-intentional rollback point and is + // preserved by renaming it out of the collision path (sol review 260722). + cleanupOpenAiTierBackupAfterInit(); + console.log(`\nāœ… Config saved to ~/.opencodex/config.json`); + if (oauthHint) console.log(`šŸ” Authenticate this provider with: ocx login ${providerName}`); + + const injectAnswer = await prompt.ask("Inject into Codex config.toml? [Y/n]: "); + if (injectAnswer.trim().toLowerCase() !== "n") { + console.log("Fetching available models from provider..."); + const result = await injectCodexConfig(port, config); + console.log(result.success ? `āœ… ${result.message}` : `āš ļø ${result.message}`); + } - const shimAnswer = await prompt.ask("Install Codex autostart shim? [Y/n]: "); - if (shimAnswer.trim().toLowerCase() !== "n") { - try { - const { installCodexShim } = await import("../codex/shim"); - const result = installCodexShim(); - console.log(result.installed ? `āœ… ${result.message}` : `āš ļø ${result.message}`); - } catch (err) { - console.log(`āš ļø Codex autostart shim skipped: ${err instanceof Error ? err.message : String(err)}`); + const shimAnswer = await prompt.ask("Install Codex autostart shim? [Y/n]: "); + if (shimAnswer.trim().toLowerCase() !== "n") { + try { + const { installCodexShim } = await import("../codex/shim"); + const result = installCodexShim(); + console.log(result.installed ? `āœ… ${result.message}` : `āš ļø ${result.message}`); + } catch (err) { + console.log(`āš ļø Codex autostart shim skipped: ${err instanceof Error ? err.message : String(err)}`); + } } - } - console.log(`\nšŸš€ Setup complete! Run 'ocx start' to start the proxy.`); - prompt.close(); + console.log(`\nšŸš€ Setup complete! Run 'ocx start' to start the proxy.`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (/stdin (closed|reached EOF)/i.test(message)) { + console.error(`\nāŒ ${message}. Re-run \`ocx init\` in an interactive terminal, or supply a complete answer pipe.`); + process.exitCode = 1; + return; + } + throw error; + } finally { + prompt.close(); + } } diff --git a/src/codex/catalog.ts b/src/codex/catalog.ts index 03173400b..0bd364934 100644 --- a/src/codex/catalog.ts +++ b/src/codex/catalog.ts @@ -1,8 +1,8 @@ // AUTO-SPLIT facade: original catalog.ts body moved into ./catalog/* modules. // Public surface preserved exactly; importers keep using "src/codex/catalog". -export { isMediaGenerationModelId, shouldExposeRoutedModel, readCodexCatalogPath, normalizeRoutedCatalogEntry, catalogModelSlug, filterSupportedNativeSlugs, catalogModelSupportsReasoningSummaries } from "./catalog/parsing"; +export { isMediaGenerationModelId, shouldExposeRoutedModel, readCodexCatalogPath, readCatalog, normalizeRoutedCatalogEntry, catalogModelSlug, filterSupportedNativeSlugs, catalogModelSupportsReasoningSummaries, sanitizeCodexInputModalities, repairCatalogInputModalities, CODEX_CATALOG_INPUT_MODALITIES, ensureStrictCatalogFields } from "./catalog/parsing"; export type { CatalogModel, MultiAgentMode } from "./catalog/parsing"; -export { NATIVE_OPENAI_MODELS, nativeOpenAiContextWindow, disabledNativeSlugs, visibleNativeSlugs, nativeModelRows, applyNativeVisibility, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs } from "./catalog/metadata"; +export { NATIVE_OPENAI_MODELS, nativeOpenAiContextWindow, disabledNativeSlugs, visibleNativeSlugs, desktopVisibleNativeSlugs, nativeModelRows, applyNativeVisibility, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs } from "./catalog/metadata"; export { isSpawnableCodexCandidate, codexExecInvocation, loadBundledCodexCatalog, materializeBundledCodexCatalog, loadCatalogTemplate } from "./catalog/bundled"; export { nativeEffortClamp, shouldApplyNativeEffortClamp, catalogModelEfforts, codexSupportedReasoningEfforts, clampedDefaultEffort, clampEntryToCodexSupportedEfforts, clampCatalogModelsToCodexSupport } from "./catalog/effort"; export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, clearGatherRoutedModelsInflight, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithJawcodeMetadata } from "./catalog/provider-fetch"; diff --git a/src/codex/catalog/bundled.ts b/src/codex/catalog/bundled.ts index 25ae3cdc4..213bc1f35 100644 --- a/src/codex/catalog/bundled.ts +++ b/src/codex/catalog/bundled.ts @@ -31,7 +31,7 @@ import { redactSecretString } from "../../lib/redact"; import upstreamModelsSnapshot from "../data/upstream-models.json"; -import { activeCodexModelsCachePath, catalogBackupPathFor, findNativeTemplate, isDefaultCatalogPath, legacyCatalogBackupPath, parseCatalogJson, readCatalog, readCatalogBackup, readCodexCatalogPath } from "./parsing"; +import { activeCodexModelsCachePath, catalogBackupPathFor, findNativeTemplate, isDefaultCatalogPath, legacyCatalogBackupPath, parseCatalogJson, readCatalog, readCatalogBackup, readCodexCatalogPath, repairCatalogInputModalities } from "./parsing"; import type { RawCatalog, RawEntry } from "./parsing"; import { codexExecInvocation, isSpawnableCodexCandidate } from "../exec-invocation"; import { resolveAndPersistCodexRuntime } from "../runtime"; @@ -219,6 +219,7 @@ export function loadCatalogForSync(path: string): RawCatalog | null { const bundled = isDefaultCatalogPath(path) ? loadBundledCodexCatalog() : null; if (bundled) return JSON.parse(JSON.stringify(bundled)) as RawCatalog; const catalog = readCatalog(path); + if (catalog) repairCatalogInputModalities(catalog); if (catalog && findNativeTemplate(catalog)) return catalog; return readCatalog(catalogBackupPathFor(path)) ?? (isDefaultCatalogPath(path) ? readCatalog(legacyCatalogBackupPath()) : null) diff --git a/src/codex/catalog/metadata.ts b/src/codex/catalog/metadata.ts index 8587c286a..4f4786e17 100644 --- a/src/codex/catalog/metadata.ts +++ b/src/codex/catalog/metadata.ts @@ -123,6 +123,12 @@ export function visibleNativeSlugs(config: Pick): s return nativeOpenAiSlugs().filter(slug => !disabled.has(slug)); } +/** Native slugs exposed to Claude Desktop show/export/apply (opt-out via claudeCode.desktopNativeModels). */ +export function desktopVisibleNativeSlugs(config: Pick): string[] { + if (config.claudeCode?.desktopNativeModels === false) return []; + return visibleNativeSlugs(config); +} + export function nativeModelRows(config: Pick): Array<{ slug: string; disabled: boolean; contextWindow?: number }> { const disabled = disabledNativeSlugs(config); return NATIVE_OPENAI_MODELS.map(slug => { diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index 432ace821..0c9253c78 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -228,6 +228,42 @@ export function normalizeServiceTiers(entry: RawEntry): RawEntry { return entry; } +/** Codex catalog parser accepts only these input modality enum values. */ +export const CODEX_CATALOG_INPUT_MODALITIES = ["text", "image", "audio"] as const; + +export type CodexCatalogInputModality = typeof CODEX_CATALOG_INPUT_MODALITIES[number]; + +const CODEX_CATALOG_INPUT_MODALITY_SET = new Set(CODEX_CATALOG_INPUT_MODALITIES); + +/** Strip out-of-enum modalities (e.g. provider-advertised `video`) before Codex reads the catalog. */ +export function sanitizeCodexInputModalities(modalities: unknown): CodexCatalogInputModality[] { + if (!Array.isArray(modalities)) return ["text"]; + const out: CodexCatalogInputModality[] = []; + for (const value of modalities) { + if (typeof value !== "string") continue; + const normalized = value.trim().toLowerCase(); + if (!CODEX_CATALOG_INPUT_MODALITY_SET.has(normalized)) continue; + const modality = normalized as CodexCatalogInputModality; + if (!out.includes(modality)) out.push(modality); + } + return out.length > 0 ? out : ["text"]; +} + +/** Repair poisoned on-disk catalog rows so sync can rewrite a Codex-parseable file. */ +export function repairCatalogInputModalities(catalog: RawCatalog): boolean { + let changed = false; + for (const entry of catalog.models ?? []) { + if (!Array.isArray(entry.input_modalities)) continue; + const sanitized = sanitizeCodexInputModalities(entry.input_modalities); + const previous = entry.input_modalities as unknown[]; + if (JSON.stringify(sanitized) !== JSON.stringify(previous)) { + entry.input_modalities = sanitized; + changed = true; + } + } + return changed; +} + export function ensureAutoCompactTokenLimit(entry: RawEntry): RawEntry { if ( typeof entry.context_window === "number" @@ -271,7 +307,9 @@ export function ensureStrictCatalogFields( if (typeof entry.supports_parallel_tool_calls !== "boolean") entry.supports_parallel_tool_calls = true; if (typeof entry.supports_image_detail_original !== "boolean") entry.supports_image_detail_original = false; if (!Array.isArray(entry.experimental_supported_tools)) entry.experimental_supported_tools = []; - if (!Array.isArray(entry.input_modalities) && !options.preserveExactInputModalities) { + if (Array.isArray(entry.input_modalities)) { + entry.input_modalities = sanitizeCodexInputModalities(entry.input_modalities); + } else if (!options.preserveExactInputModalities) { entry.input_modalities = ["text"]; } const contextWindow = typeof entry.context_window === "number" && entry.context_window > 0 ? entry.context_window : 128000; diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 404441ae2..881a851f0 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -324,7 +324,7 @@ function modelInputModalities( ?? capabilityRecord?.input_modalities, 8, 24, - )?.filter(value => value === "text" || value === "image" || value === "audio" || value === "video"); + )?.filter(value => value === "text" || value === "image" || value === "audio"); if (explicit && explicit.length > 0) return explicit; if (capabilityRecord?.vision === false) return ["text"]; if (capabilityRecord?.vision === true || capabilities?.some(value => value === "vision" || value === "image-input")) { diff --git a/src/lib/destination-policy.ts b/src/lib/destination-policy.ts index 0b060fb90..db4ecf983 100644 --- a/src/lib/destination-policy.ts +++ b/src/lib/destination-policy.ts @@ -130,13 +130,20 @@ function registryAllowsPrivateNetwork(name: string): boolean { return getProviderRegistryEntry(name)?.allowPrivateNetworkByDefault === true; } +/** True when the provider config or registry default admits private/loopback destinations. */ +export function providerAllowsPrivateNetwork( + name: string, + provider: Pick, +): boolean { + return provider.allowPrivateNetwork === true || registryAllowsPrivateNetwork(name); +} + export function providerDestinationConfigError(name: string, provider: Pick): string | null { const assessment = assessDestination(provider.baseUrl); if (!assessment) return null; if (assessment.kind === "public" || assessment.kind === "hostname") return null; if (assessment.kind === "metadata") return "baseUrl targets a blocked metadata endpoint"; - if (registryAllowsPrivateNetwork(name)) return null; - if (provider.allowPrivateNetwork === true) return null; + if (providerAllowsPrivateNetwork(name, provider)) return null; return `baseUrl points to a ${assessment.detail}; set allowPrivateNetwork:true only for intentionally local/self-hosted providers`; } @@ -174,7 +181,7 @@ export async function providerDestinationResolvedError( if (!hostname || isIP(hostname) !== 0 || hostname === "localhost" || hostname.endsWith(".localhost")) { return null; // literals and localhost are fully handled by the sync path } - if (registryAllowsPrivateNetwork(name) || provider.allowPrivateNetwork === true) return null; + if (providerAllowsPrivateNetwork(name, provider)) return null; let addresses: { address: string }[]; try { addresses = await lookup(hostname, { all: true, verbatim: true }); diff --git a/src/lib/provider-outbound.ts b/src/lib/provider-outbound.ts index e4f7d0a5c..3123c0c9c 100644 --- a/src/lib/provider-outbound.ts +++ b/src/lib/provider-outbound.ts @@ -2,6 +2,7 @@ import type { OcxProviderConfig } from "../types"; import { assessUrlDestination, DestinationDnsResolutionError, + providerAllowsPrivateNetwork, providerDestinationConfigError, resolvePublicAddresses, } from "./destination-policy"; @@ -116,7 +117,8 @@ export async function providerOutboundGet( if (assessment?.kind === "metadata" || assessment?.kind === "link-local" || assessment?.kind === "unspecified") { throw new ProviderOutboundPolicyError(`provider URL targets ${assessment.detail}`); } - if (!provider.allowPrivateNetwork) { + const allowPrivate = providerAllowsPrivateNetwork(name, provider); + if (!allowPrivate) { const destinationError = providerDestinationConfigError(name, { baseUrl: url, allowPrivateNetwork: false, @@ -129,11 +131,12 @@ export async function providerOutboundGet( const proxyConfigured = configuredProxyFor(); const resolveAddresses = dependencies.resolveAddresses ?? resolvePublicAddresses; const pinnedGet = dependencies.pinnedGet ?? pinnedHttpGet; + const allowPrivate = providerAllowsPrivateNetwork(name, provider); let resolved: Awaited>; try { resolved = await resolveAddresses(url, { context: "provider URL", - allowPrivateNetwork: provider.allowPrivateNetwork, + allowPrivateNetwork: allowPrivate, }); } catch (error) { const dnsResolutionFailed = error instanceof DestinationDnsResolutionError diff --git a/src/lib/winsw.ts b/src/lib/winsw.ts index 5c00e47af..42a5f9553 100644 --- a/src/lib/winsw.ts +++ b/src/lib/winsw.ts @@ -165,6 +165,12 @@ function runWinsw(args: string[]): string { /** `install /p` prompts for the service-account password on the console — stdin must be inherited. */ function runWinswInteractive(args: string[]): void { + if (!process.stdin.isTTY) { + throw new Error( + "WinSW install requires an interactive console to prompt for the service account password. " + + "Run `ocx service install --native` from an elevated Command Prompt or PowerShell window, not a hidden or piped session.", + ); + } execFileSync(winswExePath(), args, { stdio: "inherit" }); } diff --git a/src/providers/base-url-choices.ts b/src/providers/base-url-choices.ts index 2c17a8e23..0cf4ffc24 100644 --- a/src/providers/base-url-choices.ts +++ b/src/providers/base-url-choices.ts @@ -39,6 +39,15 @@ export const ALIBABA_INTL_BASE_URL_CHOICES: readonly ProviderBaseUrlChoice[] = [ { id: "custom", label: "Custom" }, ]; +/** Alibaba Coding Plan endpoint presets (international default; China mainland selectable). */ +export const ALIBABA_CODING_INTL_BASE_URL = "https://coding-intl.dashscope.aliyuncs.com/v1"; +export const ALIBABA_CODING_CN_BASE_URL = "https://coding.dashscope.aliyuncs.com/v1"; + +export const ALIBABA_CODING_BASE_URL_CHOICES: readonly ProviderBaseUrlChoice[] = [ + { id: "intl", label: "International", baseUrl: ALIBABA_CODING_INTL_BASE_URL }, + { id: "china", label: "China", baseUrl: ALIBABA_CODING_CN_BASE_URL }, +]; + /** Match a saved baseUrl to a known choice id (`custom` when it does not match). */ export function matchBaseUrlChoice( choices: readonly ProviderBaseUrlChoice[], diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 663e8c8ec..df3ce4c3c 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -5,6 +5,7 @@ import type { ProviderBaseUrlChoice } from "./base-url-choices"; import { QWEN_CLOUD_BASE_URL_CHOICES, QWEN_CLOUD_TOKEN_PLAN_BASE_URL, ALIBABA_INTL_BASE_URL_CHOICES, ALIBABA_INTL_TOKEN_PLAN_BASE_URL, + ALIBABA_CODING_BASE_URL_CHOICES, ALIBABA_CODING_INTL_BASE_URL, } from "./base-url-choices"; import { CURSOR_STATIC_MODELS, @@ -969,7 +970,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // 2026-07-10: docs unverified; model data frozen. Evidence: devlog/_plan/260710_provider_hardening/002_research_cn.md. { id: "qianfan", label: "Qianfan (Baidu)", baseUrl: "https://qianfan.baidubce.com/v2", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://console.bce.baidu.com/iam/#/iam/apikey/list" }, // 2026-07-10: docs unverified; model data frozen. Evidence: devlog/_plan/260710_provider_hardening/002_research_cn.md. - { id: "alibaba", label: "Alibaba Coding Plan", baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://dashscope.console.aliyun.com/apiKey" }, + { id: "alibaba", label: "Alibaba Coding Plan", baseUrl: ALIBABA_CODING_INTL_BASE_URL, adapter: "openai-chat", authKind: "key", allowBaseUrlOverride: true, baseUrlChoices: ALIBABA_CODING_BASE_URL_CHOICES, dashboardUrl: "https://dashscope.console.aliyun.com/apiKey" }, { id: "alibaba-token-plan", label: "Alibaba Token Plan (Beijing)", diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 5b4d48c21..4f99ba3c8 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -70,16 +70,6 @@ export function isSameOriginAsRequest(req: Request, origin: string): boolean { } export function isAllowedRequestOrigin(req: Request, config: OcxConfig): boolean { - function isExtraAllowedOrigin(origin: string, cfg: OcxConfig): boolean { - if (!cfg.corsAllowOrigins?.length) return false; - return cfg.corsAllowOrigins.some(allowed => { - try { - return new URL(allowed).origin === new URL(origin).origin; - } catch { - return allowed === origin; - } - }); - } const origin = req.headers.get("Origin"); if (!isApiAuthRequired(config)) { if (!isLoopbackRequestHost(req.headers.get("Host"))) return false; @@ -88,6 +78,33 @@ export function isAllowedRequestOrigin(req: Request, config: OcxConfig): boolean return !origin || isLoopbackOriginValue(origin) || isSameOriginAsRequest(req, origin) || isExtraAllowedOrigin(origin, config); } +function isExtraAllowedOrigin(origin: string, cfg: OcxConfig): boolean { + if (!cfg.corsAllowOrigins?.length) return false; + return cfg.corsAllowOrigins.some(allowed => { + try { + return new URL(allowed).origin === new URL(origin).origin; + } catch { + return allowed === origin; + } + }); +} + +/** True when Origin and the process-derived origin share a host across http/https (TLS terminator). */ +function sameManagementHost(origin: string, requestOrigin: string): boolean { + try { + const left = new URL(origin); + const right = new URL(requestOrigin); + if (left.protocol !== "http:" && left.protocol !== "https:") return false; + if (right.protocol !== "http:" && right.protocol !== "https:") return false; + if (left.hostname.toLowerCase() !== right.hostname.toLowerCase()) return false; + // Same scheme with unequal origins means a port (or rare URL) mismatch — keep fail-closed. + // Cross-scheme only: browser https Origin vs process http Host behind a TLS terminator. + return left.protocol !== right.protocol; + } catch { + return false; + } +} + export function managementRequestOrigin(req: Request, config: OcxConfig): string | null { const host = req.headers.get("Host"); const parsedHost = parseHttpHost(host); @@ -106,7 +123,14 @@ export function isAllowedManagementOrigin(req: Request, config: OcxConfig): bool const requestOrigin = managementRequestOrigin(req, config); if (!requestOrigin) return false; const origin = req.headers.get("Origin"); - return !origin || origin === requestOrigin; + if (!origin) return true; + if (origin === requestOrigin) return true; + // Explicit operator allowlist (documented reverse-proxy deployments). + if (isExtraAllowedOrigin(origin, config)) return true; + // TLS-terminating reverse proxy: browser sends https://host while the process sees http://host. + // Hostname match is enough; credentials still required by requireManagementAuth. + if (sameManagementHost(origin, requestOrigin)) return true; + return false; } export function browserSecurityHeaders(): Record { diff --git a/src/server/management-auth.ts b/src/server/management-auth.ts index 52fd46401..fcb0e12ad 100644 --- a/src/server/management-auth.ts +++ b/src/server/management-auth.ts @@ -43,6 +43,8 @@ export type ManagementAuthState = token: string; source: "environment" | "file"; sessions: Map; + /** Set when file-backed token was accepted despite unverified Windows ACL harden. */ + aclUnverified?: boolean; } | { available: false; reason: string }; @@ -50,38 +52,58 @@ function fail(reason: string): ManagementAuthState { return { available: false, reason }; } +function allowUnverifiedAdminTokenAcl(): boolean { + const raw = process.env.OPENCODEX_ALLOW_UNVERIFIED_ADMIN_TOKEN_ACL?.trim().toLowerCase(); + return raw === "1" || raw === "true" || raw === "yes"; +} + function assertSafeDirectory(path: string): void { mkdirSync(path, { recursive: true, mode: 0o700 }); const stat = lstatSync(path); if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("management token directory is not a regular directory"); chmodSync(path, 0o700); const hardened = hardenSecretDir(path, { required: true }); - if (!hardened.ok) throw new Error("management token directory ACL hardening did not complete"); + if (!hardened.ok) { + if (allowUnverifiedAdminTokenAcl() && /timed out|ETIMEDOUT|budget exhausted|previous attempt timed out/i.test(hardened.diagnostics ?? "")) { + console.warn(`[opencodex] management token directory ACL unverified (${hardened.diagnostics}); continuing because OPENCODEX_ALLOW_UNVERIFIED_ADMIN_TOKEN_ACL is set`); + return; + } + throw new Error("management token directory ACL hardening did not complete"); + } } -function readExistingToken(path: string): string { +function readExistingToken(path: string): { token: string; aclUnverified: boolean } { const stat = lstatSync(path); if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 512) { throw new Error("management token path is not a regular secret file"); } chmodSync(path, 0o600); const hardened = hardenSecretPath(path, { required: true }); - if (!hardened.ok) throw new Error("management token file ACL hardening did not complete"); + let aclUnverified = false; + if (!hardened.ok) { + if (allowUnverifiedAdminTokenAcl() && /timed out|ETIMEDOUT|budget exhausted|previous attempt timed out/i.test(hardened.diagnostics ?? "")) { + console.warn(`[opencodex] management token file ACL unverified (${hardened.diagnostics}); continuing because OPENCODEX_ALLOW_UNVERIFIED_ADMIN_TOKEN_ACL is set`); + aclUnverified = true; + } else { + throw new Error("management token file ACL hardening did not complete"); + } + } const token = readFileSync(path, "utf8").trim(); if (!/^ocx_admin_[A-Za-z0-9_-]{43}$/.test(token)) throw new Error("management token file is invalid"); - return token; + return { token, aclUnverified }; } function removeBestEffort(path: string): void { try { unlinkSync(path); } catch { /* fail-closed state is preserved by the caller */ } } -function createTokenFile(path: string): string { +function createTokenFile(path: string): { token: string; aclUnverified: boolean } { const directory = dirname(path); const token = `ocx_admin_${randomBytes(32).toString("base64url")}`; const temporary = join(directory, `.${randomUUID()}.admin-token.tmp`); let linked = false; let fd: number | null = null; + let aclUnverified = false; try { fd = openSync(temporary, "wx", 0o600); writeFileSync(fd, `${token}\n`, "utf8"); @@ -90,7 +112,14 @@ function createTokenFile(path: string): string { fd = null; chmodSync(temporary, 0o600); const temporaryHardened = hardenSecretPath(temporary, { required: true }); - if (!temporaryHardened.ok) throw new Error("management token temporary ACL hardening did not complete"); + if (!temporaryHardened.ok) { + if (allowUnverifiedAdminTokenAcl() && /timed out|ETIMEDOUT|budget exhausted|previous attempt timed out/i.test(temporaryHardened.diagnostics ?? "")) { + console.warn(`[opencodex] management token temporary ACL unverified (${temporaryHardened.diagnostics}); continuing because OPENCODEX_ALLOW_UNVERIFIED_ADMIN_TOKEN_ACL is set`); + aclUnverified = true; + } else { + throw new Error("management token temporary ACL hardening did not complete"); + } + } try { linkSync(temporary, path); linked = true; @@ -99,8 +128,15 @@ function createTokenFile(path: string): string { throw error; } const finalHardened = hardenSecretPath(path, { required: true }); - if (!finalHardened.ok) throw new Error("management token file ACL hardening did not complete"); - return token; + if (!finalHardened.ok) { + if (allowUnverifiedAdminTokenAcl() && /timed out|ETIMEDOUT|budget exhausted|previous attempt timed out/i.test(finalHardened.diagnostics ?? "")) { + console.warn(`[opencodex] management token file ACL unverified (${finalHardened.diagnostics}); continuing because OPENCODEX_ALLOW_UNVERIFIED_ADMIN_TOKEN_ACL is set`); + aclUnverified = true; + } else { + throw new Error("management token file ACL hardening did not complete"); + } + } + return { token, aclUnverified }; } catch (error) { if (linked) removeBestEffort(path); throw error; @@ -112,30 +148,51 @@ function createTokenFile(path: string): string { } } -function ready(token: string, source: "environment" | "file", config: OcxConfig): ManagementAuthState { +function ready( + token: string, + source: "environment" | "file", + config: OcxConfig, + options: { aclUnverified?: boolean } = {}, +): ManagementAuthState { if (isDataPlaneAdmissionSecret(token, config)) { return fail("management credential conflicts with a data-plane credential"); } - return { available: true, token, source, sessions: new Map() }; + return { + available: true, + token, + source, + sessions: new Map(), + ...(options.aclUnverified ? { aclUnverified: true } : {}), + }; +} + +let lastManagementAuthAclUnverified = false; + +/** Whether the current process accepted a file-backed admin token without verified NTFS ACL harden. */ +export function managementAuthAclUnverified(): boolean { + return lastManagementAuthAclUnverified; } export function initializeManagementAuthState(config: OcxConfig): ManagementAuthState { const environmentToken = process.env.OPENCODEX_ADMIN_AUTH_TOKEN?.trim(); if (environmentToken) { + lastManagementAuthAclUnverified = false; return ready(environmentToken, "environment", config); } try { const path = adminApiTokenFilePath(); assertSafeDirectory(dirname(path)); - let token: string; + let loaded: { token: string; aclUnverified: boolean }; try { - token = readExistingToken(path); + loaded = readExistingToken(path); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; - token = createTokenFile(path); + loaded = createTokenFile(path); } - return ready(token, "file", config); + lastManagementAuthAclUnverified = loaded.aclUnverified === true; + return ready(loaded.token, "file", config, { aclUnverified: loaded.aclUnverified }); } catch (error) { + lastManagementAuthAclUnverified = false; return fail(error instanceof Error ? error.message : "management token initialization failed"); } } diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index 96bb39c99..953dad184 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -80,12 +80,12 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise if (config.claudeCode?.desktopAutoApply === false) return; if (!config.claudeCode?.desktopProfile) return; const { writeDesktop3pConfig } = await import("../../claude/desktop-3p"); - const { visibleNativeSlugs, filterCatalogVisibleModels } = await import("../../codex/catalog"); + const { filterCatalogVisibleModels, desktopVisibleNativeSlugs } = await import("../../codex/catalog"); const allModels = await fetchAllModels(config); const routed = filterCatalogVisibleModels(allModels, config).map(m => ({ provider: m.provider, id: m.id, contextWindow: m.contextWindow })); const result = writeDesktop3pConfig( config.port ?? 10100, - [...visibleNativeSlugs(config)], + [...desktopVisibleNativeSlugs(config)], routed, config.apiKeys?.[0]?.key, "static", @@ -533,7 +533,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile: state.profile }; saveConfigPreservingClaudeCode(config); const { writeDesktop3pConfig } = await import("../../claude/desktop-3p"); - const { visibleNativeSlugs } = await import("../../codex/catalog"); + const { desktopVisibleNativeSlugs } = await import("../../codex/catalog"); const routed = state.models .filter(model => model.available && !model.route.startsWith("native/")) .map(model => { @@ -542,7 +542,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise }); const result = writeDesktop3pConfig( Number(url.port) || config.port, - [...visibleNativeSlugs(config)], + [...desktopVisibleNativeSlugs(config)], routed, config.apiKeys?.[0]?.key, "static", diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 3801ca849..f1df3f33d 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -54,6 +54,7 @@ import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from ". import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost"; import type { PersistedUsageAttempt } from "../../usage/log"; import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors"; +import { managementAuthAclUnverified } from "../management-auth"; import { applySystemEnvToggle } from "../system-env"; import { getCachedStartupHealth, invalidateStartupHealthCache } from "../startup-health-cache"; import { runWindowsTrayAction } from "../windows-tray-control"; @@ -110,11 +111,13 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise { const { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort } = ctx; + if (url.pathname === "/api/catalog" && req.method === "GET") { + const { readCatalog, readCodexCatalogPath } = await import("../../codex/catalog"); + const catalog = readCatalog(readCodexCatalogPath()); + if (!catalog) return jsonResponse({ error: "catalog not found" }, 404, req, config); + const headers: Record = { + "Content-Type": "application/json", + ...corsHeaders(req, config), + }; + try { + const { resolveCodexRuntime } = await import("../../codex/runtime"); + const version = resolveCodexRuntime().runtime.version; + if (version) headers["x-opencodex-codex-version"] = version; + } catch { /* best-effort skew hint */ } + return new Response(JSON.stringify(catalog), { status: 200, headers }); + } + if (url.pathname === "/api/models" && req.method === "GET") { const models = await fetchAllModels(config); const disabled = new Set(config.disabledModels ?? []); diff --git a/src/server/management/shared.ts b/src/server/management/shared.ts index 6c6f20282..02b9ab831 100644 --- a/src/server/management/shared.ts +++ b/src/server/management/shared.ts @@ -185,10 +185,10 @@ export interface GrokCandidateModel { * from the same two sources as the sync so the two can never disagree. */ export async function fetchGrokCandidateModels(config: OcxConfig): Promise { - const { filterCatalogVisibleModels, nativeOpenAiContextWindow, visibleNativeSlugs } = await import("../../codex/catalog"); + const { filterCatalogVisibleModels, nativeOpenAiContextWindow, desktopVisibleNativeSlugs } = await import("../../codex/catalog"); const routed = filterCatalogVisibleModels(await fetchAllModels(config), config); return [ - ...visibleNativeSlugs(config).map(id => { + ...desktopVisibleNativeSlugs(config).map(id => { const contextWindow = nativeOpenAiContextWindow(id); return { id, native: true, ...(contextWindow !== undefined ? { contextWindow } : {}) }; }), @@ -214,14 +214,14 @@ export function stripRegistryOnlyStaticHeaders(name: string, provider: OcxProvid /** Shared Desktop profile DTO builder for the management API and CLI. */ export async function buildClaudeDesktopState(config: OcxConfig, stored?: OcxClaudeDesktopProfile) { - const { filterCatalogVisibleModels, nativeOpenAiContextWindow, visibleNativeSlugs } = await import("../../codex/catalog"); + const { filterCatalogVisibleModels, nativeOpenAiContextWindow, desktopVisibleNativeSlugs } = await import("../../codex/catalog"); const { DESKTOP_SUPPORTS_1M_THRESHOLD } = await import("../../claude/desktop-3p"); const { reconcileDesktopProfile, renderDesktopProfile } = await import("../../claude/desktop-profile"); const routed = filterCatalogVisibleModels(await fetchAllModels(config), config); const profileModels: DesktopProfileModel[] = [ // Native rows carry their real context window from the same accessor the Grok sync // uses — otherwise Sol's 372k and gpt-5.5's 272k render as blank on Desktop. - ...visibleNativeSlugs(config).map(id => { + ...desktopVisibleNativeSlugs(config).map(id => { const contextWindow = nativeOpenAiContextWindow(id); return { route: `native/${id}`, label: `${id} (native)`, ...(contextWindow !== undefined ? { contextWindow } : {}) }; @@ -233,6 +233,19 @@ export async function buildClaudeDesktopState(config: OcxConfig, stored?: OcxCla })), ]; const profile = reconcileDesktopProfile(stored ?? config.claudeCode?.desktopProfile, profileModels); + if (config.claudeCode?.desktopNativeModels === false) { + for (const route of Object.keys(profile.assignments)) { + if (route.startsWith("native/")) delete profile.assignments[route]; + } + for (const family of ["opus", "fable", "sonnet", "haiku"] as const) { + const current = profile.defaults[family]; + if (current?.startsWith("native/")) { + profile.defaults[family] = Object.keys(profile.assignments) + .filter(route => profile.assignments[route]?.family === family) + .sort()[0] ?? null; + } + } + } const available = new Set(profileModels.map(model => model.route)); const modelByRoute = new Map(profileModels.map(model => [model.route, model])); // Effort support: routed models with a non-empty reasoningEfforts ladder support effort; @@ -241,7 +254,7 @@ export async function buildClaudeDesktopState(config: OcxConfig, stored?: OcxCla for (const m of routed) { effortByRoute.set(`${m.provider}/${m.id}`, Array.isArray(m.reasoningEfforts) && m.reasoningEfforts.length > 0); } - for (const id of visibleNativeSlugs(config)) { + for (const id of desktopVisibleNativeSlugs(config)) { effortByRoute.set(`native/${id}`, true); } const models = Object.keys(profile.assignments).sort().map(route => ({ diff --git a/src/server/request-log.ts b/src/server/request-log.ts index 854837b32..f6f6cae40 100644 --- a/src/server/request-log.ts +++ b/src/server/request-log.ts @@ -126,7 +126,7 @@ export interface RequestLogEntry { } const requestLog: RequestLogEntry[] = []; -const MAX_LOG_SIZE = 200; +const MAX_LOG_SIZE = 2000; let requestLogSeq = 0; /** True after hydrateRequestLogsFromDisk ran once in this process. */ let requestLogsHydratedFromDisk = false; @@ -760,6 +760,17 @@ export function filterRequestLogs(logs: RequestLogEntry[], params: URLSearchPara const tail = Number.parseInt(tailRaw, 10); if (Number.isFinite(tail) && tail > 0) filtered = filtered.slice(-Math.min(tail, MAX_LOG_SIZE)); } + const offsetRaw = params.get("offset")?.trim(); + const limitRaw = params.get("limit")?.trim(); + if (limitRaw) { + const limit = Number.parseInt(limitRaw, 10); + const offset = offsetRaw ? Number.parseInt(offsetRaw, 10) : 0; + if (Number.isFinite(limit) && limit > 0) { + const capped = Math.min(limit, MAX_LOG_SIZE); + const start = Number.isFinite(offset) && offset > 0 ? offset : 0; + filtered = filtered.slice(start, start + capped); + } + } return filtered; } diff --git a/src/service.ts b/src/service.ts index b38305fa1..fc64d4c4c 100644 --- a/src/service.ts +++ b/src/service.ts @@ -6,6 +6,7 @@ * restore it via the command. */ import { execFileSync, execSync } from "node:child_process"; +import { findLiveProxy } from "./server/proxy-liveness"; import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; @@ -1363,7 +1364,38 @@ export async function repairService(deps: RepairServiceDeps = {}): Promise * scheduler backend first; on failure the machine is left with NO service (explicitly * reported) — never a silent fallback to the scheduler. */ +/** Refuse WinSW when the interactive user is a Microsoft account (SCM cannot authenticate it). */ +export function assertWindowsNativeServiceAccountSupported(): void { + if (process.platform !== "win32") return; + const source = readWindowsPrincipalSource(); + if (source?.toLowerCase() === "microsoftaccount") { + throw new Error( + "The native (WinSW) service backend cannot run under a Microsoft-account Windows login. " + + "Keep the Task Scheduler backend (`ocx service install`) or sign in with a local/domain account before `ocx service install --native`.", + ); + } +} + +function readWindowsPrincipalSource(): string | null { + if (process.platform !== "win32") return null; + const ps = join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe"); + if (!existsSync(ps)) return null; + try { + const out = execFileSync(ps, [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + "(Get-LocalUser -Name $env:USERNAME -ErrorAction SilentlyContinue).PrincipalSource", + ], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true }).trim(); + return out || null; + } catch { + return null; + } +} + async function installWindowsNative(): Promise { + assertWindowsNativeServiceAccountSupported(); recordOwnedConfigPath(getConfigDir(), serviceStatePath()); if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true }); writeServiceApiTokenFile(); @@ -1402,7 +1434,23 @@ function stopWindows(): void { try { schtasks(["/end", "/tn", TASK]); } catch { function statusWindows(): string { try { return schtasks(["/query", "/tn", TASK]); } catch { return ""; } } function statusWindowsXml(): string { try { return schtasks(["/query", "/tn", TASK, "/xml"]); } catch { return ""; } } function uninstallWindows(): void { - try { schtasks(["/delete", "/tn", TASK, "/f"]); } catch { /* absent */ } + const probe = probeWindowsSchedulerTask(TASK); + if (probe.status === "present") { + try { + schtasks(["/delete", "/tn", TASK, "/f"]); + } catch (error) { + throw new Error(`Failed to delete Task Scheduler task ${TASK}: ${error instanceof Error ? error.message : String(error)}`); + } + const afterDelete = probeWindowsSchedulerTask(TASK); + if (afterDelete.status === "present") { + throw new Error(`Task Scheduler task ${TASK} is still present after delete — refusing to remove service assets. Retry from an elevated shell.`); + } + if (afterDelete.status === "unknown") { + throw new Error(`Task Scheduler task ${TASK} presence could not be verified after delete — refusing to remove service assets.`); + } + } else if (probe.status === "unknown") { + throw new Error(`Task Scheduler task ${TASK} presence could not be verified — refusing to remove service assets.`); + } if (existsSync(windowsServiceScriptPath())) unlinkSync(windowsServiceScriptPath()); if (existsSync(windowsLauncherVbsPath())) unlinkSync(windowsLauncherVbsPath()); if (existsSync(windowsTaskXmlPath())) unlinkSync(windowsTaskXmlPath()); @@ -1562,17 +1610,29 @@ function platformOps(backend: ServiceBackend = "scheduler"): ServiceOps | null { type TrackedProxyCleanupResult = "none" | "stale" | "stopped"; async function stopTrackedProxyIfRunning(): Promise { + let stopped = false; const pid = readPid(); - if (!pid) return "none"; - if (!isProcessAlive(pid)) { + if (pid && isProcessAlive(pid)) { + await stopProxy(pid); removePid(pid); removeRuntimePort(pid); - return "stale"; + stopped = true; + } else if (pid) { + removePid(pid); + removeRuntimePort(pid); + } + // Orphan recovery: the pid file can be missing/stale while the service wrapper keeps + // a live proxy running — mirror `ocx stop`'s identity-checked findLiveProxy fallback. + const live = await findLiveProxy({ timeoutMs: 1500 }); + if (live?.pid) { + await stopProxy(live.pid); + removePid(live.pid); + removeRuntimePort(live.pid); + stopped = true; } - await stopProxy(pid); - removePid(pid); - removeRuntimePort(pid); - return "stopped"; + if (stopped) return "stopped"; + if (pid) return "stale"; + return "none"; } async function stopTrackedProxyForServiceCommand(): Promise { @@ -1886,6 +1946,10 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise; @@ -204,7 +233,11 @@ function replaceOwnedFile(path: string, contents: string | Buffer): void { } } -function writeState(entry: WindowsTrayEntry, runValue: string, runCommand: string): void { +function writeState( + entry: WindowsTrayEntry & { launcherPath: string }, + runValue: string, + runCommand: string, +): void { const path = trayStatePath(); replaceOwnedFile(path, JSON.stringify({ version: TRAY_STATE_VERSION, ...entry, runValue, runCommand }, null, 2) + "\n"); } @@ -376,7 +409,8 @@ function trayStatusFrom(registered: string | null): WindowsTrayStatus { const running = heartbeatProcessAlive(heartbeat); const registrationOwned = state !== null && registered === state.runCommand - && [state.bun, state.cli, state.script, ...installedTrayIconPaths()].every(path => existsSync(path)); + && [state.bun, state.cli, state.script, ...(state.launcherPath ? [state.launcherPath] : []), ...installedTrayIconPaths()] + .every(path => existsSync(path)); const stale = windowsTrayRegistrationIsStale({ registered: registered !== null, registrationOwned, @@ -479,7 +513,12 @@ export function installWindowsTray(startNow = true): WindowsTrayStatus { } recordOwnedConfigPath(getConfigDir(), trayStatePath()); if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 }); - const runCommand = buildWindowsTrayRunCommand(entry); + const launcherPath = installedTrayLauncherPath(); + const entryWithLauncher = { ...entry, launcherPath }; + const runCommand = buildWindowsTrayRunCommand(entryWithLauncher); + if (runCommand.length > 260) { + throw new Error(`Tray Run command exceeds the Windows 260-character limit (${runCommand.length} chars).`); + } const runValue = windowsTrayRunValue(entry.opencodexHome); const existing = readOwnedRunValue(runValue); const state = readState(); @@ -489,6 +528,9 @@ export function installWindowsTray(startNow = true): WindowsTrayStatus { if (existsSync(entry.script) && (!state || resolve(state.script) !== resolve(entry.script))) { throw new Error(`Refusing to overwrite an unowned tray script at ${entry.script}.`); } + if (existsSync(launcherPath) && state?.launcherPath && resolve(state.launcherPath) !== resolve(launcherPath)) { + throw new Error(`Refusing to overwrite an unowned tray launcher at ${launcherPath}.`); + } if (!state && iconPairs.some(pair => existsSync(pair.installed))) { throw new Error("Refusing to overwrite unowned Windows tray icon assets."); } @@ -503,6 +545,7 @@ export function installWindowsTray(startNow = true): WindowsTrayStatus { const previousStateBytes = existsSync(trayStatePath()) ? readFileSync(trayStatePath()) : null; const previousScriptBytes = existsSync(entry.script) ? readFileSync(entry.script) : null; + const previousLauncherBytes = existsSync(launcherPath) ? readFileSync(launcherPath) : null; const previousIconBytes = new Map(iconPairs.map(pair => [ pair.installed, existsSync(pair.installed) ? readFileSync(pair.installed) : null, @@ -512,6 +555,10 @@ export function installWindowsTray(startNow = true): WindowsTrayStatus { if (previousScriptBytes) replaceOwnedFile(entry.script, previousScriptBytes); else if (existsSync(entry.script)) unlinkSync(entry.script); } catch { /* rollback best-effort */ } + try { + if (previousLauncherBytes) replaceOwnedFile(launcherPath, previousLauncherBytes); + else if (existsSync(launcherPath)) unlinkSync(launcherPath); + } catch { /* rollback best-effort */ } for (const [path, contents] of previousIconBytes) { try { if (contents) replaceOwnedFile(path, contents); @@ -539,8 +586,9 @@ export function installWindowsTray(startNow = true): WindowsTrayStatus { if (!hardenedDir.ok) throw new Error("Windows tray directory ACL hardening did not complete; refusing to install persistence."); replaceOwnedFile(entry.script, readFileSync(sourceScript)); for (const pair of iconPairs) replaceOwnedFile(pair.installed, readFileSync(pair.source)); + replaceOwnedFile(launcherPath, buildWindowsTrayLauncherScript(entry)); runRegistry(["add", RUN_KEY, "/v", runValue, "/t", "REG_SZ", "/d", runCommand, "/f", "/reg:64"]); - writeState(entry, runValue, runCommand); + writeState(entryWithLauncher, runValue, runCommand); } catch (error) { restorePreviousInstall(); throw error; @@ -550,9 +598,6 @@ export function installWindowsTray(startNow = true): WindowsTrayStatus { restorePreviousInstall(); throw new Error("The tray startup registration was installed, but the tray process did not become healthy."); } - if (state?.launcherPath && existsSync(state.launcherPath)) { - try { unlinkSync(state.launcherPath); } catch { /* old owned VBS is inert after a committed Run replacement */ } - } return getWindowsTrayStatus(); } diff --git a/src/types.ts b/src/types.ts index 983b483dd..647eb1a9f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -455,6 +455,11 @@ export interface OcxClaudeCodeConfig { desktopProfile?: OcxClaudeDesktopProfile; /** Auto-reconcile Desktop 3P config when provider catalog changes. Default: enabled. */ desktopAutoApply?: boolean; + /** + * When false, omit `native/*` rows from Claude Desktop show/export/apply. Default: enabled. + * Routing-sidecar alias decoding is unchanged — only the Desktop model list writer. + */ + desktopNativeModels?: boolean; } export type OcxClaudeDesktopFamily = "opus" | "fable" | "sonnet" | "haiku"; diff --git a/tests/anthropic-stream-hardening.test.ts b/tests/anthropic-stream-hardening.test.ts new file mode 100644 index 000000000..7302ac568 --- /dev/null +++ b/tests/anthropic-stream-hardening.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, test } from "bun:test"; +import { anthropicMessagesUrl, createAnthropicAdapter } from "../src/adapters/anthropic"; +import type { AdapterEvent } from "../src/types"; + +const provider = { adapter: "anthropic", baseUrl: "https://example.test", apiKey: "key" }; + +async function collect(gen: AsyncGenerator): Promise { + const out: AdapterEvent[] = []; + for await (const e of gen) out.push(e); + return out; +} + +describe("anthropicMessagesUrl", () => { + test.each([ + ["https://example.test", "https://example.test/v1/messages"], + ["https://example.test/", "https://example.test/v1/messages"], + ["https://example.test/v1", "https://example.test/v1/messages"], + ["https://example.test/v1/", "https://example.test/v1/messages"], + ["https://example.test/v1/messages", "https://example.test/v1/messages"], + ["https://example.test/v1/messages/", "https://example.test/v1/messages"], + ] as const)("normalizes %s", (input, expected) => { + expect(anthropicMessagesUrl(input)).toBe(expected); + }); +}); + +describe("anthropic stream hardening", () => { + test("EOF after content without message_stop completes as done", async () => { + const response = new Response([ + "event: content_block_start\n", + 'data: {"type":"content_block_start","content_block":{"type":"text","text":""}}\n\n', + "event: content_block_delta\n", + 'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"hi"}}\n\n', + ].join("")); + const events = await collect(createAnthropicAdapter(provider).parseStream(response)); + expect(events.at(-1)?.type).toBe("done"); + expect(events.some(e => e.type === "error")).toBe(false); + }); + + test("input_json_delta outside tool_use is ignored", async () => { + const response = new Response([ + "event: content_block_start\n", + 'data: {"type":"content_block_start","content_block":{"type":"text","text":""}}\n\n', + "event: content_block_delta\n", + 'data: {"type":"content_block_delta","delta":{"type":"input_json_delta","partial_json":"{\\"x\\":1}"}}\n\n', + "event: message_stop\n", + 'data: {"type":"message_stop"}\n\n', + ].join("")); + const events = await collect(createAnthropicAdapter(provider).parseStream(response)); + expect(events.some(e => e.type === "tool_call_delta")).toBe(false); + expect(events.at(-1)?.type).toBe("done"); + }); + + test("tool_use without id synthesizes a toolu_ id", async () => { + const response = new Response([ + "event: content_block_start\n", + 'data: {"type":"content_block_start","content_block":{"type":"tool_use","name":"get_weather"}}\n\n', + "event: content_block_delta\n", + 'data: {"type":"content_block_delta","delta":{"type":"input_json_delta","partial_json":"{}"}}\n\n', + "event: content_block_stop\n", + 'data: {"type":"content_block_stop"}\n\n', + "event: message_stop\n", + 'data: {"type":"message_stop"}\n\n', + ].join("")); + const events = await collect(createAnthropicAdapter(provider).parseStream(response)); + const start = events.find(e => e.type === "tool_call_start"); + expect(start).toMatchObject({ type: "tool_call_start", name: "get_weather" }); + expect(start && "id" in start && start.id.startsWith("toolu_")).toBe(true); + }); + + test("empty EOF without content still errors", async () => { + const response = new Response(""); + const events = await collect(createAnthropicAdapter(provider).parseStream(response)); + expect(events.at(-1)?.type).toBe("error"); + expect(events.some(e => e.type === "done")).toBe(false); + }); +}); + +describe("anthropic non-stream tool_use input", () => { + test("parses string tool_use.input", async () => { + const adapter = createAnthropicAdapter(provider); + const events = await adapter.parseResponse!(new Response(JSON.stringify({ + content: [{ type: "tool_use", id: "toolu_1", name: "get_weather", input: "{\"city\":\"Paris\"}" }], + stop_reason: "tool_use", + }))); + expect(events.find(e => e.type === "tool_call_delta")).toMatchObject({ + type: "tool_call_delta", + arguments: "{\"city\":\"Paris\"}", + }); + expect(events.at(-1)?.type).toBe("done"); + }); +}); diff --git a/tests/catalog-input-modality-enum.test.ts b/tests/catalog-input-modality-enum.test.ts new file mode 100644 index 000000000..7c49d596c --- /dev/null +++ b/tests/catalog-input-modality-enum.test.ts @@ -0,0 +1,163 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + buildCatalogEntries, + ensureStrictCatalogFields, + mergeCatalogEntriesForSync, + repairCatalogInputModalities, + resetCatalogRuntimeStateForTests, + sanitizeCodexInputModalities, + syncCatalogModels, +} from "../src/codex/catalog"; +import { catalogHintsFromModelsApiItem } from "../src/codex/catalog/provider-fetch"; +import { handleManagementAPI } from "../src/server/management-api"; +import type { OcxConfig } from "../src/types"; + +const previousCodexHome = process.env.CODEX_HOME; + +afterEach(() => { + resetCatalogRuntimeStateForTests(); + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; +}); + +describe("Codex catalog input_modalities enum", () => { + test("sanitizeCodexInputModalities drops video and keeps text|image|audio", () => { + expect(sanitizeCodexInputModalities(["text", "video", "image", "audio", "video"])).toEqual(["text", "image", "audio"]); + expect(sanitizeCodexInputModalities(["video"])).toEqual(["text"]); + expect(sanitizeCodexInputModalities(undefined)).toEqual(["text"]); + }); + + test("ensureStrictCatalogFields strips video even when preserveExactInputModalities is true", () => { + const entry = ensureStrictCatalogFields({ + slug: "combo/test-alias", + input_modalities: ["text", "video", "image"], + }, { preserveExactInputModalities: true, isRouted: true }); + expect(entry.input_modalities).toEqual(["text", "image"]); + }); + + test("provider model discovery metadata never advertises video to the catalog builder", () => { + const hints = catalogHintsFromModelsApiItem("zenmux", { + id: "meta-muse-spark-1.1", + input_modalities: ["text", "image", "video"], + }); + expect(hints.inputModalities).toEqual(["text", "image"]); + }); + + test("mergeCatalogEntriesForSync repairs poisoned on-disk modalities", () => { + const merged = mergeCatalogEntriesForSync( + [{ + slug: "fakeprov/poisoned", + input_modalities: ["text", "video"], + visibility: "list", + priority: 5, + }], + [], + new Map(), + [], + false, + ); + expect(merged[0]?.input_modalities).toEqual(["text"]); + }); + + test("syncCatalogModels rewrites a poisoned catalog file to Codex-safe modalities", async () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-catalog-enum-")); + process.env.CODEX_HOME = dir; + const catalogPath = join(dir, "opencodex-catalog.json"); + writeFileSync(catalogPath, JSON.stringify({ + models: [{ + slug: "gpt-5.5", + display_name: "gpt-5.5", + description: "native", + priority: 1, + visibility: "list", + base_instructions: "You are a helpful coding assistant.", + input_modalities: ["text", "video"], + }, { + slug: "fakeprov/routed", + display_name: "fakeprov/routed", + description: "routed", + priority: 5, + visibility: "list", + base_instructions: "You are a helpful coding assistant.", + input_modalities: ["text", "video", "image"], + }], + }, null, 2) + "\n"); + writeFileSync(join(dir, "config.toml"), `model_catalog_json = "${catalogPath.replace(/\\/g, "/")}"\n`); + + const config: OcxConfig = { + port: 10100, + defaultProvider: "fakeprov", + providers: { + fakeprov: { + adapter: "openai-chat", + baseUrl: "https://api.example.test/v1", + liveModels: false, + models: ["routed"], + fetch: (() => { throw new Error("unexpected fetch"); }) as typeof fetch, + }, + }, + }; + + await syncCatalogModels(config); + const written = JSON.parse(readFileSync(catalogPath, "utf8")) as { models: Array<{ slug?: string; input_modalities?: string[] }> }; + for (const entry of written.models) { + expect(entry.input_modalities?.includes("video")).toBe(false); + expect(entry.input_modalities?.every(value => value === "text" || value === "image" || value === "audio")).toBe(true); + } + rmSync(dir, { recursive: true, force: true }); + }); +}); + +describe("GET /api/catalog", () => { + test("returns the on-disk catalog under management auth without secrets", async () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-catalog-api-")); + process.env.CODEX_HOME = dir; + const catalogPath = join(dir, "opencodex-catalog.json"); + writeFileSync(catalogPath, JSON.stringify({ + models: [{ + slug: "gpt-5.5", + display_name: "gpt-5.5", + description: "native", + priority: 1, + visibility: "list", + base_instructions: "You are a helpful coding assistant.", + input_modalities: ["text", "image"], + }], + }, null, 2) + "\n"); + writeFileSync(join(dir, "config.toml"), `model_catalog_json = "${catalogPath.replace(/\\/g, "/")}"\n`); + + const url = new URL("http://127.0.0.1/api/catalog"); + const response = await handleManagementAPI( + new Request(url, { headers: { Host: "127.0.0.1" } }), + url, + { port: 10100, hostname: "127.0.0.1" }, + ); + expect(response?.status).toBe(200); + const body = await response!.json() as { models: Array<{ slug?: string }> }; + expect(body.models.some(entry => entry.slug === "gpt-5.5")).toBe(true); + expect(JSON.stringify(body)).not.toMatch(/api[_-]?key|sk-[a-z0-9]/i); + rmSync(dir, { recursive: true, force: true }); + }); +}); + +describe("catalog builder choke point", () => { + test("buildCatalogEntries never serializes video into input_modalities", () => { + const entries = buildCatalogEntries(null, [], [{ + provider: "fakeprov", + id: "video-model", + inputModalities: ["text", "video", "image"], + }], [], false); + const routed = entries.find(entry => entry.slug === "fakeprov/video-model"); + expect(routed?.input_modalities).toEqual(["text", "image"]); + }); + + test("repairCatalogInputModalities reports and fixes poisoned rows", () => { + const catalog = { models: [{ slug: "x/y", input_modalities: ["text", "video"] }] }; + expect(repairCatalogInputModalities(catalog)).toBe(true); + expect(catalog.models[0]?.input_modalities).toEqual(["text"]); + expect(repairCatalogInputModalities(catalog)).toBe(false); + }); +}); diff --git a/tests/claude-desktop-cli.test.ts b/tests/claude-desktop-cli.test.ts index 22e1eee97..b44a9aec1 100644 --- a/tests/claude-desktop-cli.test.ts +++ b/tests/claude-desktop-cli.test.ts @@ -70,6 +70,31 @@ test("import rejects invalid profiles without replacing saved state", async () = } }); +test("desktopNativeModels:false omits native/* from show and exported profile", async () => { + saveConfig({ + port: 10100, + defaultProvider: "mock", + providers: { + mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1:1/v1", apiKey: "k", allowPrivateNetwork: true, models: ["test-model"] }, + }, + claudeCode: { desktopNativeModels: false }, + } as OcxConfig); + const log = spyOn(console, "log").mockImplementation(() => {}); + try { + expect(await handleClaudeDesktopCommand(["show", "--json"])).toBe(0); + const state = JSON.parse(String(log.mock.calls.at(-1)?.[0])); + expect(state.models.every((model: { route: string }) => !model.route.startsWith("native/"))).toBe(true); + expect(Object.keys(state.profile.assignments).every((route: string) => !route.startsWith("native/"))).toBe(true); + + const target = join(dir, "desktop-profile.json"); + expect(await handleClaudeDesktopCommand(["export", target])).toBe(0); + const exported = JSON.parse(readFileSync(target, "utf8")); + expect(Object.keys(exported.assignments).every((route: string) => !route.startsWith("native/"))).toBe(true); + } finally { + log.mockRestore(); + } +}); + test("no-arg and legacy mode flags apply Desktop config", async () => { const log = spyOn(console, "log").mockImplementation(() => {}); const error = spyOn(console, "error").mockImplementation(() => {}); diff --git a/tests/claude-messages-endpoint.test.ts b/tests/claude-messages-endpoint.test.ts index 15e7b7f16..543647aae 100644 --- a/tests/claude-messages-endpoint.test.ts +++ b/tests/claude-messages-endpoint.test.ts @@ -1,3 +1,4 @@ +import { logsFromApiBody } from "./helpers/logs-api"; import { afterEach, beforeEach, expect, test } from "bun:test"; import { managementFetch as fetch } from "./helpers/management-auth"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; @@ -112,9 +113,7 @@ test("POST /v1/messages?beta=true streams an Anthropic-shaped turn end to end", // Request log regression (live smoke round 2): the tap must see the PRE-translation // Responses stream — the translated Anthropic stream has no response.completed, which // used to record a bogus 502 with no usage. - const logs = await (await fetch(new URL("/api/logs", server.url))).json() as { - status: number; model: string; usage?: { inputTokens: number; outputTokens: number }; usageStatus: string; - }[]; + const logs = logsFromApiBody(await (await fetch(new URL("/api/logs", server.url))).json()); const row = logs.find(l => l.model === "test-model" || l.model === "mock/test-model"); expect(row).toBeDefined(); expect(row!.status).toBe(200); diff --git a/tests/claude-native-passthrough.test.ts b/tests/claude-native-passthrough.test.ts index 6743ded19..8f0fa5563 100644 --- a/tests/claude-native-passthrough.test.ts +++ b/tests/claude-native-passthrough.test.ts @@ -1,3 +1,4 @@ +import { logsFromApiBody } from "./helpers/logs-api"; import { afterEach, beforeEach, expect, test } from "bun:test"; import { managementFetch as fetch } from "./helpers/management-auth"; import { mkdtempSync, rmSync } from "node:fs"; @@ -116,7 +117,7 @@ test("unmapped claude model + sk-ant credential passes through verbatim", async expect(hit.body).toEqual(claudeBody()); // Request log: native provider tag + usage incl. cache detail from the SSE tap. - const logs = await (await fetch(new URL("/api/logs", server.url))).json() as any[]; + const logs = logsFromApiBody(await (await fetch(new URL("/api/logs", server.url))).json()); const row = logs.find(l => l.provider === "anthropic-native"); expect(row).toBeDefined(); expect(row.status).toBe(200); @@ -168,10 +169,10 @@ test("native passthrough persists conversationId from metadata.user_id", async ( expect(res.status).toBe(200); await res.text(); - const logs = await (await fetch(new URL("/api/logs?tail=1", server.url))).json() as Array<{ + const logs = logsFromApiBody<{ provider?: string; conversationId?: string; - }>; + }>(await (await fetch(new URL("/api/logs?tail=1", server.url))).json()); expect(logs).toHaveLength(1); expect(logs[0]?.provider).toBe("anthropic-native"); expect(logs[0]?.conversationId).toBe(createHash("sha256").update(userId).digest("hex").slice(0, 32)); diff --git a/tests/doctor-provider-apikey.test.ts b/tests/doctor-provider-apikey.test.ts new file mode 100644 index 000000000..56474aefe --- /dev/null +++ b/tests/doctor-provider-apikey.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test"; +import { collectProviderApiKeyDiagnostics } from "../src/cli/doctor"; + +describe("doctor provider apiKey env diagnostics (#762)", () => { + test("warns when a key-auth env reference resolves empty without printing secrets", () => { + const rows = collectProviderApiKeyDiagnostics({ + openrouter: { + authMode: "key", + apiKey: "${OPENROUTER_API_KEY}", + }, + }, {}); + expect(rows).toEqual([{ + provider: "openrouter", + envName: "OPENROUTER_API_KEY", + detail: "provider openrouter: env reference OPENROUTER_API_KEY is unset or empty in this process", + }]); + }); + + test("passes when the referenced env var is set", () => { + const rows = collectProviderApiKeyDiagnostics({ + openrouter: { + authMode: "key", + apiKey: "${OPENROUTER_API_KEY}", + }, + }, { OPENROUTER_API_KEY: "secret-value" }); + expect(rows).toEqual([]); + }); + + test("ignores literal keys and non-key providers", () => { + const rows = collectProviderApiKeyDiagnostics({ + openrouter: { authMode: "key", apiKey: "sk-live-not-an-env-ref" }, + openai: { authMode: "forward", apiKey: "${SHOULD_NOT_MATTER}" }, + }, {}); + expect(rows).toEqual([]); + }); +}); diff --git a/tests/helpers/logs-api.ts b/tests/helpers/logs-api.ts new file mode 100644 index 000000000..82a324723 --- /dev/null +++ b/tests/helpers/logs-api.ts @@ -0,0 +1,7 @@ +export function logsFromApiBody = Record>(body: unknown): T[] { + if (Array.isArray(body)) return body as T[]; + if (body && typeof body === "object" && Array.isArray((body as { logs?: unknown }).logs)) { + return (body as { logs: T[] }).logs; + } + return []; +} diff --git a/tests/init-eof.test.ts b/tests/init-eof.test.ts new file mode 100644 index 000000000..297abe467 --- /dev/null +++ b/tests/init-eof.test.ts @@ -0,0 +1,33 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +describe("ocx init piped stdin (#754)", () => { + const dirs: string[] = []; + afterEach(() => { + while (dirs.length) rmSync(dirs.pop()!, { recursive: true, force: true }); + }); + + test("exits cleanly when stdin closes before the first prompt answer", async () => { + const home = mkdtempSync(join(tmpdir(), "ocx-init-eof-")); + dirs.push(home); + const cli = join(import.meta.dir, "..", "src", "cli", "index.ts"); + const proc = Bun.spawn({ + cmd: [process.execPath, cli, "init"], + env: { ...process.env, OPENCODEX_HOME: home }, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + proc.stdin.end(); + const exit = await Promise.race([ + proc.exited, + new Promise((_, reject) => setTimeout(() => reject(new Error("init did not exit after stdin EOF")), 8_000)), + ]); + expect(exit).toBe(1); + const stderr = await new Response(proc.stderr).text(); + expect(stderr.toLowerCase()).toMatch(/stdin (closed|reached eof)/); + expect(existsSync(join(home, "config.json"))).toBe(false); + }); +}); diff --git a/tests/management-api-logs-metrics.test.ts b/tests/management-api-logs-metrics.test.ts index 3b4c6c2d5..4223970b5 100644 --- a/tests/management-api-logs-metrics.test.ts +++ b/tests/management-api-logs-metrics.test.ts @@ -16,7 +16,10 @@ async function readLogs(): Promise>> { const url = new URL("http://localhost/api/logs"); const response = await handleManagementAPI(new Request(url), url, config); expect(response?.status).toBe(200); - return await response!.json() as Array>; + const body = await response!.json() as { logs?: Array>; timeZone?: string }; + expect(typeof body.timeZone).toBe("string"); + expect(body.timeZone!.length).toBeGreaterThan(0); + return body.logs ?? []; } function baseEntry(overrides: Partial): RequestLogEntry { diff --git a/tests/management-origin-tls.test.ts b/tests/management-origin-tls.test.ts new file mode 100644 index 000000000..073cddcf5 --- /dev/null +++ b/tests/management-origin-tls.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test"; +import { isAllowedManagementOrigin } from "../src/server/auth-cors"; +import type { OcxConfig } from "../src/types"; + +function config(partial: Partial = {}): OcxConfig { + return { + port: 10100, + hostname: "0.0.0.0", + providers: {}, + ...partial, + } as OcxConfig; +} + +describe("management origin behind TLS termination (#760)", () => { + test("allows https Origin when the process sees http with the same Host", () => { + const req = new Request("http://127.0.0.1:10100/api/v2", { + method: "PUT", + headers: { + Host: "proxy.example.com", + Origin: "https://proxy.example.com", + }, + }); + expect(isAllowedManagementOrigin(req, config())).toBe(true); + }); + + test("still rejects a different Origin host", () => { + const req = new Request("http://127.0.0.1:10100/api/v2", { + method: "PUT", + headers: { + Host: "proxy.example.com", + Origin: "https://evil.example.com", + }, + }); + expect(isAllowedManagementOrigin(req, config())).toBe(false); + }); + + test("still rejects same-scheme cross-port Origins", () => { + const req = new Request("http://127.0.0.1:10100/api/config", { + method: "GET", + headers: { + Host: "127.0.0.1:10100", + Origin: "http://127.0.0.1:65534", + }, + }); + expect(isAllowedManagementOrigin(req, config({ hostname: "127.0.0.1" }))).toBe(false); + }); + + test("honours corsAllowOrigins for an explicit external origin", () => { + const req = new Request("http://127.0.0.1:10100/api/v2", { + method: "PUT", + headers: { + Host: "internal.example.com", + Origin: "https://dashboard.example.com", + }, + }); + expect(isAllowedManagementOrigin(req, config({ + corsAllowOrigins: ["https://dashboard.example.com"], + }))).toBe(true); + }); +}); diff --git a/tests/openai-api-virtual-models.test.ts b/tests/openai-api-virtual-models.test.ts index 3ff38c5e1..56a403708 100644 --- a/tests/openai-api-virtual-models.test.ts +++ b/tests/openai-api-virtual-models.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; +import { logsFromApiBody } from "./helpers/logs-api"; import { managementHeaders } from "./helpers/management-auth"; import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -231,7 +232,8 @@ describe("OpenAI API compact transport", () => { const server = startServer(0); const readLogs = () => originalFetch(new URL("/api/logs", server.url), { headers: managementHeaders() }) - .then(response => response.json()) as Promise>>; + .then(response => response.json()) + .then(body => logsFromApiBody(body)); const readUsage = (): Array> => existsSync(usageLogPath()) ? readFileSync(usageLogPath(), "utf8").trim().split("\n").filter(Boolean).map(line => JSON.parse(line) as Record) : []; @@ -399,7 +401,8 @@ describe("OpenAI API Pro transport identities", () => { ? readFileSync(usageLogPath(), "utf8").trim().split("\n").filter(Boolean).map(line => JSON.parse(line) as Record) : []; const readLogs = () => originalFetch(new URL("/api/logs", server.url), { headers: managementHeaders() }) - .then(response => response.json()) as Promise>>; + .then(response => response.json()) + .then(body => logsFromApiBody(body)); const expectOnePersisted = async ( beforeLogs: number, beforeUsage: number, diff --git a/tests/openai-chat-eof.test.ts b/tests/openai-chat-eof.test.ts index 696151637..a505ddf9a 100644 --- a/tests/openai-chat-eof.test.ts +++ b/tests/openai-chat-eof.test.ts @@ -12,11 +12,18 @@ async function collect(gen: AsyncGenerator): Promise { - test("truncated stream (no [DONE], no finish_reason) yields a terminal error, not a clean done", async () => { + test("truncated stream (no [DONE], no finish_reason) yields done when content was emitted", async () => { const response = new Response('data: {"choices":[{"delta":{"content":"par"}}]}\n\n'); const events = await collect(createOpenAIChatAdapter(provider).parseStream(response)); const last = events[events.length - 1]; - expect(last.type).toBe("error"); + expect(last.type).toBe("done"); + expect(events.some(e => e.type === "error")).toBe(false); + }); + + test("empty EOF without content still errors", async () => { + const response = new Response(""); + const events = await collect(createOpenAIChatAdapter(provider).parseStream(response)); + expect(events.at(-1)?.type).toBe("error"); expect(events.some(e => e.type === "done")).toBe(false); }); @@ -128,11 +135,11 @@ describe("openai-chat stream EOF fail-closed", () => { expect(events.some(e => e.type === "error")).toBe(false); }); - test("genuinely truncated stream WITHOUT a trailing newline still fails closed", async () => { - // Mid-content frame, no terminator, no newline — must remain a terminal error. + test("genuinely truncated stream WITHOUT a trailing newline completes when content was emitted", async () => { + // Mid-content frame, no terminator, no newline — content was yielded, so accept done. const response = new Response('data: {"choices":[{"delta":{"content":"par"}}]}'); const events = await collect(createOpenAIChatAdapter(provider).parseStream(response)); - expect(events.at(-1)?.type).toBe("error"); - expect(events.some(e => e.type === "done")).toBe(false); + expect(events.at(-1)?.type).toBe("done"); + expect(events.some(e => e.type === "error")).toBe(false); }); }); diff --git a/tests/openai-provider-option-e2e.test.ts b/tests/openai-provider-option-e2e.test.ts index c865f2eb0..243e43dba 100644 --- a/tests/openai-provider-option-e2e.test.ts +++ b/tests/openai-provider-option-e2e.test.ts @@ -1,3 +1,4 @@ +import { logsFromApiBody } from "./helpers/logs-api"; import { describe, expect, test } from "bun:test"; import { managementFetch as fetch } from "./helpers/management-auth"; import { createHash } from "node:crypto"; @@ -486,7 +487,7 @@ describe("OpenAI provider-option integration spine", () => { expect((await put("/api/injection-model", { model: selected, effort: "high" })).status).toBe(200); expect(await local("/api/injection-model").then(response => response.json())).toMatchObject({ model: selected, effort: "high" }); - const logs = await local("/api/logs").then(response => response.json()) as Array>; + const logs = logsFromApiBody(await local("/api/logs").then(response => response.json())); expect(logs.some(row => row.provider === "openai-p123abc" && row.requestedModel === "gpt-5.6-sol" && row.resolvedModel === "gpt-5.6-sol")).toBe(true); diff --git a/tests/provider-outbound.test.ts b/tests/provider-outbound.test.ts index de9b78d90..2fc871208 100644 --- a/tests/provider-outbound.test.ts +++ b/tests/provider-outbound.test.ts @@ -90,6 +90,36 @@ describe("provider outbound GET transport", () => { expect(captured.address).toBeUndefined(); }); + test("built-in ollama admits loopback discovery without an explicit allowPrivateNetwork flag (#758)", async () => { + for (const key of proxyKeys) delete process.env[key]; + const { providerOutboundGet } = await import("../src/lib/provider-outbound"); + let sawAllowPrivate: boolean | undefined; + const dependencies: ProviderOutboundDependencies = { + resolveAddresses: mock(async (_url, options) => { + sawAllowPrivate = typeof options === "object" && options?.allowPrivateNetwork === true; + return { + hostname: "127.0.0.1", + addresses: [{ address: "127.0.0.1", family: 4 }], + privateNetwork: true, + }; + }), + pinnedGet: mock(async () => new Response('{"data":[{"id":"llama"}]}', { + status: 200, + headers: { "content-type": "application/json" }, + })), + }; + + const response = await providerOutboundGet( + "ollama", + { baseUrl: "http://127.0.0.1:11434/v1" }, + "http://127.0.0.1:11434/v1/models", + {}, + dependencies, + ); + expect(sawAllowPrivate).toBe(true); + expect(await response.json()).toEqual({ data: [{ id: "llama" }] }); + }); + test("direct redirects return the same credential-safe final-URL guidance", async () => { for (const key of proxyKeys) delete process.env[key]; const redirectTarget = new URL("https://final.example/v1/models?token=secret#fragment"); diff --git a/tests/provider-registry-parity.test.ts b/tests/provider-registry-parity.test.ts index d2f7d5735..897e83db0 100644 --- a/tests/provider-registry-parity.test.ts +++ b/tests/provider-registry-parity.test.ts @@ -252,6 +252,10 @@ describe("provider registry parity", () => { label: "Alibaba Coding Plan", baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1", }); + expect(PROVIDER_REGISTRY.find(entry => entry.id === "alibaba")?.baseUrlChoices).toEqual([ + { id: "intl", label: "International", baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1" }, + { id: "china", label: "China", baseUrl: "https://coding.dashscope.aliyuncs.com/v1" }, + ]); expect(KEY_LOGIN_PROVIDERS["alibaba-token-plan"]).toMatchObject({ label: "Alibaba Token Plan (Beijing)", adapter: "openai-chat", @@ -479,7 +483,7 @@ describe("provider registry parity", () => { test("base URL override permission is registry-only and limited to opted-in providers", () => { const optedIn = PROVIDER_REGISTRY.filter(entry => entry.allowBaseUrlOverride); - expect(optedIn.map(entry => entry.id)).toEqual(["ollama", "vllm", "lm-studio", "qwen-cloud", "alibaba-token-plan-intl", "litellm"]); + expect(optedIn.map(entry => entry.id)).toEqual(["ollama", "vllm", "lm-studio", "qwen-cloud", "alibaba", "alibaba-token-plan-intl", "litellm"]); for (const entry of optedIn) { expect(providerConfigSeed(entry)).not.toHaveProperty("allowBaseUrlOverride"); } diff --git a/tests/request-log.test.ts b/tests/request-log.test.ts index 2e39509df..d0bf91f63 100644 --- a/tests/request-log.test.ts +++ b/tests/request-log.test.ts @@ -544,6 +544,12 @@ describe("request log metadata", () => { expect(combined.map(entry => entry.requestId)).toEqual(["c"]); }); + test("filters logs by offset and limit", () => { + const logs = Array.from({ length: 5 }, (_, i) => log({ requestId: `r${i}`, provider: "openai", status: 200 })); + expect(filterRequestLogs(logs, new URLSearchParams("limit=2")).map(entry => entry.requestId)).toEqual(["r0", "r1"]); + expect(filterRequestLogs(logs, new URLSearchParams("offset=2&limit=2")).map(entry => entry.requestId)).toEqual(["r2", "r3"]); + }); + test("deferred JSON logging preserves response service tier before final log", async () => { const entries: RequestLogEntry[] = []; const logCtx = { @@ -1250,7 +1256,7 @@ describe("request log restart hydrate", () => { test("hydrate keeps only the newest MAX_LOG_SIZE rows from a long usage.jsonl", () => { clearRequestLogsForTests(); - const persisted: PersistedUsageEntry[] = Array.from({ length: 205 }, (_, i) => ({ + const persisted: PersistedUsageEntry[] = Array.from({ length: 2005 }, (_, i) => ({ requestId: `ocx-${i}`, timestamp: i, provider: "openai", @@ -1259,10 +1265,10 @@ describe("request log restart hydrate", () => { durationMs: 1, usageStatus: "unreported" as const, })); - expect(hydrateRequestLogsFromDisk(() => persisted)).toBe(200); + expect(hydrateRequestLogsFromDisk(() => persisted)).toBe(2000); const ids = getRequestLogEntries().map(e => e.requestId); expect(ids[0]).toBe("ocx-5"); - expect(ids.at(-1)).toBe("ocx-204"); + expect(ids.at(-1)).toBe("ocx-2004"); }); test("hydrate swallows usage.jsonl read failures instead of crashing startup", () => { diff --git a/tests/server-403-permission-e2e.test.ts b/tests/server-403-permission-e2e.test.ts index a4f0ce348..71ca9e3bb 100644 --- a/tests/server-403-permission-e2e.test.ts +++ b/tests/server-403-permission-e2e.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { logsFromApiBody } from "./helpers/logs-api"; import { managementFetch as fetch } from "./helpers/management-auth"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -83,11 +84,7 @@ async function runUpstreamFailure(status: 401 | 403, body: unknown): Promise<{ const payload = await response.json() as { error: { message?: string; type?: string; code?: string | null }; }; - const logs = await fetch(new URL("/api/logs?tail=1", proxy.url)).then(res => res.json()) as Array<{ - status?: number; - errorCode?: string; - upstreamError?: string; - }>; + const logs = logsFromApiBody(await fetch(new URL("/api/logs?tail=1", proxy.url)).then(res => res.json())); return { path, responseStatus: response.status, diff --git a/tests/server-auth.test.ts b/tests/server-auth.test.ts index 1576f6d5d..fba9c83a3 100644 --- a/tests/server-auth.test.ts +++ b/tests/server-auth.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { logsFromApiBody } from "./helpers/logs-api"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { request as httpRequest } from "node:http"; import { tmpdir } from "node:os"; @@ -1620,7 +1621,7 @@ describe("server local API auth", () => { ws.close(); expect(seenAuth).toEqual(["Bearer old-access-token", "Bearer new-access-token"]); - const logs = await fetch(new URL("/api/logs?tail=2", server.url), { headers: managementHeaders() }).then(r => r.json()) as Array<{ status: number }>; + const logs = logsFromApiBody(await fetch(new URL("/api/logs?tail=2", server.url), { headers: managementHeaders() }).then(r => r.json())); expect(logs.map(entry => entry.status)).toEqual([200, 200]); } finally { Date.now = originalNow; @@ -1692,14 +1693,7 @@ describe("server local API auth", () => { await waitForTerminal(); ws.close(); - const logs = await fetch(new URL("/api/logs?tail=1", server.url), { headers: managementHeaders() }).then(r => r.json()) as Array<{ - status: number; - terminalStatus?: string; - closeReason?: string; - usageStatus?: string; - totalTokens?: number; - usage?: { inputTokens: number; outputTokens: number; cachedInputTokens?: number }; - }>; + const logs = logsFromApiBody(await fetch(new URL("/api/logs?tail=1", server.url), { headers: managementHeaders() }).then(r => r.json())); expect(logs.at(-1)).toMatchObject({ status: 200, terminalStatus: "completed", @@ -2210,7 +2204,7 @@ describe("server local API auth", () => { consecutiveFailures: 3, lastFailureStatus: 502, }); - const logs = await fetch(new URL("/api/logs?tail=1", server.url), { headers: managementHeaders() }).then(r => r.json()) as Array<{ status: number; errorCode?: string; terminalStatus?: string; closeReason?: string }>; + const logs = logsFromApiBody(await fetch(new URL("/api/logs?tail=1", server.url), { headers: managementHeaders() }).then(r => r.json())); expect(logs.at(-1)).toMatchObject({ status: 502, errorCode: "upstream_server_error", @@ -2266,14 +2260,7 @@ describe("server local API auth", () => { expect(response.status).toBe(200); await response.text(); - const logs = await fetch(new URL("/api/logs?tail=1", server.url), { headers: managementHeaders() }).then(r => r.json()) as Array<{ - status: number; - terminalStatus?: string; - closeReason?: string; - usageStatus?: string; - totalTokens?: number; - usage?: { inputTokens: number; outputTokens: number; cachedInputTokens?: number; reasoningOutputTokens?: number }; - }>; + const logs = logsFromApiBody(await fetch(new URL("/api/logs?tail=1", server.url), { headers: managementHeaders() }).then(r => r.json())); expect(logs.at(-1)).toMatchObject({ status: 200, terminalStatus: "completed", diff --git a/tests/server-combo-failover-e2e.test.ts b/tests/server-combo-failover-e2e.test.ts index 4de483572..718b1773c 100644 --- a/tests/server-combo-failover-e2e.test.ts +++ b/tests/server-combo-failover-e2e.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, mock, setDefaultTimeout, test } from "bun:test"; +import { logsFromApiBody } from "./helpers/logs-api"; import { managementFetch as fetch, ManagementRequest as Request } from "./helpers/management-auth"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -286,7 +287,7 @@ async function postModelLogged( async function latestAttemptReceipts(config: OcxConfig) { const response = await management(config, "GET", "/api/logs?tail=1"); - const logs = await response!.json() as Array>; + const logs = logsFromApiBody(await response!.json()); const usage = readUsageEntries(); return { log: logs[0]!, usage: usage.at(-1)! }; } @@ -493,7 +494,7 @@ describe("server combo failover 030 activation matrix", () => { clearRequestLogsForTests(); expect(hydrateRequestLogsFromDisk()).toBe(1); const hydratedResponse = await management(config, "GET", "/api/logs?tail=1"); - const hydrated = await hydratedResponse!.json() as Array>; + const hydrated = logsFromApiBody(await hydratedResponse!.json()); expect(hydrated).toHaveLength(1); expectMappedReceipt(hydrated[0]!); }); diff --git a/tests/server-management-auth.test.ts b/tests/server-management-auth.test.ts index 1484eb2eb..18595d24b 100644 --- a/tests/server-management-auth.test.ts +++ b/tests/server-management-auth.test.ts @@ -10,6 +10,7 @@ import { isProxyAdmissionSecret } from "../src/server/auth-cors"; import { initializeManagementAuthState, issueGuiSession, + managementAuthAclUnverified, requireManagementAuth, } from "../src/server/management-auth"; import { @@ -21,6 +22,7 @@ import { const previousHome = process.env.OPENCODEX_HOME; const previousDataToken = process.env.OPENCODEX_API_AUTH_TOKEN; const previousAdminToken = process.env.OPENCODEX_ADMIN_AUTH_TOKEN; +const previousAllowUnverifiedAcl = process.env.OPENCODEX_ALLOW_UNVERIFIED_ADMIN_TOKEN_ACL; let testHome = ""; function remoteConfig(): OcxConfig { @@ -78,6 +80,8 @@ afterEach(() => { else process.env.OPENCODEX_API_AUTH_TOKEN = previousDataToken; if (previousAdminToken === undefined) delete process.env.OPENCODEX_ADMIN_AUTH_TOKEN; else process.env.OPENCODEX_ADMIN_AUTH_TOKEN = previousAdminToken; + if (previousAllowUnverifiedAcl === undefined) delete process.env.OPENCODEX_ALLOW_UNVERIFIED_ADMIN_TOKEN_ACL; + else process.env.OPENCODEX_ALLOW_UNVERIFIED_ADMIN_TOKEN_ACL = previousAllowUnverifiedAcl; if (testHome) rmSync(testHome, { recursive: true, force: true }); testHome = ""; }); @@ -436,4 +440,37 @@ describe("management and data-plane credential separation", () => { await server.stop(true); } }); + + test("OPENCODEX_ALLOW_UNVERIFIED_ADMIN_TOKEN_ACL soft-fails timeouts and surfaces aclUnverified", async () => { + delete process.env.OPENCODEX_ADMIN_AUTH_TOKEN; + process.env.OPENCODEX_ALLOW_UNVERIFIED_ADMIN_TOKEN_ACL = "1"; + saveConfig(remoteConfig()); + const adminToken = `ocx_admin_${"c".repeat(43)}`; + writeFileSync(join(testHome, "admin-api-token"), `${adminToken}\n`, { mode: 0o600 }); + setPlatformForTests("win32"); + setIcaclsRunnerForTests(args => { + const target = args[0] ?? ""; + if (target.endsWith("admin-api-token")) { + return { success: false, exitCode: null, timedOut: true, stdout: "" }; + } + return { success: true, exitCode: 0, timedOut: false, stdout: "" }; + }); + const state = initializeManagementAuthState(remoteConfig()); + expect(state.available).toBe(true); + if (!state.available) return; + expect(state.aclUnverified).toBe(true); + expect(managementAuthAclUnverified()).toBe(true); + + const server = startServer(0); + try { + const settings = await fetch(new URL("/api/settings", server.url), { + headers: { "x-opencodex-api-key": adminToken }, + }); + expect(settings.status).toBe(200); + const body = await settings.json() as { managementAuthAclUnverified?: boolean }; + expect(body.managementAuthAclUnverified).toBe(true); + } finally { + await server.stop(true); + } + }); }); diff --git a/tests/service.test.ts b/tests/service.test.ts index 9bdb6d0fc..63725e652 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -644,31 +644,47 @@ describe("service lifecycle cleanup ordering", () => { expect(service).toContain('code !== "EBUSY" && code !== "EPERM" && code !== "EACCES"'); }); - test("Windows service uninstall removes generated task XML", async () => { + test("Windows service uninstall verifies task deletion before removing assets", async () => { const service = await readText("src/service.ts"); const uninstallWindows = service.slice(service.indexOf("function uninstallWindows()"), service.indexOf("function serviceDiagnosticsSummary()")); + expect(uninstallWindows).toContain("probeWindowsSchedulerTask(TASK)"); expect(uninstallWindows).toContain("windowsServiceScriptPath()"); expect(uninstallWindows).toContain("windowsTaskXmlPath()"); expect(uninstallWindows).toContain("unlinkSync(windowsTaskXmlPath())"); + expect(uninstallWindows).toContain("refusing to remove service assets"); }); - test("service cleanup stops gracefully first via the shared stopper and clears the pid file", async () => { + test("service cleanup falls back to findLiveProxy and clears the pid file", async () => { const service = await readText("src/service.ts"); expect(service).toContain('import { expandUserPath, getConfigDir, readPid, removePid, removeRuntimePort } from "./config";'); expect(service).toContain("removeRuntimePort(pid);"); expect(service).toContain('import { isProcessAlive, stopProxy } from "./lib/process-control";'); + expect(service).toContain('import { findLiveProxy } from "./server/proxy-liveness";'); expect(service).toContain('type TrackedProxyCleanupResult = "none" | "stale" | "stopped";'); expect(service).toContain("async function stopTrackedProxyIfRunning(): Promise"); - expect(service).toContain('if (!pid) return "none";'); - expect(service).toContain("if (!isProcessAlive(pid))"); - expect(service).toContain('return "stale";'); + expect(service).toContain("await findLiveProxy({ timeoutMs: 1500 })"); expect(service).toContain("await stopProxy(pid);"); expect(service).toContain("removePid(pid);"); expect(service).toContain('return "stopped";'); }); + test("service stop refuses success while the proxy is still live", async () => { + const service = await readText("src/service.ts"); + const stopCase = service.slice(service.indexOf('case "stop":'), service.indexOf('case "status":')); + expect(stopCase).toContain("await findLiveProxy({ timeoutMs: 1500 })"); + expect(stopCase).toContain("Service stop did not terminate the proxy"); + expect(stopCase).toContain("process.exit(1)"); + }); + + test("native install refuses Microsoft-account logins before removing the scheduler backend", async () => { + const service = await readText("src/service.ts"); + const installNative = service.slice(service.indexOf("async function installWindowsNative()"), service.indexOf("function startWindows()")); + expect(installNative.indexOf("assertWindowsNativeServiceAccountSupported()")).toBeLessThan(installNative.indexOf("uninstallWindows()")); + expect(service).toContain("Microsoft-account Windows login"); + }); + test("service command cleanup logs kill failures without skipping restore/delete", async () => { const service = await readText("src/service.ts"); diff --git a/tests/windows-tray-run-limit.test.ts b/tests/windows-tray-run-limit.test.ts new file mode 100644 index 000000000..115d84475 --- /dev/null +++ b/tests/windows-tray-run-limit.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test"; +import { + buildWindowsTrayRunCommand, + buildWindowsTrayPowerShellCommand, +} from "../src/tray/windows"; + +describe("windows tray Run registration (#696)", () => { + test("short wscript Run command stays within the 260-character Windows limit under long paths", () => { + const longHome = "C:\\Users\\VeryLongWindowsUserNameForTestingPaths\\AppData\\Roaming\\npm\\node_modules\\@bitkyc08\\opencodex"; + const entry = { + bun: `${longHome}\\node_modules\\bun\\bin\\bun.exe`, + cli: `${longHome}\\src\\cli\\index.ts`, + script: "C:\\Users\\VeryLongWindowsUserNameForTestingPaths\\.opencodex\\opencodex-tray.ps1", + codexHome: "C:\\Users\\VeryLongWindowsUserNameForTestingPaths\\.codex", + opencodexHome: "C:\\Users\\VeryLongWindowsUserNameForTestingPaths\\.opencodex", + launcherPath: "C:\\Users\\VeryLongWindowsUserNameForTestingPaths\\.opencodex\\opencodex-tray.vbs", + }; + const runCommand = buildWindowsTrayRunCommand(entry); + expect(runCommand.length).toBeLessThanOrEqual(260); + expect(runCommand.toLowerCase()).toContain("wscript.exe"); + expect(runCommand).toContain("opencodex-tray.vbs"); + // The long PowerShell form still exists for the VBS body, but must not be the Run value. + expect(buildWindowsTrayPowerShellCommand(entry).length).toBeGreaterThan(260); + }); +}); diff --git a/tests/windows-tray.test.ts b/tests/windows-tray.test.ts index 36cfe6f9c..185dcd728 100644 --- a/tests/windows-tray.test.ts +++ b/tests/windows-tray.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { + buildWindowsTrayPowerShellCommand, buildWindowsTrayRunCommand, parseWindowsTrayRunValue, readWindowsTrayRunValueWithAsyncRunner, @@ -38,11 +39,17 @@ describe("Windows tray packaging and command safety", () => { }); test("quotes metacharacter and Unicode paths without shell interpolation", () => { - const powershellCommand = buildWindowsTrayRunCommand(entry, "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"); + const powershellCommand = buildWindowsTrayPowerShellCommand(entry, "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"); expect(powershellCommand).toContain(`-File "${entry.script}"`); expect(powershellCommand).toContain(`-OpenCodexHome "${entry.opencodexHome}"`); expect(powershellCommand).not.toContain("cmd /c"); expect(powershellCommand).not.toContain("-Command"); + const runCommand = buildWindowsTrayRunCommand({ + ...entry, + launcherPath: `${entry.opencodexHome}\\opencodex-tray.vbs`, + }); + expect(runCommand.toLowerCase()).toContain("wscript.exe"); + expect(runCommand.length).toBeLessThanOrEqual(260); }); test("rejects quote and control-character path injection", () => { diff --git a/tests/winsw.test.ts b/tests/winsw.test.ts index 2f728a8b8..8c922d095 100644 --- a/tests/winsw.test.ts +++ b/tests/winsw.test.ts @@ -165,6 +165,13 @@ describe("winsw install flow", () => { expect(calls).toEqual([["interactive", "install", "/p"], ["verify"], ["run", "start"]]); }); + test("install /p refuses non-interactive stdin instead of hanging", () => { + const winsw = readFileSync(new URL("../src/lib/winsw.ts", import.meta.url), "utf8"); + const fn = winsw.slice(winsw.indexOf("function runWinswInteractive"), winsw.indexOf("function scQc()")); + expect(fn).toContain("process.stdin.isTTY"); + expect(fn).toContain("interactive console"); + }); + test("repair over an existing service rewrites assets and restarts without re-prompting", async () => { const calls: string[][] = []; await installWinswService(entry, {