Skip to content
Merged
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
34 changes: 32 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 4 additions & 2 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion shell/about.html
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@
<div class="title"><span class="t">T</span><span class="rest">andem</span></div>
<div class="subtitle">First-Party OpenClaw Companion Browser</div>
<div class="status-badge">Developer Preview</div>
<div class="version" id="version">v0.62.14</div>
<div class="version" id="version">v0.62.16</div>
<div class="info">
Built specifically for human-AI collaboration with OpenClaw.<br>
Maintained in the same ecosystem as OpenClaw, with security and local control built in.
Expand Down
164 changes: 125 additions & 39 deletions shell/chat/openclaw-backend.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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';
Expand All @@ -31,9 +31,6 @@ class OpenClawBackend {
}

async connect() {
if (!this._token) {
await this._fetchToken();
}
this._doConnect();
}

Expand All @@ -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); }
Expand All @@ -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(),
Expand All @@ -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;
}
}

Expand All @@ -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') {
Expand All @@ -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)]);
}
}
};
Expand All @@ -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',
Expand All @@ -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
Expand Down Expand Up @@ -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;
}
}
4 changes: 2 additions & 2 deletions shell/chat/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading
Loading