From 304b99d0baaf3727466af80088000d7a27abd700 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Tue, 14 Apr 2026 03:18:43 -0500 Subject: [PATCH 1/2] restore Codex backend provider catalog --- apps/server/package.json | 1 + .../provider/Layers/ProviderHealth.test.ts | 205 +- .../src/provider/Layers/ProviderHealth.ts | 86 +- apps/server/src/provider/codexConfig.test.ts | 161 + apps/server/src/provider/codexConfig.ts | 205 + apps/server/src/sme/authValidation.test.ts | 77 + apps/server/src/sme/authValidation.ts | 53 +- apps/server/src/wsServer.test.ts | 28 + apps/server/src/wsServer.ts | 3 + apps/web/src/appSettings.ts | 30 +- apps/web/src/components/ChatView.browser.tsx | 5 + .../components/KeybindingsToast.browser.tsx | 5 + .../settings/CodexBackendSection.test.tsx | 30 + .../settings/CodexBackendSection.tsx | 84 + .../settings/SettingsRouteContext.tsx | 5 + .../src/lib/claudeAuthTokenHelperPresets.ts | 23 + apps/web/src/lib/codexBackendCatalog.test.ts | 79 + apps/web/src/lib/codexBackendCatalog.ts | 149 + apps/web/src/lib/providerAvailability.ts | 12 + apps/web/src/lib/settingsProviderMetadata.tsx | 107 + .../routes/-_chat.settings.route.browser.tsx | 74 + apps/web/src/routes/_chat.settings.index.tsx | 244 +- apps/web/src/routes/_chat.settings.tsx | 3698 +---------------- bun.lock | 1 + packages/contracts/src/codexConfig.ts | 20 + packages/contracts/src/index.ts | 1 + packages/contracts/src/server.ts | 2 + packages/shared/package.json | 4 + .../shared/src/codexModelProviders.test.ts | 54 + packages/shared/src/codexModelProviders.ts | 132 + 30 files changed, 1472 insertions(+), 4106 deletions(-) create mode 100644 apps/server/src/provider/codexConfig.test.ts create mode 100644 apps/server/src/provider/codexConfig.ts create mode 100644 apps/server/src/sme/authValidation.test.ts create mode 100644 apps/web/src/components/settings/CodexBackendSection.test.tsx create mode 100644 apps/web/src/components/settings/CodexBackendSection.tsx create mode 100644 apps/web/src/lib/claudeAuthTokenHelperPresets.ts create mode 100644 apps/web/src/lib/codexBackendCatalog.test.ts create mode 100644 apps/web/src/lib/codexBackendCatalog.ts create mode 100644 apps/web/src/lib/settingsProviderMetadata.tsx create mode 100644 apps/web/src/routes/-_chat.settings.route.browser.tsx create mode 100644 packages/contracts/src/codexConfig.ts create mode 100644 packages/shared/src/codexModelProviders.test.ts create mode 100644 packages/shared/src/codexModelProviders.ts diff --git a/apps/server/package.json b/apps/server/package.json index 010b2f7b..112f6d9e 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -31,6 +31,7 @@ "effect": "catalog:", "node-pty": "^1.1.0", "open": "^10.1.0", + "toml": "^3.0.0", "ws": "^8.18.0", "yaml": "^2.8.1" }, diff --git a/apps/server/src/provider/Layers/ProviderHealth.test.ts b/apps/server/src/provider/Layers/ProviderHealth.test.ts index a617f8b2..7cbb51c5 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.test.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.test.ts @@ -7,10 +7,8 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { checkClaudeProviderStatus, checkCodexProviderStatus, - hasCustomModelProvider, parseAuthStatusFromOutput, parseClaudeAuthStatusFromOutput, - readCodexConfigModelProvider, } from "./ProviderHealth"; // ── Test helpers ──────────────────────────────────────────────────── @@ -237,53 +235,44 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { ); }); - // ── Custom model provider: checkCodexProviderStatus integration ─── - - describe("checkCodexProviderStatus with custom model provider", () => { - it.effect("skips auth probe and returns ready when a custom model provider is configured", () => - Effect.gen(function* () { - yield* withTempCodexHome( - [ - 'model_provider = "portkey"', - "", - "[model_providers.portkey]", - 'base_url = "https://api.portkey.ai/v1"', - 'env_key = "PORTKEY_API_KEY"', - ].join("\n"), - ); - const status = yield* checkCodexProviderStatus; - assert.strictEqual(status.provider, "codex"); - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.available, true); - assert.strictEqual(status.authStatus, "unknown"); - assert.strictEqual( - status.message, - "Using a custom Codex model provider; OpenAI login check skipped.", - ); - }).pipe( - Effect.provide( - // The spawner only handles --version; if the test attempts - // "login status" the throw proves the auth probe was NOT skipped. - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; - throw new Error(`Auth probe should have been skipped but got args: ${joined}`); - }), + // ── Codex backend auth probing ──────────────────────────────────── + + describe("checkCodexProviderStatus backend auth probing", () => { + const skippedBackends = [ + { id: "ollama", label: "built-in ollama" }, + { id: "lmstudio", label: "built-in lmstudio" }, + { id: "portkey", label: "curated portkey" }, + { id: "azure", label: "curated azure" }, + ] as const; + + for (const backend of skippedBackends) { + it.effect(`skips the OpenAI login probe for ${backend.label}`, () => + Effect.gen(function* () { + yield* withTempCodexHome(`model_provider = "${backend.id}"\n`); + const status = yield* checkCodexProviderStatus; + assert.strictEqual(status.provider, "codex"); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.available, true); + assert.strictEqual(status.authStatus, "unknown"); + assert.strictEqual( + status.message, + `Codex is configured to use backend '${backend.id}'; OpenAI login check skipped.`, + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Auth probe should have been skipped but got args: ${joined}`); + }), + ), ), - ), - ); + ); + } - it.effect("still reports error when codex CLI is missing even with custom provider", () => + it.effect("still reports error when codex CLI is missing even with a non-OpenAI backend", () => Effect.gen(function* () { - yield* withTempCodexHome( - [ - 'model_provider = "portkey"', - "", - "[model_providers.portkey]", - 'base_url = "https://api.portkey.ai/v1"', - 'env_key = "PORTKEY_API_KEY"', - ].join("\n"), - ); + yield* withTempCodexHome('model_provider = "portkey"\n'); const status = yield* checkCodexProviderStatus; assert.strictEqual(status.status, "error"); assert.strictEqual(status.available, false); @@ -343,130 +332,6 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { }); }); - // ── readCodexConfigModelProvider tests ───────────────────────────── - - describe("readCodexConfigModelProvider", () => { - it.effect("returns undefined when config file does not exist", () => - Effect.gen(function* () { - yield* withTempCodexHome(); - assert.strictEqual(yield* readCodexConfigModelProvider, undefined); - }), - ); - - it.effect("returns undefined when config has no model_provider key", () => - Effect.gen(function* () { - yield* withTempCodexHome('model = "gpt-5-codex"\n'); - assert.strictEqual(yield* readCodexConfigModelProvider, undefined); - }), - ); - - it.effect("returns the provider when model_provider is set at top level", () => - Effect.gen(function* () { - yield* withTempCodexHome('model = "gpt-5-codex"\nmodel_provider = "portkey"\n'); - assert.strictEqual(yield* readCodexConfigModelProvider, "portkey"); - }), - ); - - it.effect("returns openai when model_provider is openai", () => - Effect.gen(function* () { - yield* withTempCodexHome('model_provider = "openai"\n'); - assert.strictEqual(yield* readCodexConfigModelProvider, "openai"); - }), - ); - - it.effect("ignores model_provider inside section headers", () => - Effect.gen(function* () { - yield* withTempCodexHome( - [ - 'model = "gpt-5-codex"', - "", - "[model_providers.portkey]", - 'base_url = "https://api.portkey.ai/v1"', - 'model_provider = "should-be-ignored"', - "", - ].join("\n"), - ); - assert.strictEqual(yield* readCodexConfigModelProvider, undefined); - }), - ); - - it.effect("handles comments and whitespace", () => - Effect.gen(function* () { - yield* withTempCodexHome( - [ - "# This is a comment", - "", - ' model_provider = "azure" ', - "", - "[profiles.deep-review]", - 'model = "gpt-5-pro"', - ].join("\n"), - ); - assert.strictEqual(yield* readCodexConfigModelProvider, "azure"); - }), - ); - - it.effect("handles single-quoted values in TOML", () => - Effect.gen(function* () { - yield* withTempCodexHome("model_provider = 'mistral'\n"); - assert.strictEqual(yield* readCodexConfigModelProvider, "mistral"); - }), - ); - }); - - // ── hasCustomModelProvider tests ─────────────────────────────────── - - describe("hasCustomModelProvider", () => { - it.effect("returns false when no config file exists", () => - Effect.gen(function* () { - yield* withTempCodexHome(); - assert.strictEqual(yield* hasCustomModelProvider, false); - }), - ); - - it.effect("returns false when model_provider is not set", () => - Effect.gen(function* () { - yield* withTempCodexHome('model = "gpt-5-codex"\n'); - assert.strictEqual(yield* hasCustomModelProvider, false); - }), - ); - - it.effect("returns false when model_provider is openai", () => - Effect.gen(function* () { - yield* withTempCodexHome('model_provider = "openai"\n'); - assert.strictEqual(yield* hasCustomModelProvider, false); - }), - ); - - it.effect("returns true when model_provider is portkey", () => - Effect.gen(function* () { - yield* withTempCodexHome('model_provider = "portkey"\n'); - assert.strictEqual(yield* hasCustomModelProvider, true); - }), - ); - - it.effect("returns true when model_provider is azure", () => - Effect.gen(function* () { - yield* withTempCodexHome('model_provider = "azure"\n'); - assert.strictEqual(yield* hasCustomModelProvider, true); - }), - ); - - it.effect("returns true when model_provider is ollama", () => - Effect.gen(function* () { - yield* withTempCodexHome('model_provider = "ollama"\n'); - assert.strictEqual(yield* hasCustomModelProvider, true); - }), - ); - - it.effect("returns true when model_provider is a custom proxy", () => - Effect.gen(function* () { - yield* withTempCodexHome('model_provider = "my-company-proxy"\n'); - assert.strictEqual(yield* hasCustomModelProvider, true); - }), - ); - }); - // ── checkClaudeProviderStatus tests ────────────────────────── describe("checkClaudeProviderStatus", () => { diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index 97f3861b..cc4511cf 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -7,7 +7,6 @@ * * @module ProviderHealthLive */ -import * as OS from "node:os"; import { CopilotClient } from "@github/copilot-sdk"; import type { ServerProvider, @@ -15,7 +14,7 @@ import type { ServerProviderStatus, ServerProviderStatusState, } from "@okcode/contracts"; -import { Array, Data, Effect, FileSystem, Layer, Option, Path, Result, Stream } from "effect"; +import { Array, Data, Effect, FileSystem, Layer, Option, Result, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { serverBuildInfo } from "../../buildInfo.ts"; @@ -26,6 +25,7 @@ import { isCodexCliVersionSupported, parseCodexCliVersion, } from "../codexCliVersion"; +import { readCodexConfigSummary, usesOpenAiLoginForSelectedCodexBackend } from "../codexConfig"; import { withServerProviderModels } from "../providerCatalog.ts"; import { ProviderHealth, type ProviderHealthShape } from "../Services/ProviderHealth"; @@ -243,72 +243,6 @@ export function parseAuthStatusFromOutput(result: CommandResult): { }; } -// ── Codex CLI config detection ────────────────────────────────────── - -/** - * Providers that use OpenAI-native authentication via `codex login`. - * When the configured `model_provider` is one of these, the `codex login - * status` probe still runs. For any other provider value the auth probe - * is skipped because authentication is handled externally (e.g. via - * environment variables like `PORTKEY_API_KEY` or `AZURE_API_KEY`). - */ -const OPENAI_AUTH_PROVIDERS = new Set(["openai"]); - -/** - * Read the `model_provider` value from the Codex CLI config file. - * - * Looks for the file at `$CODEX_HOME/config.toml` (falls back to - * `~/.codex/config.toml`). Uses a simple line-by-line scan rather than - * a full TOML parser to avoid adding a dependency for a single key. - * - * Returns `undefined` when the file does not exist or does not set - * `model_provider`. - */ -export const readCodexConfigModelProvider = Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const codexHome = process.env.CODEX_HOME || path.join(OS.homedir(), ".codex"); - const configPath = path.join(codexHome, "config.toml"); - - const content = yield* fileSystem - .readFileString(configPath) - .pipe(Effect.orElseSucceed(() => undefined)); - if (content === undefined) { - return undefined; - } - - // We need to find `model_provider = "..."` at the top level of the - // TOML file (i.e. before any `[section]` header). Lines inside - // `[profiles.*]`, `[model_providers.*]`, etc. are ignored. - let inTopLevel = true; - for (const line of content.split("\n")) { - const trimmed = line.trim(); - // Skip comments and empty lines. - if (!trimmed || trimmed.startsWith("#")) continue; - // Detect section headers — once we leave the top level, stop. - if (trimmed.startsWith("[")) { - inTopLevel = false; - continue; - } - if (!inTopLevel) continue; - - const match = trimmed.match(/^model_provider\s*=\s*["']([^"']+)["']/); - if (match) return match[1]; - } - return undefined; -}); - -/** - * Returns `true` when the Codex CLI is configured with a custom - * (non-OpenAI) model provider, meaning `codex login` auth is not - * required because authentication is handled through provider-specific - * environment variables. - */ -export const hasCustomModelProvider = Effect.map( - readCodexConfigModelProvider, - (provider) => provider !== undefined && !OPENAI_AUTH_PROVIDERS.has(provider), -); - // ── Effect-native command execution ───────────────────────────────── const collectStreamAsString = (stream: Stream.Stream): Effect.Effect => @@ -496,7 +430,7 @@ export const checkCopilotProviderStatus: Effect.Effect = Effect.gen(function* () { const checkedAt = new Date().toISOString(); @@ -566,13 +500,13 @@ export const checkCodexProviderStatus: Effect.Effect< }); } + const codexConfig = yield* readCodexConfigSummary(); + // Probe 2: `codex login status` — is the user authenticated? // - // Custom model providers (e.g. Portkey, Azure OpenAI proxy) handle - // authentication through their own environment variables, so `codex - // login status` will report "not logged in" even when the CLI works - // fine. Skip the auth probe entirely for non-OpenAI providers. - if (yield* hasCustomModelProvider) { + // Non-OpenAI backends handle authentication externally, so `codex + // login status` is only meaningful for the default OpenAI backend. + if (!usesOpenAiLoginForSelectedCodexBackend(codexConfig)) { return createServerProviderStatus({ provider: CODEX_PROVIDER, enabled: true, @@ -581,7 +515,7 @@ export const checkCodexProviderStatus: Effect.Effect< status: "ready" as const, auth: { status: "unknown" as const }, checkedAt, - message: "Using a custom Codex model provider; OpenAI login check skipped.", + message: `Codex is configured to use backend '${codexConfig.selectedModelProviderId}'; OpenAI login check skipped.`, }); } @@ -1100,7 +1034,6 @@ export const ProviderHealthLive = Layer.effect( ProviderHealth, Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const openclawGatewayConfig = yield* OpenclawGatewayConfig; @@ -1118,7 +1051,6 @@ export const ProviderHealthLive = Layer.effect( }, ).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(OpenclawGatewayConfig, openclawGatewayConfig), ), diff --git a/apps/server/src/provider/codexConfig.test.ts b/apps/server/src/provider/codexConfig.test.ts new file mode 100644 index 00000000..a8d9baec --- /dev/null +++ b/apps/server/src/provider/codexConfig.test.ts @@ -0,0 +1,161 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { readCodexConfigSummaryFromFile } from "./codexConfig"; + +const tempHomes: string[] = []; + +async function makeCodexHome(configContent?: string): Promise { + const homePath = await mkdtemp(join(tmpdir(), "okcode-codex-config-")); + tempHomes.push(homePath); + if (configContent !== undefined) { + await writeFile(join(homePath, "config.toml"), configContent, "utf-8"); + } + return homePath; +} + +afterEach(async () => { + await Promise.all( + tempHomes.splice(0).map((homePath) => rm(homePath, { recursive: true, force: true })), + ); +}); + +describe("readCodexConfigSummaryFromFile", () => { + it("returns an empty summary when the config file does not exist", async () => { + const homePath = await makeCodexHome(); + + await expect(readCodexConfigSummaryFromFile({ homePath })).resolves.toEqual({ + selectedModelProviderId: null, + entries: [], + parseError: null, + }); + }); + + it("reads a top-level openai model_provider", async () => { + const homePath = await makeCodexHome('model_provider = "openai"\n'); + + await expect(readCodexConfigSummaryFromFile({ homePath })).resolves.toEqual({ + selectedModelProviderId: "openai", + entries: [ + { + id: "openai", + selected: true, + definedInConfig: true, + isBuiltIn: true, + isKnownPreset: true, + requiresOpenAiLogin: true, + }, + ], + parseError: null, + }); + }); + + it("reads a top-level ollama model_provider", async () => { + const homePath = await makeCodexHome('model_provider = "ollama"\n'); + + await expect(readCodexConfigSummaryFromFile({ homePath })).resolves.toEqual({ + selectedModelProviderId: "ollama", + entries: [ + { + id: "ollama", + selected: true, + definedInConfig: true, + isBuiltIn: true, + isKnownPreset: true, + requiresOpenAiLogin: false, + }, + ], + parseError: null, + }); + }); + + it("reads a curated backend selected at top level with a matching model_providers entry", async () => { + const homePath = await makeCodexHome( + [ + 'model_provider = "openrouter"', + "", + "[model_providers.openrouter]", + 'name = "OpenRouter"', + ].join("\n"), + ); + + await expect(readCodexConfigSummaryFromFile({ homePath })).resolves.toEqual({ + selectedModelProviderId: "openrouter", + entries: [ + { + id: "openrouter", + selected: true, + definedInConfig: true, + isBuiltIn: false, + isKnownPreset: true, + requiresOpenAiLogin: false, + }, + ], + parseError: null, + }); + }); + + it("preserves unknown custom provider ids exactly as configured", async () => { + const homePath = await makeCodexHome( + [ + 'model_provider = "my-company-proxy"', + "", + '[model_providers."my-company-proxy"]', + 'name = "Internal proxy"', + ].join("\n"), + ); + + await expect(readCodexConfigSummaryFromFile({ homePath })).resolves.toEqual({ + selectedModelProviderId: "my-company-proxy", + entries: [ + { + id: "my-company-proxy", + selected: true, + definedInConfig: true, + isBuiltIn: false, + isKnownPreset: false, + requiresOpenAiLogin: false, + }, + ], + parseError: null, + }); + }); + + it("does not mistake section-local model_provider keys for the active top-level backend", async () => { + const homePath = await makeCodexHome( + ["[profiles.deep-review]", 'model_provider = "openrouter"'].join("\n"), + ); + + await expect(readCodexConfigSummaryFromFile({ homePath })).resolves.toEqual({ + selectedModelProviderId: null, + entries: [], + parseError: null, + }); + }); + + it("falls back to the scanned top-level provider when TOML is malformed", async () => { + const homePath = await makeCodexHome( + ['model_provider = "azure"', "", "[model_providers.azure", 'name = "Azure OpenAI"'].join( + "\n", + ), + ); + + const summary = await readCodexConfigSummaryFromFile({ homePath }); + + expect(summary.selectedModelProviderId).toBe("azure"); + expect(summary.entries).toEqual([ + { + id: "azure", + selected: true, + definedInConfig: true, + isBuiltIn: false, + isKnownPreset: true, + requiresOpenAiLogin: false, + }, + ]); + expect(summary.parseError).toEqual(expect.any(String)); + }); +}); diff --git a/apps/server/src/provider/codexConfig.ts b/apps/server/src/provider/codexConfig.ts new file mode 100644 index 00000000..eb184be0 --- /dev/null +++ b/apps/server/src/provider/codexConfig.ts @@ -0,0 +1,205 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { readFile } from "node:fs/promises"; + +import type { ServerCodexConfigSummary, ServerCodexModelProviderEntry } from "@okcode/contracts"; +import { + getCodexModelProviderPreset, + isCodexBuiltInModelProvider, + requiresOpenAiLoginForCodexModelProvider, +} from "@okcode/shared/codexModelProviders"; +import { Effect, FileSystem, Result } from "effect"; +import { parse as parseToml } from "toml"; + +export interface CodexConfigReadOptions { + readonly homePath?: string | null | undefined; + readonly env?: NodeJS.ProcessEnv | undefined; +} + +function emptyCodexConfigSummary(): ServerCodexConfigSummary { + return { + selectedModelProviderId: null, + entries: [], + parseError: null, + }; +} + +function trimToNull(value: string | null | undefined): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function createSummaryEntry(input: { + readonly id: string; + readonly selected: boolean; + readonly definedInConfig: boolean; +}): ServerCodexModelProviderEntry { + return { + id: input.id, + selected: input.selected, + definedInConfig: input.definedInConfig, + isBuiltIn: isCodexBuiltInModelProvider(input.id), + isKnownPreset: getCodexModelProviderPreset(input.id) !== undefined, + requiresOpenAiLogin: requiresOpenAiLoginForCodexModelProvider(input.id), + }; +} + +function getSelectedModelProviderId(parsed: Record): string | null { + return trimToNull(parsed["model_provider"] as string | null | undefined); +} + +function getDefinedModelProviderIds(parsed: Record): string[] { + const providers = parsed["model_providers"]; + if (!providers || typeof providers !== "object" || Array.isArray(providers)) { + return []; + } + + const ids: string[] = []; + for (const key of Object.keys(providers)) { + const trimmed = trimToNull(key); + if (trimmed !== null) { + ids.push(trimmed); + } + } + return ids; +} + +function getParseErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isMissingFileError(error: unknown): boolean { + if (!error || typeof error !== "object" || !("code" in error)) { + return false; + } + return (error as { code?: unknown }).code === "ENOENT"; +} + +export function resolveCodexHomePath(options: CodexConfigReadOptions = {}): string { + const env = options.env ?? process.env; + return trimToNull(options.homePath) ?? trimToNull(env.CODEX_HOME) ?? join(homedir(), ".codex"); +} + +export function resolveCodexConfigPath(options: CodexConfigReadOptions = {}): string { + return join(resolveCodexHomePath(options), "config.toml"); +} + +export function fallbackScanTopLevelModelProvider(content: string): string | null { + let inTopLevel = true; + + for (const line of content.split(/\r?\n/u)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) { + continue; + } + if (trimmed.startsWith("[")) { + inTopLevel = false; + continue; + } + if (!inTopLevel) { + continue; + } + const match = trimmed.match(/^model_provider\s*=\s*["']([^"']+)["']/u); + if (match?.[1]) { + return match[1]; + } + } + + return null; +} + +export function summarizeCodexConfigToml(content: string): ServerCodexConfigSummary { + try { + const parsed = parseToml(content) as Record; + const selectedModelProviderId = getSelectedModelProviderId(parsed); + const definedModelProviderIds = getDefinedModelProviderIds(parsed); + const definedModelProviderIdSet = new Set(definedModelProviderIds); + + const entryIds: string[] = []; + if (selectedModelProviderId !== null) { + entryIds.push(selectedModelProviderId); + } + for (const id of definedModelProviderIds) { + if (!entryIds.includes(id)) { + entryIds.push(id); + } + } + + return { + selectedModelProviderId, + entries: entryIds.map((id) => + createSummaryEntry({ + id, + selected: selectedModelProviderId === id, + definedInConfig: definedModelProviderIdSet.has(id) || selectedModelProviderId === id, + }), + ), + parseError: null, + }; + } catch (error) { + const selectedModelProviderId = fallbackScanTopLevelModelProvider(content); + return { + selectedModelProviderId, + entries: + selectedModelProviderId === null + ? [] + : [ + createSummaryEntry({ + id: selectedModelProviderId, + selected: true, + definedInConfig: true, + }), + ], + parseError: getParseErrorMessage(error), + }; + } +} + +export async function readCodexConfigSummaryFromFile( + options: CodexConfigReadOptions = {}, +): Promise { + const configPath = resolveCodexConfigPath(options); + + try { + const content = await readFile(configPath, "utf-8"); + return summarizeCodexConfigToml(content); + } catch (error) { + if (isMissingFileError(error)) { + return emptyCodexConfigSummary(); + } + return { + ...emptyCodexConfigSummary(), + parseError: getParseErrorMessage(error), + }; + } +} + +export const readCodexConfigSummary = (options: CodexConfigReadOptions = {}) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const configPath = resolveCodexConfigPath(options); + const exists = yield* fileSystem.exists(configPath).pipe(Effect.orElseSucceed(() => false)); + if (!exists) { + return emptyCodexConfigSummary(); + } + + const content = yield* fileSystem.readFileString(configPath).pipe(Effect.result); + if (Result.isFailure(content)) { + return { + ...emptyCodexConfigSummary(), + parseError: getParseErrorMessage(content.failure), + }; + } + + return summarizeCodexConfigToml(content.success); + }); + +export function usesOpenAiLoginForSelectedCodexBackend(summary: ServerCodexConfigSummary): boolean { + return ( + summary.selectedModelProviderId === null || + requiresOpenAiLoginForCodexModelProvider(summary.selectedModelProviderId) + ); +} diff --git a/apps/server/src/sme/authValidation.test.ts b/apps/server/src/sme/authValidation.test.ts new file mode 100644 index 00000000..45a9b579 --- /dev/null +++ b/apps/server/src/sme/authValidation.test.ts @@ -0,0 +1,77 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { validateCodexSetup } from "./authValidation"; + +const tempHomes: string[] = []; + +async function makeCodexHome(configContent?: string): Promise { + const homePath = await mkdtemp(join(tmpdir(), "okcode-sme-codex-")); + tempHomes.push(homePath); + if (configContent !== undefined) { + await writeFile(join(homePath, "config.toml"), configContent, "utf-8"); + } + return homePath; +} + +afterEach(async () => { + await Promise.all( + tempHomes.splice(0).map((homePath) => rm(homePath, { recursive: true, force: true })), + ); +}); + +describe("validateCodexSetup", () => { + it("accepts custom-provider mode when a non-OpenAI backend is configured", async () => { + const homePath = await makeCodexHome('model_provider = "portkey"\n'); + + await expect( + validateCodexSetup({ + authMethod: "customProvider", + providerOptions: { codex: { homePath } }, + }), + ).resolves.toEqual({ + ok: true, + severity: "ready", + message: "Codex is configured to use non-OpenAI backend 'portkey'.", + resolvedAuthMethod: "customProvider", + resolvedAccountType: "unknown", + }); + }); + + it("rejects custom-provider mode when Codex falls back to the implicit OpenAI backend", async () => { + const homePath = await makeCodexHome(); + + await expect( + validateCodexSetup({ + authMethod: "customProvider", + providerOptions: { codex: { homePath } }, + }), + ).resolves.toEqual({ + ok: false, + severity: "error", + message: + "Codex custom provider mode requires a non-OpenAI backend configured via `model_provider` in the Codex config.", + resolvedAuthMethod: "customProvider", + }); + }); + + it("uses neutral non-OpenAI backend wording when auto mode resolves away from OpenAI", async () => { + const homePath = await makeCodexHome('model_provider = "azure"\n'); + + await expect( + validateCodexSetup({ + authMethod: "auto", + providerOptions: { codex: { homePath } }, + }), + ).resolves.toEqual({ + ok: true, + severity: "ready", + message: "Codex auto mode resolved to non-OpenAI backend 'azure'.", + resolvedAuthMethod: "customProvider", + resolvedAccountType: "unknown", + }); + }); +}); diff --git a/apps/server/src/sme/authValidation.ts b/apps/server/src/sme/authValidation.ts index 2afa8ad8..5e517cd3 100644 --- a/apps/server/src/sme/authValidation.ts +++ b/apps/server/src/sme/authValidation.ts @@ -7,19 +7,18 @@ import { } from "@okcode/contracts"; import { resolveAnthropicClientOptions } from "./backends/anthropic.ts"; -import { homedir } from "node:os"; -import { join } from "node:path"; import { createInterface } from "node:readline"; import { spawn } from "node:child_process"; -import { readFile } from "node:fs/promises"; import { buildCodexInitializeParams, readCodexAccountSnapshot, type CodexAppServerStartSessionInput, } from "../codexAppServerManager.ts"; - -const OPENAI_MODEL_PROVIDERS = new Set(["openai"]); +import { + readCodexConfigSummaryFromFile, + usesOpenAiLoginForSelectedCodexBackend, +} from "../provider/codexConfig.ts"; const CLAUDE_SME_MISSING_CREDENTIALS_MESSAGE = "Claude SME Chat uses direct Anthropic credentials, not the Claude CLI login. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN."; @@ -189,31 +188,6 @@ export function validateAnthropicSetup(input: { export const validateClaudeSetup = validateAnthropicSetup; -async function readCodexConfigModelProvider( - providerOptions?: CodexAppServerStartSessionInput["providerOptions"], -): Promise { - const homePath = - providerOptions?.codex?.homePath?.trim() || process.env.CODEX_HOME || join(homedir(), ".codex"); - try { - const content = await readFile(join(homePath, "config.toml"), "utf-8"); - let inTopLevel = true; - for (const line of content.split("\n")) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - if (trimmed.startsWith("[")) { - inTopLevel = false; - continue; - } - if (!inTopLevel) continue; - const match = trimmed.match(/^model_provider\s*=\s*["']([^"']+)["']/); - if (match) return match[1]; - } - return undefined; - } catch { - return undefined; - } -} - async function readCodexAccountType( providerOptions?: CodexAppServerStartSessionInput["providerOptions"], ): Promise<"apiKey" | "chatgpt" | "unknown"> { @@ -327,34 +301,37 @@ export async function validateCodexSetup(input: { readonly authMethod: Extract; readonly providerOptions?: CodexAppServerStartSessionInput["providerOptions"]; }): Promise { - const modelProvider = await readCodexConfigModelProvider(input.providerOptions); - const customProviderConfigured = - modelProvider !== undefined && !OPENAI_MODEL_PROVIDERS.has(modelProvider); + const codexConfig = await readCodexConfigSummaryFromFile({ + homePath: input.providerOptions?.codex?.homePath, + }); + const modelProvider = codexConfig.selectedModelProviderId; + const nonOpenAiBackendConfigured = + modelProvider !== null && !usesOpenAiLoginForSelectedCodexBackend(codexConfig); if (input.authMethod === "customProvider") { - if (!customProviderConfigured) { + if (!nonOpenAiBackendConfigured) { return { ok: false, severity: "error", message: - "Codex custom provider mode requires a non-OpenAI `model_provider` in the Codex config.", + "Codex custom provider mode requires a non-OpenAI backend configured via `model_provider` in the Codex config.", resolvedAuthMethod: "customProvider", }; } return { ok: true, severity: "ready", - message: `Codex is configured to use custom model provider '${modelProvider}'.`, + message: `Codex is configured to use non-OpenAI backend '${modelProvider}'.`, resolvedAuthMethod: "customProvider", resolvedAccountType: "unknown", }; } - if (input.authMethod === "auto" && customProviderConfigured) { + if (input.authMethod === "auto" && nonOpenAiBackendConfigured) { return { ok: true, severity: "ready", - message: `Codex auto mode resolved to custom provider '${modelProvider}'.`, + message: `Codex auto mode resolved to non-OpenAI backend '${modelProvider}'.`, resolvedAuthMethod: "customProvider", resolvedAccountType: "unknown", }; diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 8f3d6f6f..dba317d4 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -91,6 +91,12 @@ const defaultProviderHealthService: ProviderHealthShape = { getStatuses: Effect.succeed(defaultProviderStatuses), }; +const defaultCodexConfigSummary = { + selectedModelProviderId: null, + entries: [], + parseError: null, +} as const; + const expectedServerBuildInfo = expect.objectContaining({ surface: "server", version: serverBuildInfo.version, @@ -489,6 +495,7 @@ function makeWorkspaceFixture(name: string): { baseDir: string; cwd: string } { describe("WebSocket Server", () => { let server: Http.Server | null = null; let serverScope: Scope.Closeable | null = null; + let originalCodexHome: string | undefined; const connections: WebSocket[] = []; const tempDirs: string[] = []; @@ -526,6 +533,10 @@ describe("WebSocket Server", () => { } const baseDir = options.baseDir ?? makeTempDir("okcode-ws-base-"); + originalCodexHome = process.env.CODEX_HOME; + const codexHome = path.join(baseDir, ".codex"); + fs.mkdirSync(codexHome, { recursive: true }); + process.env.CODEX_HOME = codexHome; const devUrl = options.devUrl ? new URL(options.devUrl) : undefined; const derivedPaths = deriveServerPathsSync(baseDir, devUrl); const cwd = options.cwd ?? path.join(baseDir, "project"); @@ -603,6 +614,12 @@ describe("WebSocket Server", () => { return runtime; } catch (error) { await Effect.runPromise(Scope.close(scope, Exit.void)); + if (originalCodexHome !== undefined) { + process.env.CODEX_HOME = originalCodexHome; + } else { + delete process.env.CODEX_HOME; + } + originalCodexHome = undefined; throw error; } } @@ -612,6 +629,12 @@ describe("WebSocket Server", () => { const scope = serverScope; serverScope = null; await Effect.runPromise(Scope.close(scope, Exit.void)); + if (originalCodexHome !== undefined) { + process.env.CODEX_HOME = originalCodexHome; + } else { + delete process.env.CODEX_HOME; + } + originalCodexHome = undefined; } afterEach(async () => { @@ -896,6 +919,7 @@ describe("WebSocket Server", () => { keybindings: DEFAULT_RESOLVED_KEYBINDINGS, issues: [], providers: defaultProviderStatuses, + codexConfig: defaultCodexConfigSummary, availableEditors: expect.any(Array), buildInfo: expectedServerBuildInfo, }); @@ -979,6 +1003,7 @@ describe("WebSocket Server", () => { keybindings: DEFAULT_RESOLVED_KEYBINDINGS, issues: [], providers: defaultProviderStatuses, + codexConfig: defaultCodexConfigSummary, availableEditors: expect.any(Array), buildInfo: expectedServerBuildInfo, }); @@ -1017,6 +1042,7 @@ describe("WebSocket Server", () => { }, ], providers: defaultProviderStatuses, + codexConfig: defaultCodexConfigSummary, availableEditors: expect.any(Array), buildInfo: expectedServerBuildInfo, }); @@ -1290,6 +1316,7 @@ describe("WebSocket Server", () => { keybindings: compileKeybindings(persistedConfig), issues: [], providers: defaultProviderStatuses, + codexConfig: defaultCodexConfigSummary, availableEditors: expect.any(Array), buildInfo: expectedServerBuildInfo, }); @@ -1340,6 +1367,7 @@ describe("WebSocket Server", () => { keybindings: compileKeybindings(persistedConfig), issues: [], providers: defaultProviderStatuses, + codexConfig: defaultCodexConfigSummary, availableEditors: expect.any(Array), buildInfo: expectedServerBuildInfo, }); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 20a7483d..095318a9 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -100,6 +100,7 @@ import { SkillService } from "./skills/SkillService.ts"; import { SmeChatService } from "./sme/Services/SmeChatService.ts"; import { TokenManager } from "./tokenManager.ts"; import { resolveRuntimeEnvironment, RuntimeEnv } from "./runtimeEnvironment.ts"; +import { readCodexConfigSummary } from "./provider/codexConfig"; import { TerminalRuntimeEnvResolver } from "./terminal/Services/RuntimeEnvResolver.ts"; import { version as serverVersion } from "../package.json" with { type: "json" }; import { serverBuildInfo } from "./buildInfo"; @@ -1601,12 +1602,14 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< case WS_METHODS.serverGetConfig: const keybindingsConfig = yield* keybindingsManager.loadConfigState; const providers = yield* getProviderStatuses(); + const codexConfig = yield* readCodexConfigSummary(); return { cwd, keybindingsConfigPath, keybindings: keybindingsConfig.keybindings, issues: keybindingsConfig.issues, providers, + codexConfig, availableEditors, buildInfo: serverBuildInfo, }; diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 23a2c50f..3b5d3685 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -91,6 +91,9 @@ export const AppSettingsSchema = Schema.Struct({ claudeBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), copilotBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), copilotConfigDir: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), + claudeAuthTokenHelperCommand: Schema.String.check(Schema.isMaxLength(4096)).pipe( + withDefaults(() => ""), + ), codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), backgroundImageUrl: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), @@ -389,7 +392,9 @@ export function getProviderStartOptions( | "codexHomePath" | "openclawGatewayUrl" | "openclawPassword" - >, + > & { + claudeAuthTokenHelperCommand?: AppSettings["claudeAuthTokenHelperCommand"]; + }, ): ProviderStartOptions | undefined { const providerOptions: ProviderStartOptions = { ...(settings.codexBinaryPath || settings.codexHomePath @@ -400,26 +405,13 @@ export function getProviderStartOptions( }, } : {}), - ...(settings.claudeBinaryPath + ...(settings.claudeBinaryPath || settings.claudeAuthTokenHelperCommand ? { claudeAgent: { - binaryPath: settings.claudeBinaryPath, - }, - } - : {}), - ...(settings.copilotBinaryPath || settings.copilotConfigDir - ? { - copilot: { - ...(settings.copilotBinaryPath ? { binaryPath: settings.copilotBinaryPath } : {}), - ...(settings.copilotConfigDir ? { configDir: settings.copilotConfigDir } : {}), - }, - } - : {}), - ...(settings.openclawGatewayUrl || settings.openclawPassword - ? { - openclaw: { - ...(settings.openclawGatewayUrl ? { gatewayUrl: settings.openclawGatewayUrl } : {}), - ...(settings.openclawPassword ? { password: settings.openclawPassword } : {}), + ...(settings.claudeBinaryPath ? { binaryPath: settings.claudeBinaryPath } : {}), + ...(settings.claudeAuthTokenHelperCommand + ? { authTokenHelperCommand: settings.claudeAuthTokenHelperCommand } + : {}), }, } : {}), diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 6e8f869f..65e573ab 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -124,6 +124,11 @@ function createBaseServerConfig(): ServerConfig { checkedAt: NOW_ISO, }, ], + codexConfig: { + selectedModelProviderId: null, + entries: [], + parseError: null, + }, availableEditors: [], }; } diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 61088dd8..11612071 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -52,6 +52,11 @@ function createBaseServerConfig(): ServerConfig { checkedAt: NOW_ISO, }, ], + codexConfig: { + selectedModelProviderId: null, + entries: [], + parseError: null, + }, availableEditors: [], }; } diff --git a/apps/web/src/components/settings/CodexBackendSection.test.tsx b/apps/web/src/components/settings/CodexBackendSection.test.tsx new file mode 100644 index 00000000..6a190f46 --- /dev/null +++ b/apps/web/src/components/settings/CodexBackendSection.test.tsx @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { CodexBackendSection } from "./CodexBackendSection"; + +describe("CodexBackendSection", () => { + it("shows a parse warning while keeping the fallback-selected backend visible", () => { + const markup = renderToStaticMarkup( + , + ); + expect(markup).toContain("Codex config parsing failed"); + expect(markup).toContain("Azure OpenAI"); + expect(markup).toContain("Configured"); + }); +}); diff --git a/apps/web/src/components/settings/CodexBackendSection.tsx b/apps/web/src/components/settings/CodexBackendSection.tsx new file mode 100644 index 00000000..d68e707a --- /dev/null +++ b/apps/web/src/components/settings/CodexBackendSection.tsx @@ -0,0 +1,84 @@ +import type { ServerCodexConfigSummary } from "@okcode/contracts"; + +import { buildCodexBackendCatalog } from "../../lib/codexBackendCatalog"; +import { cn } from "../../lib/utils"; + +function CodexBackendGroup({ + title, + rows, +}: { + title: string; + rows: ReadonlyArray["builtIn"][number]>; +}) { + if (rows.length === 0) { + return null; + } + + return ( +
+

