Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 188 additions & 0 deletions __tests__/gateway-context.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import React from 'react'
import { act, render, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { GatewayProvider, useGateway } from '@/context/gateway-context'

const {
mockGetOrCreateDeviceIdentity,
mockBuildDeviceAuthPayload,
mockSignPayload,
mockLoadDeviceToken,
mockStoreDeviceToken,
} = vi.hoisted(() => ({
mockGetOrCreateDeviceIdentity: vi.fn(),
mockBuildDeviceAuthPayload: vi.fn(),
mockSignPayload: vi.fn(),
mockLoadDeviceToken: vi.fn(),
mockStoreDeviceToken: vi.fn(),
}))

vi.mock('@/lib/device-auth', () => ({
getOrCreateDeviceIdentity: mockGetOrCreateDeviceIdentity,
buildDeviceAuthPayload: mockBuildDeviceAuthPayload,
signPayload: mockSignPayload,
loadDeviceToken: mockLoadDeviceToken,
storeDeviceToken: mockStoreDeviceToken,
}))

vi.mock('@/lib/tauri', () => ({
isTauri: () => false,
tauriInvoke: vi.fn(),
}))

vi.mock('@/lib/skill-first-policy', () => ({
buildSkillFirstBlockMessage: vi.fn(() => 'blocked'),
evaluateSkillFirstPolicy: vi.fn(() => ({ blocked: false })),
updateSkillProbeFromMessage: vi.fn(),
}))

class MockWebSocket {
static OPEN = 1
static CONNECTING = 0
static instances: MockWebSocket[] = []
static sentFrames: Array<Record<string, unknown>> = []

url: string
readyState = MockWebSocket.OPEN
onopen: ((ev: Event) => void) | null = null
onclose: ((ev: CloseEvent) => void) | null = null
onerror: (() => void) | null = null
onmessage: ((ev: MessageEvent) => void) | null = null

constructor(url: string) {
this.url = url
MockWebSocket.instances.push(this)
}

send(data: string) {
MockWebSocket.sentFrames.push(JSON.parse(data) as Record<string, unknown>)
}

close() {
this.readyState = 3
}
}

function Harness({ onReady }: { onReady: (api: ReturnType<typeof useGateway>) => void }) {
const api = useGateway()
React.useEffect(() => {
onReady(api)
}, [api, onReady])
return null
}

describe('GatewayProvider connect.challenge auth paths', () => {
beforeEach(() => {
MockWebSocket.instances = []
MockWebSocket.sentFrames = []
localStorage.clear()

vi.clearAllMocks()
vi.stubGlobal('WebSocket', MockWebSocket)
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')))

mockGetOrCreateDeviceIdentity.mockResolvedValue({
deviceId: 'dev-1',
publicKeyBase64Url: 'pub-key',
privateKey: 'priv-key',
})
mockBuildDeviceAuthPayload.mockReturnValue('payload-to-sign')
mockSignPayload.mockResolvedValue('sig-123')
})

it('without stored token sends connect request without device block', async () => {
mockLoadDeviceToken.mockReturnValue(null)

let api: ReturnType<typeof useGateway> | null = null
render(
<GatewayProvider>
<Harness onReady={(ctx) => (api = ctx)} />
</GatewayProvider>,
)

await waitFor(() => expect(api).not.toBeNull())

act(() => {
api!.connect('ws://localhost:18789', 'gateway-secret')
})

const ws = MockWebSocket.instances.at(-1)
expect(ws).toBeTruthy()

act(() => {
ws!.onmessage?.({
data: JSON.stringify({ type: 'event', event: 'connect.challenge', payload: { nonce: 'n1' } }),
} as MessageEvent)
})

await waitFor(() => {
expect(MockWebSocket.sentFrames.length).toBeGreaterThan(0)
})

const req = MockWebSocket.sentFrames[0]
expect(req.method).toBe('connect')
const params = req.params as Record<string, unknown>
const auth = params.auth as Record<string, unknown>

expect(auth.password).toBe('gateway-secret')
expect(auth.token).toBe('gateway-secret')
expect(params.device).toBeUndefined()

expect(mockBuildDeviceAuthPayload).not.toHaveBeenCalled()
expect(mockSignPayload).not.toHaveBeenCalled()
})

it('with stored token sends signed device block and token-preferred auth', async () => {
mockLoadDeviceToken.mockReturnValue('stored-device-token')

let api: ReturnType<typeof useGateway> | null = null
render(
<GatewayProvider>
<Harness onReady={(ctx) => (api = ctx)} />
</GatewayProvider>,
)

await waitFor(() => expect(api).not.toBeNull())

act(() => {
api!.connect('ws://localhost:18789', 'gateway-secret')
})

const ws = MockWebSocket.instances.at(-1)
expect(ws).toBeTruthy()

act(() => {
ws!.onmessage?.({
data: JSON.stringify({ type: 'event', event: 'connect.challenge', payload: { nonce: 'n2' } }),
} as MessageEvent)
})

await waitFor(() => {
expect(MockWebSocket.sentFrames.length).toBeGreaterThan(0)
})

const req = MockWebSocket.sentFrames[0]
expect(req.method).toBe('connect')
const params = req.params as Record<string, unknown>
const auth = params.auth as Record<string, unknown>
const device = params.device as Record<string, unknown>

expect(auth.password).toBe('gateway-secret')
expect(auth.token).toBe('stored-device-token')

expect(device.id).toBe('dev-1')
expect(device.publicKey).toBe('pub-key')
expect(device.signature).toBe('sig-123')
expect(device.nonce).toBe('n2')
expect(typeof device.signedAt).toBe('number')

expect(mockBuildDeviceAuthPayload).toHaveBeenCalledWith(
expect.objectContaining({
deviceId: 'dev-1',
token: 'stored-device-token',
nonce: 'n2',
}),
)
expect(mockSignPayload).toHaveBeenCalledWith('priv-key', 'payload-to-sign')
})
})
22 changes: 12 additions & 10 deletions __tests__/gateway-protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,19 @@ describe('makeRequest', () => {
})

describe('makeConnectRequest', () => {
it('creates a connect request with password', () => {
it('creates a connect request with password and token', () => {
const req = makeConnectRequest('secret123')
expect(req.method).toBe('connect')
expect((req.params as Record<string, unknown>).auth).toEqual({ password: 'secret123' })
const auth = (req.params as Record<string, unknown>).auth as Record<string, unknown>
expect(auth.password).toBe('secret123')
// Token is also sent so gateway auth.mode "token" works
expect(auth.token).toBe('secret123')
})

it('includes stored token when provided', () => {
it('prefers stored device token over password for token field', () => {
const req = makeConnectRequest('pass', undefined, 'saved-token')
const auth = (req.params as Record<string, unknown>).auth as Record<string, unknown>
expect(auth.password).toBe('pass')
expect(auth.token).toBe('saved-token')
})
})
Expand Down Expand Up @@ -86,9 +90,7 @@ describe('computeUsageStats', () => {

it('handles snake_case field names', () => {
const stats = computeUsageStats({
records: [
{ usage: { input_tokens: 500, output_tokens: 250 } },
],
records: [{ usage: { input_tokens: 500, output_tokens: 250 } }],
})
expect(stats.totalInputTokens).toBe(500)
expect(stats.totalOutputTokens).toBe(250)
Expand All @@ -106,9 +108,7 @@ describe('computeUsageStats', () => {
const stats = computeUsageStats({
records: [],
aggregates: {
daily: [
{ date: '2025-01-01', tokens: 1000, cost: 0.05 },
],
daily: [{ date: '2025-01-01', tokens: 1000, cost: 0.05 }],
},
})
expect(stats.daily).toHaveLength(1)
Expand Down Expand Up @@ -190,6 +190,8 @@ describe('formatSchedule', () => {

it('formats cron schedule', () => {
expect(formatSchedule({ kind: 'cron', expr: '0 * * * *' })).toBe('0 * * * *')
expect(formatSchedule({ kind: 'cron', expr: '0 9 * * 1', tz: 'US/Eastern' })).toBe('0 9 * * 1 (US/Eastern)')
expect(formatSchedule({ kind: 'cron', expr: '0 9 * * 1', tz: 'US/Eastern' })).toBe(
'0 9 * * 1 (US/Eastern)',
)
})
})
60 changes: 37 additions & 23 deletions context/gateway-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,29 +162,43 @@ export function GatewayProvider({ children }: { children: React.ReactNode }) {
const signedAt = Date.now()
const existingToken = loadDeviceToken(identity.deviceId, role)

const authPayload = buildDeviceAuthPayload({
deviceId: identity.deviceId,
clientId: 'gateway-client',
clientMode: 'ui',
role,
scopes,
signedAtMs: signedAt,
token: existingToken,
nonce,
})
const signature = await signPayload(identity.privateKey, authPayload)

const connectReq = makeConnectRequest(
password,
{
id: identity.deviceId,
publicKey: identity.publicKeyBase64Url,
signature,
signedAt,
...(nonce ? { nonce } : {}),
},
existingToken ?? undefined,
)
// When we have a stored device token from a prior successful pairing,
// send full device identity with cryptographic signature.
// On first connect (no stored token), skip device auth entirely and
// rely on token/password-only auth. This avoids the signature mismatch
// where the client signs with an empty token field but the server
// reconstructs the payload using auth.token (the gateway token).
// After a successful token-only connect, the gateway issues a
// deviceToken that gets stored for subsequent connections.
let connectReq: ReturnType<typeof makeConnectRequest>
if (existingToken) {
const authPayload = buildDeviceAuthPayload({
deviceId: identity.deviceId,
clientId: 'gateway-client',
clientMode: 'ui',
role,
scopes,
signedAtMs: signedAt,
token: existingToken,
nonce,
})
const signature = await signPayload(identity.privateKey, authPayload)

connectReq = makeConnectRequest(
password,
{
id: identity.deviceId,
publicKey: identity.publicKeyBase64Url,
signature,
signedAt,
...(nonce ? { nonce } : {}),
},
existingToken,
)
} else {
// First connection: token-only, no device block
connectReq = makeConnectRequest(password)
}

ws.send(JSON.stringify(connectReq))

Expand Down
9 changes: 8 additions & 1 deletion lib/gateway-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -526,7 +526,14 @@ export function makeConnectRequest(
role: "operator",
scopes: ["operator.read", "operator.write", "operator.admin"],
caps: [],
auth: { password, ...(storedToken ? { token: storedToken } : {}) },
auth: {
password,
// Gateway auth.mode can be token or password. The gateway checks
// auth.token when mode is token and auth.password when mode is
// password, so send the credential as both to cover either mode.
// A stored device token (from prior pairing) takes precedence.
token: storedToken || password || undefined,
},
...(device ? { device } : {}),
},
};
Expand Down
36 changes: 27 additions & 9 deletions src-tauri/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,31 +122,49 @@ pub struct GatewayConfig {

#[tauri::command]
pub fn engine_gateway_config() -> Result<GatewayConfig, String> {
// Read ~/.openclaw/openclaw.json for gateway port and password
// Read ~/.openclaw/openclaw.json for gateway port and auth token.
// OpenClaw nests these under a "gateway" key:
// { "gateway": { "port": 18789, "auth": { "mode": "token", "token": "..." } } }
// Fall back to top-level keys for flat configs or legacy layouts.
let home = std::env::var("HOME").unwrap_or_default();
let config_path = std::path::PathBuf::from(&home).join(".openclaw/openclaw.json");

if !config_path.exists() {
return Err("Config not found".to_string());
return Err("Config not found: ~/.openclaw/openclaw.json".to_string());
}

let content = std::fs::read_to_string(&config_path)
.map_err(|e| format!("Failed to read config: {}", e))?;

let config: serde_json::Value =
let root: serde_json::Value =
serde_json::from_str(&content).map_err(|e| format!("Failed to parse config: {}", e))?;

// Extract port (default 18789) and password
let port = config.get("port").and_then(|v| v.as_u64()).unwrap_or(18789);
// Prefer gateway.* sub-object; fall back to top-level for flat configs
let gateway = root.get("gateway").unwrap_or(&root);

let password = config
let port = gateway
.get("port")
.and_then(|v| v.as_u64())
.unwrap_or(18789);

// OpenClaw uses "token" (with auth.mode: "token") as the primary auth method.
// Also check "password" for legacy/alternative auth modes.
let token_from_config = gateway
.get("auth")
.and_then(|a| a.get("password"))
.and_then(|p| p.as_str())
.and_then(|a| a.get("token").or_else(|| a.get("password")))
.and_then(|v| v.as_str())
.unwrap_or("");

// Also check OPENCLAW_GATEWAY_TOKEN env var as final fallback,
// matching how the gateway itself resolves the token at runtime.
let password = if token_from_config.is_empty() {
std::env::var("OPENCLAW_GATEWAY_TOKEN").unwrap_or_default()
} else {
token_from_config.to_string()
};

Ok(GatewayConfig {
url: format!("ws://127.0.0.1:{}", port),
password: password.to_string(),
password,
})
}