From 11e0a01e7361003dfc06a5166cd17b1a81378407 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sun, 12 Apr 2026 23:12:29 -0500 Subject: [PATCH] Support Claude auth tokens and OAuth failure handling - Let Claude use ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN - Surface clearer auth health and runtime errors for unsupported OAuth - Add settings UI for helper commands and token presets --- apps/server/src/doctor.ts | 4 +- .../src/provider/Layers/ClaudeAdapter.test.ts | 91 + .../src/provider/Layers/ClaudeAdapter.ts | 64 +- .../provider/Layers/ProviderHealth.test.ts | 40 +- .../src/provider/Layers/ProviderHealth.ts | 71 +- .../provider/claudeAuthTokenHelper.test.ts | 58 + .../src/provider/claudeAuthTokenHelper.ts | 100 + apps/server/src/sme/authValidation.ts | 2 +- apps/web/src/appSettings.test.ts | 21 + apps/web/src/appSettings.ts | 21 +- apps/web/src/components/ChatView.tsx | 3 +- .../EnvironmentVariablesEditor.test.tsx | 8 + .../components/EnvironmentVariablesEditor.tsx | 40 +- .../src/components/chat/ProviderSetupCard.tsx | 5 +- .../src/components/chat/threadError.test.ts | 8 +- apps/web/src/components/chat/threadError.ts | 14 +- .../components/sme/smeConversationConfig.ts | 4 +- apps/web/src/i18n/messages/en.json | 2 +- apps/web/src/i18n/messages/es.json | 2 +- apps/web/src/i18n/messages/fr.json | 2 +- apps/web/src/i18n/messages/zh-CN.json | 2 +- .../lib/claudeAuthTokenHelperPresets.test.ts | 25 + .../src/lib/claudeAuthTokenHelperPresets.ts | 23 + apps/web/src/lib/providerAvailability.test.ts | 13 +- apps/web/src/lib/providerAvailability.ts | 12 + apps/web/src/routes/_chat.settings.tsx | 3288 ++++++++++++++++- packages/contracts/src/orchestration.ts | 1 + packages/contracts/src/provider.test.ts | 4 + 28 files changed, 3885 insertions(+), 43 deletions(-) create mode 100644 apps/server/src/provider/claudeAuthTokenHelper.test.ts create mode 100644 apps/server/src/provider/claudeAuthTokenHelper.ts create mode 100644 apps/web/src/lib/claudeAuthTokenHelperPresets.test.ts create mode 100644 apps/web/src/lib/claudeAuthTokenHelperPresets.ts diff --git a/apps/server/src/doctor.ts b/apps/server/src/doctor.ts index 5577d9a0b..15d77de72 100644 --- a/apps/server/src/doctor.ts +++ b/apps/server/src/doctor.ts @@ -79,7 +79,9 @@ const doctorProgram = Effect.gen(function* () { console.log("No providers are ready. Set up at least one provider to start coding:"); console.log(""); console.log(" Codex: npm install -g @openai/codex && codex login"); - console.log(" Claude Code: npm install -g @anthropic-ai/claude-code && claude auth login"); + console.log( + " Claude Code: npm install -g @anthropic-ai/claude-code && set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN", + ); } else if (readyCount === statuses.length) { console.log("All providers are ready."); } else { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 78e64f382..547c8a1ba 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -132,6 +132,7 @@ class FakeClaudeQuery implements AsyncIterable { function makeHarness(config?: { readonly nativeEventLogPath?: string; readonly nativeEventLogger?: ClaudeAdapterLiveOptions["nativeEventLogger"]; + readonly readAuthTokenFromHelperCommand?: ClaudeAdapterLiveOptions["readAuthTokenFromHelperCommand"]; readonly cwd?: string; readonly baseDir?: string; }) { @@ -148,6 +149,11 @@ function makeHarness(config?: { createInput = input; return query; }, + ...(config?.readAuthTokenFromHelperCommand + ? { + readAuthTokenFromHelperCommand: config.readAuthTokenFromHelperCommand, + } + : {}), ...(config?.nativeEventLogger ? { nativeEventLogger: config.nativeEventLogger, @@ -237,6 +243,7 @@ async function readFirstPromptMessage( const THREAD_ID = ThreadId.makeUnsafe("thread-claude-1"); const RESUME_THREAD_ID = ThreadId.makeUnsafe("thread-claude-resume"); +const THREAD_CWD = "/tmp/claude-session-workspace"; describe("ClaudeAdapterLive", () => { it.effect("returns validation error for non-claude provider on startSession", () => { @@ -351,6 +358,90 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("sources ANTHROPIC_AUTH_TOKEN from the configured helper command", () => { + const helperCommand = "op read op://shared/anthropic/token --no-newline"; + const helperToken = "helper-token"; + let helperCall: { + readonly command: string; + readonly options?: { readonly cwd?: string }; + } | null = null; + const helper: NonNullable = ( + command, + options, + ) => { + helperCall = { + command, + ...(options?.cwd ? { options: { cwd: options.cwd } } : {}), + }; + return helperToken; + }; + const harness = makeHarness({ + readAuthTokenFromHelperCommand: helper, + }); + + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + cwd: THREAD_CWD, + runtimeMode: "full-access", + env: { + ANTHROPIC_AUTH_TOKEN: "", + }, + providerOptions: { + claudeAgent: { + authTokenHelperCommand: helperCommand, + }, + }, + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.env?.ANTHROPIC_AUTH_TOKEN, helperToken); + assert.equal(helperCall?.command, helperCommand); + assert.equal(helperCall?.options?.cwd, THREAD_CWD); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("prefers an explicit ANTHROPIC_AUTH_TOKEN over the helper command", () => { + let helperCallCount = 0; + const helper: NonNullable = () => { + helperCallCount += 1; + return "helper-token"; + }; + const harness = makeHarness({ + readAuthTokenFromHelperCommand: helper, + }); + + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + cwd: THREAD_CWD, + runtimeMode: "full-access", + env: { + ANTHROPIC_AUTH_TOKEN: "env-token", + }, + providerOptions: { + claudeAgent: { + authTokenHelperCommand: "op read op://shared/anthropic/token --no-newline", + }, + }, + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.env?.ANTHROPIC_AUTH_TOKEN, "env-token"); + assert.equal(helperCallCount, 0); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("forwards claude effort levels into query options", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 4b6e4e1c0..edf7dce53 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -75,6 +75,7 @@ import { extractTextAttachmentContents, } from "../../attachmentText.ts"; import { ServerConfig } from "../../config.ts"; +import { readClaudeAuthTokenFromHelperCommand } from "../claudeAuthTokenHelper.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, @@ -186,6 +187,7 @@ export interface ClaudeAdapterLiveOptions { readonly prompt: AsyncIterable; readonly options: ClaudeQueryOptions; }) => ClaudeQueryRuntime; + readonly readAuthTokenFromHelperCommand?: typeof readClaudeAuthTokenFromHelperCommand; readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; } @@ -209,6 +211,12 @@ function toError(cause: unknown, fallback: string): Error { return cause instanceof Error ? cause : new Error(toMessage(cause, fallback)); } +function nonEmptyTrimmed(value: string | undefined): string | undefined { + if (!value) return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + function normalizeClaudeStreamMessages(cause: Cause.Cause): ReadonlyArray { const errors = Cause.prettyErrors(cause) .map((error) => error.message.trim()) @@ -237,6 +245,27 @@ function isClaudeInterruptedCause(cause: Cause.Cause): boolean { ); } +const CLAUDE_AUTH_ERROR_PATTERNS = [ + "oauth authentication is currently not supported", + "could not resolve authentication method", + "expected either apiKey or authToken to be set", + "no access token was provided", + "no auth token was provided", +] as const; + +function isClaudeAuthErrorMessage(message: string): boolean { + const normalized = message.toLowerCase(); + return CLAUDE_AUTH_ERROR_PATTERNS.some((pattern) => normalized.includes(pattern.toLowerCase())); +} + +function isClaudeAuthCause(cause: Cause.Cause): boolean { + return normalizeClaudeStreamMessages(cause).some(isClaudeAuthErrorMessage); +} + +function claudeAuthFailureMessage(): string { + return "Claude Code is not configured with a supported Anthropic credential. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN and try again."; +} + function messageFromClaudeStreamCause(cause: Cause.Cause, fallback: string): string { return normalizeClaudeStreamMessages(cause)[0] ?? fallback; } @@ -996,6 +1025,8 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { stream: "native", }) : undefined); + const readAuthTokenFromHelperCommand = + options?.readAuthTokenFromHelperCommand ?? readClaudeAuthTokenFromHelperCommand; const createQuery = options?.createQuery ?? @@ -2349,6 +2380,10 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { interruptionMessageFromClaudeCause(exit.cause), ); } + } else if (isClaudeAuthCause(exit.cause)) { + const message = claudeAuthFailureMessage(); + yield* emitRuntimeError(context, message, Cause.pretty(exit.cause)); + yield* completeTurn(context, "failed", message); } else { const message = messageFromClaudeStreamCause( exit.cause, @@ -2796,6 +2831,29 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { ...(fastMode ? { fastMode: true } : {}), }; const runtimeEnv = input.env ? compactNodeProcessEnv(input.env) : undefined; + const baseEnv = mergeNodeProcessEnv(process.env, runtimeEnv); + const explicitAuthToken = nonEmptyTrimmed(baseEnv.ANTHROPIC_AUTH_TOKEN); + const helperCommand = providerOptions?.authTokenHelperCommand; + let authToken = explicitAuthToken; + if (!authToken && helperCommand) { + authToken = yield* Effect.try({ + try: () => + readAuthTokenFromHelperCommand(helperCommand, { + ...(input.cwd ? { cwd: input.cwd } : {}), + env: baseEnv, + }), + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail: `Failed to resolve Claude auth token from helper command: ${toMessage(cause, "unknown error")}`, + cause, + }), + }); + } + const queryEnv = authToken + ? mergeNodeProcessEnv(baseEnv, { ANTHROPIC_AUTH_TOKEN: authToken }) + : baseEnv; const queryOptions: ClaudeQueryOptions = { ...(input.cwd ? { cwd: input.cwd } : {}), @@ -2815,7 +2873,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { ...(newSessionId ? { sessionId: newSessionId } : {}), includePartialMessages: true, canUseTool, - env: sanitizeShellEnvironment(mergeNodeProcessEnv(process.env, runtimeEnv)), + env: sanitizeShellEnvironment(queryEnv), ...(input.cwd ? { additionalDirectories: [input.cwd] } : {}), }; @@ -2829,7 +2887,9 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { new ProviderAdapterProcessError({ provider: PROVIDER, threadId, - detail: toMessage(cause, "Failed to start Claude runtime session."), + detail: isClaudeAuthErrorMessage(toMessage(cause, "")) + ? claudeAuthFailureMessage() + : toMessage(cause, "Failed to start Claude runtime session."), cause, }), }); diff --git a/apps/server/src/provider/Layers/ProviderHealth.test.ts b/apps/server/src/provider/Layers/ProviderHealth.test.ts index 17099e4b5..a1faac272 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.test.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.test.ts @@ -484,7 +484,7 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; if (joined === "auth status") return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stdout: '{"loggedIn":true,"authMethod":"apiKey"}\n', stderr: "", code: 0, }; @@ -535,7 +535,7 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { assert.strictEqual(status.authStatus, "unauthenticated"); assert.strictEqual( status.message, - "Claude is not authenticated. Run `claude auth login` and try again.", + "Claude is not configured with a supported Anthropic credential. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN and try again.", ); }).pipe( Effect.provide( @@ -554,6 +554,34 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { ), ); + it.effect("returns unauthenticated when auth status reports oauth auth", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus; + assert.strictEqual(status.provider, "claudeAgent"); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.available, true); + assert.strictEqual(status.authStatus, "unauthenticated"); + assert.strictEqual( + status.message, + "Claude Code is signed in with OAuth, which is not supported here. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN and try again.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("returns unauthenticated when output includes 'not logged in'", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus; @@ -613,8 +641,12 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { stderr: "", code: 0, }); - assert.strictEqual(parsed.status, "ready"); - assert.strictEqual(parsed.authStatus, "authenticated"); + assert.strictEqual(parsed.status, "error"); + assert.strictEqual(parsed.authStatus, "unauthenticated"); + assert.strictEqual( + parsed.message, + "Claude Code is signed in with OAuth, which is not supported here. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN and try again.", + ); }); it("JSON with loggedIn=false is unauthenticated", () => { diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index 06c56fa1a..0a13aeee5 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -98,6 +98,32 @@ function extractAuthBoolean(value: unknown): boolean | undefined { return undefined; } +function extractAuthString(value: unknown): string | undefined { + if (Array.isArray(value)) { + for (const entry of value) { + const nested = extractAuthString(entry); + if (nested !== undefined) return nested; + } + return undefined; + } + + if (!value || typeof value !== "object") return undefined; + + const record = value as Record; + for (const key of ["authMethod", "auth_method", "method"] as const) { + if (typeof record[key] === "string") return record[key]; + } + for (const key of ["auth", "session", "account"] as const) { + const nested = extractAuthString(record[key]); + if (nested !== undefined) return nested; + } + return undefined; +} + +const CLAUDE_OAUTH_AUTH_METHODS = new Set(["claude.ai", "oauth"]); +const CLAUDE_SUPPORTED_AUTH_METHODS = new Set(["apiKey", "authToken"]); +const CLAUDE_AUTH_GUIDANCE = "Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN and try again."; + export function parseAuthStatusFromOutput(result: CommandResult): { readonly status: ServerProviderStatusState; readonly authStatus: ServerProviderAuthStatus; @@ -449,13 +475,14 @@ export function parseClaudeAuthStatusFromOutput(result: CommandResult): { lowerOutput.includes("not logged in") || lowerOutput.includes("login required") || lowerOutput.includes("authentication required") || + lowerOutput.includes("oauth authentication is currently not supported") || lowerOutput.includes("run `claude login`") || lowerOutput.includes("run claude login") ) { return { status: "error", authStatus: "unauthenticated", - message: "Claude is not authenticated. Run `claude auth login` and try again.", + message: `Claude is not configured with a supported Anthropic credential. ${CLAUDE_AUTH_GUIDANCE}`, }; } @@ -463,29 +490,63 @@ export function parseClaudeAuthStatusFromOutput(result: CommandResult): { const parsedAuth = (() => { const trimmed = result.stdout.trim(); if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) { - return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; + return { + attemptedJsonParse: false as const, + auth: undefined as boolean | undefined, + authMethod: undefined as string | undefined, + }; } try { + const parsed = JSON.parse(trimmed); return { attemptedJsonParse: true as const, - auth: extractAuthBoolean(JSON.parse(trimmed)), + auth: extractAuthBoolean(parsed), + authMethod: extractAuthString(parsed), }; } catch { - return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; + return { + attemptedJsonParse: false as const, + auth: undefined as boolean | undefined, + authMethod: undefined as string | undefined, + }; } })(); + const authMethod = parsedAuth.authMethod?.trim(); + const normalizedAuthMethod = authMethod?.toLowerCase(); + if (normalizedAuthMethod && CLAUDE_OAUTH_AUTH_METHODS.has(normalizedAuthMethod)) { + return { + status: "error", + authStatus: "unauthenticated", + message: `Claude Code is signed in with OAuth, which is not supported here. ${CLAUDE_AUTH_GUIDANCE}`, + }; + } + if (parsedAuth.auth === true) { + if (authMethod && !CLAUDE_SUPPORTED_AUTH_METHODS.has(authMethod)) { + return { + status: "warning", + authStatus: "unknown", + message: `Claude authentication status reported an unsupported credential type '${authMethod}'. ${CLAUDE_AUTH_GUIDANCE}`, + }; + } return { status: "ready", authStatus: "authenticated" }; } if (parsedAuth.auth === false) { return { status: "error", authStatus: "unauthenticated", - message: "Claude is not authenticated. Run `claude auth login` and try again.", + message: `Claude is not configured with a supported Anthropic credential. ${CLAUDE_AUTH_GUIDANCE}`, }; } if (parsedAuth.attemptedJsonParse) { + if (authMethod && !CLAUDE_SUPPORTED_AUTH_METHODS.has(authMethod)) { + return { + status: "error", + authStatus: "unauthenticated", + message: `Claude authentication status reported an unsupported credential type '${authMethod}'. ${CLAUDE_AUTH_GUIDANCE}`, + }; + } return { status: "warning", authStatus: "unknown", diff --git a/apps/server/src/provider/claudeAuthTokenHelper.test.ts b/apps/server/src/provider/claudeAuthTokenHelper.test.ts new file mode 100644 index 000000000..73da41ed8 --- /dev/null +++ b/apps/server/src/provider/claudeAuthTokenHelper.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from "vitest"; + +import { readClaudeAuthTokenFromHelperCommand } from "./claudeAuthTokenHelper"; + +describe("readClaudeAuthTokenFromHelperCommand", () => { + it("runs the helper command through a shell and trims the token", () => { + const spawnSync = vi.fn(() => ({ + pid: 123, + output: ["", " token-from-helper \n", ""], + error: undefined, + status: 0, + signal: null, + stdout: " token-from-helper \n", + stderr: "", + })) as unknown as typeof import("node:child_process").spawnSync; + + const token = readClaudeAuthTokenFromHelperCommand("op read op://shared/anthropic/token", { + cwd: "/tmp/project", + env: { PATH: "/usr/bin" }, + platform: "linux", + spawnSync, + timeoutMs: 1234, + maxBuffer: 2048, + }); + + expect(token).toBe("token-from-helper"); + expect(spawnSync).toHaveBeenCalledWith( + "/bin/sh", + ["-lc", "op read op://shared/anthropic/token"], + expect.objectContaining({ + cwd: "/tmp/project", + encoding: "utf8", + env: { PATH: "/usr/bin" }, + maxBuffer: 2048, + timeout: 1234, + }), + ); + }); + + it("fails when the helper command exits non-zero", () => { + const spawnSync = vi.fn(() => ({ + pid: 123, + output: ["", "ignored-token\n", ""], + error: undefined, + status: 1, + signal: null, + stdout: "ignored-token\n", + stderr: "secret manager unavailable", + })) as unknown as typeof import("node:child_process").spawnSync; + + expect(() => + readClaudeAuthTokenFromHelperCommand("pass show anthropic/token", { + spawnSync, + platform: "linux", + }), + ).toThrow("secret manager unavailable"); + }); +}); diff --git a/apps/server/src/provider/claudeAuthTokenHelper.ts b/apps/server/src/provider/claudeAuthTokenHelper.ts new file mode 100644 index 000000000..2283e2936 --- /dev/null +++ b/apps/server/src/provider/claudeAuthTokenHelper.ts @@ -0,0 +1,100 @@ +import { spawnSync, type SpawnSyncReturns } from "node:child_process"; + +const DEFAULT_HELPER_TIMEOUT_MS = 5_000; +const DEFAULT_HELPER_MAX_BUFFER_BYTES = 64 * 1024; + +export interface ClaudeAuthTokenHelperExecutionOptions { + readonly cwd?: string; + readonly env?: NodeJS.ProcessEnv; + readonly maxBuffer?: number; + readonly platform?: NodeJS.Platform; + readonly spawnSync?: ( + command: string, + args: ReadonlyArray, + options: { + readonly cwd?: string; + readonly encoding: "utf8"; + readonly env?: NodeJS.ProcessEnv; + readonly maxBuffer: number; + readonly timeout: number; + }, + ) => SpawnSyncReturns; + readonly timeoutMs?: number; +} + +function trimSingleLineOutput(value: string): string { + const trimmed = value.trim(); + if (trimmed.length === 0) { + return ""; + } + if (trimmed.includes("\n") || trimmed.includes("\r")) { + throw new Error("Claude auth token helper command must print a single-line token."); + } + return trimmed; +} + +function shellCommandForPlatform(platform: NodeJS.Platform): { + readonly command: string; + readonly args: readonly string[]; +} { + if (platform === "win32") { + return { + command: "cmd.exe", + args: ["/d", "/s", "/c"], + }; + } + + return { + command: "/bin/sh", + args: ["-lc"], + }; +} + +function formatHelperFailureMessage(result: SpawnSyncReturns): string { + if (result.error instanceof Error) { + return result.error.message; + } + + const stderr = result.stderr.trim(); + if (stderr.length > 0) { + return stderr; + } + + if (result.status === null) { + return "Timed out while running Claude auth token helper command."; + } + + return `Claude auth token helper command exited with code ${result.status}.`; +} + +export function readClaudeAuthTokenFromHelperCommand( + helperCommand: string, + options?: ClaudeAuthTokenHelperExecutionOptions, +): string { + const command = helperCommand.trim(); + if (command.length === 0) { + throw new Error("Claude auth token helper command is empty."); + } + + const platform = options?.platform ?? process.platform; + const shell = shellCommandForPlatform(platform); + const spawn = options?.spawnSync ?? spawnSync; + const result = spawn(shell.command, [...shell.args, command], { + encoding: "utf8", + maxBuffer: options?.maxBuffer ?? DEFAULT_HELPER_MAX_BUFFER_BYTES, + timeout: options?.timeoutMs ?? DEFAULT_HELPER_TIMEOUT_MS, + ...(options?.cwd ? { cwd: options.cwd } : {}), + ...(options?.env ? { env: options.env } : {}), + }) as SpawnSyncReturns; + + if (result.error || result.status !== 0) { + throw new Error(formatHelperFailureMessage(result)); + } + + const token = trimSingleLineOutput(result.stdout); + if (token.length === 0) { + throw new Error("Claude auth token helper command returned no output."); + } + + return token; +} diff --git a/apps/server/src/sme/authValidation.ts b/apps/server/src/sme/authValidation.ts index 20cadfbe5..2c348804c 100644 --- a/apps/server/src/sme/authValidation.ts +++ b/apps/server/src/sme/authValidation.ts @@ -76,7 +76,7 @@ export function validateAnthropicSetup(input: { severity: "error", message: providerStatus.message ?? - "Claude Code CLI is not authenticated. Run `claude auth login` and try again.", + "Claude Code is not configured with a supported Anthropic credential. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN and try again.", resolvedAuthMethod: input.authMethod, resolvedAccountType: "unknown", }; diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index e3e408b83..d6fbb4ca3 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -9,6 +9,7 @@ import { DEFAULT_SIDEBAR_PROJECT_ROW_HEIGHT, DEFAULT_SIDEBAR_SPACING, DEFAULT_SIDEBAR_THREAD_ROW_HEIGHT, + getProviderStartOptions, resolveBrowserPreviewStartPageUrl, } from "./appSettings"; @@ -25,6 +26,7 @@ describe("AppSettingsSchema", () => { expect(settings.showNotificationDetails).toBe(false); expect(settings.includeDiagnosticsTipsInCopy).toBe(false); expect(settings.browserPreviewStartPageUrl).toBe(""); + expect(settings.claudeAuthTokenHelperCommand).toBe(""); }); it("defaults sidebar appearance controls", () => { @@ -61,6 +63,25 @@ describe("AppSettingsSchema", () => { }); }); +describe("getProviderStartOptions", () => { + it("includes the Claude auth token helper command when configured", () => { + expect( + getProviderStartOptions({ + claudeBinaryPath: "", + claudeAuthTokenHelperCommand: "op read op://shared/anthropic/token --no-newline", + codexBinaryPath: "", + codexHomePath: "", + openclawGatewayUrl: "", + openclawPassword: "", + }), + ).toEqual({ + claudeAgent: { + authTokenHelperCommand: "op read op://shared/anthropic/token --no-newline", + }, + }); + }); +}); + describe("resolveBrowserPreviewStartPageUrl", () => { it("falls back to the default start page for blank or invalid values", () => { expect(resolveBrowserPreviewStartPageUrl("")).toBe(DEFAULT_BROWSER_PREVIEW_START_PAGE_URL); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 74259a92e..39c4c3de8 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -82,6 +82,9 @@ const withDefaults = export const AppSettingsSchema = Schema.Struct({ claudeBinaryPath: 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(() => "")), @@ -233,6 +236,7 @@ function normalizeAppSettings(settings: AppSettings): AppSettings { ...settings, backgroundImageUrl: settings.backgroundImageUrl.trim(), browserPreviewStartPageUrl: settings.browserPreviewStartPageUrl.trim(), + claudeAuthTokenHelperCommand: settings.claudeAuthTokenHelperCommand.trim(), backgroundImageOpacity: clampBackgroundOpacity(settings.backgroundImageOpacity), sidebarOpacity: clampOpacity(settings.sidebarOpacity), sidebarProjectRowHeight: clampSidebarProjectRowHeight(settings.sidebarProjectRowHeight), @@ -345,7 +349,15 @@ export function getCustomModelOptionsByProvider( } export function getProviderStartOptions( - settings: Pick, + settings: Pick< + AppSettings, + | "claudeBinaryPath" + | "claudeAuthTokenHelperCommand" + | "codexBinaryPath" + | "codexHomePath" + | "openclawGatewayUrl" + | "openclawPassword" + >, ): ProviderStartOptions | undefined { const providerOptions: ProviderStartOptions = { ...(settings.codexBinaryPath || settings.codexHomePath @@ -356,10 +368,13 @@ export function getProviderStartOptions( }, } : {}), - ...(settings.claudeBinaryPath + ...(settings.claudeBinaryPath || settings.claudeAuthTokenHelperCommand ? { claudeAgent: { - binaryPath: settings.claudeBinaryPath, + ...(settings.claudeBinaryPath ? { binaryPath: settings.claudeBinaryPath } : {}), + ...(settings.claudeAuthTokenHelperCommand + ? { authTokenHelperCommand: settings.claudeAuthTokenHelperCommand } + : {}), }, } : {}), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d6e2f3cc6..4cc9e19bd 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -870,8 +870,9 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) { getSelectableThreadProviders({ statuses: providerStatuses, openclawGatewayUrl: settings.openclawGatewayUrl, + claudeAuthTokenHelperCommand: settings.claudeAuthTokenHelperCommand, }), - [providerStatuses, settings.openclawGatewayUrl], + [providerStatuses, settings.claudeAuthTokenHelperCommand, settings.openclawGatewayUrl], ); const hasThreadStarted = Boolean( activeThread && diff --git a/apps/web/src/components/EnvironmentVariablesEditor.test.tsx b/apps/web/src/components/EnvironmentVariablesEditor.test.tsx index 0478a88c7..68a1d0253 100644 --- a/apps/web/src/components/EnvironmentVariablesEditor.test.tsx +++ b/apps/web/src/components/EnvironmentVariablesEditor.test.tsx @@ -12,6 +12,13 @@ describe("EnvironmentVariablesEditor", () => { emptyMessage="No variables" saveButtonLabel="Save" addButtonLabel="Add variable" + quickAddPresets={[ + { + label: "Add Claude token", + description: "Create ANTHROPIC_AUTH_TOKEN in your global environment.", + entry: { key: "ANTHROPIC_AUTH_TOKEN", value: "" }, + }, + ]} onSave={async (entries) => entries} />, ); @@ -19,5 +26,6 @@ describe("EnvironmentVariablesEditor", () => { expect(markup).toContain('aria-label="Show value"'); expect(markup).toContain("lucide-eye"); expect(markup).toContain("-webkit-text-security:disc"); + expect(markup).toContain("Add Claude token"); }); }); diff --git a/apps/web/src/components/EnvironmentVariablesEditor.tsx b/apps/web/src/components/EnvironmentVariablesEditor.tsx index 6d9938064..86259d788 100644 --- a/apps/web/src/components/EnvironmentVariablesEditor.tsx +++ b/apps/web/src/components/EnvironmentVariablesEditor.tsx @@ -107,6 +107,11 @@ export interface EnvironmentVariablesEditorProps { readonly emptyMessage: string; readonly saveButtonLabel: string; readonly addButtonLabel: string; + readonly quickAddPresets?: ReadonlyArray<{ + readonly label: string; + readonly description: string; + readonly entry: EnvironmentVariableEntry; + }>; readonly onSave: ( entries: ReadonlyArray, ) => Promise>; @@ -119,6 +124,7 @@ export function EnvironmentVariablesEditor({ emptyMessage, saveButtonLabel, addButtonLabel, + quickAddPresets, onSave, disabled = false, }: EnvironmentVariablesEditorProps) { @@ -172,6 +178,12 @@ export function EnvironmentVariablesEditor({ setSaveError(null); }; + const addQuickPreset = (entry: EnvironmentVariableEntry) => { + if (!canAddRow) return; + setRows((current) => [...current, createDraftRow(entry)]); + setSaveError(null); + }; + const removeRow = (rowId: string) => { setRows((current) => current.filter((row) => row.id !== rowId)); setVisibleValueRowIds((current) => { @@ -384,10 +396,30 @@ export function EnvironmentVariablesEditor({
{normalizedEntries.length}/{ENVIRONMENT_VARIABLE_MAX_COUNT} saved variables
- +
+ {quickAddPresets?.length ? ( +
+ {quickAddPresets.map((preset) => ( + + ))} +
+ ) : null} + +
); diff --git a/apps/web/src/components/chat/ProviderSetupCard.tsx b/apps/web/src/components/chat/ProviderSetupCard.tsx index 4b5a74f43..eaa900be4 100644 --- a/apps/web/src/components/chat/ProviderSetupCard.tsx +++ b/apps/web/src/components/chat/ProviderSetupCard.tsx @@ -23,11 +23,13 @@ const PROVIDER_CONFIG = { installCmd: "npm install -g @openai/codex", authCmd: "codex login", verifyCmd: "codex login status", + note: undefined, }, claudeAgent: { installCmd: "npm install -g @anthropic-ai/claude-code", - authCmd: "claude auth login", + authCmd: "set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN", verifyCmd: "claude auth status", + note: "You can also configure a Claude auth token helper command or one-click secret-manager preset in Settings.", }, } as const; @@ -93,6 +95,7 @@ function ProviderRow({ status }: { status: ServerProviderStatus }) { {config.verifyCmd} + {config.note ?

{config.note}

: null} )} diff --git a/apps/web/src/components/chat/threadError.test.ts b/apps/web/src/components/chat/threadError.test.ts index 97a0cdf1e..1c0b71cf8 100644 --- a/apps/web/src/components/chat/threadError.test.ts +++ b/apps/web/src/components/chat/threadError.test.ts @@ -48,7 +48,7 @@ describe("humanizeThreadError", () => { ).toBe(true); expect( isAuthenticationThreadError( - "Claude is not authenticated. Run `claude auth login` and try again.", + "Claude is not configured with a supported Anthropic credential. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN and try again.", ), ).toBe(true); }); @@ -81,9 +81,11 @@ describe("humanizeThreadError", () => { ).toContain("Troubleshooting:"); expect( buildThreadErrorDiagnosticsCopy( - "Codex CLI is not authenticated. Run `codex login` and try again.", + "Claude Code is signed in with OAuth, which is not supported here. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN and try again.", { includeTips: true }, ), - ).toContain("Run `codex login` and retry the turn."); + ).toContain( + "Set `ANTHROPIC_API_KEY` or `ANTHROPIC_AUTH_TOKEN` in the runtime environment and retry the turn.", + ); }); }); diff --git a/apps/web/src/components/chat/threadError.ts b/apps/web/src/components/chat/threadError.ts index 58fdde358..27d719598 100644 --- a/apps/web/src/components/chat/threadError.ts +++ b/apps/web/src/components/chat/threadError.ts @@ -18,6 +18,10 @@ const AUTH_FAILURE_PATTERNS = [ "run claude auth login", "codex cli is not authenticated", "claude is not authenticated", + "supported anthropic credential", + "signed in with oauth", + "oauth authentication is currently not supported", + "could not resolve authentication method", "authentication required", ] as const; @@ -34,7 +38,7 @@ function extractWorktreeDetail(error: string): string | null { function getProviderLoginCommand(error: string): string | null { const lower = error.toLowerCase(); if (lower.includes("claude")) { - return "`claude auth login`"; + return "`ANTHROPIC_API_KEY` or `ANTHROPIC_AUTH_TOKEN`"; } if (lower.includes("codex")) { return "`codex login`"; @@ -48,9 +52,11 @@ function buildTroubleshootingTips(error: string, presentation: ThreadErrorPresen if (isAuthenticationThreadError(error)) { const loginCommand = getProviderLoginCommand(error); tips.push( - loginCommand - ? `Run ${loginCommand} and retry the turn.` - : "Run the provider login command for this CLI, then retry the turn.", + loginCommand?.includes("ANTHROPIC") + ? `Set ${loginCommand} in the runtime environment and retry the turn.` + : loginCommand + ? `Run ${loginCommand} and retry the turn.` + : "Run the provider login command for this CLI, then retry the turn.", ); } diff --git a/apps/web/src/components/sme/smeConversationConfig.ts b/apps/web/src/components/sme/smeConversationConfig.ts index 69e49a3a3..8912d6c44 100644 --- a/apps/web/src/components/sme/smeConversationConfig.ts +++ b/apps/web/src/components/sme/smeConversationConfig.ts @@ -23,8 +23,8 @@ export function getSmeAuthMethodOptions( switch (provider) { case "claudeAgent": return [ - { value: "apiKey", label: "OAuth" }, - { value: "authToken", label: "Setup Token" }, + { value: "apiKey", label: "Anthropic API Key" }, + { value: "authToken", label: "Auth Token" }, { value: "auto", label: "CLI" }, ]; case "codex": diff --git a/apps/web/src/i18n/messages/en.json b/apps/web/src/i18n/messages/en.json index 0a5aecc9a..389bd976f 100644 --- a/apps/web/src/i18n/messages/en.json +++ b/apps/web/src/i18n/messages/en.json @@ -133,7 +133,7 @@ "settings.advanced.keybindings.opening": "Opening...", "settings.advanced.keybindings.resolvingPath": "Resolving keybindings path...", "settings.advanced.keybindings.title": "Keybindings", - "settings.advanced.providerInstalls.claude.binaryDescription": "Leave blank to use claude from your PATH. Authentication uses claude auth login.", + "settings.advanced.providerInstalls.claude.binaryDescription": "Leave blank to use claude from your PATH. Claude Code needs ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN in the runtime environment.", "settings.advanced.providerInstalls.claude.binaryPathLabel": "Claude Code binary path", "settings.advanced.providerInstalls.claude.binaryPlaceholder": "Claude Code binary path", "settings.advanced.providerInstalls.codex.binaryDescription": "Leave blank to use codex from your PATH. Authentication normally uses codex login unless your Codex config points at a custom model provider.", diff --git a/apps/web/src/i18n/messages/es.json b/apps/web/src/i18n/messages/es.json index 52e9fea29..794d8d74e 100644 --- a/apps/web/src/i18n/messages/es.json +++ b/apps/web/src/i18n/messages/es.json @@ -133,7 +133,7 @@ "settings.advanced.keybindings.opening": "Abriendo...", "settings.advanced.keybindings.resolvingPath": "Resolviendo la ruta de keybindings...", "settings.advanced.keybindings.title": "Atajos", - "settings.advanced.providerInstalls.claude.binaryDescription": "Déjalo vacío para usar claude desde tu PATH. La autenticación usa claude auth login.", + "settings.advanced.providerInstalls.claude.binaryDescription": "Déjalo vacío para usar claude desde tu PATH. Claude Code necesita ANTHROPIC_API_KEY o ANTHROPIC_AUTH_TOKEN en el entorno de ejecución.", "settings.advanced.providerInstalls.claude.binaryPathLabel": "Ruta del binario de Claude Code", "settings.advanced.providerInstalls.claude.binaryPlaceholder": "Ruta del binario de Claude Code", "settings.advanced.providerInstalls.codex.binaryDescription": "Déjalo vacío para usar codex desde tu PATH. La autenticación normalmente usa codex login, salvo que tu configuración de Codex apunte a un proveedor de modelos personalizado.", diff --git a/apps/web/src/i18n/messages/fr.json b/apps/web/src/i18n/messages/fr.json index 49e3a153c..93d790eed 100644 --- a/apps/web/src/i18n/messages/fr.json +++ b/apps/web/src/i18n/messages/fr.json @@ -133,7 +133,7 @@ "settings.advanced.keybindings.opening": "Ouverture...", "settings.advanced.keybindings.resolvingPath": "Résolution du chemin de keybindings...", "settings.advanced.keybindings.title": "Raccourcis", - "settings.advanced.providerInstalls.claude.binaryDescription": "Laissez vide pour utiliser claude depuis votre PATH. L'authentification utilise claude auth login.", + "settings.advanced.providerInstalls.claude.binaryDescription": "Laissez vide pour utiliser claude depuis votre PATH. Claude Code a besoin de ANTHROPIC_API_KEY ou ANTHROPIC_AUTH_TOKEN dans l'environnement d'exécution.", "settings.advanced.providerInstalls.claude.binaryPathLabel": "Chemin du binaire Claude Code", "settings.advanced.providerInstalls.claude.binaryPlaceholder": "Chemin du binaire Claude Code", "settings.advanced.providerInstalls.codex.binaryDescription": "Laissez vide pour utiliser codex depuis votre PATH. L'authentification utilise normalement codex login, sauf si votre configuration Codex pointe vers un fournisseur de modèle personnalisé.", diff --git a/apps/web/src/i18n/messages/zh-CN.json b/apps/web/src/i18n/messages/zh-CN.json index dc2db4360..c8d330102 100644 --- a/apps/web/src/i18n/messages/zh-CN.json +++ b/apps/web/src/i18n/messages/zh-CN.json @@ -133,7 +133,7 @@ "settings.advanced.keybindings.opening": "打开中...", "settings.advanced.keybindings.resolvingPath": "正在解析 keybindings 路径...", "settings.advanced.keybindings.title": "快捷键", - "settings.advanced.providerInstalls.claude.binaryDescription": "留空则使用 PATH 中的 claude。认证使用 claude auth login。", + "settings.advanced.providerInstalls.claude.binaryDescription": "留空则使用 PATH 中的 claude。Claude Code 需要在运行环境中提供 ANTHROPIC_API_KEYANTHROPIC_AUTH_TOKEN。", "settings.advanced.providerInstalls.claude.binaryPathLabel": "Claude Code 二进制路径", "settings.advanced.providerInstalls.claude.binaryPlaceholder": "Claude Code 二进制路径", "settings.advanced.providerInstalls.codex.binaryDescription": "留空则使用 PATH 中的 codex。认证通常使用 codex login,除非你的 Codex 配置指向了自定义模型提供方。", diff --git a/apps/web/src/lib/claudeAuthTokenHelperPresets.test.ts b/apps/web/src/lib/claudeAuthTokenHelperPresets.test.ts new file mode 100644 index 000000000..c332685f5 --- /dev/null +++ b/apps/web/src/lib/claudeAuthTokenHelperPresets.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; + +import { CLAUDE_AUTH_TOKEN_HELPER_PRESETS } from "./claudeAuthTokenHelperPresets"; + +describe("CLAUDE_AUTH_TOKEN_HELPER_PRESETS", () => { + it("includes presets for common secret managers", () => { + expect(CLAUDE_AUTH_TOKEN_HELPER_PRESETS).toEqual([ + { + label: "1Password", + description: "Prefill an `op read` command for a 1Password secret.", + command: 'op read "op://Private/Anthropic/Claude Code/token" --no-newline', + }, + { + label: "pass", + description: "Prefill a `pass show` command for the `pass` password store.", + command: "pass show anthropic/claude-code", + }, + { + label: "Doppler", + description: "Prefill a `doppler secrets get` command for a linked project.", + command: "doppler secrets get ANTHROPIC_AUTH_TOKEN --plain", + }, + ]); + }); +}); diff --git a/apps/web/src/lib/claudeAuthTokenHelperPresets.ts b/apps/web/src/lib/claudeAuthTokenHelperPresets.ts new file mode 100644 index 000000000..97be2191b --- /dev/null +++ b/apps/web/src/lib/claudeAuthTokenHelperPresets.ts @@ -0,0 +1,23 @@ +export type ClaudeAuthTokenHelperPreset = { + readonly label: string; + readonly description: string; + readonly command: string; +}; + +export const CLAUDE_AUTH_TOKEN_HELPER_PRESETS: readonly ClaudeAuthTokenHelperPreset[] = [ + { + label: "1Password", + description: "Prefill an `op read` command for a 1Password secret.", + command: 'op read "op://Private/Anthropic/Claude Code/token" --no-newline', + }, + { + label: "pass", + description: "Prefill a `pass show` command for the `pass` password store.", + command: "pass show anthropic/claude-code", + }, + { + label: "Doppler", + description: "Prefill a `doppler secrets get` command for a linked project.", + command: "doppler secrets get ANTHROPIC_AUTH_TOKEN --plain", + }, +] as const; diff --git a/apps/web/src/lib/providerAvailability.test.ts b/apps/web/src/lib/providerAvailability.test.ts index 53c4fb68e..af996aab7 100644 --- a/apps/web/src/lib/providerAvailability.test.ts +++ b/apps/web/src/lib/providerAvailability.test.ts @@ -49,6 +49,16 @@ describe("providerAvailability", () => { ).toBe(false); }); + it("allows Claude when a local auth token helper is configured", () => { + expect( + isProviderReadyForThreadSelection({ + provider: "claudeAgent", + statuses: [makeStatus("claudeAgent", { status: "error", authStatus: "unauthenticated" })], + claudeAuthTokenHelperCommand: "op read op://shared/anthropic/token --no-newline", + }), + ).toBe(true); + }); + it("treats configured OpenClaw as selectable even when server auth state is unknown", () => { expect( isProviderReadyForThreadSelection({ @@ -67,8 +77,9 @@ describe("providerAvailability", () => { makeStatus("codex"), makeStatus("claudeAgent", { status: "error", authStatus: "unauthenticated" }), ], + claudeAuthTokenHelperCommand: "op read op://shared/anthropic/token --no-newline", }), - ).toEqual(["codex", "openclaw"]); + ).toEqual(["codex", "claudeAgent", "openclaw"]); }); it("falls back to the first selectable provider when the preferred one is unavailable", () => { diff --git a/apps/web/src/lib/providerAvailability.ts b/apps/web/src/lib/providerAvailability.ts index d25fa28e5..9c12891db 100644 --- a/apps/web/src/lib/providerAvailability.ts +++ b/apps/web/src/lib/providerAvailability.ts @@ -23,6 +23,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); @@ -37,18 +38,29 @@ export function isProviderReadyForThreadSelection(input: { return false; } + if ( + input.provider === "claudeAgent" && + (input.claudeAuthTokenHelperCommand ?? "").trim().length > 0 && + status.available && + status.authStatus === "unauthenticated" + ) { + return true; + } + return status.available && status.status === "ready" && status.authStatus !== "unauthenticated"; } 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/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 5df8d20bb..a3ab15563 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -1,15 +1,3289 @@ -import { Outlet, createFileRoute } from "@tanstack/react-router"; +import { createFileRoute } from "@tanstack/react-router"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { + CheckCircle2Icon, + ChevronDownIcon, + CpuIcon, + 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 { + applyCustomTheme, + clearFontOverride, + clearFontSizeOverride, + clearRadiusOverride, + clearStoredCustomTheme, + getStoredCustomTheme, + getStoredFontOverride, + getStoredFontSizeOverride, + getStoredRadiusOverride, + removeCustomTheme, + setStoredFontOverride, + setStoredFontSizeOverride, + setStoredRadiusOverride, + type CustomThemeData, +} from "../lib/customTheme"; +import { openUrlInAppBrowser } from "../lib/openUrlInAppBrowser"; +import { CLAUDE_AUTH_TOKEN_HELPER_PRESETS } from "../lib/claudeAuthTokenHelperPresets"; +import { + getSelectableThreadProviders, + isProviderReadyForThreadSelection, +} from "../lib/providerAvailability"; +import { 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 { + getProviderLabel as getProviderStatusLabelName, + getProviderStatusDescription, + getProviderStatusHeading, +} from "../components/chat/providerStatusPresentation"; -import { SettingsRouteContextProvider } from "../components/settings/SettingsRouteContext"; +// --------------------------------------------------------------------------- +// Settings navigation sections +// --------------------------------------------------------------------------- +type SettingsSectionId = + | "general" + | "authentication" + | "hotkeys" + | "environment" + | "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: "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. Claude Code sessions need an{" "} + ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN in the runtime + environment. You can also source the token automatically with a helper command. + + ), + }, +]; + +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: "set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN", + verifyCmd: "claude auth status", + note: "Claude Code must be installed and configured with an Anthropic API key or auth token before it appears in the thread picker. Use Environment to add a Claude auth token in one click, or configure a helper command in the Claude install panel.", + }, + openclaw: { + verifyCmd: "Test Connection", + note: "OpenClaw uses the gateway URL and password below rather than a local CLI login. A configured gateway unlocks it for new-thread selection.", + }, +}; + +function getAuthenticationBadgeCopy(input: { + status: ServerProviderStatus | null; + provider: ProviderKind; + openclawGatewayUrl: string; + hasClaudeAuthTokenHelper: boolean; +}): { + tone: "success" | "warning" | "error"; + label: string; +} { + if ( + input.provider === "claudeAgent" && + input.hasClaudeAuthTokenHelper && + input.status?.available === true && + input.status?.authStatus === "unauthenticated" + ) { + return { tone: "success", label: "Token helper configured" }; + } + + if ( + isProviderReadyForThreadSelection({ + provider: input.provider, + statuses: input.status ? [input.status] : [], + openclawGatewayUrl: input.openclawGatewayUrl, + claudeAuthTokenHelperCommand: input.hasClaudeAuthTokenHelper ? "configured" : undefined, + }) + ) { + 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, + claudeAuthTokenHelperCommand, +}: { + provider: ProviderKind; + status: ServerProviderStatus | null; + openclawGatewayUrl: string; + claudeAuthTokenHelperCommand: string; +}) { + const guide = PROVIDER_AUTH_GUIDES[provider]; + const hasClaudeAuthTokenHelper = + provider === "claudeAgent" && claudeAuthTokenHelperCommand.trim().length > 0; + const badge = getAuthenticationBadgeCopy({ + status, + provider, + openclawGatewayUrl, + hasClaudeAuthTokenHelper, + }); + 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 + ? hasClaudeAuthTokenHelper && + status.provider === "claudeAgent" && + status.available && + status.authStatus === "unauthenticated" + ? "Claude Code will source its auth token automatically" + : getProviderStatusHeading(status) + : provider === "openclaw" && openclawGatewayUrl.trim().length > 0 + ? "OpenClaw gateway is configured locally" + : `${getProviderStatusLabelName(provider)} needs configuration`; + const description = + status !== null + ? hasClaudeAuthTokenHelper && + status.provider === "claudeAgent" && + status.available && + status.authStatus === "unauthenticated" + ? "The configured helper command will run locally at session start and print ANTHROPIC_AUTH_TOKEN to stdout." + : 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]); -function SettingsLayoutRouteView() { 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 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 || settings.claudeAuthTokenHelperCommand), + openclaw: Boolean(settings.openclawGatewayUrl || settings.openclawPassword), + }); + const [selectedCustomModelProvider, setSelectedCustomModelProvider] = + useState("codex"); + const [customModelInputByProvider, setCustomModelInputByProvider] = useState< + Record + >({ + codex: "", + claudeAgent: "", + openclaw: "", + }); + 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 { 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 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]); + + 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: settings.claudeAuthTokenHelperCommand, + }); + + 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.claudeAuthTokenHelperCommand !== defaults.claudeAuthTokenHelperCommand || + settings.codexBinaryPath !== defaults.codexBinaryPath || + settings.codexHomePath !== defaults.codexHomePath; + const isOpenClawSettingsDirty = + settings.openclawGatewayUrl !== defaults.openclawGatewayUrl || + settings.openclawPassword !== defaults.openclawPassword; + 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 testOpenclawGateway = useCallback(async () => { + if (openclawTestLoading) return; + setOpenclawTestLoading(true); + setOpenclawTestResult(null); + try { + const api = ensureNativeApi(); + const result = await api.server.testOpenclawGateway({ + gatewayUrl: settings.openclawGatewayUrl, + password: settings.openclawPassword || undefined, + }); + setOpenclawTestResult(result); + } catch (err) { + setOpenclawTestResult({ + success: false, + steps: [], + totalDurationMs: 0, + error: err instanceof Error ? err.message : "Unexpected error during test.", + }); + } finally { + setOpenclawTestLoading(false); + } + }, [openclawTestLoading, settings.openclawGatewayUrl, settings.openclawPassword]); + + const handleCopyOpenclawDebugReport = useCallback(() => { + if (!openclawTestResult) return; + copyOpenclawDebugReport(formatOpenclawGatewayDebugReport(openclawTestResult), undefined); + }, [copyOpenclawDebugReport, openclawTestResult]); + + 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, + openclaw: false, + }); + setSelectedCustomModelProvider("codex"); + setCustomModelInputByProvider({ + codex: "", + claudeAgent: "", + openclaw: "", + }); + 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"; + + 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={settings.openclawGatewayUrl} + claudeAuthTokenHelperCommand={settings.claudeAuthTokenHelperCommand} + /> + ))} +
+
+ + { + updateSettings({ + claudeBinaryPath: defaults.claudeBinaryPath, + claudeAuthTokenHelperCommand: defaults.claudeAuthTokenHelperCommand, + codexBinaryPath: defaults.codexBinaryPath, + codexHomePath: defaults.codexHomePath, + }); + setOpenInstallProviders({ + codex: false, + claudeAgent: false, + openclaw: 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 || + settings.claudeAuthTokenHelperCommand !== + defaults.claudeAuthTokenHelperCommand; + const binaryPathValue = + providerSettings.binaryPathKey === "claudeBinaryPath" + ? claudeBinaryPath + : codexBinaryPath; + + return ( + + setOpenInstallProviders((existing) => ({ + ...existing, + [providerSettings.provider]: open, + })) + } + > +
+ + + +
+
+ + + {providerSettings.provider === "claudeAgent" ? ( + + ) : null} + + {providerSettings.homePathKey ? ( + + ) : null} +
+
+
+
+
+ ); + })} +
+
+
+ + 0 + ? `Configured for ${settings.openclawGatewayUrl}` + : "Not configured" + } + resetAction={ + isOpenClawSettingsDirty ? ( + + updateSettings({ + openclawGatewayUrl: defaults.openclawGatewayUrl, + openclawPassword: defaults.openclawPassword, + }) + } + /> + ) : 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.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. + ) + } + > + + + + + {selectedProject.name} · {selectedProject.cwd} + + ) : ( + Open a project to edit project variables. + ) + } + control={ + projects.length > 0 ? ( + + ) : ( + + No projects available. + + ) + } + > + + + + )} + + {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: SettingsLayoutRouteView, + component: SettingsRouteView, }); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index e7154983b..04a9c454d 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -55,6 +55,7 @@ export const ClaudeProviderStartOptions = Schema.Struct({ binaryPath: Schema.optional(TrimmedNonEmptyString), permissionMode: Schema.optional(TrimmedNonEmptyString), maxThinkingTokens: Schema.optional(NonNegativeInt), + authTokenHelperCommand: Schema.optional(TrimmedNonEmptyString), }); export const OpenClawProviderStartOptions = Schema.Struct({ diff --git a/packages/contracts/src/provider.test.ts b/packages/contracts/src/provider.test.ts index 5e034f74f..fb46dc219 100644 --- a/packages/contracts/src/provider.test.ts +++ b/packages/contracts/src/provider.test.ts @@ -61,6 +61,7 @@ describe("ProviderSessionStartInput", () => { binaryPath: "/usr/local/bin/claude", permissionMode: "plan", maxThinkingTokens: 12_000, + authTokenHelperCommand: "op read op://shared/anthropic/token --no-newline", }, }, runtimeMode: "full-access", @@ -72,6 +73,9 @@ describe("ProviderSessionStartInput", () => { expect(parsed.providerOptions?.claudeAgent?.binaryPath).toBe("/usr/local/bin/claude"); expect(parsed.providerOptions?.claudeAgent?.permissionMode).toBe("plan"); expect(parsed.providerOptions?.claudeAgent?.maxThinkingTokens).toBe(12_000); + expect(parsed.providerOptions?.claudeAgent?.authTokenHelperCommand).toBe( + "op read op://shared/anthropic/token --no-newline", + ); expect(parsed.runtimeMode).toBe("full-access"); }); });