+ {title} +

+
+ {rows.map((row) => ( +
+
+
+ {row.title} + {row.statusBadge ? ( + + {row.statusBadge} + + ) : null} +
+ {row.id} +
+
{row.authNote}
+
+ {row.selected + ? "Active backend" + : row.definedInConfig + ? "Detected from config" + : "Available preset"} +
+
+ ))} +
+
+ ); +} + +export function CodexBackendSection({ + summary, +}: { + summary: ServerCodexConfigSummary | null | undefined; +}) { + const catalog = buildCodexBackendCatalog(summary); + + return ( +
+ {catalog.parseError ? ( +
+ Codex config parsing failed, so this list may be incomplete. The last recoverable backend + selection is still shown. +
+ ) : null} + + + + +
+ ); +} diff --git a/apps/web/src/components/settings/SettingsRouteContext.tsx b/apps/web/src/components/settings/SettingsRouteContext.tsx index 0967140d..b49d6f9b 100644 --- a/apps/web/src/components/settings/SettingsRouteContext.tsx +++ b/apps/web/src/components/settings/SettingsRouteContext.tsx @@ -90,6 +90,9 @@ export function SettingsRouteContextProvider({ children }: { children: ReactNode currentGitTextGenerationModel !== defaultGitTextGenerationModel; const isInstallSettingsDirty = settings.claudeBinaryPath !== defaults.claudeBinaryPath || + settings.claudeAuthTokenHelperCommand !== defaults.claudeAuthTokenHelperCommand || + settings.copilotBinaryPath !== defaults.copilotBinaryPath || + settings.copilotConfigDir !== defaults.copilotConfigDir || settings.codexBinaryPath !== defaults.codexBinaryPath || settings.codexHomePath !== defaults.codexHomePath; const isOpenClawSettingsDirty = @@ -151,6 +154,8 @@ export function SettingsRouteContextProvider({ children }: { children: ReactNode ...(isGitTextGenerationModelDirty ? ["Git writing model"] : []), ...(settings.customCodexModels.length > 0 || settings.customClaudeModels.length > 0 || + settings.customCopilotModels.length > 0 || + settings.customGeminiModels.length > 0 || settings.customOpenClawModels.length > 0 ? ["Custom models"] : []), diff --git a/apps/web/src/lib/claudeAuthTokenHelperPresets.ts b/apps/web/src/lib/claudeAuthTokenHelperPresets.ts new file mode 100644 index 00000000..59dc2c6d --- /dev/null +++ b/apps/web/src/lib/claudeAuthTokenHelperPresets.ts @@ -0,0 +1,23 @@ +export interface ClaudeAuthTokenHelperPreset { + readonly label: string; + readonly command: string; + readonly description: string; +} + +export const CLAUDE_AUTH_TOKEN_HELPER_PRESETS: readonly ClaudeAuthTokenHelperPreset[] = [ + { + label: "1Password", + command: "op read op://shared/anthropic/token --no-newline", + description: "Reads an Anthropic auth token from 1Password CLI.", + }, + { + label: "Bitwarden", + command: "bw get notes anthropic-auth-token", + description: "Reads an Anthropic auth token from Bitwarden CLI notes.", + }, + { + label: "Doppler", + command: "doppler secrets get ANTHROPIC_AUTH_TOKEN --plain", + description: "Reads ANTHROPIC_AUTH_TOKEN from Doppler.", + }, +] as const; diff --git a/apps/web/src/lib/codexBackendCatalog.test.ts b/apps/web/src/lib/codexBackendCatalog.test.ts new file mode 100644 index 00000000..733e1541 --- /dev/null +++ b/apps/web/src/lib/codexBackendCatalog.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; + +import { buildCodexBackendCatalog } from "./codexBackendCatalog"; + +describe("buildCodexBackendCatalog", () => { + it("merges shipped presets with detected custom providers", () => { + const catalog = buildCodexBackendCatalog({ + selectedModelProviderId: "my-company-proxy", + entries: [ + { + id: "my-company-proxy", + selected: true, + definedInConfig: true, + isBuiltIn: false, + isKnownPreset: false, + requiresOpenAiLogin: false, + }, + { + id: "openrouter", + selected: false, + definedInConfig: true, + isBuiltIn: false, + isKnownPreset: true, + requiresOpenAiLogin: false, + }, + ], + parseError: null, + }); + + expect(catalog.effectiveSelectedModelProviderId).toBe("my-company-proxy"); + expect(catalog.curated.find((row) => row.id === "openrouter")?.statusBadge).toBe( + "Defined in config", + ); + expect(catalog.detectedCustom).toEqual([ + { + id: "my-company-proxy", + title: "My Company Proxy", + group: "custom", + authNote: "Provider-specific credentials", + statusBadge: "Configured", + selected: true, + definedInConfig: true, + isKnownPreset: false, + }, + ]); + }); + + it("renders openai as the implicit default when no model_provider is configured", () => { + const catalog = buildCodexBackendCatalog({ + selectedModelProviderId: null, + entries: [], + parseError: null, + }); + + expect(catalog.effectiveSelectedModelProviderId).toBe("openai"); + expect(catalog.builtIn.find((row) => row.id === "openai")?.statusBadge).toBe( + "Implicit default", + ); + }); + + it("shows configured on a detected curated preset", () => { + const catalog = buildCodexBackendCatalog({ + selectedModelProviderId: "portkey", + entries: [ + { + id: "portkey", + selected: true, + definedInConfig: true, + isBuiltIn: false, + isKnownPreset: true, + requiresOpenAiLogin: false, + }, + ], + parseError: null, + }); + + expect(catalog.curated.find((row) => row.id === "portkey")?.statusBadge).toBe("Configured"); + }); +}); diff --git a/apps/web/src/lib/codexBackendCatalog.ts b/apps/web/src/lib/codexBackendCatalog.ts new file mode 100644 index 00000000..c8997f59 --- /dev/null +++ b/apps/web/src/lib/codexBackendCatalog.ts @@ -0,0 +1,149 @@ +import type { ServerCodexConfigSummary } from "@okcode/contracts"; +import { + CODEX_MODEL_PROVIDER_PRESETS, + getCodexModelProviderPreset, + requiresOpenAiLoginForCodexModelProvider, + type CodexModelProviderPresetKind, +} from "@okcode/shared/codexModelProviders"; + +export type CodexBackendStatusBadge = + | "Configured" + | "Defined in config" + | "Implicit default" + | null; +export type CodexBackendGroupId = "built-in" | "curated" | "custom"; + +export interface CodexBackendCatalogRow { + readonly id: string; + readonly title: string; + readonly group: CodexBackendGroupId; + readonly authNote: string; + readonly statusBadge: CodexBackendStatusBadge; + readonly selected: boolean; + readonly definedInConfig: boolean; + readonly isKnownPreset: boolean; +} + +export interface CodexBackendCatalog { + readonly parseError: string | null; + readonly selectedModelProviderId: string | null; + readonly effectiveSelectedModelProviderId: string; + readonly builtIn: readonly CodexBackendCatalogRow[]; + readonly curated: readonly CodexBackendCatalogRow[]; + readonly detectedCustom: readonly CodexBackendCatalogRow[]; +} + +const DEFAULT_CODEX_CONFIG_SUMMARY: ServerCodexConfigSummary = { + selectedModelProviderId: null, + entries: [], + parseError: null, +}; + +function humanizeCodexBackendId(id: string): string { + return id + .split(/[-_.\s]+/u) + .filter((part) => part.length > 0) + .map((part) => part.slice(0, 1).toUpperCase() + part.slice(1)) + .join(" "); +} + +function toAuthNote(id: string): string { + const preset = getCodexModelProviderPreset(id); + const authMode = preset?.authMode; + if (authMode === "openai-login") { + return "OpenAI login"; + } + if (authMode === "local") { + return "Local backend"; + } + if (authMode === "provider-specific") { + return "Provider-specific credentials"; + } + return requiresOpenAiLoginForCodexModelProvider(id) + ? "OpenAI login" + : "Provider-specific credentials"; +} + +function getStatusBadge(input: { + readonly id: string; + readonly selectedModelProviderId: string | null; + readonly selected: boolean; + readonly definedInConfig: boolean; +}): CodexBackendStatusBadge { + if (input.selected) { + return "Configured"; + } + if (input.selectedModelProviderId === null && input.id === "openai") { + return "Implicit default"; + } + if (input.definedInConfig) { + return "Defined in config"; + } + return null; +} + +function toPresetGroup(kind: CodexModelProviderPresetKind): Exclude { + return kind === "built-in" ? "built-in" : "curated"; +} + +function toCatalogRow(input: { + readonly id: string; + readonly selectedModelProviderId: string | null; + readonly selected: boolean; + readonly definedInConfig: boolean; + readonly isKnownPreset: boolean; +}): CodexBackendCatalogRow { + const preset = getCodexModelProviderPreset(input.id); + + return { + id: input.id, + title: preset?.title ?? humanizeCodexBackendId(input.id), + group: preset ? toPresetGroup(preset.kind) : "custom", + authNote: toAuthNote(input.id), + statusBadge: getStatusBadge(input), + selected: input.selected, + definedInConfig: input.definedInConfig, + isKnownPreset: input.isKnownPreset, + }; +} + +export function buildCodexBackendCatalog( + summary: ServerCodexConfigSummary | null | undefined, +): CodexBackendCatalog { + const resolvedSummary = summary ?? DEFAULT_CODEX_CONFIG_SUMMARY; + const dynamicEntryById = new Map( + resolvedSummary.entries.map((entry) => [entry.id, entry] as const), + ); + + const presetRows = CODEX_MODEL_PROVIDER_PRESETS.map((preset) => { + const dynamicEntry = dynamicEntryById.get(preset.id); + return toCatalogRow({ + id: preset.id, + selectedModelProviderId: resolvedSummary.selectedModelProviderId, + selected: dynamicEntry?.selected ?? false, + definedInConfig: dynamicEntry?.definedInConfig ?? false, + isKnownPreset: true, + }); + }); + + const detectedCustom = resolvedSummary.entries + .filter((entry) => !entry.isKnownPreset) + .map((entry) => + toCatalogRow({ + id: entry.id, + selectedModelProviderId: resolvedSummary.selectedModelProviderId, + selected: entry.selected, + definedInConfig: entry.definedInConfig, + isKnownPreset: false, + }), + ); + + return { + parseError: resolvedSummary.parseError, + selectedModelProviderId: resolvedSummary.selectedModelProviderId, + effectiveSelectedModelProviderId: resolvedSummary.selectedModelProviderId ?? "openai", + builtIn: presetRows.filter((row) => row.group === "built-in"), + curated: presetRows.filter((row) => row.group === "curated"), + detectedCustom, + }; +} diff --git a/apps/web/src/lib/providerAvailability.ts b/apps/web/src/lib/providerAvailability.ts index 64017e8a..c0d86da8 100644 --- a/apps/web/src/lib/providerAvailability.ts +++ b/apps/web/src/lib/providerAvailability.ts @@ -31,6 +31,7 @@ export function isProviderReadyForThreadSelection(input: { provider: ProviderKind; statuses: ReadonlyArray; openclawGatewayUrl?: string | null | undefined; + claudeAuthTokenHelperCommand?: string | null | undefined; }): boolean { const status = getProviderStatusByKind(input.statuses, input.provider); @@ -45,6 +46,15 @@ export function isProviderReadyForThreadSelection(input: { return false; } + if ( + input.provider === "claudeAgent" && + (input.claudeAuthTokenHelperCommand ?? "").trim().length > 0 && + status.available && + (status.authStatus ?? status.auth?.status) === "unauthenticated" + ) { + return true; + } + const authStatus = status.authStatus ?? status.auth?.status; return Boolean(status.available && status.status === "ready" && authStatus !== "unauthenticated"); } @@ -52,12 +62,14 @@ export function isProviderReadyForThreadSelection(input: { export function getSelectableThreadProviders(input: { statuses: ReadonlyArray; openclawGatewayUrl?: string | null | undefined; + claudeAuthTokenHelperCommand?: string | null | undefined; }): ProviderKind[] { return THREAD_PROVIDER_ORDER.filter((provider) => isProviderReadyForThreadSelection({ provider, statuses: input.statuses, openclawGatewayUrl: input.openclawGatewayUrl, + claudeAuthTokenHelperCommand: input.claudeAuthTokenHelperCommand, }), ); } diff --git a/apps/web/src/lib/settingsProviderMetadata.tsx b/apps/web/src/lib/settingsProviderMetadata.tsx new file mode 100644 index 00000000..cad52b1b --- /dev/null +++ b/apps/web/src/lib/settingsProviderMetadata.tsx @@ -0,0 +1,107 @@ +import type { ProviderKind } from "@okcode/contracts"; +import type { ReactNode } from "react"; + +export type InstallBinarySettingsKey = "claudeBinaryPath" | "codexBinaryPath" | "copilotBinaryPath"; +export type InstallHomeSettingsKey = "codexHomePath" | "copilotConfigDir"; + +export interface InstallProviderSettings { + readonly provider: Extract; + readonly title: string; + readonly binaryPathKey: InstallBinarySettingsKey; + readonly binaryPlaceholder: string; + readonly binaryDescription: ReactNode; + readonly homePathKey?: InstallHomeSettingsKey; + readonly homePlaceholder?: string; + readonly homeDescription?: ReactNode; +} + +export interface ProviderAuthGuide { + readonly installCmd?: string; + readonly authCmd?: string; + readonly verifyCmd?: string; + readonly note: string; +} + +export const SETTINGS_AUTH_PROVIDER_ORDER = [ + "codex", + "claudeAgent", + "gemini", + "copilot", + "openclaw", +] as const satisfies readonly ProviderKind[]; + +export const INSTALL_PROVIDER_SETTINGS = [ + { + provider: "codex", + title: "Codex", + binaryPathKey: "codexBinaryPath", + binaryPlaceholder: "Codex binary path", + binaryDescription: ( + <> + Leave blank to use codex from your PATH. Authentication normally uses{" "} + codex login unless your Codex config selects a non-OpenAI backend. + + ), + homePathKey: "codexHomePath", + homePlaceholder: "CODEX_HOME", + homeDescription: "Optional custom Codex home and config directory.", + }, + { + provider: "claudeAgent", + title: "Claude Code", + binaryPathKey: "claudeBinaryPath", + binaryPlaceholder: "Claude Code binary path", + binaryDescription: ( + <> + Leave blank to use claude from your PATH. Authentication uses{" "} + claude auth login. + + ), + }, + { + provider: "copilot", + title: "GitHub Copilot", + binaryPathKey: "copilotBinaryPath", + binaryPlaceholder: "GitHub Copilot binary path", + binaryDescription: ( + <> + Leave blank to use copilot from your PATH. Authentication uses{" "} + copilot login or GitHub CLI credentials. + + ), + homePathKey: "copilotConfigDir", + homePlaceholder: "Copilot config directory", + homeDescription: "Optional custom Copilot config directory.", + }, +] as const satisfies readonly InstallProviderSettings[]; + +export const PROVIDER_AUTH_GUIDES: Record = { + codex: { + installCmd: "npm install -g @openai/codex", + authCmd: "codex login", + verifyCmd: "codex login status", + note: "Codex appears in the thread picker when the CLI is reachable and the selected backend is either OpenAI-authenticated or a configured non-OpenAI backend.", + }, + claudeAgent: { + installCmd: "npm install -g @anthropic-ai/claude-code", + authCmd: "claude auth login", + verifyCmd: "claude auth status", + note: "Claude Code must be installed and signed in before it appears in the thread picker.", + }, + gemini: { + installCmd: "npm install -g @google/gemini-cli", + authCmd: "set GEMINI_API_KEY or GOOGLE_API_KEY", + verifyCmd: "gemini --version", + note: "Gemini CLI appears in the thread picker when the binary is installed and headless auth is available or locally cached.", + }, + copilot: { + installCmd: "npm install -g @github/copilot", + authCmd: "copilot login", + verifyCmd: "copilot auth status", + note: "GitHub Copilot must be installed and signed in before it appears in the thread picker.", + }, + openclaw: { + verifyCmd: "Test Connection", + note: "OpenClaw uses the gateway URL and shared secret below rather than a local CLI login. Shared-secret auth usually works without device pairing and is the recommended default for Tailscale and remote gateways.", + }, +}; diff --git a/apps/web/src/routes/-_chat.settings.route.browser.tsx b/apps/web/src/routes/-_chat.settings.route.browser.tsx new file mode 100644 index 00000000..402604f2 --- /dev/null +++ b/apps/web/src/routes/-_chat.settings.route.browser.tsx @@ -0,0 +1,74 @@ +import "../index.css"; + +import type { NativeApi, ServerConfig } from "@okcode/contracts"; +import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; +import { page } from "vitest/browser"; +import { afterEach, describe, expect, it } from "vitest"; +import { render } from "vitest-browser-react"; + +import { getRouter } from "../router"; + +const BASE_SERVER_CONFIG: ServerConfig = { + cwd: "/repo/project", + keybindingsConfigPath: "/repo/project/.okcode-keybindings.json", + keybindings: [], + issues: [], + providers: [ + { + provider: "codex", + status: "ready", + available: true, + authStatus: "authenticated", + checkedAt: "2026-04-14T12:00:00.000Z", + }, + ], + codexConfig: { + selectedModelProviderId: null, + entries: [], + parseError: null, + }, + availableEditors: [], +}; + +function makeNativeApi(serverConfig: ServerConfig): NativeApi { + return { + server: { + getConfig: async () => serverConfig, + getGlobalEnvironmentVariables: async () => ({ entries: [] }), + }, + } as unknown as NativeApi; +} + +async function renderSettings(pathname: "/settings" | "/settings/") { + (window as Window & { nativeApi?: NativeApi }).nativeApi = makeNativeApi(BASE_SERVER_CONFIG); + const history = createMemoryHistory({ initialEntries: [pathname] }); + const router = getRouter(history); + const screen = await render(); + + await expect.element(page.getByText("Time format")).toBeInTheDocument(); + + return screen; +} + +afterEach(() => { + delete (window as Window & { nativeApi?: NativeApi }).nativeApi; + window.localStorage.clear(); +}); + +describe("settings route canonical rendering", () => { + it("renders the canonical settings page for /settings and /settings/", async () => { + const withoutTrailingSlash = await renderSettings("/settings"); + try { + await expect.element(page.getByText("Time format")).toBeInTheDocument(); + } finally { + await withoutTrailingSlash.unmount(); + } + + const withTrailingSlash = await renderSettings("/settings/"); + try { + await expect.element(page.getByText("Time format")).toBeInTheDocument(); + } finally { + await withTrailingSlash.unmount(); + } + }); +}); diff --git a/apps/web/src/routes/_chat.settings.index.tsx b/apps/web/src/routes/_chat.settings.index.tsx index 326af929..272a1fef 100644 --- a/apps/web/src/routes/_chat.settings.index.tsx +++ b/apps/web/src/routes/_chat.settings.index.tsx @@ -10,7 +10,7 @@ import { XCircleIcon, XIcon, } from "lucide-react"; -import { type ReactNode, useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import type { TestOpenclawGatewayHostKind, TestOpenclawGatewayResult } from "@okcode/contracts"; import { type BuildMetadata, @@ -75,11 +75,18 @@ import { ensureNativeApi } from "../nativeApi"; import { useStore } from "../store"; import { PairingLink } from "../components/mobile/PairingLink"; import { ProjectIcon } from "../components/ProjectIcon"; +import { CodexBackendSection } from "../components/settings/CodexBackendSection"; import { getProviderLabel as getProviderStatusLabelName, getProviderStatusDescription, getProviderStatusHeading, } from "../components/chat/providerStatusPresentation"; +import { CLAUDE_AUTH_TOKEN_HELPER_PRESETS } from "../lib/claudeAuthTokenHelperPresets"; +import { + INSTALL_PROVIDER_SETTINGS, + PROVIDER_AUTH_GUIDES, + SETTINGS_AUTH_PROVIDER_ORDER, +} from "../lib/settingsProviderMetadata"; import { APP_LOCALE_PREFERENCES } from "../i18n/types"; import { useT } from "../i18n/useI18n"; @@ -225,106 +232,11 @@ function formatOpenclawGatewayDebugReport(result: TestOpenclawGatewayResult): st return lines.join("\n"); } -type InstallBinarySettingsKey = "claudeBinaryPath" | "codexBinaryPath" | "copilotBinaryPath"; -type InstallProviderSettings = { - provider: ProviderKind; - title: string; - binaryPathKey: InstallBinarySettingsKey; - binaryPlaceholder: string; - binaryDescription: ReactNode; - homePathKey?: "codexHomePath" | "copilotConfigDir"; - homePlaceholder?: string; - homeDescription?: ReactNode; -}; - -const INSTALL_PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ - { - provider: "codex", - title: "Codex", - binaryPathKey: "codexBinaryPath", - binaryPlaceholder: "Codex binary path", - binaryDescription: ( - <> - Leave blank to use codex from your PATH. Authentication normally uses{" "} - codex login unless your Codex config points at a custom model provider. - - ), - homePathKey: "codexHomePath", - homePlaceholder: "CODEX_HOME", - homeDescription: "Optional custom Codex home and config directory.", - }, - { - provider: "claudeAgent", - title: "Claude Code", - binaryPathKey: "claudeBinaryPath", - binaryPlaceholder: "Claude Code binary path", - binaryDescription: ( - <> - Leave blank to use claude from your PATH. Authentication uses{" "} - claude auth login. - - ), - }, - { - provider: "copilot", - title: "GitHub Copilot", - binaryPathKey: "copilotBinaryPath", - binaryPlaceholder: "GitHub Copilot binary path", - binaryDescription: ( - <> - Leave blank to use copilot from your PATH. Authentication uses{" "} - copilot login or GitHub CLI credentials. - - ), - homePathKey: "copilotConfigDir", - homePlaceholder: "Copilot config directory", - homeDescription: "Optional custom Copilot config directory.", - }, -]; - -const PROVIDER_AUTH_GUIDES: Record< - ProviderKind, - { - installCmd?: string; - authCmd?: string; - verifyCmd?: string; - note: string; - } -> = { - codex: { - installCmd: "npm install -g @openai/codex", - authCmd: "codex login", - verifyCmd: "codex login status", - note: "Codex stays available in thread creation when the CLI is ready and its auth is either confirmed or delegated to a custom model provider.", - }, - claudeAgent: { - installCmd: "npm install -g @anthropic-ai/claude-code", - authCmd: "claude auth login", - verifyCmd: "claude auth status", - note: "Claude Code must be installed and signed in before it appears in the thread picker.", - }, - copilot: { - installCmd: "npm install -g @github/copilot", - authCmd: "copilot login", - verifyCmd: "copilot auth status", - note: "GitHub Copilot must be installed and signed in before it appears in the thread picker.", - }, - gemini: { - installCmd: "npm install -g @google/gemini-cli", - authCmd: "set GEMINI_API_KEY or GOOGLE_API_KEY", - verifyCmd: "gemini --version", - note: "Gemini CLI appears in the thread picker when the binary is installed and headless auth is available or locally cached.", - }, - openclaw: { - verifyCmd: "Test Connection", - note: "OpenClaw uses the gateway URL and shared secret below rather than a local CLI login. Shared-secret auth usually works without device pairing and is the recommended default for Tailscale and remote gateways.", - }, -}; - function getAuthenticationBadgeCopy(input: { status: ServerProviderStatus | null; provider: ProviderKind; openclawGatewayUrl: string; + claudeAuthTokenHelperCommand: string; }): { tone: "success" | "warning" | "error"; label: string; @@ -334,6 +246,7 @@ function getAuthenticationBadgeCopy(input: { provider: input.provider, statuses: input.status ? [input.status] : [], openclawGatewayUrl: input.openclawGatewayUrl, + claudeAuthTokenHelperCommand: input.claudeAuthTokenHelperCommand, }) ) { return { tone: "success", label: "Available in thread picker" }; @@ -358,13 +271,20 @@ function AuthenticationStatusCard({ provider, status, openclawGatewayUrl, + claudeAuthTokenHelperCommand, }: { provider: ProviderKind; status: ServerProviderStatus | null; openclawGatewayUrl: string; + claudeAuthTokenHelperCommand: string; }) { const guide = PROVIDER_AUTH_GUIDES[provider]; - const badge = getAuthenticationBadgeCopy({ status, provider, openclawGatewayUrl }); + const badge = getAuthenticationBadgeCopy({ + status, + provider, + openclawGatewayUrl, + claudeAuthTokenHelperCommand, + }); const badgeClassName = badge.tone === "success" ? "border-emerald-500/25 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300" @@ -499,7 +419,7 @@ function SettingsRouteView() { const [openKeybindingsError, setOpenKeybindingsError] = useState(null); const [openInstallProviders, setOpenInstallProviders] = useState>({ codex: Boolean(settings.codexBinaryPath || settings.codexHomePath), - claudeAgent: Boolean(settings.claudeBinaryPath), + claudeAgent: Boolean(settings.claudeBinaryPath || settings.claudeAuthTokenHelperCommand), gemini: false, copilot: Boolean(settings.copilotBinaryPath || settings.copilotConfigDir), openclaw: Boolean(settings.openclawGatewayUrl || settings.openclawPassword), @@ -554,12 +474,18 @@ function SettingsRouteView() { const codexBinaryPath = settings.codexBinaryPath; const codexHomePath = settings.codexHomePath; const claudeBinaryPath = settings.claudeBinaryPath; + const claudeAuthTokenHelperCommand = settings.claudeAuthTokenHelperCommand; + const selectedClaudeAuthTokenHelperPreset = + CLAUDE_AUTH_TOKEN_HELPER_PRESETS.find( + (preset) => preset.command === claudeAuthTokenHelperCommand, + )?.label ?? ""; const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; const availableEditors = serverConfigQuery.data?.availableEditors; const providerStatuses = serverConfigQuery.data?.providers ?? []; const selectableProviders = getSelectableThreadProviders({ statuses: providerStatuses, openclawGatewayUrl: settings.openclawGatewayUrl, + claudeAuthTokenHelperCommand, }); const gitTextGenerationModelOptions = getAppModelOptions( @@ -585,6 +511,7 @@ function SettingsRouteView() { settings.customCodexModels.length + settings.customClaudeModels.length + settings.customCopilotModels.length + + settings.customGeminiModels.length + settings.customOpenClawModels.length; const activeProjectEnvironmentVariables = selectedProjectEnvironmentVariablesQuery.data?.entries; const savedCustomModelRows = MODEL_PROVIDER_SETTINGS.flatMap((providerSettings) => @@ -600,6 +527,7 @@ function SettingsRouteView() { : savedCustomModelRows.slice(0, 5); const isInstallSettingsDirty = settings.claudeBinaryPath !== defaults.claudeBinaryPath || + settings.claudeAuthTokenHelperCommand !== defaults.claudeAuthTokenHelperCommand || settings.copilotBinaryPath !== defaults.copilotBinaryPath || settings.copilotConfigDir !== defaults.copilotConfigDir || settings.codexBinaryPath !== defaults.codexBinaryPath || @@ -1427,12 +1355,13 @@ function SettingsRouteView() { status={`${selectableProviders.length} provider${selectableProviders.length === 1 ? "" : "s"} currently selectable`} >
- {(["codex", "claudeAgent", "openclaw"] as const).map((provider) => ( + {SETTINGS_AUTH_PROVIDER_ORDER.map((provider) => ( status.provider === provider) ?? null} openclawGatewayUrl={settings.openclawGatewayUrl} + claudeAuthTokenHelperCommand={claudeAuthTokenHelperCommand} /> ))}
@@ -1449,6 +1378,7 @@ function SettingsRouteView() { onClick={() => { updateSettings({ claudeBinaryPath: defaults.claudeBinaryPath, + claudeAuthTokenHelperCommand: defaults.claudeAuthTokenHelperCommand, codexBinaryPath: defaults.codexBinaryPath, codexHomePath: defaults.codexHomePath, copilotBinaryPath: defaults.copilotBinaryPath, @@ -1475,7 +1405,9 @@ function SettingsRouteView() { ? settings.codexBinaryPath !== defaults.codexBinaryPath || settings.codexHomePath !== defaults.codexHomePath : providerSettings.provider === "claudeAgent" - ? settings.claudeBinaryPath !== defaults.claudeBinaryPath + ? settings.claudeBinaryPath !== defaults.claudeBinaryPath || + settings.claudeAuthTokenHelperCommand !== + defaults.claudeAuthTokenHelperCommand : settings.copilotBinaryPath !== defaults.copilotBinaryPath || settings.copilotConfigDir !== defaults.copilotConfigDir; const binaryPathValue = @@ -1484,6 +1416,16 @@ function SettingsRouteView() { : providerSettings.provider === "copilot" ? settings.copilotBinaryPath : codexBinaryPath; + const homePathKey = + "homePathKey" in providerSettings ? providerSettings.homePathKey : undefined; + const homePlaceholder = + "homePlaceholder" in providerSettings + ? providerSettings.homePlaceholder + : undefined; + const homeDescription = + "homeDescription" in providerSettings + ? providerSettings.homeDescription + : undefined; return ( - {providerSettings.homePathKey ? ( + {providerSettings.provider === "claudeAgent" ? ( + ) : null} + + {homePathKey ? ( + @@ -2035,7 +2047,7 @@ function SettingsRouteView() { 0 ? ( + + +
+ +
+
)} diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 31c7da36..a0eebf9e 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -1,3699 +1,15 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { - CheckCircle2Icon, - ChevronDownIcon, - CpuIcon, - FolderIcon, - GlobeIcon, - GitBranchIcon, - ImportIcon, - KeyboardIcon, - Loader2Icon, - PaletteIcon, - PlusIcon, - RefreshCwIcon, - RotateCcwIcon, - ShieldCheckIcon, - SkipForwardIcon, - SmartphoneIcon, - Undo2Icon, - VariableIcon, - WrenchIcon, - XCircleIcon, - XIcon, -} from "lucide-react"; -import { type ReactNode, useCallback, useEffect, useState } from "react"; -import type { TestOpenclawGatewayHostKind, TestOpenclawGatewayResult } from "@okcode/contracts"; -import { - type BuildMetadata, - type KeybindingCommand, - type KeybindingRule, - type ProjectId, - type ProviderKind, - type ServerProviderStatus, - DEFAULT_GIT_TEXT_GENERATION_MODEL, -} from "@okcode/contracts"; -import { getModelOptions, normalizeModelSlug } from "@okcode/shared/model"; -import { validateHttpPreviewUrl } from "@okcode/shared/preview"; -import { - DEFAULT_BROWSER_PREVIEW_START_PAGE_URL, - DEFAULT_SIDEBAR_FONT_SIZE, - DEFAULT_SIDEBAR_PROJECT_ROW_HEIGHT, - DEFAULT_SIDEBAR_SPACING, - DEFAULT_SIDEBAR_THREAD_ROW_HEIGHT, - DEFAULT_PR_REVIEW_REQUEST_CHANGES_TONE, - getAppModelOptions, - getCustomModelsForProvider, - MAX_CUSTOM_MODEL_LENGTH, - MODEL_PROVIDER_SETTINGS, - patchCustomModels, - PrReviewRequestChangesTone, - resolveBrowserPreviewStartPageUrl, - SIDEBAR_FONT_SIZE_MAX, - SIDEBAR_FONT_SIZE_MIN, - SIDEBAR_PROJECT_ROW_HEIGHT_MAX, - SIDEBAR_PROJECT_ROW_HEIGHT_MIN, - SIDEBAR_SPACING_MAX, - SIDEBAR_SPACING_MIN, - SIDEBAR_THREAD_ROW_HEIGHT_MAX, - SIDEBAR_THREAD_ROW_HEIGHT_MIN, - useAppSettings, -} from "../appSettings"; -import { APP_BUILD_INFO } from "../branding"; -import { Button } from "../components/ui/button"; -import { Collapsible, CollapsibleContent } from "../components/ui/collapsible"; -import { EnvironmentVariablesEditor } from "../components/EnvironmentVariablesEditor"; -import { HotkeysSettingsSection } from "../components/settings/HotkeysSettingsSection"; -import { Input } from "../components/ui/input"; -import { - Select, - SelectItem, - SelectPopup, - SelectTrigger, - SelectValue, -} from "../components/ui/select"; -import { SidebarTrigger } from "../components/ui/sidebar"; -import { Switch } from "../components/ui/switch"; -import { SidebarInset } from "../components/ui/sidebar"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip"; -import { CustomThemeDialog } from "../components/CustomThemeDialog"; -import { resolveAndPersistPreferredEditor } from "../editorPreferences"; -import { isElectron, isMobileShell } from "../env"; -import { useTheme, COLOR_THEMES, DEFAULT_COLOR_THEME, FONT_FAMILIES } from "../hooks/useTheme"; -import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; -import { - environmentVariablesQueryKeys, - globalEnvironmentVariablesQueryOptions, - projectEnvironmentVariablesQueryOptions, -} from "../lib/environmentVariablesReactQuery"; -import { normalizeProjectIconPath } from "../lib/projectIcons"; -import { updateProjectIconOverride } from "../lib/projectMeta"; -import { - applyCustomTheme, - clearFontOverride, - clearFontSizeOverride, - clearRadiusOverride, - clearStoredCustomTheme, - getStoredCustomTheme, - getStoredFontOverride, - getStoredFontSizeOverride, - getStoredRadiusOverride, - removeCustomTheme, - setStoredFontOverride, - setStoredFontSizeOverride, - setStoredRadiusOverride, - type CustomThemeData, -} from "../lib/customTheme"; -import { openUrlInAppBrowser } from "../lib/openUrlInAppBrowser"; -import { - getSelectableThreadProviders, - isProviderReadyForThreadSelection, -} from "../lib/providerAvailability"; -import { - openclawGatewayConfigQueryOptions, - serverConfigQueryOptions, - serverQueryKeys, -} from "../lib/serverReactQuery"; -import { cn } from "../lib/utils"; -import { ensureNativeApi, readNativeApi } from "../nativeApi"; -import { useStore } from "../store"; -import { PairingLink } from "../components/mobile/PairingLink"; -import { ProjectIcon } from "../components/ProjectIcon"; -import { - getProviderLabel as getProviderStatusLabelName, - getProviderStatusDescription, - getProviderStatusHeading, -} from "../components/chat/providerStatusPresentation"; +import { Outlet, createFileRoute } from "@tanstack/react-router"; -// --------------------------------------------------------------------------- -// Settings navigation sections -// --------------------------------------------------------------------------- -type SettingsSectionId = - | "general" - | "authentication" - | "hotkeys" - | "environment" - | "projects" - | "git" - | "models" - | "mobile" - | "advanced"; - -interface SettingsNavItem { - id: SettingsSectionId; - label: string; - icon: ReactNode; - hidden?: boolean; -} - -function useSettingsNavItems(): SettingsNavItem[] { - return [ - { id: "general", label: "General", icon: }, - { - id: "authentication", - label: "Authentication", - icon: , - }, - { id: "hotkeys", label: "Hotkeys", icon: }, - { id: "environment", label: "Environment", icon: }, - { id: "projects", label: "Projects", icon: }, - { id: "git", label: "Git", icon: }, - { id: "models", label: "Models", icon: }, - { - id: "mobile", - label: "Mobile Companion", - icon: , - hidden: isMobileShell, - }, - { id: "advanced", label: "Advanced", icon: }, - ]; -} - -const THEME_OPTIONS = [ - { - value: "system", - label: "System", - description: "Match your OS appearance setting.", - }, - { - value: "light", - label: "Light", - description: "Always use the light theme.", - }, - { - value: "dark", - label: "Dark", - description: "Always use the dark theme.", - }, -] as const; - -const TIMESTAMP_FORMAT_LABELS = { - locale: "System default", - "12-hour": "12-hour", - "24-hour": "24-hour", -} as const; - -const PR_REVIEW_REQUEST_CHANGES_TONE_OPTIONS: ReadonlyArray<{ - value: PrReviewRequestChangesTone; - label: string; -}> = [ - { value: "warning", label: "Warning" }, - { value: "neutral", label: "Neutral" }, - { value: "brand", label: "Brand" }, -]; - -function describeOpenclawGatewayHostKind(hostKind: TestOpenclawGatewayHostKind): string { - switch (hostKind) { - case "loopback": - return "Loopback / same machine"; - case "tailscale": - return "Tailscale / tailnet"; - case "private": - return "Private LAN"; - case "public": - return "Public / internet-routable"; - case "unknown": - return "Unknown"; - } -} - -function describeOpenclawGatewayHealthStatus(result: TestOpenclawGatewayResult): string | null { - const diagnostics = result.diagnostics; - if (!diagnostics) return null; - switch (diagnostics.healthStatus) { - case "pass": - return diagnostics.healthDetail ? `Reachable (${diagnostics.healthDetail})` : "Reachable"; - case "fail": - return diagnostics.healthDetail ? `Failed (${diagnostics.healthDetail})` : "Failed"; - case "skip": - return diagnostics.healthDetail ?? "Skipped"; - } -} - -function formatOpenclawGatewayDebugReport(result: TestOpenclawGatewayResult): string { - const lines = [ - `OpenClaw gateway connection test: ${result.success ? "success" : "failed"}`, - `Total duration: ${result.totalDurationMs}ms`, - ]; - - if (result.error) { - lines.push(`Error: ${result.error}`); - } - - lines.push(""); - lines.push("Steps:"); - for (const step of result.steps) { - lines.push( - `- ${step.name}: ${step.status} (${step.durationMs}ms)${ - step.detail ? ` — ${step.detail}` : "" - }`, - ); - } - - if (result.serverInfo) { - lines.push(""); - lines.push("Server info:"); - if (result.serverInfo.version) { - lines.push(`- Version: ${result.serverInfo.version}`); - } - if (result.serverInfo.sessionId) { - lines.push(`- Session: ${result.serverInfo.sessionId}`); - } - } - - if (result.diagnostics) { - const diagnostics = result.diagnostics; - lines.push(""); - lines.push("Diagnostics:"); - if (diagnostics.normalizedUrl) { - lines.push(`- Endpoint: ${diagnostics.normalizedUrl}`); - } - if (diagnostics.hostKind) { - lines.push(`- Host type: ${describeOpenclawGatewayHostKind(diagnostics.hostKind)}`); - } - if (diagnostics.resolvedAddresses.length > 0) { - lines.push(`- Resolved: ${diagnostics.resolvedAddresses.join(", ")}`); - } - const healthStatus = describeOpenclawGatewayHealthStatus(result); - if (healthStatus) { - lines.push( - `- Health probe: ${healthStatus}${ - diagnostics.healthUrl ? ` at ${diagnostics.healthUrl}` : "" - }`, - ); - } - if (diagnostics.socketCloseCode !== undefined) { - lines.push( - `- Socket close: ${diagnostics.socketCloseCode}${ - diagnostics.socketCloseReason ? ` (${diagnostics.socketCloseReason})` : "" - }`, - ); - } - if (diagnostics.socketError) { - lines.push(`- Socket error: ${diagnostics.socketError}`); - } - if (diagnostics.gatewayErrorCode) { - lines.push(`- Gateway error code: ${diagnostics.gatewayErrorCode}`); - } - if (diagnostics.gatewayErrorDetailCode) { - lines.push(`- Gateway detail code: ${diagnostics.gatewayErrorDetailCode}`); - } - if (diagnostics.gatewayErrorDetailReason) { - lines.push(`- Gateway detail reason: ${diagnostics.gatewayErrorDetailReason}`); - } - if (diagnostics.gatewayRecommendedNextStep) { - lines.push(`- Gateway next step: ${diagnostics.gatewayRecommendedNextStep}`); - } - if (diagnostics.gatewayCanRetryWithDeviceToken !== undefined) { - lines.push( - `- Device-token retry available: ${diagnostics.gatewayCanRetryWithDeviceToken ? "yes" : "no"}`, - ); - } - if (diagnostics.observedNotifications.length > 0) { - lines.push(`- Gateway events: ${diagnostics.observedNotifications.join(", ")}`); - } - if (diagnostics.hints.length > 0) { - lines.push(""); - lines.push("Troubleshooting:"); - for (const hint of diagnostics.hints) { - lines.push(`- ${hint}`); - } - } - } - - return lines.join("\n"); -} - -type InstallBinarySettingsKey = "claudeBinaryPath" | "codexBinaryPath"; -type InstallProviderSettings = { - provider: ProviderKind; - title: string; - binaryPathKey: InstallBinarySettingsKey; - binaryPlaceholder: string; - binaryDescription: ReactNode; - homePathKey?: "codexHomePath"; - homePlaceholder?: string; - homeDescription?: ReactNode; -}; - -const INSTALL_PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ - { - provider: "codex", - title: "Codex", - binaryPathKey: "codexBinaryPath", - binaryPlaceholder: "Codex binary path", - binaryDescription: ( - <> - Leave blank to use codex from your PATH. Authentication normally uses{" "} - codex login unless your Codex config points at a custom model provider. - - ), - homePathKey: "codexHomePath", - homePlaceholder: "CODEX_HOME", - homeDescription: "Optional custom Codex home and config directory.", - }, - { - provider: "claudeAgent", - title: "Claude Code", - binaryPathKey: "claudeBinaryPath", - binaryPlaceholder: "Claude Code binary path", - binaryDescription: ( - <> - Leave blank to use claude from your PATH. Authentication uses{" "} - claude auth login. - - ), - }, -]; - -const PROVIDER_AUTH_GUIDES: Record< - ProviderKind, - { - installCmd?: string; - authCmd?: string; - verifyCmd?: string; - note: string; - } -> = { - codex: { - installCmd: "npm install -g @openai/codex", - authCmd: "codex login", - verifyCmd: "codex login status", - note: "Codex stays available in thread creation when the CLI is ready and its auth is either confirmed or delegated to a custom model provider.", - }, - claudeAgent: { - installCmd: "npm install -g @anthropic-ai/claude-code", - authCmd: "claude auth login", - verifyCmd: "claude auth status", - note: "Claude Code must be installed and signed in through the CLI before it appears in the thread picker.", - }, - gemini: { - installCmd: "npm install -g @google/gemini-cli", - authCmd: "set GEMINI_API_KEY or GOOGLE_API_KEY", - verifyCmd: "gemini --version", - note: "Gemini CLI appears in the thread picker when the binary is installed and headless auth is available or locally cached.", - }, - openclaw: { - verifyCmd: "Test Connection", - note: "OpenClaw uses the gateway URL and shared secret below rather than a local CLI login. Shared-secret auth usually works without device pairing and is the recommended default for Tailscale and remote gateways.", - }, - copilot: { - installCmd: "npm install -g @github/copilot", - authCmd: "copilot login", - verifyCmd: "gh auth status", - note: "GitHub Copilot must be installed and authenticated before it can appear in the thread picker.", - }, -}; - -function getAuthenticationBadgeCopy(input: { - status: ServerProviderStatus | null; - provider: ProviderKind; - openclawGatewayUrl: string; -}): { - tone: "success" | "warning" | "error"; - label: string; -} { - if ( - isProviderReadyForThreadSelection({ - provider: input.provider, - statuses: input.status ? [input.status] : [], - openclawGatewayUrl: input.openclawGatewayUrl, - }) - ) { - return { tone: "success", label: "Available in thread picker" }; - } - - if (input.status?.authStatus === "unauthenticated") { - return { tone: "error", label: "Sign-in required" }; - } - - if (input.provider === "openclaw" && input.openclawGatewayUrl.trim().length === 0) { - return { tone: "warning", label: "Gateway not configured" }; - } - - if (input.status?.available === false || input.status?.status === "error") { - return { tone: "error", label: "Unavailable" }; - } - - return { tone: "warning", label: "Needs verification" }; -} - -function AuthenticationStatusCard({ - provider, - status, - openclawGatewayUrl, -}: { - provider: ProviderKind; - status: ServerProviderStatus | null; - openclawGatewayUrl: string; -}) { - const guide = PROVIDER_AUTH_GUIDES[provider]; - const badge = getAuthenticationBadgeCopy({ - status, - provider, - openclawGatewayUrl, - }); - const badgeClassName = - badge.tone === "success" - ? "border-emerald-500/25 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300" - : badge.tone === "error" - ? "border-red-500/25 bg-red-500/10 text-red-700 dark:text-red-300" - : "border-amber-500/25 bg-amber-500/10 text-amber-700 dark:text-amber-300"; - const heading = - status !== null - ? getProviderStatusHeading(status) - : provider === "openclaw" && openclawGatewayUrl.trim().length > 0 - ? "OpenClaw gateway is configured locally" - : `${getProviderStatusLabelName(provider)} needs configuration`; - const description = - status !== null - ? getProviderStatusDescription(status) - : provider === "openclaw" && openclawGatewayUrl.trim().length > 0 - ? "OpenClaw is configured in local settings. Use Test Connection below to verify the gateway before starting a thread." - : guide.note; - return ( -
-
-
-
-

- {getProviderStatusLabelName(provider)} -

- - {badge.label} - -
-

{heading}

-

{description}

-
- {status?.checkedAt ? ( - - Checked {new Date(status.checkedAt).toLocaleString()} - - ) : null} -
- -
-
-
Install
- - {guide.installCmd ?? "Configured in-app"} - -
-
-
Authenticate
- - {guide.authCmd ?? "Use gateway password"} - -
-
-
Verify
- {guide.verifyCmd ?? "N/A"} -
-
- -

{guide.note}

-
- ); -} - -function SettingsSection({ - title, - description, - children, - actions, -}: { - title: string; - description?: string; - children: ReactNode; - actions?: ReactNode; -}) { - return ( -
-
-
-

{title}

- {description ?

{description}

: null} -
- {actions ?
{actions}
: null} -
-
- {children} -
-
- ); -} - -function SettingsNavSidebar({ - items, - activeSection, - onSelect, -}: { - items: SettingsNavItem[]; - activeSection: SettingsSectionId; - onSelect: (id: SettingsSectionId) => void; -}) { - return ( - - ); -} - -function SettingsRow({ - title, - description, - status, - resetAction, - control, - children, - onClick, -}: { - title: string; - description: string; - status?: ReactNode; - resetAction?: ReactNode; - control?: ReactNode; - children?: ReactNode; - onClick?: () => void; -}) { - return ( -
-
-
-
-

{title}

- - {resetAction} - -
-

{description}

- {status ?
{status}
: null} -
- {control ? ( -
- {control} -
- ) : null} -
- {children} -
- ); -} - -function SettingResetButton({ label, onClick }: { label: string; onClick: () => void }) { - return ( - - { - event.stopPropagation(); - onClick(); - }} - > - - - } - /> - Reset to default - - ); -} - -function getErrorMessage(error: unknown): string { - if (error instanceof Error && error.message.trim().length > 0) { - return error.message; - } - if (typeof error === "string" && error.trim().length > 0) { - return error; - } - return "Unknown error"; -} - -function BuildInfoBlock({ label, buildInfo }: { label: string; buildInfo: BuildMetadata }) { - return ( -
-
{label}
-
-
- {buildInfo.version} - - {buildInfo.surface} - - - {buildInfo.platform}/{buildInfo.arch} - -
-
- {buildInfo.channel} - - {buildInfo.commitHash ?? "unknown"} - - {buildInfo.buildTimestamp} -
-
-
- ); -} - -function BackgroundImageSettings({ - backgroundImageUrl, - backgroundImageOpacity, - defaultBackgroundImageUrl, - defaultBackgroundImageOpacity, - updateSettings, -}: { - backgroundImageUrl: string; - backgroundImageOpacity: number; - defaultBackgroundImageUrl: string; - defaultBackgroundImageOpacity: number; - updateSettings: (patch: { backgroundImageOpacity?: number; backgroundImageUrl?: string }) => void; -}) { - const hasBackground = backgroundImageUrl.trim().length > 0; - - const handleUrlChange = useCallback( - (value: string) => { - updateSettings({ - backgroundImageUrl: value, - }); - }, - [updateSettings], - ); - - const handleOpacityChange = useCallback( - (value: number) => { - updateSettings({ backgroundImageOpacity: value }); - }, - [updateSettings], - ); - - const handleReset = useCallback(() => { - updateSettings({ - backgroundImageUrl: defaultBackgroundImageUrl, - backgroundImageOpacity: defaultBackgroundImageOpacity, - }); - }, [defaultBackgroundImageOpacity, defaultBackgroundImageUrl, updateSettings]); - - return ( - <> - - ) : null - } - control={ - handleUrlChange(e.target.value)} - placeholder="https://example.com/image.jpg" - className="w-full sm:w-56" - aria-label="Background image URL" - /> - } - /> - {hasBackground && ( - - { - const value = Number(e.target.value) / 100; - handleOpacityChange(value); - }} - className="h-1.5 w-24 cursor-pointer appearance-none rounded-full bg-muted accent-foreground sm:w-28" - aria-label="Background opacity" - /> - - {Math.round(backgroundImageOpacity * 100)}% - - - } - /> - )} - - ); -} - -function SettingsRouteView() { - const { theme, setTheme, colorTheme, setColorTheme, fontFamily, setFontFamily } = useTheme(); - const { settings, defaults, updateSettings, resetSettings } = useAppSettings(); - const serverConfigQuery = useQuery(serverConfigQueryOptions()); - const openclawGatewayConfigQuery = useQuery(openclawGatewayConfigQueryOptions()); - const queryClient = useQueryClient(); - const trimmedBrowserPreviewStartPageUrl = settings.browserPreviewStartPageUrl.trim(); - const browserPreviewStartPageValidation = - trimmedBrowserPreviewStartPageUrl.length > 0 - ? validateHttpPreviewUrl(trimmedBrowserPreviewStartPageUrl) - : null; - const effectiveBrowserPreviewStartPageUrl = resolveBrowserPreviewStartPageUrl( - settings.browserPreviewStartPageUrl, - ); - const projects = useStore((state) => state.projects); - const threads = useStore((state) => state.threads); - const [selectedProjectId, setSelectedProjectId] = useState( - () => projects[0]?.id ?? null, - ); - const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false); - const [openKeybindingsError, setOpenKeybindingsError] = useState(null); - const [openInstallProviders, setOpenInstallProviders] = useState>({ - codex: Boolean(settings.codexBinaryPath || settings.codexHomePath), - claudeAgent: Boolean(settings.claudeBinaryPath), - gemini: false, - openclaw: Boolean(settings.openclawGatewayUrl || settings.openclawPassword), - copilot: Boolean(settings.copilotBinaryPath || settings.copilotConfigDir), - }); - const [selectedCustomModelProvider, setSelectedCustomModelProvider] = - useState("codex"); - const [customModelInputByProvider, setCustomModelInputByProvider] = useState< - Record - >({ - codex: "", - claudeAgent: "", - gemini: "", - openclaw: "", - copilot: "", - }); - const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< - Partial> - >({}); - const [showAllCustomModels, setShowAllCustomModels] = useState(false); - const [customThemeDialogOpen, setCustomThemeDialogOpen] = useState(false); - const [radiusOverride, setRadiusOverrideState] = useState(() => - getStoredRadiusOverride(), - ); - const [fontOverride, setFontOverrideState] = useState( - () => getStoredFontOverride() ?? "", - ); - const [fontSizeOverride, setFontSizeOverrideState] = useState(() => - getStoredFontSizeOverride(), - ); - const [openclawTestResult, setOpenclawTestResult] = useState( - null, - ); - const [openclawTestLoading, setOpenclawTestLoading] = useState(false); - const [openclawGatewayDraft, setOpenclawGatewayDraft] = useState(null); - const [openclawSharedSecretDraft, setOpenclawSharedSecretDraft] = useState(""); - const [openclawSaveLoading, setOpenclawSaveLoading] = useState(false); - const [openclawResetLoading, setOpenclawResetLoading] = useState<"token" | "identity" | null>( - null, - ); - const { copyToClipboard: copyOpenclawDebugReport, isCopied: openclawDebugReportCopied } = - useCopyToClipboard(); - - const globalEnvironmentVariablesQuery = useQuery(globalEnvironmentVariablesQueryOptions()); - const activeProjectId = selectedProjectId ?? projects[0]?.id ?? null; - const selectedProject = projects.find((project) => project.id === activeProjectId) ?? null; - const [projectIconDraft, setProjectIconDraft] = useState(""); - const selectedProjectEnvironmentVariablesQuery = useQuery( - projectEnvironmentVariablesQueryOptions(activeProjectId), - ); - const activeProjectPreviewThreadId = - activeProjectId === null - ? null - : (threads - .filter((thread) => thread.projectId === activeProjectId) - .toSorted((a, b) => - (b.updatedAt ?? b.createdAt).localeCompare(a.updatedAt ?? a.createdAt), - ) - .at(0)?.id ?? null); - - useEffect(() => { - if (projects.length === 0) { - if (selectedProjectId !== null) { - setSelectedProjectId(null); - } - return; - } - - if (!selectedProjectId || !projects.some((project) => project.id === selectedProjectId)) { - setSelectedProjectId(projects[0]?.id ?? null); - } - }, [projects, selectedProjectId]); - - useEffect(() => { - setProjectIconDraft(selectedProject?.iconPath ?? ""); - }, [selectedProject?.iconPath, selectedProject?.id]); - - const codexBinaryPath = settings.codexBinaryPath; - const codexHomePath = settings.codexHomePath; - const claudeBinaryPath = settings.claudeBinaryPath; - const savedOpenclawGatewayUrl = openclawGatewayConfigQuery.data?.gatewayUrl ?? ""; - const savedOpenclawHasSharedSecret = openclawGatewayConfigQuery.data?.hasSharedSecret ?? false; - const effectiveOpenclawGatewayUrl = openclawGatewayDraft ?? savedOpenclawGatewayUrl; - const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; - const availableEditors = serverConfigQuery.data?.availableEditors; - const providerStatuses = serverConfigQuery.data?.providers ?? []; - const selectableProviders = getSelectableThreadProviders({ - statuses: providerStatuses, - openclawGatewayUrl: effectiveOpenclawGatewayUrl, - }); - const canImportLegacyOpenclawSettings = - openclawGatewayConfigQuery.isSuccess && - !savedOpenclawGatewayUrl && - Boolean(settings.openclawGatewayUrl || settings.openclawPassword); - - const gitTextGenerationModelOptions = getAppModelOptions( - "codex", - settings.customCodexModels, - settings.textGenerationModel, - ); - const currentGitTextGenerationModel = - settings.textGenerationModel ?? DEFAULT_GIT_TEXT_GENERATION_MODEL; - const defaultGitTextGenerationModel = - defaults.textGenerationModel ?? DEFAULT_GIT_TEXT_GENERATION_MODEL; - const isGitTextGenerationModelDirty = - currentGitTextGenerationModel !== defaultGitTextGenerationModel; - const selectedGitTextGenerationModelLabel = - gitTextGenerationModelOptions.find((option) => option.slug === currentGitTextGenerationModel) - ?.name ?? currentGitTextGenerationModel; - const selectedCustomModelProviderSettings = MODEL_PROVIDER_SETTINGS.find( - (providerSettings) => providerSettings.provider === selectedCustomModelProvider, - )!; - const selectedCustomModelInput = customModelInputByProvider[selectedCustomModelProvider]; - const selectedCustomModelError = customModelErrorByProvider[selectedCustomModelProvider] ?? null; - const totalCustomModels = settings.customCodexModels.length + settings.customClaudeModels.length; - const activeProjectEnvironmentVariables = selectedProjectEnvironmentVariablesQuery.data?.entries; - const savedCustomModelRows = MODEL_PROVIDER_SETTINGS.flatMap((providerSettings) => - getCustomModelsForProvider(settings, providerSettings.provider).map((slug) => ({ - key: `${providerSettings.provider}:${slug}`, - provider: providerSettings.provider, - providerTitle: providerSettings.title, - slug, - })), - ); - const visibleCustomModelRows = showAllCustomModels - ? savedCustomModelRows - : savedCustomModelRows.slice(0, 5); - const isInstallSettingsDirty = - settings.claudeBinaryPath !== defaults.claudeBinaryPath || - settings.codexBinaryPath !== defaults.codexBinaryPath || - settings.codexHomePath !== defaults.codexHomePath; - const isOpenClawSettingsDirty = - (openclawGatewayDraft !== null && openclawGatewayDraft !== savedOpenclawGatewayUrl) || - openclawSharedSecretDraft.length > 0; - const changedSettingLabels = [ - ...(theme !== "system" ? ["Theme"] : []), - ...(colorTheme !== DEFAULT_COLOR_THEME ? ["Color theme"] : []), - ...(fontFamily !== "inter" ? ["Font"] : []), - ...(settings.prReviewRequestChangesTone !== DEFAULT_PR_REVIEW_REQUEST_CHANGES_TONE - ? ["PR request changes button"] - : []), - ...(settings.timestampFormat !== defaults.timestampFormat ? ["Time format"] : []), - ...(settings.showStitchBorder !== defaults.showStitchBorder ? ["Stitch border"] : []), - ...(settings.enableAssistantStreaming !== defaults.enableAssistantStreaming - ? ["Assistant output"] - : []), - ...(settings.showReasoningContent !== defaults.showReasoningContent - ? ["Reasoning content"] - : []), - ...(settings.showAuthFailuresAsErrors !== defaults.showAuthFailuresAsErrors - ? ["Auth failure errors"] - : []), - ...(settings.showNotificationDetails !== defaults.showNotificationDetails - ? ["Notification details"] - : []), - ...(settings.includeDiagnosticsTipsInCopy !== defaults.includeDiagnosticsTipsInCopy - ? ["Diagnostics copy tips"] - : []), - ...(settings.openLinksExternally !== defaults.openLinksExternally - ? ["Open links externally"] - : []), - ...(settings.codeViewerAutosave !== defaults.codeViewerAutosave - ? ["Code preview autosave"] - : []), - ...(settings.defaultThreadEnvMode !== defaults.defaultThreadEnvMode ? ["New thread mode"] : []), - ...(settings.autoUpdateWorktreeBaseBranch !== defaults.autoUpdateWorktreeBaseBranch - ? ["Worktree base refresh"] - : []), - ...(settings.confirmThreadDelete !== defaults.confirmThreadDelete - ? ["Delete confirmation"] - : []), - ...(settings.autoDeleteMergedThreads !== defaults.autoDeleteMergedThreads - ? ["Auto-delete merged threads"] - : []), - ...(settings.autoDeleteMergedThreadsDelayMinutes !== - defaults.autoDeleteMergedThreadsDelayMinutes - ? ["Auto-delete delay"] - : []), - ...(settings.rebaseBeforeCommit !== defaults.rebaseBeforeCommit - ? ["Rebase before commit"] - : []), - ...(isGitTextGenerationModelDirty ? ["Git writing model"] : []), - ...(settings.customCodexModels.length > 0 || - settings.customClaudeModels.length > 0 || - settings.customOpenClawModels.length > 0 - ? ["Custom models"] - : []), - ...(isInstallSettingsDirty ? ["Provider installs"] : []), - ...(isOpenClawSettingsDirty ? ["OpenClaw gateway"] : []), - ...(settings.backgroundImageUrl !== defaults.backgroundImageUrl ? ["Background image"] : []), - ...(settings.backgroundImageOpacity !== defaults.backgroundImageOpacity - ? ["Background opacity"] - : []), - ...(settings.sidebarOpacity !== defaults.sidebarOpacity ? ["Sidebar opacity"] : []), - ...(settings.sidebarProjectRowHeight !== defaults.sidebarProjectRowHeight - ? ["Project height"] - : []), - ...(settings.sidebarThreadRowHeight !== defaults.sidebarThreadRowHeight - ? ["Thread height"] - : []), - ...(settings.sidebarFontSize !== defaults.sidebarFontSize ? ["Sidebar font size"] : []), - ...(settings.sidebarSpacing !== defaults.sidebarSpacing ? ["Sidebar spacing"] : []), - ...(radiusOverride !== null ? ["Border radius"] : []), - ...(fontOverride ? ["Font family"] : []), - ...(fontSizeOverride !== null ? ["Code font size"] : []), - ]; - - const openTweakcn = useCallback(() => { - void openUrlInAppBrowser({ - url: "https://tweakcn.com", - projectId: activeProjectId, - threadId: activeProjectPreviewThreadId, - popOut: true, - nativeApi: readNativeApi(), - }).catch(() => { - const nativeApi = ensureNativeApi(); - return nativeApi.shell.openExternal("https://tweakcn.com"); - }); - }, [activeProjectId, activeProjectPreviewThreadId]); - - const openKeybindingsFile = useCallback(() => { - if (!keybindingsConfigPath) return; - setOpenKeybindingsError(null); - setIsOpeningKeybindings(true); - const api = ensureNativeApi(); - const editor = resolveAndPersistPreferredEditor(availableEditors ?? []); - if (!editor) { - setOpenKeybindingsError("No available editors found."); - setIsOpeningKeybindings(false); - return; - } - void api.shell - .openInEditor(keybindingsConfigPath, editor) - .catch((error) => { - setOpenKeybindingsError( - error instanceof Error ? error.message : "Unable to open keybindings file.", - ); - }) - .finally(() => { - setIsOpeningKeybindings(false); - }); - }, [availableEditors, keybindingsConfigPath]); - - const replaceKeybindingRules = useCallback( - async (command: KeybindingCommand, rules: readonly KeybindingRule[]) => { - const api = ensureNativeApi(); - await api.server.replaceKeybindingRules({ command, rules: [...rules] }); - await queryClient.invalidateQueries({ queryKey: serverQueryKeys.all }); - }, - [queryClient], - ); - - const refreshProviderStatuses = useCallback(async () => { - await queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() }); - }, [queryClient]); - - const saveGlobalEnvironmentVariables = useCallback( - async (entries: ReadonlyArray<{ key: string; value: string }>) => { - const api = ensureNativeApi(); - const result = await api.server.saveGlobalEnvironmentVariables({ entries }); - queryClient.setQueryData(environmentVariablesQueryKeys.global(), result); - return result.entries; - }, - [queryClient], - ); - - const saveProjectEnvironmentVariables = useCallback( - async (entries: ReadonlyArray<{ key: string; value: string }>) => { - if (!selectedProject) { - throw new Error("Select a project before saving project variables."); - } - const api = ensureNativeApi(); - const result = await api.server.saveProjectEnvironmentVariables({ - projectId: selectedProject.id, - entries, - }); - queryClient.setQueryData(environmentVariablesQueryKeys.project(selectedProject.id), result); - return result.entries; - }, - [queryClient, selectedProject], - ); - - const saveProjectIconOverride = useCallback(async () => { - if (!selectedProject) { - throw new Error("Select a project before saving the project icon."); - } - const nextIconPath = normalizeProjectIconPath(projectIconDraft); - const currentIconPath = normalizeProjectIconPath(selectedProject.iconPath); - if (nextIconPath === currentIconPath) { - return; - } - - const api = ensureNativeApi(); - await updateProjectIconOverride(api, selectedProject.id, nextIconPath); - }, [projectIconDraft, selectedProject]); - - const testOpenclawGateway = useCallback(async () => { - if (openclawTestLoading) return; - setOpenclawTestLoading(true); - setOpenclawTestResult(null); - try { - const api = ensureNativeApi(); - const gatewayUrl = effectiveOpenclawGatewayUrl.trim(); - const sharedSecret = openclawSharedSecretDraft.trim(); - const result = await api.server.testOpenclawGateway({ - ...(gatewayUrl ? { gatewayUrl } : {}), - password: sharedSecret || undefined, - }); - setOpenclawTestResult(result); - } catch (err) { - setOpenclawTestResult({ - success: false, - steps: [], - totalDurationMs: 0, - error: err instanceof Error ? err.message : "Unexpected error during test.", - }); - } finally { - setOpenclawTestLoading(false); - } - }, [effectiveOpenclawGatewayUrl, openclawSharedSecretDraft, openclawTestLoading]); - - const handleCopyOpenclawDebugReport = useCallback(() => { - if (!openclawTestResult) return; - copyOpenclawDebugReport(formatOpenclawGatewayDebugReport(openclawTestResult), undefined); - }, [copyOpenclawDebugReport, openclawTestResult]); - - const saveOpenclawGatewayConfig = useCallback(async () => { - if (openclawSaveLoading) return; - const gatewayUrl = effectiveOpenclawGatewayUrl.trim(); - if (!gatewayUrl) { - throw new Error("Gateway URL is required."); - } - - setOpenclawSaveLoading(true); - try { - const api = ensureNativeApi(); - const sharedSecret = openclawSharedSecretDraft.trim(); - const summary = await api.server.saveOpenclawGatewayConfig({ - gatewayUrl, - ...(sharedSecret ? { sharedSecret } : {}), - }); - queryClient.setQueryData(serverQueryKeys.openclawGatewayConfig(), summary); - setOpenclawGatewayDraft(null); - setOpenclawSharedSecretDraft(""); - setOpenclawTestResult(null); - } finally { - setOpenclawSaveLoading(false); - } - }, [effectiveOpenclawGatewayUrl, openclawSaveLoading, openclawSharedSecretDraft, queryClient]); - - const clearSavedOpenclawSharedSecret = useCallback(async () => { - const gatewayUrl = effectiveOpenclawGatewayUrl.trim(); - if (!gatewayUrl) { - throw new Error("Gateway URL is required before clearing the saved secret."); - } - - setOpenclawSaveLoading(true); - try { - const api = ensureNativeApi(); - const summary = await api.server.saveOpenclawGatewayConfig({ - gatewayUrl, - clearSharedSecret: true, - }); - queryClient.setQueryData(serverQueryKeys.openclawGatewayConfig(), summary); - setOpenclawSharedSecretDraft(""); - setOpenclawTestResult(null); - } finally { - setOpenclawSaveLoading(false); - } - }, [effectiveOpenclawGatewayUrl, queryClient]); - - const resetOpenclawDeviceState = useCallback( - async (regenerateIdentity: boolean) => { - if (openclawResetLoading) return; - setOpenclawResetLoading(regenerateIdentity ? "identity" : "token"); - try { - const api = ensureNativeApi(); - const summary = await api.server.resetOpenclawGatewayDeviceState({ - regenerateIdentity, - }); - queryClient.setQueryData(serverQueryKeys.openclawGatewayConfig(), summary); - setOpenclawTestResult(null); - } finally { - setOpenclawResetLoading(null); - } - }, - [openclawResetLoading, queryClient], - ); - - const importLegacyOpenclawSettings = useCallback(async () => { - const gatewayUrl = settings.openclawGatewayUrl.trim(); - if (!gatewayUrl) { - throw new Error("Legacy OpenClaw settings do not contain a gateway URL."); - } - - setOpenclawSaveLoading(true); - try { - const api = ensureNativeApi(); - const sharedSecret = settings.openclawPassword.trim(); - const summary = await api.server.saveOpenclawGatewayConfig({ - gatewayUrl, - ...(sharedSecret ? { sharedSecret } : {}), - }); - queryClient.setQueryData(serverQueryKeys.openclawGatewayConfig(), summary); - updateSettings({ - openclawGatewayUrl: defaults.openclawGatewayUrl, - openclawPassword: defaults.openclawPassword, - }); - setOpenclawGatewayDraft(null); - setOpenclawSharedSecretDraft(""); - setOpenclawTestResult(null); - } finally { - setOpenclawSaveLoading(false); - } - }, [ - defaults.openclawGatewayUrl, - defaults.openclawPassword, - queryClient, - settings.openclawGatewayUrl, - settings.openclawPassword, - updateSettings, - ]); - - const addCustomModel = useCallback( - (provider: ProviderKind) => { - const customModelInput = customModelInputByProvider[provider]; - const customModels = getCustomModelsForProvider(settings, provider); - const normalized = normalizeModelSlug(customModelInput, provider); - if (!normalized) { - setCustomModelErrorByProvider((existing) => ({ - ...existing, - [provider]: "Enter a model slug.", - })); - return; - } - if (getModelOptions(provider).some((option) => option.slug === normalized)) { - setCustomModelErrorByProvider((existing) => ({ - ...existing, - [provider]: "That model is already built in.", - })); - return; - } - if (normalized.length > MAX_CUSTOM_MODEL_LENGTH) { - setCustomModelErrorByProvider((existing) => ({ - ...existing, - [provider]: `Model slugs must be ${MAX_CUSTOM_MODEL_LENGTH} characters or less.`, - })); - return; - } - if (customModels.includes(normalized)) { - setCustomModelErrorByProvider((existing) => ({ - ...existing, - [provider]: "That custom model is already saved.", - })); - return; - } - - updateSettings(patchCustomModels(provider, [...customModels, normalized])); - setCustomModelInputByProvider((existing) => ({ - ...existing, - [provider]: "", - })); - setCustomModelErrorByProvider((existing) => ({ - ...existing, - [provider]: null, - })); - }, - [customModelInputByProvider, settings, updateSettings], - ); - - const removeCustomModel = useCallback( - (provider: ProviderKind, slug: string) => { - const customModels = getCustomModelsForProvider(settings, provider); - updateSettings( - patchCustomModels( - provider, - customModels.filter((model) => model !== slug), - ), - ); - setCustomModelErrorByProvider((existing) => ({ - ...existing, - [provider]: null, - })); - }, - [settings, updateSettings], - ); - - async function restoreDefaults() { - if (changedSettingLabels.length === 0) return; - - const api = readNativeApi(); - const confirmed = await (api ?? ensureNativeApi()).dialogs.confirm( - ["Restore default settings?", `This will reset: ${changedSettingLabels.join(", ")}.`].join( - "\n", - ), - ); - if (!confirmed) return; - - setTheme("system"); - setColorTheme(DEFAULT_COLOR_THEME); - setFontFamily("inter"); - resetSettings(); - setOpenInstallProviders({ - codex: false, - claudeAgent: false, - gemini: false, - openclaw: false, - copilot: false, - }); - setSelectedCustomModelProvider("codex"); - setCustomModelInputByProvider({ - codex: "", - claudeAgent: "", - gemini: "", - openclaw: "", - copilot: "", - }); - setCustomModelErrorByProvider({}); - - // Reset custom theme + overrides - clearStoredCustomTheme(); - removeCustomTheme(); - clearRadiusOverride(); - setRadiusOverrideState(null); - clearFontOverride(); - setFontOverrideState(""); - clearFontSizeOverride(); - setFontSizeOverrideState(null); - } - - const navItems = useSettingsNavItems(); - const [activeSection, setActiveSection] = useState("general"); - const activeSectionLabel = navItems.find((item) => item.id === activeSection)?.label ?? "General"; +import { SettingsRouteContextProvider } from "../components/settings/SettingsRouteContext"; +function SettingsLayoutRoute() { return ( - -
- {/* Header */} - {!isElectron && ( -
-
- -
- Settings - / - {activeSectionLabel} -
-
- -
-
-
- )} - - {isElectron && ( -
-
- Settings - / - {activeSectionLabel} -
-
- -
-
- )} - - {/* Body: sidebar + content */} -
- {/* Settings sidebar navigation */} - - - {/* Main content area */} -
- {/* Mobile section selector (visible on small screens) */} -
- -
- -
-
- {activeSection === "general" && ( - - setTheme("system")} /> - ) : null - } - control={ - - } - /> - - { - setColorTheme(DEFAULT_COLOR_THEME); - clearStoredCustomTheme(); - removeCustomTheme(); - }} - /> - ) : null - } - control={ -
- - - - - - } - /> - - Open tweakcn in the in-app browser - - - - setCustomThemeDialogOpen(true)} - aria-label="Import custom theme" - > - - - } - /> - Import from tweakcn.com - -
- } - /> - - { - clearRadiusOverride(); - setRadiusOverrideState(null); - }} - /> - ) : null - } - control={ -
- { - const value = Number.parseFloat(e.target.value); - setRadiusOverrideState(value); - setStoredRadiusOverride(value); - }} - className="h-1.5 w-24 cursor-pointer appearance-none rounded-full bg-muted accent-foreground sm:w-28" - aria-label="Border radius" - /> - - {(radiusOverride ?? 0.625).toFixed(2)}rem - -
- } - /> - - { - clearFontSizeOverride(); - setFontSizeOverrideState(null); - }} - /> - ) : null - } - control={ -
- { - const value = Number.parseFloat(e.target.value); - setFontSizeOverrideState(value); - setStoredFontSizeOverride(value); - }} - className="h-1.5 w-24 cursor-pointer appearance-none rounded-full bg-muted accent-foreground sm:w-28" - aria-label="Code font size" - /> - - {fontSizeOverride ?? 12}px - -
- } - /> - - { - clearFontOverride(); - setFontOverrideState(""); - }} - /> - ) : null - } - control={ - { - const value = e.target.value; - setFontOverrideState(value); - if (value.trim()) { - setStoredFontOverride(value); - } else { - clearFontOverride(); - } - }} - placeholder="e.g. Inter, sans-serif" - spellCheck={false} - aria-label="Font family override" - /> - } - /> - - { - applyCustomTheme(theme); - setColorTheme("custom"); - }} - /> - - setFontFamily("inter")} /> - ) : null - } - control={ - - } - /> - - - updateSettings({ sidebarOpacity: defaults.sidebarOpacity }) - } - /> - ) : null - } - control={ -
- { - const value = Number(e.target.value) / 100; - updateSettings({ sidebarOpacity: value }); - }} - className="h-1.5 w-24 cursor-pointer appearance-none rounded-full bg-muted accent-foreground sm:w-28" - aria-label="Sidebar opacity" - /> - - {Math.round(settings.sidebarOpacity * 100)}% - -
- } - /> - - - updateSettings({ - sidebarProjectRowHeight: DEFAULT_SIDEBAR_PROJECT_ROW_HEIGHT, - }) - } - /> - ) : null - } - control={ -
- { - updateSettings({ - sidebarProjectRowHeight: Number(e.target.value), - }); - }} - className="h-1.5 w-24 cursor-pointer appearance-none rounded-full bg-muted accent-foreground sm:w-28" - aria-label="Project height" - /> - - {settings.sidebarProjectRowHeight}px - -
- } - /> - - - updateSettings({ - sidebarThreadRowHeight: DEFAULT_SIDEBAR_THREAD_ROW_HEIGHT, - }) - } - /> - ) : null - } - control={ -
- { - updateSettings({ - sidebarThreadRowHeight: Number(e.target.value), - }); - }} - className="h-1.5 w-24 cursor-pointer appearance-none rounded-full bg-muted accent-foreground sm:w-28" - aria-label="Thread height" - /> - - {settings.sidebarThreadRowHeight}px - -
- } - /> - - - updateSettings({ sidebarFontSize: DEFAULT_SIDEBAR_FONT_SIZE }) - } - /> - ) : null - } - control={ -
- { - updateSettings({ sidebarFontSize: Number(e.target.value) }); - }} - className="h-1.5 w-24 cursor-pointer appearance-none rounded-full bg-muted accent-foreground sm:w-28" - aria-label="Sidebar font size" - /> - - {settings.sidebarFontSize}px - -
- } - /> - - - updateSettings({ sidebarSpacing: DEFAULT_SIDEBAR_SPACING }) - } - /> - ) : null - } - control={ -
- { - updateSettings({ sidebarSpacing: Number(e.target.value) }); - }} - className="h-1.5 w-24 cursor-pointer appearance-none rounded-full bg-muted accent-foreground sm:w-28" - aria-label="Sidebar spacing" - /> - - {settings.sidebarSpacing}px - -
- } - /> - - - - - updateSettings({ - sidebarAccentProjectNames: defaults.sidebarAccentProjectNames, - }) - } - /> - ) : null - } - control={ - - updateSettings({ - sidebarAccentProjectNames: Boolean(checked), - }) - } - aria-label="Accent project names" - /> - } - /> - - - updateSettings({ - sidebarAccentColorOverride: undefined, - }) - } - /> - ) : null - } - control={ -
- - { - const value = e.target.value.trim(); - updateSettings({ - sidebarAccentColorOverride: value || undefined, - }); - }} - className="h-8 w-28 rounded-md border border-border bg-background px-2 text-xs text-foreground placeholder:text-muted-foreground/60 focus:outline-none focus:ring-1 focus:ring-ring sm:w-32" - aria-label="Accent color value" - /> -
- } - /> - - - updateSettings({ - sidebarAccentBgColorOverride: undefined, - }) - } - /> - ) : null - } - control={ -
- - { - const value = e.target.value.trim(); - updateSettings({ - sidebarAccentBgColorOverride: value || undefined, - }); - }} - className="h-8 w-28 rounded-md border border-border bg-background px-2 text-xs text-foreground placeholder:text-muted-foreground/60 focus:outline-none focus:ring-1 focus:ring-ring sm:w-32" - aria-label="Accent background color value" - /> -
- } - /> - - - updateSettings({ - prReviewRequestChangesTone: DEFAULT_PR_REVIEW_REQUEST_CHANGES_TONE, - }) - } - /> - ) : null - } - control={ - - } - /> - - - updateSettings({ - timestampFormat: defaults.timestampFormat, - }) - } - /> - ) : null - } - control={ - - } - /> - - - updateSettings({ - showStitchBorder: defaults.showStitchBorder, - }) - } - /> - ) : null - } - control={ - - updateSettings({ - showStitchBorder: Boolean(checked), - }) - } - aria-label="Show stitch border" - /> - } - /> - - - updateSettings({ - enableAssistantStreaming: defaults.enableAssistantStreaming, - }) - } - /> - ) : null - } - control={ - - updateSettings({ - enableAssistantStreaming: Boolean(checked), - }) - } - aria-label="Stream assistant messages" - /> - } - /> - - - updateSettings({ - showReasoningContent: defaults.showReasoningContent, - }) - } - /> - ) : null - } - control={ - - updateSettings({ - showReasoningContent: Boolean(checked), - }) - } - aria-label="Show reasoning content in work log" - /> - } - /> - - - updateSettings({ - showAuthFailuresAsErrors: defaults.showAuthFailuresAsErrors, - }) - } - /> - ) : null - } - control={ - - updateSettings({ - showAuthFailuresAsErrors: Boolean(checked), - }) - } - aria-label="Show authentication failures as thread errors" - /> - } - /> - - - updateSettings({ - showNotificationDetails: defaults.showNotificationDetails, - }) - } - /> - ) : null - } - control={ - - updateSettings({ - showNotificationDetails: Boolean(checked), - }) - } - aria-label="Show notification details by default" - /> - } - /> - - - updateSettings({ - includeDiagnosticsTipsInCopy: defaults.includeDiagnosticsTipsInCopy, - }) - } - /> - ) : null - } - control={ - - updateSettings({ - includeDiagnosticsTipsInCopy: Boolean(checked), - }) - } - aria-label="Include diagnostics tips in copied text" - /> - } - /> - - - updateSettings({ - openLinksExternally: defaults.openLinksExternally, - }) - } - /> - ) : null - } - control={ - - updateSettings({ - openLinksExternally: Boolean(checked), - }) - } - aria-label="Open links externally" - /> - } - /> - - - Blank uses the default start page:{" "} - {DEFAULT_BROWSER_PREVIEW_START_PAGE_URL} - - ) : browserPreviewStartPageValidation?.ok ? ( - <> - New blank preview tabs will open at{" "} - {browserPreviewStartPageValidation.url}. - - ) : ( - <> - - Invalid URL. Falling back to{" "} - {DEFAULT_BROWSER_PREVIEW_START_PAGE_URL}. - - - Effective start page:{" "} - {effectiveBrowserPreviewStartPageUrl} - - - ) - } - resetAction={ - settings.browserPreviewStartPageUrl !== - defaults.browserPreviewStartPageUrl ? ( - - updateSettings({ - browserPreviewStartPageUrl: defaults.browserPreviewStartPageUrl, - }) - } - /> - ) : null - } - control={ - - updateSettings({ - browserPreviewStartPageUrl: event.target.value, - }) - } - placeholder={DEFAULT_BROWSER_PREVIEW_START_PAGE_URL} - aria-label="Browser preview start page" - autoCapitalize="off" - autoCorrect="off" - spellCheck={false} - className="w-full sm:w-72" - /> - } - /> - - - updateSettings({ - codeViewerAutosave: defaults.codeViewerAutosave, - }) - } - /> - ) : null - } - control={ - - updateSettings({ - codeViewerAutosave: Boolean(checked), - }) - } - aria-label="Enable code preview autosave" - /> - } - /> - - - updateSettings({ - defaultThreadEnvMode: defaults.defaultThreadEnvMode, - }) - } - /> - ) : null - } - control={ - - } - /> - - - updateSettings({ - autoUpdateWorktreeBaseBranch: defaults.autoUpdateWorktreeBaseBranch, - }) - } - /> - ) : null - } - control={ - - updateSettings({ - autoUpdateWorktreeBaseBranch: Boolean(checked), - }) - } - aria-label="Refresh base branch before creating new worktrees" - /> - } - /> - - - updateSettings({ - confirmThreadDelete: defaults.confirmThreadDelete, - }) - } - /> - ) : null - } - control={ - - updateSettings({ - confirmThreadDelete: Boolean(checked), - }) - } - aria-label="Confirm thread deletion" - /> - } - /> - - - updateSettings({ - autoDeleteMergedThreads: defaults.autoDeleteMergedThreads, - }) - } - /> - ) : null - } - control={ - - updateSettings({ - autoDeleteMergedThreads: Boolean(checked), - }) - } - aria-label="Auto-delete merged threads" - /> - } - /> - - {settings.autoDeleteMergedThreads ? ( - - updateSettings({ - autoDeleteMergedThreadsDelayMinutes: - defaults.autoDeleteMergedThreadsDelayMinutes, - }) - } - /> - ) : null - } - control={ - - } - /> - ) : null} -
- )} - - {activeSection === "authentication" && ( - void refreshProviderStatuses()} - > - - Refresh status - - } - > - -
- {(["codex", "claudeAgent", "openclaw"] as const).map((provider) => ( - status.provider === provider) ?? - null - } - openclawGatewayUrl={effectiveOpenclawGatewayUrl} - /> - ))} -
-
- - { - updateSettings({ - claudeBinaryPath: defaults.claudeBinaryPath, - codexBinaryPath: defaults.codexBinaryPath, - codexHomePath: defaults.codexHomePath, - }); - setOpenInstallProviders({ - codex: false, - claudeAgent: false, - gemini: false, - openclaw: false, - copilot: false, - }); - }} - /> - ) : null - } - > -
-
- {INSTALL_PROVIDER_SETTINGS.map((providerSettings) => { - const isOpen = openInstallProviders[providerSettings.provider]; - const isDirty = - providerSettings.provider === "codex" - ? settings.codexBinaryPath !== defaults.codexBinaryPath || - settings.codexHomePath !== defaults.codexHomePath - : settings.claudeBinaryPath !== defaults.claudeBinaryPath; - const binaryPathValue = - providerSettings.binaryPathKey === "claudeBinaryPath" - ? claudeBinaryPath - : codexBinaryPath; - - return ( - - setOpenInstallProviders((existing) => ({ - ...existing, - [providerSettings.provider]: open, - })) - } - > -
- - - -
-
- - {providerSettings.homePathKey ? ( - - ) : null} -
-
-
-
-
- ); - })} -
-
-
- - { - setOpenclawGatewayDraft(null); - setOpenclawSharedSecretDraft(""); - setOpenclawTestResult(null); - }} - /> - ) : null - } - > -
- {canImportLegacyOpenclawSettings ? ( -
-
-
- Legacy local settings were found for OpenClaw. Import them into the - persisted gateway config to unlock saved credentials and device - state. -
- -
-
- ) : null} - - - -
-
- Saved gateway:{" "} - - {savedOpenclawGatewayUrl || "Not saved"} - -
-
- Saved shared secret:{" "} - - {savedOpenclawHasSharedSecret ? "Configured" : "Not configured"} - -
-
- Device fingerprint:{" "} - - {openclawGatewayConfigQuery.data?.deviceFingerprint ?? "Not created"} - -
-
- Cached device token:{" "} - - {openclawGatewayConfigQuery.data?.hasDeviceToken - ? "Configured" - : "Not configured"} - -
-
- -
-
- - - {savedOpenclawHasSharedSecret ? ( - - ) : null} - - -
-
- - {openclawTestResult ? ( -
-
- {openclawTestResult.success ? ( - - ) : ( - - )} - - {openclawTestResult.success - ? "Connection successful" - : "Connection failed"} - - - {openclawTestResult.totalDurationMs}ms total - - -
- - {openclawTestResult.steps.length > 0 ? ( -
- {openclawTestResult.steps.map((step) => ( -
- {step.status === "pass" ? ( - - ) : null} - {step.status === "fail" ? ( - - ) : null} - {step.status === "skip" ? ( - - ) : null} -
-
- - {step.name} - - - {step.durationMs}ms - -
- {step.detail ? ( - - {step.detail} - - ) : null} -
-
- ))} -
- ) : null} - - {openclawTestResult.serverInfo ? ( -
- - Server Info - -
- {openclawTestResult.serverInfo.version ? ( -
- Version:{" "} - - {openclawTestResult.serverInfo.version} - -
- ) : null} - {openclawTestResult.serverInfo.sessionId ? ( -
- Session:{" "} - - {openclawTestResult.serverInfo.sessionId} - -
- ) : null} -
-
- ) : null} - - {openclawTestResult.diagnostics ? ( -
- - Debugging Context - -
- {openclawTestResult.diagnostics.normalizedUrl ? ( -
- Endpoint:{" "} - - {openclawTestResult.diagnostics.normalizedUrl} - -
- ) : null} - {openclawTestResult.diagnostics.hostKind ? ( -
- Host type:{" "} - - {describeOpenclawGatewayHostKind( - openclawTestResult.diagnostics.hostKind, - )} - -
- ) : null} - {openclawTestResult.diagnostics.resolvedAddresses.length > 0 ? ( -
- Resolved:{" "} - - {openclawTestResult.diagnostics.resolvedAddresses.join( - ", ", - )} - -
- ) : null} - {describeOpenclawGatewayHealthStatus(openclawTestResult) ? ( -
- Health probe:{" "} - - {describeOpenclawGatewayHealthStatus(openclawTestResult)} - - {openclawTestResult.diagnostics.healthUrl ? ( - <> - {" "} - at{" "} - - {openclawTestResult.diagnostics.healthUrl} - - - ) : null} -
- ) : null} - {openclawTestResult.diagnostics.socketCloseCode !== undefined ? ( -
- Socket close:{" "} - - {openclawTestResult.diagnostics.socketCloseCode} - {openclawTestResult.diagnostics.socketCloseReason - ? ` (${openclawTestResult.diagnostics.socketCloseReason})` - : ""} - -
- ) : null} - {openclawTestResult.diagnostics.socketError ? ( -
- Socket error:{" "} - - {openclawTestResult.diagnostics.socketError} - -
- ) : null} - {openclawTestResult.diagnostics.gatewayErrorCode ? ( -
- Gateway error code:{" "} - - {openclawTestResult.diagnostics.gatewayErrorCode} - -
- ) : null} - {openclawTestResult.diagnostics.gatewayErrorDetailCode ? ( -
- Gateway detail code:{" "} - - {openclawTestResult.diagnostics.gatewayErrorDetailCode} - -
- ) : null} - {openclawTestResult.diagnostics.gatewayErrorDetailReason ? ( -
- Gateway detail reason:{" "} - - {openclawTestResult.diagnostics.gatewayErrorDetailReason} - -
- ) : null} - {openclawTestResult.diagnostics.gatewayRecommendedNextStep ? ( -
- Gateway next step:{" "} - - {openclawTestResult.diagnostics.gatewayRecommendedNextStep} - -
- ) : null} - {openclawTestResult.diagnostics.gatewayCanRetryWithDeviceToken !== - undefined ? ( -
- Device-token retry available:{" "} - - {openclawTestResult.diagnostics - .gatewayCanRetryWithDeviceToken - ? "Yes" - : "No"} - -
- ) : null} - {openclawTestResult.diagnostics.observedNotifications.length > - 0 ? ( -
- Gateway events:{" "} - - {openclawTestResult.diagnostics.observedNotifications.join( - ", ", - )} - -
- ) : null} -
-
- ) : null} - - {openclawTestResult.diagnostics?.hints.length ? ( -
- - Troubleshooting - -
    - {openclawTestResult.diagnostics.hints.map((hint) => ( -
  • - - {hint} -
  • - ))} -
-
- ) : null} - - {openclawTestResult.error && - !openclawTestResult.steps.some((step) => step.status === "fail") ? ( -
- {openclawTestResult.error} -
- ) : null} -
- ) : null} -
-
-
- )} - - {activeSection === "hotkeys" && ( - - )} - - {activeSection === "environment" && ( - - - Failed to load saved variables:{" "} - {getErrorMessage(globalEnvironmentVariablesQuery.error)} - - ) : globalEnvironmentVariablesQuery.isFetching ? ( - Loading saved variables... - ) : globalEnvironmentVariablesQuery.data?.entries.length ? ( - - {globalEnvironmentVariablesQuery.data.entries.length} saved variables - - ) : ( - No global variables saved yet. - ) - } - > - - - - )} - - {activeSection === "projects" && ( - - - {selectedProject.name} · {selectedProject.cwd} - - ) : ( - Open a project to edit project settings. - ) - } - control={ - projects.length > 0 ? ( - - ) : ( - - No projects available. - - ) - } - /> - - - - - - - - {projectIconDraft.trim().length > 0 - ? projectIconDraft.trim() - : (selectedProject.iconPath ?? "Using the project favicon")} - - ) : ( - Open or create a project to edit the icon. - ) - } - control={ -
- setProjectIconDraft(event.target.value)} - onBlur={() => { - void saveProjectIconOverride(); - }} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - void saveProjectIconOverride(); - } else if (event.key === "Escape") { - event.preventDefault(); - setProjectIconDraft(selectedProject?.iconPath ?? ""); - } - }} - placeholder="public/icon.svg or https://example.com/icon.png" - className="w-full sm:w-64" - aria-label="Project icon path" - disabled={!selectedProject} - /> - -
- } - /> -
- )} - - {activeSection === "git" && ( - - - updateSettings({ - rebaseBeforeCommit: defaults.rebaseBeforeCommit, - }) - } - /> - ) : null - } - control={ - - updateSettings({ - rebaseBeforeCommit: Boolean(checked), - }) - } - aria-label="Rebase onto the default branch before committing" - /> - } - /> - - )} - - {activeSection === "models" && ( - - - updateSettings({ - textGenerationModel: defaults.textGenerationModel, - }) - } - /> - ) : null - } - control={ - - } - /> - - 0 ? ( - { - updateSettings({ - customCodexModels: defaults.customCodexModels, - customClaudeModels: defaults.customClaudeModels, - }); - setCustomModelErrorByProvider({}); - setShowAllCustomModels(false); - }} - /> - ) : null - } - > -
-
- - { - const value = event.target.value; - setCustomModelInputByProvider((existing) => ({ - ...existing, - [selectedCustomModelProvider]: value, - })); - if (selectedCustomModelError) { - setCustomModelErrorByProvider((existing) => ({ - ...existing, - [selectedCustomModelProvider]: null, - })); - } - }} - onKeyDown={(event) => { - if (event.key !== "Enter") return; - event.preventDefault(); - addCustomModel(selectedCustomModelProvider); - }} - placeholder={selectedCustomModelProviderSettings.example} - spellCheck={false} - /> - -
- - {selectedCustomModelError ? ( -

- {selectedCustomModelError} -

- ) : null} - - {totalCustomModels > 0 ? ( -
-
- {visibleCustomModelRows.map((row) => ( -
- - {row.providerTitle} - - - {row.slug} - - -
- ))} -
- - {savedCustomModelRows.length > 5 ? ( - - ) : null} -
- ) : null} -
-
-
- )} - - {activeSection === "mobile" && !isMobileShell && ( - - -
- -
-
-
- )} - - {activeSection === "advanced" && ( - - - - {serverConfigQuery.data?.buildInfo ? ( - - ) : null} -
- } - /> - - )} -
-
-
-
- -
+ + + ); } export const Route = createFileRoute("/_chat/settings")({ - component: SettingsRouteView, + component: SettingsLayoutRoute, }); diff --git a/bun.lock b/bun.lock index d9adbe28..98aa23fd 100644 --- a/bun.lock +++ b/bun.lock @@ -136,6 +136,7 @@ "effect": "catalog:", "node-pty": "^1.1.0", "open": "^10.1.0", + "toml": "^3.0.0", "ws": "^8.18.0", "yaml": "^2.8.1", }, diff --git a/packages/contracts/src/codexConfig.ts b/packages/contracts/src/codexConfig.ts new file mode 100644 index 00000000..bb100c1f --- /dev/null +++ b/packages/contracts/src/codexConfig.ts @@ -0,0 +1,20 @@ +import { Schema } from "effect"; + +import { TrimmedNonEmptyString } from "./baseSchemas"; + +export const ServerCodexModelProviderEntry = Schema.Struct({ + id: TrimmedNonEmptyString, + selected: Schema.Boolean, + definedInConfig: Schema.Boolean, + isBuiltIn: Schema.Boolean, + isKnownPreset: Schema.Boolean, + requiresOpenAiLogin: Schema.Boolean, +}); +export type ServerCodexModelProviderEntry = typeof ServerCodexModelProviderEntry.Type; + +export const ServerCodexConfigSummary = Schema.Struct({ + selectedModelProviderId: Schema.NullOr(TrimmedNonEmptyString), + entries: Schema.Array(ServerCodexModelProviderEntry), + parseError: Schema.NullOr(Schema.String), +}); +export type ServerCodexConfigSummary = typeof ServerCodexConfigSummary.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 2ad7d5db..f550bc53 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,5 +1,6 @@ export * from "./baseSchemas"; export * from "./buildInfo"; +export * from "./codexConfig"; export * from "./ipc"; export * from "./terminal"; export * from "./provider"; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 4193bf00..9bc3f14a 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -1,6 +1,7 @@ import { Schema } from "effect"; import { DeviceId, IsoDateTime, PairingId, TrimmedNonEmptyString } from "./baseSchemas"; import { BuildMetadata } from "./buildInfo"; +import { ServerCodexConfigSummary } from "./codexConfig"; import { KeybindingCommand, KeybindingRule, @@ -81,6 +82,7 @@ export const ServerConfig = Schema.Struct({ keybindings: ResolvedKeybindingsConfig, issues: ServerConfigIssues, providers: ServerProviderStatuses, + codexConfig: ServerCodexConfigSummary, availableEditors: Schema.Array(EditorId), buildInfo: Schema.optional(BuildMetadata), }); diff --git a/packages/shared/package.json b/packages/shared/package.json index c8e132c4..2192724b 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -8,6 +8,10 @@ "types": "./src/model.ts", "import": "./src/model.ts" }, + "./codexModelProviders": { + "types": "./src/codexModelProviders.ts", + "import": "./src/codexModelProviders.ts" + }, "./modelSelection": { "types": "./src/modelSelection.ts", "import": "./src/modelSelection.ts" diff --git a/packages/shared/src/codexModelProviders.test.ts b/packages/shared/src/codexModelProviders.test.ts new file mode 100644 index 00000000..21f068a7 --- /dev/null +++ b/packages/shared/src/codexModelProviders.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; + +import { + CODEX_BUILT_IN_MODEL_PROVIDER_IDS, + CODEX_MODEL_PROVIDER_PRESETS, + getCodexModelProviderPreset, + isCodexBuiltInModelProvider, + requiresOpenAiLoginForCodexModelProvider, +} from "./codexModelProviders"; + +describe("CODEX_MODEL_PROVIDER_PRESETS", () => { + it("ships the curated catalog in the expected order", () => { + expect(CODEX_MODEL_PROVIDER_PRESETS.map((preset) => preset.id)).toEqual([ + "openai", + "ollama", + "lmstudio", + "azure", + "cerebras", + "deepseek", + "fireworks", + "groq", + "mistral", + "openrouter", + "perplexity", + "portkey", + "together", + "xai", + ]); + }); + + it("marks built-ins separately from curated presets", () => { + expect(CODEX_BUILT_IN_MODEL_PROVIDER_IDS).toEqual(["openai", "ollama", "lmstudio"]); + expect(isCodexBuiltInModelProvider("openai")).toBe(true); + expect(isCodexBuiltInModelProvider("openrouter")).toBe(false); + }); + + it("requires OpenAI login only for openai", () => { + expect(requiresOpenAiLoginForCodexModelProvider("openai")).toBe(true); + expect(requiresOpenAiLoginForCodexModelProvider("ollama")).toBe(false); + expect(requiresOpenAiLoginForCodexModelProvider("openrouter")).toBe(false); + expect(requiresOpenAiLoginForCodexModelProvider("my-company-proxy")).toBe(false); + }); + + it("returns preset metadata when available", () => { + expect(getCodexModelProviderPreset("azure")).toEqual({ + id: "azure", + title: "Azure OpenAI", + kind: "curated", + authMode: "provider-specific", + description: "Curated preset for Azure-hosted OpenAI-compatible deployments.", + }); + expect(getCodexModelProviderPreset("unknown-provider")).toBeUndefined(); + }); +}); diff --git a/packages/shared/src/codexModelProviders.ts b/packages/shared/src/codexModelProviders.ts new file mode 100644 index 00000000..a180476f --- /dev/null +++ b/packages/shared/src/codexModelProviders.ts @@ -0,0 +1,132 @@ +export type CodexModelProviderPresetKind = "built-in" | "curated"; +export type CodexModelProviderAuthMode = "openai-login" | "local" | "provider-specific"; + +export interface CodexModelProviderPreset { + readonly id: string; + readonly title: string; + readonly kind: CodexModelProviderPresetKind; + readonly authMode: CodexModelProviderAuthMode; + readonly description: string; +} + +export const CODEX_MODEL_PROVIDER_PRESETS = [ + { + id: "openai", + title: "OpenAI", + kind: "built-in", + authMode: "openai-login", + description: "Codex's default hosted backend using OpenAI account or API-key auth.", + }, + { + id: "ollama", + title: "Ollama", + kind: "built-in", + authMode: "local", + description: "Built-in local backend for models served through Ollama.", + }, + { + id: "lmstudio", + title: "LM Studio", + kind: "built-in", + authMode: "local", + description: "Built-in local backend for models served through LM Studio.", + }, + { + id: "azure", + title: "Azure OpenAI", + kind: "curated", + authMode: "provider-specific", + description: "Curated preset for Azure-hosted OpenAI-compatible deployments.", + }, + { + id: "cerebras", + title: "Cerebras", + kind: "curated", + authMode: "provider-specific", + description: "Curated preset for Cerebras-hosted inference endpoints.", + }, + { + id: "deepseek", + title: "DeepSeek", + kind: "curated", + authMode: "provider-specific", + description: "Curated preset for DeepSeek-compatible hosted APIs.", + }, + { + id: "fireworks", + title: "Fireworks AI", + kind: "curated", + authMode: "provider-specific", + description: "Curated preset for Fireworks AI hosted inference.", + }, + { + id: "groq", + title: "Groq", + kind: "curated", + authMode: "provider-specific", + description: "Curated preset for Groq-hosted compatible APIs.", + }, + { + id: "mistral", + title: "Mistral", + kind: "curated", + authMode: "provider-specific", + description: "Curated preset for Mistral hosted APIs.", + }, + { + id: "openrouter", + title: "OpenRouter", + kind: "curated", + authMode: "provider-specific", + description: "Curated preset for OpenRouter's OpenAI-compatible gateway.", + }, + { + id: "perplexity", + title: "Perplexity", + kind: "curated", + authMode: "provider-specific", + description: "Curated preset for Perplexity-hosted APIs.", + }, + { + id: "portkey", + title: "Portkey", + kind: "curated", + authMode: "provider-specific", + description: "Curated preset for Portkey proxy and gateway setups.", + }, + { + id: "together", + title: "Together AI", + kind: "curated", + authMode: "provider-specific", + description: "Curated preset for Together AI hosted APIs.", + }, + { + id: "xai", + title: "xAI", + kind: "curated", + authMode: "provider-specific", + description: "Curated preset for xAI-hosted compatible APIs.", + }, +] as const satisfies readonly CodexModelProviderPreset[]; + +export const CODEX_BUILT_IN_MODEL_PROVIDER_IDS = CODEX_MODEL_PROVIDER_PRESETS.filter( + (preset) => preset.kind === "built-in", +).map((preset) => preset.id); + +const CODEX_MODEL_PROVIDER_PRESET_BY_ID = new Map( + CODEX_MODEL_PROVIDER_PRESETS.map((preset) => [preset.id, preset] as const), +); +const CODEX_BUILT_IN_MODEL_PROVIDER_ID_SET = new Set(CODEX_BUILT_IN_MODEL_PROVIDER_IDS); + +export function getCodexModelProviderPreset(id: string): CodexModelProviderPreset | undefined { + return CODEX_MODEL_PROVIDER_PRESET_BY_ID.get(id); +} + +export function isCodexBuiltInModelProvider(id: string): boolean { + return CODEX_BUILT_IN_MODEL_PROVIDER_ID_SET.has(id); +} + +export function requiresOpenAiLoginForCodexModelProvider(id: string): boolean { + return id === "openai"; +} From 4eee4ee03b1915660d330b092748dff44600d92f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 09:54:41 +0000 Subject: [PATCH 2/2] fix: require status=ready in claudeAgent auth-token-helper override Agent-Logs-Url: https://github.com/OpenKnots/okcode/sessions/2fef1788-3f93-4657-95e2-921b05c337f3 Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com> --- apps/web/src/lib/providerAvailability.test.ts | 24 +++++++++++++++++++ apps/web/src/lib/providerAvailability.ts | 1 + 2 files changed, 25 insertions(+) diff --git a/apps/web/src/lib/providerAvailability.test.ts b/apps/web/src/lib/providerAvailability.test.ts index 53c4fb68..43ef604f 100644 --- a/apps/web/src/lib/providerAvailability.test.ts +++ b/apps/web/src/lib/providerAvailability.test.ts @@ -49,6 +49,30 @@ describe("providerAvailability", () => { ).toBe(false); }); + it("blocks claudeAgent when status is error even if claudeAuthTokenHelperCommand is set and available is true", () => { + expect( + isProviderReadyForThreadSelection({ + provider: "claudeAgent", + statuses: [ + makeStatus("claudeAgent", { status: "error", available: true, authStatus: "unauthenticated" }), + ], + claudeAuthTokenHelperCommand: "my-token-helper", + }), + ).toBe(false); + }); + + it("allows claudeAgent with claudeAuthTokenHelperCommand when status is ready but auth is unauthenticated", () => { + expect( + isProviderReadyForThreadSelection({ + provider: "claudeAgent", + statuses: [ + makeStatus("claudeAgent", { status: "ready", available: true, authStatus: "unauthenticated" }), + ], + claudeAuthTokenHelperCommand: "my-token-helper", + }), + ).toBe(true); + }); + it("treats configured OpenClaw as selectable even when server auth state is unknown", () => { expect( isProviderReadyForThreadSelection({ diff --git a/apps/web/src/lib/providerAvailability.ts b/apps/web/src/lib/providerAvailability.ts index c0d86da8..c6765010 100644 --- a/apps/web/src/lib/providerAvailability.ts +++ b/apps/web/src/lib/providerAvailability.ts @@ -50,6 +50,7 @@ export function isProviderReadyForThreadSelection(input: { input.provider === "claudeAgent" && (input.claudeAuthTokenHelperCommand ?? "").trim().length > 0 && status.available && + status.status === "ready" && (status.authStatus ?? status.auth?.status) === "unauthenticated" ) { return true;