mirror of
https://github.com/lotsoftick/openclaw_client.git
synced 2026-08-14 00:48:07 +00:00
@@ -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 ──
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,7 +18,20 @@ const CLI_OPTS = {
|
||||
timeout: 15000,
|
||||
};
|
||||
|
||||
const ALLOWED_THINKING = new Set<string>(['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<string>([
|
||||
'off',
|
||||
'minimal',
|
||||
'low',
|
||||
'medium',
|
||||
'high',
|
||||
'xhigh',
|
||||
'adaptive',
|
||||
'max',
|
||||
'inherit',
|
||||
]);
|
||||
|
||||
function readConfig(): OpenclawConfig | null {
|
||||
try {
|
||||
|
||||
@@ -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 <id>'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<void> {
|
||||
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');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user