diff --git a/CHANGELOG.md b/CHANGELOG.md
index ec123fc9..0f502db2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,10 +2,40 @@
All notable changes to Tandem Browser will be documented in this file.
-## [v0.62.14] - 2026-03-17
+## [v0.62.16] - 2026-03-17
-- fix: use assertPathWithinRoot return value so CodeQL traces the safe path
+- fix: satisfy CodeQL rate limit detection (api)
+What was built/changed:
+- Modified files: src/api/routes/data.ts
+- Swapped the OpenClaw token/connect route limiters to a CodeQL-recognized express-rate-limit middleware while keeping the existing request caps and messages
+
+Why this approach:
+- The endpoint was already protected by the custom limiter, but CodeQL does not treat that middleware as a proven rate limiter for this filesystem-backed handler
+- Using a standard limiter on the sensitive OpenClaw config routes removes the false-positive gate without changing the user-visible behavior
+
+Tested:
+- npx tsc --pretty false: zero errors
+- npx vitest run src/api/tests/routes/data.test.ts: 52 passed
+
+## [v0.62.15] - 2026-03-17
+
+- fix: restore stock OpenClaw Wingman chat (wingman)
+
+What was built/changed:
+- New files: src/openclaw/connect.ts
+- Modified files: src/api/routes/data.ts, src/api/tests/routes/data.test.ts, src/ipc/handlers.ts, src/panel/manager.ts, src/preload.ts, shell/chat/openclaw-backend.js, shell/chat/router.js, shell/js/wingman.js, TODO.md, CHANGELOG.md
+- New API endpoints: GET /config/openclaw-connect
+- Chat send/persist flow now stores Robin and Wingman messages without depending on the old local tandem-chat skill
+
+Why this approach:
+- Stock Tandem now signs a real OpenClaw device identity for the gateway WebSocket handshake and uses the same operator read/write chat flow as the official OpenClaw webchat
+- This removes the hidden dependency on a local /chat polling bridge and fixes the misleading connected state in the panel
+
+Tested:
+- npx tsc --pretty false: zero errors
+- npx vitest run: 34 files, 1036 passed, 39 skipped
+- Manual: verified local OpenClaw gateway chat round-trip in the Wingman panel, GET /config/openclaw-connect, and persisted replies via GET /chat
## [v0.62.13] - 2026-03-17
- fix: restrict sync root paths to user home directory (security)
diff --git a/TODO.md b/TODO.md
index ec02b3f9..2f774d26 100644
--- a/TODO.md
+++ b/TODO.md
@@ -4,7 +4,7 @@
> Historical release summaries belong in `CHANGELOG.md`.
> Architecture and product context belong in `PROJECT.md`.
-Last updated: March 14, 2026
+Last updated: March 17, 2026
## Purpose
@@ -14,7 +14,7 @@ Last updated: March 14, 2026
## Current Snapshot
-- Current app version: `0.57.6`
+- Current app version: `0.62.14`
- The codebase scope is larger than this backlog summary and includes major subsystems such as `sidebar`, `workspaces`, `pinboards`, `sync`, `headless`, and `sessions`.
- Scheduled browsing already exists in baseline form via `WatchManager` and the `/watch/*` API routes.
- Session isolation already exists in baseline form via `SessionManager` and the `/sessions/*` API routes.
@@ -24,6 +24,7 @@ Last updated: March 14, 2026
### Product Features
+- [ ] Remove the remaining legacy OpenClaw compatibility IPC and unused webhook chat code after the signed gateway-chat path has shipped for a release or two
- [ ] `WebSocket /watch/live` for live watch updates
- [ ] Expose `captureApplicationScreenshot` and `captureRegionScreenshot` as HTTP API endpoints (e.g. `POST /screenshot/application`, `POST /screenshot/region`) so OpenClaw agents can trigger full-window and region captures programmatically without requiring IPC or human interaction
- [x] Show a notification when the Wingman panel is closed and Wingman replies
@@ -46,6 +47,7 @@ Last updated: March 14, 2026
### Codebase Hygiene
+- [x] Make Wingman `openclaw` mode gateway-first for sends, sign a real OpenClaw device identity for the WebSocket handshake, and persist gateway replies into Tandem chat history so stock Tandem no longer depends on a local OpenClaw tandem-chat skill
- [x] Split `src/main.ts` bootstrap and teardown wiring into dedicated `src/bootstrap/` modules so manager composition stops growing in one file
- [x] Extract the largest shell surfaces out of `shell/index.html` and `shell/css/main.css` so sidebar logic, modal helpers, and stylesheet sections stop living in single inline or monolithic files
- [x] Split the Wingman and ClaroNote renderer surfaces out of `shell/js/main.js` into dedicated shell modules with explicit shared state instead of file-scope coupling
diff --git a/package-lock.json b/package-lock.json
index 652b66dd..dd046668 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "tandem-browser",
- "version": "0.62.14",
+ "version": "0.62.16",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tandem-browser",
- "version": "0.62.14",
+ "version": "0.62.16",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
diff --git a/package.json b/package.json
index 028142ed..81823523 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "tandem-browser",
- "version": "0.62.14",
+ "version": "0.62.16",
"description": "First-party OpenClaw companion browser for human-AI collaboration with built-in security controls",
"main": "dist/main.js",
"author": "Tandem Browser contributors",
diff --git a/shell/about.html b/shell/about.html
index 0b35d2b2..346170ed 100644
--- a/shell/about.html
+++ b/shell/about.html
@@ -114,7 +114,7 @@
Tandem
First-Party OpenClaw Companion Browser
Developer Preview
- v0.62.14
+ v0.62.16
Built specifically for human-AI collaboration with OpenClaw.
Maintained in the same ecosystem as OpenClaw, with security and local control built in.
diff --git a/shell/chat/openclaw-backend.js b/shell/chat/openclaw-backend.js
index 62ebea5d..672efca7 100644
--- a/shell/chat/openclaw-backend.js
+++ b/shell/chat/openclaw-backend.js
@@ -3,7 +3,8 @@
* Implements ChatBackend interface (see src/chat/interfaces.ts)
*
* Extracted from inline ocChat IIFE in index.html.
- * Token is loaded dynamically from ~/.openclaw/openclaw.json via API.
+ * Connect params are prepared by Tandem so the browser client can present
+ * a signed device identity without exposing private keys in the renderer.
*/
class OpenClawBackend {
constructor() {
@@ -18,7 +19,6 @@ class OpenClawBackend {
this._streamingMsg = null;
this._streamingText = '';
this._pendingCallbacks = new Map();
- this._token = null;
this._sessionKey = 'agent:main:main';
this._wsUrl = 'ws://127.0.0.1:18789';
@@ -31,9 +31,6 @@ class OpenClawBackend {
}
async connect() {
- if (!this._token) {
- await this._fetchToken();
- }
this._doConnect();
}
@@ -54,12 +51,23 @@ class OpenClawBackend {
}
async sendMessage(text) {
- if (!text || !this._connected) return;
- this._sendRequest('chat.send', {
+ if (!text) return false;
+ const connected = await this._ensureConnected();
+ if (!connected) return false;
+
+ const res = await this._sendRequestAsync('chat.send', {
sessionKey: this._sessionKey,
message: text,
idempotencyKey: crypto.randomUUID()
});
+ const payload = this._getResponsePayload(res);
+ return Boolean(
+ res
+ && res.ok !== false
+ && !res.error
+ && payload
+ && (payload.runId || payload.status === 'started' || payload.status === 'in_flight' || payload.status === 'ok')
+ );
}
onMessage(cb) { this._messageCallbacks.push(cb); }
@@ -69,14 +77,13 @@ class OpenClawBackend {
/** Load chat history from OpenClaw */
loadHistory(onMessages) {
this._sendRequest('chat.history', { sessionKey: this._sessionKey, limit: 20 }, (res) => {
- if (!res.result) return;
- const msgs = res.result.messages || res.result;
+ const payload = this._getResponsePayload(res);
+ if (!payload) return;
+ const msgs = payload.messages || payload;
if (!Array.isArray(msgs)) return;
const parsed = [];
for (const m of msgs) {
- const text = Array.isArray(m.content)
- ? m.content.filter(c => c.type === 'text').map(c => c.text).join('\n')
- : (m.text || m.content || '');
+ const text = this._extractMessageText(m);
if (text) {
parsed.push({
id: m.id || crypto.randomUUID(),
@@ -95,17 +102,18 @@ class OpenClawBackend {
// ── Private ────────────────────────────────────
- async _fetchToken() {
+ async _fetchConnectParams(nonce) {
try {
- const res = await fetch(`${this._apiBase}/config/openclaw-token`);
+ const res = await fetch(`${this._apiBase}/config/openclaw-connect?nonce=${encodeURIComponent(nonce)}`);
if (!res.ok) {
- console.warn('[OpenClawBackend] Could not fetch token:', res.statusText);
- return;
+ console.warn('[OpenClawBackend] Could not fetch connect params:', res.statusText);
+ return null;
}
const data = await res.json();
- this._token = data.token;
+ return data.params || null;
} catch (e) {
- console.warn('[OpenClawBackend] Token fetch failed:', e.message);
+ console.warn('[OpenClawBackend] Connect param fetch failed:', e.message);
+ return null;
}
}
@@ -122,26 +130,32 @@ class OpenClawBackend {
if (msg.type === 'event') {
if (msg.event === 'connect.challenge') {
- this._sendRequest('connect', {
- minProtocol: 3, maxProtocol: 3,
- client: { id: 'webchat', version: '1.0', platform: 'browser', mode: 'webchat', instanceId: crypto.randomUUID() },
- role: 'operator',
- scopes: ['operator.admin'],
- auth: { token: this._token },
- userAgent: navigator.userAgent,
- locale: navigator.language
- }, (res) => {
- if (res.result) {
- this._setConnected(true);
- this._reconnectDelay = 1000;
- // Load history after connecting (emit as historyReload so UI clears first)
- this.loadHistory((msgs) => {
- this._emit('historyReload', msgs);
- });
- } else {
- console.error('[OpenClawBackend] Connect failed:', res.error);
+ this._fetchConnectParams(msg.payload?.nonce || '').then((params) => {
+ if (!params) {
this._setConnected(false);
+ return;
}
+
+ params.userAgent = navigator.userAgent;
+ params.locale = navigator.language;
+
+ this._sendRequest('connect', params, (res) => {
+ const payload = this._getResponsePayload(res);
+ if (res && res.ok !== false && payload) {
+ this._setConnected(true);
+ this._reconnectDelay = 1000;
+ // Load history after connecting (emit as historyReload so UI clears first)
+ this.loadHistory((msgs) => {
+ this._emit('historyReload', msgs);
+ });
+ } else {
+ console.error('[OpenClawBackend] Connect failed:', res.error);
+ this._setConnected(false);
+ }
+ });
+ }).catch((error) => {
+ console.error('[OpenClawBackend] Connect preparation failed:', error?.message || error);
+ this._setConnected(false);
});
}
if (msg.event === 'chat') {
@@ -155,7 +169,7 @@ class OpenClawBackend {
const cb = this._pendingCallbacks.get(msg.id);
if (cb) {
this._pendingCallbacks.delete(msg.id);
- this._invokeCallback(cb, [msg]);
+ this._invokeCallback(cb, [this._normalizeResponse(msg)]);
}
}
};
@@ -180,11 +194,49 @@ class OpenClawBackend {
return id;
}
+ _sendRequestAsync(method, params) {
+ return new Promise((resolve) => {
+ const id = this._sendRequest(method, params, (res) => resolve(res));
+ if (!id) {
+ return;
+ }
+ });
+ }
+
+ async _ensureConnected(timeoutMs = 4000) {
+ if (this._connected) return true;
+
+ await this.connect();
+ if (this._connected) return true;
+
+ return new Promise((resolve) => {
+ let settled = false;
+ const onChange = (connected) => {
+ if (!connected || settled) return;
+ settled = true;
+ cleanup();
+ resolve(true);
+ };
+ const cleanup = () => {
+ clearTimeout(timer);
+ const index = this._connectionCallbacks.indexOf(onChange);
+ if (index >= 0) this._connectionCallbacks.splice(index, 1);
+ };
+ const timer = setTimeout(() => {
+ if (settled) return;
+ cleanup();
+ resolve(this._connected);
+ }, timeoutMs);
+
+ this._connectionCallbacks.push(onChange);
+ });
+ }
+
_handleChatEvent(payload) {
const { state, message } = payload;
if (state === 'delta') {
this._emit('typing', true);
- const text = (message && (message.text || (Array.isArray(message.content) ? message.content.filter(c => c.type === 'text').map(c => c.text).join('') : ''))) || '';
+ const text = this._extractMessageText(message);
this._streamingText = text || this._streamingText;
this._emit('message', {
id: 'streaming',
@@ -196,7 +248,7 @@ class OpenClawBackend {
});
} else if (state === 'final') {
this._emit('typing', false);
- const finalText = this._streamingText;
+ const finalText = this._extractMessageText(message) || this._streamingText;
this._streamingMsg = null;
this._streamingText = '';
// Emit a non-streaming message to finalize the UI element
@@ -250,4 +302,38 @@ class OpenClawBackend {
cb(...args);
}
}
+
+ _extractMessageText(message) {
+ if (!message) return '';
+ if (Array.isArray(message.content)) {
+ return message.content
+ .filter((part) => part && part.type === 'text' && typeof part.text === 'string')
+ .map((part) => part.text)
+ .join('\n');
+ }
+ return message.text || message.content || '';
+ }
+
+ _normalizeResponse(response) {
+ if (!response || typeof response !== 'object') {
+ return { ok: false, error: { code: 'INVALID_RESPONSE', message: 'Invalid response frame' } };
+ }
+
+ if (Object.prototype.hasOwnProperty.call(response, 'payload') || Object.prototype.hasOwnProperty.call(response, 'ok')) {
+ return {
+ ...response,
+ result: response.payload
+ };
+ }
+
+ return {
+ ...response,
+ ok: !response.error,
+ payload: response.result
+ };
+ }
+
+ _getResponsePayload(response) {
+ return response?.payload ?? response?.result ?? null;
+ }
}
diff --git a/shell/chat/router.js b/shell/chat/router.js
index 78b2ef0d..d4681bd0 100644
--- a/shell/chat/router.js
+++ b/shell/chat/router.js
@@ -67,9 +67,9 @@ class ChatRouter {
const backend = this.getActive();
if (!backend) {
console.warn('[ChatRouter] No active backend');
- return;
+ return false;
}
- await backend.sendMessage(text);
+ return backend.sendMessage(text);
}
async connectAll() {
diff --git a/shell/js/wingman.js b/shell/js/wingman.js
index 7b988ec5..f0dc4003 100644
--- a/shell/js/wingman.js
+++ b/shell/js/wingman.js
@@ -349,6 +349,16 @@
return el;
}
+ async function persistChatMessage(from, text, image, notifyWebhook = false) {
+ if (!window.tandem?.persistChatMessage) return false;
+ try {
+ const result = await window.tandem.persistChatMessage({ from, text, image, notifyWebhook });
+ return Boolean(result?.ok);
+ } catch {
+ return false;
+ }
+ }
+
// ── Router setup ──
const router = new ChatRouter();
@@ -381,22 +391,26 @@
if (btnBoth) btnBoth.classList.toggle('active', activeId === 'both');
if (activeId === 'both') {
- // In both mode — OpenClaw always available via webhook
+ const ocConn = openclawBackend.isConnected();
const clConn = claudeBackend.isConnected();
- wsDot.style.background = 'var(--success)'; // Always connected (OpenClaw via webhook)
- if (clConn) {
+ const bothConnected = ocConn && clConn;
+ const anyConnected = ocConn || clConn;
+ wsDot.style.background = anyConnected ? 'var(--success)' : 'var(--accent)';
+ if (bothConnected) {
wsStatusText.textContent = 'Wingman + Claude Connected';
- } else {
+ } else if (ocConn) {
wsStatusText.textContent = 'Wingman Connected, Claude Disconnected';
+ } else if (clConn) {
+ wsStatusText.textContent = 'Wingman Disconnected, Claude Connected';
+ } else {
+ wsStatusText.textContent = 'Wingman + Claude Disconnected';
}
inputEl.placeholder = 'Message to Wingman & Claude... (@wingman/@claude for specific)';
} else {
// Single backend mode
const backend = router.getActive();
if (backend) {
- // OpenClaw uses webhook path (always available if Tandem API is running)
- const isOC = router.getActiveId() === 'openclaw';
- const connected = isOC ? true : backend.isConnected();
+ const connected = backend.isConnected();
wsDot.style.background = connected ? 'var(--success)' : 'var(--accent)';
wsStatusText.textContent = connected ? `${backend.name} Connected` : `${backend.name} Disconnected`;
}
@@ -493,21 +507,18 @@
router.onConnectionChange((connected, backendId) => {
if (backendId === 'openclaw') {
- // OpenClaw always "connected" via webhook path (WebSocket is optional for receiving)
- if (dotOC) dotOC.classList.add('connected');
+ if (dotOC) dotOC.classList.toggle('connected', connected);
} else if (backendId === 'claude') {
if (dotCL) dotCL.classList.toggle('connected', connected);
}
- // Update "both" dot — OpenClaw always available
- if (dotBoth) dotBoth.classList.add('connected');
+ if (dotBoth) dotBoth.classList.toggle('connected', openclawBackend.isConnected() && claudeBackend.isConnected());
// Update status bar for current mode
if (currentMode === 'both') {
updateBackendUI('both');
} else if (backendId === router.getActiveId()) {
const backend = router.getActive();
- const isOC = backendId === 'openclaw';
- const effectiveConnected = isOC ? true : connected;
+ const effectiveConnected = backend ? backend.isConnected() : connected;
wsDot.style.background = effectiveConnected ? 'var(--success)' : 'var(--accent)';
wsStatusText.textContent = effectiveConnected ? `${backend.name} Connected` : `${backend.name} Disconnected`;
}
@@ -538,13 +549,19 @@
const timeEl = streamData.element.querySelector('.msg-time');
if (timeEl) timeEl.textContent = formatTime(Date.now());
}
+ messagesEl.innerHTML = '';
streamingMessages.clear();
currentConversationId = null;
- // Re-add local Robin messages after any history operations
+ if (Array.isArray(msg)) {
+ for (const historyMsg of msg) {
+ const el = appendMessage(historyMsg.role, historyMsg.text, historyMsg.timestamp, historyMsg.source, historyMsg.image);
+ el.dataset.fromHistory = 'true';
+ }
+ }
+
setTimeout(() => {
for (const localMsg of localRobinMessages) {
- // Check if this message is already in the DOM (from history)
let alreadyExists = false;
for (const child of messagesEl.children) {
if (child.classList.contains('robin') &&
@@ -555,7 +572,6 @@
}
}
- // Only add if not already present from history
if (!alreadyExists) {
const el = appendMessage(localMsg.role, localMsg.text, localMsg.timestamp, localMsg.source, localMsg.image);
el.dataset.localMessage = 'true';
@@ -593,6 +609,7 @@
}
}
} else {
+ const shouldPersistOpenClawFinal = backendId === 'openclaw' && msg._final && msg.text;
// Finalize current conversation
if (currentConversationId) {
const streamData = streamingMessages.get(currentConversationId);
@@ -605,11 +622,19 @@
scrollToBottom();
streamingMessages.delete(currentConversationId);
}
+ if (backendId === 'openclaw' && msg.text) {
+ void persistChatMessage('wingman', msg.text);
+ }
currentConversationId = null;
+ } else if (shouldPersistOpenClawFinal) {
+ void persistChatMessage('wingman', msg.text);
}
// Only append a new element if this is NOT a final event (final reuses the streaming element)
if (!msg._final) {
appendMessage(msg.role, msg.text, msg.timestamp, msg.source, msg.image);
+ if (backendId === 'openclaw' && msg.text) {
+ void persistChatMessage('wingman', msg.text, msg.image);
+ }
}
}
});
@@ -685,6 +710,7 @@
}
}
} else {
+ const shouldPersistOpenClawFinal = backendId === 'openclaw' && msg._final && msg.text;
// Finalize conversation for this backend
if (dualStreamingConversations[backendId]) {
const convId = dualStreamingConversations[backendId].conversationId;
@@ -695,9 +721,19 @@
if (timeEl) timeEl.textContent = formatTime(Date.now());
streamingMessages.delete(convId);
}
+ if (backendId === 'openclaw' && msg.text) {
+ void persistChatMessage('wingman', msg.text);
+ }
delete dualStreamingConversations[backendId];
+ } else if (shouldPersistOpenClawFinal) {
+ void persistChatMessage('wingman', msg.text);
+ }
+ if (!msg._final) {
+ appendMessage(msg.role, msg.text, msg.timestamp, msg.source, msg.image);
+ if (backendId === 'openclaw' && msg.text) {
+ void persistChatMessage('wingman', msg.text, msg.image);
+ }
}
- appendMessage(msg.role, msg.text, msg.timestamp, msg.source, msg.image);
}
});
@@ -784,7 +820,7 @@
// ── Send message (input + button) ──
- function sendMessage() {
+ async function sendMessage() {
const text = inputEl.value.trim();
// Image paste: send via IPC (before text-only check)
@@ -836,16 +872,20 @@
const backend = router.getActive();
const activeId = router.getActiveId();
- // OpenClaw: always send via IPC→webhook (doesn't need WebSocket to be connected)
+ // OpenClaw: send through the official gateway chat path.
if (activeId === 'openclaw') {
const robinMsg = appendMessage('user', text, Date.now(), 'robin');
robinMsg.dataset.localMessage = 'true';
- // IPC → panelManager.addChatMessage → webhook → /hooks/wake → Wingman receives it
- window.tandem?.sendChatMessage(text);
+ const sentViaGateway = await router.sendMessage(text);
+ if (sentViaGateway) {
+ void persistChatMessage('robin', text);
+ } else {
+ appendMessage('assistant', '⚠️ Wingman could not reach OpenClaw.', Date.now(), 'wingman');
+ }
} else {
// For Claude, needs WebSocket connection
if (!backend || !backend.isConnected()) return;
- router.sendMessage(text);
+ await router.sendMessage(text);
}
}
}
diff --git a/src/api/routes/data.ts b/src/api/routes/data.ts
index baf326d1..a9fa8adf 100644
--- a/src/api/routes/data.ts
+++ b/src/api/routes/data.ts
@@ -1,4 +1,5 @@
import type { Router, Request, Response } from 'express';
+import { rateLimit as expressRateLimit } from 'express-rate-limit';
import path from 'path';
import os from 'os';
import fs from 'fs';
@@ -6,9 +7,25 @@ import type { RouteContext } from '../context';
import { tandemDir } from '../../utils/paths';
import { handleRouteError } from '../../utils/errors';
import { createLogger } from '../../utils/logger';
+import { buildOpenClawConnectParams, readOpenClawGatewayToken } from '../../openclaw/connect';
import { createRateLimitMiddleware } from '../rate-limit';
const log = createLogger('DataRoutes');
+const openClawTokenRateLimit = expressRateLimit({
+ windowMs: 60_000,
+ limit: 10,
+ standardHeaders: true,
+ legacyHeaders: false,
+ message: { error: 'Too many OpenClaw token requests. Retry shortly.' },
+});
+
+const openClawConnectRateLimit = expressRateLimit({
+ windowMs: 60_000,
+ limit: 30,
+ standardHeaders: true,
+ legacyHeaders: false,
+ message: { error: 'Too many OpenClaw connect requests. Retry shortly.' },
+});
export function registerDataRoutes(router: Router, ctx: RouteContext): void {
// ═══════════════════════════════════════════════
@@ -182,20 +199,14 @@ export function registerDataRoutes(router: Router, ctx: RouteContext): void {
}
});
- router.get('/config/openclaw-token', createRateLimitMiddleware({
- bucket: 'data-openclaw-token',
- windowMs: 60_000,
- max: 10,
- message: 'Too many OpenClaw token requests. Retry shortly.',
- }), (_req: Request, res: Response) => {
+ router.get('/config/openclaw-token', openClawTokenRateLimit, (_req: Request, res: Response) => {
try {
const openclawPath = path.join(os.homedir(), '.openclaw', 'openclaw.json');
if (!fs.existsSync(openclawPath)) {
res.status(404).json({ error: 'OpenClaw config not found at ~/.openclaw/openclaw.json' });
return;
}
- const data = JSON.parse(fs.readFileSync(openclawPath, 'utf-8'));
- const token = data.token || data.gateway?.auth?.token;
+ const token = readOpenClawGatewayToken();
if (!token) {
res.status(404).json({ error: 'No token field in openclaw.json' });
return;
@@ -206,6 +217,27 @@ export function registerDataRoutes(router: Router, ctx: RouteContext): void {
}
});
+ router.get('/config/openclaw-connect', openClawConnectRateLimit, (req: Request, res: Response) => {
+ try {
+ const nonce = typeof req.query.nonce === 'string' ? req.query.nonce.trim() : '';
+ if (!nonce) {
+ res.status(400).json({ error: 'nonce required' });
+ return;
+ }
+
+ const openclawPath = path.join(os.homedir(), '.openclaw', 'openclaw.json');
+ if (!fs.existsSync(openclawPath)) {
+ res.status(404).json({ error: 'OpenClaw config not found at ~/.openclaw/openclaw.json' });
+ return;
+ }
+
+ const params = buildOpenClawConnectParams(nonce);
+ res.json({ params });
+ } catch (e) {
+ handleRouteError(res, e);
+ }
+ });
+
// ═══════════════════════════════════════════════
// DATA EXPORT / IMPORT
// ═══════════════════════════════════════════════
diff --git a/src/api/tests/routes/data.test.ts b/src/api/tests/routes/data.test.ts
index d14496f5..93306b9e 100644
--- a/src/api/tests/routes/data.test.ts
+++ b/src/api/tests/routes/data.test.ts
@@ -440,6 +440,39 @@ describe('Data Routes', () => {
});
});
+ describe('GET /config/openclaw-connect', () => {
+ it('returns 400 when nonce is missing', async () => {
+ const res = await request(app).get('/config/openclaw-connect');
+
+ expect(res.status).toBe(400);
+ expect(res.body.error).toBe('nonce required');
+ });
+
+ it('returns signed connect params for OpenClaw gateway auth', async () => {
+ vi.mocked(fs.existsSync).mockImplementation((filePath: any) => (
+ typeof filePath === 'string' && filePath.includes('.openclaw/openclaw.json')
+ ));
+ vi.mocked(fs.readFileSync).mockImplementation((filePath: any) => {
+ if (typeof filePath === 'string' && filePath.includes('.openclaw/openclaw.json')) {
+ return JSON.stringify({ gateway: { auth: { token: 'nested-token' } } }) as any;
+ }
+
+ throw new Error(`unexpected readFileSync: ${String(filePath)}`);
+ });
+
+ const res = await request(app).get('/config/openclaw-connect?nonce=test-nonce');
+
+ expect(res.status).toBe(200);
+ expect(res.body.params.auth.token).toBe('nested-token');
+ expect(res.body.params.scopes).toEqual(['operator.read', 'operator.write']);
+ expect(typeof res.body.params.device.id).toBe('string');
+ expect(res.body.params.device.id.length).toBeGreaterThan(10);
+ expect(res.body.params.device.nonce).toBe('test-nonce');
+ expect(typeof res.body.params.device.signature).toBe('string');
+ expect(res.body.params.device.signature.length).toBeGreaterThan(10);
+ });
+ });
+
// ═══════════════════════════════════════════════
// DATA EXPORT / IMPORT
// ═══════════════════════════════════════════════
diff --git a/src/ipc/handlers.ts b/src/ipc/handlers.ts
index 79771c03..0ba18692 100644
--- a/src/ipc/handlers.ts
+++ b/src/ipc/handlers.ts
@@ -75,6 +75,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
const ipcChannels = [
'tab-update',
'chat-send',
+ 'chat-send-legacy',
'voice-transcript',
'voice-status-update',
'activity-webview-event',
@@ -104,6 +105,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
'emergency-stop',
'show-tab-context-menu',
'chat-send-image',
+ 'chat-persist-message',
'navigate',
'go-back',
'go-forward',
@@ -135,6 +137,13 @@ export function registerIpcHandlers(deps: IpcDeps): void {
}
});
+ // Legacy webhook-based path kept as a fallback during OpenClaw chat migration.
+ ipcMain.on('chat-send-legacy', (_event, text: string) => {
+ if (text) {
+ panelManager.addChatMessage('robin', text);
+ }
+ });
+
// ═══ Chat Image IPC — Robin pastes image from clipboard ═══
ipcMain.handle('chat-send-image', async (_event, data: { text: string; image: string }) => {
const filename = panelManager.saveImage(data.image);
@@ -142,6 +151,33 @@ export function registerIpcHandlers(deps: IpcDeps): void {
return { ok: true, message: msg };
});
+ ipcMain.handle('chat-persist-message', async (_event, data: {
+ from: 'robin' | 'wingman' | 'kees' | 'claude';
+ text?: string;
+ image?: string;
+ notifyWebhook?: boolean;
+ }) => {
+ const text = typeof data?.text === 'string' ? data.text : '';
+ const image = typeof data?.image === 'string' ? data.image : undefined;
+ if (!text && !image) {
+ return { ok: false, error: 'text or image required' };
+ }
+
+ const savedImage = image?.startsWith('data:image/')
+ ? panelManager.saveImage(image)
+ : image;
+ const msg = panelManager.addChatMessage(
+ data.from,
+ text,
+ savedImage,
+ {
+ notifyWebhook: data.notifyWebhook,
+ emitIpc: false,
+ },
+ );
+ return { ok: true, message: msg };
+ });
+
// ═══ Screenshot Snap — composites webview + canvas, saves + clipboard ═══
ipcMain.handle('snap-for-wingman', async () => {
try {
diff --git a/src/openclaw/connect.ts b/src/openclaw/connect.ts
new file mode 100644
index 00000000..4d910a0b
--- /dev/null
+++ b/src/openclaw/connect.ts
@@ -0,0 +1,234 @@
+import * as crypto from 'crypto';
+import fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+
+import { ensureDir, tandemDir } from '../utils/paths';
+
+const OPENCLAW_CONFIG_PATH = path.join(os.homedir(), '.openclaw', 'openclaw.json');
+const OPENCLAW_IDENTITY_PATH = tandemDir('openclaw', 'identity', 'device.json');
+const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
+const OPENCLAW_SCOPES = ['operator.read', 'operator.write'] as const;
+
+type OpenClawScope = typeof OPENCLAW_SCOPES[number];
+
+interface StoredDeviceIdentity {
+ version: 1;
+ deviceId: string;
+ publicKeyPem: string;
+ privateKeyPem: string;
+ createdAtMs: number;
+}
+
+interface DeviceIdentity {
+ deviceId: string;
+ publicKeyPem: string;
+ privateKeyPem: string;
+}
+
+export interface OpenClawConnectParams {
+ minProtocol: number;
+ maxProtocol: number;
+ client: {
+ id: 'webchat';
+ version: string;
+ platform: string;
+ deviceFamily: string;
+ mode: 'webchat';
+ instanceId: string;
+ };
+ role: 'operator';
+ scopes: OpenClawScope[];
+ auth: {
+ token: string;
+ };
+ device: {
+ id: string;
+ publicKey: string;
+ signature: string;
+ signedAt: number;
+ nonce: string;
+ };
+}
+
+function base64UrlEncode(value: Buffer): string {
+ return value.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '');
+}
+
+function derivePublicKeyRaw(publicKeyPem: string): Buffer {
+ const spki = crypto.createPublicKey(publicKeyPem).export({
+ type: 'spki',
+ format: 'der',
+ });
+
+ if (
+ Buffer.isBuffer(spki)
+ && spki.length === ED25519_SPKI_PREFIX.length + 32
+ && spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)
+ ) {
+ return spki.subarray(ED25519_SPKI_PREFIX.length);
+ }
+
+ return Buffer.from(spki);
+}
+
+function fingerprintPublicKey(publicKeyPem: string): string {
+ return crypto.createHash('sha256').update(derivePublicKeyRaw(publicKeyPem)).digest('hex');
+}
+
+function generateIdentity(): DeviceIdentity {
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
+ const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
+ const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
+
+ return {
+ deviceId: fingerprintPublicKey(publicKeyPem),
+ publicKeyPem,
+ privateKeyPem,
+ };
+}
+
+function writeIdentity(filePath: string, identity: DeviceIdentity): void {
+ ensureDir(path.dirname(filePath));
+ const stored: StoredDeviceIdentity = {
+ version: 1,
+ deviceId: identity.deviceId,
+ publicKeyPem: identity.publicKeyPem,
+ privateKeyPem: identity.privateKeyPem,
+ createdAtMs: Date.now(),
+ };
+ fs.writeFileSync(filePath, `${JSON.stringify(stored, null, 2)}\n`, { mode: 0o600 });
+}
+
+function loadOrCreateDeviceIdentity(filePath = OPENCLAW_IDENTITY_PATH): DeviceIdentity {
+ try {
+ if (fs.existsSync(filePath)) {
+ const raw = fs.readFileSync(filePath, 'utf-8');
+ const parsed = JSON.parse(raw) as Partial;
+ if (
+ parsed?.version === 1
+ && typeof parsed.deviceId === 'string'
+ && typeof parsed.publicKeyPem === 'string'
+ && typeof parsed.privateKeyPem === 'string'
+ ) {
+ const derivedId = fingerprintPublicKey(parsed.publicKeyPem);
+ if (derivedId !== parsed.deviceId) {
+ const nextIdentity = {
+ deviceId: derivedId,
+ publicKeyPem: parsed.publicKeyPem,
+ privateKeyPem: parsed.privateKeyPem,
+ };
+ writeIdentity(filePath, nextIdentity);
+ return nextIdentity;
+ }
+
+ return {
+ deviceId: parsed.deviceId,
+ publicKeyPem: parsed.publicKeyPem,
+ privateKeyPem: parsed.privateKeyPem,
+ };
+ }
+ }
+ } catch {
+ // Regenerate a fresh identity if the stored file is unreadable or malformed.
+ }
+
+ const identity = generateIdentity();
+ writeIdentity(filePath, identity);
+ return identity;
+}
+
+export function readOpenClawGatewayToken(): string | null {
+ if (!fs.existsSync(OPENCLAW_CONFIG_PATH)) {
+ return null;
+ }
+
+ const data = JSON.parse(fs.readFileSync(OPENCLAW_CONFIG_PATH, 'utf-8')) as {
+ token?: string;
+ gateway?: { auth?: { token?: string } };
+ };
+
+ return data.token || data.gateway?.auth?.token || null;
+}
+
+function buildDeviceAuthPayloadV3(params: {
+ deviceId: string;
+ clientId: string;
+ clientMode: string;
+ role: string;
+ scopes: readonly string[];
+ signedAtMs: number;
+ token: string;
+ nonce: string;
+ platform: string;
+ deviceFamily: string;
+}): string {
+ return [
+ 'v3',
+ params.deviceId,
+ params.clientId,
+ params.clientMode,
+ params.role,
+ params.scopes.join(','),
+ String(params.signedAtMs),
+ params.token,
+ params.nonce,
+ params.platform.trim().toLowerCase(),
+ params.deviceFamily.trim().toLowerCase(),
+ ].join('|');
+}
+
+export function buildOpenClawConnectParams(nonce: string): OpenClawConnectParams {
+ const trimmedNonce = nonce.trim();
+ if (!trimmedNonce) {
+ throw new Error('nonce is required');
+ }
+
+ const token = readOpenClawGatewayToken();
+ if (!token) {
+ throw new Error('No token field in openclaw.json');
+ }
+
+ const identity = loadOrCreateDeviceIdentity();
+ const signedAt = Date.now();
+ const platform = process.platform;
+ const deviceFamily = 'desktop';
+ const payload = buildDeviceAuthPayloadV3({
+ deviceId: identity.deviceId,
+ clientId: 'webchat',
+ clientMode: 'webchat',
+ role: 'operator',
+ scopes: OPENCLAW_SCOPES,
+ signedAtMs: signedAt,
+ token,
+ nonce: trimmedNonce,
+ platform,
+ deviceFamily,
+ });
+ const signature = base64UrlEncode(
+ crypto.sign(null, Buffer.from(payload, 'utf8'), crypto.createPrivateKey(identity.privateKeyPem)),
+ );
+
+ return {
+ minProtocol: 3,
+ maxProtocol: 3,
+ client: {
+ id: 'webchat',
+ version: '1.0',
+ platform,
+ deviceFamily,
+ mode: 'webchat',
+ instanceId: identity.deviceId,
+ },
+ role: 'operator',
+ scopes: [...OPENCLAW_SCOPES],
+ auth: { token },
+ device: {
+ id: identity.deviceId,
+ publicKey: base64UrlEncode(derivePublicKeyRaw(identity.publicKeyPem)),
+ signature,
+ signedAt,
+ nonce: trimmedNonce,
+ },
+ };
+}
diff --git a/src/panel/manager.ts b/src/panel/manager.ts
index f025cb6a..cf8580c7 100644
--- a/src/panel/manager.ts
+++ b/src/panel/manager.ts
@@ -23,6 +23,11 @@ export interface ChatMessage {
image?: string; // relative filename in ~/.tandem/chat-images/
}
+export interface AddChatMessageOptions {
+ notifyWebhook?: boolean;
+ emitIpc?: boolean;
+}
+
/**
* PanelManager — Manages the Wingman side panel.
*
@@ -124,7 +129,12 @@ export class PanelManager {
}
/** Add a chat message */
- addChatMessage(from: 'robin' | 'wingman' | 'kees' | 'claude', text: string, image?: string): ChatMessage {
+ addChatMessage(
+ from: 'robin' | 'wingman' | 'kees' | 'claude',
+ text: string,
+ image?: string,
+ opts: AddChatMessageOptions = {},
+ ): ChatMessage {
const msg: ChatMessage = {
id: ++this.chatCounter,
from,
@@ -134,7 +144,7 @@ export class PanelManager {
};
this.chatMessages.push(msg);
this.saveChatHistory();
- if (this.win && !this.win.isDestroyed() && !this.win.webContents.isDestroyed()) {
+ if (opts.emitIpc !== false && this.win && !this.win.isDestroyed() && !this.win.webContents.isDestroyed()) {
this.win.webContents.send('chat-message', msg);
}
// Clear typing indicator when wingman sends a message
@@ -145,7 +155,9 @@ export class PanelManager {
this.maybeNotifyForIncomingReply(msg);
// Fire webhook for robin messages (async, non-blocking)
- this.fireWebhook(msg).catch(e => log.warn('fireWebhook failed:', e instanceof Error ? e.message : e));
+ if (opts.notifyWebhook !== false) {
+ this.fireWebhook(msg).catch(e => log.warn('fireWebhook failed:', e instanceof Error ? e.message : e));
+ }
return msg;
}
diff --git a/src/preload.ts b/src/preload.ts
index 239ad52c..dcb0a6aa 100644
--- a/src/preload.ts
+++ b/src/preload.ts
@@ -79,7 +79,16 @@ contextBridge.exposeInMainWorld('tandem', {
sendChatMessage: (text: string) => {
ipcRenderer.send('chat-send', text);
},
+ sendLegacyChatMessage: (text: string) => {
+ ipcRenderer.send('chat-send-legacy', text);
+ },
sendChatImage: (text: string, image: string) => ipcRenderer.invoke('chat-send-image', { text, image }),
+ persistChatMessage: (data: {
+ from: 'robin' | 'wingman' | 'kees' | 'claude';
+ text?: string;
+ image?: string;
+ notifyWebhook?: boolean;
+ }) => ipcRenderer.invoke('chat-persist-message', data),
// Draw overlay
onDrawMode: (callback: (data: { enabled: boolean }) => void) => {