Skip to content

feat(serve): Franklin desktop agent server + backend (cloud sync, wallet holdings/swaps, cost-saver)#80

Merged
1bcMax merged 6 commits into
mainfrom
feat/webui-server
Jun 7, 2026
Merged

feat(serve): Franklin desktop agent server + backend (cloud sync, wallet holdings/swaps, cost-saver)#80
1bcMax merged 6 commits into
mainfrom
feat/webui-server

Conversation

@KillerQueen-Z

Copy link
Copy Markdown
Collaborator

The Franklin-agent side of the Franklin Desktop app.

What

  • franklin serve — local WebSocket agent server (localhost:3737) the desktop renderer connects to (renamed from franklin webui).
  • serve/server.ts: model grouping by provider, cost-saver settings, wallet spend / token holdings / swap RPCs, cloud-first history.load/save, per-conversation session isolation, per-tool detail.
  • serve/cloud-sync.ts: SIWE cloud sync — wallet as identity → franklin.run conversations API.
  • stats/swap-log.ts: record swaps to ~/.blockrun/swaps.jsonl; zerox tools append on success.
  • agent (llm/loop/streaming-executor/types): bloat-compaction + cost-saver gate, model fallback guards, per-tool input preview.
  • Also rolls in: restore GPT-5/Grok families + real errors (fix(agent): restore GPT-5 / Grok families and surface real errors #79), image-paste fix (fix(ui): improve image paste fallback and display #78), v3.25.3.

Notes

  • This is what the desktop app (BlockRunAI/franklin-desktop) bundles + connects to.

A WebSocket server that drives the real interactiveSession agent loop for the
desktop app / local web UI. Speaks an envelope wire protocol (agent.send /
session.* / wallet.info / models.list) and maps StreamEvents to the UI:

- streams text, tool steps (grouped), media artifacts
- wallet.info returns address + live USDC balance
- per-turn model switching via injected /model
- serves generated media files over a /file route (audio/video/image)

Unlike `franklin panel` (a read-only dashboard) this actually runs agent turns
with the same tools, wallet, routing and signing as the CLI.

New: src/webui/server.ts, src/commands/webui.ts; registers the `webui` command.
Drops the webui naming (the local agent server backs the desktop app, not just
a browser UI). src/webui → src/serve, webuiCommand → serveCommand,
startWebuiServer → startServer.
When the CLI is run via Electron-as-node (ELECTRON_RUN_AS_NODE=1 — how the
packaged desktop app spawns `franklin serve`), commander detected
process.versions.electron + no defaultApp and sliced argv as a packaged
electron app, treating the script path as the command ('unknown command
.../dist/index.js'). Forcing from:'node' keeps [exec, script, ...args].
…gs/swaps, cost-saver

- serve/server.ts: model provider grouping, cost-saver settings, wallet spend/tokens/swaps
  RPCs, cloud-first history load/save, session isolation, per-tool detail
- serve/cloud-sync.ts: SIWE cloud sync (wallet identity → franklin.run conversations API)
- stats/swap-log.ts: record swaps to ~/.blockrun/swaps.jsonl
- agent (llm/loop/streaming-executor/types): bloat-compaction + cost-saver, model fallback
  guards, per-tool input preview
- commands/config.ts: cost-saver key; zerox tools: append swaps to the log
@VickyXAI

VickyXAI commented Jun 7, 2026

Copy link
Copy Markdown

Code review

Found 5 issues:

  1. The WebSocket server accepts any connection — no origin check, no verifyClient, no auth token. Loopback binding does not protect against browsers: any web page the user visits can open ws://127.0.0.1:3737/agent and issue agent.send (which runs with permissionMode: 'trust' and can spend USDC), settings.set, wallet.tokens, and wallet.spend. This is a drive-by attack surface on a wallet-bearing agent. An Origin-header check (allow null/Electron and the known desktop origin only) or a handshake token passed by the desktop app would close it.

});
const wss = new WebSocket.Server({ server: httpServer, path: '/agent' });

  1. The /file route streams any file on disk from an unsanitized path query param, and explicitly sets Access-Control-Allow-Origin: * — so a malicious web page can fetch('http://127.0.0.1:3737/file?path=~/.ssh/id_rsa') (or wallet key files under ~/.blockrun) and read the response cross-origin. The "loopback-only server, so a path param is fine" assumption does not hold for browser-initiated requests. Confine paths to the session work dir (resolve + prefix check) and drop the wildcard CORS header.

