diff --git a/api/src/@types/gateway.ts b/api/src/@types/gateway.ts index 835bf05..6bec133 100644 --- a/api/src/@types/gateway.ts +++ b/api/src/@types/gateway.ts @@ -13,10 +13,24 @@ export interface AuthCredentials { }; } +export interface SharedGatewayAuth { + /** `gateway.auth.token` from `~/.openclaw/openclaw.json` (mode = "token"). */ + token?: string; + /** `gateway.auth.password` from `~/.openclaw/openclaw.json` (mode = "password"). */ + password?: string; +} + export interface GatewayCredentials { - device: DeviceCredentials; + /** Device identity — required for the legacy device-auth path; optional + * when `sharedAuth` is configured (loopback backend clients can connect + * with a shared secret and no device pairing). */ + device: DeviceCredentials | null; auth: AuthCredentials; gatewayPort: number; + /** Shared-secret credentials read from the OpenClaw config file. When + * present, the connect handshake uses these instead of device-pairing, + * so no `openclaw devices approve` step is ever required. */ + sharedAuth: SharedGatewayAuth | null; } // ── Wire protocol ── diff --git a/api/src/@types/openclaw.ts b/api/src/@types/openclaw.ts index a8b1368..d21de9b 100644 --- a/api/src/@types/openclaw.ts +++ b/api/src/@types/openclaw.ts @@ -117,9 +117,34 @@ export interface JsonlEntry { // ── Session settings ── -export type ThinkingLevel = 'minimal' | 'low' | 'medium' | 'high' | 'inherit'; +/* OpenClaw thinking-level vocabulary as of 2026.5.x. + * `off | minimal | low | medium | high | xhigh | adaptive | max` plus our + * `inherit` sentinel. Per-model the daemon may only advertise a subset + * (e.g. Gemini 3.1 Pro Preview only accepts `off | low | adaptive | high`); + * the picker UI reflects the active model's profile, but the type carries + * the full vocabulary so older stored values keep round-tripping. + * See openclaw/docs/tools/thinking.md. */ +export type ThinkingLevel = + | 'off' + | 'minimal' + | 'low' + | 'medium' + | 'high' + | 'xhigh' + | 'adaptive' + | 'max' + | 'inherit'; export type VerboseLevel = 'low' | 'medium' | 'high' | 'inherit'; -export type ReasoningLevel = 'minimal' | 'low' | 'medium' | 'high' | 'inherit'; +export type ReasoningLevel = + | 'off' + | 'minimal' + | 'low' + | 'medium' + | 'high' + | 'xhigh' + | 'adaptive' + | 'max' + | 'inherit'; export interface SessionSettings { thinkingLevel: string; @@ -254,7 +279,17 @@ export interface AgentSkillsPatch { // ── Agent subagents ── -export type AgentSubagentsThinking = 'minimal' | 'low' | 'medium' | 'high' | 'inherit' | string; +export type AgentSubagentsThinking = + | 'off' + | 'minimal' + | 'low' + | 'medium' + | 'high' + | 'xhigh' + | 'adaptive' + | 'max' + | 'inherit' + | string; export interface AgentSubagentsConfig { allowAgents: string[] | null; @@ -377,7 +412,14 @@ export interface AgentLimitsPatch { export interface OpenclawConfig { agents?: OpenclawAgentsSection; - gateway?: { port?: number }; + gateway?: { + port?: number; + auth?: { + mode?: 'none' | 'token' | 'password' | 'trusted-proxy'; + token?: string; + password?: string; + }; + }; [key: string]: unknown; } diff --git a/api/src/routes/agent/doc.yaml b/api/src/routes/agent/doc.yaml index 83e0f76..95a543e 100644 --- a/api/src/routes/agent/doc.yaml +++ b/api/src/routes/agent/doc.yaml @@ -1227,7 +1227,9 @@ components: thinking: type: string nullable: true - description: One of minimal, low, medium, high, or null to inherit. + description: >- + One of off, minimal, low, medium, high, xhigh, adaptive, max, or + null/inherit. Per-model the daemon may only advertise a subset. requireAgentId: type: boolean nullable: true @@ -1268,7 +1270,7 @@ components: thinking: type: string nullable: true - enum: [minimal, low, medium, high, inherit] + enum: [off, minimal, low, medium, high, xhigh, adaptive, max, inherit] requireAgentId: type: boolean nullable: true diff --git a/api/src/services/openclaw/agentSubagents.ts b/api/src/services/openclaw/agentSubagents.ts index 695b684..f69c80b 100644 --- a/api/src/services/openclaw/agentSubagents.ts +++ b/api/src/services/openclaw/agentSubagents.ts @@ -18,7 +18,20 @@ const CLI_OPTS = { timeout: 15000, }; -const ALLOWED_THINKING = new Set(['minimal', 'low', 'medium', 'high', 'inherit']); +/* Full OpenClaw thinking-level vocabulary (openclaw/docs/tools/thinking.md). + * Per-model the daemon may only advertise a subset; we accept any of these + * here and let the daemon enforce model-specific support at runtime. */ +const ALLOWED_THINKING = new Set([ + 'off', + 'minimal', + 'low', + 'medium', + 'high', + 'xhigh', + 'adaptive', + 'max', + 'inherit', +]); function readConfig(): OpenclawConfig | null { try { diff --git a/api/src/services/openclaw/chat.ts b/api/src/services/openclaw/chat.ts index c21bbf1..bab5008 100644 --- a/api/src/services/openclaw/chat.ts +++ b/api/src/services/openclaw/chat.ts @@ -20,9 +20,15 @@ function runAgentWithEmitter( emitter: SseEmitter ): void { const sessionSettings = getSessionSettingsInternal(agentId, sessionKey); - const thinkingArg = - sessionSettings.thinkingLevel === 'inherit' ? 'medium' : sessionSettings.thinkingLevel; - const args = ['agent', '--agent', agentId, '-m', message, '--thinking', thinkingArg]; + const args = ['agent', '--agent', agentId, '-m', message]; + /* Omit `--thinking` entirely when the session is set to `inherit` so the + * daemon uses the active model's profile-managed default. Hard-coding + * `medium` here would be rejected by models that don't support it + * (Gemini 3.1 Pro Preview lists `off|low|adaptive|high`, Z.AI is binary, + * MiniMax disables thinking by default — see openclaw/docs/tools/thinking.md). */ + if (sessionSettings.thinkingLevel && sessionSettings.thinkingLevel !== 'inherit') { + args.push('--thinking', sessionSettings.thinkingLevel); + } if (sessionSettings.reasoningLevel && sessionSettings.reasoningLevel !== 'inherit') { args.push('--reasoning', sessionSettings.reasoningLevel); } @@ -218,8 +224,13 @@ function runAgentViaGateway( message, agentId, idempotencyKey: runId, - thinking: sessionSettings.thinkingLevel || 'medium', }; + /* Same rationale as the CLI fallback path above: only forward an + * explicit thinking override; `inherit` / unset → let the daemon + * resolve the model's profile default. */ + if (sessionSettings.thinkingLevel && sessionSettings.thinkingLevel !== 'inherit') { + params.thinking = sessionSettings.thinkingLevel; + } if (sessionKey) { const fullKey = `agent:${agentId}:${sessionKey}`; params.sessionId = sessionKey; @@ -284,15 +295,21 @@ export async function runChat( try { const gwReady = await gateway.ensureConnected(); const creds = gwReady ? loadGatewayCredentials() : null; - const hasWriteScope = creds - ? (creds.auth.tokens?.operator?.scopes || []).includes('operator.write') + /* Shared-token / shared-password auth on the daemon implies full + * gateway authorization, so the device-scope check is moot in that + * mode (and would always fail because device tokens carry no scopes + * when shared auth is configured). Fall back to the device-auth + * scope gate only on hosts that don't have a shared secret. */ + const canUseGateway = creds + ? Boolean(creds.sharedAuth) || + (creds.auth.tokens?.operator?.scopes || []).includes('operator.write') : false; - if (gwReady && hasWriteScope) { + if (gwReady && canUseGateway) { console.log('[chat] using gateway direct connection'); runAgentViaGateway(agentId, fullMessage, sessionKey, emitter); } else { - if (gwReady && !hasWriteScope) { + if (gwReady && !canUseGateway) { console.log( '[chat] gateway connected but device-auth lacks operator.write — using CLI fallback. ' + 'Fix: openclaw devices list → openclaw devices approve ' diff --git a/api/src/services/openclaw/sessions.ts b/api/src/services/openclaw/sessions.ts index 8aaa287..08c493f 100644 --- a/api/src/services/openclaw/sessions.ts +++ b/api/src/services/openclaw/sessions.ts @@ -49,7 +49,10 @@ function findSessionEntry( function defaultSettings(): SessionSettings { return { - thinkingLevel: 'medium', + /* `inherit` lets the daemon pick the model's profile-managed default + * instead of forcing `medium` (which several newer models — Gemini + * 3.1 Pro, Z.AI, MiniMax — reject as unsupported). */ + thinkingLevel: 'inherit', fastMode: null, verboseLevel: 'inherit', reasoningLevel: 'inherit', diff --git a/api/src/services/openclawGateway.ts b/api/src/services/openclawGateway.ts index 70cf798..c80b170 100644 --- a/api/src/services/openclawGateway.ts +++ b/api/src/services/openclawGateway.ts @@ -21,6 +21,7 @@ import { EventListener, DeviceCredentials, AuthCredentials, + SharedGatewayAuth, } from '../@types/gateway'; import { OpenclawConfig } from '../@types/openclaw'; import { errMsg, execErrText } from '../utils/errors'; @@ -57,19 +58,58 @@ function signPayload(privPem: string, payload: string): string { ); } +function loadSharedAuthFromConfig(config: OpenclawConfig | null): SharedGatewayAuth | null { + const auth = config?.gateway?.auth; + if (!auth) return null; + const token = typeof auth.token === 'string' && auth.token.length > 0 ? auth.token : undefined; + const password = + typeof auth.password === 'string' && auth.password.length > 0 ? auth.password : undefined; + /* `gateway.auth.mode` may be "none" / "trusted-proxy" / unset on hosts that + * delegate auth elsewhere. We only opt into shared-secret auth when there's + * an actual secret to send; the mode field itself is informational here. */ + if (!token && !password) return null; + return { token, password }; +} + export function loadGatewayCredentials(): GatewayCredentials | null { + let config: OpenclawConfig | null = null; + try { + const configPath = path.join(OPENCLAW_HOME, 'openclaw.json'); + config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as OpenclawConfig; + } catch (err) { + console.warn('[gateway] could not read openclaw.json:', errMsg(err)); + return null; + } + + const gatewayPort = config?.gateway?.port || 18789; + const sharedAuth = loadSharedAuthFromConfig(config); + + /* Device files are optional when shared auth is configured. Backend + * loopback clients (`client.id: "gateway-client"`, `client.mode: "backend"`) + * may omit `device` entirely on direct loopback when authenticating with a + * shared token/password — see openclaw/docs/gateway/protocol.md §Handshake. */ + let device: DeviceCredentials | null = null; + let auth: AuthCredentials = {}; try { const identityPath = path.join(OPENCLAW_HOME, 'identity', 'device.json'); const authPath = path.join(OPENCLAW_HOME, 'identity', 'device-auth.json'); - const configPath = path.join(OPENCLAW_HOME, 'openclaw.json'); - const device = JSON.parse(fs.readFileSync(identityPath, 'utf-8')) as DeviceCredentials; - const auth = JSON.parse(fs.readFileSync(authPath, 'utf-8')) as AuthCredentials; - const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as OpenclawConfig; - return { device, auth, gatewayPort: config.gateway?.port || 18789 }; + device = JSON.parse(fs.readFileSync(identityPath, 'utf-8')) as DeviceCredentials; + auth = JSON.parse(fs.readFileSync(authPath, 'utf-8')) as AuthCredentials; } catch (err) { - console.warn('[gateway] could not load credentials:', errMsg(err)); + if (!sharedAuth) { + /* Only complain when device files are actually required (no shared + * auth fallback). Otherwise their absence is expected and silent. */ + console.warn('[gateway] could not load device credentials:', errMsg(err)); + return null; + } + } + + if (!sharedAuth && !device) { + console.warn('[gateway] no credentials available (neither shared auth nor device pairing)'); return null; } + + return { device, auth, gatewayPort, sharedAuth }; } function isConnectChallenge( @@ -127,9 +167,12 @@ export class GatewayClient { return; } - const { device, auth, gatewayPort } = this.credentials; + const { device, auth, gatewayPort, sharedAuth } = this.credentials; const url = `ws://127.0.0.1:${gatewayPort}`; - console.log(`[gateway] connecting to ${url}...`); + let authMode: 'token' | 'password' | 'device' = 'device'; + if (sharedAuth?.token) authMode = 'token'; + else if (sharedAuth?.password) authMode = 'password'; + console.log(`[gateway] connecting to ${url} (auth: ${authMode})...`); const ws = new WsWebSocket(url); this.ws = ws; @@ -173,6 +216,65 @@ export class GatewayClient { if (isConnectChallenge(msg)) { const { nonce } = msg.payload; + const baseClient = { + id: 'gateway-client', + version: '1.0.0', + platform: process.platform, + mode: 'backend', + }; + + /* Prefer shared-secret auth when available — backend loopback + * clients can connect with `auth.token` / `auth.password` and + * skip device pairing entirely (no `openclaw devices approve` + * needed, ever). See openclaw/docs/gateway/protocol.md §Auth. + * + * Shared-secret auth is treated as trusted operator access (see + * docs/gateway/operator-scopes.md §Shared-secret auth), but the + * WebSocket connect frame still needs an explicit `role` + + * `scopes` declaration — the daemon doesn't auto-broaden a + * connection that authenticated with no claimed scopes, so + * subsequent `agent` / `chat.send` requests would reject with + * `missing scope: operator.write`. We ask for the full + * operator set; the daemon caps to whatever the shared secret + * is allowed to mint. */ + if (sharedAuth) { + ws.send( + JSON.stringify({ + type: 'req', + id: crypto.randomUUID(), + method: 'connect', + params: { + minProtocol: 3, + maxProtocol: 4, + client: baseClient, + caps: [], + role: 'operator', + scopes: ['operator.admin', 'operator.read', 'operator.write'], + /* `auth.password` is forwarded orthogonally; `auth.token` + * carries the shared token in priority order. Sending + * both is harmless on hosts configured with one. */ + auth: { + ...(sharedAuth.token ? { token: sharedAuth.token } : {}), + ...(sharedAuth.password ? { password: sharedAuth.password } : {}), + }, + }, + }) + ); + return; + } + + /* Legacy device-auth path. Keeps the v3 signed-payload format + * for backward compat with hosts that don't have a shared + * gateway secret configured. */ + if (!device) { + console.error('[gateway] no device credentials and no shared auth — cannot connect'); + try { + ws.terminate(); + } catch { + /* idempotent */ + } + return; + } const role = 'operator'; const scopes = auth.tokens?.operator?.scopes || [ 'operator.admin', @@ -203,12 +305,7 @@ export class GatewayClient { params: { minProtocol: 3, maxProtocol: 4, - client: { - id: 'gateway-client', - version: '1.0.0', - platform: process.platform, - mode: 'backend', - }, + client: baseClient, caps: [], role, scopes, @@ -507,6 +604,13 @@ export function ocSpawn(args: string[], options: SpawnOptions = {}): ChildProces export async function ensureDevicePaired(): Promise { const creds = loadGatewayCredentials(); + /* Shared-secret auth bypasses device pairing entirely — no scope-upgrade + * approval is ever needed, so this whole bootstrap is a no-op. */ + if (creds?.sharedAuth) { + const mode = creds.sharedAuth.token ? 'token' : 'password'; + console.log(`[setup] gateway shared-${mode} auth configured — skipping device pairing`); + return; + } const scopes = creds?.auth?.tokens?.operator?.scopes || []; if (scopes.includes('operator.write')) { console.log('[setup] device-auth already has operator.write'); diff --git a/client/src/features/agent/subagents/ui/AgentSubagents.tsx b/client/src/features/agent/subagents/ui/AgentSubagents.tsx index c60ebe5..1403ed4 100644 --- a/client/src/features/agent/subagents/ui/AgentSubagents.tsx +++ b/client/src/features/agent/subagents/ui/AgentSubagents.tsx @@ -28,10 +28,14 @@ import { const THINKING_OPTIONS: { value: string; label: string }[] = [ { value: 'inherit', label: 'Inherit' }, + { value: 'off', label: 'Off' }, { value: 'minimal', label: 'Minimal' }, { value: 'low', label: 'Low' }, { value: 'medium', label: 'Medium' }, { value: 'high', label: 'High' }, + { value: 'xhigh', label: 'X-High' }, + { value: 'adaptive', label: 'Adaptive' }, + { value: 'max', label: 'Max' }, ]; interface AgentSubagentsProps { diff --git a/client/src/widgets/chat/ui/SessionSettingsBar.tsx b/client/src/widgets/chat/ui/SessionSettingsBar.tsx index 9433900..f2d328e 100644 --- a/client/src/widgets/chat/ui/SessionSettingsBar.tsx +++ b/client/src/widgets/chat/ui/SessionSettingsBar.tsx @@ -3,7 +3,17 @@ import { Box } from '@mui/material'; import { useGetSessionSettingsQuery, usePatchSessionSettingsMutation } from '../../../entities/agent'; import SettingChip from './SettingChip'; -const THINKING_OPTIONS = ['inherit', 'off', 'minimal', 'low', 'medium', 'high', 'xhigh'] as const; +const THINKING_OPTIONS = [ + 'inherit', + 'off', + 'minimal', + 'low', + 'medium', + 'high', + 'xhigh', + 'adaptive', + 'max', +] as const; const FAST_OPTIONS = [ { value: 'inherit', label: 'inherit' }, { value: 'true', label: 'on' },