diff --git a/electron-builder.json b/electron-builder.json index c7151e6b..6614b1ee 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -16,6 +16,12 @@ "node_modules/better-sqlite3/**", "node_modules/node-pty/**" ], + "extraResources": [ + { + "from": "dist-bridge", + "to": "bridge" + } + ], "publish": [ { "provider": "github", diff --git a/electron/ipc/bridge.ts b/electron/ipc/bridge.ts new file mode 100644 index 00000000..6620e981 --- /dev/null +++ b/electron/ipc/bridge.ts @@ -0,0 +1,145 @@ +/** + * Bridge IPC — approval round-trips between the bridge gateway (main) and the + * renderer approval surface, plus server lifecycle and project registration. + * + * Parallel to ipc/aria.ts on purpose: aria's pending-approval map is keyed to + * chat-transcript turns and its events route to the initiating sender; bridge + * approvals have no turn, broadcast to every window, and carry a timeout. + * `bridge:approve` is renderer-only (trusted sender) — the shim can never + * resolve an approval. + */ +import fs from 'node:fs' +import path from 'node:path' +import { app, ipcMain, BrowserWindow } from 'electron' +import { ipcHandler } from '../services/IpcHandlerFactory' +import { isTrustedSender } from '../security/ipcSender' +import * as McpConfig from '../services/McpConfig' +import { ensureBridgeToken, writeBridgeRuntimeInfo, rotateBridgeToken, bridgeInfoFile } from '../services/bridge/bridgeToken' +import { startBridgeServer, stopBridgeServer, getBridgeStatus } from '../services/bridge/BridgeServerService' +import { listBridgeTools, executeBridgeCall, type BridgeCallRequest } from '../services/bridge/BridgeToolGateway' +import type { BridgeToolEvent } from '../shared/types' + +const pendingBridgeApprovals = new Map void>() + +function emitToRenderer(event: BridgeToolEvent) { + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed()) win.webContents.send('bridge:event', event) + } +} + +function focusAppWindow() { + const win = BrowserWindow.getAllWindows()[0] + if (!win || win.isDestroyed()) return + if (win.isMinimized()) win.restore() + win.focus() +} + +/** Block until the user decides in DAEMON's UI. The gateway adds the timeout. */ +function requestBridgeApproval(req: { callId: string; name: string; risk: 'read' | 'write' | 'sensitive'; summary: string; input: unknown }): Promise { + return new Promise((resolve) => { + pendingBridgeApprovals.set(req.callId, resolve) + emitToRenderer({ kind: 'approval-request', callId: req.callId, name: req.name, risk: req.risk, summary: req.summary, input: req.input, source: 'bridge' }) + focusAppWindow() + }) +} + +export function cancelBridgeApproval(callId: string): void { + pendingBridgeApprovals.delete(callId) + emitToRenderer({ kind: 'approval-expired', callId }) +} + +export function cancelAllBridgeApprovals(): void { + for (const [callId, resolve] of pendingBridgeApprovals) { + resolve(false) + emitToRenderer({ kind: 'approval-expired', callId }) + } + pendingBridgeApprovals.clear() +} + +function executeCall(req: BridgeCallRequest) { + return executeBridgeCall(req, { + requestApproval: requestBridgeApproval, + cancelApproval: cancelBridgeApproval, + emit: emitToRenderer, + }) +} + +export async function startBridge(): Promise { + const userData = app.getPath('userData') + const { token, file } = ensureBridgeToken(userData) + const status = await startBridgeServer({ + token, + tokenFile: file, + version: app.getVersion(), + listTools: listBridgeTools, + executeCall, + }) + if (status.running) { + writeBridgeRuntimeInfo(userData, { port: status.port, token, version: app.getVersion() }) + } +} + +export async function stopBridge(): Promise { + cancelAllBridgeApprovals() + await stopBridgeServer() +} + +function resolveShimPath(): string { + return app.isPackaged + ? path.join(process.resourcesPath, 'bridge', 'daemon-bridge-shim.mjs') + : path.join(process.env.APP_ROOT ?? app.getAppPath(), 'dist-bridge', 'daemon-bridge-shim.mjs') +} + +/** Offline tool list so the shim can complete the MCP handshake before DAEMON is up. */ +function writeToolsSnapshot(userData: string): void { + const file = path.join(path.dirname(bridgeInfoFile(userData)), 'bridge-tools.json') + fs.writeFileSync(file, JSON.stringify(listBridgeTools(), null, 2), 'utf8') +} + +export function registerBridgeHandlers(): void { + ipcMain.handle('bridge:status', ipcHandler(async () => getBridgeStatus())) + + ipcMain.handle('bridge:rotate-token', ipcHandler(async () => { + const userData = app.getPath('userData') + const token = rotateBridgeToken(userData) + await stopBridge() + const status = await startBridgeServer({ + token, + tokenFile: bridgeInfoFile(userData), + version: app.getVersion(), + listTools: listBridgeTools, + executeCall, + }) + if (status.running) writeBridgeRuntimeInfo(userData, { port: status.port, token, version: app.getVersion() }) + return status + })) + + ipcMain.handle('bridge:register-project', ipcHandler(async (_event, projectPath: string) => { + if (!projectPath || typeof projectPath !== 'string') throw new Error('Project path required') + const shimPath = resolveShimPath() + if (!fs.existsSync(shimPath)) { + throw new Error('Bridge shim not built — run "pnpm run build:bridge" first.') + } + const userData = app.getPath('userData') + McpConfig.addRegistryMcp( + 'daemon-bridge', + JSON.stringify({ command: 'node', args: [shimPath], env: { DAEMON_BRIDGE_INFO: bridgeInfoFile(userData) } }), + 'DAEMON Bridge — user-gated wallet, launch, and memory tools', + false, + ) + McpConfig.toggleProjectMcp(projectPath, 'daemon-bridge', true) + writeToolsSnapshot(userData) + return getBridgeStatus() + })) + + // Raw channel (not ipcHandler): mirrors aria:approve. Guard the sender frame — + // only DAEMON's own renderer may resolve approvals. + ipcMain.on('bridge:approve', (event, callId: string, approved: boolean) => { + if (!isTrustedSender(event)) return + const resolve = pendingBridgeApprovals.get(callId) + if (resolve) { + pendingBridgeApprovals.delete(callId) + resolve(Boolean(approved)) + } + }) +} diff --git a/electron/main/index.ts b/electron/main/index.ts index a86366bb..3ba9d3f5 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -39,6 +39,7 @@ import { registerShiplineHandlers } from '../ipc/shipline' import { registerEmailHandlers } from '../ipc/email' import { registerImageHandlers } from '../ipc/images' import { registerAriaHandlers } from '../ipc/aria' +import { registerBridgeHandlers, startBridge, stopBridge } from '../ipc/bridge' import { registerSwarmHandlers } from '../ipc/swarm' import { registerMemoryHandlers } from '../ipc/memory' import { killAll as killAllSwarmLanes, } from '../services/SwarmOrchestrator' @@ -206,6 +207,7 @@ function cleanupRuntimeState() { killAllSessions() killAllSwarmLanes() shutdownAllLspSessions() + void stopBridge() clearLoadedWallets() closeDb() } @@ -371,6 +373,7 @@ function registerAllIpc() { registerBrowserHandlers() registerEmailHandlers() registerAriaHandlers() + registerBridgeHandlers() registerDashboardHandlers() registerRegistryHandlers() registerSaidHandlers() @@ -393,6 +396,12 @@ function registerAllIpc() { void reconcileSwarmOnBoot().catch(() => {}) } + // Bridge server is always on (loopback + bearer token); the tool catalog + // itself re-filters against enabled packs on every request. + void startBridge().catch((error) => { + console.warn('[bridge] failed to start:', error instanceof Error ? error.message : String(error)) + }) + // Window controls — raw channels (not wrapped by ipcHandler), so guard the // sender frame inline. Embedded/cross-origin frames must not drive the window. ipcMain.on('window:minimize', (event) => { if (isTrustedSender(event)) win?.minimize() }) diff --git a/electron/preload/index.ts b/electron/preload/index.ts index c2d5b919..13137399 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -643,6 +643,18 @@ contextBridge.exposeInMainWorld('daemon', { }, }, + bridge: { + status: () => ipcRenderer.invoke('bridge:status'), + rotateToken: () => ipcRenderer.invoke('bridge:rotate-token'), + registerProject: (projectPath: string) => ipcRenderer.invoke('bridge:register-project', projectPath), + approve: (callId: string, approved: boolean) => ipcRenderer.send('bridge:approve', callId, approved), + onEvent: (handler: (event: unknown) => void) => { + const listener = (_e: unknown, ev: unknown) => handler(ev) + ipcRenderer.on('bridge:event', listener) + return () => ipcRenderer.removeListener('bridge:event', listener) + }, + }, + swarm: { launch: (req: unknown) => ipcRenderer.invoke('swarm:launch', req), list: (limit?: number) => ipcRenderer.invoke('swarm:list', limit), diff --git a/electron/services/AriaAgentService.ts b/electron/services/AriaAgentService.ts index cff5ecd1..52dc9d97 100644 --- a/electron/services/AriaAgentService.ts +++ b/electron/services/AriaAgentService.ts @@ -16,6 +16,7 @@ import { recordLocalAiUsage } from './DaemonAIService' import { ARIA_TOOLS, getTool } from './aria/toolCatalog' import * as MemoryService from './MemoryService' import { assembleSystemPrompt } from './aria/contextAssembler' +import { clusterMark } from './aria/tools/shared' import { toAnthropicTools, type AriaTool, type AriaContextSnapshot, type AriaUiEffect } from './aria/AriaTool' import { laneToClaudeModel, buildPlanSteps, buildPatchProposal } from './aria/patchUtils' import type { AgentMessage } from './providers/agentTurn' @@ -336,6 +337,18 @@ function parseCaptureJson(text: string): { kind: MemoryKind; title: string; valu } } +/** Execute one tool through the standard risk gate, outside the chat loop. + * Used by the Bridge: `planApproved` is pinned false so every write/sensitive + * call pauses on `transport.requestApproval` — external agents never get the + * plan-mode auto-run path. */ +export async function executeToolCall( + use: { id: string; name: string; input: Record }, + ctx: { sessionId: string; snapshot: AriaContextSnapshot; runUiEffect: AriaTransport['runUiEffect'] }, + transport: AriaTransport, +): Promise { + return executeTool(use, ctx, transport, { plan: [], patch: null, planApproved: false }) +} + async function executeTool( use: { id: string; name: string; input: Record }, ctx: { sessionId: string; snapshot: AriaContextSnapshot; runUiEffect: AriaTransport['runUiEffect'] }, @@ -488,7 +501,9 @@ async function handleProposePatch( function describeIntent(tool: AriaTool, input: Record): string { const arg = Object.values(input)[0] - return `${tool.name}${arg !== undefined ? `: ${String(arg).slice(0, 80)}` : ''}` + const intent = `${tool.name}${arg !== undefined ? `: ${String(arg).slice(0, 80)}` : ''}` + // Approval cards must make the network unmistakable before the user decides. + return tool.risk === 'read' ? intent : clusterMark(intent) } /** Non-Claude providers: single-shot text answer, no tools. */ diff --git a/electron/services/bridge/BridgeServerService.ts b/electron/services/bridge/BridgeServerService.ts new file mode 100644 index 00000000..11c1c7f4 --- /dev/null +++ b/electron/services/bridge/BridgeServerService.ts @@ -0,0 +1,226 @@ +/** + * Bridge HTTP server — the loopback endpoint the MCP shim talks to. + * + * Modeled on SeekerRelayService with two deliberate deltas: it binds + * 127.0.0.1 (never the LAN), and it has no import-side-effect auto-start — + * main/index.ts starts it explicitly. Auth failures return a generic 404 so + * the server doesn't advertise itself to local port scanners. + */ +import http, { type IncomingMessage, type ServerResponse } from 'node:http' +import crypto from 'node:crypto' +import type { BridgeCallResult, BridgeStatus, BridgeToolDescriptor } from '../../shared/types' +import type { BridgeCallRequest } from './BridgeToolGateway' +import { getApprovalTimeoutMs } from './BridgeToolGateway' + +export interface BridgeServerOptions { + port?: number + token: string + tokenFile: string + version?: string + listTools: () => BridgeToolDescriptor[] + executeCall: (req: BridgeCallRequest) => Promise +} + +const DEFAULT_PORT = Number(process.env.DAEMON_BRIDGE_PORT ?? 7337) +const MAX_BODY_BYTES = 512 * 1024 +const MAX_IN_FLIGHT = 4 +/** Headroom past the approval timeout for the tool handler itself to run. */ +const EXECUTION_ALLOWANCE_MS = 180_000 + +let server: http.Server | null = null +let boundPort = DEFAULT_PORT +let options: BridgeServerOptions | null = null +let lastError: string | null = null +const inFlight = new Set() + +function json(res: ServerResponse, statusCode: number, payload: unknown) { + if (res.writableEnded) return + res.writeHead(statusCode, { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + 'x-content-type-options': 'nosniff', + }) + res.end(JSON.stringify(payload)) +} + +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let body = '' + req.on('data', (chunk) => { + body += chunk + if (body.length > MAX_BODY_BYTES) { + reject(new Error('Request body too large')) + req.destroy() + } + }) + req.on('end', () => { + if (!body.trim()) return resolve({}) + try { resolve(JSON.parse(body)) } catch { reject(new Error('Invalid JSON')) } + }) + req.on('error', reject) + }) +} + +function isLoopbackRequest(req: IncomingMessage): boolean { + const addr = req.socket.remoteAddress ?? '' + return addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1' +} + +/** Web pages must not reach the bridge even from localhost; the shim sends no Origin. */ +function hasBrowserOrigin(req: IncomingMessage): boolean { + const origin = req.headers['origin'] + return typeof origin === 'string' && origin.length > 0 +} + +/** Constant-time bearer check against the bridge token. */ +function isAuthorized(req: IncomingMessage, token: string): boolean { + const header = req.headers['authorization'] + const presented = typeof header === 'string' && header.startsWith('Bearer ') + ? header.slice('Bearer '.length).trim() + : '' + if (!presented || presented.length !== token.length) return false + try { + return crypto.timingSafeEqual(Buffer.from(presented), Buffer.from(token)) + } catch { + return false + } +} + +function parseCallRequest(body: unknown): BridgeCallRequest | null { + if (!body || typeof body !== 'object') return null + const record = body as Record + if (typeof record.toolName !== 'string' || !record.toolName.trim()) return null + const input = record.input + if (input !== undefined && (typeof input !== 'object' || input === null || Array.isArray(input))) return null + return { + toolName: record.toolName.trim(), + input: (input as Record) ?? {}, + cwd: typeof record.cwd === 'string' ? record.cwd : undefined, + } +} + +async function handleCall(req: IncomingMessage, res: ServerResponse, opts: BridgeServerOptions) { + if (inFlight.size >= MAX_IN_FLIGHT) { + return json(res, 429, { ok: false, error: 'Too many concurrent bridge calls' }) + } + let body: unknown + try { + body = await readBody(req) + } catch (error) { + return json(res, 400, { ok: false, error: error instanceof Error ? error.message : 'Invalid request' }) + } + const call = parseCallRequest(body) + if (!call) return json(res, 400, { ok: false, error: 'Expected { toolName, input?, cwd? }' }) + + inFlight.add(res) + const timeoutMs = getApprovalTimeoutMs() + EXECUTION_ALLOWANCE_MS + const timer = setTimeout(() => { + json(res, 504, { ok: false, error: 'Bridge call timed out' }) + inFlight.delete(res) + }, timeoutMs) + try { + const result = await opts.executeCall(call) + clearTimeout(timer) + json(res, 200, { ok: true, data: result }) + } catch (error) { + clearTimeout(timer) + json(res, 500, { ok: false, error: error instanceof Error ? error.message : 'Bridge call failed' }) + } finally { + inFlight.delete(res) + } +} + +async function handleRequest(req: IncomingMessage, res: ServerResponse, opts: BridgeServerOptions) { + if (!isLoopbackRequest(req)) return json(res, 403, { ok: false, error: 'Forbidden' }) + if (hasBrowserOrigin(req)) return json(res, 403, { ok: false, error: 'Forbidden' }) + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`) + + if (req.method === 'GET' && url.pathname === '/bridge/ping') { + return json(res, 200, { ok: true, data: { app: 'daemon', version: opts.version ?? '0.0.0', running: true } }) + } + + // Auth failures fall through to a generic 404: don't confirm the bridge exists. + if (!isAuthorized(req, opts.token)) return json(res, 404, { ok: false, error: 'Not found' }) + + if (req.method === 'GET' && url.pathname === '/bridge/tools') { + return json(res, 200, { ok: true, data: opts.listTools() }) + } + if (req.method === 'POST' && url.pathname === '/bridge/call') { + return handleCall(req, res, opts) + } + return json(res, 404, { ok: false, error: 'Not found' }) +} + +async function probeExistingBridge(port: number): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 600) + try { + const res = await fetch(`http://127.0.0.1:${port}/bridge/ping`, { signal: controller.signal }) + const body = await res.json().catch(() => null) as { ok?: boolean; data?: { app?: string } } | null + return Boolean(res.ok && body?.ok && body.data?.app === 'daemon') + } catch { + return false + } finally { + clearTimeout(timeout) + } +} + +function isAddressInUse(error: unknown): boolean { + return Boolean(error && typeof error === 'object' && (error as NodeJS.ErrnoException).code === 'EADDRINUSE') +} + +export async function startBridgeServer(opts: BridgeServerOptions): Promise { + if (server?.listening) return getBridgeStatus() + options = opts + lastError = null + const port = opts.port ?? DEFAULT_PORT + + try { + await new Promise((resolve, reject) => { + const nextServer = http.createServer((req, res) => { + void handleRequest(req, res, opts).catch((error) => { + json(res, 500, { ok: false, error: error instanceof Error ? error.message : 'Bridge error' }) + }) + }) + nextServer.once('error', reject) + nextServer.listen(port, '127.0.0.1', () => { + server = nextServer + const address = nextServer.address() + boundPort = address && typeof address === 'object' ? address.port : port + resolve() + }) + }) + } catch (error) { + if (isAddressInUse(error)) { + const isOurs = await probeExistingBridge(port) + lastError = isOurs + ? `Port ${port} is already served by another DAEMON instance` + : `Port ${port} is in use by another process — set DAEMON_BRIDGE_PORT` + return getBridgeStatus() + } + lastError = error instanceof Error ? error.message : String(error) + return getBridgeStatus() + } + return getBridgeStatus() +} + +export async function stopBridgeServer(): Promise { + if (!server) return + const current = server + server = null + for (const res of inFlight) { + json(res, 503, { ok: false, error: 'DAEMON is shutting down' }) + } + inFlight.clear() + await new Promise((resolve) => current.close(() => resolve())) +} + +export function getBridgeStatus(): BridgeStatus { + return { + running: Boolean(server?.listening), + port: boundPort, + tokenFile: options?.tokenFile ?? '', + toolCount: options ? options.listTools().length : 0, + ...(lastError ? { error: lastError } : {}), + } +} diff --git a/electron/services/bridge/BridgeToolGateway.ts b/electron/services/bridge/BridgeToolGateway.ts new file mode 100644 index 00000000..e75a9295 --- /dev/null +++ b/electron/services/bridge/BridgeToolGateway.ts @@ -0,0 +1,181 @@ +/** + * Bridge gateway — the single path from an external MCP agent to an ARIA tool. + * + * Electron-free on purpose (DB + settings only) so Vitest can drive it directly. + * Risk gating is NOT re-implemented here: every call goes through + * AriaAgentService.executeToolCall with planApproved pinned false, so write and + * sensitive tools always pause on the injected requestApproval. The approval + * itself can only be resolved from DAEMON's renderer — never by the caller. + */ +import crypto from 'node:crypto' +import { getDb } from '../../db/db' +import * as SettingsService from '../SettingsService' +import { executeToolCall, type AriaTransport } from '../AriaAgentService' +import { getTool } from '../aria/toolCatalog' +import type { AriaTool } from '../aria/AriaTool' +import { BRIDGE_TOOL_ALLOWLIST } from './bridgeManifest' +import type { BridgeCallResult, BridgeToolDescriptor, BridgeToolEvent } from '../../shared/types' + +const DEFAULT_APPROVAL_TIMEOUT_MS = 120_000 + +/** Tools that cannot run without a resolved DAEMON project. */ +const PROJECT_REQUIRED_TOOLS = new Set([ + 'remember_fact', 'recall_memories', 'forget_memory', 'update_memory', + 'assign_project_wallet', +]) + +export interface BridgeCallRequest { + toolName: string + input: Record + cwd?: string +} + +export interface BridgeGatewayDeps { + requestApproval: AriaTransport['requestApproval'] + cancelApproval: (callId: string) => void + emit?: (event: BridgeToolEvent) => void + approvalTimeoutMs?: number +} + +export function getApprovalTimeoutMs(): number { + const raw = Number(process.env.DAEMON_BRIDGE_APPROVAL_TIMEOUT_MS) + return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_APPROVAL_TIMEOUT_MS +} + +/** Allowlist ∩ enabled packs, resolved against the live catalog. */ +export function listBridgeTools(): BridgeToolDescriptor[] { + const packs = SettingsService.getEnabledPacks() + const tools: BridgeToolDescriptor[] = [] + for (const entry of BRIDGE_TOOL_ALLOWLIST) { + if (entry.packId && packs[entry.packId] === false) continue + const tool = getTool(entry.name) + if (!tool) continue + tools.push({ + name: tool.name, + description: tool.description, + risk: tool.risk, + inputSchema: tool.input as Record, + }) + } + return tools +} + +/** Resolve a tool name through the allowlist + pack filter, with a caller-safe reason. */ +export function findBridgeTool(name: string): { ok: true; tool: AriaTool } | { ok: false; error: string } { + const entry = BRIDGE_TOOL_ALLOWLIST.find((t) => t.name === name) + if (!entry) return { ok: false, error: `Unknown tool "${name}" — not exposed over the DAEMON Bridge.` } + if (entry.packId && SettingsService.getEnabledPacks()[entry.packId] === false) { + return { ok: false, error: `Tool unavailable: the ${entry.packId} pack is disabled in DAEMON.` } + } + const tool = getTool(name) + if (!tool) return { ok: false, error: `Unknown tool "${name}".` } + return { ok: true, tool } +} + +interface ProjectMatch { id: string; path: string } + +/** Longest-prefix match of the caller's cwd against registered project paths. */ +export function resolveProjectForCwd(cwd: string | undefined): ProjectMatch | null { + if (!cwd) return null + const rows = getDb().prepare('SELECT id, path FROM projects').all() as ProjectMatch[] + const target = normalizeFsPath(cwd) + let best: ProjectMatch | null = null + let bestLen = -1 + for (const row of rows) { + const root = normalizeFsPath(row.path) + const isMatch = target === root || target.startsWith(root + '/') + if (isMatch && root.length > bestLen) { + best = row + bestLen = root.length + } + } + return best +} + +/** + * Normalize a caller-supplied path for prefix comparison. DAEMON runs on + * Windows, so cwds are Windows paths — but this also executes on Linux CI, where + * `path.resolve` would mangle `C:\...`. So we normalize separators ourselves + * instead of leaning on the host's `path` semantics: backslashes → `/`, collapse + * repeats, strip a trailing slash, and lowercase (Windows paths are + * case-insensitive). Keeps comparison stable regardless of the runtime OS. + */ +function normalizeFsPath(value: string): string { + const unified = value + .replace(/\\/g, '/') + .replace(/\/+/g, '/') + .replace(/\/$/, '') + return unified.toLowerCase() +} + +/** Execute one external tool call through the standard ARIA risk gate. */ +export async function executeBridgeCall(req: BridgeCallRequest, deps: BridgeGatewayDeps): Promise { + const found = findBridgeTool(req.toolName) + if (!found.ok) return { status: 'error', summary: found.error } + if (!req.input || typeof req.input !== 'object' || Array.isArray(req.input)) { + return { status: 'error', summary: 'Tool input must be a JSON object.' } + } + + const project = resolveProjectForCwd(req.cwd) + if (!project && PROJECT_REQUIRED_TOOLS.has(req.toolName)) { + return { + status: 'error', + summary: `No DAEMON project matches "${req.cwd ?? '(no cwd)'}" — add this folder as a project in DAEMON first.`, + } + } + + const callId = crypto.randomUUID() + const timeoutMs = deps.approvalTimeoutMs ?? getApprovalTimeoutMs() + let approvalTimedOut = false + + const transport: AriaTransport = { + emit: () => {}, + requestApproval: (approval) => + new Promise((resolve) => { + const timer = setTimeout(() => { + approvalTimedOut = true + deps.cancelApproval(approval.callId) + resolve(false) + }, timeoutMs) + deps.requestApproval(approval).then((approved) => { + clearTimeout(timer) + resolve(approved) + }) + }), + requestPatchDecision: async () => 'discard', + runUiEffect: async () => { + // Tripwire: no allowlisted tool uses UI effects. If one ever does, fail loudly + // instead of silently no-oping a renderer round-trip. + throw new Error('UI effects are not available over the bridge.') + }, + } + + const record = await executeToolCall( + { id: callId, name: req.toolName, input: req.input }, + { + sessionId: `bridge:${crypto.randomUUID()}`, + snapshot: { + activeProjectId: project?.id ?? null, + activeProjectPath: project?.path ?? null, + currentPanelId: null, + openFilePath: null, + chips: { activeFile: false, projectTree: false, gitDiff: false, terminalLogs: false, walletContext: false }, + planMode: false, + }, + runUiEffect: transport.runUiEffect, + }, + transport, + ) + + const status: BridgeCallResult['status'] = + record.status === 'rejected' && approvalTimedOut ? 'timeout' + : record.status === 'rejected' ? 'rejected' + : record.status === 'error' ? 'error' + : 'done' + const summary = + status === 'timeout' + ? `Approval timed out — no response in DAEMON within ${Math.round(timeoutMs / 1000)}s.` + : record.summary + deps.emit?.({ kind: 'call', callId, name: req.toolName, risk: found.tool.risk, status, summary }) + return { status, summary, result: record.result } +} diff --git a/electron/services/bridge/bridgeManifest.ts b/electron/services/bridge/bridgeManifest.ts new file mode 100644 index 00000000..2f6ff0ff --- /dev/null +++ b/electron/services/bridge/bridgeManifest.ts @@ -0,0 +1,37 @@ +/** + * Bridge tool allowlist — the only ARIA tools reachable by external MCP agents. + * + * Deliberately import-free (no services) so the gateway, tests, and the shim + * build can all consume it. `packId: null` marks core tools that are always + * available; everything else is filtered against the enabled packs at runtime. + * + * Excluded by design: anything touching `runUiEffect` (renderer round-trips), + * `present_plan`/`propose_patch` (transcript UI, not side effects), and all + * domains beyond wallet/launch/memory for v1. + */ +import type { PackId } from '../../shared/packManifest' + +export interface BridgeAllowlistEntry { + name: string + packId: PackId | null +} + +export const BRIDGE_TOOL_ALLOWLIST: readonly BridgeAllowlistEntry[] = [ + // wallet pack + { name: 'read_wallet', packId: 'wallet' }, + { name: 'generate_wallet', packId: 'wallet' }, + { name: 'set_default_wallet', packId: 'wallet' }, + { name: 'assign_project_wallet', packId: 'wallet' }, + { name: 'store_helius_key', packId: 'wallet' }, + // launch pack + { name: 'tokenlaunch_list_launchpads', packId: 'launch' }, + { name: 'tokenlaunch_preflight', packId: 'launch' }, + { name: 'tokenlaunch_create', packId: 'launch' }, + // memory pack + { name: 'remember_fact', packId: 'memory' }, + { name: 'recall_memories', packId: 'memory' }, + { name: 'forget_memory', packId: 'memory' }, + { name: 'update_memory', packId: 'memory' }, + // core + { name: 'read_project_status', packId: null }, +] diff --git a/electron/services/bridge/bridgeToken.ts b/electron/services/bridge/bridgeToken.ts new file mode 100644 index 00000000..924e5c5d --- /dev/null +++ b/electron/services/bridge/bridgeToken.ts @@ -0,0 +1,85 @@ +/** + * Bridge token + runtime-info file. The shim authenticates to the loopback + * bridge server with a bearer token it reads from `/bridge/bridge.json`. + * + * Windows note: chmod 0o600 is a no-op on NTFS. The file lives under the user's + * profile (%APPDATA%), whose default ACLs already block other non-admin users; + * explicit `icacls` hardening is a documented follow-up, not v1. + */ +import crypto from 'node:crypto' +import fs from 'node:fs' +import path from 'node:path' + +export interface BridgeRuntimeInfo { + token: string + port: number + pid: number + version: string + updatedAt: number +} + +function bridgeDir(userDataDir: string): string { + return path.join(userDataDir, 'bridge') +} + +export function bridgeInfoFile(userDataDir: string): string { + return path.join(bridgeDir(userDataDir), 'bridge.json') +} + +function readInfo(file: string): Partial { + try { + return JSON.parse(fs.readFileSync(file, 'utf8')) as Partial + } catch { + return {} + } +} + +function writeInfo(file: string, info: BridgeRuntimeInfo): void { + fs.mkdirSync(path.dirname(file), { recursive: true }) + fs.writeFileSync(file, JSON.stringify(info, null, 2), { encoding: 'utf8', mode: 0o600 }) +} + +/** Return the persisted token, creating one on first run. */ +export function ensureBridgeToken(userDataDir: string): { token: string; file: string } { + const file = bridgeInfoFile(userDataDir) + const existing = readInfo(file) + if (typeof existing.token === 'string' && existing.token.length >= 32) { + return { token: existing.token, file } + } + const token = crypto.randomBytes(32).toString('hex') + writeInfo(file, { + token, + port: typeof existing.port === 'number' ? existing.port : 0, + pid: process.pid, + version: process.env.npm_package_version ?? '0.0.0', + updatedAt: Date.now(), + }) + return { token, file } +} + +/** Record the live port/pid so shims can discover the running server. */ +export function writeBridgeRuntimeInfo(userDataDir: string, info: { port: number; token: string; version?: string }): void { + const file = bridgeInfoFile(userDataDir) + writeInfo(file, { + token: info.token, + port: info.port, + pid: process.pid, + version: info.version ?? process.env.npm_package_version ?? '0.0.0', + updatedAt: Date.now(), + }) +} + +/** Replace the token. Shims re-read bridge.json per process start, so rotation is safe. */ +export function rotateBridgeToken(userDataDir: string): string { + const file = bridgeInfoFile(userDataDir) + const existing = readInfo(file) + const token = crypto.randomBytes(32).toString('hex') + writeInfo(file, { + token, + port: typeof existing.port === 'number' ? existing.port : 0, + pid: process.pid, + version: typeof existing.version === 'string' ? existing.version : '0.0.0', + updatedAt: Date.now(), + }) + return token +} diff --git a/electron/services/bridge/shim.ts b/electron/services/bridge/shim.ts new file mode 100644 index 00000000..aa8c1b48 --- /dev/null +++ b/electron/services/bridge/shim.ts @@ -0,0 +1,203 @@ +/** + * DAEMON Bridge shim — the stdio MCP server external agents spawn. + * + * Thin by design: it advertises DAEMON's bridge tools and forwards every call + * to the loopback bridge server in the running DAEMON app, where risk gating + * and user approval happen. The shim holds no secrets beyond the bearer token + * it reads from bridge.json, and it can approve nothing. + * + * Built standalone (vite.bridge.config.ts → dist-bridge/daemon-bridge-shim.mjs, + * SDK bundled) and run by the system node (>= 18 for global fetch). + * stdout is MCP protocol — log to stderr only. + */ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { Server } from '@modelcontextprotocol/sdk/server/index.js' +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' +import type { BridgeCallResult, BridgeToolDescriptor } from '../../shared/types' + +const SHIM_VERSION = '1.0.0' +const TOOLS_FETCH_TIMEOUT_MS = 800 +const CALL_HARD_CAP_MS = 10 * 60 * 1000 +const PROGRESS_INTERVAL_MS = 10_000 + +const NOT_RUNNING = 'DAEMON is not running — open the DAEMON app and try again.' +const TOKEN_MISMATCH = 'Bridge token mismatch — re-register the DAEMON Bridge from DAEMON settings.' +const BUSY = 'DAEMON is busy — too many concurrent bridge calls. Try again in a moment.' + +interface BridgeInfo { + token: string + port: number +} + +function log(message: string): void { + process.stderr.write(`[daemon-bridge-shim] ${message}\n`) +} + +function bridgeInfoCandidates(): string[] { + const fromEnv = process.env.DAEMON_BRIDGE_INFO + const home = os.homedir() + const roots = process.platform === 'win32' + ? [process.env.APPDATA ?? path.join(home, 'AppData', 'Roaming')] + : process.platform === 'darwin' + ? [path.join(home, 'Library', 'Application Support')] + : [process.env.XDG_CONFIG_HOME ?? path.join(home, '.config')] + const defaults = roots.flatMap((root) => [ + path.join(root, 'daemon', 'bridge', 'bridge.json'), + path.join(root, 'DAEMON', 'bridge', 'bridge.json'), + ]) + return fromEnv ? [fromEnv, ...defaults] : defaults +} + +function readBridgeInfo(): { info: BridgeInfo; file: string } | null { + for (const file of bridgeInfoCandidates()) { + try { + const parsed = JSON.parse(fs.readFileSync(file, 'utf8')) as Partial + if (typeof parsed.token === 'string' && typeof parsed.port === 'number' && parsed.port > 0) { + return { info: { token: parsed.token, port: parsed.port }, file } + } + } catch { + // try the next candidate + } + } + return null +} + +async function fetchJson(url: string, init: RequestInit, timeoutMs: number): Promise<{ status: number; body: unknown }> { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + const res = await fetch(url, { ...init, signal: controller.signal }) + const body = await res.json().catch(() => null) + return { status: res.status, body } + } finally { + clearTimeout(timer) + } +} + +/** Live tool list from DAEMON, falling back to the snapshot written at registration. */ +async function loadTools(located: { info: BridgeInfo; file: string } | null): Promise { + if (located) { + const url = `http://127.0.0.1:${located.info.port}/bridge/tools` + const headers = { authorization: `Bearer ${located.info.token}` } + for (let attempt = 0; attempt < 2; attempt++) { + try { + const { status, body } = await fetchJson(url, { headers }, TOOLS_FETCH_TIMEOUT_MS) + const data = (body as { ok?: boolean; data?: BridgeToolDescriptor[] } | null)?.data + if (status === 200 && Array.isArray(data)) { + try { + fs.writeFileSync(path.join(path.dirname(located.file), 'bridge-tools.json'), JSON.stringify(data, null, 2), 'utf8') + } catch { + // cache write is best-effort + } + return data + } + } catch { + // retry once, then fall through to the cache + } + } + try { + const cached = JSON.parse(fs.readFileSync(path.join(path.dirname(located.file), 'bridge-tools.json'), 'utf8')) as BridgeToolDescriptor[] + if (Array.isArray(cached)) return cached + } catch { + // no cache either + } + } + log('no tool list available — DAEMON not reachable and no cached bridge-tools.json') + return [] +} + +function describeFailure(status: number, body: unknown): string { + if (status === 401 || status === 403 || status === 404) return TOKEN_MISMATCH + if (status === 429) return BUSY + const error = (body as { error?: string } | null)?.error + return error ? `DAEMON bridge error: ${error}` : `DAEMON bridge error (HTTP ${status}).` +} + +function toContent(result: BridgeCallResult): { content: Array<{ type: 'text'; text: string }>; isError?: boolean } { + if (result.status === 'rejected') { + return { content: [{ type: 'text', text: 'The user rejected this action in DAEMON.' }], isError: true } + } + if (result.status !== 'done') { + return { content: [{ type: 'text', text: result.summary }], isError: true } + } + const detail = result.result !== undefined && result.result !== result.summary + ? `\n${JSON.stringify(result.result, null, 2)}` + : '' + return { content: [{ type: 'text', text: `${result.summary}${detail}` }] } +} + +async function main(): Promise { + const located = readBridgeInfo() + if (!located) log('bridge.json not found — calls will fail until DAEMON registers the bridge') + const tools = await loadTools(located) + + const server = new Server( + { name: 'daemon-bridge', version: SHIM_VERSION }, + { capabilities: { tools: {} } }, + ) + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: tools.map((tool) => ({ + name: tool.name, + description: `${tool.description}${tool.risk === 'read' ? '' : ' Requires user approval inside the DAEMON app.'}`, + inputSchema: tool.inputSchema, + })), + })) + + server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { + // Re-read per call: the port or token may have changed since the shim started. + const live = readBridgeInfo() ?? located + if (!live) { + return { content: [{ type: 'text', text: NOT_RUNNING }], isError: true } + } + + const progressToken = request.params._meta?.progressToken + let progress = 0 + const progressTimer = progressToken === undefined ? null : setInterval(() => { + progress += 1 + void extra.sendNotification({ + method: 'notifications/progress', + params: { progressToken, progress, message: 'Waiting on DAEMON (approval may be pending)…' }, + }).catch(() => {}) + }, PROGRESS_INTERVAL_MS) + + try { + const { status, body } = await fetchJson( + `http://127.0.0.1:${live.info.port}/bridge/call`, + { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${live.info.token}` }, + body: JSON.stringify({ + toolName: request.params.name, + input: request.params.arguments ?? {}, + cwd: process.cwd(), + }), + }, + CALL_HARD_CAP_MS, + ) + if (status !== 200) { + return { content: [{ type: 'text', text: describeFailure(status, body) }], isError: true } + } + const result = (body as { data?: BridgeCallResult } | null)?.data + if (!result || typeof result.summary !== 'string') { + return { content: [{ type: 'text', text: 'DAEMON returned an unexpected bridge response.' }], isError: true } + } + return toContent(result) + } catch { + return { content: [{ type: 'text', text: NOT_RUNNING }], isError: true } + } finally { + if (progressTimer) clearInterval(progressTimer) + } + }) + + await server.connect(new StdioServerTransport()) + log(`ready — ${tools.length} tools, target port ${located?.info.port ?? 'unknown'}`) +} + +main().catch((error) => { + log(`fatal: ${error instanceof Error ? error.message : String(error)}`) + process.exit(1) +}) diff --git a/electron/shared/types.ts b/electron/shared/types.ts index 0329ea48..3f4c5338 100644 --- a/electron/shared/types.ts +++ b/electron/shared/types.ts @@ -2689,6 +2689,40 @@ export interface AriaMemorySuggestionLite { value: string } +// --- Bridge (external MCP agents) --- + +export type BridgeCallStatus = 'done' | 'error' | 'rejected' | 'timeout' + +/** Events streamed from the Bridge to the renderer approval surface. */ +export type BridgeToolEvent = + | { kind: 'approval-request'; callId: string; name: string; risk: AriaToolRiskTier; summary: string; input: unknown; source: 'bridge' } + | { kind: 'approval-expired'; callId: string } + | { kind: 'call'; callId: string; name: string; risk: AriaToolRiskTier; status: BridgeCallStatus; summary: string } + +/** Bridge server status surfaced in Settings. */ +export interface BridgeStatus { + running: boolean + port: number + tokenFile: string + toolCount: number + error?: string +} + +/** One tool as advertised to external MCP clients. */ +export interface BridgeToolDescriptor { + name: string + description: string + risk: AriaToolRiskTier + inputSchema: Record +} + +/** Result of one bridge tool call, returned to the shim. */ +export interface BridgeCallResult { + status: BridgeCallStatus + summary: string + result?: unknown +} + // --- Onboarding --- export type OnboardingStepStatus = 'pending' | 'complete' | 'skipped' diff --git a/osv-scanner.toml b/osv-scanner.toml index c1771262..ed60edca 100644 --- a/osv-scanner.toml +++ b/osv-scanner.toml @@ -12,3 +12,15 @@ id = "GHSA-5xrq-8626-4rwp" # CVE-2026-47429 — vitest < 4.1.0 # separately to avoid breaking the test suite on a release. ignoreUntil = 2026-09-01 reason = "Dev-only: vitest UI/Browser-mode server not used; we run headless `vitest run`. Bump to vitest 4.x tracked post-release." + +[[IgnoredVulns]] +id = "GHSA-4x5r-pxfx-6jf8" # @babel/core arbitrary file read via sourceMappingURL +# Why ignored: build-time only. @babel/core is pulled in transitively by +# @vitejs/plugin-react and runs against our own first-party source during the +# Vite build — it never processes untrusted input at runtime and is not shipped +# in the Electron bundle. The advisory requires compiling attacker-controlled +# code containing a crafted sourceMappingURL, which never happens in our pipeline. +# The only fix is @babel/core 8.0.0-rc (no stable 7.x patch); we will not pin a +# release-candidate major into the build. Re-evaluate when 8.x ships stable. +ignoreUntil = 2026-09-01 +reason = "Build-time devDep (vite-plugin-react); compiles only first-party source, not shipped at runtime. No stable 7.x fix — only 8.0.0-rc." diff --git a/package.json b/package.json index 58f315de..7378fe19 100644 --- a/package.json +++ b/package.json @@ -17,12 +17,13 @@ "dev:debug": "powershell -NoProfile -Command \"$env:DAEMON_OPEN_DEVTOOLS='1'; vite\"", "build": "tsc && vite build", "build:daemon-ai-cloud": "vite build --config vite.cloud.config.ts", + "build:bridge": "vite build --config vite.bridge.config.ts", "typecheck": "tsc --noEmit", "typecheck:watch": "tsc --noEmit --watch --preserveWatchOutput", "mobile:seeker": "npm --prefix apps/seeker-mobile run start", "mobile:seeker:android": "npm --prefix apps/seeker-mobile run android", "mobile:seeker:typecheck": "npm --prefix apps/seeker-mobile run typecheck", - "package": "pnpm run build && pnpm run rebuild && electron-builder", + "package": "pnpm run build && pnpm run build:bridge && pnpm run rebuild && electron-builder", "postinstall": "pnpm run rebuild:native", "rebuild": "pnpm run rebuild:native", "rebuild:sqlite": "electron-rebuild -f --only better-sqlite3", @@ -56,6 +57,7 @@ "test:release": "pnpm run release:check:v4:local", "test:all": "pnpm run test:ci && pnpm run test:smoke:core && pnpm run test:smoke:ui", "test:smoke": "pnpm run build && pnpm run rebuild && node scripts/smoke/electron-smoke.mjs", + "test:bridge-shim": "pnpm run build:bridge && node scripts/smoke/bridge-shim.mjs", "test:mcp-stress": "pnpm run build && node scripts/smoke/daemon-mcp-stress.mjs", "test:pro-entitlement": "pnpm run build && node scripts/smoke/pro-entitlement-flow.mjs", "test:scaffold-smoke": "pnpm run build && node scripts/smoke/project-scaffold.mjs", @@ -81,6 +83,7 @@ "@metaplex-foundation/umi": "^1.5.1", "@metaplex-foundation/umi-bundle-defaults": "^1.5.1", "@meteora-ag/dynamic-bonding-curve-sdk": "1.5.7", + "@modelcontextprotocol/sdk": "1.29.0", "@nirholas/pump-sdk": "1.30.0", "@oobe-protocol-labs/synapse-sap-sdk": "0.19.8", "@payai/facilitator": "^2.3.6", @@ -104,7 +107,7 @@ "imapflow": "^1.3.3", "mailparser": "^3.9.8", "node-pty": "1.1.0", - "nodemailer": "^8.0.7", + "nodemailer": "^9.0.1", "pidusage": "^4.0.1", "ps-list": "^8.1.1", "pyright": "^1.1.409", @@ -147,7 +150,7 @@ "pngjs": "^7.0.0", "react": "^19.2.5", "react-dom": "^19.2.5", - "vite": "^6.4.2", + "vite": "^6.4.3", "vite-plugin-electron": "^0.29.0", "vite-plugin-electron-renderer": "^0.14.7", "vitest": "^3.2.4", @@ -160,18 +163,23 @@ "basic-ftp": "5.3.1", "bigint-buffer": "workspace:*", "brace-expansion": "5.0.6", - "dompurify": "3.4.0", + "dompurify": "3.4.11", "esbuild": "0.25.12", - "hono": "4.12.21", + "form-data": "4.0.6", + "hono": "4.12.25", + "js-yaml": "4.2.0", "lodash": "4.18.1", + "nodemailer": "9.0.1", "follow-redirects": "1.16.0", "postcss": "8.5.10", - "protobufjs": "7.5.8", + "protobufjs": "7.6.3", "qs": "6.15.2", "shell-quote": "1.8.4", - "tmp": "0.2.6", + "tar": "7.5.16", + "tmp": "0.2.7", + "undici": "6.27.0", "uuid": "11.1.1", - "ws": "8.20.1" + "ws": "8.21.0" }, "peerDependencyRules": { "allowedVersions": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6bde7b2c..2537a4b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,18 +10,23 @@ overrides: basic-ftp: 5.3.1 bigint-buffer: workspace:* brace-expansion: 5.0.6 - dompurify: 3.4.0 + dompurify: 3.4.11 esbuild: 0.25.12 - hono: 4.12.21 + form-data: 4.0.6 + hono: 4.12.25 + js-yaml: 4.2.0 lodash: 4.18.1 + nodemailer: 9.0.1 follow-redirects: 1.16.0 postcss: 8.5.10 - protobufjs: 7.5.8 + protobufjs: 7.6.3 qs: 6.15.2 shell-quote: 1.8.4 - tmp: 0.2.6 + tar: 7.5.16 + tmp: 0.2.7 + undici: 6.27.0 uuid: 11.1.1 - ws: 8.20.1 + ws: 8.21.0 patchedDependencies: '@oobe-protocol-labs/synapse-sap-sdk@0.19.8': @@ -37,10 +42,10 @@ importers: dependencies: '@anthropic-ai/sdk': specifier: ^0.98.0 - version: 0.98.0(zod@3.25.76) + version: 0.98.0(zod@4.4.3) '@google/genai': specifier: ^1.52.0 - version: 1.52.0(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@3.25.76))(bufferutil@4.1.0)(utf-8-validate@6.0.6) + version: 1.52.0(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@metaplex-foundation/mpl-agent-registry': specifier: ^0.2.5 version: 0.2.5(@metaplex-foundation/umi@1.5.1)(@noble/hashes@1.8.0) @@ -56,6 +61,9 @@ importers: '@meteora-ag/dynamic-bonding-curve-sdk': specifier: 1.5.7 version: 1.5.7(bufferutil@4.1.0)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@6.0.6) + '@modelcontextprotocol/sdk': + specifier: 1.29.0 + version: 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3) '@nirholas/pump-sdk': specifier: 1.30.0 version: 1.30.0(bufferutil@4.1.0)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(puppeteer-core@24.40.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(typescript@5.9.3)(utf-8-validate@6.0.6) @@ -76,7 +84,7 @@ importers: version: 6.9.0(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@6.0.6) '@solana/mpp': specifier: ^0.6.0 - version: 0.6.0(@solana/kit@6.9.0(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@6.0.6))(mppx@0.5.5(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@3.25.76))(express@5.2.1)(hono@4.12.21)(typescript@5.9.3)(viem@2.50.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@3.25.76))) + version: 0.6.0(@solana/kit@6.9.0(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@6.0.6))(mppx@0.5.5(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(express@5.2.1)(hono@4.12.25)(typescript@5.9.3)(viem@2.50.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@4.4.3))) '@solana/spl-token': specifier: ^0.4.9 version: 0.4.14(@solana/web3.js@1.98.4(bufferutil@4.1.0)(encoding@0.1.13)(typescript@5.9.3)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@6.0.6) @@ -126,8 +134,8 @@ importers: specifier: 1.1.0 version: 1.1.0(patch_hash=f41f3f1b27203d2dfc08a004c1c51bc4a56ca84fce763607028e80840c5bcc3e) nodemailer: - specifier: ^8.0.7 - version: 8.0.7 + specifier: 9.0.1 + version: 9.0.1 pidusage: specifier: ^4.0.1 version: 4.0.1 @@ -200,7 +208,7 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': specifier: ^5.2.0 - version: 5.2.0(vite@6.4.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.9.0)) + version: 5.2.0(vite@6.4.3(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.9.0)) '@xterm/addon-fit': specifier: ^0.10.0 version: 0.10.0(@xterm/xterm@5.5.0) @@ -250,8 +258,8 @@ importers: specifier: ^19.2.5 version: 19.2.5(react@19.2.5) vite: - specifier: ^6.4.2 - version: 6.4.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.9.0) + specifier: ^6.4.3 + version: 6.4.3(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.9.0) vite-plugin-electron: specifier: ^0.29.0 version: 0.29.1(vite-plugin-electron-renderer@0.14.7) @@ -1238,7 +1246,7 @@ packages: resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} engines: {node: '>=18.14.1'} peerDependencies: - hono: 4.12.21 + hono: 4.12.25 '@isaacs/fs-minipass@4.0.1': resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} @@ -1483,17 +1491,17 @@ packages: '@protobufjs/codegen@2.0.5': resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} - '@protobufjs/eventemitter@1.1.0': - resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + '@protobufjs/eventemitter@1.1.1': + resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} - '@protobufjs/fetch@1.1.0': - resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} '@protobufjs/float@1.0.2': resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} - '@protobufjs/inquire@1.1.1': - resolution: {integrity: sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==} + '@protobufjs/inquire@1.1.2': + resolution: {integrity: sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==} '@protobufjs/path@1.1.2': resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} @@ -3703,8 +3711,8 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} - dompurify@3.4.0: - resolution: {integrity: sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==} + dompurify@3.4.11: + resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==} domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -4205,8 +4213,8 @@ packages: resolution: {integrity: sha512-SKmowqGTJoPzLO1T0BBJpkfp3EMacCMOuH40hOUbrbzElVktk4DioXVM99QkLCyKoiuOmyjgcWMpVz2xjE7LZw==} engines: {node: '>=0.10.0'} - form-data@4.0.5: - resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} formdata-polyfill@4.0.10: @@ -4369,6 +4377,10 @@ packages: resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} engines: {node: '>= 0.4'} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} @@ -4406,8 +4418,8 @@ packages: hermes-parser@0.35.0: resolution: {integrity: sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==} - hono@4.12.21: - resolution: {integrity: sha512-uV63apnb0kyPtAUwoWgaGh9HyIFcv8lgmzPZSiTBQAFOFGIzka5EZ1dZocmGnn0XdX0+XTqJ6Tqv7selMuGLRQ==} + hono@4.12.25: + resolution: {integrity: sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==} engines: {node: '>=16.9.0'} hosted-git-info@4.1.0: @@ -4611,12 +4623,12 @@ packages: isomorphic-ws@4.0.1: resolution: {integrity: sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==} peerDependencies: - ws: 8.20.1 + ws: 8.21.0 isows@1.0.7: resolution: {integrity: sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==} peerDependencies: - ws: 8.20.1 + ws: 8.21.0 jake@10.9.4: resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} @@ -4666,8 +4678,8 @@ packages: js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + js-yaml@4.2.0: + resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} hasBin: true jsc-safe-url@0.2.4: @@ -5318,7 +5330,7 @@ packages: '@modelcontextprotocol/sdk': '>=1.25.0' elysia: '>=1' express: '>=5' - hono: 4.12.21 + hono: 4.12.25 viem: '>=2.47.5' peerDependenciesMeta: '@modelcontextprotocol/sdk': @@ -5423,12 +5435,8 @@ packages: node-releases@2.0.38: resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} - nodemailer@8.0.5: - resolution: {integrity: sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==} - engines: {node: '>=6.0.0'} - - nodemailer@8.0.7: - resolution: {integrity: sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==} + nodemailer@9.0.1: + resolution: {integrity: sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==} engines: {node: '>=6.0.0'} nopt@9.0.0: @@ -5723,8 +5731,8 @@ packages: property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} - protobufjs@7.5.8: - resolution: {integrity: sha512-dvpCIeLPbXZS/Ete7yLaO7RenOdken2NHKykBXbsaGxZT0UTltcarBciw+A78SRQs9iMAAVpsYA+l8b1hTePIA==} + protobufjs@7.6.3: + resolution: {integrity: sha512-+k0vdJKNdW+Vu+dYe8tZA/VvQb6XKNWexC6URwBFXxNnjLJz9nQJCemGyNgRAWD+B7+nGNc9qMPGwcD7s4nzUw==} engines: {node: '>=12.0.0'} proxy-addr@2.0.7: @@ -6454,8 +6462,8 @@ packages: tar-stream@3.1.8: resolution: {integrity: sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==} - tar@7.5.13: - resolution: {integrity: sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==} + tar@7.5.16: + resolution: {integrity: sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==} engines: {node: '>=18'} teex@1.0.1: @@ -6525,8 +6533,8 @@ packages: tmp-promise@3.0.3: resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} - tmp@0.2.6: - resolution: {integrity: sha512-5sJPdPjfI5Kx+qbrDesxkglRBxW//g7hCsqspEjwkewGvBMGIKMOTKzLt1hFVJzyadba3lDUN20O9qhvbQUSTA==} + tmp@0.2.7: + resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} engines: {node: '>=14.14'} tmpl@1.0.5: @@ -6634,8 +6642,8 @@ packages: undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} - undici@6.25.0: - resolution: {integrity: sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==} + undici@6.27.0: + resolution: {integrity: sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==} engines: {node: '>=18.17'} unicode-canonical-property-names-ecmascript@2.0.1: @@ -6789,8 +6797,8 @@ packages: vite-plugin-electron-renderer: optional: true - vite@6.4.2: - resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==} + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: @@ -6933,8 +6941,8 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.20.1: - resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -7048,13 +7056,6 @@ snapshots: '@adraffy/ens-normalize@1.11.1': {} - '@anthropic-ai/sdk@0.98.0(zod@3.25.76)': - dependencies: - json-schema-to-ts: 3.1.1 - standardwebhooks: 1.0.0 - optionalDependencies: - zod: 3.25.76 - '@anthropic-ai/sdk@0.98.0(zod@4.4.3)': dependencies: json-schema-to-ts: 3.1.1 @@ -7911,7 +7912,7 @@ snapshots: terminal-link: 2.1.1 toqr: 0.1.1 wrap-ansi: 7.0.0 - ws: 8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6) + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) zod: 3.25.76 optionalDependencies: expo-router: 55.0.13(@expo/log-box@55.0.11)(@expo/metro-runtime@55.0.10)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(expo-constants@55.0.15)(expo-font@55.0.6)(expo-linking@55.0.15)(expo@55.0.20)(react-dom@19.2.5(react@19.2.5))(react-native-safe-area-context@5.8.0(react-native@0.85.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.5)(utf-8-validate@6.0.6))(react@19.2.5))(react-native-screens@4.25.2(react-native@0.85.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.5)(utf-8-validate@6.0.6))(react@19.2.5))(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-native@0.85.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.5)(utf-8-validate@6.0.6))(react@19.2.5) @@ -8204,25 +8205,24 @@ snapshots: dependencies: '@babel/code-frame': 7.29.0 chalk: 4.1.2 - js-yaml: 4.1.1 + js-yaml: 4.2.0 - '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@3.25.76))(bufferutil@4.1.0)(utf-8-validate@6.0.6)': + '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(bufferutil@4.1.0)(utf-8-validate@6.0.6)': dependencies: google-auth-library: 10.6.2 p-retry: 4.6.2 - protobufjs: 7.5.8 - ws: 8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6) + protobufjs: 7.6.3 + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) optionalDependencies: - '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@3.25.76) + '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3) transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - '@hono/node-server@1.19.14(hono@4.12.21)': + '@hono/node-server@1.19.14(hono@4.12.25)': dependencies: - hono: 4.12.21 - optional: true + hono: 4.12.25 '@isaacs/fs-minipass@4.0.1': dependencies: @@ -8420,9 +8420,9 @@ snapshots: - fastestsmallesttextencoderdecoder - utf-8-validate - '@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@3.25.76)': + '@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3)': dependencies: - '@hono/node-server': 1.19.14(hono@4.12.21) + '@hono/node-server': 1.19.14(hono@4.12.25) ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) content-type: 1.0.5 @@ -8432,18 +8432,17 @@ snapshots: eventsource-parser: 3.1.0 express: 5.2.1 express-rate-limit: 8.5.2(express@5.2.1) - hono: 4.12.21 + hono: 4.12.25 jose: 6.2.3 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 - zod: 3.25.76 - zod-to-json-schema: 3.25.2(zod@3.25.76) + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) optionalDependencies: '@cfworker/json-schema': 4.1.1 transitivePeerDependencies: - supports-color - optional: true '@modelcontextprotocol/server@2.0.0-alpha.2(@cfworker/json-schema@4.1.1)': dependencies: @@ -8540,16 +8539,15 @@ snapshots: '@protobufjs/codegen@2.0.5': {} - '@protobufjs/eventemitter@1.1.0': {} + '@protobufjs/eventemitter@1.1.1': {} - '@protobufjs/fetch@1.1.0': + '@protobufjs/fetch@1.1.1': dependencies: '@protobufjs/aspromise': 1.1.2 - '@protobufjs/inquire': 1.1.1 '@protobufjs/float@1.0.2': {} - '@protobufjs/inquire@1.1.1': {} + '@protobufjs/inquire@1.1.2': {} '@protobufjs/path@1.1.2': {} @@ -8937,7 +8935,7 @@ snapshots: nullthrows: 1.1.1 open: 7.4.2 serve-static: 1.16.3 - ws: 8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6) + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil - supports-color @@ -8956,7 +8954,7 @@ snapshots: nullthrows: 1.1.1 open: 7.4.2 serve-static: 1.16.3 - ws: 8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6) + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil - supports-color @@ -9152,12 +9150,12 @@ snapshots: '@noble/hashes': 1.8.0 apg-js: 4.4.0 - '@signinwithethereum/siwe@4.2.0(ethers@6.16.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(viem@2.50.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@3.25.76))': + '@signinwithethereum/siwe@4.2.0(ethers@6.16.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(viem@2.50.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@4.4.3))': dependencies: '@signinwithethereum/siwe-parser': 4.2.0 optionalDependencies: ethers: 6.16.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) - viem: 2.50.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@3.25.76) + viem: 2.50.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@4.4.3) '@simple-git/args-pathspec@1.0.3': {} @@ -9434,13 +9432,13 @@ snapshots: - fastestsmallesttextencoderdecoder - utf-8-validate - '@solana/mpp@0.6.0(@solana/kit@6.9.0(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@6.0.6))(mppx@0.5.5(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@3.25.76))(express@5.2.1)(hono@4.12.21)(typescript@5.9.3)(viem@2.50.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@3.25.76)))': + '@solana/mpp@0.6.0(@solana/kit@6.9.0(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@6.0.6))(mppx@0.5.5(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(express@5.2.1)(hono@4.12.25)(typescript@5.9.3)(viem@2.50.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@4.4.3)))': dependencies: '@solana-program/compute-budget': 0.15.0(@solana/kit@6.9.0(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@6.0.6)) '@solana-program/system': 0.12.2(@solana/kit@6.9.0(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@6.0.6)) '@solana-program/token': 0.11.0(@solana/kit@6.9.0(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@6.0.6)) '@solana/kit': 6.9.0(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@6.0.6) - mppx: 0.5.5(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@3.25.76))(express@5.2.1)(hono@4.12.21)(typescript@5.9.3)(viem@2.50.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@3.25.76)) + mppx: 0.5.5(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(express@5.2.1)(hono@4.12.25)(typescript@5.9.3)(viem@2.50.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@4.4.3)) '@solana/nominal-types@6.9.0(typescript@5.9.3)': optionalDependencies: @@ -9584,7 +9582,7 @@ snapshots: '@solana/functional': 6.9.0(typescript@5.9.3) '@solana/rpc-subscriptions-spec': 6.9.0(typescript@5.9.3) '@solana/subscribable': 6.9.0(typescript@5.9.3) - ws: 8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6) + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -10074,7 +10072,7 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-react@5.2.0(vite@6.4.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.9.0))': + '@vitejs/plugin-react@5.2.0(vite@6.4.3(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -10082,7 +10080,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 6.4.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.9.0) + vite: 6.4.3(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.9.0) transitivePeerDependencies: - supports-color @@ -10094,13 +10092,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@6.4.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.9.0))': + '@vitest/mocker@3.2.4(vite@6.4.3(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.9.0))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 6.4.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.9.0) + vite: 6.4.3(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.9.0) '@vitest/pretty-format@3.2.4': dependencies: @@ -10151,7 +10149,7 @@ snapshots: dependencies: '@noble/curves': 1.9.7 '@scure/base': 1.2.6 - '@signinwithethereum/siwe': 4.2.0(ethers@6.16.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(viem@2.50.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@3.25.76)) + '@signinwithethereum/siwe': 4.2.0(ethers@6.16.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(viem@2.50.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@4.4.3)) '@x402/core': 2.12.0 ajv: 8.20.0 jose: 5.10.0 @@ -10205,6 +10203,11 @@ snapshots: typescript: 5.9.3 zod: 3.25.76 + abitype@1.2.3(typescript@5.9.3)(zod@4.4.3): + optionalDependencies: + typescript: 5.9.3 + zod: 4.4.3 + abitype@1.2.4(typescript@5.9.3)(zod@3.25.76): optionalDependencies: typescript: 5.9.3 @@ -10249,7 +10252,6 @@ snapshots: ajv-formats@3.0.1(ajv@8.20.0): optionalDependencies: ajv: 8.20.0 - optional: true ajv-keywords@3.5.2(ajv@6.15.0): dependencies: @@ -10321,7 +10323,7 @@ snapshots: hosted-git-info: 4.1.0 isbinaryfile: 5.0.7 jiti: 2.6.1 - js-yaml: 4.1.1 + js-yaml: 4.2.0 json5: 2.2.3 lazy-val: 1.0.5 minimatch: 10.2.5 @@ -10329,7 +10331,7 @@ snapshots: proper-lockfile: 4.1.2 resedit: 1.7.2 semver: 7.7.4 - tar: 7.5.13 + tar: 7.5.16 temp-file: 3.4.0 tiny-async-pool: 1.3.0 which: 5.0.0 @@ -10381,7 +10383,7 @@ snapshots: axios@1.16.1: dependencies: follow-redirects: 1.16.0 - form-data: 4.0.5 + form-data: 4.0.6 https-proxy-agent: 5.0.1 proxy-from-env: 2.1.0 transitivePeerDependencies: @@ -10657,7 +10659,7 @@ snapshots: fs-extra: 10.1.0 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 - js-yaml: 4.1.1 + js-yaml: 4.2.0 sanitize-filename: 1.6.4 source-map-support: 0.5.21 stat-mode: 1.0.0 @@ -10915,13 +10917,12 @@ snapshots: dependencies: object-assign: 4.1.1 vary: 1.1.2 - optional: true cosmiconfig@9.0.1(typescript@5.9.3): dependencies: env-paths: 2.2.1 import-fresh: 3.3.1 - js-yaml: 4.1.1 + js-yaml: 4.2.0 parse-json: 5.2.0 optionalDependencies: typescript: 5.9.3 @@ -11055,7 +11056,7 @@ snapshots: builder-util: 26.8.1 fs-extra: 10.1.0 iconv-lite: 0.6.3 - js-yaml: 4.1.1 + js-yaml: 4.2.0 optionalDependencies: dmg-license: 1.0.11 transitivePeerDependencies: @@ -11092,7 +11093,7 @@ snapshots: dependencies: domelementtype: 2.3.0 - dompurify@3.4.0: + dompurify@3.4.11: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -11160,7 +11161,7 @@ snapshots: builder-util: 26.8.1 builder-util-runtime: 9.5.1 chalk: 4.1.2 - form-data: 4.0.5 + form-data: 4.0.6 fs-extra: 10.1.0 lazy-val: 1.0.5 mime: 2.6.0 @@ -11173,7 +11174,7 @@ snapshots: dependencies: builder-util-runtime: 9.5.1 fs-extra: 10.1.0 - js-yaml: 4.1.1 + js-yaml: 4.2.0 lazy-val: 1.0.5 lodash.escaperegexp: 4.1.2 lodash.isequal: 4.5.0 @@ -11250,7 +11251,7 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 - hasown: 2.0.3 + hasown: 2.0.4 es6-error@4.1.1: optional: true @@ -11330,7 +11331,7 @@ snapshots: '@types/node': 22.7.5 aes-js: 4.0.0-beta.5 tslib: 2.7.0 - ws: 8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6) + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil - utf-8-validate @@ -11350,13 +11351,11 @@ snapshots: transitivePeerDependencies: - bare-abort-controller - eventsource-parser@3.1.0: - optional: true + eventsource-parser@3.1.0: {} eventsource@3.0.7: dependencies: eventsource-parser: 3.1.0 - optional: true expand-template@2.0.3: {} @@ -11715,12 +11714,12 @@ snapshots: dependencies: for-in: 1.0.2 - form-data@4.0.5: + form-data@4.0.6: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 es-set-tostringtag: 2.1.0 - hasown: 2.0.3 + hasown: 2.0.4 mime-types: 2.1.35 formdata-polyfill@4.0.10: @@ -11901,7 +11900,7 @@ snapshots: '@types/ws': 8.18.1 entities: 7.0.1 whatwg-mimetype: 3.0.0 - ws: 8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6) + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil - utf-8-validate @@ -11924,6 +11923,10 @@ snapshots: dependencies: function-bind: 1.1.2 + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.8 @@ -11976,8 +11979,7 @@ snapshots: dependencies: hermes-estree: 0.35.0 - hono@4.12.21: - optional: true + hono@4.12.25: {} hosted-git-info@4.1.0: dependencies: @@ -12076,7 +12078,7 @@ snapshots: libbase64: 1.3.0 libmime: 5.3.8 libqp: 2.1.1 - nodemailer: 8.0.7 + nodemailer: 9.0.1 pino: 10.3.1 socks: 2.8.8 @@ -12174,13 +12176,13 @@ snapshots: isobject@3.0.1: {} - isomorphic-ws@4.0.1(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6)): + isomorphic-ws@4.0.1(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)): dependencies: - ws: 8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6) + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) - isows@1.0.7(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6)): + isows@1.0.7(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)): dependencies: - ws: 8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6) + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) jake@10.9.4: dependencies: @@ -12197,11 +12199,11 @@ snapshots: delay: 5.0.0 es6-promisify: 5.0.0 eyes: 0.1.8 - isomorphic-ws: 4.0.1(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + isomorphic-ws: 4.0.1(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) json-stringify-safe: 5.0.1 stream-json: 1.9.1 uuid: 11.1.1 - ws: 8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6) + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil - utf-8-validate @@ -12239,8 +12241,7 @@ snapshots: jose@5.10.0: {} - jose@6.2.3: - optional: true + jose@6.2.3: {} js-sha256@0.11.1: {} @@ -12248,7 +12249,7 @@ snapshots: js-tokens@9.0.1: {} - js-yaml@4.1.1: + js-yaml@4.2.0: dependencies: argparse: 2.0.1 @@ -12273,8 +12274,7 @@ snapshots: json-schema-traverse@1.0.0: {} - json-schema-typed@8.0.2: - optional: true + json-schema-typed@8.0.2: {} json-stable-stringify@1.3.0: dependencies: @@ -12486,7 +12486,7 @@ snapshots: iconv-lite: 0.7.2 libmime: 5.3.8 linkify-it: 5.0.0 - nodemailer: 8.0.5 + nodemailer: 9.0.1 punycode.js: 2.3.1 tlds: 1.261.0 @@ -12971,7 +12971,7 @@ snapshots: serialize-error: 2.1.0 source-map: 0.5.7 throat: 5.0.0 - ws: 8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6) + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) yargs: 17.7.2 transitivePeerDependencies: - bufferutil @@ -13017,7 +13017,7 @@ snapshots: serialize-error: 2.1.0 source-map: 0.5.7 throat: 5.0.0 - ws: 8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6) + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) yargs: 17.7.2 transitivePeerDependencies: - bufferutil @@ -13285,21 +13285,21 @@ snapshots: monaco-editor@0.55.1: dependencies: - dompurify: 3.4.0 + dompurify: 3.4.11 marked: 14.0.0 - mppx@0.5.5(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@3.25.76))(express@5.2.1)(hono@4.12.21)(typescript@5.9.3)(viem@2.50.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@3.25.76)): + mppx@0.5.5(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(express@5.2.1)(hono@4.12.25)(typescript@5.9.3)(viem@2.50.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@4.4.3)): dependencies: '@remix-run/fetch-proxy': 0.7.1 '@remix-run/node-fetch-server': 0.13.3 incur: 0.3.25 ox: 0.14.7(typescript@5.9.3)(zod@4.4.3) - viem: 2.50.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@3.25.76) + viem: 2.50.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@4.4.3) zod: 4.4.3 optionalDependencies: - '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@3.25.76) + '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3) express: 5.2.1 - hono: 4.12.21 + hono: 4.12.25 transitivePeerDependencies: - typescript @@ -13370,9 +13370,9 @@ snapshots: nopt: 9.0.0 proc-log: 6.1.0 semver: 7.7.4 - tar: 7.5.13 + tar: 7.5.16 tinyglobby: 0.2.16 - undici: 6.25.0 + undici: 6.27.0 which: 6.0.1 node-int64@0.4.0: {} @@ -13383,9 +13383,7 @@ snapshots: node-releases@2.0.38: {} - nodemailer@8.0.5: {} - - nodemailer@8.0.7: {} + nodemailer@9.0.1: {} nopt@9.0.0: dependencies: @@ -13471,6 +13469,21 @@ snapshots: transitivePeerDependencies: - zod + ox@0.14.22(typescript@5.9.3)(zod@4.4.3): + dependencies: + '@adraffy/ens-normalize': 1.11.1 + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.4(typescript@5.9.3)(zod@4.4.3) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - zod + ox@0.14.7(typescript@5.9.3)(zod@4.4.3): dependencies: '@adraffy/ens-normalize': 1.11.1 @@ -13563,7 +13576,7 @@ snapshots: open: 7.4.2 semver: 7.7.4 slash: 2.0.0 - tmp: 0.2.6 + tmp: 0.2.7 yaml: 2.8.3 path-is-absolute@1.0.1: {} @@ -13623,8 +13636,7 @@ snapshots: dependencies: pngjs: 7.0.0 - pkce-challenge@5.0.1: - optional: true + pkce-challenge@5.0.1: {} playwright-core@1.59.0: {} @@ -13724,15 +13736,15 @@ snapshots: property-information@7.2.0: {} - protobufjs@7.5.8: + protobufjs@7.6.3: dependencies: '@protobufjs/aspromise': 1.1.2 '@protobufjs/base64': 1.1.2 '@protobufjs/codegen': 2.0.5 - '@protobufjs/eventemitter': 1.1.0 - '@protobufjs/fetch': 1.1.0 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 '@protobufjs/float': 1.0.2 - '@protobufjs/inquire': 1.1.1 + '@protobufjs/inquire': 1.1.2 '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.1 @@ -13780,7 +13792,7 @@ snapshots: devtools-protocol: 0.0.1581282 typed-query-selector: 2.12.1 webdriver-bidi-protocol: 0.4.1 - ws: 8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6) + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bare-abort-controller - bare-buffer @@ -13901,7 +13913,7 @@ snapshots: react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6): dependencies: shell-quote: 1.8.4 - ws: 8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6) + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil - utf-8-validate @@ -14006,7 +14018,7 @@ snapshots: stacktrace-parser: 0.1.11 tinyglobby: 0.2.16 whatwg-fetch: 3.6.20 - ws: 8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6) + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) yargs: 17.7.2 optionalDependencies: '@types/react': 19.2.14 @@ -14232,7 +14244,7 @@ snapshots: buffer: 6.0.3 eventemitter3: 5.0.4 uuid: 11.1.1 - ws: 8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6) + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) optionalDependencies: bufferutil: 4.1.0 utf-8-validate: 6.0.6 @@ -14640,7 +14652,7 @@ snapshots: - bare-buffer - react-native-b4a - tar@7.5.13: + tar@7.5.16: dependencies: '@isaacs/fs-minipass': 4.0.1 chownr: 3.0.0 @@ -14716,9 +14728,9 @@ snapshots: tmp-promise@3.0.3: dependencies: - tmp: 0.2.6 + tmp: 0.2.7 - tmp@0.2.6: {} + tmp@0.2.7: {} tmpl@1.0.5: {} @@ -14802,7 +14814,7 @@ snapshots: undici-types@8.3.0: {} - undici@6.25.0: {} + undici@6.27.0: {} unicode-canonical-property-names-ecmascript@2.0.1: {} @@ -14937,9 +14949,26 @@ snapshots: '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 abitype: 1.2.3(typescript@5.9.3)(zod@3.25.76) - isows: 1.0.7(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + isows: 1.0.7(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) ox: 0.14.22(typescript@5.9.3)(zod@3.25.76) - ws: 8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6) + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - zod + + viem@2.50.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@4.4.3): + dependencies: + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.9.3)(zod@4.4.3) + isows: 1.0.7(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + ox: 0.14.22(typescript@5.9.3)(zod@4.4.3) + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -14953,7 +14982,7 @@ snapshots: debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 6.4.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.9.0) + vite: 6.4.3(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.9.0) transitivePeerDependencies: - '@types/node' - jiti @@ -14974,7 +15003,7 @@ snapshots: optionalDependencies: vite-plugin-electron-renderer: 0.14.7 - vite@6.4.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.9.0): + vite@6.4.3(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.9.0): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) @@ -14994,7 +15023,7 @@ snapshots: dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@6.4.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.9.0)) + '@vitest/mocker': 3.2.4(vite@6.4.3(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.9.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -15012,7 +15041,7 @@ snapshots: tinyglobby: 0.2.16 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 6.4.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.9.0) + vite: 6.4.3(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.9.0) vite-node: 3.2.4(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: @@ -15098,7 +15127,7 @@ snapshots: wrappy@1.0.2: {} - ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6): + ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6): optionalDependencies: bufferutil: 4.1.0 utf-8-validate: 6.0.6 @@ -15150,10 +15179,9 @@ snapshots: yocto-queue@0.1.0: {} - zod-to-json-schema@3.25.2(zod@3.25.76): + zod-to-json-schema@3.25.2(zod@4.4.3): dependencies: - zod: 3.25.76 - optional: true + zod: 4.4.3 zod@3.25.76: {} diff --git a/scripts/smoke/bridge-shim.mjs b/scripts/smoke/bridge-shim.mjs new file mode 100644 index 00000000..f020964f --- /dev/null +++ b/scripts/smoke/bridge-shim.mjs @@ -0,0 +1,163 @@ +/** + * Bridge shim smoke — drives the built MCP shim (dist-bridge/daemon-bridge-shim.mjs) + * over real stdio against an in-process fake of the DAEMON bridge server. + * No Electron involved: this validates the shim half of the contract — + * MCP handshake, tools/list from the live endpoint, tools/call round-trip + * (including bearer auth), and the "DAEMON is not running" path. + * + * Run: pnpm run test:bridge-shim + */ +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import http from 'node:http' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const repoRoot = path.resolve(__dirname, '..', '..') +const shimPath = path.join(repoRoot, 'dist-bridge', 'daemon-bridge-shim.mjs') + +const TOKEN = 'f'.repeat(64) +const TOOLS = [ + { name: 'read_wallet', description: 'List wallets', risk: 'read', inputSchema: { type: 'object', properties: {} } }, + { name: 'remember_fact', description: 'Store a fact', risk: 'write', inputSchema: { type: 'object', properties: { title: { type: 'string' }, value: { type: 'string' } }, required: ['title', 'value'] } }, +] + +function log(message) { + console.log(`[bridge-shim-smoke] ${message}`) +} + +// --- Fake DAEMON bridge server --------------------------------------------- +const seenCalls = [] +const fakeServer = http.createServer((req, res) => { + const send = (code, payload) => { + res.writeHead(code, { 'content-type': 'application/json' }) + res.end(JSON.stringify(payload)) + } + const authed = req.headers.authorization === `Bearer ${TOKEN}` + if (req.method === 'GET' && req.url === '/bridge/ping') { + return send(200, { ok: true, data: { app: 'daemon', version: 'smoke', running: true } }) + } + if (!authed) return send(404, { ok: false, error: 'Not found' }) + if (req.method === 'GET' && req.url === '/bridge/tools') { + return send(200, { ok: true, data: TOOLS }) + } + if (req.method === 'POST' && req.url === '/bridge/call') { + let body = '' + req.on('data', (chunk) => { body += chunk }) + req.on('end', () => { + const call = JSON.parse(body) + seenCalls.push(call) + if (call.toolName === 'remember_fact') { + return send(200, { ok: true, data: { status: 'rejected', summary: 'User rejected this action.' } }) + } + return send(200, { ok: true, data: { status: 'done', summary: '2 wallets', result: { count: 2 } } }) + }) + return + } + return send(404, { ok: false, error: 'Not found' }) +}) + +await new Promise((resolve) => fakeServer.listen(0, '127.0.0.1', resolve)) +const port = fakeServer.address().port +log(`fake bridge listening on ${port}`) + +// --- Temp bridge.json -------------------------------------------------------- +const sandbox = mkdtempSync(path.join(tmpdir(), 'daemon-bridge-smoke-')) +const infoFile = path.join(sandbox, 'bridge.json') +writeFileSync(infoFile, JSON.stringify({ token: TOKEN, port, pid: 0, version: 'smoke', updatedAt: Date.now() })) + +// --- Spawn shim and speak newline-delimited JSON-RPC ------------------------- +const shim = spawn(process.execPath, [shimPath], { + env: { ...process.env, DAEMON_BRIDGE_INFO: infoFile }, + stdio: ['pipe', 'pipe', 'pipe'], +}) +shim.stderr.on('data', (chunk) => process.stderr.write(`[shim] ${chunk}`)) + +let buffer = '' +const pending = new Map() +shim.stdout.on('data', (chunk) => { + buffer += chunk.toString() + let index + while ((index = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, index).trim() + buffer = buffer.slice(index + 1) + if (!line) continue + const message = JSON.parse(line) + if (message.id !== undefined && pending.has(message.id)) { + pending.get(message.id)(message) + pending.delete(message.id) + } + } +}) + +let nextId = 0 +function rpc(method, params, timeoutMs = 15_000) { + const id = ++nextId + const payload = { jsonrpc: '2.0', id, method, params } + shim.stdin.write(JSON.stringify(payload) + '\n') + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`timeout waiting for ${method}`)), timeoutMs) + pending.set(id, (message) => { clearTimeout(timer); resolve(message) }) + }) +} + +function notify(method, params) { + shim.stdin.write(JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n') +} + +let exitCode = 0 +try { + // 1. MCP handshake + const init = await rpc('initialize', { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'bridge-shim-smoke', version: '1.0.0' }, + }) + assert.equal(init.result.serverInfo.name, 'daemon-bridge') + notify('notifications/initialized', {}) + log('handshake ok') + + // 2. tools/list reflects the live endpoint, with approval note on write tools + const list = await rpc('tools/list', {}) + const names = list.result.tools.map((t) => t.name) + assert.deepEqual(names, ['read_wallet', 'remember_fact']) + assert.match( + list.result.tools.find((t) => t.name === 'remember_fact').description, + /Requires user approval inside the DAEMON app/, + ) + log(`tools/list ok (${names.join(', ')})`) + + // 3. tools/call round-trip carries cwd + auth and renders the result + const call = await rpc('tools/call', { name: 'read_wallet', arguments: {} }) + assert.equal(call.result.isError, undefined) + assert.match(call.result.content[0].text, /2 wallets/) + assert.equal(seenCalls[0].toolName, 'read_wallet') + assert.ok(typeof seenCalls[0].cwd === 'string' && seenCalls[0].cwd.length > 0, 'cwd forwarded') + log('tools/call ok') + + // 4. rejection surfaces as a clear isError result + const rejected = await rpc('tools/call', { name: 'remember_fact', arguments: { title: 't', value: 'v' } }) + assert.equal(rejected.result.isError, true) + assert.match(rejected.result.content[0].text, /rejected this action in DAEMON/) + log('rejection path ok') + + // 5. DAEMON down → clear "not running" error + await new Promise((resolve) => fakeServer.close(resolve)) + const down = await rpc('tools/call', { name: 'read_wallet', arguments: {} }) + assert.equal(down.result.isError, true) + assert.match(down.result.content[0].text, /DAEMON is not running/) + log('not-running path ok') + + log('PASS') +} catch (error) { + exitCode = 1 + console.error('[bridge-shim-smoke] FAIL:', error) +} finally { + shim.kill() + await new Promise((resolve) => fakeServer.close(() => resolve())).catch(() => {}) + rmSync(sandbox, { recursive: true, force: true }) +} +process.exit(exitCode) diff --git a/scripts/smoke/visual-baselines/win32/agent-panel-composer.png b/scripts/smoke/visual-baselines/win32/agent-panel-composer.png index 472d7138..cc85b183 100644 Binary files a/scripts/smoke/visual-baselines/win32/agent-panel-composer.png and b/scripts/smoke/visual-baselines/win32/agent-panel-composer.png differ diff --git a/scripts/smoke/visual-baselines/win32/agent-panel-header.png b/scripts/smoke/visual-baselines/win32/agent-panel-header.png index 46b34ae5..ac6615c8 100644 Binary files a/scripts/smoke/visual-baselines/win32/agent-panel-header.png and b/scripts/smoke/visual-baselines/win32/agent-panel-header.png differ diff --git a/scripts/smoke/visual-baselines/win32/compact/agent-panel-composer.png b/scripts/smoke/visual-baselines/win32/compact/agent-panel-composer.png index e19f265b..225dbb82 100644 Binary files a/scripts/smoke/visual-baselines/win32/compact/agent-panel-composer.png and b/scripts/smoke/visual-baselines/win32/compact/agent-panel-composer.png differ diff --git a/scripts/smoke/visual-baselines/win32/compact/agent-panel-header.png b/scripts/smoke/visual-baselines/win32/compact/agent-panel-header.png index 4355f850..c902b200 100644 Binary files a/scripts/smoke/visual-baselines/win32/compact/agent-panel-header.png and b/scripts/smoke/visual-baselines/win32/compact/agent-panel-header.png differ diff --git a/scripts/smoke/visual-baselines/win32/wide/agent-panel-composer.png b/scripts/smoke/visual-baselines/win32/wide/agent-panel-composer.png index 7a2b7d07..b4072722 100644 Binary files a/scripts/smoke/visual-baselines/win32/wide/agent-panel-composer.png and b/scripts/smoke/visual-baselines/win32/wide/agent-panel-composer.png differ diff --git a/scripts/smoke/visual-baselines/win32/wide/agent-panel-header.png b/scripts/smoke/visual-baselines/win32/wide/agent-panel-header.png index 84322abb..08b6075b 100644 Binary files a/scripts/smoke/visual-baselines/win32/wide/agent-panel-header.png and b/scripts/smoke/visual-baselines/win32/wide/agent-panel-header.png differ diff --git a/src/App.tsx b/src/App.tsx index 27b0e2fd..0ea122d8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,7 @@ import { OnboardingWizard } from './panels/Onboarding/OnboardingWizard' import { LaunchWizard } from './panels/LaunchWizard/LaunchWizard' import { TourOverlay } from './components/Tour/TourOverlay' import { ToastHost } from './components/ToastHost' +import { BridgeApprovalHost } from './components/BridgeApprovalHost' import { ConfirmDialog } from './components/ConfirmDialog' import { KeyboardShortcutsOverlay } from './components/KeyboardShortcutsOverlay' import { useNotificationsStore } from './store/notifications' @@ -657,6 +658,7 @@ function App() { {tourActive && } {showShortcuts && setShowShortcuts(false)} />} + diff --git a/src/components/BridgeApprovalHost.module.css b/src/components/BridgeApprovalHost.module.css new file mode 100644 index 00000000..d4d297c5 --- /dev/null +++ b/src/components/BridgeApprovalHost.module.css @@ -0,0 +1,39 @@ +/* Fixed overlay for external-agent (bridge) approval requests. Sits above the + workspace but below toasts; cards reuse the AgentWorkbench approval styles. */ +.host { + position: fixed; + right: var(--space-lg); + bottom: calc(var(--space-2xl) + var(--space-sm)); + width: 320px; + z-index: 900; + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.header { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-sm) var(--space-md); + background: var(--bg-overlay); + border: 1px solid var(--s6); + border-radius: var(--radius-md); + color: var(--t1); + font-size: var(--fs-12); + font-weight: 600; +} + +.headerDot { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--amber); + flex: none; +} + +.headerHint { + color: var(--t3); + font-weight: 400; + margin-left: auto; +} diff --git a/src/components/BridgeApprovalHost.tsx b/src/components/BridgeApprovalHost.tsx new file mode 100644 index 00000000..a68b5e35 --- /dev/null +++ b/src/components/BridgeApprovalHost.tsx @@ -0,0 +1,31 @@ +import { useEffect } from 'react' +import { useBridgeStore } from '../store/bridge' +import { ApprovalCard } from '../panels/AgentWorkbench/ApprovalCard' +// The approval card styles live in the (lazy-loaded) workbench stylesheet — +// import it here so bridge approvals render even if the console never opened. +import '../panels/AgentWorkbench/AgentWorkbench.css' +import styles from './BridgeApprovalHost.module.css' + +/** Overlay for approval requests arriving from external MCP agents (Claude + * Code, Cursor) via the DAEMON Bridge. Mounted once at the app root. */ +export function BridgeApprovalHost() { + const approvals = useBridgeStore((s) => s.approvals) + const approve = useBridgeStore((s) => s.approve) + const subscribe = useBridgeStore((s) => s.subscribe) + + useEffect(() => subscribe(), [subscribe]) + + if (approvals.length === 0) return null + return ( +
+
+ + External agent request + via DAEMON Bridge +
+ {approvals.map((approval) => ( + + ))} +
+ ) +} diff --git a/src/panels/AgentWorkbench/ApprovalCard.tsx b/src/panels/AgentWorkbench/ApprovalCard.tsx index 3e274ea6..628eca68 100644 --- a/src/panels/AgentWorkbench/ApprovalCard.tsx +++ b/src/panels/AgentWorkbench/ApprovalCard.tsx @@ -7,8 +7,13 @@ const RISK_LABEL: Record = { sensitive: 'SENSITIVE', } -export function ApprovalCard({ approval }: { approval: AriaApproval }) { - const approve = useAriaStore((s) => s.approve) +export function ApprovalCard({ approval, onDecide }: { + approval: AriaApproval + /** Override the decision sink (the bridge surface routes to bridge:approve). Defaults to the ARIA store. */ + onDecide?: (callId: string, approved: boolean) => void +}) { + const ariaApprove = useAriaStore((s) => s.approve) + const approve = onDecide ?? ariaApprove const [typed, setTyped] = useState('') // Plan-mode gate: one approval to run the whole plan (sentinel name). diff --git a/src/panels/SettingsPanel/BridgeSection.tsx b/src/panels/SettingsPanel/BridgeSection.tsx new file mode 100644 index 00000000..b6bb7fe0 --- /dev/null +++ b/src/panels/SettingsPanel/BridgeSection.tsx @@ -0,0 +1,101 @@ +import { useCallback, useEffect, useState } from 'react' +import { daemon } from '../../lib/daemonBridge' +import { runIpc } from '../../lib/runIpc' +import { useBridgeStore } from '../../store/bridge' + +/** + * DAEMON Bridge settings — status of the loopback MCP bridge, token rotation, + * and per-project registration so external agents (Claude Code, Cursor) can + * call DAEMON's gated tools. Rendered inside the Integrations tab; reuses the + * global settings-* classes from SettingsPanel.css. + */ +export function BridgeSection({ projectPath }: { projectPath: string | null }) { + const [status, setStatus] = useState(null) + const [busy, setBusy] = useState(false) + const [registered, setRegistered] = useState(false) + const activity = useBridgeStore((s) => s.activity) + + const load = useCallback(async () => { + const data = await runIpc(daemon.bridge.status(), { context: 'Bridge', silent: true }) + if (data) setStatus(data) + }, []) + + useEffect(() => { void load() }, [load]) + + const handleRotate = async () => { + setBusy(true) + const data = await runIpc(daemon.bridge.rotateToken(), { context: 'Bridge' }) + if (data) setStatus(data) + setBusy(false) + } + + const handleRegister = async () => { + if (!projectPath) return + setBusy(true) + const data = await runIpc(daemon.bridge.registerProject(projectPath), { context: 'Bridge' }) + if (data) { + setStatus(data) + setRegistered(true) + } + setBusy(false) + } + + return ( +
+
DAEMON Bridge
+
+ Lets agents in other tools (Claude Code, Cursor) call DAEMON's wallet, launch, and + memory tools over MCP. Every write action still requires your approval inside DAEMON; + sensitive actions keep typed confirmation. +
+ +
+
+
+ + {status?.running ? `Listening on 127.0.0.1:${status.port}` : status?.error ?? 'Not running'} + + + {status ? `${status.toolCount} tools exposed` : ''} + +
+ {status?.tokenFile ? ( +
+
+ Token: {status.tokenFile} +
+ ) : null} +
+ +
+ + +
+ + {activity.length > 0 ? ( + <> +
Recent bridge calls
+
+ {activity.slice(0, 8).map((entry) => ( +
+
+ {entry.name} + {entry.summary} +
+ ))} +
+ + ) : null} +
+ ) +} diff --git a/src/panels/SettingsPanel/SettingsPanel.tsx b/src/panels/SettingsPanel/SettingsPanel.tsx index 5ed15878..de6c30e1 100644 --- a/src/panels/SettingsPanel/SettingsPanel.tsx +++ b/src/panels/SettingsPanel/SettingsPanel.tsx @@ -10,6 +10,7 @@ import { Toggle } from '../../components/Toggle' import { PanelHeader } from '../../components/Panel' import { BUILTIN_TOOLS, TOOL_NAMES } from '../../components/CommandDrawer/CommandDrawer' import { KeyboardShortcuts } from '../../components/KeyboardShortcuts' +import { BridgeSection } from './BridgeSection' import { NavigationGuide } from '../../components/NavigationGuide' import { isToolDisableable } from '../../constants/toolRegistry' import { @@ -75,7 +76,7 @@ interface AgentRow { // Lookup table mapping common search keywords to the tab they live in const SEARCH_INDEX: { tab: SettingsTab; keywords: string[] }[] = [ { tab: 'keys', keywords: ['key', 'api', 'token', 'secret', 'helius', 'openai', 'anthropic', 'birdeye', 'gemini'] }, - { tab: 'integrations', keywords: ['integration', 'claude', 'codex', 'mcp', 'voight', 'observability', 'sign in', 'login', 'connect', 'subscription', 'cli'] }, + { tab: 'integrations', keywords: ['integration', 'claude', 'codex', 'mcp', 'voight', 'observability', 'sign in', 'login', 'connect', 'subscription', 'cli', 'bridge', 'cursor', 'external agent'] }, { tab: 'aiProviders', keywords: ['ai provider', 'provider', 'default provider', 'aria', 'daemon ai', 'codex', 'claude', 'model'] }, { tab: 'agents', keywords: ['agent', 'provider', 'default provider', 'model', 'system prompt'] }, { tab: 'tools', keywords: ['tool', 'tools', 'extra tools', 'disable tools', 'sidebar', 'command drawer', 'profile', 'workspace'] }, @@ -196,7 +197,12 @@ export function SettingsPanel() {
{tab === 'keys' && } - {tab === 'integrations' && } + {tab === 'integrations' && ( + <> + + + + )} {tab === 'aiProviders' && } {tab === 'agents' && } {tab === 'tools' && } diff --git a/src/store/bridge.ts b/src/store/bridge.ts new file mode 100644 index 00000000..85e13622 --- /dev/null +++ b/src/store/bridge.ts @@ -0,0 +1,46 @@ +import { create } from 'zustand' +import type { BridgeToolEvent } from '../../electron/shared/types' +import { daemon } from '../lib/daemonBridge' +import type { AriaApproval } from './aria' + +const MAX_ACTIVITY = 50 + +interface BridgeState { + /** External-agent approvals awaiting a decision, oldest first. */ + approvals: AriaApproval[] + /** Recent completed bridge calls, newest first (activity feed for Settings). */ + activity: Array> + approve: (callId: string, approved: boolean) => void + subscribe: () => () => void +} + +export const useBridgeStore = create((set) => ({ + approvals: [], + activity: [], + + approve: (callId, approved) => { + daemon.bridge.approve(callId, approved) + set((s) => ({ approvals: s.approvals.filter((a) => a.callId !== callId) })) + }, + + subscribe: () => { + return daemon.bridge.onEvent((event) => { + switch (event.kind) { + case 'approval-request': + set((s) => ({ + approvals: [...s.approvals, { + callId: event.callId, name: event.name, risk: event.risk, + summary: event.summary, input: event.input, + }], + })) + break + case 'approval-expired': + set((s) => ({ approvals: s.approvals.filter((a) => a.callId !== event.callId) })) + break + case 'call': + set((s) => ({ activity: [event, ...s.activity].slice(0, MAX_ACTIVITY) })) + break + } + }) + }, +})) diff --git a/src/types/daemon.d.ts b/src/types/daemon.d.ts index 1459c31e..be9cd7dd 100644 --- a/src/types/daemon.d.ts +++ b/src/types/daemon.d.ts @@ -542,6 +542,8 @@ declare global { type AriaPlanStep = import('../../electron/shared/types').AriaPlanStep type AriaPatchProposalLite = import('../../electron/shared/types').AriaPatchProposalLite type AriaPatchAction = import('../../electron/shared/types').AriaPatchAction + type BridgeToolEvent = import('../../electron/shared/types').BridgeToolEvent + type BridgeStatus = import('../../electron/shared/types').BridgeStatus type DaemonAiModelLane = import('../../electron/shared/types').DaemonAiModelLane type OnboardingProgress = import('../../electron/shared/types').OnboardingProgress type OnboardingStepStatus = import('../../electron/shared/types').OnboardingStepStatus @@ -1092,6 +1094,14 @@ declare global { onUiEffect: (handler: (payload: { callId: string; effect: AriaUiEffectPayload; awaitData: boolean }) => void) => () => void } + interface DaemonBridge { + status: () => Promise> + rotateToken: () => Promise> + registerProject: (projectPath: string) => Promise> + approve: (callId: string, approved: boolean) => void + onEvent: (handler: (event: BridgeToolEvent) => void) => () => void + } + interface SwarmRun { id: string session_id: string | null @@ -1767,6 +1777,7 @@ declare global { email: DaemonEmail images: DaemonImages aria: DaemonAria + bridge: DaemonBridge swarm: DaemonSwarm memory: DaemonMemory launch: DaemonLaunch diff --git a/test/panels/BridgeApprovalHost.dom.test.tsx b/test/panels/BridgeApprovalHost.dom.test.tsx new file mode 100644 index 00000000..22af7409 --- /dev/null +++ b/test/panels/BridgeApprovalHost.dom.test.tsx @@ -0,0 +1,83 @@ +// @vitest-environment happy-dom + +import { render, screen, act } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { BridgeApprovalHost } from '../../src/components/BridgeApprovalHost' +import { useBridgeStore } from '../../src/store/bridge' +import type { BridgeToolEvent } from '../../electron/shared/types' + +let emitBridgeEvent: (event: BridgeToolEvent) => void = () => {} +const approveSpy = vi.fn() + +beforeEach(() => { + approveSpy.mockClear() + ;(window as unknown as { daemon: unknown }).daemon = { + bridge: { + approve: approveSpy, + onEvent: (handler: (event: BridgeToolEvent) => void) => { + emitBridgeEvent = handler + return () => { emitBridgeEvent = () => {} } + }, + }, + } + useBridgeStore.setState({ approvals: [], activity: [] }) +}) + +function pushApproval(partial: Partial> = {}) { + act(() => { + emitBridgeEvent({ + kind: 'approval-request', + callId: 'call-1', + name: 'remember_fact', + risk: 'write', + summary: 'remember_fact: Use pnpm', + input: { title: 'Use pnpm' }, + source: 'bridge', + ...partial, + }) + }) +} + +describe('BridgeApprovalHost', () => { + it('renders nothing until an external approval arrives', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + + pushApproval() + expect(screen.getByText('External agent request')).toBeTruthy() + expect(screen.getByText('remember_fact: Use pnpm')).toBeTruthy() + }) + + it('routes approve to daemon.bridge.approve and drops the card', async () => { + render() + pushApproval() + + await userEvent.click(screen.getByRole('button', { name: 'Approve' })) + expect(approveSpy).toHaveBeenCalledWith('call-1', true) + expect(screen.queryByText('External agent request')).toBeNull() + }) + + it('requires typed confirmation for sensitive tools', async () => { + render() + pushApproval({ callId: 'call-2', name: 'generate_wallet', risk: 'sensitive', summary: 'generate_wallet: hot', input: { name: 'hot' } }) + + const approveBtn = screen.getByRole('button', { name: 'Approve' }) as HTMLButtonElement + expect(approveBtn.disabled).toBe(true) + + await userEvent.type(screen.getByPlaceholderText('hot'), 'hot') + expect((screen.getByRole('button', { name: 'Approve' }) as HTMLButtonElement).disabled).toBe(false) + + await userEvent.click(screen.getByRole('button', { name: 'Approve' })) + expect(approveSpy).toHaveBeenCalledWith('call-2', true) + }) + + it('removes the card when the approval expires server-side', () => { + render() + pushApproval() + expect(screen.getByText('External agent request')).toBeTruthy() + + act(() => emitBridgeEvent({ kind: 'approval-expired', callId: 'call-1' })) + expect(screen.queryByText('External agent request')).toBeNull() + }) +}) diff --git a/test/services/AriaAgentService.executeToolCall.test.ts b/test/services/AriaAgentService.executeToolCall.test.ts new file mode 100644 index 00000000..f9d6f728 --- /dev/null +++ b/test/services/AriaAgentService.executeToolCall.test.ts @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AriaTransport } from '../../electron/services/AriaAgentService' + +// Mock the operator loop's heavy import chain — executeToolCall only needs the +// tool catalog, the risk gate, and describeIntent's clusterMark. +vi.mock('../../electron/db/db', () => ({ getDb: vi.fn() })) +vi.mock('../../electron/services/providers/ClaudeProvider', () => ({ runClaudeAgentTurn: vi.fn() })) +vi.mock('../../electron/services/providers/ProviderRegistry', () => ({ getFeatureProvider: vi.fn() })) +vi.mock('../../electron/services/providers/glmConfig', () => ({ + resolveOperatorBackend: vi.fn(), + getGlmEndpoint: vi.fn(), +})) +vi.mock('../../electron/services/DaemonAIService', () => ({ recordLocalAiUsage: vi.fn() })) +vi.mock('../../electron/services/MemoryService', () => ({ createSuggestion: vi.fn() })) +vi.mock('../../electron/services/aria/contextAssembler', () => ({ assembleSystemPrompt: vi.fn(() => '') })) +vi.mock('../../electron/services/aria/patchUtils', () => ({ + laneToClaudeModel: vi.fn(), + buildPlanSteps: vi.fn(() => []), + buildPatchProposal: vi.fn(), +})) + +const isMainnet = vi.fn(() => false) +vi.mock('../../electron/services/aria/tools/shared', () => ({ + clusterMark: (summary: string) => (isMainnet() ? `[MAINNET] ${summary}` : summary), +})) + +const writeHandler = vi.fn(async () => ({ ok: true, summary: 'fact stored' })) +const sensitiveHandler = vi.fn(async () => ({ ok: true, summary: 'wallet created' })) +const readHandler = vi.fn(async () => ({ ok: true, summary: 'two wallets' })) + +vi.mock('../../electron/services/aria/toolCatalog', () => { + const tools = [ + { name: 'read_tool', description: '', kind: 'read', risk: 'read', input: {}, handler: (...a: unknown[]) => readHandler(...a) }, + { name: 'write_tool', description: '', kind: 'edit', risk: 'write', input: {}, handler: (...a: unknown[]) => writeHandler(...a) }, + { name: 'sensitive_tool', description: '', kind: 'run', risk: 'sensitive', input: {}, handler: (...a: unknown[]) => sensitiveHandler(...a) }, + ] + return { ARIA_TOOLS: tools, getTool: (name: string) => tools.find((t) => t.name === name) } +}) + +import { executeToolCall } from '../../electron/services/AriaAgentService' + +const snapshot = { + activeProjectId: null, + activeProjectPath: null, + currentPanelId: null, + openFilePath: null, + chips: { activeFile: false, projectTree: false, gitDiff: false, terminalLogs: false, walletContext: false }, +} + +function makeTransport(approve: boolean): AriaTransport & { requestApproval: ReturnType } { + return { + emit: vi.fn(), + requestApproval: vi.fn(async () => approve), + requestPatchDecision: vi.fn(async () => 'discard' as const), + runUiEffect: vi.fn(async () => undefined), + } +} + +function ctx(transport: AriaTransport) { + return { sessionId: 'bridge:test', snapshot, runUiEffect: transport.runUiEffect } +} + +beforeEach(() => { + vi.clearAllMocks() + isMainnet.mockReturnValue(false) +}) + +describe('executeToolCall (bridge entry into the risk gate)', () => { + it('runs read tools without requesting approval', async () => { + const transport = makeTransport(true) + const record = await executeToolCall({ id: 'c1', name: 'read_tool', input: {} }, ctx(transport), transport) + + expect(record.status).toBe('done') + expect(transport.requestApproval).not.toHaveBeenCalled() + expect(readHandler).toHaveBeenCalled() + }) + + it('always gates write tools — planApproved can never be smuggled in', async () => { + const transport = makeTransport(true) + const record = await executeToolCall({ id: 'c2', name: 'write_tool', input: { title: 'x' } }, ctx(transport), transport) + + expect(transport.requestApproval).toHaveBeenCalledTimes(1) + expect(record.status).toBe('done') + }) + + it('returns rejected without running the handler when the user declines', async () => { + const transport = makeTransport(false) + const record = await executeToolCall({ id: 'c3', name: 'sensitive_tool', input: { name: 'w' } }, ctx(transport), transport) + + expect(record.status).toBe('rejected') + expect(sensitiveHandler).not.toHaveBeenCalled() + }) + + it('marks gated approval summaries with [MAINNET] on mainnet', async () => { + isMainnet.mockReturnValue(true) + const transport = makeTransport(true) + await executeToolCall({ id: 'c4', name: 'sensitive_tool', input: { name: 'hot' } }, ctx(transport), transport) + + const req = transport.requestApproval.mock.calls[0][0] as { summary: string } + expect(req.summary).toMatch(/^\[MAINNET\] /) + }) + + it('never cluster-marks read tools and returns unknown-tool errors cleanly', async () => { + const transport = makeTransport(true) + const record = await executeToolCall({ id: 'c5', name: 'nope', input: {} }, ctx(transport), transport) + expect(record.status).toBe('error') + expect(record.summary).toContain('Unknown tool') + }) +}) diff --git a/test/services/BridgeServerService.test.ts b/test/services/BridgeServerService.test.ts new file mode 100644 index 00000000..c1002d10 --- /dev/null +++ b/test/services/BridgeServerService.test.ts @@ -0,0 +1,140 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { BridgeCallResult, BridgeToolDescriptor } from '../../electron/shared/types' + +// The server only pulls getApprovalTimeoutMs from the gateway; mock it so this +// test never loads the AriaAgentService import chain. +vi.mock('../../electron/services/bridge/BridgeToolGateway', () => ({ + getApprovalTimeoutMs: () => 120_000, +})) + +import { + getBridgeStatus, + startBridgeServer, + stopBridgeServer, + type BridgeServerOptions, +} from '../../electron/services/bridge/BridgeServerService' + +const TOKEN = 'a'.repeat(64) +const TOOLS: BridgeToolDescriptor[] = [ + { name: 'read_wallet', description: 'Read wallets', risk: 'read', inputSchema: { type: 'object', properties: {} } }, +] + +function makeOptions(overrides: Partial = {}): BridgeServerOptions { + return { + port: 0, + token: TOKEN, + tokenFile: 'C:/tmp/bridge.json', + version: '0.0.0-test', + listTools: () => TOOLS, + executeCall: async () => ({ status: 'done', summary: 'ok' } satisfies BridgeCallResult), + ...overrides, + } +} + +async function request( + port: number, + pathname: string, + init: { method?: string; token?: string; origin?: string; body?: unknown } = {}, +) { + const headers: Record = { 'content-type': 'application/json' } + if (init.token) headers['authorization'] = `Bearer ${init.token}` + if (init.origin) headers['origin'] = init.origin + const res = await fetch(`http://127.0.0.1:${port}${pathname}`, { + method: init.method ?? 'GET', + headers, + body: init.body != null ? JSON.stringify(init.body) : undefined, + }) + return { status: res.status, json: await res.json().catch(() => null) as { ok?: boolean; data?: unknown; error?: string } | null } +} + +describe('BridgeServerService', () => { + afterEach(async () => { + await stopBridgeServer() + }) + + it('answers ping without auth and reports status', async () => { + const status = await startBridgeServer(makeOptions()) + expect(status.running).toBe(true) + expect(status.toolCount).toBe(1) + + const res = await request(status.port, '/bridge/ping') + expect(res.status).toBe(200) + expect((res.json?.data as { app?: string })?.app).toBe('daemon') + }) + + it('returns a generic 404 for missing or wrong bearer tokens', async () => { + const { port } = await startBridgeServer(makeOptions()) + + const missing = await request(port, '/bridge/tools') + expect(missing.status).toBe(404) + + const wrong = await request(port, '/bridge/tools', { token: 'b'.repeat(64) }) + expect(wrong.status).toBe(404) + + const right = await request(port, '/bridge/tools', { token: TOKEN }) + expect(right.status).toBe(200) + expect(right.json?.data).toEqual(TOOLS) + }) + + it('rejects any request carrying a browser Origin header', async () => { + const { port } = await startBridgeServer(makeOptions()) + const res = await request(port, '/bridge/tools', { token: TOKEN, origin: 'http://localhost:5173' }) + expect(res.status).toBe(403) + }) + + it('round-trips a call through the executor', async () => { + const executeCall = vi.fn(async () => ({ status: 'done', summary: 'balance: 1 SOL', result: { sol: 1 } } satisfies BridgeCallResult)) + const { port } = await startBridgeServer(makeOptions({ executeCall })) + + const res = await request(port, '/bridge/call', { + method: 'POST', + token: TOKEN, + body: { toolName: 'read_wallet', input: {}, cwd: 'C:/work/project' }, + }) + expect(res.status).toBe(200) + expect((res.json?.data as BridgeCallResult).summary).toBe('balance: 1 SOL') + expect(executeCall).toHaveBeenCalledWith({ toolName: 'read_wallet', input: {}, cwd: 'C:/work/project' }) + }) + + it('rejects malformed call bodies with 400', async () => { + const { port } = await startBridgeServer(makeOptions()) + const res = await request(port, '/bridge/call', { method: 'POST', token: TOKEN, body: { input: {} } }) + expect(res.status).toBe(400) + }) + + it('caps concurrent calls at 4 with 429', async () => { + let release: () => void = () => {} + const blocked = new Promise((resolve) => { release = resolve }) + const executeCall = vi.fn(async () => { + await blocked + return { status: 'done', summary: 'ok' } satisfies BridgeCallResult + }) + const { port } = await startBridgeServer(makeOptions({ executeCall })) + + const body = { toolName: 'read_wallet', input: {} } + const pending = Array.from({ length: 4 }, () => + request(port, '/bridge/call', { method: 'POST', token: TOKEN, body })) + // Give the four in-flight requests time to register before the fifth. + await vi.waitFor(() => expect(executeCall).toHaveBeenCalledTimes(4)) + + const fifth = await request(port, '/bridge/call', { method: 'POST', token: TOKEN, body }) + expect(fifth.status).toBe(429) + + release() + const results = await Promise.all(pending) + for (const res of results) expect(res.status).toBe(200) + }) + + it('responds 503 to in-flight calls on shutdown', async () => { + const executeCall = vi.fn(() => new Promise(() => {})) // never resolves + const { port } = await startBridgeServer(makeOptions({ executeCall })) + + const hanging = request(port, '/bridge/call', { method: 'POST', token: TOKEN, body: { toolName: 'read_wallet', input: {} } }) + await vi.waitFor(() => expect(executeCall).toHaveBeenCalled()) + + await stopBridgeServer() + const res = await hanging + expect(res.status).toBe(503) + expect(getBridgeStatus().running).toBe(false) + }) +}) diff --git a/test/services/BridgeToolGateway.test.ts b/test/services/BridgeToolGateway.test.ts new file mode 100644 index 00000000..7505714d --- /dev/null +++ b/test/services/BridgeToolGateway.test.ts @@ -0,0 +1,183 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AriaToolCallRecord } from '../../electron/shared/types' + +// Fake catalog: one tool per risk tier, names matching the real allowlist so +// the allowlist ∩ catalog intersection is exercised. vi.hoisted so the vi.mock +// factories (hoisted above imports) can reference it. +const FAKE_TOOLS = vi.hoisted(() => [ + { name: 'read_wallet', description: 'Read wallets', kind: 'read', risk: 'read', input: { type: 'object', properties: {} } }, + { name: 'remember_fact', description: 'Store a fact', kind: 'edit', risk: 'write', input: { type: 'object', properties: { title: { type: 'string' } } } }, + { name: 'generate_wallet', description: 'New wallet', kind: 'run', risk: 'sensitive', input: { type: 'object', properties: { name: { type: 'string' } } } }, + { name: 'tokenlaunch_preflight', description: 'Preflight', kind: 'read', risk: 'read', input: { type: 'object', properties: {} } }, +]) + +vi.mock('../../electron/services/aria/toolCatalog', () => ({ + ARIA_TOOLS: FAKE_TOOLS, + getTool: (name: string) => FAKE_TOOLS.find((t) => t.name === name), +})) + +const executeToolCall = vi.hoisted(() => vi.fn()) +vi.mock('../../electron/services/AriaAgentService', () => ({ + executeToolCall: (...args: unknown[]) => executeToolCall(...args), +})) + +const getEnabledPacks = vi.hoisted(() => vi.fn<() => Record>(() => ({}))) +vi.mock('../../electron/services/SettingsService', () => ({ + getEnabledPacks: () => getEnabledPacks(), +})) + +const projectRows = vi.hoisted(() => [] as Array<{ id: string; path: string }>) +vi.mock('../../electron/db/db', () => ({ + getDb: () => ({ + prepare: () => ({ all: () => projectRows }), + }), +})) + +import { + executeBridgeCall, + findBridgeTool, + listBridgeTools, + resolveProjectForCwd, + type BridgeGatewayDeps, +} from '../../electron/services/bridge/BridgeToolGateway' + +function makeDeps(overrides: Partial = {}): BridgeGatewayDeps { + return { + requestApproval: vi.fn(async () => true), + cancelApproval: vi.fn(), + emit: vi.fn(), + approvalTimeoutMs: 50, + ...overrides, + } +} + +function doneRecord(partial: Partial = {}): AriaToolCallRecord { + return { + callId: 'call-1', name: 'read_wallet', toolKind: 'read', risk: 'read', + status: 'done', summary: 'ok', input: {}, ...partial, + } +} + +beforeEach(() => { + executeToolCall.mockReset() + getEnabledPacks.mockReturnValue({}) + projectRows.length = 0 +}) + +describe('listBridgeTools / findBridgeTool', () => { + it('exposes only allowlisted tools present in the catalog', () => { + const names = listBridgeTools().map((t) => t.name) + expect(names).toEqual(['read_wallet', 'generate_wallet', 'tokenlaunch_preflight', 'remember_fact']) + }) + + it('drops tools whose pack is disabled and explains on call', () => { + getEnabledPacks.mockReturnValue({ wallet: false }) + const names = listBridgeTools().map((t) => t.name) + expect(names).toEqual(['tokenlaunch_preflight', 'remember_fact']) + + const found = findBridgeTool('read_wallet') + expect(found.ok).toBe(false) + if (!found.ok) expect(found.error).toContain('wallet pack is disabled') + }) + + it('refuses tools outside the allowlist even if they exist in the catalog', () => { + const found = findBridgeTool('present_plan') + expect(found.ok).toBe(false) + if (!found.ok) expect(found.error).toContain('not exposed over the DAEMON Bridge') + }) +}) + +describe('resolveProjectForCwd', () => { + it('matches case-insensitively on win32 and prefers the longest prefix', () => { + projectRows.push( + { id: 'outer', path: 'C:\\Work' }, + { id: 'inner', path: 'C:\\Work\\daemon' }, + ) + expect(resolveProjectForCwd('c:\\work\\DAEMON\\src')?.id).toBe('inner') + expect(resolveProjectForCwd('C:\\Work\\other')?.id).toBe('outer') + expect(resolveProjectForCwd('D:\\elsewhere')).toBeNull() + expect(resolveProjectForCwd(undefined)).toBeNull() + }) + + it('does not match sibling directories sharing a name prefix', () => { + projectRows.push({ id: 'p', path: 'C:\\Work\\daemon' }) + expect(resolveProjectForCwd('C:\\Work\\daemon-other')).toBeNull() + }) +}) + +describe('executeBridgeCall', () => { + it('requires a resolved project for project-scoped tools', async () => { + const result = await executeBridgeCall( + { toolName: 'remember_fact', input: { title: 'x' }, cwd: 'D:\\nowhere' }, + makeDeps(), + ) + expect(result.status).toBe('error') + expect(result.summary).toContain('No DAEMON project matches') + expect(executeToolCall).not.toHaveBeenCalled() + }) + + it('runs through executeToolCall with a project-scoped snapshot', async () => { + projectRows.push({ id: 'proj-1', path: 'C:\\Work\\daemon' }) + executeToolCall.mockResolvedValue(doneRecord({ summary: '2 wallets', result: { count: 2 } })) + const deps = makeDeps() + + const result = await executeBridgeCall( + { toolName: 'read_wallet', input: {}, cwd: 'C:\\Work\\daemon\\packages' }, + deps, + ) + + expect(result).toEqual({ status: 'done', summary: '2 wallets', result: { count: 2 } }) + const [, ctx] = executeToolCall.mock.calls[0] as [unknown, { sessionId: string; snapshot: { activeProjectId: string | null } }] + expect(ctx.sessionId).toMatch(/^bridge:/) + expect(ctx.snapshot.activeProjectId).toBe('proj-1') + expect(deps.emit).toHaveBeenCalledWith(expect.objectContaining({ kind: 'call', status: 'done' })) + }) + + it('maps a user rejection to status rejected', async () => { + executeToolCall.mockImplementation(async (_use, _ctx, transport: { requestApproval: (r: unknown) => Promise }) => { + const approved = await transport.requestApproval({ callId: 'c1', name: 'remember_fact', risk: 'write', summary: 's', input: {} }) + return doneRecord(approved ? {} : { status: 'rejected', summary: 'User rejected this action.' }) + }) + const deps = makeDeps({ requestApproval: vi.fn(async () => false) }) + projectRows.push({ id: 'p', path: 'C:\\Work\\daemon' }) + + const result = await executeBridgeCall({ toolName: 'remember_fact', input: { title: 'x' }, cwd: 'C:\\Work\\daemon' }, deps) + expect(result.status).toBe('rejected') + }) + + it('auto-rejects as timeout when no one answers the approval', async () => { + executeToolCall.mockImplementation(async (_use, _ctx, transport: { requestApproval: (r: unknown) => Promise }) => { + const approved = await transport.requestApproval({ callId: 'c2', name: 'generate_wallet', risk: 'sensitive', summary: 's', input: {} }) + return doneRecord(approved ? {} : { status: 'rejected', summary: 'User rejected this action.' }) + }) + const deps = makeDeps({ + requestApproval: vi.fn(() => new Promise(() => {})), // never answered + approvalTimeoutMs: 30, + }) + + const result = await executeBridgeCall({ toolName: 'generate_wallet', input: { name: 'w' } }, deps) + expect(result.status).toBe('timeout') + expect(result.summary).toContain('Approval timed out') + expect(deps.cancelApproval).toHaveBeenCalledWith('c2') + }) + + it('hands tools a runUiEffect that throws (bridge tripwire)', async () => { + executeToolCall.mockImplementation(async (_use, ctx: { runUiEffect: (e: unknown, a: boolean) => Promise }) => { + await expect(ctx.runUiEffect({ type: 'open_tool', toolId: 'x' }, false)).rejects.toThrow('not available over the bridge') + return doneRecord() + }) + + const result = await executeBridgeCall({ toolName: 'read_wallet', input: {} }, makeDeps()) + expect(result.status).toBe('done') + }) + + it('rejects non-object input before reaching the executor', async () => { + const result = await executeBridgeCall( + { toolName: 'read_wallet', input: [1, 2] as unknown as Record }, + makeDeps(), + ) + expect(result.status).toBe('error') + expect(result.summary).toContain('JSON object') + expect(executeToolCall).not.toHaveBeenCalled() + }) +}) diff --git a/test/services/bridgeToken.test.ts b/test/services/bridgeToken.test.ts new file mode 100644 index 00000000..e1dd9a55 --- /dev/null +++ b/test/services/bridgeToken.test.ts @@ -0,0 +1,56 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + bridgeInfoFile, + ensureBridgeToken, + rotateBridgeToken, + writeBridgeRuntimeInfo, +} from '../../electron/services/bridge/bridgeToken' + +describe('bridgeToken', () => { + let userData: string + + beforeEach(() => { + userData = fs.mkdtempSync(path.join(os.tmpdir(), 'daemon-bridge-token-')) + }) + + afterEach(() => { + fs.rmSync(userData, { recursive: true, force: true }) + }) + + it('creates a 256-bit hex token on first run and persists it', () => { + const first = ensureBridgeToken(userData) + expect(first.token).toMatch(/^[0-9a-f]{64}$/) + expect(first.file).toBe(bridgeInfoFile(userData)) + expect(fs.existsSync(first.file)).toBe(true) + + const second = ensureBridgeToken(userData) + expect(second.token).toBe(first.token) + }) + + it('records the live port without losing the token', () => { + const { token, file } = ensureBridgeToken(userData) + writeBridgeRuntimeInfo(userData, { port: 7337, token }) + + const parsed = JSON.parse(fs.readFileSync(file, 'utf8')) + expect(parsed.token).toBe(token) + expect(parsed.port).toBe(7337) + expect(parsed.pid).toBe(process.pid) + expect(typeof parsed.updatedAt).toBe('number') + }) + + it('rotates to a fresh token while keeping the recorded port', () => { + const { token } = ensureBridgeToken(userData) + writeBridgeRuntimeInfo(userData, { port: 4242, token }) + + const rotated = rotateBridgeToken(userData) + expect(rotated).toMatch(/^[0-9a-f]{64}$/) + expect(rotated).not.toBe(token) + + const parsed = JSON.parse(fs.readFileSync(bridgeInfoFile(userData), 'utf8')) + expect(parsed.token).toBe(rotated) + expect(parsed.port).toBe(4242) + }) +}) diff --git a/vite.bridge.config.ts b/vite.bridge.config.ts new file mode 100644 index 00000000..5494c97e --- /dev/null +++ b/vite.bridge.config.ts @@ -0,0 +1,31 @@ +import { builtinModules } from 'node:module' +import { defineConfig } from 'vite' + +// Unlike vite.cloud.config.ts, dependencies are NOT external: the shim must be +// a single self-contained .mjs the system node can run from app resources, +// so the MCP SDK is bundled in. Only node builtins stay external. +const external = [ + ...builtinModules, + ...builtinModules.map((name) => `node:${name}`), +] + +export default defineConfig({ + ssr: { + // Bundle the MCP SDK (and everything else) — only node builtins stay external. + noExternal: true, + }, + build: { + target: 'node18', + ssr: 'electron/services/bridge/shim.ts', + outDir: 'dist-bridge', + emptyOutDir: true, + rollupOptions: { + external, + output: { + format: 'es', + entryFileNames: 'daemon-bridge-shim.mjs', + inlineDynamicImports: true, + }, + }, + }, +})