const url = new URL(req.url || '/', 'http://127.0.0.1');
if (url.pathname === '/file') {
const p = url.searchParams.get('path') || '';
if (!p || !fs.existsSync(p) || !fs.statSync(p).isFile()) { res.writeHead(404); res.end(); return; }
const ext = p.toLowerCase().split('.').pop() || '';
const mime =
ext === 'mp3' ? 'audio/mpeg' : ext === 'wav' ? 'audio/wav' : ext === 'm4a' ? 'audio/mp4' :
ext === 'ogg' ? 'audio/ogg' : ext === 'flac' ? 'audio/flac' :
ext === 'mp4' ? 'video/mp4' : ext === 'webm' ? 'video/webm' :
/^(png|jpe?g|webp|gif)$/.test(ext) ? `image/${ext === 'jpg' ? 'jpeg' : ext}` : 'application/octet-stream';
res.writeHead(200, { 'Content-Type': mime, 'Access-Control-Allow-Origin': '*' });
fs.createReadStream(p).pipe(res);
return;

  1. Race condition in cloudSync: it reads and rewrites the module-global lastSynced map across awaited network calls, and it is invoked fire-and-forget (void cloudSync(...)) from both history.load (migration path, server.ts L470) and history.save (L485). Two in-flight invocations interleave: the delete pass walks a stale lastSynced snapshot and can cloudDelete a conversation a concurrent call just uploaded, and the final lastSynced = current clobber means the loss is not self-healing. A simple promise-chain mutex (serialize calls) fixes it.

export async function cloudSync(conversations: CloudConversation[]): Promise<void> {
const current = new Map(conversations.map((c) => [c.id, c.updatedAt]));
for (const c of conversations) {
if (lastSynced.get(c.id) !== c.updatedAt) await cloudPut(c);
}
for (const id of [...lastSynced.keys()]) {
if (!current.has(id)) await cloudDelete(id);
}
lastSynced = current;
}

  1. The new projectCompactionSavings(history).worthIt gate wraps bloatTriggered as a whole, which includes the turnCostUsd > TURN_COST_CAP_FOR_EARLY_COMPACT ($1.00) emergency trigger added in 3.15.94 after a real $9.45 runaway session ("bloat compactor fires on $1 cost cap, not just 15-call gate"). If projected savings are under 20% when the $1 cap is crossed (plausible when recent un-compactable messages dominate), the emergency compact silently never fires and the runaway scenario returns. The ROI gate should apply only to the 15-call/$0.03 trigger, leaving the $1 cap unconditional.

Franklin/src/agent/loop.ts

Lines 1159 to 1169 in b8e5074

// clear the floor (≥20%), which a small history can never do.
const bloatTriggered =
(turnToolCalls > 15 && turnCostUsd > 0.03) ||
turnCostUsd > TURN_COST_CAP_FOR_EARLY_COMPACT;
if (
config.costSaver !== false &&
!bloatCompactedThisTurn &&
compactFailures < 3 &&
bloatTriggered &&
projectCompactionSavings(history).worthIt
) {

  1. zerox-base.ts records the swap immediately after sendRawTransaction returns (mempool submission, L527) — before on-chain confirmation — contradicting the swap-log.ts contract ("the swap tools ... append here on success") and the gasless tool in this same PR, which correctly gates appendSwap on final.status === 'confirmed' || 'succeeded'. A Permit2 swap that reverts on slippage will show up in the desktop wallet history as a successful swap.

try {
appendSwap({
ts: Date.now(),

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@VickyXAI VickyXAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes per the review above.

Blocking (must fix before merge):

  1. WebSocket origin/auth check — the desktop app should pass a handshake token (or the server should verify the Origin header), otherwise any web page can drive a trust-mode agent with spend authority.
  2. /file path confinement — restrict to the session work dir and drop Access-Control-Allow-Origin: *.

Both come from the same wrong assumption: loopback-only ≠ local-process-only. Browsers reach 127.0.0.1 freely.

Should also fix in this PR (small diffs):
3. Serialize cloudSync (promise-chain mutex) — wrong cloud deletes are data loss.
4. Keep the $1.00 emergency compact unconditional; apply the worthIt ROI gate only to the 15-call/$0.03 trigger.
5. Gate appendSwap in zerox-base.ts on receipt confirmation, matching the gasless tool.

KillerQueen-Z and others added 2 commits June 7, 2026 00:34
…ng (#81)

* feat(channels): restore Slack bot + Telegram allowlist & group @-mention gating

- Slack: restore the `slack` command + channel (was uncommitted WIP) and register it in the CLI
- Telegram: add TELEGRAM_ALLOWED_USERS allowlist (owner always allowed); in groups only
  act on @mention or reply-to-bot (mention stripped before the prompt) and ignore other
  group chatter; private chats unchanged

* fix(telegram): one 'Working…' ping per turn instead of one per tool call

* feat(telegram): per-turn tool-usage summary + /tools toggle (persisted)
- serve: gate WS upgrades + /file behind an Origin allowlist (loopback !=
  local-process-only — any web page can reach 127.0.0.1); optional
  FRANKLIN_SERVE_TOKEN handshake token for defense-in-depth
- serve: confine /file to media files under the work dir (+ symlink-resolved
  prefix check, extension allowlist); reflect vetted origins instead of ACAO *
- cloud-sync: serialize sync passes via a promise queue — concurrent
  fire-and-forget calls interleaved on the shared lastSynced map and could
  delete a conversation a concurrent pass just uploaded
- loop: keep the $1.00 emergency compact unconditional; the >=20% ROI gate now
  applies only to the 15-call trigger (the cap exists to stop runaway turns)
- zerox-base: wait for the tx receipt and record to the swap log only on
  confirmed success, matching the gasless tool; reverted swaps now report
  as errors instead of '✓ executed'
- slack: batch per-tool pings into one turn-end summary (same fix as 7e527e8
  on Telegram); reset toolsUsed on /new in both channels
- telegram: English user-facing strings for /tools toggle + tool summary
- deps: declare @slack/bolt (franklin slack crashed at runtime without it)

@VickyXAI VickyXAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All five review findings addressed in 74db8bc: WS Origin allowlist + optional handshake token, /file confined to work-dir media with reflected CORS, cloudSync serialized, $1.00 emergency compact unconditional again, swap log gated on receipt confirmation. Slack/Telegram follow-ups (missing @slack/bolt dep, per-tool ping flood, English strings) fixed in the same commit. Verified live: evil-origin WS upgrade → 401, /file path escape → 403, legit media → 200.

@1bcMax 1bcMax merged commit 9c38de8 into main Jun 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants