From 7a513e3306c0e744297ed0e208067e2e549cd530 Mon Sep 17 00:00:00 2001 From: 0xpantera <0xpantera@proton.me> Date: Sun, 14 Jun 2026 15:16:35 +0200 Subject: [PATCH 1/3] feat: add minimal EIP-1193 browser bridge scaffold --- crates/deckard-mcp/src/browser_bridge.rs | 311 +++++++++++++++++++++++ crates/deckard-mcp/src/lib.rs | 1 + crates/deckard-mcp/src/main.rs | 174 +++++++++++++ docs/browser-bridge.md | 137 ++++++++++ examples/browser-bridge-dapp/index.html | 51 ++++ extension/background.js | 61 +++++ extension/content.js | 22 ++ extension/injected.js | 79 ++++++ extension/manifest.json | 26 ++ 9 files changed, 862 insertions(+) create mode 100644 crates/deckard-mcp/src/browser_bridge.rs create mode 100644 docs/browser-bridge.md create mode 100644 examples/browser-bridge-dapp/index.html create mode 100644 extension/background.js create mode 100644 extension/content.js create mode 100644 extension/injected.js create mode 100644 extension/manifest.json diff --git a/crates/deckard-mcp/src/browser_bridge.rs b/crates/deckard-mcp/src/browser_bridge.rs new file mode 100644 index 0000000..9fd306e --- /dev/null +++ b/crates/deckard-mcp/src/browser_bridge.rs @@ -0,0 +1,311 @@ +//! Minimal experimental EIP-1193 browser bridge. +//! +//! This is intentionally a narrow localhost-only vertical slice for the unpacked browser +//! connector. It is key-less: account discovery reads the already-unlocked Deckard address +//! through the existing [`Sidecar`] / signer-daemon path, or a dev-only mock address when +//! explicitly enabled for browser-bridge testing. + +use std::collections::BTreeMap; +use std::sync::{Arc, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use crate::Sidecar; + +const PERMISSION_ETH_ACCOUNTS: &str = "eth_accounts"; +const DEV_ACCOUNT_ENV: &str = "DECKARD_BRIDGE_DEV_ACCOUNT"; +const DEFAULT_DEV_ACCOUNT: &str = "0xdeC0ded0000000000000000000000000000001193"; + +/// Per-origin dapp session remembered by the bridge process. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct DappSession { + pub origin: String, + pub chain_id: u64, + pub account: String, + pub permissions: Vec, + pub created_at: u64, + pub last_seen: u64, + pub revoked: bool, +} + +#[derive(Default)] +pub struct DappSessionStore { + sessions: BTreeMap, +} + +impl DappSessionStore { + pub fn get(&self, origin: &str) -> Option<&DappSession> { + self.sessions.get(origin).filter(|session| !session.revoked) + } + + pub fn grant_accounts( + &mut self, + origin: String, + chain_id: u64, + account: String, + ) -> DappSession { + let now = unix_now(); + let session = self.sessions.entry(origin.clone()).or_insert(DappSession { + origin, + chain_id, + account: account.clone(), + permissions: vec![PERMISSION_ETH_ACCOUNTS.to_string()], + created_at: now, + last_seen: now, + revoked: false, + }); + session.chain_id = chain_id; + session.account = account; + session.last_seen = now; + session.revoked = false; + if !session + .permissions + .iter() + .any(|permission| permission == PERMISSION_ETH_ACCOUNTS) + { + session + .permissions + .push(PERMISSION_ETH_ACCOUNTS.to_string()); + } + session.clone() + } + + #[allow(dead_code)] + pub fn revoke(&mut self, origin: &str) -> bool { + match self.sessions.get_mut(origin) { + Some(session) => { + session.revoked = true; + session.last_seen = unix_now(); + true + } + None => false, + } + } +} + +#[derive(Clone)] +pub enum BridgeBackend { + Sidecar(Arc), + DevMock { account: String }, +} + +impl BridgeBackend { + pub fn from_env(sidecar: Sidecar) -> Self { + match std::env::var(DEV_ACCOUNT_ENV) { + Ok(account) => Self::DevMock { account }, + Err(_) => Self::Sidecar(Arc::new(sidecar)), + } + } + + async fn account(&self) -> Result { + match self { + Self::DevMock { account } => Ok(account.clone()), + Self::Sidecar(sidecar) => { + let value = sidecar + .wallet_address() + .await + .map_err(|failure| BridgeError { + code: 4900, + message: failure.to_human(), + })?; + value + .get("address") + .and_then(Value::as_str) + .map(ToOwned::to_owned) + .ok_or_else(|| BridgeError { + code: 4900, + message: "Deckard returned an address response without an address".into(), + }) + } + } + } +} + +pub struct BrowserBridge { + chain_id: u64, + backend: BridgeBackend, + sessions: Mutex, +} + +impl BrowserBridge { + pub fn new(chain_id: u64, backend: BridgeBackend) -> Self { + Self { + chain_id, + backend, + sessions: Mutex::new(DappSessionStore::default()), + } + } + + pub async fn handle_request(&self, origin: &str, request: BridgeRequest) -> BridgeResponse { + let id = request.id.clone(); + let result = match request.method.as_str() { + "eth_chainId" => Ok(json!(format!("0x{:x}", self.chain_id))), + "eth_accounts" => Ok(json!(self.accounts_for_origin(origin))), + "eth_requestAccounts" => self + .request_accounts(origin) + .await + .map(|accounts| json!(accounts)), + method => Err(BridgeError { + code: 4200, + message: format!("Deckard browser bridge does not support {method}"), + }), + }; + BridgeResponse::from_result(id, result) + } + + fn accounts_for_origin(&self, origin: &str) -> Vec { + let Ok(store) = self.sessions.lock() else { + return Vec::new(); + }; + store + .get(origin) + .map(|session| vec![session.account.clone()]) + .unwrap_or_default() + } + + async fn request_accounts(&self, origin: &str) -> Result, BridgeError> { + let account = self.backend.account().await?; + let session = { + let mut store = self.sessions.lock().map_err(|_| BridgeError { + code: 4900, + message: "Deckard browser bridge session store is unavailable".into(), + })?; + store.grant_accounts(origin.to_string(), self.chain_id, account) + }; + Ok(vec![session.account]) + } +} + +#[derive(Clone, Debug, Deserialize)] +pub struct BridgeRequest { + #[serde(default)] + pub id: Value, + pub method: String, + #[serde(default)] + pub params: Value, +} + +#[derive(Clone, Debug, Serialize)] +pub struct BridgeResponse { + pub jsonrpc: &'static str, + pub id: Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl BridgeResponse { + fn from_result(id: Value, result: Result) -> Self { + match result { + Ok(result) => Self { + jsonrpc: "2.0", + id, + result: Some(result), + error: None, + }, + Err(error) => Self { + jsonrpc: "2.0", + id, + result: None, + error: Some(error), + }, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct BridgeError { + pub code: i64, + pub message: String, +} + +pub fn dev_account_from_env() -> String { + std::env::var(DEV_ACCOUNT_ENV).unwrap_or_else(|_| DEFAULT_DEV_ACCOUNT.to_string()) +} + +pub fn dev_account_env_name() -> &'static str { + DEV_ACCOUNT_ENV +} + +fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + const ORIGIN: &str = "http://127.0.0.1:8765"; + + fn bridge() -> BrowserBridge { + BrowserBridge::new( + 11155111, + BridgeBackend::DevMock { + account: DEFAULT_DEV_ACCOUNT.to_string(), + }, + ) + } + + #[test] + fn session_creation_and_lookup() { + let mut store = DappSessionStore::default(); + let session = store.grant_accounts( + ORIGIN.to_string(), + 11155111, + DEFAULT_DEV_ACCOUNT.to_string(), + ); + assert_eq!(session.origin, ORIGIN); + assert_eq!(session.chain_id, 11155111); + assert_eq!(session.account, DEFAULT_DEV_ACCOUNT); + assert_eq!(session.permissions, vec![PERMISSION_ETH_ACCOUNTS]); + assert!(!session.revoked); + assert_eq!( + store.get(ORIGIN).map(|stored| stored.account.as_str()), + Some(DEFAULT_DEV_ACCOUNT) + ); + assert!(store.revoke(ORIGIN)); + assert!(store.get(ORIGIN).is_none()); + } + + #[tokio::test] + async fn unsupported_method_returns_eip1193_style_error() { + let response = bridge() + .handle_request( + ORIGIN, + BridgeRequest { + id: json!(7), + method: "eth_sendTransaction".into(), + params: Value::Null, + }, + ) + .await; + let error = response.error.expect("unsupported method error"); + assert_eq!(error.code, 4200); + assert!(error.message.contains("eth_sendTransaction")); + } + + #[tokio::test] + async fn account_request_returns_expected_address_in_dev_mode() { + let bridge = bridge(); + let response = bridge + .handle_request( + ORIGIN, + BridgeRequest { + id: json!(1), + method: "eth_requestAccounts".into(), + params: Value::Null, + }, + ) + .await; + assert_eq!(response.result, Some(json!([DEFAULT_DEV_ACCOUNT]))); + + let accounts = bridge.accounts_for_origin(ORIGIN); + assert_eq!(accounts, vec![DEFAULT_DEV_ACCOUNT]); + } +} diff --git a/crates/deckard-mcp/src/lib.rs b/crates/deckard-mcp/src/lib.rs index 0552b02..fd2ad94 100644 --- a/crates/deckard-mcp/src/lib.rs +++ b/crates/deckard-mcp/src/lib.rs @@ -19,6 +19,7 @@ //! - **STOP is always available:** `deckard_revoke_all` / `deckard-mcp stop`. pub mod amount; +pub mod browser_bridge; pub mod failure; pub mod install; pub mod secrets; diff --git a/crates/deckard-mcp/src/main.rs b/crates/deckard-mcp/src/main.rs index 5250d83..a5a2d98 100644 --- a/crates/deckard-mcp/src/main.rs +++ b/crates/deckard-mcp/src/main.rs @@ -2,8 +2,15 @@ //! the MCP stdio server (`--mcp`) or the CLI command tree — both thin shells over the same //! key-less [`deckard_mcp::Sidecar`], so nothing is reachable only via Claude. +use std::sync::Arc; + use clap::{Parser, Subcommand}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use deckard_mcp::browser_bridge::{ + dev_account_env_name, BridgeBackend, BridgeRequest, BrowserBridge, +}; use deckard_mcp::{install, secrets, server, Sidecar}; #[derive(Parser)] @@ -51,6 +58,15 @@ enum Command { /// STOP — the panic brake: zeroize the key, lock the daemon, deny everything /// in flight. Re-arm by unlocking in the Deckard app. Stop, + /// Experimental localhost EIP-1193 browser bridge for the unpacked extension. + BrowserBridge { + /// Loopback bind address. Keep this on 127.0.0.1 unless you are debugging. + #[arg(long, default_value = "127.0.0.1:8765")] + bind: String, + /// Return this mock address instead of reading an unlocked Deckard daemon. + #[arg(long = "dev-mock-account")] + dev_mock_account: Option, + }, /// Print (or, with --write + confirmation, write) the Claude Desktop registration. Install { /// Emit the demo env block (isolated config dir + socket, Sepolia fork chain id, @@ -103,6 +119,15 @@ async fn run(cli: Cli) -> anyhow::Result<()> { return install::run(demo, write, &mut lock); } + if let Command::BrowserBridge { + bind, + dev_mock_account, + } = &command + { + let sidecar = Sidecar::from_env()?; + return serve_browser_bridge(bind, sidecar, dev_mock_account.clone()).await; + } + let sidecar = Sidecar::from_env()?; let result = match &command { Command::Balance => sidecar.wallet_balance().await, @@ -111,6 +136,7 @@ async fn run(cli: Cli) -> anyhow::Result<()> { Command::Shield { amount_eth } => sidecar.shield(amount_eth).await, Command::Execute { request_id } => sidecar.execute(request_id).await, Command::Stop => sidecar.revoke_all().await, + Command::BrowserBridge { .. } => unreachable!("handled above"), Command::Install { .. } => unreachable!("handled above"), }; match result { @@ -123,3 +149,151 @@ async fn run(cli: Cli) -> anyhow::Result<()> { } } } + +async fn serve_browser_bridge( + bind: &str, + sidecar: Sidecar, + dev_mock_account: Option, +) -> anyhow::Result<()> { + if !bind.starts_with("127.0.0.1:") && !bind.starts_with("localhost:") { + anyhow::bail!("browser bridge must bind to loopback (example: 127.0.0.1:8765)"); + } + let chain_id = sidecar.chain_id(); + let backend = match dev_mock_account { + Some(account) => BridgeBackend::DevMock { account }, + None => BridgeBackend::from_env(sidecar), + }; + let bridge = Arc::new(BrowserBridge::new(chain_id, backend)); + let listener = TcpListener::bind(bind).await?; + eprintln!( + "Deckard browser bridge listening on http://{bind}/rpc (dev mock via {})", + dev_account_env_name() + ); + loop { + let (stream, _) = listener.accept().await?; + let bridge = Arc::clone(&bridge); + tokio::spawn(async move { + if let Err(e) = handle_http_connection(stream, bridge).await { + eprintln!("browser bridge request failed: {e}"); + } + }); + } +} + +async fn handle_http_connection( + mut stream: TcpStream, + bridge: Arc, +) -> anyhow::Result<()> { + let mut buf = vec![0_u8; 64 * 1024]; + let mut read = 0_usize; + let header_end = loop { + let n = stream.read(&mut buf[read..]).await?; + if n == 0 { + return Ok(()); + } + read += n; + if let Some(pos) = find_header_end(&buf[..read]) { + break pos; + } + if read == buf.len() { + return write_http(&mut stream, 413, "text/plain", "request too large").await; + } + }; + + let headers = std::str::from_utf8(&buf[..header_end])?.to_string(); + let (method, path) = request_line(&headers)?; + let origin = header_value(&headers, "x-deckard-origin") + .or_else(|| header_value(&headers, "origin")) + .unwrap_or("unknown-origin") + .to_string(); + + if method == "OPTIONS" { + return write_http(&mut stream, 204, "text/plain", "").await; + } + if method != "POST" || path != "/rpc" { + return write_http(&mut stream, 404, "text/plain", "not found").await; + } + if !host_is_loopback(&headers) { + return write_http(&mut stream, 403, "text/plain", "host must be localhost").await; + } + + let content_length = header_value(&headers, "content-length") + .and_then(|s| s.parse::().ok()) + .ok_or_else(|| anyhow::anyhow!("missing content-length"))?; + if content_length > 32 * 1024 { + return write_http(&mut stream, 413, "text/plain", "body too large").await; + } + + let body_start = header_end + 4; + while read < body_start + content_length { + let n = stream.read(&mut buf[read..]).await?; + if n == 0 { + anyhow::bail!("connection closed before request body completed"); + } + read += n; + } + + let request: BridgeRequest = + serde_json::from_slice(&buf[body_start..body_start + content_length])?; + let response = bridge.handle_request(&origin, request).await; + let response_body = serde_json::to_string(&response)?; + write_http(&mut stream, 200, "application/json", &response_body).await +} + +async fn write_http( + stream: &mut TcpStream, + status: u16, + content_type: &str, + body: &str, +) -> anyhow::Result<()> { + let reason = match status { + 200 => "OK", + 204 => "No Content", + 403 => "Forbidden", + 404 => "Not Found", + 413 => "Payload Too Large", + _ => "Error", + }; + let response = format!( + "HTTP/1.1 {status} {reason}\r\ncontent-type: {content_type}\r\ncontent-length: {}\r\naccess-control-allow-origin: *\r\naccess-control-allow-headers: content-type,x-deckard-origin\r\naccess-control-allow-methods: POST,OPTIONS\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).await?; + Ok(()) +} + +fn find_header_end(buf: &[u8]) -> Option { + buf.windows(4).position(|window| window == b"\r\n\r\n") +} + +fn request_line(headers: &str) -> anyhow::Result<(&str, &str)> { + let line = headers + .lines() + .next() + .ok_or_else(|| anyhow::anyhow!("missing request line"))?; + let mut parts = line.split_whitespace(); + let method = parts + .next() + .ok_or_else(|| anyhow::anyhow!("missing request method"))?; + let path = parts + .next() + .ok_or_else(|| anyhow::anyhow!("missing request path"))?; + Ok((method, path)) +} + +fn header_value<'a>(headers: &'a str, name: &str) -> Option<&'a str> { + headers.lines().find_map(|line| { + let (key, value) = line.split_once(':')?; + if key.eq_ignore_ascii_case(name) { + Some(value.trim()) + } else { + None + } + }) +} + +fn host_is_loopback(headers: &str) -> bool { + header_value(headers, "host") + .map(|host| host.starts_with("127.0.0.1:") || host.starts_with("localhost:")) + .unwrap_or(false) +} diff --git a/docs/browser-bridge.md b/docs/browser-bridge.md new file mode 100644 index 0000000..1a98e02 --- /dev/null +++ b/docs/browser-bridge.md @@ -0,0 +1,137 @@ +# Experimental browser bridge (EIP-1193 vertical slice) + +> Experimental. Not audited. Not for real funds or real mainnet keys. + +This milestone proves one narrow path: + +```text +local test dapp + -> injected `window.ethereum` EIP-1193 provider + -> unpacked browser extension + -> localhost Deckard bridge endpoint on 127.0.0.1 + -> Deckard sidecar/session handling + -> selected account/address returned to the dapp +``` + +It does **not** ship a production wallet extension. The extension contains no keys, seed phrases, +signing logic, or durable wallet state. It only forwards a tiny allowlist of methods to a local +Deckard process. + +## Repo map + +- Desktop app / UI: `crates/deckard-app` (`deckard` GPUI binary). +- Existing local daemon / signer API: `crates/deckard-signerd`, over a same-uid Unix-domain socket. +- Key-less sidecar / API surface: `crates/deckard-mcp`; this milestone adds the experimental + `deckard-mcp browser-bridge` localhost endpoint here instead of creating a competing daemon. +- Existing account/address state: the unlocked signer daemon answers `SignerRequest::Address`, surfaced + by `Sidecar::wallet_address()`. +- Browser connector scaffold: `extension/`. +- Local test dapp: `examples/browser-bridge-dapp/index.html`. +- Tests: `crates/deckard-mcp/src/browser_bridge.rs`. + +## Supported methods + +- `eth_chainId` -> returns the configured `DECKARD_CHAIN_ID` as hex. +- `eth_accounts` -> returns the account only after this origin has an active in-memory dapp session. +- `eth_requestAccounts` -> asks Deckard for the current unlocked address (or returns a dev mock address) + and grants this origin an in-memory session. + +Unsupported methods return an EIP-1193-style error object with code `4200`. + +`personal_sign`, `eth_sendTransaction`, broad signing, hardware wallets, Kohaku, native messaging, and +store distribution are intentionally not implemented in this PR. + +## Dapp sessions + +The bridge stores sessions in memory only, keyed by origin: + +- `origin` +- `chain_id` +- `account` +- `permissions` +- `created_at` +- `last_seen` +- `revoked` + +This is deliberately minimal. Restarting the bridge clears sessions. Future work should move this into a +reviewed permissions registry with explicit approval UI, anti-phishing copy, revocation UX, and persistence. + +## Run in dev/mock mode + +This is the smallest way to test the browser bridge without an unlocked wallet: + +```sh +cargo run -p deckard-mcp -- browser-bridge \ + --bind 127.0.0.1:8765 \ + --dev-mock-account 0xdeC0ded0000000000000000000000000000001193 +``` + +Alternatively, the same dev mock can be supplied through the environment: + +```sh +export DECKARD_BRIDGE_DEV_ACCOUNT=0xdeC0ded0000000000000000000000000000001193 +cargo run -p deckard-mcp -- browser-bridge --bind 127.0.0.1:8765 +``` + +## Run against local Deckard + +In one terminal, run Deckard's normal demo stack and unlock a throwaway wallet: + +```sh +export RPC_URL_SEPOLIA=https://eth-sepolia.g.alchemy.com/v2/ +just demo +``` + +In another terminal, run the bridge on loopback: + +```sh +export DECKARD_CHAIN_ID=11155111 +cargo run -p deckard-mcp -- browser-bridge --bind 127.0.0.1:8765 +``` + +The bridge uses the existing Deckard socket path (`DECKARD_SOCKET_PATH` or the default) and calls the +same key-less sidecar path as `deckard-mcp address`. + +## Load the unpacked extension + +Chromium/Chrome/Brave: + +1. Open `chrome://extensions` (or `brave://extensions`). +2. Enable **Developer mode**. +3. Click **Load unpacked**. +4. Select the repository's `extension/` directory. + +The extension injects `window.ethereum` and forwards only `eth_chainId`, `eth_accounts`, and +`eth_requestAccounts` to `http://127.0.0.1:8765/rpc`. + +## Open the local test dapp + +Serve the test page from localhost so it has a stable origin: + +```sh +python3 -m http.server 8777 --directory examples/browser-bridge-dapp +``` + +Open in the browser where the unpacked extension is loaded. Then click: + +1. **eth_requestAccounts** -> should show the Deckard/mock address. +2. **eth_chainId** -> should show the chain id, for example `0xaa36a7` for Sepolia. + +## Security notes + +- The bridge binds to loopback only (`127.0.0.1` / `localhost`) and rejects non-loopback Host headers. +- The extension has no keys and performs no signing. +- The dapp origin is sent to the bridge and bound to an in-memory session before `eth_accounts` returns + anything. +- CORS is permissive in this milestone because the extension is the intended caller and the API supports + only address disclosure in dev/local mode. A production bridge needs explicit origin allowlisting, + CSRF/rebinding hardening, a stronger browser-to-native transport decision, and user-visible approval. +- Use throwaway wallets only. This bridge is not for real funds. + +## Follow-up work + +- Decide native messaging vs hardened localhost using the PRD-04 spike evidence. +- Add a real approval UI for `eth_requestAccounts` and per-origin revocation. +- Add EIP-6963 provider announcement. +- Persist permissions safely. +- Add clear-signing/message-signing only after Deckard has the reviewed intent model for it. diff --git a/examples/browser-bridge-dapp/index.html b/examples/browser-bridge-dapp/index.html new file mode 100644 index 0000000..631479a --- /dev/null +++ b/examples/browser-bridge-dapp/index.html @@ -0,0 +1,51 @@ + + + + + + Deckard EIP-1193 bridge test dapp + + + +

Deckard EIP-1193 bridge test dapp

+

Experimental local-only bridge. Use throwaway/dev accounts only; do not use real funds.

+ + +
Waiting for window.ethereum…
+ + + + diff --git a/extension/background.js b/extension/background.js new file mode 100644 index 0000000..2d48edd --- /dev/null +++ b/extension/background.js @@ -0,0 +1,61 @@ +const SUPPORTED_METHODS = new Set([ + 'eth_chainId', + 'eth_accounts', + 'eth_requestAccounts', +]); + +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (!message || message.type !== 'deckard:eip1193-request') { + return false; + } + + if (!SUPPORTED_METHODS.has(message.method)) { + sendResponse({ + id: message.id, + error: { + code: 4200, + message: `Deckard extension does not support ${message.method}`, + }, + }); + return false; + } + + const origin = sender.origin || new URL(sender.url || 'http://unknown.invalid').origin; + fetch('http://127.0.0.1:8765/rpc', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-deckard-origin': origin, + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: message.id, + method: message.method, + params: message.params ?? [], + }), + }) + .then(async (response) => { + if (!response.ok) { + throw new Error(`Deckard bridge HTTP ${response.status}`); + } + return response.json(); + }) + .then((payload) => { + if (payload.error) { + sendResponse({ id: message.id, error: payload.error }); + } else { + sendResponse({ id: message.id, result: payload.result }); + } + }) + .catch((error) => { + sendResponse({ + id: message.id, + error: { + code: 4900, + message: error instanceof Error ? error.message : String(error), + }, + }); + }); + + return true; +}); diff --git a/extension/content.js b/extension/content.js new file mode 100644 index 0000000..a2dbb85 --- /dev/null +++ b/extension/content.js @@ -0,0 +1,22 @@ +const script = document.createElement('script'); +script.src = chrome.runtime.getURL('injected.js'); +script.onload = () => script.remove(); +(document.documentElement || document.head).appendChild(script); + +window.addEventListener('message', (event) => { + if (event.source !== window) return; + const message = event.data; + if (!message || message.type !== 'deckard:eip1193-request') return; + + chrome.runtime.sendMessage(message, (response) => { + window.postMessage( + { + type: 'deckard:eip1193-response', + id: message.id, + result: response?.result, + error: response?.error, + }, + window.location.origin, + ); + }); +}); diff --git a/extension/injected.js b/extension/injected.js new file mode 100644 index 0000000..6cc50b0 --- /dev/null +++ b/extension/injected.js @@ -0,0 +1,79 @@ +(() => { + let nextId = 1; + const pending = new Map(); + + class DeckardProvider { + constructor() { + this.isDeckard = true; + this.selectedAddress = null; + this.chainId = null; + } + + request(args) { + if (!args || typeof args.method !== 'string') { + return Promise.reject(providerError(4100, 'request({ method }) is required')); + } + + const id = nextId++; + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject, method: args.method }); + window.postMessage( + { + type: 'deckard:eip1193-request', + id, + method: args.method, + params: args.params ?? [], + }, + window.location.origin, + ); + }); + } + + on() { + // Event emitter support is intentionally out of scope for this milestone. + return this; + } + + removeListener() { + return this; + } + } + + function providerError(code, message) { + const error = new Error(message); + error.code = code; + return error; + } + + const provider = new DeckardProvider(); + + window.addEventListener('message', (event) => { + if (event.source !== window) return; + const message = event.data; + if (!message || message.type !== 'deckard:eip1193-response') return; + + const entry = pending.get(message.id); + if (!entry) return; + pending.delete(message.id); + + if (message.error) { + entry.reject(providerError(message.error.code, message.error.message)); + return; + } + + if (entry.method === 'eth_chainId') { + provider.chainId = message.result; + } + if (entry.method === 'eth_requestAccounts' && Array.isArray(message.result)) { + provider.selectedAddress = message.result[0] || null; + } + entry.resolve(message.result); + }); + + Object.defineProperty(window, 'ethereum', { + value: provider, + configurable: true, + }); + + window.dispatchEvent(new Event('ethereum#initialized')); +})(); diff --git a/extension/manifest.json b/extension/manifest.json new file mode 100644 index 0000000..4a1601e --- /dev/null +++ b/extension/manifest.json @@ -0,0 +1,26 @@ +{ + "manifest_version": 3, + "name": "Deckard Browser Bridge (experimental)", + "version": "0.0.1", + "description": "Experimental key-less EIP-1193 connector for local Deckard dev builds. Not for real funds.", + "permissions": [], + "host_permissions": ["http://127.0.0.1:8765/*"], + "background": { + "service_worker": "background.js", + "type": "module" + }, + "content_scripts": [ + { + "matches": ["http://*/*", "https://*/*", "file:///*"], + "js": ["content.js"], + "run_at": "document_start", + "all_frames": false + } + ], + "web_accessible_resources": [ + { + "resources": ["injected.js"], + "matches": [""] + } + ] +} From a9b7d55190223854987743ac6069b25ae28f9c29 Mon Sep 17 00:00:00 2001 From: 0xpantera <0xpantera@proton.me> Date: Sun, 14 Jun 2026 15:57:19 +0200 Subject: [PATCH 2/3] refactor: move browser bridge out of deckard-mcp --- AGENTS.md | 3 + Cargo.lock | 26 +++ Cargo.toml | 4 + crates/deckard-browser-bridge/Cargo.toml | 28 +++ .../src/lib.rs} | 176 ++++++++++++++++-- crates/deckard-browser-bridge/src/main.rs | 36 ++++ crates/deckard-mcp/Cargo.toml | 1 + crates/deckard-mcp/src/lib.rs | 3 +- crates/deckard-mcp/src/main.rs | 175 ----------------- crates/deckard-mcp/src/sidecar.rs | 130 ++----------- crates/deckard-wallet-client/Cargo.toml | 22 +++ .../src/failure.rs | 4 +- crates/deckard-wallet-client/src/lib.rs | 137 ++++++++++++++ docs/browser-bridge.md | 56 ++++-- 14 files changed, 483 insertions(+), 318 deletions(-) create mode 100644 crates/deckard-browser-bridge/Cargo.toml rename crates/{deckard-mcp/src/browser_bridge.rs => deckard-browser-bridge/src/lib.rs} (60%) create mode 100644 crates/deckard-browser-bridge/src/main.rs create mode 100644 crates/deckard-wallet-client/Cargo.toml rename crates/{deckard-mcp => deckard-wallet-client}/src/failure.rs (99%) create mode 100644 crates/deckard-wallet-client/src/lib.rs diff --git a/AGENTS.md b/AGENTS.md index 55df3dc..71db85f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,9 @@ Treat key material with care. belongs here, not in the app. - `crates/deckard-contract` — the frozen wire contract (Intent / Decision / Policy / RPC / ReadStatus). - `crates/deckard-signerd` — the process-isolated signer daemon (owns the key; UDS server). +- `crates/deckard-wallet-client` — shared key-less signer client/account/chain/error primitives for local interfaces. +- `crates/deckard-mcp` — MCP/agent interface over shared wallet capabilities. +- `crates/deckard-browser-bridge` — EIP-1193 dapp/browser interface over shared wallet capabilities. ## Commands - Iterate fast: `just core` — clippy + test the GPUI-free engine (`deckard-core`) without building the diff --git a/Cargo.lock b/Cargo.lock index ec29635..570de62 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4005,6 +4005,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deckard-browser-bridge" +version = "0.0.1-alpha" +dependencies = [ + "anyhow", + "clap", + "deckard-wallet-client", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "deckard-contract" version = "0.0.1-alpha" @@ -4052,6 +4064,7 @@ dependencies = [ "deckard-contract", "deckard-core", "deckard-signerd", + "deckard-wallet-client", "rmcp", "serde", "serde_json", @@ -4079,6 +4092,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deckard-wallet-client" +version = "0.0.1-alpha" +dependencies = [ + "alloy-primitives", + "anyhow", + "deckard-contract", + "deckard-core", + "deckard-signerd", + "serde", + "serde_json", +] + [[package]] name = "deflate64" version = "0.1.12" diff --git a/Cargo.toml b/Cargo.toml index 73ec31c..f75a691 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,9 @@ # - deckard-core the headless engine (Ethereum provider, balances, keystore) # - deckard-contract the frozen wire contract (Intent / Decision / Policy / RPC) # - deckard-signerd the process-isolated signer daemon (owns the key; UDS server) +# - deckard-wallet-client shared key-less client primitives for local interfaces # - deckard-mcp the key-less agent surface (one binary: CLI + `--mcp` stdio server) +# - deckard-browser-bridge the key-less dapp/browser interface # # `cargo run` from the repo root still launches the app via `default-members`. [workspace] @@ -15,7 +17,9 @@ members = [ "crates/deckard-core", "crates/deckard-contract", "crates/deckard-signerd", + "crates/deckard-wallet-client", "crates/deckard-mcp", + "crates/deckard-browser-bridge", ] default-members = ["crates/deckard-app"] diff --git a/crates/deckard-browser-bridge/Cargo.toml b/crates/deckard-browser-bridge/Cargo.toml new file mode 100644 index 0000000..bfa35d6 --- /dev/null +++ b/crates/deckard-browser-bridge/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "deckard-browser-bridge" +version = "0.0.1-alpha" +edition = "2021" +license = "AGPL-3.0-or-later" +description = "Deckard's experimental key-less EIP-1193 localhost browser bridge." + +[lib] +name = "deckard_browser_bridge" +path = "src/lib.rs" + +[[bin]] +name = "deckard-browser-bridge" +path = "src/main.rs" + +[dependencies] +deckard-wallet-client = { path = "../deckard-wallet-client" } +clap = { version = "4", features = ["derive"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util"] } +serde = { workspace = true } +serde_json = "1" +anyhow = "1" + +[dev-dependencies] +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } + +[lints] +workspace = true diff --git a/crates/deckard-mcp/src/browser_bridge.rs b/crates/deckard-browser-bridge/src/lib.rs similarity index 60% rename from crates/deckard-mcp/src/browser_bridge.rs rename to crates/deckard-browser-bridge/src/lib.rs index 9fd306e..16953cf 100644 --- a/crates/deckard-mcp/src/browser_bridge.rs +++ b/crates/deckard-browser-bridge/src/lib.rs @@ -2,17 +2,20 @@ //! //! This is intentionally a narrow localhost-only vertical slice for the unpacked browser //! connector. It is key-less: account discovery reads the already-unlocked Deckard address -//! through the existing [`Sidecar`] / signer-daemon path, or a dev-only mock address when -//! explicitly enabled for browser-bridge testing. +//! through shared wallet-client primitives, or a dev-only mock address when explicitly +//! enabled for browser-bridge testing. use std::collections::BTreeMap; use std::sync::{Arc, Mutex}; + use std::time::{SystemTime, UNIX_EPOCH}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use crate::Sidecar; +use deckard_wallet_client::WalletClient; const PERMISSION_ETH_ACCOUNTS: &str = "eth_accounts"; const DEV_ACCOUNT_ENV: &str = "DECKARD_BRIDGE_DEV_ACCOUNT"; @@ -87,36 +90,28 @@ impl DappSessionStore { #[derive(Clone)] pub enum BridgeBackend { - Sidecar(Arc), + WalletClient(Arc), DevMock { account: String }, } impl BridgeBackend { - pub fn from_env(sidecar: Sidecar) -> Self { + pub fn from_env(wallet: WalletClient) -> Self { match std::env::var(DEV_ACCOUNT_ENV) { Ok(account) => Self::DevMock { account }, - Err(_) => Self::Sidecar(Arc::new(sidecar)), + Err(_) => Self::WalletClient(Arc::new(wallet)), } } async fn account(&self) -> Result { match self { Self::DevMock { account } => Ok(account.clone()), - Self::Sidecar(sidecar) => { - let value = sidecar + Self::WalletClient(wallet) => { + wallet .wallet_address() .await .map_err(|failure| BridgeError { code: 4900, message: failure.to_human(), - })?; - value - .get("address") - .and_then(Value::as_str) - .map(ToOwned::to_owned) - .ok_or_else(|| BridgeError { - code: 4900, - message: "Deckard returned an address response without an address".into(), }) } } @@ -237,6 +232,155 @@ fn unix_now() -> u64 { .unwrap_or(0) } +/// Serve the bridge JSON-RPC endpoint on a loopback TCP address. +pub async fn serve( + bind: &str, + wallet: WalletClient, + dev_mock_account: Option, +) -> anyhow::Result<()> { + if !bind.starts_with("127.0.0.1:") && !bind.starts_with("localhost:") { + anyhow::bail!("browser bridge must bind to loopback (example: 127.0.0.1:8765)"); + } + let chain_id = wallet.chain_id(); + let backend = match dev_mock_account { + Some(account) => BridgeBackend::DevMock { account }, + None => BridgeBackend::from_env(wallet), + }; + let bridge = Arc::new(BrowserBridge::new(chain_id, backend)); + let listener = TcpListener::bind(bind).await?; + eprintln!( + "Deckard browser bridge listening on http://{bind}/rpc (dev mock via {})", + dev_account_env_name() + ); + loop { + let (stream, _) = listener.accept().await?; + let bridge = Arc::clone(&bridge); + tokio::spawn(async move { + if let Err(e) = handle_http_connection(stream, bridge).await { + eprintln!("browser bridge request failed: {e}"); + } + }); + } +} + +async fn handle_http_connection( + mut stream: TcpStream, + bridge: Arc, +) -> anyhow::Result<()> { + let mut buf = vec![0_u8; 64 * 1024]; + let mut read = 0_usize; + let header_end = loop { + let n = stream.read(&mut buf[read..]).await?; + if n == 0 { + return Ok(()); + } + read += n; + if let Some(pos) = find_header_end(&buf[..read]) { + break pos; + } + if read == buf.len() { + return write_http(&mut stream, 413, "text/plain", "request too large").await; + } + }; + + let headers = std::str::from_utf8(&buf[..header_end])?.to_string(); + let (method, path) = request_line(&headers)?; + let origin = header_value(&headers, "x-deckard-origin") + .or_else(|| header_value(&headers, "origin")) + .unwrap_or("unknown-origin") + .to_string(); + + if method == "OPTIONS" { + return write_http(&mut stream, 204, "text/plain", "").await; + } + if method != "POST" || path != "/rpc" { + return write_http(&mut stream, 404, "text/plain", "not found").await; + } + if !host_is_loopback(&headers) { + return write_http(&mut stream, 403, "text/plain", "host must be localhost").await; + } + + let content_length = header_value(&headers, "content-length") + .and_then(|s| s.parse::().ok()) + .ok_or_else(|| anyhow::anyhow!("missing content-length"))?; + if content_length > 32 * 1024 { + return write_http(&mut stream, 413, "text/plain", "body too large").await; + } + + let body_start = header_end + 4; + while read < body_start + content_length { + let n = stream.read(&mut buf[read..]).await?; + if n == 0 { + anyhow::bail!("connection closed before request body completed"); + } + read += n; + } + + let request: BridgeRequest = + serde_json::from_slice(&buf[body_start..body_start + content_length])?; + let response = bridge.handle_request(&origin, request).await; + let response_body = serde_json::to_string(&response)?; + write_http(&mut stream, 200, "application/json", &response_body).await +} + +async fn write_http( + stream: &mut TcpStream, + status: u16, + content_type: &str, + body: &str, +) -> anyhow::Result<()> { + let reason = match status { + 200 => "OK", + 204 => "No Content", + 403 => "Forbidden", + 404 => "Not Found", + 413 => "Payload Too Large", + _ => "Error", + }; + let response = format!( + "HTTP/1.1 {status} {reason}\r\ncontent-type: {content_type}\r\ncontent-length: {}\r\naccess-control-allow-origin: *\r\naccess-control-allow-headers: content-type,x-deckard-origin\r\naccess-control-allow-methods: POST,OPTIONS\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).await?; + Ok(()) +} + +fn find_header_end(buf: &[u8]) -> Option { + buf.windows(4).position(|window| window == b"\r\n\r\n") +} + +fn request_line(headers: &str) -> anyhow::Result<(&str, &str)> { + let line = headers + .lines() + .next() + .ok_or_else(|| anyhow::anyhow!("missing request line"))?; + let mut parts = line.split_whitespace(); + let method = parts + .next() + .ok_or_else(|| anyhow::anyhow!("missing request method"))?; + let path = parts + .next() + .ok_or_else(|| anyhow::anyhow!("missing request path"))?; + Ok((method, path)) +} + +fn header_value<'a>(headers: &'a str, name: &str) -> Option<&'a str> { + headers.lines().find_map(|line| { + let (key, value) = line.split_once(':')?; + if key.eq_ignore_ascii_case(name) { + Some(value.trim()) + } else { + None + } + }) +} + +fn host_is_loopback(headers: &str) -> bool { + header_value(headers, "host") + .map(|host| host.starts_with("127.0.0.1:") || host.starts_with("localhost:")) + .unwrap_or(false) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/deckard-browser-bridge/src/main.rs b/crates/deckard-browser-bridge/src/main.rs new file mode 100644 index 0000000..26552e5 --- /dev/null +++ b/crates/deckard-browser-bridge/src/main.rs @@ -0,0 +1,36 @@ +//! `deckard-browser-bridge` entry point: the key-less dapp/browser interface. + +use clap::Parser; +use deckard_wallet_client::WalletClient; + +#[derive(Parser)] +#[command( + name = "deckard-browser-bridge", + version, + about = "Deckard's experimental key-less EIP-1193 localhost browser bridge. Holds no keys." +)] +struct Cli { + /// Loopback bind address. Keep this on 127.0.0.1 unless you are debugging. + #[arg(long, default_value = "127.0.0.1:8765")] + bind: String, + /// Return this mock address instead of reading an unlocked Deckard daemon. + #[arg(long = "dev-mock-account")] + dev_mock_account: Option, +} + +#[tokio::main] +async fn main() -> std::process::ExitCode { + let cli = Cli::parse(); + match run(cli).await { + Ok(()) => std::process::ExitCode::SUCCESS, + Err(e) => { + eprintln!("{e}"); + std::process::ExitCode::FAILURE + } + } +} + +async fn run(cli: Cli) -> anyhow::Result<()> { + let wallet = WalletClient::from_env()?; + deckard_browser_bridge::serve(&cli.bind, wallet, cli.dev_mock_account).await +} diff --git a/crates/deckard-mcp/Cargo.toml b/crates/deckard-mcp/Cargo.toml index 50b60e1..ac56281 100644 --- a/crates/deckard-mcp/Cargo.toml +++ b/crates/deckard-mcp/Cargo.toml @@ -19,6 +19,7 @@ path = "src/main.rs" # The frozen wire contract + the key-less socket client (re-exported from signerd). deckard-contract = { path = "../deckard-contract" } deckard-signerd = { path = "../deckard-signerd", default-features = false } +deckard-wallet-client = { path = "../deckard-wallet-client" } # Only the key-less shield-calldata builder (`shield`); no verified-reads — the sidecar # reads balances THROUGH the daemon, it never owns an RPC/Helios client. deckard-core = { path = "../deckard-core", default-features = false, features = ["shield"] } diff --git a/crates/deckard-mcp/src/lib.rs b/crates/deckard-mcp/src/lib.rs index fd2ad94..1ed243e 100644 --- a/crates/deckard-mcp/src/lib.rs +++ b/crates/deckard-mcp/src/lib.rs @@ -19,8 +19,7 @@ //! - **STOP is always available:** `deckard_revoke_all` / `deckard-mcp stop`. pub mod amount; -pub mod browser_bridge; -pub mod failure; +pub use deckard_wallet_client::failure; pub mod install; pub mod secrets; pub mod server; diff --git a/crates/deckard-mcp/src/main.rs b/crates/deckard-mcp/src/main.rs index a5a2d98..662a23b 100644 --- a/crates/deckard-mcp/src/main.rs +++ b/crates/deckard-mcp/src/main.rs @@ -2,15 +2,7 @@ //! the MCP stdio server (`--mcp`) or the CLI command tree — both thin shells over the same //! key-less [`deckard_mcp::Sidecar`], so nothing is reachable only via Claude. -use std::sync::Arc; - use clap::{Parser, Subcommand}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::{TcpListener, TcpStream}; - -use deckard_mcp::browser_bridge::{ - dev_account_env_name, BridgeBackend, BridgeRequest, BrowserBridge, -}; use deckard_mcp::{install, secrets, server, Sidecar}; #[derive(Parser)] @@ -58,15 +50,6 @@ enum Command { /// STOP — the panic brake: zeroize the key, lock the daemon, deny everything /// in flight. Re-arm by unlocking in the Deckard app. Stop, - /// Experimental localhost EIP-1193 browser bridge for the unpacked extension. - BrowserBridge { - /// Loopback bind address. Keep this on 127.0.0.1 unless you are debugging. - #[arg(long, default_value = "127.0.0.1:8765")] - bind: String, - /// Return this mock address instead of reading an unlocked Deckard daemon. - #[arg(long = "dev-mock-account")] - dev_mock_account: Option, - }, /// Print (or, with --write + confirmation, write) the Claude Desktop registration. Install { /// Emit the demo env block (isolated config dir + socket, Sepolia fork chain id, @@ -119,15 +102,6 @@ async fn run(cli: Cli) -> anyhow::Result<()> { return install::run(demo, write, &mut lock); } - if let Command::BrowserBridge { - bind, - dev_mock_account, - } = &command - { - let sidecar = Sidecar::from_env()?; - return serve_browser_bridge(bind, sidecar, dev_mock_account.clone()).await; - } - let sidecar = Sidecar::from_env()?; let result = match &command { Command::Balance => sidecar.wallet_balance().await, @@ -136,7 +110,6 @@ async fn run(cli: Cli) -> anyhow::Result<()> { Command::Shield { amount_eth } => sidecar.shield(amount_eth).await, Command::Execute { request_id } => sidecar.execute(request_id).await, Command::Stop => sidecar.revoke_all().await, - Command::BrowserBridge { .. } => unreachable!("handled above"), Command::Install { .. } => unreachable!("handled above"), }; match result { @@ -149,151 +122,3 @@ async fn run(cli: Cli) -> anyhow::Result<()> { } } } - -async fn serve_browser_bridge( - bind: &str, - sidecar: Sidecar, - dev_mock_account: Option, -) -> anyhow::Result<()> { - if !bind.starts_with("127.0.0.1:") && !bind.starts_with("localhost:") { - anyhow::bail!("browser bridge must bind to loopback (example: 127.0.0.1:8765)"); - } - let chain_id = sidecar.chain_id(); - let backend = match dev_mock_account { - Some(account) => BridgeBackend::DevMock { account }, - None => BridgeBackend::from_env(sidecar), - }; - let bridge = Arc::new(BrowserBridge::new(chain_id, backend)); - let listener = TcpListener::bind(bind).await?; - eprintln!( - "Deckard browser bridge listening on http://{bind}/rpc (dev mock via {})", - dev_account_env_name() - ); - loop { - let (stream, _) = listener.accept().await?; - let bridge = Arc::clone(&bridge); - tokio::spawn(async move { - if let Err(e) = handle_http_connection(stream, bridge).await { - eprintln!("browser bridge request failed: {e}"); - } - }); - } -} - -async fn handle_http_connection( - mut stream: TcpStream, - bridge: Arc, -) -> anyhow::Result<()> { - let mut buf = vec![0_u8; 64 * 1024]; - let mut read = 0_usize; - let header_end = loop { - let n = stream.read(&mut buf[read..]).await?; - if n == 0 { - return Ok(()); - } - read += n; - if let Some(pos) = find_header_end(&buf[..read]) { - break pos; - } - if read == buf.len() { - return write_http(&mut stream, 413, "text/plain", "request too large").await; - } - }; - - let headers = std::str::from_utf8(&buf[..header_end])?.to_string(); - let (method, path) = request_line(&headers)?; - let origin = header_value(&headers, "x-deckard-origin") - .or_else(|| header_value(&headers, "origin")) - .unwrap_or("unknown-origin") - .to_string(); - - if method == "OPTIONS" { - return write_http(&mut stream, 204, "text/plain", "").await; - } - if method != "POST" || path != "/rpc" { - return write_http(&mut stream, 404, "text/plain", "not found").await; - } - if !host_is_loopback(&headers) { - return write_http(&mut stream, 403, "text/plain", "host must be localhost").await; - } - - let content_length = header_value(&headers, "content-length") - .and_then(|s| s.parse::().ok()) - .ok_or_else(|| anyhow::anyhow!("missing content-length"))?; - if content_length > 32 * 1024 { - return write_http(&mut stream, 413, "text/plain", "body too large").await; - } - - let body_start = header_end + 4; - while read < body_start + content_length { - let n = stream.read(&mut buf[read..]).await?; - if n == 0 { - anyhow::bail!("connection closed before request body completed"); - } - read += n; - } - - let request: BridgeRequest = - serde_json::from_slice(&buf[body_start..body_start + content_length])?; - let response = bridge.handle_request(&origin, request).await; - let response_body = serde_json::to_string(&response)?; - write_http(&mut stream, 200, "application/json", &response_body).await -} - -async fn write_http( - stream: &mut TcpStream, - status: u16, - content_type: &str, - body: &str, -) -> anyhow::Result<()> { - let reason = match status { - 200 => "OK", - 204 => "No Content", - 403 => "Forbidden", - 404 => "Not Found", - 413 => "Payload Too Large", - _ => "Error", - }; - let response = format!( - "HTTP/1.1 {status} {reason}\r\ncontent-type: {content_type}\r\ncontent-length: {}\r\naccess-control-allow-origin: *\r\naccess-control-allow-headers: content-type,x-deckard-origin\r\naccess-control-allow-methods: POST,OPTIONS\r\nconnection: close\r\n\r\n{body}", - body.len() - ); - stream.write_all(response.as_bytes()).await?; - Ok(()) -} - -fn find_header_end(buf: &[u8]) -> Option { - buf.windows(4).position(|window| window == b"\r\n\r\n") -} - -fn request_line(headers: &str) -> anyhow::Result<(&str, &str)> { - let line = headers - .lines() - .next() - .ok_or_else(|| anyhow::anyhow!("missing request line"))?; - let mut parts = line.split_whitespace(); - let method = parts - .next() - .ok_or_else(|| anyhow::anyhow!("missing request method"))?; - let path = parts - .next() - .ok_or_else(|| anyhow::anyhow!("missing request path"))?; - Ok((method, path)) -} - -fn header_value<'a>(headers: &'a str, name: &str) -> Option<&'a str> { - headers.lines().find_map(|line| { - let (key, value) = line.split_once(':')?; - if key.eq_ignore_ascii_case(name) { - Some(value.trim()) - } else { - None - } - }) -} - -fn host_is_loopback(headers: &str) -> bool { - header_value(headers, "host") - .map(|host| host.starts_with("127.0.0.1:") || host.starts_with("localhost:")) - .unwrap_or(false) -} diff --git a/crates/deckard-mcp/src/sidecar.rs b/crates/deckard-mcp/src/sidecar.rs index 9ec84e5..1a104fe 100644 --- a/crates/deckard-mcp/src/sidecar.rs +++ b/crates/deckard-mcp/src/sidecar.rs @@ -1,154 +1,71 @@ -//! The key-less core both surfaces (CLI + MCP tools) share: one [`SignerClient`] to the -//! daemon socket, the expected chain, and the six operations. **No key material ever enters +//! The key-less MCP/agent sidecar: shared wallet-client access to the daemon socket, +//! the expected chain, and the six agent operations. **No key material ever enters //! this process** — writes are `Intent`s proposed to `deckard-signerd`, which enforces //! policy and signs. The one secret this sidecar transiently handles is the Railgun //! *viewing* key (it rides alongside the wallet's own 0zk address in `RailgunViewGrant`); //! it is moved into `Zeroizing` on receipt, never logged, and never put in any response. use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, Ordering}; -use alloy_primitives::{Address, Bytes, B256, U256}; +use alloy_primitives::{B256, U256}; use serde_json::json; use zeroize::Zeroizing; use deckard_contract::{ - deny_reasons, ApprovalMode, Decision, ExecuteResult, Intent, IntentKind, Policy, ReadStatus, - SignerRequest, SignerResponse, + ApprovalMode, Decision, ExecuteResult, Policy, ReadStatus, SignerRequest, SignerResponse, }; -use deckard_signerd::SignerClient; +use deckard_wallet_client::{failure, unexpected, Failure, SignerClient, WalletClient}; use crate::amount::{format_wei_as_eth, parse_eth_to_wei}; -use crate::failure::{self, Failure}; /// A successful tool/CLI outcome, rendered as JSON for the agent and lines for a human. pub type OpResult = Result; /// The shared sidecar state. pub struct Sidecar { - client: SignerClient, - /// The chain this sidecar builds intents for (`DECKARD_CHAIN_ID`, default 1 — matching - /// the daemon's own default; `install --demo` pins 11155111 for both processes). - chain_id: u64, - /// `DECKARD_CONFIG_DIR` when set — used only to sharpen the `locked` error into the - /// no-vault case. Never read for secrets. - config_dir: Option, - /// Set once the connect-time chain probe has conclusively confirmed the daemon signs - /// for [`Self::chain_id`] (so the probe runs at most once per process). - chain_checked: AtomicBool, + wallet: WalletClient, } impl Sidecar { /// Resolve from the environment: socket path (`DECKARD_SOCKET_PATH` or the per-uid /// default), chain id (`DECKARD_CHAIN_ID`, default 1), optional config dir. pub fn from_env() -> anyhow::Result { - let socket_path = match std::env::var_os("DECKARD_SOCKET_PATH") { - Some(p) => PathBuf::from(p), - None => deckard_signerd::socket::default_socket_path(), - }; - let chain_id = match std::env::var("DECKARD_CHAIN_ID") { - Ok(s) => s - .trim() - .parse::() - .map_err(|_| anyhow::anyhow!("DECKARD_CHAIN_ID must be a u64, got {s:?}"))?, - Err(_) => 1, - }; - let config_dir = std::env::var_os("DECKARD_CONFIG_DIR").map(PathBuf::from); Ok(Self { - client: SignerClient::new(socket_path), - chain_id, - config_dir, - chain_checked: AtomicBool::new(false), + wallet: WalletClient::from_env()?, }) } /// Test/builder constructor with explicit wiring. pub fn new(socket_path: PathBuf, chain_id: u64, config_dir: Option) -> Self { Self { - client: SignerClient::new(socket_path), - chain_id, - config_dir, - chain_checked: AtomicBool::new(false), + wallet: WalletClient::new(socket_path, chain_id, config_dir), } } pub fn chain_id(&self) -> u64 { - self.chain_id + self.wallet.chain_id() } fn config_dir(&self) -> Option<&std::path::Path> { - self.config_dir.as_deref() + self.wallet.config_dir() } /// One request → one response, with connect failures mapped to the catalog. async fn request(&self, req: &SignerRequest) -> Result { - self.client - .request(req) - .await - .map_err(|_| failure::socket_missing(self.client.path())) + self.wallet.request(req).await } /// Connect-time chain probe: confirm the daemon signs for our chain BEFORE building /// real intents, so a demo sidecar attached to the mainnet daemon (or vice versa) /// fails with an actionable error instead of a confusing deny later. - /// - /// The probe is a deliberately-undecodable `Send` (non-empty calldata): the daemon's - /// `chain_mismatch` pre-check runs before the policy gate, and the policy gate's - /// `undecodable` deny stores no pending record — so the probe is side-effect-free and - /// can never be executed. A `locked` answer is now CONCLUSIVE for chain identity: the - /// daemon checks `chain_mismatch` before `locked` (the chain check needs no key), so a - /// wrong chain would have returned `chain_mismatch` first — a `locked` reply therefore - /// implies the chain matched. We cache the probe success and let the real call surface - /// its own locked error. async fn ensure_chain(&self) -> Result<(), Failure> { - if self.chain_checked.load(Ordering::Relaxed) { - return Ok(()); - } - let probe = Intent { - chain_id: self.chain_id, - to: Address::ZERO, - token: None, - value: U256::ZERO, - calldata: Bytes::from_static(&[0x00]), // undecodable for Send → never stored - kind: IntentKind::Send, - }; - match self - .request(&SignerRequest::Propose { intent: probe }) - .await? - { - SignerResponse::Decision(Decision::Deny { reason }) - if reason == deny_reasons::CHAIN_MISMATCH => - { - Err(failure::from_deny_reason( - deny_reasons::CHAIN_MISMATCH, - self.config_dir(), - )) - } - SignerResponse::Decision(Decision::Deny { reason }) - if reason == deny_reasons::LOCKED => - { - // Conclusive: the daemon checks chain BEFORE locked, so a `locked` reply - // means the chain matched. Cache the success; the real call surfaces `locked`. - self.chain_checked.store(true, Ordering::Relaxed); - Ok(()) - } - _ => { - self.chain_checked.store(true, Ordering::Relaxed); - Ok(()) - } - } + self.wallet.ensure_chain().await } /// `deckard_wallet_address` / `deckard-mcp address`. pub async fn wallet_address(&self) -> OpResult { - self.ensure_chain().await?; - match self.request(&SignerRequest::Address).await? { - SignerResponse::Address(addr) => Ok(json!({ "address": format!("{addr:#x}") })), - SignerResponse::Decision(Decision::Deny { reason }) => { - Err(failure::from_deny_reason(&reason, self.config_dir())) - } - other => Err(unexpected("Address", &other)), - } + let address = self.wallet.wallet_address().await?; + Ok(json!({ "address": address })) } /// `deckard_wallet_balance` / `deckard-mcp balance`. Public only in v0.1 — the @@ -229,7 +146,7 @@ impl Sidecar { let recipient_0zk = { let grant = match self .request(&SignerRequest::RailgunViewGrant { - chain_id: self.chain_id, + chain_id: self.chain_id(), index: 0, }) .await? @@ -256,7 +173,7 @@ impl Sidecar { "this is a daemon-side bug — check the Deckard app and report it", ) })?; - let intent = deckard_core::build_shield_native_intent(self.chain_id, recipient, wei) + let intent = deckard_core::build_shield_native_intent(self.chain_id(), recipient, wei) .map_err(|e| { Failure::new( "could not build the shield calldata", @@ -308,7 +225,8 @@ impl Sidecar { // A transport error HERE is ambiguous (the broadcast may have happened) — map it // to the do-NOT-retry catalog entry, not the generic socket error. let resp = self - .client + .wallet + .signer_client() .request(&SignerRequest::Execute { request_id }) .await .map_err(|_| failure::execute_transport_unknown())?; @@ -371,15 +289,3 @@ fn policy_json(p: &Policy) -> serde_json::Value { "note": "read-only here — a human edits policy.json in the Deckard config dir", }) } - -/// A wire response that doesn't match the request shape — a daemon/sidecar version skew. -fn unexpected(what: &str, _resp: &SignerResponse) -> Failure { - // Deliberately does NOT echo the response payload: an unexpected frame is exactly the - // case where we can't vouch for its contents being transcript-safe. - Failure::new( - format!("the daemon returned an unexpected response to {what}"), - "the daemon and this sidecar disagree on the wire contract (version skew)", - "rebuild both from the same checkout (`cargo build -p deckard-signerd -p \ - deckard-mcp`) and restart the app", - ) -} diff --git a/crates/deckard-wallet-client/Cargo.toml b/crates/deckard-wallet-client/Cargo.toml new file mode 100644 index 0000000..d3f67dd --- /dev/null +++ b/crates/deckard-wallet-client/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "deckard-wallet-client" +version = "0.0.1-alpha" +edition = "2021" +license = "AGPL-3.0-or-later" +description = "Shared key-less wallet client primitives for local Deckard interfaces." + +[lib] +name = "deckard_wallet_client" +path = "src/lib.rs" + +[dependencies] +deckard-contract = { path = "../deckard-contract" } +deckard-signerd = { path = "../deckard-signerd", default-features = false } +deckard-core = { path = "../deckard-core", default-features = false } +alloy-primitives = { workspace = true } +serde = { workspace = true } +serde_json = "1" +anyhow = "1" + +[lints] +workspace = true diff --git a/crates/deckard-mcp/src/failure.rs b/crates/deckard-wallet-client/src/failure.rs similarity index 99% rename from crates/deckard-mcp/src/failure.rs rename to crates/deckard-wallet-client/src/failure.rs index 2d7930d..fcd2c2e 100644 --- a/crates/deckard-mcp/src/failure.rs +++ b/crates/deckard-wallet-client/src/failure.rs @@ -1,5 +1,5 @@ -//! The typed error catalog: every failure an agent (or a CLI user) can hit maps to a -//! three-part `problem + cause + fix` — actionable, deterministic, and secret-free. An LLM's +//! The typed error catalog shared by key-less Deckard local interfaces: every failure maps to a +//! three-part `problem + cause + fix` — actionable, deterministic, and secret-free. An agent's //! default instinct on error is to retry; these messages say explicitly when retrying is //! wrong (broadcast-timeout, already_executed) and what to do instead. diff --git a/crates/deckard-wallet-client/src/lib.rs b/crates/deckard-wallet-client/src/lib.rs new file mode 100644 index 0000000..400f392 --- /dev/null +++ b/crates/deckard-wallet-client/src/lib.rs @@ -0,0 +1,137 @@ +//! Shared key-less wallet client/session primitives for local Deckard interfaces. +//! +//! This crate owns the signer-daemon client access, chain-id configuration, and common +//! failure mapping used by sibling surfaces such as `deckard-mcp` (agent/MCP) and +//! `deckard-browser-bridge` (dapp/browser). It never holds signing keys. + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; + +use alloy_primitives::{Address, Bytes, U256}; +use deckard_contract::{deny_reasons, Decision, Intent, IntentKind, SignerRequest, SignerResponse}; +pub use deckard_signerd::SignerClient; + +pub mod failure; +pub use failure::Failure; + +/// Shared signer-daemon client state for key-less local Deckard interfaces. +pub struct WalletClient { + client: SignerClient, + /// The chain this client expects (`DECKARD_CHAIN_ID`, default 1 — matching the daemon's default). + chain_id: u64, + /// `DECKARD_CONFIG_DIR` when set — used only to sharpen the `locked` error into the no-vault case. + config_dir: Option, + /// Set once the connect-time chain probe has conclusively confirmed the daemon signs for `chain_id`. + chain_checked: AtomicBool, +} + +impl WalletClient { + /// Resolve from the environment: socket path (`DECKARD_SOCKET_PATH` or the per-uid default), + /// chain id (`DECKARD_CHAIN_ID`, default 1), optional config dir. + pub fn from_env() -> anyhow::Result { + let socket_path = match std::env::var_os("DECKARD_SOCKET_PATH") { + Some(p) => PathBuf::from(p), + None => deckard_signerd::socket::default_socket_path(), + }; + let chain_id = match std::env::var("DECKARD_CHAIN_ID") { + Ok(s) => s + .trim() + .parse::() + .map_err(|_| anyhow::anyhow!("DECKARD_CHAIN_ID must be a u64, got {s:?}"))?, + Err(_) => 1, + }; + let config_dir = std::env::var_os("DECKARD_CONFIG_DIR").map(PathBuf::from); + Ok(Self::new(socket_path, chain_id, config_dir)) + } + + /// Test/builder constructor with explicit wiring. + pub fn new(socket_path: PathBuf, chain_id: u64, config_dir: Option) -> Self { + Self { + client: SignerClient::new(socket_path), + chain_id, + config_dir, + chain_checked: AtomicBool::new(false), + } + } + + pub fn chain_id(&self) -> u64 { + self.chain_id + } + + pub fn config_dir(&self) -> Option<&Path> { + self.config_dir.as_deref() + } + + pub fn signer_client(&self) -> &SignerClient { + &self.client + } + + /// One request → one response, with connect failures mapped to the shared catalog. + pub async fn request(&self, req: &SignerRequest) -> Result { + self.client + .request(req) + .await + .map_err(|_| failure::socket_missing(self.client.path())) + } + + /// Connect-time chain probe: confirm the daemon signs for our chain before building real intents. + pub async fn ensure_chain(&self) -> Result<(), Failure> { + if self.chain_checked.load(Ordering::Relaxed) { + return Ok(()); + } + let probe = Intent { + chain_id: self.chain_id, + to: Address::ZERO, + token: None, + value: U256::ZERO, + calldata: Bytes::from_static(&[0x00]), + kind: IntentKind::Send, + }; + match self + .request(&SignerRequest::Propose { intent: probe }) + .await? + { + SignerResponse::Decision(Decision::Deny { reason }) + if reason == deny_reasons::CHAIN_MISMATCH => + { + Err(failure::from_deny_reason( + deny_reasons::CHAIN_MISMATCH, + self.config_dir(), + )) + } + SignerResponse::Decision(Decision::Deny { reason }) + if reason == deny_reasons::LOCKED => + { + self.chain_checked.store(true, Ordering::Relaxed); + Ok(()) + } + _ => { + self.chain_checked.store(true, Ordering::Relaxed); + Ok(()) + } + } + } + + /// Read the wallet's public address through the signer daemon. + pub async fn wallet_address(&self) -> Result { + self.ensure_chain().await?; + match self.request(&SignerRequest::Address).await? { + SignerResponse::Address(addr) => Ok(format!("{addr:#x}")), + SignerResponse::Decision(Decision::Deny { reason }) => { + Err(failure::from_deny_reason(&reason, self.config_dir())) + } + other => Err(unexpected("Address", &other)), + } + } +} + +/// A wire response that doesn't match the request shape — a daemon/client version skew. +pub fn unexpected(what: &str, _resp: &SignerResponse) -> Failure { + // Deliberately does NOT echo the response payload: an unexpected frame is exactly the + // case where we can't vouch for its contents being transcript-safe. + Failure::new( + format!("the daemon returned an unexpected response to {what}"), + "the daemon and this client disagree on the wire contract (version skew)", + "rebuild local Deckard binaries from the same checkout and restart the app", + ) +} diff --git a/docs/browser-bridge.md b/docs/browser-bridge.md index 1a98e02..bb2218c 100644 --- a/docs/browser-bridge.md +++ b/docs/browser-bridge.md @@ -9,7 +9,8 @@ local test dapp -> injected `window.ethereum` EIP-1193 provider -> unpacked browser extension -> localhost Deckard bridge endpoint on 127.0.0.1 - -> Deckard sidecar/session handling + -> shared key-less wallet client/session primitives + -> deckard-signerd -> selected account/address returned to the dapp ``` @@ -17,17 +18,49 @@ It does **not** ship a production wallet extension. The extension contains no ke signing logic, or durable wallet state. It only forwards a tiny allowlist of methods to a local Deckard process. +## Architecture + +The browser bridge is a dapp/browser interface. It is intentionally separate from `deckard-mcp`, +which is an MCP/agent interface. Both are key-less local clients over shared wallet capabilities: + +```text +deckard-signerd + ↑ +crates/deckard-wallet-client + ↑ ↑ +crates/deckard-mcp crates/deckard-browser-bridge + ↑ + extension/ +``` + +`deckard-wallet-client` owns reusable non-browser-specific pieces: + +- signer-daemon client access (`SignerClient` wiring) +- chain id configuration (`DECKARD_CHAIN_ID`, default `1`) +- wallet/account address lookup +- common failure mapping for daemon denies and socket errors + +`deckard-browser-bridge` owns browser/dapp-specific pieces: + +- the loopback `/rpc` HTTP endpoint +- EIP-1193 request/response types +- EIP-1193 error mapping for unsupported methods +- per-origin in-memory dapp sessions +- dev/mock account mode for local extension and dapp testing + +`deckard-mcp` stays focused on MCP/agent interaction and reuses the same wallet client primitives for +its CLI/tools. It does not own or serve the browser bridge. + ## Repo map - Desktop app / UI: `crates/deckard-app` (`deckard` GPUI binary). -- Existing local daemon / signer API: `crates/deckard-signerd`, over a same-uid Unix-domain socket. -- Key-less sidecar / API surface: `crates/deckard-mcp`; this milestone adds the experimental - `deckard-mcp browser-bridge` localhost endpoint here instead of creating a competing daemon. -- Existing account/address state: the unlocked signer daemon answers `SignerRequest::Address`, surfaced - by `Sidecar::wallet_address()`. +- Local signer daemon / signer API: `crates/deckard-signerd`, over a same-uid Unix-domain socket. +- Shared key-less wallet client primitives: `crates/deckard-wallet-client`. +- Agent/MCP interface: `crates/deckard-mcp`. +- Browser/dapp interface: `crates/deckard-browser-bridge` (`deckard-browser-bridge` binary). - Browser connector scaffold: `extension/`. - Local test dapp: `examples/browser-bridge-dapp/index.html`. -- Tests: `crates/deckard-mcp/src/browser_bridge.rs`. +- Browser bridge tests: `crates/deckard-browser-bridge/src/lib.rs`. ## Supported methods @@ -61,7 +94,7 @@ reviewed permissions registry with explicit approval UI, anti-phishing copy, rev This is the smallest way to test the browser bridge without an unlocked wallet: ```sh -cargo run -p deckard-mcp -- browser-bridge \ +cargo run -p deckard-browser-bridge -- \ --bind 127.0.0.1:8765 \ --dev-mock-account 0xdeC0ded0000000000000000000000000000001193 ``` @@ -70,7 +103,7 @@ Alternatively, the same dev mock can be supplied through the environment: ```sh export DECKARD_BRIDGE_DEV_ACCOUNT=0xdeC0ded0000000000000000000000000000001193 -cargo run -p deckard-mcp -- browser-bridge --bind 127.0.0.1:8765 +cargo run -p deckard-browser-bridge -- --bind 127.0.0.1:8765 ``` ## Run against local Deckard @@ -86,11 +119,11 @@ In another terminal, run the bridge on loopback: ```sh export DECKARD_CHAIN_ID=11155111 -cargo run -p deckard-mcp -- browser-bridge --bind 127.0.0.1:8765 +cargo run -p deckard-browser-bridge -- --bind 127.0.0.1:8765 ``` The bridge uses the existing Deckard socket path (`DECKARD_SOCKET_PATH` or the default) and calls the -same key-less sidecar path as `deckard-mcp address`. +same shared key-less wallet client path that `deckard-mcp address` uses. ## Load the unpacked extension @@ -134,4 +167,5 @@ Open in the browser where the unpacked extension is loa - Add a real approval UI for `eth_requestAccounts` and per-origin revocation. - Add EIP-6963 provider announcement. - Persist permissions safely. +- Consider a small integration test for the loopback `/rpc` HTTP boundary. - Add clear-signing/message-signing only after Deckard has the reviewed intent model for it. From 364bd62c547fe52e3c997d65970cdae106d0c160 Mon Sep 17 00:00:00 2001 From: 0xpantera <0xpantera@proton.me> Date: Mon, 15 Jun 2026 12:31:11 +0200 Subject: [PATCH 3/3] ci: allow new first-party crate licenses --- deny.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/deny.toml b/deny.toml index 6cec04c..77214fb 100644 --- a/deny.toml +++ b/deny.toml @@ -91,6 +91,8 @@ exceptions = [ { allow = ["AGPL-3.0-or-later"], crate = "deckard-mcp" }, { allow = ["AGPL-3.0-or-later"], crate = "deckard-contract" }, { allow = ["AGPL-3.0-or-later"], crate = "deckard-signerd" }, + { allow = ["AGPL-3.0-or-later"], crate = "deckard-wallet-client" }, + { allow = ["AGPL-3.0-or-later"], crate = "deckard-browser-bridge" }, { allow = ["GPL-3.0-or-later"], crate = "zlog" }, { allow = ["GPL-3.0-or-later"], crate = "ztracing" }, { allow = ["GPL-3.0-or-later"], crate = "ztracing_macro" },