diff --git a/__tests__/gateway-context.test.tsx b/__tests__/gateway-context.test.tsx new file mode 100644 index 0000000..c3cc873 --- /dev/null +++ b/__tests__/gateway-context.test.tsx @@ -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> = [] + + 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) + } + + close() { + this.readyState = 3 + } +} + +function Harness({ onReady }: { onReady: (api: ReturnType) => 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 | null = null + render( + + (api = ctx)} /> + , + ) + + 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 + const auth = params.auth as Record + + 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 | null = null + render( + + (api = ctx)} /> + , + ) + + 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 + const auth = params.auth as Record + const device = params.device as Record + + 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') + }) +}) diff --git a/__tests__/gateway-protocol.test.ts b/__tests__/gateway-protocol.test.ts index 988b23f..5c66271 100644 --- a/__tests__/gateway-protocol.test.ts +++ b/__tests__/gateway-protocol.test.ts @@ -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).auth).toEqual({ password: 'secret123' }) + const auth = (req.params as Record).auth as Record + 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).auth as Record + expect(auth.password).toBe('pass') expect(auth.token).toBe('saved-token') }) }) @@ -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) @@ -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) @@ -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)', + ) }) }) diff --git a/context/gateway-context.tsx b/context/gateway-context.tsx index 251d754..7c8a4f9 100644 --- a/context/gateway-context.tsx +++ b/context/gateway-context.tsx @@ -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 + 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)) diff --git a/lib/gateway-protocol.ts b/lib/gateway-protocol.ts index 5b813e5..be458fe 100644 --- a/lib/gateway-protocol.ts +++ b/lib/gateway-protocol.ts @@ -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 } : {}), }, }; diff --git a/src-tauri/src/engine.rs b/src-tauri/src/engine.rs index c74ca43..80007e9 100644 --- a/src-tauri/src/engine.rs +++ b/src-tauri/src/engine.rs @@ -122,31 +122,49 @@ pub struct GatewayConfig { #[tauri::command] pub fn engine_gateway_config() -> Result { - // 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, }) }