Merge pull request #6 from lotsoftick/pairing-v4

Pairing v4
This commit is contained in:
Davit Hakobyan
2026-05-17 14:48:30 +04:00
committed by GitHub
9 changed files with 241 additions and 32 deletions
+15 -1
View File
@@ -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 ──
+46 -4
View File
@@ -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;
}
+4 -2
View File
@@ -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
+14 -1
View File
@@ -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 {
+25 -8
View File
@@ -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>'
+4 -1
View File
@@ -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',
+118 -14
View File
@@ -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' },