You deploy OpenClaw (an AI gateway) inside a Vercel Sandbox and connect it to Telegram. Users chat with the bot. Everything works great — until someone says:
"Text me a silly joke every 5 minutes"
The bot tries to create a scheduled task (a cron job). It fails:
Cron: 'Silly Joke' failed: gateway closed (1008): pairing required
The bot apologizes and says the cron system isn't available because "the gateway isn't connected in this sandbox environment."
What the user wanted: A recurring scheduled message. What they got: A cryptic pairing error.
OpenClaw has a security system called "device pairing" — think of it like Bluetooth pairing. Before a device (like a CLI tool or the AI agent) can talk to the gateway, it needs to be "paired" — approved and registered.
On a normal computer, this happens automatically: the gateway sees the connection is coming from localhost (the same machine) and auto-approves it silently. No human intervention needed.
Inside a Vercel Sandbox (a lightweight virtual machine), this breaks. The auto-approval mechanism has a subtle bug that causes it to fail silently, and the gateway permanently rejects the AI agent's attempts to create cron jobs.
The fix is to "pre-pair" the device during setup — like pre-approving a Bluetooth device before you even turn it on.
Browser → Vercel Proxy → Sandbox MicroVM (port 3000)
└── OpenClaw Gateway
├── /v1/chat/completions (HTTP API) ✅ Works
└── WebSocket Gateway Protocol (RPC) ❌ Fails
The HTTP chat completions endpoint uses simple token auth. The WebSocket gateway protocol requires device pairing on top of token auth.
- User sends message via Telegram
- Message queued in Redis, drained to
/v1/chat/completionson port 3000 - AI agent processes the request, decides to create a cron job
- Agent calls
callGatewayTool("cron.add", ...)(src/agents/tools/cron-tool.ts) callGatewayTool→callGateway()→new GatewayClient(...)(src/gateway/call.ts)GatewayClientalways callsloadOrCreateDeviceIdentity()— there's no option to skip this- Client opens WebSocket to
ws://127.0.0.1:3000with device identity - Gateway message handler processes the connect frame (src/gateway/server/ws-connection/message-handler.ts)
Connection arrives with device identity
│
├─ Is client "openclaw-control-ui"? → allowInsecureAuth bypass → SKIP pairing ✅
│
└─ Is client "gateway-client"? (callGatewayTool uses this)
│
├─ skipPairing = allowControlUiBypass && sharedAuthOk
│ └─ allowControlUiBypass = false (not control UI) → skipPairing = false
│
└─ Pairing check runs:
│
├─ getPairedDevice(device.id) → Is device already paired?
│ ├─ YES + publicKey matches → Skip pairing ✅
│ └─ NO → requestDevicePairing({ silent: isLocalClient })
│
└─ requestDevicePairing():
│
├─ Existing pending request for this deviceId?
│ └─ YES → Return EXISTING request (BUG: silent flag NOT updated)
│
└─ No existing → Create new request with silent: isLocalClient
│
├─ silent: true → Auto-approve, connection continues ✅
└─ silent: false → Close with 1008 "pairing required" ❌
In src/infra/device-pairing.ts, requestDevicePairing() has this code:
const existing = Object.values(state.pendingById).find((p) => p.deviceId === deviceId);
if (existing) {
return { status: "pending", request: existing, created: false };
}If the first-ever pairing attempt for a deviceId was non-local (silent: false), it creates a pending request with silent: false. Every subsequent attempt — even local ones with silent: true — returns that stale request without updating the silent flag. The pending request has a 5-minute TTL, but in practice the gateway keeps retrying and the request never expires.
Even with --bind loopback, isLocalDirectRequest() may return false inside a MicroVM due to:
- Internal proxy headers injected by the VM infrastructure
- Non-standard
req.socket.remoteAddressvalues - IPv6 edge cases in the VM's network stack
The isLocalDirectRequest() check requires ALL of:
isLoopbackAddress(clientIp)= true- Host header resolves to
localhost,127.0.0.1, or::1 - No proxy headers (or from trusted proxy)
If any of these fail, isLocalClient = false → silent = false → triggers Root Cause #1.
| Attempt | Why It Didn't Work |
|---|---|
--bind loopback |
Correct for self-connection URL, but doesn't fix isLocalClient in VM |
allowInsecureAuth: true |
Only applies to Control UI clients, not gateway-client |
dangerouslyDisableDeviceAuth: true |
Only nullifies device for Control UI, not other clients |
mkdir -p devices/ |
writeJsonAtomic() already creates dirs; not the issue |
Clearing paired.json on startup |
Was already happening; doesn't help if auto-approval never runs |
The gateway's pairing check (line 674-675) does:
const paired = await getPairedDevice(device.id);
const isPaired = paired?.publicKey === devicePublicKey;If the device is already in paired.json with a matching public key and sufficient scopes, the entire pairing flow is skipped. The connection proceeds immediately.
Run a Node.js script before the gateway starts that:
- Ensures the device identity exists (
~/.openclaw/identity/device.json) - Reads the device's public key
- Writes a pre-approved entry to
~/.openclaw/devices/paired.json - Clears
~/.openclaw/devices/pending.jsonto prevent the sticky-request bug
The script generates the same Ed25519 key pair that OpenClaw uses, derives the deviceId from the public key hash (SHA-256), and writes it with operator role and the three scopes that callGatewayTool requests: operator.admin, operator.approvals, operator.pairing.
Add loopback addresses to gateway.trustedProxies in the config:
{
"gateway": {
"trustedProxies": ["10.0.0.0/8", "127.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "::1"]
}
}This ensures isLocalDirectRequest() correctly handles any proxy headers the VM might inject.
| Layer | Issue | Fix |
|---|---|---|
| OpenClaw bug | requestDevicePairing() reuses stale pending requests without updating silent flag |
Clear pending.json at boot |
| VM networking | isLocalDirectRequest() may return false in MicroVM |
Add loopback to trustedProxies |
| Architecture | callGatewayTool() always sends device identity, no bypass option |
Pre-pair the device identity on disk |
| Config | allowInsecureAuth only covers Control UI, not gateway-client |
Pre-pairing makes this irrelevant |
The "do this now" plan: Write a pre-pairing script into the sandbox setup that creates the device identity and writes paired.json before the gateway starts. This makes the entire pairing flow irrelevant — the device is already approved.