Skip to content

feat: browser-proxy wallet signing relay - #2

Merged
0xdewy merged 10 commits into
mainfrom
feat/tx-signing-relay
Aug 6, 2026
Merged

feat: browser-proxy wallet signing relay#2
0xdewy merged 10 commits into
mainfrom
feat/tx-signing-relay

Conversation

@0xdewy

@0xdewy 0xdewy commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Summary

Enables wallet transaction and message signing from inside workspace VMs. Private keys never enter the guest — signing is relayed to the user's browser wallet.

How it works

guest process
  → bloom-workspace sign CLI
    → guest-control socket (signing.request / signing.status)
      → agent HTTP (/v1/workspaces/:id/signing/pending|resolve)
        → control plane HTTP (/api/workspaces/:id/signing/pending|resolve)
          → browser 3s poll → wallet provider request

The user runs bloom-workspace sign personal_sign '["0x..."]' inside the workspace. The request flows through the existing guest→agent→control chain to the browser, which forwards it to the connected wallet (injected or WalletConnect). The wallet prompts the user for approval. The result flows back through the same chain.

Design decisions

  • HTTP polling (3s) instead of WebSocket push: signing requests are rare human-in-the-loop events. A 3-second poll is negligible latency for wallet approval (which takes 10-30+ seconds of human time), and avoids duplicating the terminal WebSocket infrastructure.
  • In-memory queue in guest controller: pending requests live in the Python process. No persistence needed — if the VM restarts, pending signing requests are correctly lost.
  • TTL expiry: requests expire after 5 minutes if unresolved.
  • Allowlisted methods: personal_sign, eth_sendTransaction, eth_signTypedData_v4 only.
  • Private keys stay in browser: the bootstrap reject_signer_inputs guard remains unchanged. Keys never enter the VM.

Changes (13 files, +253/-16)

Layer File Change
Guest bloom-guest-control.py 3 new operations, signing queue, capability flags → true
Guest bloom-workspace CLI sign subcommand with polling + timeout
Protocol guest-protocol.ts SigningRequest / SigningResolve envelope schemas
Protocol results.ts z.literal(false)z.boolean()
Agent server.ts 3 HTTP endpoints + randomUUID import
Agent agent-client.ts signingPending / signingResolve / signingRequest
Control workspaces.ts signingPending / signingResolve service methods
Control server.ts 2 authenticated HTTP routes
Control auth.ts SIWE statement updated
Browser main.ts 3s poll, wallet dispatch, WalletConnect methods expanded
Bootstrap guest-bootstrap.sh Comment updated
Docs README.md Documents signing relay

Verification

  • npm run typecheck
  • npm test ✅ (140/141; 1 pre-existing uid/gid env failure unrelated to signing)
  • npm run build
  • python3 -c "import py_compile; ..." ✅ (both Python files)

0xdewy added 10 commits August 5, 2026 12:03
…used checks

- package-lock.json had a corrupted empty version string for the axios
  override that broke `npm ci` (Invalid Version error). Regenerated from
  package.json — now resolves cleanly with 0 vulnerabilities.
- Removed unused `IncomingMessage` import from src/agent/server.ts.
- Enabled `noUnusedLocals` and `noUnusedParameters` in tsconfig.server.json
  to prevent future dead imports from being committed. The codebase already
  passes with these flags after the import fix.
Enable wallet transaction and message signing from inside workspace VMs
without private keys ever entering the guest. The relay chain is:

  guest process → bloom-workspace CLI → guest-control Unix socket
    → agent HTTP (signing.request/signing.status/signing.resolve)
    → control plane HTTP (/api/workspaces/:id/signing/pending|resolve)
    → browser 3s poll → wallet provider (personal_sign, eth_sendTransaction,
      eth_signTypedData_v4)

Changes across 13 files:
- Python guest controller: signing.request/status/resolve operations,
  in-memory pending queue with TTL expiry, capability flags now true
- Guest CLI (bloom-workspace): 'sign' subcommand with polling + timeout
- TypeScript protocol: SigningRequest/SigningResolve envelope types in
  guest-protocol.ts, z.boolean() for walletSigning/transactions in results
- Agent server: 3 new HTTP endpoints, randomUUID import
- Agent client: signingPending/signingResolve/signingRequest methods
- Control plane: WorkspaceService.signingPending/signingResolve,
  2 new authenticated HTTP routes
- Browser: 3s signing poll, resolveSigningRequest dispatches to wallet,
  starts on workspace running, cleaned up on close
- SIWE statement updated to disclose signing relay
- WalletConnect methods expanded to include transaction methods
- Bootstrap comment updated to document relay-based signing
- Browser now shows a preview dialog before forwarding signing requests
  to the wallet. Users see method, from, to, value, and data before
  approving. This prevents blind signing of malicious transactions.
- formatSigningRequest() renders human-readable summaries for
  personal_sign, eth_sendTransaction, and eth_signTypedData_v4.
- seen-set pruned to prevent unbounded memory growth during long sessions.
- 5 integration tests covering: full request→pending→resolve lifecycle,
  method/param validation, double-resolve prevention, error rejection,
  and unknown request ID handling.
- Signing dialog HTML + CSS.
BREAKING CHANGE: replaces raw EIP-1193 passthrough with Bloom outbox bridge

The previous implementation forwarded raw personal_sign / eth_sendTransaction
calls from the guest to the browser wallet, bypassing all of Bloom's safety
infrastructure. This rewrite goes through Bloom's native pipeline:

  guest process writes intent to /bloom/wallets/<w>/chains/<c>/outbox/new.tx
    → bloom serve stages it, simulates it, generates plan.md
      → guest controller reads pending entries via IPC
        → agent HTTP → control plane → browser
          → browser shows Bloom's plan.md
            → user approves → confirm written back via IPC

This reuses Bloom's:
- Intent parser (shell/TOML/JSON → typed RawIntent)
- TxEngine (stage → simulate → confirm → broadcast)
- Policy engine (spending limits, allowed contracts)
- MEV/sandwich detection
- Nonce management and conflict resolution
- Outbox audit trail (intent.json, plan.md, policy_check.json)

Changes (13 files, +173/-310):
- Guest controller: replaced signing.request/status/pending/resolve with
  outbox.pending/confirm/cancel using bloom IPC (JSON-RPC over Unix socket)
- Guest CLI: removed 'sign' subcommand (use bloom CLI's native outbox)
- Agent server: replaced signing endpoints with outbox endpoints
- Control plane: replaced signing routes with outbox routes
- Browser: shows Bloom's plan.md instead of raw hex
- Protocol: outbox.confirm/cancel schemas use txId to avoid envelope id clash
- Tests: removed signing tests (outbox requires running bloom serve daemon)
- Updated SIWE statement, WalletConnect methods, bootstrap comment, README
The 128 MiB default was hardcoded in three places (db.ts schema,
db.ts migration, workspaces.ts constant) with no env var or config
path. This made the quota impossible to tune without code changes.

- Add BLOOM_STORAGE_QUOTA_MIB env var (16–5120 MiB, default 512)
- Wire config.storageQuotaBytes through WorkspaceService
- Remove hardcoded DEFAULT_STORAGE_QUOTA_BYTES constant
- Update DB schema and migration defaults to 512 MiB (536870912)
- Update Python guest controller default to match
- Remove hardcoded '128 MiB' text from web UI (actual quota is
  already shown dynamically via formatBytes)
- Add config test for the new option
- Update workspace-data-api test expectation
- Add __pycache__/ to .gitignore
…irectory walk

Security: outbox confirm/cancel endpoints accepted arbitrary strings for
txId, chain, and wallet fields — only length-validated. These values are
interpolated into Bloom VFS paths (e.g. /wallets/{wallet}/chains/{chain}/
outbox/pending/{txId}/confirm). A crafted value like '../../' could
traverse the VFS tree. Now enforced at all four layers: guest protocol
Zod schema, control server, agent server, and Python guest controller.

Performance: file write handler walked the entire workspace directory tree
twice per write — once before writing (quota check) and once after (usage
report). The post-write walk was redundant since we already know the delta
from the pre-write walk + file stat. Replaced with arithmetic.

Tests: added path traversal rejection tests to guest-protocol.test.ts.
…endpoint

Reliability: ensureExt4Volume now checks available disk space via statfs
before allocating a workspace volume. Returns HTTP 507 if insufficient.

Observability: every HTTP request to both control and agent servers is now
logged as structured JSON (timestamp, level, component, requestId, method,
url, statusCode, durationMs). Request IDs are generated per-request and
exposed via x-request-id response header for client-side correlation.
Health-check 200s are suppressed to avoid log noise.

Metrics: GET /metricsz returns workspace counts by state (running, pending,
stopped, failed, total), process uptime, and memory usage. Intended for
operator dashboards and monitoring tools.
…TP endpoints

security.test.ts (31 tests): safeEqual timing-safe comparison, cookie parsing
edge cases, clientIp trusted-proxy chain extraction, origin validation, token
hashing determinism.

data-volume.test.ts (6 tests): volume ID validation (path traversal rejection),
quota bounds enforcement, disk space pre-flight check, idempotent creation.

http-security.test.ts (7 tests): path traversal payloads rejected at HTTP layer
for outbox confirm/cancel (id, chain, wallet fields), unauthenticated requests
rejected, cross-wallet access denied, metricsz and healthz endpoints validated.
Previously, guest-bootstrap.sh hardcoded preinstalled = [] to prevent
network-fetched code from entering guest VMs. This also blocked
operator-curated Petals — a core workflow for Bloom workspaces.

Changes:
- Add BLOOM_PREINSTALLED_PETALS env var (comma-separated, max 32,
  alphanumeric/dash/underscore, validated by Zod)
- guest-bootstrap.sh now writes the operator-approved list into
  config.toml instead of hardcoding []
- Re-bootstrap validates configured Petals against the approved list
- Petal list passed through kernel cmdline (bloom_petals=) for
  QEMU and Firecracker runtimes, env var for process runtime
- bloom-init parses bloom_petals from /proc/cmdline and exports
  BLOOM_PREINSTALLED_PETALS before bootstrap

Users can still bloom install explicitly from the terminal (GitHub
is in the egress allowlist). The operator-approved list controls
what gets auto-installed during bootstrap.
Replace the 1,157-line Python guest-control daemon and shell wrapper with a
modular Rust crate — a single binary serving as both the guest-side control
server (AF_VSOCK, Unix socket, stdio) and the guest-local CLI client.

Rebrand the outbox transaction relay as Bloom's Sealed Approval ceremony:
- Remove outbox.confirm/outbox.cancel endpoints and protocol operations
- Add read-only ceremony.pending that returns a ceremony URL
- Reduce WalletConnect scope to personal_sign only (drop
  eth_sendTransaction and eth_signTypedData_v4)
- Update SIWE auth statement to reference the ceremony flow

Web UI: ceremony polling replaces outbox polling, lease countdown gains
warning/critical visual states and an expiry message.

Build: demo image and guest-bootstrap updated for the Rust binary.
Excludes debug-env.test.ts (scratch file).
@0xdewy
0xdewy merged commit 13727b8 into main Aug 6, 2026
1 check failed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant